| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804780578067807780878097810781178127813781478157816781778187819782078217822782378247825782678277828782978307831783278337834783578367837783878397840784178427843784478457846784778487849785078517852785378547855785678577858785978607861786278637864786578667867786878697870787178727873787478757876787778787879788078817882788378847885788678877888788978907891789278937894789578967897789878997900790179027903790479057906790779087909791079117912791379147915791679177918791979207921792279237924792579267927792879297930793179327933793479357936793779387939794079417942794379447945794679477948794979507951795279537954795579567957795879597960796179627963796479657966796779687969797079717972797379747975797679777978797979807981798279837984798579867987798879897990799179927993799479957996799779987999800080018002800380048005800680078008800980108011801280138014801580168017801880198020802180228023802480258026802780288029803080318032803380348035803680378038803980408041804280438044804580468047804880498050805180528053805480558056805780588059806080618062806380648065806680678068806980708071807280738074807580768077807880798080808180828083808480858086808780888089809080918092809380948095809680978098809981008101810281038104810581068107810881098110811181128113811481158116811781188119812081218122812381248125812681278128812981308131813281338134813581368137813881398140814181428143814481458146814781488149815081518152815381548155815681578158815981608161816281638164816581668167816881698170817181728173817481758176817781788179818081818182818381848185818681878188818981908191819281938194819581968197819881998200820182028203820482058206820782088209821082118212821382148215821682178218821982208221822282238224822582268227822882298230823182328233823482358236823782388239824082418242824382448245824682478248824982508251825282538254825582568257825882598260826182628263826482658266826782688269827082718272827382748275827682778278827982808281828282838284828582868287828882898290829182928293829482958296829782988299830083018302830383048305830683078308830983108311831283138314831583168317831883198320832183228323832483258326832783288329833083318332833383348335833683378338833983408341834283438344834583468347834883498350835183528353835483558356835783588359836083618362836383648365836683678368836983708371837283738374837583768377837883798380838183828383838483858386838783888389839083918392839383948395839683978398839984008401840284038404840584068407840884098410841184128413841484158416841784188419842084218422842384248425842684278428842984308431843284338434843584368437843884398440844184428443844484458446844784488449845084518452845384548455845684578458845984608461846284638464846584668467846884698470847184728473847484758476847784788479848084818482848384848485848684878488848984908491849284938494849584968497 |
- //
- // ViewController.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import Combine
- import CryptoKit
- import StoreKit
- import SwiftUI
- import WebKit
- /// Default main window **content** size; OAuth sign-in uses the same dimensions and aligns to this content area.
- private enum AppWindowMetrics {
- static let defaultContentWidth: CGFloat = 1020
- static let defaultContentHeight: CGFloat = 690
- }
- /// OAuth entry points stay in the system browser so enterprise SSO extensions and password managers behave as users expect.
- private enum InAppWebBrowserPolicy {
- static func shouldOpenInSystemBrowser(_ url: URL) -> Bool {
- guard let host = url.host?.lowercased() else { return false }
- let path = url.path.lowercased()
- let zoomHost = host == "zoom.us" || host.hasSuffix(".zoom.us") || host == "zoom.com" || host.hasSuffix(".zoom.com")
- if zoomHost, path.contains("/oauth/authorize") { return true }
- if host == "accounts.google.com" {
- return path.contains("/o/oauth2/") || path.contains("/signin/oauth")
- }
- return false
- }
- }
- /// Errors from the main-window embedded OAuth web session (right panel).
- private enum EmbeddedOAuthFlowError: Error {
- case webViewUnavailable
- case userCancelled
- case timedOut
- }
- @MainActor
- protocol EmbeddedOAuthPresenting: AnyObject {
- func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL
- }
- class ViewController: NSViewController {
- enum HomeSection: String {
- case home = "Home"
- case meetings = "Meetings"
- case scheduler = "Scheduler"
- case widgets = "Widgets"
- case settings = "Settings"
- }
- private let googleOAuth = GoogleOAuthService.shared
- private let zoomOAuth = ZoomOAuthService.shared
- private let loginStateKey = "zoom_app.isLoggedIn"
- private let darkModeDefaultsKey = "settings.darkModeEnabled"
- private let ratingPromptShownKey = "zoom_app.ratingPromptShown"
- private let ratingCompletedKey = "zoom_app.ratingCompleted"
- private let usageSessionStartedAtKey = "zoom_app.usageSessionStartedAt"
- private let meetingTimeZoneOverrideKey = "zoom_app.meetingTimeZoneOverrideByMeetingID"
- private struct Palette {
- let isDarkMode: Bool
- var appBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 10 / 255, green: 11 / 255, blue: 12 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.96, alpha: 1)
- }
- var sidebarBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 16 / 255, green: 17 / 255, blue: 19 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.94, alpha: 1)
- }
- var sidebarActiveBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.86, alpha: 1)
- }
- var cardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 20 / 255, green: 21 / 255, blue: 24 / 255, alpha: 1)
- : NSColor.white
- }
- var secondaryCardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- // Slightly darker than pure white so cards are visible on Light background.
- : NSColor(calibratedWhite: 0.93, alpha: 1)
- }
- var appShellBackground: NSColor { appBackground }
- var contentShellBackground: NSColor { appBackground }
- var topStripBackground: NSColor { chromeUnifiedBackground }
- var chromeUnifiedBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 22 / 255, green: 23 / 255, blue: 26 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.92, alpha: 1)
- }
- var searchPillBackground: NSColor {
- isDarkMode
- ? NSColor.white.withAlphaComponent(0.06)
- : NSColor.black.withAlphaComponent(0.06)
- }
- var meetingCardBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 30 / 255, green: 34 / 255, blue: 42 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.98, alpha: 1)
- }
- var sectionCard: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 18 / 255, green: 19 / 255, blue: 22 / 255, alpha: 1)
- : NSColor.white
- }
- var inputBackground: NSColor {
- isDarkMode
- ? NSColor(calibratedRed: 24 / 255, green: 25 / 255, blue: 29 / 255, alpha: 1)
- : NSColor(calibratedWhite: 0.97, alpha: 1)
- }
- var inputBorder: NSColor {
- isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.10)
- }
- var primaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.98, alpha: 1) : NSColor(calibratedWhite: 0.10, alpha: 1) }
- var secondaryText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.78, alpha: 1) : NSColor(calibratedWhite: 0.30, alpha: 1) }
- var mutedText: NSColor { isDarkMode ? NSColor(calibratedWhite: 0.66, alpha: 1) : NSColor(calibratedWhite: 0.42, alpha: 1) }
- var sidebarInactiveText: NSColor {
- isDarkMode ? secondaryText : NSColor(calibratedWhite: 0.12, alpha: 1)
- }
- }
- private final class TopAlignedClipView: NSClipView {
- override func constrainBoundsRect(_ proposedBounds: NSRect) -> NSRect {
- var rect = super.constrainBoundsRect(proposedBounds)
- rect.origin.x = 0
- return rect
- }
- }
- private final class HoverButton: NSButton {
- var normalColor: NSColor = .clear { didSet { applyBackground() } }
- var hoverColor: NSColor = .clear
- var onHoverChanged: ((Bool) -> Void)?
- private var tracking: NSTrackingArea?
- private var hovering = false { didSet { applyBackground() } }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let area = NSTrackingArea(rect: bounds, options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect], owner: self, userInfo: nil)
- addTrackingArea(area)
- tracking = area
- }
- override func mouseEntered(with event: NSEvent) {
- hovering = true
- onHoverChanged?(true)
- }
- override func mouseExited(with event: NSEvent) {
- hovering = false
- onHoverChanged?(false)
- }
- private func applyBackground() {
- wantsLayer = true
- layer?.backgroundColor = (hovering ? hoverColor : normalColor).cgColor
- }
- }
- private final class HoverTileView: NSView {
- var onHoverChanged: ((Bool) -> Void)?
- private var tracking: NSTrackingArea?
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.activeInActiveApp, .mouseEnteredAndExited, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- addTrackingArea(area)
- tracking = area
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- onHoverChanged?(true)
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- onHoverChanged?(false)
- }
- }
- private var palette = Palette(isDarkMode: true)
- private var darkModeEnabled: Bool {
- get {
- let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
- return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
- }
- set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
- }
- private let sidebarWidth: CGFloat = 78
- private var appBackground: NSColor { palette.appBackground }
- private var sidebarBackground: NSColor { palette.sidebarBackground }
- private var sidebarActiveBackground: NSColor { palette.sidebarActiveBackground }
- private var cardBackground: NSColor { palette.cardBackground }
- private var secondaryCardBackground: NSColor { palette.secondaryCardBackground }
- private var appShellBackground: NSColor { palette.appShellBackground }
- private var contentShellBackground: NSColor { palette.contentShellBackground }
- private var topStripBackground: NSColor { palette.topStripBackground }
- private var chromeUnifiedBackground: NSColor { palette.chromeUnifiedBackground }
- private var searchPillBackground: NSColor { palette.searchPillBackground }
- private var meetingCardBackground: NSColor { palette.meetingCardBackground }
- private let appShellCornerRadius: CGFloat = 20
- private let homeChromeHeaderHeight: CGFloat = 56
- private let nativeTrafficLightsLeading: CGFloat = 14
- private let nativeTrafficLightsTopInset: CGFloat = 20
- private let brandLeadingInset: CGFloat = 84
- private let brandFullScreenLeadingInset: CGFloat = 14
- private let accentBlue = NSColor(calibratedRed: 27 / 255, green: 115 / 255, blue: 232 / 255, alpha: 1)
- private let accentOrange = NSColor(calibratedRed: 254 / 255, green: 117 / 255, blue: 46 / 255, alpha: 1)
- private var primaryText: NSColor { palette.primaryText }
- private var secondaryText: NSColor { palette.secondaryText }
- private var mutedText: NSColor { palette.mutedText }
- private var sidebarInactiveText: NSColor { palette.sidebarInactiveText }
- private let rootContainer = NSView()
- private var homeView: NSView?
- private weak var googleButton: NSButton?
- private weak var timeLabel: NSTextField?
- private weak var dateLabel: NSTextField?
- private weak var meetingsDayHeaderLabel: NSTextField?
- private weak var emptyMeetingLabel: NSTextField?
- private weak var meetingsListStack: NSStackView?
- private weak var meetingsStatusLabel: NSTextField?
- private weak var meetingsScrollView: NSScrollView?
- private weak var meetingsPrevDayButton: NSButton?
- private weak var meetingsNextDayButton: NSButton?
- private weak var meetingsTodayButton: NSButton?
- private weak var refreshMeetingsButton: NSButton?
- private weak var homeWelcomeLabel: NSTextField?
- private weak var homeTimeLabelView: NSTextField?
- private weak var homeDateLabelView: NSTextField?
- private weak var homeActionsRow: NSView?
- private weak var homeMeetingsPanel: NSView?
- private weak var homePlaceholderLabel: NSTextField?
- private weak var homeSearchField: NSTextField?
- private weak var homeSearchPill: NSView?
- private weak var meetingSearchOverlayView: NSView?
- private weak var meetingSearchPanelView: NSView?
- private weak var meetingSearchField: NSTextField?
- private weak var meetingSearchResultsStack: NSStackView?
- private weak var meetingSearchEmptyLabel: NSTextField?
- private weak var homeSettingsView: NSView?
- private weak var schedulerRootView: NSView?
- private weak var schedulerMonthLabel: NSTextField?
- private weak var schedulerCalendarGridStack: NSStackView?
- private weak var schedulerSelectedDateLabel: NSTextField?
- private weak var schedulerMeetingsListStack: NSStackView?
- private weak var schedulerMeetingsEmptyLabel: NSTextField?
- private weak var schedulerTodayButton: NSButton?
- private weak var meetingsPageRootView: NSView?
- private weak var widgetsPageRootView: NSView?
- private weak var meetingsPageCategoryFilter: NSPopUpButton?
- private weak var meetingsPageFromDatePicker: NSDatePicker?
- private weak var meetingsPageToDatePicker: NSDatePicker?
- private weak var meetingsPageRefreshButton: NSButton?
- private weak var meetingsPageCountLabel: NSTextField?
- private weak var meetingsPageStatusLabel: NSTextField?
- private weak var meetingsPageListStack: NSStackView?
- private weak var meetingsPageEmptyLabel: NSTextField?
- private weak var meetingsPageLoadMoreButton: NSButton?
- private weak var meetingsPageScrollView: NSScrollView?
- private weak var embeddedBrowserWebView: WKWebView?
- private weak var embeddedBrowserPanel: NSView?
- private weak var homeBodyRowView: NSView?
- private weak var homeMainContentColumnView: NSView?
- private weak var embeddedBrowserBackButton: NSButton?
- private weak var embeddedBrowserForwardButton: NSButton?
- private var embeddedBrowserPanelWidthConstraint: NSLayoutConstraint?
- private var embeddedBrowserMainColumnCollapsedWidthConstraint: NSLayoutConstraint?
- private var oauthSessionContinuation: CheckedContinuation<URL, Error>?
- private var oauthSessionCallbackPrefix: String?
- /// Gap between main column and right web panel (must match Auto Layout constant).
- private let homeEmbeddedBrowserColumnGap: CGFloat = 10
- private weak var homePremiumLockOverlay: NSButton?
- private weak var meetingsPremiumLockOverlay: NSButton?
- private weak var schedulerPremiumLockOverlay: NSButton?
- private var schedulerDayButtons: [Date: NSButton] = [:]
- private var schedulerDateByButtonTag: [Int: Date] = [:]
- private var nextSchedulerButtonTag: Int = 1
- private weak var settingsDarkModeSwitch: NSSwitch?
- private weak var settingsUpgradeButton: NSButton?
- private weak var settingsUpgradeTitleLabel: NSTextField?
- private weak var settingsGoogleActionButton: NSButton?
- private weak var topBarPremiumButton: NSButton?
- private weak var topBarAuthButton: NSButton?
- private var topBarAuthButtonWidthConstraint: NSLayoutConstraint?
- private var profileMenuPopover: NSPopover?
- private weak var paywallOverlayView: NSView?
- private weak var paywallPanelView: NSView?
- private weak var paywallBodyStack: NSStackView?
- private weak var paywallPlansRowWide: NSStackView?
- private weak var paywallPlansGridNarrow: NSStackView?
- private var paywallIsDesktopLayout = false
- private var paywallPanelWidthConstraint: NSLayoutConstraint?
- private var paywallPanelHeightConstraint: NSLayoutConstraint?
- private var joinMeetingWindow: NSWindow?
- private var scheduleMeetingWindow: NSWindow?
- private weak var scheduleTopicField: NSTextField?
- private weak var scheduleDateField: NSTextField?
- private weak var scheduleTimeField: NSTextField?
- private weak var scheduleTimeZonePopup: NSPopUpButton?
- private weak var scheduleDurationField: NSTextField?
- private weak var scheduleSubmitButton: NSButton?
- private var scheduleTimeZoneDisplayToIdentifier: [String: String] = [:]
- private weak var joinURLField: NSTextField?
- private weak var joinMeetingIDField: NSTextField?
- private weak var joinPasscodeField: NSTextField?
- private weak var joinURLFieldsContainer: NSView?
- private weak var joinIDFieldsContainer: NSView?
- private weak var joinModeSegment: NSSegmentedControl?
- private let paywallContentWidth: CGFloat = 520
- private var selectedPremiumPlan: PremiumPlan = .yearly
- private var paywallPlanViews: [PremiumPlan: [NSView]] = [:]
- private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
- private var paywallPlanPriceLabels: [PremiumPlan: [NSTextField]] = [:]
- private var paywallPlanSubtitleLabels: [PremiumPlan: [NSTextField]] = [:]
- private var paywallPlanStrikeLabels: [PremiumPlan: [NSTextField]] = [:]
- private weak var paywallOfferLabel: NSTextField?
- private weak var paywallContinueLabel: NSTextField?
- private weak var paywallContinueButton: NSView?
- private var paywallPurchaseTask: Task<Void, Never>?
- private var paywallContinueEnabled = true
- private var lastKnownPremiumAccess = false
- private var allScheduledMeetings: [ScheduledMeeting] = []
- private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
- private var schedulerVisibleMonthStart: Date = {
- let calendar = Calendar.current
- let today = Date()
- let components = calendar.dateComponents([.year, .month], from: today)
- return calendar.date(from: components) ?? calendar.startOfDay(for: today)
- }()
- private var selectedHomeSidebarItem: String = "Home"
- private var homeSidebarRowViews: [String: NSView] = [:]
- private var homeSidebarIconViews: [String: NSImageView] = [:]
- private var homeSidebarLabels: [String: NSTextField] = [:]
- private var searchTextObserver: NSObjectProtocol?
- private var searchShortcutMonitor: Any?
- private var searchOutsideClickMonitor: Any?
- private var meetingSearchTextObserver: NSObjectProtocol?
- private var globalSearchResults: [ScheduledMeeting] = []
- private var topBarProfileInitial = "H"
- private var clockTimer: Timer?
- private var meetingsRefreshTimer: Timer?
- private var brandLeadingConstraint: NSLayoutConstraint?
- private var windowFullScreenObservers: [NSObjectProtocol] = []
- private var isSigningIn = false
- private var isPromptingZoomCredentials = false
- private var isLoadingMeetings = false
- private var meetingsScrollObserver: NSObjectProtocol?
- private var meetingsPageScrollObserver: NSObjectProtocol?
- private var lastMeetingsRefreshAt = Date.distantPast
- private var lastScrollEdgeRefreshAt = Date.distantPast
- private var lastMeetingsPageAutoLoadAt = Date.distantPast
- // Keep this conservative to avoid Zoom API rate limits.
- private let meetingsRefreshInterval: TimeInterval = 60
- private let scrollRefreshCooldown: TimeInterval = 3
- private let meetingsPageAutoLoadCooldown: TimeInterval = 0.25
- private var meetingsRateLimitedUntil: Date?
- private var zoomAccountTimeZone: TimeZone?
- private var meetingTimeZoneOverrides: [String: String] = [:]
- private var meetingsPageFilteredMeetings: [ScheduledMeeting] = []
- private var meetingsPageSelectedRangePreset: String = "All"
- private var meetingsPageFromDateFilter: Date?
- private var meetingsPageToDateFilter: Date?
- private var meetingsPageVisibleCount: Int = 0
- private let meetingsPageSize: Int = 8
- private var premiumRatingPromptWorkItem: DispatchWorkItem?
- private var usageRatingPromptWorkItem: DispatchWorkItem?
- private let usageRatingPromptDelay: TimeInterval = 30 * 60
- private let premiumRatingPromptDelay: TimeInterval = 10
-
- private enum SidebarStyle {
- case home
- }
- private enum PremiumPlan: String, CaseIterable {
- case weekly = "com.mqldev.zoomapp.premium.weekly"
- case monthly = "com.mqldev.zoomapp.premium.monthly"
- case yearly = "com.mqldev.zoomapp.premium.yearly"
- case lifetime = "com.mqldev.zoomapp.premium.lifetime"
- var displayName: String {
- switch self {
- case .weekly: return "Premium Weekly"
- case .monthly: return "Premium Monthly"
- case .yearly: return "Premium Yearly"
- case .lifetime: return "Premium Lifetime"
- }
- }
- }
- private final class StoreKitCoordinator {
- enum PurchaseOutcome {
- case success
- case pending
- case cancelled
- case failed(String)
- }
- private(set) var productsByID: [String: Product] = [:]
- private(set) var activeProductIDs = Set<String>()
- var hasPremiumAccess: Bool { !activeProductIDs.isEmpty }
- var hasLifetimePremiumAccess: Bool { activeProductIDs.contains(PremiumPlan.lifetime.rawValue) }
- private var transactionUpdatesTask: Task<Void, Never>?
- var onEntitlementsChanged: ((Bool) -> Void)?
- deinit {
- transactionUpdatesTask?.cancel()
- }
- func start() async {
- await refreshProducts()
- await refreshEntitlements()
- observeTransactionUpdatesIfNeeded()
- }
- func refreshProducts() async {
- let productIDs = PremiumPlan.allCases.map(\.rawValue)
- var loadedProducts: [Product] = []
- var attempts = 0
- let maxAttempts = 3
- while attempts < maxAttempts {
- attempts += 1
- do {
- loadedProducts = try await Product.products(for: productIDs)
- } catch {
- loadedProducts = []
- }
- if loadedProducts.isEmpty == false {
- break
- }
- if attempts < maxAttempts {
- // StoreKit can transiently return empty right after launch/network changes.
- try? await Task.sleep(nanoseconds: UInt64(attempts) * 500_000_000)
- }
- }
- if loadedProducts.isEmpty == false {
- productsByID = Dictionary(uniqueKeysWithValues: loadedProducts.map { ($0.id, $0) })
- }
- }
- func purchase(plan: PremiumPlan) async -> PurchaseOutcome {
- guard let product = productsByID[plan.rawValue] else {
- await refreshProducts()
- guard let refreshed = productsByID[plan.rawValue] else {
- return .failed("Product not available. Check StoreKit configuration and product IDs.")
- }
- return await purchase(product: refreshed)
- }
- return await purchase(product: product)
- }
- func restorePurchases() async -> String {
- do {
- try await AppStore.sync()
- await refreshEntitlements()
- return hasPremiumAccess ? "Purchases restored successfully." : "No previous premium purchase was found for this Apple ID."
- } catch {
- return "Restore failed. \(error.localizedDescription)"
- }
- }
- private func purchase(product: Product) async -> PurchaseOutcome {
- do {
- let result = try await product.purchase()
- switch result {
- case .success(let verification):
- guard case .verified(let transaction) = verification else { return .failed("Purchase verification failed.") }
- await transaction.finish()
- await refreshEntitlements()
- return .success
- case .pending:
- return .pending
- case .userCancelled:
- return .cancelled
- @unknown default:
- return .failed("Unknown purchase state.")
- }
- } catch {
- return .failed(error.localizedDescription)
- }
- }
- private func observeTransactionUpdatesIfNeeded() {
- guard transactionUpdatesTask == nil else { return }
- transactionUpdatesTask = Task { [weak self] in
- for await update in Transaction.updates {
- guard case .verified(let transaction) = update else { continue }
- guard PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) else { continue }
- await self?.refreshEntitlements()
- await transaction.finish()
- }
- }
- }
- @MainActor
- private func refreshEntitlements() async {
- let previousHasPremiumAccess = hasPremiumAccess
- let allIDs = Set(PremiumPlan.allCases.map(\.rawValue))
- var active = Set<String>()
- for await entitlement in Transaction.currentEntitlements {
- guard case .verified(let transaction) = entitlement else { continue }
- guard allIDs.contains(transaction.productID) else { continue }
- if Self.isTransactionActive(transaction) {
- active.insert(transaction.productID)
- }
- }
- // StoreKit testing can briefly report empty current entitlements even though a latest
- // verified transaction exists for a non-consumable. Merge in latest transactions.
- for productID in allIDs {
- guard let latest = await Transaction.latest(for: productID),
- case .verified(let transaction) = latest else { continue }
- if Self.isTransactionActive(transaction) {
- active.insert(productID)
- }
- }
- activeProductIDs = active
- let newHasPremiumAccess = hasPremiumAccess
- if newHasPremiumAccess != previousHasPremiumAccess {
- onEntitlementsChanged?(newHasPremiumAccess)
- }
- }
- private static func isTransactionActive(_ transaction: StoreKit.Transaction) -> Bool {
- if transaction.revocationDate != nil { return false }
- if let expirationDate = transaction.expirationDate {
- return expirationDate > Date()
- }
- return true
- }
- }
- private let storeKitCoordinator = StoreKitCoordinator()
- private var storeKitStartupTask: Task<Void, Never>?
- private var launchPaywallWorkItem: DispatchWorkItem?
- private let launchPaywallDelay: TimeInterval = 2
- private var hasPresentedLaunchPaywall = false
- override func viewDidLoad() {
- super.viewDidLoad()
- darkModeEnabled = systemPrefersDarkMode()
- palette = Palette(isDarkMode: darkModeEnabled)
- NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- meetingTimeZoneOverrides = UserDefaults.standard.dictionary(forKey: meetingTimeZoneOverrideKey) as? [String: String] ?? [:]
- ensureUsageSessionStartDate()
- scheduleUsageBasedRatingPromptIfNeeded()
- setupUI()
- setupWidgetBridge()
- startStoreKit()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- if let window = view.window {
- window.setContentSize(
- NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- )
- applyWindowBackgroundForCurrentTheme(window)
- // Use full-size content view so custom top chrome sits in the titlebar region.
- window.titleVisibility = .hidden
- window.titlebarAppearsTransparent = true
- window.isMovableByWindowBackground = true
- window.styleMask.insert(.fullSizeContentView)
- installWindowFullScreenObservers(for: window)
- }
- alignNativeTrafficLights()
- showHomeView(profile: nil)
- syncBrandPositionForCurrentWindowState(animated: false)
- scheduleLaunchPaywallIfNeeded()
- DesktopWidgetWindowManager.shared.restore()
- }
- override func viewDidLayout() {
- super.viewDidLayout()
- alignNativeTrafficLights()
- if paywallOverlayView != nil {
- updatePaywallPanelSize()
- updatePaywallResponsiveLayout()
- }
- if (embeddedBrowserPanelWidthConstraint?.constant ?? 0) > 0.5 {
- updateEmbeddedRightPanelWidthForOpenState()
- }
- }
- private func setupUI() {
- view.wantsLayer = true
- view.layer?.backgroundColor = appBackground.cgColor
- rootContainer.translatesAutoresizingMaskIntoConstraints = false
- view.addSubview(rootContainer)
- NSLayoutConstraint.activate([
- rootContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- rootContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- rootContainer.topAnchor.constraint(equalTo: view.topAnchor),
- rootContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
- ])
- }
- private func installWindowFullScreenObservers(for window: NSWindow) {
- guard windowFullScreenObservers.isEmpty else { return }
- let center = NotificationCenter.default
- let enterObserver = center.addObserver(
- forName: NSWindow.didEnterFullScreenNotification,
- object: window,
- queue: .main
- ) { [weak self] _ in
- self?.syncBrandPositionForCurrentWindowState(animated: true)
- }
- let exitObserver = center.addObserver(
- forName: NSWindow.didExitFullScreenNotification,
- object: window,
- queue: .main
- ) { [weak self] _ in
- self?.syncBrandPositionForCurrentWindowState(animated: true)
- }
- windowFullScreenObservers = [enterObserver, exitObserver]
- }
- private func syncBrandPositionForCurrentWindowState(animated: Bool) {
- guard let constraint = brandLeadingConstraint else { return }
- let isFullScreen = view.window?.styleMask.contains(.fullScreen) == true
- let targetInset = isFullScreen ? brandFullScreenLeadingInset : brandLeadingInset
- guard abs(constraint.constant - targetInset) > 0.5 else { return }
- if animated {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.2
- constraint.animator().constant = targetInset
- view.layoutSubtreeIfNeeded()
- }
- } else {
- constraint.constant = targetInset
- }
- }
- private func showHomeView(profile: GoogleUserProfile?) {
- clearMeetingsScrollObserver()
- clearMeetingsPageScrollObserver()
- removeSearchFieldObserver()
- removeMeetingSearchObserver()
- removeSearchShortcutMonitor()
- homeSearchField = nil
- homeSearchPill = nil
- meetingSearchOverlayView = nil
- meetingSearchPanelView = nil
- meetingSearchField = nil
- meetingSearchResultsStack = nil
- meetingSearchEmptyLabel = nil
- meetingsPageRootView = nil
- widgetsPageRootView = nil
- homeView?.removeFromSuperview()
- if let profileName = profile?.name?.trimmingCharacters(in: .whitespacesAndNewlines), profileName.isEmpty == false {
- topBarProfileInitial = String(profileName.prefix(1)).uppercased()
- }
- selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
- homeView = makeHomeView(profile: profile)
- if let homeView { attachToRoot(homeView) }
- installSearchShortcutMonitor()
- startClock()
- applyAuthenticationUIState(triggerInitialRefresh: true)
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- private func isUserLoggedIn() -> Bool {
- UserDefaults.standard.bool(forKey: loginStateKey)
- }
- private func persistLoggedInState(_ loggedIn: Bool) {
- UserDefaults.standard.set(loggedIn, forKey: loginStateKey)
- }
- private func attachToRoot(_ subview: NSView) {
- subview.translatesAutoresizingMaskIntoConstraints = false
- if subview.superview != rootContainer {
- rootContainer.addSubview(subview)
- }
- NSLayoutConstraint.activate([
- subview.leadingAnchor.constraint(equalTo: rootContainer.leadingAnchor),
- subview.trailingAnchor.constraint(equalTo: rootContainer.trailingAnchor),
- subview.topAnchor.constraint(equalTo: rootContainer.topAnchor),
- subview.bottomAnchor.constraint(equalTo: rootContainer.bottomAnchor)
- ])
- }
- @objc private func googleLoginTapped() {
- guard isSigningIn == false else { return }
- isSigningIn = true
- googleButton?.title = "..."
- googleButton?.isEnabled = false
- Task {
- do {
- let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
- let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
- await MainActor.run {
- self.googleButton?.title = "G"
- self.resetLoginSigningInState()
- self.completeLoginAndRestart(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.resetLoginSigningInState()
- if case GoogleOAuthError.authenticationCancelled = error { return }
- self.showSimpleError("Google login failed", error: error)
- }
- }
- }
- }
- /// Primary Zoom sign-in: browser OAuth, token refresh, then home with scheduled meetings.
- @objc private func zoomPrimarySignInTapped() {
- guard isSigningIn == false else { return }
- isSigningIn = true
- topBarAuthButton?.title = "Signing in..."
- topBarAuthButton?.isEnabled = false
- googleButton?.isEnabled = false
- Task {
- do {
- let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
- guard configured else {
- await MainActor.run { self.resetLoginSigningInState() }
- return
- }
- let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
- let zoomUser = try? await fetchZoomUserProfile(accessToken: zoomToken)
- let profile = zoomUser.map { GoogleUserProfile(name: $0.displayName, email: $0.email, picture: $0.pictureURL) }
- let profileTimeZone = parseZoomTimeZone(zoomUser?.timezone)
- await MainActor.run {
- self.resetLoginSigningInState()
- self.zoomAccountTimeZone = profileTimeZone
- self.completeLoginAndRestart(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.resetLoginSigningInState()
- if case ZoomOAuthError.authenticationCancelled = error { return }
- self.showSimpleError("Zoom sign-in failed", error: error)
- }
- }
- }
- }
- @objc private func scheduleMeetingTapped() {
- guard isUserLoggedIn() else {
- showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to schedule a meeting.")
- return
- }
- guard ensurePremiumAccess() else { return }
- if let existing = scheduleMeetingWindow {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- DispatchQueue.main.async { [weak self] in
- _ = self?.scheduleTopicField?.becomeFirstResponder()
- }
- return
- }
- let content = makeScheduleMeetingPanelContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 580),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Schedule a meeting"
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
- panel.hidesOnDeactivate = true
- panel.isReleasedWhenClosed = false
- panel.standardWindowButton(.closeButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.isMovableByWindowBackground = true
- panel.becomesKeyOnlyIfNeeded = false
- panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
- panel.center()
- panel.contentViewController = controller
- panel.delegate = self
- applyWindowBackgroundForCurrentTheme(panel)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- scheduleMeetingWindow = panel
- DispatchQueue.main.async { [weak self] in
- _ = self?.scheduleTopicField?.becomeFirstResponder()
- }
- }
- private func makeScheduleMeetingPanelContent() -> NSView {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = appBackground.cgColor
- let titleLabel = makeLabel("Schedule a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
- let subtitleLabel = makeLabel("Creates a Zoom meeting on your account", size: 12, color: mutedText, weight: .regular, centered: false)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(scheduleMeetingCancelTapped))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = .systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Close"
- closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let titleRow = NSStackView()
- titleRow.orientation = .horizontal
- titleRow.alignment = .centerY
- titleRow.spacing = 10
- titleRow.translatesAutoresizingMaskIntoConstraints = false
- let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
- titleStack.orientation = .vertical
- titleStack.spacing = 2
- titleStack.alignment = .leading
- let titleSpacer = NSView()
- titleSpacer.translatesAutoresizingMaskIntoConstraints = false
- titleRow.addArrangedSubview(titleStack)
- titleRow.addArrangedSubview(titleSpacer)
- titleRow.addArrangedSubview(closeButton)
- let headerDivider = NSView()
- headerDivider.wantsLayer = true
- headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
- headerDivider.translatesAutoresizingMaskIntoConstraints = false
- let topicBox = makeJoinFormField(placeholder: "Meeting topic")
- scheduleTopicField = topicBox.textField
- topicBox.textField.stringValue = "Zoom meeting"
- let defaultStart = Date().addingTimeInterval(3600)
- let dateOnlyFormatter = DateFormatter()
- dateOnlyFormatter.locale = Locale(identifier: "en_US_POSIX")
- dateOnlyFormatter.timeZone = TimeZone.current
- dateOnlyFormatter.dateFormat = "yyyy-MM-dd"
- let time12Formatter = DateFormatter()
- time12Formatter.locale = Locale(identifier: "en_US_POSIX")
- time12Formatter.timeZone = TimeZone.current
- time12Formatter.dateFormat = "h:mm a"
- let dateBox = makeJoinFormField(placeholder: "YYYY-MM-DD")
- scheduleDateField = dateBox.textField
- dateBox.textField.stringValue = dateOnlyFormatter.string(from: defaultStart)
- let timeBox = makeJoinFormField(placeholder: "2:30 PM")
- scheduleTimeField = timeBox.textField
- timeBox.textField.stringValue = time12Formatter.string(from: defaultStart)
- let timeHint = makeLabel("12-hour time in the timezone below · example 2:30 PM", size: 11, color: mutedText, weight: .regular, centered: false)
- let tzLabel = makeLabel("Timezone", size: 12, color: secondaryText, weight: .medium, centered: false)
- let tzCombo = NSPopUpButton()
- tzCombo.translatesAutoresizingMaskIntoConstraints = false
- tzCombo.font = .systemFont(ofSize: 14, weight: .regular)
- tzCombo.wantsLayer = true
- tzCombo.layer?.cornerRadius = 10
- tzCombo.layer?.borderWidth = 1
- tzCombo.layer?.borderColor = palette.inputBorder.cgColor
- tzCombo.layer?.backgroundColor = palette.inputBackground.cgColor
- let tzOptions = makeTimeZoneDisplayOptions(referenceDate: defaultStart)
- scheduleTimeZoneDisplayToIdentifier = Dictionary(uniqueKeysWithValues: tzOptions.map { ($0.display, $0.identifier) })
- tzCombo.removeAllItems()
- tzCombo.addItems(withTitles: tzOptions.map(\.display))
- if let selected = tzOptions.first(where: { $0.identifier == TimeZone.current.identifier }) {
- tzCombo.selectItem(withTitle: selected.display)
- } else {
- tzCombo.selectItem(at: 0)
- }
- tzCombo.toolTip = "Timezone with GMT offset. Meeting start is interpreted in this zone."
- scheduleTimeZonePopup = tzCombo
- let tzHint = makeLabel("Includes GMT offset, e.g. GMT+05:00 - Asia/Karachi", size: 11, color: mutedText, weight: .regular, centered: false)
- let tzStack = NSStackView(views: [tzLabel, tzCombo, tzHint])
- tzStack.orientation = .vertical
- tzStack.spacing = 8
- tzStack.alignment = .leading
- tzStack.translatesAutoresizingMaskIntoConstraints = false
- let durationBox = makeJoinFormField(placeholder: "Duration in minutes")
- durationBox.textField.stringValue = "60"
- scheduleDurationField = durationBox.textField
- let topicLabel = makeLabel("Topic", size: 12, color: secondaryText, weight: .medium, centered: false)
- let startLabel = makeLabel("Start date & time", size: 12, color: secondaryText, weight: .medium, centered: false)
- let durationLabel = makeLabel("Duration", size: 12, color: secondaryText, weight: .medium, centered: false)
- let topicStack = NSStackView(views: [topicLabel, topicBox])
- topicStack.orientation = .vertical
- topicStack.spacing = 8
- topicStack.alignment = .leading
- topicStack.translatesAutoresizingMaskIntoConstraints = false
- let dateTimeRow = NSStackView(views: [dateBox, timeBox])
- dateTimeRow.orientation = .horizontal
- dateTimeRow.spacing = 10
- dateTimeRow.alignment = .top
- dateTimeRow.distribution = .fillEqually
- dateTimeRow.translatesAutoresizingMaskIntoConstraints = false
- let startStack = NSStackView(views: [startLabel, dateTimeRow, timeHint])
- startStack.orientation = .vertical
- startStack.spacing = 8
- startStack.alignment = .leading
- startStack.translatesAutoresizingMaskIntoConstraints = false
- let durationStack = NSStackView(views: [durationLabel, durationBox])
- durationStack.orientation = .vertical
- durationStack.spacing = 8
- durationStack.alignment = .leading
- durationStack.translatesAutoresizingMaskIntoConstraints = false
- let formCard = NSView()
- formCard.translatesAutoresizingMaskIntoConstraints = false
- formCard.wantsLayer = true
- formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
- formCard.layer?.cornerRadius = 14
- formCard.layer?.borderWidth = 1
- formCard.layer?.borderColor = palette.inputBorder.cgColor
- let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(scheduleMeetingCancelTapped))
- cancelButton.isBordered = false
- cancelButton.wantsLayer = true
- cancelButton.layer?.cornerRadius = 10
- cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
- cancelButton.layer?.borderWidth = 1
- cancelButton.layer?.borderColor = palette.inputBorder.cgColor
- cancelButton.contentTintColor = primaryText
- cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let submitButton = HoverButton(title: "Schedule", target: self, action: #selector(scheduleMeetingSubmitTapped))
- submitButton.isBordered = false
- submitButton.wantsLayer = true
- submitButton.layer?.cornerRadius = 10
- submitButton.normalColor = accentBlue
- submitButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
- submitButton.contentTintColor = .white
- submitButton.font = .systemFont(ofSize: 13, weight: .bold)
- submitButton.keyEquivalent = "\r"
- scheduleSubmitButton = submitButton
- let buttons = NSStackView(views: [cancelButton, submitButton])
- buttons.orientation = .horizontal
- buttons.spacing = 12
- buttons.alignment = .centerY
- buttons.distribution = .fillEqually
- let innerStack = NSStackView(views: [topicStack, tzStack, startStack, durationStack, buttons])
- innerStack.orientation = .vertical
- innerStack.spacing = 18
- innerStack.alignment = .leading
- innerStack.translatesAutoresizingMaskIntoConstraints = false
- formCard.addSubview(innerStack)
- [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
- NSLayoutConstraint.activate([
- titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
- headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
- headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- headerDivider.heightAnchor.constraint(equalToConstant: 1),
- formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
- formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
- innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
- innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
- innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
- innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
- topicBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- tzCombo.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- tzCombo.heightAnchor.constraint(equalToConstant: 34),
- dateTimeRow.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- durationBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- submitButton.heightAnchor.constraint(equalToConstant: 40),
- cancelButton.heightAnchor.constraint(equalToConstant: 40)
- ])
- return root
- }
- @objc private func scheduleMeetingCancelTapped() {
- scheduleMeetingWindow?.performClose(nil)
- }
- @objc private func scheduleMeetingSubmitTapped() {
- let topicRaw = scheduleTopicField?.stringValue ?? ""
- let topic = topicRaw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard topic.isEmpty == false else {
- showSimpleAlert(title: "Topic required", message: "Enter a name for your meeting.")
- return
- }
- let tzId = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if tzId.isEmpty == false,
- scheduleTimeZoneDisplayToIdentifier[tzId] == nil,
- TimeZone(identifier: tzId) == nil {
- showSimpleAlert(
- title: "Timezone",
- message: "Choose a timezone from the list (with GMT offset), for example GMT+05:00 - Asia/Karachi."
- )
- return
- }
- guard let draft = scheduleMeetingDraftFromFormFields() else {
- showSimpleAlert(
- title: "Date & time",
- message: "Enter the date as YYYY-MM-DD and the time in 12-hour form with AM or PM (in the selected timezone), for example 2026-04-17 and 2:30 PM."
- )
- return
- }
- if draft.startDate < Date().addingTimeInterval(-30) {
- showSimpleAlert(title: "Start time", message: "Choose a start time in the future.")
- return
- }
- let durationRaw = scheduleDurationField?.stringValue ?? ""
- let digits = durationRaw.filter(\.isNumber)
- guard let duration = Int(digits), duration >= 1, duration <= 24 * 60 else {
- showSimpleAlert(title: "Duration", message: "Enter duration in minutes (1–1440).")
- return
- }
- scheduleSubmitButton?.isEnabled = false
- let tz = draft.timeZone
- Task {
- do {
- let configured = await MainActor.run { self.ensureZoomOAuthClientConfigured() }
- guard configured else {
- await MainActor.run { self.scheduleSubmitButton?.isEnabled = true }
- return
- }
- let oauthParent = await MainActor.run { self.scheduleMeetingWindow ?? self.view.window }
- let token = try await zoomOAuth.validAccessToken(presentingWindow: oauthParent)
- let result = try await createZoomMeeting(
- accessToken: token,
- topic: topic,
- startTimeUTC: draft.startTimeUTC,
- durationMinutes: duration,
- timeZone: tz
- )
- await MainActor.run {
- self.saveMeetingTimeZoneOverride(meetingID: result.id.map(String.init), timeZone: tz)
- }
- await MainActor.run {
- self.scheduleSubmitButton?.isEnabled = true
- self.scheduleMeetingWindow?.performClose(nil)
- self.meetingsStatusLabel?.stringValue = "Meeting scheduled."
- self.triggerMeetingsRefresh(force: true)
- if let join = result.join_url, join.isEmpty == false {
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(join, forType: .string)
- self.showSimpleAlert(
- title: "Meeting scheduled",
- message: "The join link was copied to your clipboard. It will also appear in your upcoming meetings list below."
- )
- } else {
- self.showSimpleAlert(title: "Meeting scheduled", message: "The meeting was added to your list.")
- }
- }
- } catch {
- await MainActor.run {
- self.scheduleSubmitButton?.isEnabled = true
- if case ZoomOAuthError.missingRequiredScope(_) = error {
- self.zoomOAuth.clearSavedTokens()
- self.showSimpleAlert(
- title: "Zoom permissions",
- message: "Your Zoom app needs the meeting:write scope to schedule meetings. Add it in the Zoom Marketplace app settings, then sign in again."
- )
- } else if case ZoomOAuthError.rateLimited(let retryAfter) = error {
- let seconds = max(retryAfter ?? 300, 30)
- let minutes = Int(ceil(Double(seconds) / 60.0))
- self.showSimpleAlert(title: "Rate limited", message: "Zoom asked to wait before scheduling again. Try in about \(minutes) min.")
- } else if case ZoomOAuthError.authenticationCancelled = error {
- // User closed the sign-in window; no alert.
- } else {
- self.showSimpleError("Could not schedule", error: error)
- }
- }
- }
- }
- }
- private func resetScheduleMeetingPanelReferences() {
- scheduleMeetingWindow = nil
- scheduleTopicField = nil
- scheduleDateField = nil
- scheduleTimeField = nil
- scheduleTimeZonePopup = nil
- scheduleDurationField = nil
- scheduleSubmitButton = nil
- scheduleTimeZoneDisplayToIdentifier = [:]
- }
- /// Resolves the schedule panel timezone (IANA id from combo, or system default).
- private func resolvedScheduleTimeZoneForMeeting() -> TimeZone {
- let raw = scheduleTimeZonePopup?.titleOfSelectedItem?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- if let identifier = scheduleTimeZoneDisplayToIdentifier[raw],
- let mapped = TimeZone(identifier: identifier) {
- return mapped
- }
- guard raw.isEmpty == false, let tz = TimeZone(identifier: raw) else { return TimeZone.current }
- return tz
- }
- private struct TimeZoneDisplayOption {
- let identifier: String
- let display: String
- }
- private func makeTimeZoneDisplayOptions(referenceDate: Date) -> [TimeZoneDisplayOption] {
- TimeZone.knownTimeZoneIdentifiers.compactMap { identifier in
- guard let tz = TimeZone(identifier: identifier) else { return nil }
- let seconds = tz.secondsFromGMT(for: referenceDate)
- let sign = seconds >= 0 ? "+" : "-"
- let absSeconds = abs(seconds)
- let hours = absSeconds / 3600
- let minutes = (absSeconds % 3600) / 60
- let offset = String(format: "GMT%@%02d:%02d", sign, hours, minutes)
- return TimeZoneDisplayOption(identifier: identifier, display: "\(offset) - \(identifier)")
- }
- .sorted { lhs, rhs in
- if lhs.display == rhs.display { return lhs.identifier < rhs.identifier }
- return lhs.display < rhs.display
- }
- }
- /// Parses zoom timezone values like `Asia/Karachi`, `GMT+05:00`, `UTC+5`, `+0500`, or `Z`.
- private func parseZoomTimeZone(_ raw: String?) -> TimeZone? {
- guard let raw else { return nil }
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- if let named = TimeZone(identifier: trimmed) { return named }
- if trimmed.caseInsensitiveCompare("Z") == .orderedSame { return TimeZone(secondsFromGMT: 0) }
- let normalized = trimmed.uppercased()
- let pattern = #"^(?:GMT|UTC)?\s*([+-])(\d{1,2})(?::?(\d{2}))?$"#
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
- let nsRange = NSRange(normalized.startIndex..<normalized.endIndex, in: normalized)
- guard let match = regex.firstMatch(in: normalized, options: [], range: nsRange),
- match.numberOfRanges == 4,
- let signRange = Range(match.range(at: 1), in: normalized),
- let hourRange = Range(match.range(at: 2), in: normalized) else {
- return nil
- }
- let sign = normalized[signRange] == "-" ? -1 : 1
- let hours = Int(normalized[hourRange]) ?? -1
- let minutes: Int = {
- if let minuteRange = Range(match.range(at: 3), in: normalized) {
- return Int(normalized[minuteRange]) ?? -1
- }
- return 0
- }()
- guard (0...23).contains(hours), (0...59).contains(minutes) else { return nil }
- return TimeZone(secondsFromGMT: sign * ((hours * 3600) + (minutes * 60)))
- }
- private func saveMeetingTimeZoneOverride(meetingID: String?, timeZone: TimeZone) {
- guard let meetingID, meetingID.isEmpty == false else { return }
- meetingTimeZoneOverrides[meetingID] = timeZone.identifier
- UserDefaults.standard.set(meetingTimeZoneOverrides, forKey: meetingTimeZoneOverrideKey)
- }
- private struct ScheduleMeetingDraft {
- let startDate: Date
- let startTimeUTC: String
- let timeZone: TimeZone
- }
- /// Keeps entered local date/time exact for Zoom (`start_time`) while also producing `Date` for validation.
- private func scheduleMeetingDraftFromFormFields() -> ScheduleMeetingDraft? {
- let datePart = scheduleDateField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let timePart = scheduleTimeField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- guard datePart.isEmpty == false, timePart.isEmpty == false else { return nil }
- let tz = resolvedScheduleTimeZoneForMeeting()
- let normalizedTime = timePart.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
- // Parse date strictly as yyyy-MM-dd.
- let dateComponentsRaw = datePart.split(separator: "-")
- guard dateComponentsRaw.count == 3,
- let year = Int(dateComponentsRaw[0]),
- let month = Int(dateComponentsRaw[1]),
- let day = Int(dateComponentsRaw[2]) else {
- return nil
- }
- // Parse time strictly as h:mm AM/PM (optional :ss).
- // This avoids any locale/date formatter ambiguity that can flip AM/PM.
- let pattern = #"^\s*(\d{1,2}):(\d{2})(?::(\d{2}))?\s*([AaPp][Mm])\s*$"#
- guard let regex = try? NSRegularExpression(pattern: pattern) else { return nil }
- let nsRange = NSRange(normalizedTime.startIndex..<normalizedTime.endIndex, in: normalizedTime)
- guard let match = regex.firstMatch(in: normalizedTime, options: [], range: nsRange),
- match.numberOfRanges == 5,
- let hourRange = Range(match.range(at: 1), in: normalizedTime),
- let minuteRange = Range(match.range(at: 2), in: normalizedTime),
- let meridiemRange = Range(match.range(at: 4), in: normalizedTime) else {
- return nil
- }
- let rawHour = Int(normalizedTime[hourRange]) ?? -1
- let minute = Int(normalizedTime[minuteRange]) ?? -1
- let second: Int = {
- if let secondRange = Range(match.range(at: 3), in: normalizedTime) {
- return Int(normalizedTime[secondRange]) ?? -1
- }
- return 0
- }()
- let meridiem = normalizedTime[meridiemRange].uppercased()
- guard (1...12).contains(rawHour),
- (0...59).contains(minute),
- (0...59).contains(second) else {
- return nil
- }
- var hour24 = rawHour % 12
- if meridiem == "PM" { hour24 += 12 }
- var calendar = Calendar(identifier: .gregorian)
- calendar.timeZone = tz
- var components = DateComponents()
- components.year = year
- components.month = month
- components.day = day
- components.hour = hour24
- components.minute = minute
- components.second = second
- components.timeZone = tz
- guard let startDate = calendar.date(from: components) else { return nil }
- let utcFormatter = DateFormatter()
- utcFormatter.locale = Locale(identifier: "en_US_POSIX")
- utcFormatter.timeZone = TimeZone(secondsFromGMT: 0)
- utcFormatter.calendar = Calendar(identifier: .gregorian)
- // Send explicit UTC (`Z`) so Zoom receives an unambiguous instant in time.
- utcFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
- return ScheduleMeetingDraft(
- startDate: startDate,
- startTimeUTC: utcFormatter.string(from: startDate),
- timeZone: tz
- )
- }
- @objc private func joinMeetingTapped() {
- if let existing = joinMeetingWindow {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- DispatchQueue.main.async { [weak self] in
- _ = self?.joinURLField?.becomeFirstResponder()
- }
- return
- }
- let content = makeJoinMeetingPanelContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 520, height: 430),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Join a meeting"
- panel.titleVisibility = .hidden
- panel.titlebarAppearsTransparent = true
- panel.hidesOnDeactivate = true
- panel.isReleasedWhenClosed = false
- panel.standardWindowButton(.closeButton)?.isHidden = true
- panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
- panel.standardWindowButton(.zoomButton)?.isHidden = true
- panel.isMovableByWindowBackground = true
- // Allow the panel to become key immediately so text fields receive keyboard input (avoids "can't type" with floating/key heuristics).
- panel.becomesKeyOnlyIfNeeded = false
- panel.appearance = NSAppearance(named: palette.isDarkMode ? .darkAqua : .aqua)
- panel.center()
- panel.contentViewController = controller
- panel.delegate = self
- applyWindowBackgroundForCurrentTheme(panel)
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- joinMeetingWindow = panel
- DispatchQueue.main.async { [weak self] in
- guard let self else { return }
- _ = self.joinURLField?.becomeFirstResponder()
- }
- }
- private func makeJoinMeetingPanelContent() -> NSView {
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = appBackground.cgColor
- let titleLabel = makeLabel("Join a meeting", size: 18, color: primaryText, weight: .semibold, centered: false)
- let subtitleLabel = makeLabel("Opens in your default web browser", size: 12, color: mutedText, weight: .regular, centered: false)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(joinMeetingCancelTapped))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = .systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Close"
- closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let titleRow = NSStackView()
- titleRow.orientation = .horizontal
- titleRow.alignment = .centerY
- titleRow.spacing = 10
- titleRow.translatesAutoresizingMaskIntoConstraints = false
- let titleStack = NSStackView(views: [titleLabel, subtitleLabel])
- titleStack.orientation = .vertical
- titleStack.spacing = 2
- titleStack.alignment = .leading
- let titleSpacer = NSView()
- titleSpacer.translatesAutoresizingMaskIntoConstraints = false
- titleRow.addArrangedSubview(titleStack)
- titleRow.addArrangedSubview(titleSpacer)
- titleRow.addArrangedSubview(closeButton)
- let headerDivider = NSView()
- headerDivider.wantsLayer = true
- headerDivider.layer?.backgroundColor = palette.inputBorder.cgColor
- headerDivider.translatesAutoresizingMaskIntoConstraints = false
- let mode = NSSegmentedControl(labels: ["Join with URL", "Join with ID"], trackingMode: .selectOne, target: self, action: #selector(joinMeetingModeChanged(_:)))
- mode.segmentStyle = .rounded
- mode.selectedSegment = 0
- mode.translatesAutoresizingMaskIntoConstraints = false
- mode.font = .systemFont(ofSize: 12, weight: .semibold)
- mode.controlSize = .large
- if #available(macOS 11.0, *) {
- mode.selectedSegmentBezelColor = accentBlue
- }
- joinModeSegment = mode
- let urlBox = makeJoinFormField(placeholder: "https://zoom.us/j/… or paste invite link")
- joinURLField = urlBox.textField
- let urlLabel = makeLabel("Meeting link", size: 12, color: secondaryText, weight: .medium, centered: false)
- let urlStack = NSStackView(views: [urlLabel, urlBox])
- urlStack.orientation = .vertical
- urlStack.spacing = 8
- urlStack.alignment = .leading
- urlStack.translatesAutoresizingMaskIntoConstraints = false
- joinURLFieldsContainer = urlStack
- let idBox = makeJoinFormField(placeholder: "Meeting ID (numbers only)")
- let passBox = makeJoinFormField(placeholder: "Passcode (if required)")
- joinMeetingIDField = idBox.textField
- joinPasscodeField = passBox.textField
- let idLabel = makeLabel("Meeting ID", size: 12, color: secondaryText, weight: .medium, centered: false)
- let passLabel = makeLabel("Passcode", size: 12, color: secondaryText, weight: .medium, centered: false)
- let idStack = NSStackView(views: [idLabel, idBox, passLabel, passBox])
- idStack.orientation = .vertical
- idStack.spacing = 8
- idStack.alignment = .leading
- idStack.translatesAutoresizingMaskIntoConstraints = false
- idStack.isHidden = true
- joinIDFieldsContainer = idStack
- let formCard = NSView()
- formCard.translatesAutoresizingMaskIntoConstraints = false
- formCard.wantsLayer = true
- formCard.layer?.backgroundColor = secondaryCardBackground.cgColor
- formCard.layer?.cornerRadius = 14
- formCard.layer?.borderWidth = 1
- formCard.layer?.borderColor = palette.inputBorder.cgColor
- let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(joinMeetingCancelTapped))
- cancelButton.isBordered = false
- cancelButton.wantsLayer = true
- cancelButton.layer?.cornerRadius = 10
- cancelButton.layer?.backgroundColor = palette.inputBackground.cgColor
- cancelButton.layer?.borderWidth = 1
- cancelButton.layer?.borderColor = palette.inputBorder.cgColor
- cancelButton.contentTintColor = primaryText
- cancelButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let joinButton = HoverButton(title: "Join", target: self, action: #selector(joinMeetingSubmitTapped))
- joinButton.isBordered = false
- joinButton.wantsLayer = true
- joinButton.layer?.cornerRadius = 10
- joinButton.normalColor = accentBlue
- joinButton.hoverColor = accentBlue.blended(withFraction: 0.12, of: .white) ?? accentBlue
- joinButton.contentTintColor = .white
- joinButton.font = .systemFont(ofSize: 13, weight: .bold)
- joinButton.keyEquivalent = "\r"
- let buttons = NSStackView(views: [cancelButton, joinButton])
- buttons.orientation = .horizontal
- buttons.spacing = 12
- buttons.alignment = .centerY
- buttons.distribution = .fillEqually
- let innerStack = NSStackView(views: [mode, urlStack, idStack, buttons])
- innerStack.orientation = .vertical
- innerStack.spacing = 18
- innerStack.alignment = .leading
- innerStack.translatesAutoresizingMaskIntoConstraints = false
- formCard.addSubview(innerStack)
- [titleRow, headerDivider, formCard].forEach { root.addSubview($0) }
- NSLayoutConstraint.activate([
- titleRow.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- titleRow.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -16),
- titleRow.topAnchor.constraint(equalTo: root.topAnchor, constant: 18),
- headerDivider.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 14),
- headerDivider.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- headerDivider.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- headerDivider.heightAnchor.constraint(equalToConstant: 1),
- formCard.topAnchor.constraint(equalTo: headerDivider.bottomAnchor, constant: 14),
- formCard.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 20),
- formCard.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -20),
- formCard.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -20),
- innerStack.leadingAnchor.constraint(equalTo: formCard.leadingAnchor, constant: 16),
- innerStack.trailingAnchor.constraint(equalTo: formCard.trailingAnchor, constant: -16),
- innerStack.topAnchor.constraint(equalTo: formCard.topAnchor, constant: 16),
- innerStack.bottomAnchor.constraint(equalTo: formCard.bottomAnchor, constant: -16),
- mode.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- urlBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- idBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- passBox.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- buttons.widthAnchor.constraint(equalTo: innerStack.widthAnchor),
- joinButton.heightAnchor.constraint(equalToConstant: 40),
- cancelButton.heightAnchor.constraint(equalToConstant: 40)
- ])
- return root
- }
- private func makeJoinFormField(placeholder: String) -> JoinPanelFieldContainer {
- JoinPanelFieldContainer(
- placeholder: placeholder,
- normalBorder: palette.inputBorder,
- focusBorder: accentBlue.withAlphaComponent(0.9),
- fill: palette.inputBackground,
- primaryText: primaryText,
- mutedText: mutedText
- )
- }
- @objc private func joinMeetingModeChanged(_ sender: NSSegmentedControl) {
- let urlMode = sender.selectedSegment == 0
- joinURLFieldsContainer?.isHidden = urlMode == false
- joinIDFieldsContainer?.isHidden = urlMode
- }
- @objc private func joinMeetingCancelTapped() {
- joinMeetingWindow?.performClose(nil)
- }
- @objc private func joinMeetingSubmitTapped() {
- guard let segment = joinModeSegment else { return }
- if segment.selectedSegment == 0 {
- let raw = joinURLField?.stringValue ?? ""
- guard let url = parseZoomJoinURLFromUserInput(raw) else {
- showSimpleAlert(title: "Invalid link", message: "Enter a full Zoom meeting link (for example, https://zoom.us/j/…).")
- return
- }
- openZoomMeetingInDefaultBrowser(url)
- } else {
- let idRaw = joinMeetingIDField?.stringValue ?? ""
- let digits = idRaw.filter(\.isNumber)
- guard digits.isEmpty == false else {
- showSimpleAlert(title: "Meeting ID required", message: "Enter the numeric meeting ID.")
- return
- }
- let pass = joinPasscodeField?.stringValue ?? ""
- guard let url = zoomWebClientJoinURL(meetingIdDigits: digits, passcode: pass) else {
- showSimpleAlert(title: "Unable to join", message: "Could not build a join link from that meeting ID.")
- return
- }
- openZoomMeetingInDefaultBrowser(url)
- }
- }
- /// Same web join path used when expanding scheduled meetings (`/wc/join/` + optional `pwd`).
- private func zoomWebClientJoinURL(meetingIdDigits: String, passcode: String) -> URL? {
- guard meetingIdDigits.isEmpty == false else { return nil }
- var components = URLComponents()
- components.scheme = "https"
- components.host = "zoom.us"
- components.path = "/wc/join/\(meetingIdDigits)"
- let trimmed = passcode.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false {
- components.queryItems = [URLQueryItem(name: "pwd", value: trimmed)]
- }
- return components.url
- }
- private func parseZoomJoinURLFromUserInput(_ raw: String) -> URL? {
- let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- guard trimmed.isEmpty == false else { return nil }
- let normalized: String
- if trimmed.lowercased().hasPrefix("http://") || trimmed.lowercased().hasPrefix("https://") {
- normalized = trimmed
- } else {
- normalized = "https://\(trimmed)"
- }
- guard let url = URL(string: normalized), let host = url.host?.lowercased() else { return nil }
- let isZoom = host == "zoom.us" || host.hasSuffix(".zoom.us")
- || host == "zoom.com" || host.hasSuffix(".zoom.com")
- guard isZoom else { return nil }
- return url
- }
- /// Opens non-sign-in web content in the system default browser.
- @MainActor
- private func openWebURLPreferringInApp(_ url: URL) {
- _ = openURLInDefaultBrowser(url)
- }
- private func openZoomMeetingInDefaultBrowser(_ url: URL) {
- let opened = openURLInDefaultBrowser(url)
- if opened {
- joinMeetingWindow?.performClose(nil)
- } else {
- showSimpleAlert(title: "Unable to open", message: "Your default browser could not be opened.")
- }
- }
- @discardableResult
- private func openURLInDefaultBrowser(_ url: URL) -> Bool {
- NSWorkspace.shared.open(url)
- }
- @MainActor
- private func presentURLInEmbeddedRightPanel(_ url: URL) {
- guard let webView = embeddedBrowserWebView else {
- NSWorkspace.shared.open(url)
- return
- }
- updateEmbeddedRightPanelWidthForOpenState()
- webView.load(URLRequest(url: url))
- refreshEmbeddedBrowserNavButtons()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- /// Makes the embedded web panel fill the entire right-side content area (to match the reference behavior).
- @MainActor
- private func updateEmbeddedRightPanelWidthForOpenState() {
- guard let widthConstraint = embeddedBrowserPanelWidthConstraint else { return }
- view.layoutSubtreeIfNeeded()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- var rowW = homeBodyRowView?.bounds.width ?? 0
- if rowW < 1 {
- rowW = view.bounds.width
- }
- // Fill the entire body row so the embedded web experience matches a dedicated right-side panel.
- widthConstraint.constant = max(320, floor(rowW))
- // Collapse the left main content column while web is visible.
- homeMainContentColumnView?.isHidden = true
- embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = true
- }
- @MainActor
- private func refreshEmbeddedBrowserNavButtons() {
- embeddedBrowserBackButton?.isEnabled = embeddedBrowserWebView?.canGoBack == true
- embeddedBrowserForwardButton?.isEnabled = embeddedBrowserWebView?.canGoForward == true
- }
- @objc private func embeddedBrowserBackTapped() {
- embeddedBrowserWebView?.goBack()
- }
- @objc private func embeddedBrowserForwardTapped() {
- embeddedBrowserWebView?.goForward()
- }
- @objc private func embeddedBrowserReloadTapped() {
- embeddedBrowserWebView?.reload()
- }
- @objc private func embeddedBrowserClosePanelTapped() {
- closeEmbeddedBrowserPanel()
- }
- @MainActor
- private func closeEmbeddedBrowserPanel() {
- if let cont = oauthSessionContinuation {
- oauthSessionContinuation = nil
- oauthSessionCallbackPrefix = nil
- cont.resume(throwing: EmbeddedOAuthFlowError.userCancelled)
- }
- embeddedBrowserWebView?.stopLoading()
- embeddedBrowserPanelWidthConstraint?.constant = 0
- embeddedBrowserMainColumnCollapsedWidthConstraint?.isActive = false
- homeMainContentColumnView?.isHidden = false
- refreshEmbeddedBrowserNavButtons()
- view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- func runEmbeddedOAuthSession(startURL: URL, callbackURLPrefix: String, timeoutSeconds: Double) async throws -> URL {
- guard let webView = embeddedBrowserWebView else {
- throw EmbeddedOAuthFlowError.webViewUnavailable
- }
- do {
- return try await withThrowingTaskGroup(of: URL.self) { group in
- group.addTask { @MainActor in
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- self.oauthSessionContinuation = continuation
- self.oauthSessionCallbackPrefix = callbackURLPrefix
- self.updateEmbeddedRightPanelWidthForOpenState()
- webView.load(URLRequest(url: startURL))
- self.refreshEmbeddedBrowserNavButtons()
- }
- }
- group.addTask {
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
- await MainActor.run {
- if let cont = self.oauthSessionContinuation {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- webView.stopLoading()
- cont.resume(throwing: EmbeddedOAuthFlowError.timedOut)
- }
- }
- throw EmbeddedOAuthFlowError.timedOut
- }
- let url = try await group.next()!
- group.cancelAll()
- await MainActor.run {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- self.closeEmbeddedBrowserPanel()
- webView.load(URLRequest(url: URL(string: "about:blank")!))
- }
- return url
- }
- } catch {
- await MainActor.run {
- self.oauthSessionContinuation = nil
- self.oauthSessionCallbackPrefix = nil
- webView.stopLoading()
- self.embeddedBrowserPanelWidthConstraint?.constant = 0
- self.refreshEmbeddedBrowserNavButtons()
- webView.load(URLRequest(url: URL(string: "about:blank")!))
- self.view.window?.contentView?.layoutSubtreeIfNeeded()
- }
- throw error
- }
- }
- private func resetJoinMeetingPanelReferences() {
- joinMeetingWindow = nil
- joinURLField = nil
- joinMeetingIDField = nil
- joinPasscodeField = nil
- joinURLFieldsContainer = nil
- joinIDFieldsContainer = nil
- joinModeSegment = nil
- }
- @objc private func logoutTapped() {
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- clearMeetingsScrollObserver()
- googleOAuth.clearSavedTokens()
- zoomOAuth.clearSavedTokens()
- persistLoggedInState(false)
- allScheduledMeetings = []
- applyAuthenticationUIState(triggerInitialRefresh: false)
- Task { @MainActor in
- self.applyMeetings([])
- }
- }
- @objc private func topBarAuthButtonTapped() {
- if isUserLoggedIn() {
- showProfileMenu()
- } else {
- zoomPrimarySignInTapped()
- }
- }
- @objc private func profileMenuLogoutTapped() {
- profileMenuPopover?.performClose(nil)
- logoutTapped()
- }
- private func showProfileMenu() {
- guard let button = topBarAuthButton else { return }
- // Recreate so the popover always matches current light/dark mode.
- profileMenuPopover = makeProfileMenuPopover()
- guard let popover = profileMenuPopover else { return }
- if popover.isShown {
- popover.performClose(nil)
- return
- }
- popover.show(relativeTo: button.bounds, of: button, preferredEdge: .maxY)
- }
- private func makeProfileMenuPopover() -> NSPopover {
- let popover = NSPopover()
- popover.behavior = .transient
- popover.animates = true
- let contentController = NSViewController()
- let root = NSView(frame: NSRect(x: 0, y: 0, width: 198, height: 86))
- root.wantsLayer = true
- root.layer?.cornerRadius = 12
- root.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor(calibratedRed: 30 / 255, green: 33 / 255, blue: 40 / 255, alpha: 1)
- : NSColor(calibratedRed: 246 / 255, green: 247 / 255, blue: 250 / 255, alpha: 1)
- ).cgColor
- root.layer?.borderWidth = 1
- root.layer?.borderColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.08)
- ).cgColor
- let logoutButton = NSButton(title: "Logout", target: self, action: #selector(profileMenuLogoutTapped))
- logoutButton.translatesAutoresizingMaskIntoConstraints = false
- logoutButton.isBordered = false
- logoutButton.wantsLayer = true
- logoutButton.layer?.cornerRadius = 9
- logoutButton.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor(calibratedWhite: 0.0, alpha: 0.06)
- ).cgColor
- logoutButton.contentTintColor = (palette.isDarkMode
- ? .white
- : NSColor(calibratedRed: 22 / 255, green: 24 / 255, blue: 29 / 255, alpha: 1)
- )
- logoutButton.font = .systemFont(ofSize: 13, weight: .semibold)
- logoutButton.alignment = .left
- logoutButton.imagePosition = .imageLeading
- logoutButton.imageHugsTitle = true
- logoutButton.bezelStyle = .shadowlessSquare
- logoutButton.title = " Logout"
- if let symbol = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: "Logout") {
- logoutButton.image = symbol.withSymbolConfiguration(.init(pointSize: 13, weight: .semibold))
- }
- let subtitle = makeLabel(
- "Account",
- size: 11,
- color: palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.72)
- : NSColor(calibratedRed: 88 / 255, green: 92 / 255, blue: 102 / 255, alpha: 1),
- weight: .medium,
- centered: false
- )
- subtitle.translatesAutoresizingMaskIntoConstraints = false
- root.addSubview(subtitle)
- root.addSubview(logoutButton)
- NSLayoutConstraint.activate([
- subtitle.topAnchor.constraint(equalTo: root.topAnchor, constant: 10),
- subtitle.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 12),
- subtitle.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -12),
- logoutButton.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 8),
- logoutButton.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 10),
- logoutButton.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -10),
- logoutButton.heightAnchor.constraint(equalToConstant: 34)
- ])
- contentController.view = root
- popover.contentViewController = contentController
- popover.contentSize = root.frame.size
- return popover
- }
-
- @objc private func topBarPlaceholderTapped() {
- // Reserved for future titlebar control actions.
- }
- @objc private func upgradeToProTapped() {
- if storeKitCoordinator.hasLifetimePremiumAccess {
- openManageSubscriptions()
- } else {
- showPaywall()
- }
- }
- private func openManageSubscriptions() {
- if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
- openWebURLPreferringInApp(url)
- }
- }
- @objc private func refreshMeetingsTapped() {
- guard isUserLoggedIn() else {
- applyGuestLoginMessages()
- return
- }
- guard ensurePremiumAccess() else {
- applyPremiumLockMessages()
- return
- }
- Task { @MainActor in
- self.animateMeetingsRefresh()
- self.setMeetingsLoadingUI(true)
- }
- triggerMeetingsRefresh(force: true)
- }
- @MainActor
- private func setMeetingsLoadingUI(_ loading: Bool) {
- refreshMeetingsButton?.isEnabled = loading == false
- meetingsPageRefreshButton?.isEnabled = loading == false
- if loading {
- meetingsPageStatusLabel?.stringValue = "Refreshing meetings..."
- }
- }
- @MainActor
- private func animateMeetingsRefresh() {
- guard let stack = meetingsListStack, stack.arrangedSubviews.isEmpty == false else { return }
- let views = stack.arrangedSubviews
- let dimmed: CGFloat = 0.86
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.07
- context.timingFunction = CAMediaTimingFunction(name: .linear)
- views.forEach { $0.animator().alphaValue = dimmed }
- } completionHandler: {
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.08
- context.timingFunction = CAMediaTimingFunction(name: .linear)
- views.forEach { $0.animator().alphaValue = 1.0 }
- }
- }
- }
- private func startMeetingsAutoRefresh() {
- guard isUserLoggedIn() else { return }
- meetingsRefreshTimer?.invalidate()
- // Poll Zoom meetings periodically so newly scheduled meetings appear automatically.
- meetingsRefreshTimer = Timer.scheduledTimer(withTimeInterval: meetingsRefreshInterval, repeats: true) { [weak self] _ in
- guard let self else { return }
- self.triggerMeetingsRefresh()
- }
- }
- private func triggerMeetingsRefresh(force: Bool = false) {
- guard isUserLoggedIn() else { return }
- guard storeKitCoordinator.hasPremiumAccess else { return }
- let now = Date()
- if let until = meetingsRateLimitedUntil, now < until {
- return
- }
- if force == false, now.timeIntervalSince(lastMeetingsRefreshAt) < meetingsRefreshInterval {
- return
- }
- lastMeetingsRefreshAt = now
- Task { await self.loadScheduledMeetings() }
- }
- private func clearMeetingsScrollObserver() {
- if let meetingsScrollObserver {
- NotificationCenter.default.removeObserver(meetingsScrollObserver)
- }
- meetingsScrollObserver = nil
- meetingsScrollView?.contentView.postsBoundsChangedNotifications = false
- meetingsScrollView = nil
- }
- private func clearMeetingsPageScrollObserver() {
- if let meetingsPageScrollObserver {
- NotificationCenter.default.removeObserver(meetingsPageScrollObserver)
- }
- meetingsPageScrollObserver = nil
- meetingsPageScrollView?.contentView.postsBoundsChangedNotifications = false
- meetingsPageScrollView = nil
- }
- private func removeSearchFieldObserver() {
- if let searchTextObserver {
- NotificationCenter.default.removeObserver(searchTextObserver)
- }
- searchTextObserver = nil
- }
- private func removeMeetingSearchObserver() {
- if let meetingSearchTextObserver {
- NotificationCenter.default.removeObserver(meetingSearchTextObserver)
- }
- meetingSearchTextObserver = nil
- }
- private func installSearchShortcutMonitor() {
- removeSearchShortcutMonitor()
- searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
- guard let self else { return event }
- if event.keyCode == 53, self.meetingSearchOverlayView?.isHidden == false {
- DispatchQueue.main.async {
- self.hideMeetingSearchPopup()
- }
- return nil
- }
- guard event.modifierFlags.contains(.command),
- event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
- guard self.homeSearchPill != nil else { return event }
- DispatchQueue.main.async {
- self.showMeetingSearchPopup(seedQuery: self.meetingSearchField?.stringValue ?? "")
- }
- return nil
- }
- searchOutsideClickMonitor = NSEvent.addLocalMonitorForEvents(matching: .leftMouseDown) { [weak self] event in
- guard let self else { return event }
- guard let window = self.view.window, event.window === window else { return event }
- guard self.meetingSearchOverlayView?.isHidden == false else { return event }
- guard let panel = self.meetingSearchPanelView else { return event }
- let rectInWindow = panel.convert(panel.bounds, to: nil)
- if rectInWindow.contains(event.locationInWindow) == false {
- DispatchQueue.main.async {
- self.hideMeetingSearchPopup()
- }
- }
- return event
- }
- }
- private func removeSearchShortcutMonitor() {
- if let searchShortcutMonitor {
- NSEvent.removeMonitor(searchShortcutMonitor)
- }
- searchShortcutMonitor = nil
- if let searchOutsideClickMonitor {
- NSEvent.removeMonitor(searchOutsideClickMonitor)
- }
- searchOutsideClickMonitor = nil
- }
- private func isSearchFieldActive(_ field: NSTextField, in window: NSWindow) -> Bool {
- guard let fr = window.firstResponder else { return false }
- if fr === field { return true }
- if let editor = field.currentEditor(), fr === editor { return true }
- return false
- }
- @MainActor
- private func applySearchPillFocusBorder(focused: Bool) {
- homeSearchPill?.layer?.borderWidth = focused ? 1.5 : 0
- homeSearchPill?.layer?.borderColor = accentBlue.cgColor
- }
- @MainActor
- private func focusHomeSearchField() {
- guard let field = homeSearchField else { return }
- view.window?.makeFirstResponder(field)
- }
- @MainActor
- private func showMeetingSearchPopup(seedQuery: String) {
- guard let overlay = meetingSearchOverlayView else { return }
- overlay.isHidden = false
- overlay.alphaValue = 0
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- overlay.animator().alphaValue = 1
- }
- if let field = meetingSearchField {
- field.stringValue = seedQuery
- view.window?.makeFirstResponder(field)
- }
- applyGlobalMeetingSearch(query: seedQuery)
- }
- @MainActor
- private func hideMeetingSearchPopup() {
- guard let overlay = meetingSearchOverlayView else { return }
- overlay.isHidden = true
- overlay.alphaValue = 1
- view.window?.makeFirstResponder(nil)
- }
- private func applyGlobalMeetingSearch(query: String) {
- let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let now = Date()
- let dateFormatter = DateFormatter()
- dateFormatter.dateStyle = .medium
- dateFormatter.timeStyle = .short
- let filtered: [ScheduledMeeting]
- if trimmed.isEmpty {
- filtered = allScheduledMeetings
- } else {
- filtered = allScheduledMeetings.filter { meeting in
- let dateText = dateFormatter.string(from: meeting.start).lowercased()
- return meeting.title.lowercased().contains(trimmed)
- || meeting.host.lowercased().contains(trimmed)
- || meeting.source.lowercased().contains(trimmed)
- || (meeting.meetingID?.lowercased().contains(trimmed) ?? false)
- || dateText.contains(trimmed)
- }
- }
- globalSearchResults = filtered.sorted { lhs, rhs in
- let lhsIsUpcoming = lhs.start >= now
- let rhsIsUpcoming = rhs.start >= now
- if lhsIsUpcoming != rhsIsUpcoming {
- return lhsIsUpcoming && !rhsIsUpcoming
- }
- if lhsIsUpcoming {
- return lhs.start < rhs.start
- }
- return lhs.start > rhs.start
- }
- renderMeetingSearchResults()
- }
- private func renderMeetingSearchResults() {
- guard let stack = meetingSearchResultsStack else { return }
- stack.arrangedSubviews.forEach { row in
- stack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- meetingSearchEmptyLabel?.isHidden = globalSearchResults.isEmpty == false
- for (index, meeting) in globalSearchResults.enumerated() {
- let row = makeMeetingSearchResultRow(meeting)
- row.tag = index
- stack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- private func makeMeetingSearchResultRow(_ meeting: ScheduledMeeting) -> NSButton {
- let row = NSButton(title: "", target: self, action: #selector(meetingSearchResultTapped(_:)))
- row.isBordered = false
- row.bezelStyle = .regularSquare
- row.setButtonType(.momentaryChange)
- row.wantsLayer = true
- row.layer?.backgroundColor = meetingCardBackground.cgColor
- row.layer?.cornerRadius = 10
- row.layer?.borderWidth = 1
- row.layer?.borderColor = palette.inputBorder.cgColor
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 84).isActive = true
- let title = makeLabel(meeting.title, size: 14, color: primaryText, weight: .semibold, centered: false)
- title.lineBreakMode = .byTruncatingTail
- let subtitle = makeLabel(compactMeetingSummary(meeting), size: 12, color: secondaryText, weight: .regular, centered: false)
- subtitle.lineBreakMode = .byTruncatingTail
- let meta = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: mutedText, weight: .regular, centered: false)
- meta.lineBreakMode = .byTruncatingTail
- [title, subtitle, meta].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview($0)
- }
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
- title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 12),
- title.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -12),
- subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- subtitle.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- meta.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 4),
- meta.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- meta.trailingAnchor.constraint(equalTo: title.trailingAnchor)
- ])
- return row
- }
- private func compactMeetingSummary(_ meeting: ScheduledMeeting) -> String {
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEE, MMM d"
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
- dateFormatter.timeZone = displayTimeZone
- timeFormatter.timeZone = displayTimeZone
- let start = timeFormatter.string(from: meeting.start)
- let end = meeting.end.map { timeFormatter.string(from: $0) }
- let range = end.map { "\(start) - \($0)" } ?? start
- if let id = meeting.meetingID, id.isEmpty == false {
- return "\(dateFormatter.string(from: meeting.start)) • \(range) • ID \(id)"
- }
- return "\(dateFormatter.string(from: meeting.start)) • \(range)"
- }
- @objc private func topSearchPillTapped() {
- showMeetingSearchPopup(seedQuery: meetingSearchField?.stringValue ?? "")
- }
- @objc private func meetingSearchResultTapped(_ sender: NSButton) {
- guard let meeting = globalSearchResults[safe: sender.tag] else { return }
- if let url = meeting.webURL {
- openWebURLPreferringInApp(url)
- } else {
- let pasteboard = NSPasteboard.general
- pasteboard.clearContents()
- if let id = meeting.meetingID, id.isEmpty == false {
- pasteboard.setString(id, forType: .string)
- } else {
- pasteboard.setString(meeting.title, forType: .string)
- }
- }
- hideMeetingSearchPopup()
- }
- private func observeMeetingsScrollEdges(in scrollView: NSScrollView) {
- clearMeetingsScrollObserver()
- meetingsScrollView = scrollView
- scrollView.contentView.postsBoundsChangedNotifications = true
- meetingsScrollObserver = NotificationCenter.default.addObserver(
- forName: NSView.boundsDidChangeNotification,
- object: scrollView.contentView,
- queue: .main
- ) { [weak self, weak scrollView] _ in
- guard let self, let scrollView else { return }
- self.refreshMeetingsIfScrolledToEdge(scrollView)
- }
- }
- private func refreshMeetingsIfScrolledToEdge(_ scrollView: NSScrollView) {
- guard let documentView = scrollView.documentView else { return }
- let visibleRect = scrollView.contentView.bounds
- let contentHeight = documentView.bounds.height
- let viewportHeight = visibleRect.height
- if contentHeight <= viewportHeight + 2 {
- return
- }
- let maxOffset = max(contentHeight - viewportHeight, 0)
- let y = visibleRect.origin.y
- let threshold: CGFloat = 12
- let reachedTop = y <= threshold
- let reachedBottom = y >= (maxOffset - threshold)
- guard reachedTop || reachedBottom else { return }
- let now = Date()
- if now.timeIntervalSince(lastScrollEdgeRefreshAt) < scrollRefreshCooldown {
- return
- }
- lastScrollEdgeRefreshAt = now
- triggerMeetingsRefresh(force: true)
- }
- private func observeMeetingsPageInfiniteScroll(in scrollView: NSScrollView) {
- clearMeetingsPageScrollObserver()
- meetingsPageScrollView = scrollView
- scrollView.contentView.postsBoundsChangedNotifications = true
- meetingsPageScrollObserver = NotificationCenter.default.addObserver(
- forName: NSView.boundsDidChangeNotification,
- object: scrollView.contentView,
- queue: .main
- ) { [weak self, weak scrollView] _ in
- guard let self, let scrollView else { return }
- self.loadMoreMeetingsIfNeededForScroll(scrollView)
- }
- }
- private func loadMoreMeetingsIfNeededForScroll(_ scrollView: NSScrollView) {
- guard meetingsPageVisibleCount < meetingsPageFilteredMeetings.count else { return }
- guard let documentView = scrollView.documentView else { return }
- let visibleRect = scrollView.contentView.bounds
- let contentHeight = documentView.bounds.height
- let viewportHeight = visibleRect.height
- guard contentHeight > 0, viewportHeight > 0 else { return }
- let maxOffset = max(contentHeight - viewportHeight, 0)
- let y = visibleRect.origin.y
- let threshold: CGFloat = 72
- guard y >= (maxOffset - threshold) else { return }
- let now = Date()
- guard now.timeIntervalSince(lastMeetingsPageAutoLoadAt) >= meetingsPageAutoLoadCooldown else { return }
- lastMeetingsPageAutoLoadAt = now
- let nextCount = meetingsPageVisibleCount + meetingsPageSize
- meetingsPageVisibleCount = min(nextCount, meetingsPageFilteredMeetings.count)
- renderMeetingsPageList()
- }
- @MainActor
- private func resetLoginSigningInState() {
- isSigningIn = false
- googleButton?.title = "G"
- updateTopBarAuthButton()
- googleButton?.isEnabled = true
- }
- @MainActor
- private func completeLoginAndRestart(profile: GoogleUserProfile?) {
- persistLoggedInState(true)
- showHomeView(profile: profile)
- if let window = view.window {
- window.makeKeyAndOrderFront(nil)
- }
- NSApp.activate(ignoringOtherApps: true)
- }
- @MainActor
- private func updateTopBarAuthButton() {
- guard let button = topBarAuthButton else { return }
- let loggedIn = isUserLoggedIn()
- if loggedIn {
- button.title = topBarProfileInitial
- button.toolTip = "Profile"
- button.font = .systemFont(ofSize: 14, weight: .bold)
- button.contentTintColor = primaryText
- button.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
- button.layer?.cornerRadius = 10
- topBarAuthButtonWidthConstraint?.constant = 34
- } else {
- button.title = "Sign in with Zoom"
- button.toolTip = "Sign in with Zoom"
- button.font = .systemFont(ofSize: 12, weight: .semibold)
- button.contentTintColor = .white
- button.layer?.backgroundColor = accentBlue.cgColor
- button.layer?.cornerRadius = 11
- topBarAuthButtonWidthConstraint?.constant = 120
- }
- button.isEnabled = isSigningIn == false
- }
- @MainActor
- private func applyGuestLoginMessages() {
- let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
- let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
- let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
- meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
- emptyMeetingLabel?.isHidden = isHome == false
- emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
- meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
- meetingsPageEmptyLabel?.isHidden = isMeetings == false
- meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
- meetingsPageCountLabel?.stringValue = "0 scheduled"
- schedulerMeetingsEmptyLabel?.isHidden = isScheduler == false
- schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
- }
- @MainActor
- private func applyPremiumLockMessages() {
- // Keep underlying section labels hidden while locked to avoid duplicate text beneath overlays.
- meetingsStatusLabel?.stringValue = ""
- emptyMeetingLabel?.isHidden = true
- meetingsPageStatusLabel?.stringValue = ""
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageCountLabel?.stringValue = ""
- schedulerMeetingsEmptyLabel?.isHidden = true
- }
- private func ensurePremiumAccess() -> Bool {
- guard storeKitCoordinator.hasPremiumAccess else {
- showPaywall()
- return false
- }
- return true
- }
- @MainActor
- private func applyAuthenticationUIState(triggerInitialRefresh: Bool) {
- let loggedIn = isUserLoggedIn()
- updateTopBarAuthButton()
- settingsGoogleActionButton?.title = loggedIn ? "Sign Out" : "Sign in with Zoom"
- settingsGoogleActionButton?.isEnabled = true
- updatePremiumLockedSections()
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- meetingsRateLimitedUntil = nil
- if loggedIn {
- if storeKitCoordinator.hasPremiumAccess {
- startMeetingsAutoRefresh()
- if triggerInitialRefresh {
- triggerMeetingsRefresh(force: true)
- }
- } else {
- applyMeetings([])
- applyPremiumLockMessages()
- }
- } else {
- applyGuestLoginMessages()
- }
- }
- /// Returns false if the user cancelled or left credentials empty.
- @MainActor
- private func ensureZoomOAuthClientConfigured() -> Bool {
- if zoomOAuth.configuredClientId() != nil, zoomOAuth.configuredClientSecret() != nil {
- return true
- }
- return presentZoomOAuthCredentialPrompt()
- }
- private func showSimpleError(_ title: String, error: Error) {
- let alert = NSAlert()
- alert.alertStyle = .warning
- alert.messageText = title
- alert.informativeText = error.localizedDescription
- alert.runModal()
- }
- private func showSimpleAlert(title: String, message: String) {
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = title
- alert.informativeText = message
- alert.runModal()
- }
- private struct ScheduledMeeting {
- let title: String
- let start: Date
- let end: Date?
- let meetingTimeZone: TimeZone?
- let host: String
- let source: String
- let meetingID: String?
- let typeLabel: String
- let statusLabel: String
- let webURL: URL?
- }
- @MainActor
- private func applyMeetings(_ meetings: [ScheduledMeeting]) {
- allScheduledMeetings = meetings
- if meetings.isEmpty == false {
- let calendar = Calendar.current
- let selectedStart = calendar.startOfDay(for: selectedMeetingsDayStart)
- let selectedEnd = calendar.date(byAdding: .day, value: 1, to: selectedStart) ?? selectedStart.addingTimeInterval(60 * 60 * 24)
- let selectedDayHasMeetings = meetings.contains { meeting in
- meeting.start >= selectedStart && meeting.start < selectedEnd
- }
- if selectedDayHasMeetings == false,
- let firstUpcoming = meetings
- .filter({ $0.start >= Date() })
- .sorted(by: { $0.start < $1.start })
- .first {
- selectedMeetingsDayStart = calendar.startOfDay(for: firstUpcoming.start)
- }
- }
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: true)
- updateWidgetMeetingStoreFromCurrentMeetings()
- }
- @MainActor
- private func applyFilteredMeetings() {
- guard let stack = meetingsListStack else { return }
- let showingHomeSection = selectedHomeSidebarItem == HomeSection.home.rawValue
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- meetingsStatusLabel?.stringValue = "Sign in with Zoom to load meetings."
- emptyMeetingLabel?.isHidden = showingHomeSection == false
- emptyMeetingLabel?.stringValue = "Please sign in with Zoom to view your scheduled meetings."
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsStatusLabel?.stringValue = ""
- emptyMeetingLabel?.isHidden = true
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- let query = (homeSearchField?.stringValue ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
- let calendar = Calendar.current
- let dayStart = selectedMeetingsDayStart
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? dayStart.addingTimeInterval(60 * 60 * 24)
- let source = allScheduledMeetings.filter { meeting in
- meeting.start >= dayStart && meeting.start < dayEnd
- }
- let filtered: [ScheduledMeeting]
- if query.isEmpty {
- filtered = source
- } else {
- filtered = source.filter { meeting in
- meeting.title.lowercased().contains(query)
- || meeting.host.lowercased().contains(query)
- || meeting.source.lowercased().contains(query)
- }
- }
- let ordered = filtered.sorted(by: { $0.start < $1.start })
- if ordered.isEmpty {
- emptyMeetingLabel?.isHidden = showingHomeSection == false
- if source.isEmpty {
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- emptyMeetingLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: dayStart))."
- } else {
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- emptyMeetingLabel?.stringValue = "No meetings match your search."
- }
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- return
- }
- emptyMeetingLabel?.isHidden = true
- meetingsStatusLabel?.stringValue = "Upcoming meetings"
- for meeting in ordered {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- // Make each meeting card span the full list width (like Zoom).
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- @MainActor
- private func applyMeetingsPageFilters(resetVisible: Bool) {
- guard isUserLoggedIn() else {
- meetingsPageFilteredMeetings = []
- meetingsPageVisibleCount = 0
- renderMeetingsPageList()
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsPageFilteredMeetings = []
- meetingsPageVisibleCount = 0
- renderMeetingsPageList()
- return
- }
- let calendar = Calendar.current
- let now = Date()
- let activePreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? meetingsPageSelectedRangePreset
- meetingsPageSelectedRangePreset = activePreset
- let activeFromDate: Date?
- let activeToDate: Date?
- if activePreset == "Custom range",
- let fromPicker = meetingsPageFromDatePicker,
- let toPicker = meetingsPageToDatePicker {
- let fromDay = calendar.startOfDay(for: fromPicker.dateValue)
- let toDay = calendar.startOfDay(for: toPicker.dateValue)
- if fromDay <= toDay {
- activeFromDate = fromDay
- activeToDate = toDay
- } else {
- activeFromDate = toDay
- activeToDate = fromDay
- }
- meetingsPageFromDateFilter = activeFromDate
- meetingsPageToDateFilter = activeToDate
- } else {
- activeFromDate = meetingsPageFromDateFilter
- activeToDate = meetingsPageToDateFilter
- }
- var filtered = allScheduledMeetings.filter { meeting in
- guard meeting.start >= now else { return false }
- switch activePreset {
- case "Today":
- let dayStart = calendar.startOfDay(for: now)
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: dayStart) ?? now
- return meeting.start >= now && meeting.start < dayEnd
- case "This week":
- let weekInterval = calendar.dateInterval(of: .weekOfYear, for: now)
- let weekEnd = weekInterval?.end ?? now
- return meeting.start >= now && meeting.start < weekEnd
- case "This month":
- let monthInterval = calendar.dateInterval(of: .month, for: now)
- let monthEnd = monthInterval?.end ?? now
- return meeting.start >= now && meeting.start < monthEnd
- case "Custom range":
- if let fromDate = activeFromDate {
- let startOfFrom = calendar.startOfDay(for: fromDate)
- if meeting.start < startOfFrom { return false }
- }
- if let toDate = activeToDate {
- let startOfTo = calendar.startOfDay(for: toDate)
- let endOfTo = calendar.date(byAdding: .day, value: 1, to: startOfTo) ?? toDate
- if meeting.start >= endOfTo { return false }
- }
- return true
- default:
- return true
- }
- }
- filtered.sort { $0.start < $1.start }
- meetingsPageFilteredMeetings = filtered
- if resetVisible {
- meetingsPageVisibleCount = min(meetingsPageSize, filtered.count)
- } else if filtered.count < meetingsPageVisibleCount {
- meetingsPageVisibleCount = filtered.count
- }
- renderMeetingsPageList()
- }
- @MainActor
- private func renderMeetingsPageList() {
- guard let stack = meetingsPageListStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- meetingsPageCountLabel?.stringValue = "0 scheduled"
- meetingsPageStatusLabel?.stringValue = "Sign in with Zoom to view meetings."
- meetingsPageEmptyLabel?.isHidden = false
- meetingsPageEmptyLabel?.stringValue = "Please sign in with Zoom to view your meetings."
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- meetingsPageCountLabel?.stringValue = ""
- meetingsPageStatusLabel?.stringValue = ""
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- let totalCount = meetingsPageFilteredMeetings.count
- let visibleCount = min(meetingsPageVisibleCount, totalCount)
- meetingsPageCountLabel?.stringValue = "\(totalCount) scheduled"
- if totalCount == 0 {
- meetingsPageStatusLabel?.stringValue = "Showing 0 meetings"
- meetingsPageEmptyLabel?.isHidden = false
- meetingsPageLoadMoreButton?.isHidden = true
- return
- }
- meetingsPageEmptyLabel?.isHidden = true
- meetingsPageStatusLabel?.stringValue = "Showing \(visibleCount) of \(totalCount) meetings"
- for meeting in meetingsPageFilteredMeetings.prefix(visibleCount) {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- meetingsPageLoadMoreButton?.isHidden = true
- }
- @objc private func meetingsPageApplyFiltersTapped() {
- let selectedPreset = meetingsPageCategoryFilter?.titleOfSelectedItem ?? "All"
- meetingsPageSelectedRangePreset = selectedPreset
- updateMeetingsDatePickersEnabledState()
- if selectedPreset == "Custom range",
- let fromPicker = meetingsPageFromDatePicker, let toPicker = meetingsPageToDatePicker {
- let fromDay = Calendar.current.startOfDay(for: fromPicker.dateValue)
- let toDay = Calendar.current.startOfDay(for: toPicker.dateValue)
- if fromDay <= toDay {
- meetingsPageFromDateFilter = fromDay
- meetingsPageToDateFilter = toDay
- } else {
- meetingsPageFromDateFilter = toDay
- meetingsPageToDateFilter = fromDay
- }
- } else {
- meetingsPageFromDateFilter = nil
- meetingsPageToDateFilter = nil
- }
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- @objc private func meetingsPageResetFiltersTapped() {
- meetingsPageSelectedRangePreset = "All"
- meetingsPageFromDateFilter = nil
- meetingsPageToDateFilter = nil
- meetingsPageCategoryFilter?.selectItem(withTitle: "All")
- meetingsPageFromDatePicker?.dateValue = Date()
- meetingsPageToDatePicker?.dateValue = Date()
- updateMeetingsDatePickersEnabledState()
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- @objc private func meetingsPageRangePresetChanged(_ sender: NSPopUpButton) {
- meetingsPageSelectedRangePreset = sender.titleOfSelectedItem ?? "All"
- updateMeetingsDatePickersEnabledState()
- Task { @MainActor in
- self.applyMeetingsPageFilters(resetVisible: true)
- }
- }
- private func updateMeetingsDatePickersEnabledState() {
- let isCustomRange = meetingsPageSelectedRangePreset == "Custom range"
- meetingsPageFromDatePicker?.isEnabled = isCustomRange
- meetingsPageToDatePicker?.isEnabled = isCustomRange
- meetingsPageFromDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- meetingsPageToDatePicker?.alphaValue = isCustomRange ? 1.0 : 0.55
- }
- private func meetingsForDay(_ dayStart: Date) -> [ScheduledMeeting] {
- let calendar = Calendar.current
- let safeDayStart = calendar.startOfDay(for: dayStart)
- let dayEnd = calendar.date(byAdding: .day, value: 1, to: safeDayStart) ?? safeDayStart.addingTimeInterval(60 * 60 * 24)
- return allScheduledMeetings
- .filter { meeting in
- meeting.start >= safeDayStart && meeting.start < dayEnd
- }
- .sorted(by: { $0.start < $1.start })
- }
- @MainActor
- private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
- let calendar = Calendar.current
- selectedMeetingsDayStart = calendar.startOfDay(for: newDayStart)
- let monthComponents = calendar.dateComponents([.year, .month], from: selectedMeetingsDayStart)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? selectedMeetingsDayStart
- updateMeetingsDayUI()
- applyFilteredMeetings()
- }
- @MainActor
- private func updateMeetingsDayUI() {
- let dayStart = selectedMeetingsDayStart
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMM d"
- meetingsDayHeaderLabel?.stringValue = formatter.string(from: dayStart)
- let isToday = Calendar.current.isDate(dayStart, inSameDayAs: Date())
- meetingsTodayButton?.isEnabled = isToday == false
- meetingsTodayButton?.alphaValue = isToday ? 0.55 : 1.0
- schedulerTodayButton?.isEnabled = isToday == false
- schedulerTodayButton?.alphaValue = isToday ? 0.55 : 1.0
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- private func meetingsDayDisplayName(for dayStart: Date) -> String {
- let calendar = Calendar.current
- if calendar.isDate(dayStart, inSameDayAs: Date()) { return "today" }
- if let tomorrow = calendar.date(byAdding: .day, value: 1, to: calendar.startOfDay(for: Date())),
- calendar.isDate(dayStart, inSameDayAs: tomorrow) { return "tomorrow" }
- if let yesterday = calendar.date(byAdding: .day, value: -1, to: calendar.startOfDay(for: Date())),
- calendar.isDate(dayStart, inSameDayAs: yesterday) { return "yesterday" }
- let formatter = DateFormatter()
- formatter.dateFormat = "EEEE, MMM d"
- return formatter.string(from: dayStart)
- }
- @objc private func meetingsPrevDayTapped() {
- let calendar = Calendar.current
- let prev = calendar.date(byAdding: .day, value: -1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(-60 * 60 * 24)
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(prev)
- }
- }
- @objc private func meetingsNextDayTapped() {
- let calendar = Calendar.current
- let next = calendar.date(byAdding: .day, value: 1, to: selectedMeetingsDayStart) ?? selectedMeetingsDayStart.addingTimeInterval(60 * 60 * 24)
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(next)
- }
- }
- @objc private func meetingsTodayTapped() {
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(Date())
- }
- }
- private func updateSchedulerDateHeader() {
- let formatter = DateFormatter()
- formatter.dateFormat = "MMMM yyyy"
- schedulerMonthLabel?.stringValue = formatter.string(from: schedulerVisibleMonthStart)
- let selectedFormatter = DateFormatter()
- selectedFormatter.dateFormat = "EEEE, MMM d"
- schedulerSelectedDateLabel?.stringValue = selectedFormatter.string(from: selectedMeetingsDayStart)
- }
- @MainActor
- private func applySchedulerMeetingsForSelectedDate() {
- guard let stack = schedulerMeetingsListStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- guard isUserLoggedIn() else {
- schedulerMeetingsEmptyLabel?.isHidden = false
- schedulerMeetingsEmptyLabel?.stringValue = "Please sign in with Zoom to use Scheduler."
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- schedulerMeetingsEmptyLabel?.isHidden = true
- return
- }
- let meetings = meetingsForDay(selectedMeetingsDayStart)
- if meetings.isEmpty {
- schedulerMeetingsEmptyLabel?.isHidden = false
- schedulerMeetingsEmptyLabel?.stringValue = "No meetings scheduled for \(meetingsDayDisplayName(for: selectedMeetingsDayStart))."
- return
- }
- schedulerMeetingsEmptyLabel?.isHidden = true
- for meeting in meetings {
- let card = makeMeetingRowCard(meeting)
- stack.addArrangedSubview(card)
- if card.constraints.contains(where: { $0.firstAttribute == .width }) == false {
- card.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- }
- }
- }
- @MainActor
- private func renderSchedulerCalendarGrid() {
- guard let gridStack = schedulerCalendarGridStack else { return }
- schedulerDayButtons = [:]
- schedulerDateByButtonTag = [:]
- nextSchedulerButtonTag = 1
- gridStack.arrangedSubviews.forEach { row in
- gridStack.removeArrangedSubview(row)
- row.removeFromSuperview()
- }
- let calendar = Calendar.current
- let monthStart = schedulerVisibleMonthStart
- let monthRange = calendar.range(of: .day, in: .month, for: monthStart) ?? 1..<32
- let monthWeekday = calendar.component(.weekday, from: monthStart)
- let leadingSlots = monthWeekday - 1
- let totalSlots = 42
- for week in 0..<6 {
- let row = NSStackView()
- row.orientation = .horizontal
- row.distribution = .fillEqually
- row.spacing = 6
- row.translatesAutoresizingMaskIntoConstraints = false
- gridStack.addArrangedSubview(row)
- row.widthAnchor.constraint(equalTo: gridStack.widthAnchor).isActive = true
- for weekday in 0..<7 {
- let cellIndex = (week * 7) + weekday
- let dayOffset = cellIndex - leadingSlots
- let dayDate = calendar.date(byAdding: .day, value: dayOffset, to: monthStart) ?? monthStart
- let dayNumber = calendar.component(.day, from: dayDate)
- let inCurrentMonth = monthRange.contains(dayNumber) && calendar.isDate(dayDate, equalTo: monthStart, toGranularity: .month)
- let hasMeetings = isUserLoggedIn() && meetingsForDay(dayDate).isEmpty == false
- let button = makeSchedulerDayCell(
- day: dayNumber,
- date: dayDate,
- inCurrentMonth: inCurrentMonth,
- selected: calendar.isDate(dayDate, inSameDayAs: selectedMeetingsDayStart),
- isToday: calendar.isDateInToday(dayDate),
- hasMeetings: hasMeetings
- )
- row.addArrangedSubview(button)
- schedulerDayButtons[calendar.startOfDay(for: dayDate)] = button
- }
- }
- // Keep a consistent 6x7 grid footprint.
- if totalSlots == 42 {
- gridStack.needsLayout = true
- }
- }
- private func makeSchedulerDayCell(
- day: Int,
- date: Date,
- inCurrentMonth: Bool,
- selected: Bool,
- isToday: Bool,
- hasMeetings: Bool
- ) -> NSButton {
- let button = HoverButton(title: "\(day)", target: self, action: #selector(schedulerDayTapped(_:)))
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.font = .systemFont(ofSize: 16, weight: selected ? .semibold : .regular)
- button.alignment = .center
- button.wantsLayer = true
- button.layer?.cornerRadius = 16
- button.layer?.borderWidth = selected ? 1.5 : (hasMeetings ? 1 : 0)
- let normalBackground = selected
- ? accentBlue.withAlphaComponent(palette.isDarkMode ? 0.92 : 0.16)
- : NSColor.clear
- let hoverBackground = selected
- ? accentBlue.withAlphaComponent(palette.isDarkMode ? 1.0 : 0.24)
- : (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.08) : NSColor.black.withAlphaComponent(0.06))
- button.normalColor = normalBackground
- button.hoverColor = hoverBackground
- button.onHoverChanged = { [weak button] hovering in
- guard let button, selected == false else { return }
- if hovering {
- button.layer?.borderWidth = max(button.layer?.borderWidth ?? 0, 1)
- button.layer?.borderColor = (hasMeetings
- ? self.accentBlue.withAlphaComponent(0.65)
- : self.accentBlue.withAlphaComponent(0.45)
- ).cgColor
- } else if isToday {
- button.layer?.borderColor = self.accentOrange.withAlphaComponent(0.8).cgColor
- button.layer?.borderWidth = 1
- } else if hasMeetings {
- button.layer?.borderColor = self.accentBlue.withAlphaComponent(0.45).cgColor
- button.layer?.borderWidth = 1
- } else {
- button.layer?.borderColor = NSColor.clear.cgColor
- button.layer?.borderWidth = 0
- }
- }
- if selected {
- button.layer?.borderColor = accentBlue.cgColor
- } else if isToday {
- button.layer?.borderColor = accentOrange.withAlphaComponent(0.8).cgColor
- button.layer?.borderWidth = 1
- } else if hasMeetings {
- button.layer?.borderColor = accentBlue.withAlphaComponent(0.45).cgColor
- } else {
- button.layer?.borderColor = NSColor.clear.cgColor
- }
- button.contentTintColor = selected
- ? primaryText
- : (inCurrentMonth ? primaryText : mutedText.withAlphaComponent(0.75))
- let tag = nextSchedulerButtonTag
- nextSchedulerButtonTag += 1
- button.tag = tag
- schedulerDateByButtonTag[tag] = Calendar.current.startOfDay(for: date)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 34).isActive = true
- return button
- }
- @objc private func schedulerDayTapped(_ sender: NSButton) {
- guard let selectedDate = schedulerDateByButtonTag[sender.tag] else { return }
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(selectedDate)
- }
- }
- @objc private func schedulerPrevMonthTapped() {
- let calendar = Calendar.current
- let prev = calendar.date(byAdding: .month, value: -1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
- let monthComponents = calendar.dateComponents([.year, .month], from: prev)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? prev
- updateSchedulerDateHeader()
- renderSchedulerCalendarGrid()
- }
- @objc private func schedulerNextMonthTapped() {
- let calendar = Calendar.current
- let next = calendar.date(byAdding: .month, value: 1, to: schedulerVisibleMonthStart) ?? schedulerVisibleMonthStart
- let monthComponents = calendar.dateComponents([.year, .month], from: next)
- schedulerVisibleMonthStart = calendar.date(from: monthComponents) ?? next
- updateSchedulerDateHeader()
- renderSchedulerCalendarGrid()
- }
- @objc private func schedulerTodayTapped() {
- Task { @MainActor in
- self.setSelectedMeetingsDayStart(Date())
- }
- }
- private func loadScheduledMeetings() async {
- guard isUserLoggedIn() else {
- await MainActor.run {
- self.applyMeetings([])
- self.applyGuestLoginMessages()
- }
- return
- }
- guard storeKitCoordinator.hasPremiumAccess else {
- await MainActor.run {
- self.applyMeetings([])
- self.applyPremiumLockMessages()
- }
- return
- }
- if isLoadingMeetings { return }
- isLoadingMeetings = true
- defer {
- isLoadingMeetings = false
- Task { @MainActor in
- self.setMeetingsLoadingUI(false)
- }
- }
- await MainActor.run {
- self.setMeetingsLoadingUI(true)
- }
- do {
- let zoomToken = try await zoomOAuth.validAccessToken(presentingWindow: view.window)
- if zoomAccountTimeZone == nil {
- if let profile = try? await fetchZoomUserProfile(accessToken: zoomToken) {
- let profileTZ = parseZoomTimeZone(profile.timezone)
- await MainActor.run { self.zoomAccountTimeZone = profileTZ }
- }
- }
- let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken, fallbackTimeZone: zoomAccountTimeZone)
- await MainActor.run {
- self.meetingsRateLimitedUntil = nil
- self.applyMeetings(zoomMeetings)
- }
- } catch {
- await MainActor.run {
- self.applyMeetings([])
- if case ZoomOAuthError.missingClientId = error {
- self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingClientSecret = error {
- self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.meetingsPageStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingRequiredScope = error {
- self.zoomOAuth.clearSavedTokens()
- self.meetingsStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
- self.meetingsPageStatusLabel?.stringValue = "Zoom permissions are missing. Update your Zoom app scopes, then sign in again."
- } else if case ZoomOAuthError.rateLimited(let retryAfterSeconds) = error {
- let seconds = max(retryAfterSeconds ?? 300, 30)
- self.meetingsRateLimitedUntil = Date().addingTimeInterval(TimeInterval(seconds))
- let minutes = Int(ceil(Double(seconds) / 60.0))
- self.meetingsStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
- self.meetingsPageStatusLabel?.stringValue = "Zoom rate limit reached. Please try again in \(minutes) min."
- } else {
- self.meetingsStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
- self.meetingsPageStatusLabel?.stringValue = "Unable to load meetings right now. Please try again shortly."
- }
- }
- }
- }
- @MainActor
- private func presentZoomOAuthCredentialPrompt() -> Bool {
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = "Configure Zoom OAuth"
- alert.informativeText = "Enter your Zoom Marketplace OAuth app Client ID and Client Secret once (or set ZoomOAuthClientId in Info.plist and ZOOM_OAUTH_CLIENT_SECRET in the run environment). After this, sign-in and token refresh run automatically. Enable meeting:read and meeting:write scopes on the app so listing and scheduling work."
- let wrapper = NSStackView()
- wrapper.orientation = .vertical
- wrapper.spacing = 8
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- let clientIdField = NSTextField()
- clientIdField.placeholderString = "Zoom Client ID"
- clientIdField.stringValue = zoomOAuth.configuredClientId() ?? ""
- let clientSecretField = NSSecureTextField()
- clientSecretField.placeholderString = "Zoom Client Secret"
- clientSecretField.stringValue = zoomOAuth.configuredClientSecret() ?? ""
- [clientIdField, clientSecretField].forEach { field in
- field.translatesAutoresizingMaskIntoConstraints = false
- field.widthAnchor.constraint(equalToConstant: 420).isActive = true
- wrapper.addArrangedSubview(field)
- }
- alert.accessoryView = wrapper
- alert.addButton(withTitle: "Save")
- alert.addButton(withTitle: "Cancel")
- let result = alert.runModal()
- if result == .alertFirstButtonReturn {
- var clientId = clientIdField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- var clientSecret = clientSecretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if clientId.isEmpty { clientId = zoomOAuth.configuredClientId() ?? "" }
- if clientSecret.isEmpty { clientSecret = zoomOAuth.configuredClientSecret() ?? "" }
- if clientId.isEmpty == false, clientSecret.isEmpty == false {
- zoomOAuth.setClientCredentials(clientId: clientId, clientSecret: clientSecret)
- return true
- }
- meetingsStatusLabel?.stringValue = "Both Zoom OAuth Client ID and Client Secret are required (or set bundled values / ZOOM_OAUTH_CLIENT_SECRET)."
- }
- return false
- }
- @MainActor
- private func promptForZoomOAuthCredentialsIfNeeded() {
- guard isPromptingZoomCredentials == false else { return }
- isPromptingZoomCredentials = true
- defer { isPromptingZoomCredentials = false }
- if presentZoomOAuthCredentialPrompt() {
- meetingsStatusLabel?.stringValue = "Configured. Starting Zoom OAuth..."
- Task { await self.loadScheduledMeetings() }
- }
- }
- private struct ZoomUserMeResponse: Decodable {
- let first_name: String?
- let last_name: String?
- let display_name: String?
- let email: String?
- let pic_url: String?
- let timezone: String?
- var displayName: String? {
- if let display_name, display_name.isEmpty == false { return display_name }
- let parts = [first_name, last_name].compactMap { $0 }.filter { $0.isEmpty == false }
- return parts.isEmpty ? nil : parts.joined(separator: " ")
- }
- var pictureURL: String? {
- guard let pic_url, pic_url.isEmpty == false else { return nil }
- return pic_url
- }
- }
- private func fetchZoomUserProfile(accessToken: String) async throws -> ZoomUserMeResponse {
- let url = URL(string: "https://api.zoom.us/v2/users/me")!
- var request = URLRequest(url: url)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed to load Zoom profile")
- }
- return try JSONDecoder().decode(ZoomUserMeResponse.self, from: data)
- }
- private struct ZoomCreateMeetingBody: Encodable {
- let topic: String
- let type: Int
- let start_time: String
- let duration: Int
- let timezone: String
- }
- private struct ZoomCreateMeetingAPIResult: Decodable {
- let id: Int?
- let join_url: String?
- }
- /// Creates a scheduled meeting via [Zoom Create meeting](https://developers.zoom.us/docs/api/rest/reference/zoom-api/methods/#operation/meetingCreate).
- private func createZoomMeeting(accessToken: String, topic: String, startTimeUTC: String, durationMinutes: Int, timeZone: TimeZone) async throws -> ZoomCreateMeetingAPIResult {
- let url = URL(string: "https://api.zoom.us/v2/users/me/meetings")!
- var request = URLRequest(url: url)
- request.httpMethod = "POST"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- request.setValue("application/json", forHTTPHeaderField: "Content-Type")
- let body = ZoomCreateMeetingBody(
- topic: topic,
- type: 2,
- start_time: startTimeUTC,
- duration: durationMinutes,
- timezone: timeZone.identifier
- )
- request.httpBody = try JSONEncoder().encode(body)
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse else {
- throw GoogleOAuthError.tokenExchangeFailed("Invalid response from Zoom")
- }
- if http.statusCode == 429 {
- let retryAfterRaw = http.value(forHTTPHeaderField: "Retry-After")
- let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
- throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
- }
- guard (200..<300).contains(http.statusCode) else {
- let raw = String(data: data, encoding: .utf8) ?? "Failed to create meeting"
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- return try JSONDecoder().decode(ZoomCreateMeetingAPIResult.self, from: data)
- }
- private func fetchZoomScheduledMeetings(accessToken: String, fallbackTimeZone: TimeZone?) async throws -> [ScheduledMeeting] {
- struct ZoomMeeting: Decodable {
- let id: Int?
- let topic: String?
- let start_time: String?
- let timezone: String?
- let duration: Int?
- let host_id: String?
- let type: Int?
- let status: String?
- let join_url: String?
- }
- struct ZoomMeetingsPage: Decodable {
- let meetings: [ZoomMeeting]
- let next_page_token: String?
- }
- let iso = ISO8601DateFormatter()
- iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
- let fallbackISO = ISO8601DateFormatter()
- fallbackISO.formatOptions = [.withInternetDateTime]
- func inferredTimeZone(from startRaw: String, explicitZoomTimezone: String?) -> TimeZone? {
- if let parsedExplicit = parseZoomTimeZone(explicitZoomTimezone) {
- return parsedExplicit
- }
- if let fallbackTimeZone { return fallbackTimeZone }
- if startRaw.hasSuffix("Z") || startRaw.hasSuffix("z") {
- return TimeZone(secondsFromGMT: 0)
- }
- let startPattern = #"([+-]\d{2}:?\d{2}|[+-]\d{2})$"#
- guard let regex = try? NSRegularExpression(pattern: startPattern) else { return nil }
- let nsRange = NSRange(startRaw.startIndex..<startRaw.endIndex, in: startRaw)
- guard let match = regex.firstMatch(in: startRaw, options: [], range: nsRange),
- let offsetRange = Range(match.range(at: 1), in: startRaw) else {
- return nil
- }
- return parseZoomTimeZone(String(startRaw[offsetRange]))
- }
- func mapMeetings(_ raw: [ZoomMeeting]) -> [ScheduledMeeting] {
- raw.compactMap { meeting in
- guard let startRaw = meeting.start_time else { return nil }
- let start = iso.date(from: startRaw) ?? fallbackISO.date(from: startRaw)
- guard let start else { return nil }
- let end = meeting.duration.map { start.addingTimeInterval(TimeInterval($0 * 60)) }
- let meetingIDString = meeting.id.map(String.init)
- let overrideZone = meetingIDString
- .flatMap { self.meetingTimeZoneOverrides[$0] }
- .flatMap { TimeZone(identifier: $0) }
- let displayZone = overrideZone ?? inferredTimeZone(from: startRaw, explicitZoomTimezone: meeting.timezone)
- let webURL: URL? = {
- func wcJoinURL(meetingId: Int, pwd: String?) -> URL? {
- var components = URLComponents()
- components.scheme = "https"
- components.host = "zoom.us"
- components.path = "/wc/join/\(meetingId)"
- if let pwd, pwd.isEmpty == false {
- components.queryItems = [URLQueryItem(name: "pwd", value: pwd)]
- }
- return components.url
- }
- if let join = meeting.join_url, let url = URL(string: join), url.scheme != nil {
- // Prefer the Zoom Web Client join URL so a click joins in the browser.
- // join_url is often `https://zoom.us/j/<id>?pwd=...`
- if url.path.contains("/wc/join/") {
- return url
- }
- if let id = meeting.id {
- let pwd = URLComponents(url: url, resolvingAgainstBaseURL: false)?
- .queryItems?
- .first(where: { $0.name == "pwd" })?
- .value
- return wcJoinURL(meetingId: id, pwd: pwd)
- }
- return url
- }
- if let id = meeting.id {
- return wcJoinURL(meetingId: id, pwd: nil)
- }
- return nil
- }()
- return ScheduledMeeting(
- title: meeting.topic?.isEmpty == false ? meeting.topic! : "Zoom meeting",
- start: start,
- end: end,
- meetingTimeZone: displayZone,
- host: meeting.host_id ?? "Zoom Host",
- source: "Zoom",
- meetingID: meetingIDString,
- typeLabel: meeting.type == 8 ? "Recurring" : "Scheduled",
- statusLabel: (meeting.status?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false ? meeting.status! : "scheduled").capitalized,
- webURL: webURL
- )
- }
- }
- var allMeetings: [ZoomMeeting] = []
- var nextPageToken: String?
- repeat {
- var components = URLComponents(string: "https://api.zoom.us/v2/users/me/meetings")!
- var items: [URLQueryItem] = [
- URLQueryItem(name: "type", value: "scheduled"),
- URLQueryItem(name: "page_size", value: "30")
- ]
- if let nextPageToken, nextPageToken.isEmpty == false {
- items.append(URLQueryItem(name: "next_page_token", value: nextPageToken))
- }
- components.queryItems = items
- var request = URLRequest(url: components.url!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- let raw = String(data: data, encoding: .utf8) ?? "Failed to load Zoom meetings"
- if (response as? HTTPURLResponse)?.statusCode == 429 {
- let retryAfterRaw = (response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Retry-After")
- let seconds = retryAfterRaw.flatMap { Int($0.trimmingCharacters(in: .whitespacesAndNewlines)) }
- throw ZoomOAuthError.rateLimited(retryAfterSeconds: seconds)
- }
- if raw.localizedCaseInsensitiveContains("does not contain scopes") {
- throw ZoomOAuthError.missingRequiredScope(raw)
- }
- throw GoogleOAuthError.tokenExchangeFailed(raw)
- }
- let decoded = try JSONDecoder().decode(ZoomMeetingsPage.self, from: data)
- allMeetings.append(contentsOf: decoded.meetings)
- let token = decoded.next_page_token?.trimmingCharacters(in: .whitespacesAndNewlines)
- nextPageToken = (token?.isEmpty == false) ? token : nil
- } while nextPageToken != nil
- return mapMeetings(allMeetings)
- }
- private func makeSettingsView() -> NSView {
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- let scroll = NSScrollView()
- scroll.translatesAutoresizingMaskIntoConstraints = false
- scroll.drawsBackground = false
- scroll.hasHorizontalScroller = false
- scroll.hasVerticalScroller = true
- scroll.autohidesScrollers = true
- scroll.borderType = .noBorder
- scroll.scrollerStyle = .overlay
- scroll.automaticallyAdjustsContentInsets = false
- let clip = TopAlignedClipView()
- clip.drawsBackground = false
- scroll.contentView = clip
- panel.addSubview(scroll)
- let content = NSView()
- content.translatesAutoresizingMaskIntoConstraints = false
- scroll.documentView = content
- let card = roundedContainer(cornerRadius: 16, color: palette.sectionCard)
- card.translatesAutoresizingMaskIntoConstraints = false
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
- content.addSubview(card)
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 18
- stack.alignment = .leading
- card.addSubview(stack)
- let pageTitle = textLabel("Settings", font: .systemFont(ofSize: 28, weight: .bold), color: primaryText)
- let pageSubtitle = textLabel("Manage appearance, account, and app options.", font: .systemFont(ofSize: 13, weight: .regular), color: secondaryText)
- stack.addArrangedSubview(pageTitle)
- stack.addArrangedSubview(pageSubtitle)
- stack.setCustomSpacing(24, after: pageSubtitle)
- let appearanceTitle = textLabel("Appearance", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(appearanceTitle)
- let darkModeRow = makeSettingsDarkModeRow()
- stack.addArrangedSubview(darkModeRow)
- darkModeRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- stack.setCustomSpacing(24, after: darkModeRow)
- let accountTitle = textLabel("Account", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(accountTitle)
- let googleAccountRow = makeSettingsGoogleAccountRow()
- stack.addArrangedSubview(googleAccountRow)
- googleAccountRow.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- stack.setCustomSpacing(24, after: googleAccountRow)
- let appTitle = textLabel("App", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(appTitle)
- let shareButton = makeSettingsActionButton(icon: "⤴︎", title: "Share App", action: .shareApp)
- stack.addArrangedSubview(shareButton)
- shareButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let upgradeButton = makeSettingsActionButton(icon: "⬆︎", title: "Upgrade", action: .upgrade)
- stack.addArrangedSubview(upgradeButton)
- upgradeButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- settingsUpgradeButton = upgradeButton
- stack.setCustomSpacing(24, after: upgradeButton)
- let legalTitle = textLabel("Help & Legal", font: .systemFont(ofSize: 16, weight: .semibold), color: primaryText)
- stack.addArrangedSubview(legalTitle)
- let privacyButton = makeSettingsActionButton(icon: "🔒", title: "Privacy Policy", action: .privacyPolicy)
- stack.addArrangedSubview(privacyButton)
- privacyButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let supportButton = makeSettingsActionButton(icon: "💬", title: "Support", action: .support)
- stack.addArrangedSubview(supportButton)
- supportButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- let termsButton = makeSettingsActionButton(icon: "📄", title: "Terms of Services", action: .termsOfServices)
- stack.addArrangedSubview(termsButton)
- termsButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- NSLayoutConstraint.activate([
- scroll.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
- scroll.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
- scroll.topAnchor.constraint(equalTo: panel.topAnchor),
- scroll.bottomAnchor.constraint(equalTo: panel.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
- content.trailingAnchor.constraint(equalTo: scroll.contentView.trailingAnchor),
- content.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
- content.bottomAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.bottomAnchor),
- content.widthAnchor.constraint(equalTo: scroll.contentView.widthAnchor),
- card.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- card.topAnchor.constraint(equalTo: content.topAnchor, constant: 16),
- content.bottomAnchor.constraint(greaterThanOrEqualTo: card.bottomAnchor, constant: 36),
- card.widthAnchor.constraint(lessThanOrEqualToConstant: 620),
- card.widthAnchor.constraint(greaterThanOrEqualToConstant: 460),
- card.leadingAnchor.constraint(greaterThanOrEqualTo: content.leadingAnchor, constant: 30),
- card.trailingAnchor.constraint(lessThanOrEqualTo: content.trailingAnchor, constant: -30),
- stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 28),
- stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -28),
- stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 24),
- stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -24)
- ])
- updatePremiumButtons()
- return panel
- }
- private enum SettingsAction: Int {
- case shareApp = 1
- case upgrade = 2
- case privacyPolicy = 4
- case support = 5
- case termsOfServices = 6
- }
- private func makeSettingsDarkModeRow() -> NSView {
- let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- row.translatesAutoresizingMaskIntoConstraints = false
- row.heightAnchor.constraint(equalToConstant: 52).isActive = true
- styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let icon = textLabel("◐", font: NSFont.systemFont(ofSize: 18, weight: .medium), color: primaryText)
- let title = textLabel("Dark Mode", font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- let toggle = NSSwitch()
- toggle.translatesAutoresizingMaskIntoConstraints = false
- toggle.state = darkModeEnabled ? .on : .off
- toggle.target = self
- toggle.action = #selector(settingsDarkModeToggled(_:))
- settingsDarkModeSwitch = toggle
- row.addSubview(icon)
- row.addSubview(title)
- row.addSubview(toggle)
- NSLayoutConstraint.activate([
- icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
- toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- return row
- }
- private func makeSettingsGoogleAccountRow() -> NSView {
- let row = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
- row.translatesAutoresizingMaskIntoConstraints = false
- styleSurface(row, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let signedIn = isUserLoggedIn()
- let titleText = signedIn ? "Zoom account connected" : "Zoom account not connected"
- let subtitleText = signedIn ? "Signed in" : "Sign in to sync your meetings and calendar."
- let title = textLabel(titleText, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- let subtitle = textLabel(subtitleText, font: NSFont.systemFont(ofSize: 13, weight: .regular), color: secondaryText)
- subtitle.maximumNumberOfLines = 2
- subtitle.lineBreakMode = .byTruncatingTail
- let actionButton = NSButton(title: signedIn ? "Sign Out" : "Sign in with Google", target: self, action: #selector(settingsGoogleActionButtonClicked(_:)))
- actionButton.translatesAutoresizingMaskIntoConstraints = false
- actionButton.bezelStyle = .rounded
- actionButton.controlSize = .regular
- settingsGoogleActionButton = actionButton
- row.addSubview(title)
- row.addSubview(subtitle)
- row.addSubview(actionButton)
- NSLayoutConstraint.activate([
- row.heightAnchor.constraint(equalToConstant: 78),
- title.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 14),
- title.topAnchor.constraint(equalTo: row.topAnchor, constant: 12),
- subtitle.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- subtitle.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 4),
- subtitle.trailingAnchor.constraint(lessThanOrEqualTo: actionButton.leadingAnchor, constant: -14),
- actionButton.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -14),
- actionButton.centerYAnchor.constraint(equalTo: row.centerYAnchor)
- ])
- return row
- }
- private func makeSettingsActionButton(icon: String, title: String, action: SettingsAction) -> NSButton {
- let button = HoverButton(title: "", target: self, action: #selector(settingsPageActionButtonClicked(_:)))
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 10
- button.normalColor = palette.inputBackground
- button.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.07) : NSColor.black.withAlphaComponent(0.05)
- styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- button.heightAnchor.constraint(equalToConstant: 46).isActive = true
- button.tag = action.rawValue
- let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 17, weight: .medium), color: primaryText)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .semibold), color: primaryText)
- if action == .upgrade {
- settingsUpgradeTitleLabel = titleLabel
- }
- button.addSubview(iconLabel)
- button.addSubview(titleLabel)
- NSLayoutConstraint.activate([
- iconLabel.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 14),
- iconLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
- titleLabel.centerYAnchor.constraint(equalTo: button.centerYAnchor)
- ])
- return button
- }
- @objc private func settingsPageActionButtonClicked(_ sender: NSButton) {
- guard let action = SettingsAction(rawValue: sender.tag) else { return }
- switch action {
- case .shareApp:
- showSimpleAlert(title: "Share", message: "Share action placeholder (match reference app behavior next).")
- case .upgrade:
- settingsUpgradePremiumTapped()
- case .privacyPolicy:
- showSimpleAlert(title: "Privacy Policy", message: "Add your Privacy Policy URL in the app and open it here.")
- case .support:
- showSimpleAlert(title: "Support", message: "Add your Support URL/email in the app and open it here.")
- case .termsOfServices:
- showSimpleAlert(title: "Terms", message: "Add your Terms URL in the app and open it here.")
- }
- }
- @objc private func settingsGoogleActionButtonClicked(_ sender: NSButton) {
- if isUserLoggedIn() {
- logoutTapped()
- return
- }
- zoomPrimarySignInTapped()
- }
- private func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
- let v = NSView()
- v.wantsLayer = true
- v.layer?.backgroundColor = color.cgColor
- v.layer?.cornerRadius = cornerRadius
- return v
- }
- private func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
- view.wantsLayer = true
- view.layer?.borderColor = borderColor.cgColor
- view.layer?.borderWidth = borderWidth
- if shadow {
- view.layer?.shadowColor = NSColor.black.cgColor
- view.layer?.shadowOpacity = palette.isDarkMode ? 0.22 : 0.12
- view.layer?.shadowRadius = 18
- view.layer?.shadowOffset = NSSize(width: 0, height: -2)
- } else {
- view.layer?.shadowOpacity = 0
- }
- }
- private func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.translatesAutoresizingMaskIntoConstraints = false
- label.font = font
- label.textColor = color
- label.backgroundColor = .clear
- label.isBezeled = false
- label.isEditable = false
- label.isSelectable = false
- return label
- }
- private func styledLabel(
- _ text: String,
- font: NSFont,
- color: NSColor,
- kern: CGFloat? = nil,
- lineSpacing: CGFloat? = nil
- ) -> NSTextField {
- let label = textLabel(text, font: font, color: color)
- if kern == nil, lineSpacing == nil {
- return label
- }
- let paragraph = NSMutableParagraphStyle()
- paragraph.alignment = label.alignment
- paragraph.lineBreakMode = .byWordWrapping
- if let lineSpacing { paragraph.lineSpacing = lineSpacing }
- var attrs: [NSAttributedString.Key: Any] = [
- .font: font,
- .foregroundColor: color,
- .paragraphStyle: paragraph
- ]
- if let kern { attrs[.kern] = kern }
- label.attributedStringValue = NSAttributedString(string: text, attributes: attrs)
- return label
- }
- private func startStoreKit() {
- storeKitStartupTask?.cancel()
- storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
- DispatchQueue.main.async {
- guard let self else { return }
- let newIsPremium = self.storeKitCoordinator.hasPremiumAccess
- let wasPremium = self.lastKnownPremiumAccess
- self.lastKnownPremiumAccess = newIsPremium
- self.updatePremiumButtons()
- if (wasPremium == false) && newIsPremium {
- self.startMeetingsAutoRefresh()
- self.triggerMeetingsRefresh(force: true)
- self.schedulePremiumRatingPromptIfNeeded()
- }
- if wasPremium && !newIsPremium {
- self.premiumRatingPromptWorkItem?.cancel()
- self.premiumRatingPromptWorkItem = nil
- self.showPaywall()
- }
- if newIsPremium {
- self.launchPaywallWorkItem?.cancel()
- self.launchPaywallWorkItem = nil
- self.hasPresentedLaunchPaywall = true
- } else {
- self.scheduleLaunchPaywallIfNeeded()
- }
- }
- }
- storeKitStartupTask = Task { [weak self] in
- await self?.storeKitCoordinator.start()
- await MainActor.run {
- self?.updatePremiumButtons()
- self?.scheduleLaunchPaywallIfNeeded()
- }
- }
- }
- private func scheduleLaunchPaywallIfNeeded() {
- guard hasPresentedLaunchPaywall == false else { return }
- guard storeKitCoordinator.hasPremiumAccess == false else {
- hasPresentedLaunchPaywall = true
- return
- }
- guard launchPaywallWorkItem == nil else { return }
- let workItem = DispatchWorkItem { [weak self] in
- guard let self else { return }
- self.launchPaywallWorkItem = nil
- guard self.hasPresentedLaunchPaywall == false else { return }
- guard self.storeKitCoordinator.hasPremiumAccess == false else {
- self.hasPresentedLaunchPaywall = true
- return
- }
- self.hasPresentedLaunchPaywall = true
- self.showPaywall()
- }
- launchPaywallWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + launchPaywallDelay, execute: workItem)
- }
- @MainActor
- private func updatePremiumButtons() {
- let isPremium = storeKitCoordinator.hasPremiumAccess
- let hasLifetimePremium = storeKitCoordinator.hasLifetimePremiumAccess
- settingsUpgradeButton?.title = ""
- settingsUpgradeTitleLabel?.stringValue = hasLifetimePremium ? "Premium Lifetime" : "Upgrade"
- settingsUpgradeButton?.isHidden = hasLifetimePremium
- settingsUpgradeButton?.isEnabled = hasLifetimePremium == false
- settingsUpgradeButton?.alphaValue = hasLifetimePremium ? 0.6 : 1.0
- updatePremiumLockedSections()
- if isPremium == false, isUserLoggedIn() {
- applyMeetings([])
- applyPremiumLockMessages()
- }
- if isPremium {
- schedulePremiumRatingPromptIfNeeded()
- }
- updateTopBarPremiumButton()
- }
- @MainActor
- private func updateTopBarPremiumButton() {
- guard let button = topBarPremiumButton else { return }
- let isPremium = storeKitCoordinator.hasPremiumAccess
- if isPremium {
- let title = "Manage Subscription"
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- if let base = NSImage(systemSymbolName: "crown.fill", accessibilityDescription: "Premium"),
- let img = base.withSymbolConfiguration(symbolConfig) {
- img.isTemplate = true
- button.image = img
- button.imagePosition = .imageLeading
- button.imageHugsTitle = true
- }
- button.contentTintColor = NSColor.white
- button.toolTip = title
- button.layer?.backgroundColor = NSColor(calibratedRed: 214 / 255, green: 175 / 255, blue: 54 / 255, alpha: 1).cgColor
- } else {
- let title = "Upgrade to Pro"
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- button.image = nil
- button.toolTip = title
- button.layer?.backgroundColor = accentBlue.cgColor
- button.contentTintColor = .white
- }
- }
- @objc private func settingsDarkModeToggled(_ sender: NSSwitch) {
- setDarkMode(sender.state == .on)
- }
- private func setDarkMode(_ enabled: Bool) {
- darkModeEnabled = enabled
- palette = Palette(isDarkMode: enabled)
- NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
- if let window = view.window {
- applyWindowBackgroundForCurrentTheme(window)
- }
- let keepSelected = selectedHomeSidebarItem
- selectedHomeSidebarItem = keepSelected
- showHomeView(profile: nil)
- updatePremiumLockedSections()
- }
- private func applyWindowBackgroundForCurrentTheme(_ window: NSWindow) {
- // Keep the window's backing in sync with the app's current theme, otherwise rounded
- // shell corners can briefly show the previous mode's lighter/darker color.
- window.backgroundColor = appBackground
- window.isOpaque = true
- if let contentView = window.contentView {
- contentView.wantsLayer = true
- contentView.layer?.backgroundColor = appBackground.cgColor
- }
- }
- private func systemPrefersDarkMode() -> Bool {
- let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
- let style = global?["AppleInterfaceStyle"] as? String
- return style?.lowercased() == "dark"
- }
- @objc private func settingsUpgradePremiumTapped() {
- guard storeKitCoordinator.hasLifetimePremiumAccess == false else { return }
- showPaywall()
- }
- // MARK: - Paywall (ported from meetings_app)
- private func showPaywall() {
- if storeKitCoordinator.hasPremiumAccess && storeKitCoordinator.hasLifetimePremiumAccess == false {
- selectedPremiumPlan = .lifetime
- }
- if let existing = paywallOverlayView {
- refreshPaywallStoreUI()
- if let superview = existing.superview {
- superview.addSubview(existing)
- }
- updatePaywallResponsiveLayout()
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let overlay = NSView()
- overlay.translatesAutoresizingMaskIntoConstraints = false
- overlay.wantsLayer = true
- overlay.alphaValue = 0
- overlay.layer?.backgroundColor = NSColor.clear.cgColor
- let dim = NSView()
- dim.translatesAutoresizingMaskIntoConstraints = false
- dim.wantsLayer = true
- dim.layer?.backgroundColor = palette.isDarkMode
- ? NSColor.black.withAlphaComponent(0.62).cgColor
- : NSColor.black.withAlphaComponent(0.28).cgColor
- overlay.addSubview(dim)
- // Clicking outside the paywall dismisses it.
- let outsideButton = NSButton(title: "", target: self, action: #selector(closePaywallClicked(_:)))
- outsideButton.translatesAutoresizingMaskIntoConstraints = false
- outsideButton.isBordered = false
- outsideButton.bezelStyle = .regularSquare
- outsideButton.contentTintColor = .clear
- outsideButton.wantsLayer = true
- outsideButton.layer?.backgroundColor = NSColor.clear.cgColor
- overlay.addSubview(outsideButton)
- let content = makePaywallContent()
- overlay.addSubview(content)
- view.addSubview(overlay)
- NSLayoutConstraint.activate([
- overlay.leadingAnchor.constraint(equalTo: view.leadingAnchor),
- overlay.trailingAnchor.constraint(equalTo: view.trailingAnchor),
- overlay.topAnchor.constraint(equalTo: view.topAnchor),
- overlay.bottomAnchor.constraint(equalTo: view.bottomAnchor),
- dim.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- dim.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- dim.topAnchor.constraint(equalTo: overlay.topAnchor),
- dim.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
- outsideButton.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- outsideButton.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- outsideButton.topAnchor.constraint(equalTo: overlay.topAnchor),
- outsideButton.bottomAnchor.constraint(equalTo: overlay.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: overlay.leadingAnchor),
- content.trailingAnchor.constraint(equalTo: overlay.trailingAnchor),
- content.topAnchor.constraint(equalTo: overlay.topAnchor),
- content.bottomAnchor.constraint(equalTo: overlay.bottomAnchor)
- ])
- paywallPanelWidthConstraint = nil
- paywallPanelHeightConstraint = nil
- paywallOverlayView = overlay
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.18
- overlay.animator().alphaValue = 1
- }
- Task { [weak self] in
- guard let self else { return }
- await self.storeKitCoordinator.refreshProducts()
- await MainActor.run {
- self.refreshPaywallStoreUI()
- self.updatePaywallResponsiveLayout()
- }
- }
- }
- @objc private func closePaywallClicked(_ sender: Any?) {
- hidePaywall()
- }
- private func hidePaywall() {
- guard let overlay = paywallOverlayView else { return }
- NSAnimationContext.runAnimationGroup { context in
- context.duration = 0.16
- overlay.animator().alphaValue = 0
- } completionHandler: { [weak self] in
- overlay.removeFromSuperview()
- self?.paywallPanelWidthConstraint = nil
- self?.paywallPanelHeightConstraint = nil
- self?.paywallPanelView = nil
- self?.paywallBodyStack = nil
- self?.paywallIsDesktopLayout = false
- }
- }
- private func updatePaywallPanelSize() {
- guard paywallPanelWidthConstraint != nil, paywallPanelHeightConstraint != nil else { return }
- let safeInsetX: CGFloat = 80
- let safeInsetY: CGFloat = 110
- let maxWidth = max(640, view.bounds.width - safeInsetX)
- let maxHeight = max(560, view.bounds.height - safeInsetY)
- let panelWidth = min(1040, maxWidth)
- let panelHeight = min(860, maxHeight)
- if abs(paywallPanelWidthConstraint?.constant ?? 0 - panelWidth) > 1 {
- paywallPanelWidthConstraint?.constant = panelWidth
- }
- if abs(paywallPanelHeightConstraint?.constant ?? 0 - panelHeight) > 1 {
- paywallPanelHeightConstraint?.constant = panelHeight
- }
- }
- private func updatePaywallResponsiveLayout() {
- guard let bodyStack = paywallBodyStack, let panel = paywallPanelView else { return }
- let width = panel.bounds.width
- // Two-column desktop layout needs room for the plan column (520+) + a slimmer benefits column.
- let shouldUseDesktop = width >= 900
- guard shouldUseDesktop != paywallIsDesktopLayout else { return }
- paywallIsDesktopLayout = shouldUseDesktop
- if shouldUseDesktop {
- bodyStack.orientation = .horizontal
- bodyStack.alignment = .top
- bodyStack.spacing = 26
- } else {
- bodyStack.orientation = .vertical
- bodyStack.alignment = .leading
- bodyStack.spacing = 18
- }
- let useWidePlans = width >= 880
- paywallPlansRowWide?.isHidden = useWidePlans == false
- paywallPlansGridNarrow?.isHidden = useWidePlans
- bodyStack.layoutSubtreeIfNeeded()
- }
- private func makePaywallContent() -> NSView {
- paywallPlanViews.removeAll()
- paywallPlanPriceLabels.removeAll()
- paywallPlanSubtitleLabels.removeAll()
- paywallPlanStrikeLabels.removeAll()
- premiumPlanByView.removeAll()
- paywallOfferLabel = nil
- paywallContinueLabel = nil
- paywallContinueButton = nil
- paywallContinueEnabled = true
- paywallPlansRowWide = nil
- paywallPlansGridNarrow = nil
- let root = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.wantsLayer = true
- root.layer?.backgroundColor = NSColor.clear.cgColor
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- panel.wantsLayer = true
- // Theme-aware surface so Light Mode doesn't render on a black backdrop.
- panel.layer?.backgroundColor = palette.appBackground.cgColor
- panel.layer?.cornerRadius = 0
- styleSurface(panel, borderColor: NSColor.clear, borderWidth: 0, shadow: false)
- root.addSubview(panel)
- // Full-screen paywall: fill the entire app content area.
- NSLayoutConstraint.activate([
- panel.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- panel.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- panel.topAnchor.constraint(equalTo: root.topAnchor),
- panel.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- paywallPanelWidthConstraint = nil
- paywallPanelHeightConstraint = nil
- // Responsive logic should measure the panel size (not the full-screen root).
- self.paywallPanelView = panel
- let contentStack = NSStackView()
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.spacing = 14
- contentStack.alignment = .leading
- panel.addSubview(contentStack)
- let topRow = NSStackView()
- topRow.translatesAutoresizingMaskIntoConstraints = false
- topRow.orientation = .horizontal
- topRow.alignment = .centerY
- topRow.distribution = .fill
- topRow.spacing = 10
- topRow.addArrangedSubview(textLabel("Meetings Pro", font: NSFont.systemFont(ofSize: 26, weight: .semibold), color: primaryText))
- let topSpacer = NSView()
- topSpacer.translatesAutoresizingMaskIntoConstraints = false
- topRow.addArrangedSubview(topSpacer)
- let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
- closeButton.translatesAutoresizingMaskIntoConstraints = false
- closeButton.isBordered = false
- closeButton.bezelStyle = .regularSquare
- closeButton.wantsLayer = true
- closeButton.layer?.cornerRadius = 14
- closeButton.normalColor = palette.inputBackground
- closeButton.hoverColor = palette.isDarkMode ? NSColor.white.withAlphaComponent(0.10) : NSColor.black.withAlphaComponent(0.06)
- closeButton.layer?.borderColor = palette.inputBorder.cgColor
- closeButton.layer?.borderWidth = 1
- closeButton.font = NSFont.systemFont(ofSize: 13, weight: .bold)
- closeButton.contentTintColor = secondaryText
- closeButton.toolTip = "Continue with free plan"
- panel.addSubview(closeButton)
- NSLayoutConstraint.activate([
- closeButton.widthAnchor.constraint(equalToConstant: 28),
- closeButton.heightAnchor.constraint(equalToConstant: 28),
- closeButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
- closeButton.topAnchor.constraint(equalTo: panel.topAnchor, constant: 48)
- ])
- contentStack.addArrangedSubview(topRow)
- topRow.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let hero = paywallHeroCard(
- eyebrow: "PREMIUM EXPERIENCE",
- title: "Elevate your meetings workflow",
- subtitle: "Unlock automation, premium meeting tools, and priority support for every session."
- )
- contentStack.addArrangedSubview(hero)
- hero.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let features = paywallFeaturesRow()
- contentStack.addArrangedSubview(features)
- features.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let bodyStack = NSStackView()
- bodyStack.translatesAutoresizingMaskIntoConstraints = false
- bodyStack.orientation = .vertical
- bodyStack.spacing = 16
- bodyStack.alignment = .leading
- bodyStack.distribution = .fill
- contentStack.addArrangedSubview(bodyStack)
- paywallBodyStack = bodyStack
- bodyStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor).isActive = true
- let planColumn = NSStackView()
- planColumn.translatesAutoresizingMaskIntoConstraints = false
- planColumn.orientation = .vertical
- planColumn.spacing = 12
- planColumn.alignment = .leading
- planColumn.distribution = .fill
- bodyStack.addArrangedSubview(planColumn)
- planColumn.widthAnchor.constraint(equalTo: bodyStack.widthAnchor).isActive = true
- let weeklyCard = paywallPlanCard(
- title: "Weekly",
- price: paywallDisplayPrice(for: .weekly),
- badge: "Basic",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .weekly),
- plan: .weekly,
- strikePrice: nil
- )
- let monthlyCard = paywallPlanCard(
- title: "Monthly",
- price: paywallDisplayPrice(for: .monthly),
- badge: "Popular",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .monthly),
- plan: .monthly,
- strikePrice: nil
- )
- let yearlyCard = paywallPlanCard(
- title: "Yearly",
- price: paywallDisplayPrice(for: .yearly),
- badge: "Save 67%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .yearly),
- plan: .yearly,
- strikePrice: nil
- )
- let lifetimeCard = paywallPlanCard(
- title: "Lifetime",
- price: paywallDisplayPrice(for: .lifetime),
- badge: "Best Value",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .lifetime),
- plan: .lifetime,
- strikePrice: nil
- )
- let plansRowWide = NSStackView()
- plansRowWide.translatesAutoresizingMaskIntoConstraints = false
- plansRowWide.orientation = .horizontal
- plansRowWide.spacing = 12
- plansRowWide.alignment = .centerY
- plansRowWide.distribution = .fillEqually
- plansRowWide.addArrangedSubview(weeklyCard)
- plansRowWide.addArrangedSubview(monthlyCard)
- plansRowWide.addArrangedSubview(yearlyCard)
- plansRowWide.addArrangedSubview(lifetimeCard)
- paywallPlansRowWide = plansRowWide
- planColumn.addArrangedSubview(plansRowWide)
- plansRowWide.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- let plansGridNarrow = NSStackView()
- plansGridNarrow.translatesAutoresizingMaskIntoConstraints = false
- plansGridNarrow.orientation = .vertical
- plansGridNarrow.spacing = 12
- plansGridNarrow.alignment = .leading
- plansGridNarrow.distribution = .fillEqually
- let plansRow1 = NSStackView()
- plansRow1.translatesAutoresizingMaskIntoConstraints = false
- plansRow1.orientation = .horizontal
- plansRow1.spacing = 12
- plansRow1.alignment = .centerY
- plansRow1.distribution = .fillEqually
- plansRow1.addArrangedSubview(paywallPlanCard(
- title: "Weekly",
- price: paywallDisplayPrice(for: .weekly),
- badge: "Basic Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .weekly),
- plan: .weekly,
- strikePrice: nil
- ))
- plansRow1.addArrangedSubview(paywallPlanCard(
- title: "Monthly",
- price: paywallDisplayPrice(for: .monthly),
- badge: "Popular",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .monthly),
- plan: .monthly,
- strikePrice: nil
- ))
- let plansRow2 = NSStackView()
- plansRow2.translatesAutoresizingMaskIntoConstraints = false
- plansRow2.orientation = .horizontal
- plansRow2.spacing = 12
- plansRow2.alignment = .centerY
- plansRow2.distribution = .fillEqually
- plansRow2.addArrangedSubview(paywallPlanCard(
- title: "Yearly",
- price: paywallDisplayPrice(for: .yearly),
- badge: "Best Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .yearly),
- plan: .yearly,
- strikePrice: nil
- ))
- plansRow2.addArrangedSubview(paywallPlanCard(
- title: "Lifetime",
- price: paywallDisplayPrice(for: .lifetime),
- badge: "Save 50%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: paywallPlanSubtitle(for: .lifetime),
- plan: .lifetime,
- strikePrice: nil
- ))
- plansGridNarrow.addArrangedSubview(plansRow1)
- plansGridNarrow.addArrangedSubview(plansRow2)
- plansGridNarrow.isHidden = true
- paywallPlansGridNarrow = plansGridNarrow
- planColumn.addArrangedSubview(plansGridNarrow)
- plansGridNarrow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- plansRow1.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
- plansRow2.widthAnchor.constraint(equalTo: plansGridNarrow.widthAnchor).isActive = true
- updatePaywallPlanSelection()
- planColumn.setCustomSpacing(16, after: plansGridNarrow)
- let trustRow = paywallTrustRow()
- planColumn.addArrangedSubview(trustRow)
- trustRow.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(14, after: trustRow)
- let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: primaryText)
- offer.alignment = .center
- paywallOfferLabel = offer
- let offerWrap = NSView()
- offerWrap.translatesAutoresizingMaskIntoConstraints = false
- offerWrap.addSubview(offer)
- NSLayoutConstraint.activate([
- offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
- offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
- offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
- ])
- planColumn.addArrangedSubview(offerWrap)
- offerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(14, after: offerWrap)
- let continueButton = HoverButton(title: "", target: self, action: #selector(paywallContinueClicked(_:)))
- continueButton.translatesAutoresizingMaskIntoConstraints = false
- continueButton.isBordered = false
- continueButton.bezelStyle = .regularSquare
- continueButton.wantsLayer = true
- continueButton.layer?.cornerRadius = 14
- continueButton.normalColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 1)
- continueButton.hoverColor = NSColor(calibratedRed: 0.16, green: 0.49, blue: 0.98, alpha: 0.92)
- continueButton.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(continueButton, borderColor: NSColor.black.withAlphaComponent(0.12), borderWidth: 1, shadow: true)
- let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
- continueButton.addSubview(continueLabel)
- NSLayoutConstraint.activate([
- continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
- continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
- ])
- paywallContinueButton = continueButton
- paywallContinueLabel = continueLabel
- let continueButtonWrap = NSView()
- continueButtonWrap.translatesAutoresizingMaskIntoConstraints = false
- continueButtonWrap.addSubview(continueButton)
- planColumn.addArrangedSubview(continueButtonWrap)
- NSLayoutConstraint.activate([
- continueButtonWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor),
- continueButton.topAnchor.constraint(equalTo: continueButtonWrap.topAnchor),
- continueButton.bottomAnchor.constraint(equalTo: continueButtonWrap.bottomAnchor),
- continueButton.centerXAnchor.constraint(equalTo: continueButtonWrap.centerXAnchor),
- continueButton.widthAnchor.constraint(equalToConstant: 400),
- continueButton.widthAnchor.constraint(lessThanOrEqualTo: continueButtonWrap.widthAnchor, constant: -40)
- ])
- planColumn.setCustomSpacing(16, after: continueButtonWrap)
- let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: secondaryText)
- secure.alignment = .center
- let secureWrap = NSView()
- secureWrap.translatesAutoresizingMaskIntoConstraints = false
- secureWrap.addSubview(secure)
- NSLayoutConstraint.activate([
- secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
- secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
- secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
- ])
- planColumn.addArrangedSubview(secureWrap)
- secureWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- planColumn.setCustomSpacing(8, after: secureWrap)
- let primaryFooterButton = paywallFooterButton(
- title: storeKitCoordinator.hasPremiumAccess ? "Manage Subscription" : "Continue with free plan",
- action: storeKitCoordinator.hasPremiumAccess
- ? #selector(paywallManageSubscriptionClicked(_:))
- : #selector(closePaywallClicked(_:))
- )
- let restoreButton = paywallFooterButton(
- title: "Restore Purchase",
- action: #selector(paywallRestorePurchaseClicked(_:))
- )
- let privacyButton = paywallFooterButton(
- title: "Privacy Policy",
- action: #selector(paywallPrivacyPolicyClicked(_:))
- )
- let supportButton = paywallFooterButton(
- title: "Support",
- action: #selector(paywallSupportClicked(_:))
- )
- let termsButton = paywallFooterButton(
- title: "Terms of Services",
- action: #selector(paywallTermsOfServicesClicked(_:))
- )
- // Footer: evenly spaced links across the paywall width.
- let footerContent = NSStackView()
- footerContent.translatesAutoresizingMaskIntoConstraints = false
- footerContent.orientation = .horizontal
- footerContent.alignment = .centerY
- footerContent.distribution = .equalSpacing
- footerContent.spacing = 36
- footerContent.addArrangedSubview(primaryFooterButton)
- footerContent.addArrangedSubview(restoreButton)
- footerContent.addArrangedSubview(privacyButton)
- footerContent.addArrangedSubview(supportButton)
- footerContent.addArrangedSubview(termsButton)
- let footerWrap = NSView()
- footerWrap.translatesAutoresizingMaskIntoConstraints = false
- footerWrap.addSubview(footerContent)
- NSLayoutConstraint.activate([
- footerContent.leadingAnchor.constraint(equalTo: footerWrap.leadingAnchor, constant: 44),
- footerContent.trailingAnchor.constraint(equalTo: footerWrap.trailingAnchor, constant: -44),
- footerContent.topAnchor.constraint(equalTo: footerWrap.topAnchor),
- footerContent.bottomAnchor.constraint(equalTo: footerWrap.bottomAnchor)
- ])
- // Push footer to the bottom of the paywall.
- let footerPushSpacer = NSView()
- footerPushSpacer.translatesAutoresizingMaskIntoConstraints = false
- footerPushSpacer.setContentHuggingPriority(.defaultLow, for: .vertical)
- footerPushSpacer.setContentCompressionResistancePriority(.defaultLow, for: .vertical)
- planColumn.addArrangedSubview(footerPushSpacer)
- planColumn.addArrangedSubview(footerWrap)
- footerWrap.widthAnchor.constraint(equalTo: planColumn.widthAnchor).isActive = true
- // Keep a comfortable top margin, but center the paywall content vertically when there's room.
- let contentTopMin = contentStack.topAnchor.constraint(greaterThanOrEqualTo: panel.topAnchor, constant: 84)
- let contentBottomMax = contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -18)
- let contentCenterY = contentStack.centerYAnchor.constraint(equalTo: panel.centerYAnchor)
- contentCenterY.priority = .defaultLow
- let contentBottomEq = contentStack.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -18)
- contentBottomEq.priority = .defaultLow
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 70),
- contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -70),
- contentTopMin,
- contentBottomMax,
- contentCenterY,
- contentBottomEq
- ])
- refreshPaywallStoreUI()
- updatePaywallResponsiveLayout()
- return root
- }
- private func paywallBenefitsSection() -> NSView {
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.widthAnchor.constraint(greaterThanOrEqualToConstant: 320).isActive = true
- let rowOne = NSStackView()
- rowOne.translatesAutoresizingMaskIntoConstraints = false
- rowOne.orientation = .horizontal
- rowOne.spacing = 10
- rowOne.distribution = .fillEqually
- rowOne.alignment = .centerY
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
- rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
- let rowTwo = NSStackView()
- rowTwo.translatesAutoresizingMaskIntoConstraints = false
- rowTwo.orientation = .horizontal
- rowTwo.spacing = 10
- rowTwo.distribution = .fillEqually
- rowTwo.alignment = .centerY
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
- rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
- stack.addArrangedSubview(rowOne)
- stack.addArrangedSubview(rowTwo)
- return stack
- }
- private func paywallHeroCard(eyebrow: String, title: String, subtitle: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 14
- card.layer?.backgroundColor = palette.secondaryCardBackground.cgColor
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let eyebrowLabel = styledLabel(
- eyebrow,
- font: NSFont.systemFont(ofSize: 11, weight: .semibold),
- color: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 0.92),
- kern: 0.8
- )
- eyebrowLabel.alignment = .left
- let titleLabel = styledLabel(
- title,
- font: NSFont.systemFont(ofSize: 18, weight: .semibold),
- color: primaryText,
- lineSpacing: 1
- )
- titleLabel.alignment = .left
- let subtitleLabel = styledLabel(
- subtitle,
- font: NSFont.systemFont(ofSize: 13, weight: .regular),
- color: secondaryText,
- lineSpacing: 2
- )
- subtitleLabel.maximumNumberOfLines = 2
- subtitleLabel.lineBreakMode = .byTruncatingTail
- subtitleLabel.alignment = .left
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.alignment = .leading
- stack.spacing = 6
- stack.addArrangedSubview(eyebrowLabel)
- stack.addArrangedSubview(titleLabel)
- stack.addArrangedSubview(subtitleLabel)
- card.addSubview(stack)
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 12),
- stack.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -12)
- ])
- return card
- }
- private func paywallFeaturesRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 10
- row.alignment = .centerY
- row.distribution = .fillEqually
- row.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
- row.addArrangedSubview(paywallBenefitItem(icon: "💬", text: "Smart scheduling"))
- row.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Faster workflow"))
- row.addArrangedSubview(paywallBenefitItem(icon: "🔔", text: "Reminder notifications"))
- return row
- }
- private func paywallTrustRow() -> NSView {
- let row = NSStackView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.orientation = .horizontal
- row.spacing = 10
- row.alignment = .centerY
- row.distribution = .fillEqually
- row.addArrangedSubview(paywallTrustItem(title: "Cancel anytime", subtitle: "No lock-in"))
- row.addArrangedSubview(paywallTrustItem(title: "Instant access", subtitle: "Unlock all tools"))
- row.addArrangedSubview(paywallTrustItem(title: "Secure billing", subtitle: "Handled by Apple"))
- return row
- }
- private func paywallTrustItem(title: String, subtitle: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 12
- card.layer?.backgroundColor = palette.inputBackground.cgColor
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: primaryText)
- let subtitleLabel = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.alignment = .centerX
- stack.spacing = 2
- stack.addArrangedSubview(titleLabel)
- stack.addArrangedSubview(subtitleLabel)
- card.addSubview(stack)
- NSLayoutConstraint.activate([
- card.heightAnchor.constraint(equalToConstant: 44),
- stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
- stack.centerYAnchor.constraint(equalTo: card.centerYAnchor)
- ])
- return card
- }
- private func paywallBenefitItem(icon: String, text: String) -> NSView {
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 10
- card.layer?.backgroundColor = palette.inputBackground.cgColor
- card.heightAnchor.constraint(equalToConstant: 36).isActive = true
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
- iconWrap.translatesAutoresizingMaskIntoConstraints = false
- iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
- iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
- styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 13, weight: .medium), color: accentBlue)
- iconWrap.addSubview(iconLabel)
- NSLayoutConstraint.activate([
- iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
- iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
- ])
- let title = textLabel(text, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: primaryText)
- card.addSubview(iconWrap)
- card.addSubview(title)
- NSLayoutConstraint.activate([
- iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
- iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
- title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
- title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
- ])
- return card
- }
- private func paywallPlanCard(
- title: String,
- price: String,
- badge: String,
- badgeColor: NSColor,
- subtitle: String?,
- plan: PremiumPlan,
- strikePrice: String?
- ) -> NSView {
- let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
- wrapper.translatesAutoresizingMaskIntoConstraints = false
- wrapper.isBordered = false
- wrapper.bezelStyle = .regularSquare
- wrapper.wantsLayer = true
- wrapper.layer?.backgroundColor = NSColor.clear.cgColor
- wrapper.heightAnchor.constraint(equalToConstant: 138).isActive = true
- wrapper.tag = PremiumPlan.allCases.firstIndex(of: plan) ?? 0
- let card = NSView()
- card.translatesAutoresizingMaskIntoConstraints = false
- card.wantsLayer = true
- card.layer?.cornerRadius = 16
- card.layer?.backgroundColor = palette.sectionCard.cgColor
- wrapper.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 14),
- card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let badgeLabel = styledLabel(
- badge.uppercased(),
- font: NSFont.systemFont(ofSize: 10.5, weight: .semibold),
- color: .white,
- kern: 0.6
- )
- let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
- badgeWrap.translatesAutoresizingMaskIntoConstraints = false
- badgeWrap.wantsLayer = true
- badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
- badgeWrap.layer?.borderWidth = 1
- badgeWrap.layer?.shadowColor = NSColor.black.cgColor
- badgeWrap.layer?.shadowOpacity = 0.20
- badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
- badgeWrap.layer?.shadowRadius = 3
- badgeWrap.addSubview(badgeLabel)
- NSLayoutConstraint.activate([
- badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
- badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
- badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
- badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
- ])
- wrapper.addSubview(badgeWrap)
- let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 16, weight: .semibold), color: accentBlue)
- card.addSubview(titleLabel)
- let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: primaryText)
- card.addSubview(priceLabel)
- paywallPlanPriceLabels[plan, default: []].append(priceLabel)
- NSLayoutConstraint.activate([
- badgeWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
- badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 22),
- priceLabel.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
- priceLabel.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
- ])
- if let subtitle {
- let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 11, weight: .regular), color: secondaryText)
- card.addSubview(sub)
- paywallPlanSubtitleLabels[plan, default: []].append(sub)
- NSLayoutConstraint.activate([
- sub.leadingAnchor.constraint(equalTo: priceLabel.leadingAnchor),
- sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 2),
- sub.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -12)
- ])
- }
- if let strikePrice {
- let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
- card.addSubview(strike)
- paywallPlanStrikeLabels[plan, default: []].append(strike)
- NSLayoutConstraint.activate([
- strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
- ])
- }
- paywallPlanViews[plan, default: []].append(card)
- premiumPlanByView[ObjectIdentifier(card)] = plan
- wrapper.onHoverChanged = { [weak self, weak card] hovering in
- guard let self, let card else { return }
- self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
- }
- wrapper.onHoverChanged?(false)
- return wrapper
- }
- @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
- guard let plan = PremiumPlan.allCases[safe: sender.tag] else { return }
- selectedPremiumPlan = plan
- updatePaywallPlanSelection()
- }
- private func updatePaywallPlanSelection() {
- for (plan, views) in paywallPlanViews {
- for view in views {
- applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
- }
- }
- paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
- updatePaywallContinueState(isLoading: false)
- }
- private func paywallOfferText(for plan: PremiumPlan) -> String {
- if storeKitCoordinator.hasLifetimePremiumAccess {
- return "Lifetime premium is active on this Apple ID."
- }
- guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
- let price = paywallDisplayPrice(for: plan)
- switch plan {
- case .weekly: return "\(price)/week"
- case .monthly: return "\(price)/month"
- case .yearly: return "\(price)/year"
- case .lifetime: return "\(price) one-time purchase"
- }
- }
- if let offer = product.subscription?.introductoryOffer,
- offer.paymentMode == .freeTrial,
- let trial = paywallSubscriptionPeriodDescription(offer.period) {
- let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod)
- if let renewal {
- return "Free for \(trial), then \(product.displayPrice)/\(renewal)"
- }
- return "Free for \(trial), then \(product.displayPrice)"
- }
- if plan == .lifetime {
- return "\(product.displayPrice) one-time purchase"
- }
- if let renewal = paywallSubscriptionPeriodDescription(product.subscription?.subscriptionPeriod) {
- return "\(product.displayPrice)/\(renewal)"
- }
- let fallbackPrice = paywallDisplayPrice(for: plan)
- switch plan {
- case .weekly: return "\(fallbackPrice)/week"
- case .monthly: return "\(fallbackPrice)/month"
- case .yearly: return "\(fallbackPrice)/year"
- case .lifetime: return "\(fallbackPrice) one-time purchase"
- }
- }
- private func paywallFreeTrialText(for plan: PremiumPlan) -> String? {
- guard let product = storeKitCoordinator.productsByID[plan.rawValue],
- let offer = product.subscription?.introductoryOffer,
- offer.paymentMode == .freeTrial,
- let trial = paywallSubscriptionPeriodDescription(offer.period) else {
- return nil
- }
- return trial
- }
- private func paywallPlanSubtitle(for plan: PremiumPlan) -> String {
- if plan == .lifetime { return "One-time purchase" }
- guard let product = storeKitCoordinator.productsByID[plan.rawValue] else {
- switch plan {
- case .weekly: return "Billed weekly"
- case .monthly: return "Billed monthly"
- case .yearly: return "Billed yearly"
- case .lifetime: return "One-time purchase"
- }
- }
- if let period = product.subscription?.subscriptionPeriod,
- let unit = paywallSubscriptionPeriodDescription(period),
- let trial = paywallFreeTrialText(for: plan) {
- return "\(product.displayPrice)/\(unit) • \(trial) free trial"
- }
- if let period = product.subscription?.subscriptionPeriod,
- let unit = paywallSubscriptionPeriodDescription(period) {
- return "Billed \(unit)"
- }
- switch plan {
- case .weekly: return "Billed weekly"
- case .monthly: return "Billed monthly"
- case .yearly: return "Billed yearly"
- case .lifetime: return "One-time purchase"
- }
- }
- private func paywallSubscriptionPeriodDescription(_ period: Product.SubscriptionPeriod?) -> String? {
- guard let period else { return nil }
- let value = period.value
- switch period.unit {
- case .day:
- return value == 1 ? "1 day" : "\(value) days"
- case .week:
- return value == 1 ? "1 week" : "\(value) weeks"
- case .month:
- return value == 1 ? "1 month" : "\(value) months"
- case .year:
- return value == 1 ? "1 year" : "\(value) years"
- @unknown default:
- return nil
- }
- }
- private func refreshPaywallStoreUI() {
- refreshPaywallPlanPrices()
- updatePaywallPlanSelection()
- updatePaywallContinueState(isLoading: false)
- }
- private func refreshPaywallPlanPrices() {
- for plan in PremiumPlan.allCases {
- let price = paywallDisplayPrice(for: plan)
- paywallPlanPriceLabels[plan]?.forEach { $0.stringValue = price }
- let subtitle = paywallPlanSubtitle(for: plan)
- paywallPlanSubtitleLabels[plan]?.forEach { $0.stringValue = subtitle }
- }
- }
- private func paywallDisplayPrice(for plan: PremiumPlan) -> String {
- if let product = storeKitCoordinator.productsByID[plan.rawValue] {
- return product.displayPrice
- }
- switch plan {
- case .weekly: return "3.99"
- case .monthly: return "7.99"
- case .yearly: return "24.99"
- case .lifetime: return "44.99"
- }
- }
- @objc private func paywallContinueClicked(_ sender: Any?) {
- startSelectedPlanPurchase()
- }
- private func paywallFooterButton(title: String, action: Selector) -> NSButton {
- let button = NSButton(title: title, target: self, action: action)
- button.translatesAutoresizingMaskIntoConstraints = false
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.contentTintColor = secondaryText
- button.alignment = .center
- button.heightAnchor.constraint(equalToConstant: 24).isActive = true
- return button
- }
- @objc private func paywallPrivacyPolicyClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Privacy Policy",
- message: "Placeholder: Privacy Policy content/link will be added before publishing."
- )
- }
- @objc private func paywallSupportClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Support",
- message: "Placeholder: Support contact details/link will be added before publishing."
- )
- }
- @objc private func paywallTermsOfServicesClicked(_ sender: Any?) {
- showSimpleAlert(
- title: "Terms of Services",
- message: "Placeholder: Terms of Services content/link will be added before publishing."
- )
- }
- @objc private func paywallManageSubscriptionClicked(_ sender: Any?) {
- openManageSubscriptions()
- }
- @objc private func paywallRestorePurchaseClicked(_ sender: Any?) {
- Task { [weak self] in
- guard let self else { return }
- let message = await self.storeKitCoordinator.restorePurchases()
- await MainActor.run {
- self.refreshPaywallStoreUI()
- self.updatePremiumButtons()
- self.showSimpleAlert(title: "Restore Purchase", message: message)
- }
- }
- }
- private func startSelectedPlanPurchase() {
- guard paywallContinueEnabled else { return }
- paywallPurchaseTask?.cancel()
- updatePaywallContinueState(isLoading: true)
- let selectedPlan = selectedPremiumPlan
- let hadPremiumBeforePurchase = storeKitCoordinator.hasPremiumAccess
- let hadLifetimeBeforePurchase = storeKitCoordinator.hasLifetimePremiumAccess
- paywallPurchaseTask = Task { [weak self] in
- guard let self else { return }
- if self.storeKitCoordinator.productsByID[selectedPlan.rawValue] == nil {
- await self.storeKitCoordinator.refreshProducts()
- }
- let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
- await MainActor.run {
- self.updatePaywallContinueState(isLoading: false)
- self.refreshPaywallStoreUI()
- self.updatePremiumButtons()
- switch result {
- case .success:
- if self.storeKitCoordinator.hasPremiumAccess {
- // Ensure QA can repeatedly verify rating flow right after upgrading.
- UserDefaults.standard.set(false, forKey: self.ratingCompletedKey)
- UserDefaults.standard.set(false, forKey: self.ratingPromptShownKey)
- self.schedulePremiumRatingPromptIfNeeded()
- }
- if selectedPlan == .lifetime, hadLifetimeBeforePurchase == false, hadPremiumBeforePurchase {
- self.showSimpleAlert(
- title: "Lifetime Activated",
- message: "Lifetime premium is now active. If you previously subscribed (weekly/monthly/yearly), please cancel that subscription in your Apple ID subscriptions to avoid future renewals."
- )
- } else {
- self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
- }
- self.hidePaywall()
- if self.storeKitCoordinator.hasPremiumAccess {
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak self] in
- self?.presentRatingPrompt(source: "premium")
- }
- }
- case .pending:
- self.showSimpleAlert(title: "Purchase Pending", message: "Your purchase is pending approval. You can continue once it completes.")
- case .cancelled:
- break
- case .failed(let message):
- let fallbackMessage = "StoreKit did not return products for this plan yet. Wait a few seconds and try again, or reopen the app."
- if message.contains("Product not available") {
- self.showSimpleAlert(title: "Purchase Failed", message: fallbackMessage)
- } else {
- self.showSimpleAlert(title: "Purchase Failed", message: message)
- }
- }
- }
- }
- }
- private func updatePaywallContinueState(isLoading: Bool) {
- if isLoading {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Processing..."
- paywallContinueButton?.alphaValue = 0.75
- return
- }
- if storeKitCoordinator.hasLifetimePremiumAccess {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Lifetime Premium Active"
- paywallContinueButton?.alphaValue = 0.75
- } else if storeKitCoordinator.productsByID[selectedPremiumPlan.rawValue] == nil {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Store Unavailable"
- paywallContinueButton?.alphaValue = 0.75
- } else {
- paywallContinueEnabled = true
- if let trial = paywallFreeTrialText(for: selectedPremiumPlan) {
- paywallContinueLabel?.stringValue = "Start with \(trial) trial"
- } else {
- paywallContinueLabel?.stringValue = "Continue"
- }
- paywallContinueButton?.alphaValue = 1.0
- }
- }
- private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
- let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
- let idleBorder = palette.inputBorder
- let hoverBlend = palette.isDarkMode ? NSColor.white : NSColor.black
- let hoverIdleBackground =
- palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
- let selectedBackground = palette.isDarkMode
- ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
- : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
- card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
- card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
- card.layer?.borderWidth = isSelected ? 2 : 1
- card.layer?.shadowColor = NSColor.black.cgColor
- card.layer?.shadowOpacity = isSelected ? (palette.isDarkMode ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
- card.layer?.shadowOffset = CGSize(width: 0, height: -1)
- card.layer?.shadowRadius = isSelected ? (palette.isDarkMode ? 10 : 6) : (hovering ? 7 : 5)
- }
- // MARK: - Home UI
- private func makeHomeView(profile: GoogleUserProfile?) -> NSView {
- let root = NSView()
- let shell = NSView()
- shell.wantsLayer = true
- shell.layer?.backgroundColor = appShellBackground.cgColor
- shell.layer?.cornerRadius = appShellCornerRadius
- shell.layer?.borderWidth = 1
- shell.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).cgColor
- let chromeColumn = NSView()
- chromeColumn.wantsLayer = true
- chromeColumn.layer?.backgroundColor = chromeUnifiedBackground.cgColor
- chromeColumn.layer?.cornerRadius = appShellCornerRadius
- chromeColumn.layer?.maskedCorners = [.layerMinXMinYCorner, .layerMinXMaxYCorner]
- chromeColumn.layer?.masksToBounds = true
- let chromeDivider = NSView()
- chromeDivider.wantsLayer = true
- chromeDivider.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.32).cgColor
- let chromeHeader = NSView()
- chromeHeader.wantsLayer = true
- chromeHeader.layer?.backgroundColor = chromeUnifiedBackground.cgColor
- homeSidebarRowViews = [:]
- homeSidebarIconViews = [:]
- homeSidebarLabels = [:]
- let sidebar = makeSidebar(items: ["Home", "Meetings", "Scheduler", "Widgets", "Settings"], selected: selectedHomeSidebarItem, style: .home)
- let content = NSView()
- content.wantsLayer = true
- content.layer?.backgroundColor = NSColor.clear.cgColor
- root.addSubview(shell)
- shell.addSubview(chromeColumn)
- shell.addSubview(content)
- chromeColumn.addSubview(chromeDivider)
- chromeColumn.addSubview(chromeHeader)
- chromeColumn.addSubview(sidebar)
- shell.translatesAutoresizingMaskIntoConstraints = false
- chromeColumn.translatesAutoresizingMaskIntoConstraints = false
- chromeDivider.translatesAutoresizingMaskIntoConstraints = false
- chromeHeader.translatesAutoresizingMaskIntoConstraints = false
- sidebar.translatesAutoresizingMaskIntoConstraints = false
- content.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- shell.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- shell.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- shell.topAnchor.constraint(equalTo: root.topAnchor, constant: 0),
- shell.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- chromeColumn.leadingAnchor.constraint(equalTo: shell.leadingAnchor),
- chromeColumn.topAnchor.constraint(equalTo: shell.topAnchor),
- chromeColumn.bottomAnchor.constraint(equalTo: shell.bottomAnchor),
- chromeColumn.widthAnchor.constraint(equalToConstant: 82),
- chromeDivider.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
- chromeDivider.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
- chromeDivider.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- chromeDivider.widthAnchor.constraint(equalToConstant: 1),
- chromeHeader.topAnchor.constraint(equalTo: chromeColumn.topAnchor),
- chromeHeader.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
- chromeHeader.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- chromeHeader.heightAnchor.constraint(equalToConstant: homeChromeHeaderHeight),
- sidebar.leadingAnchor.constraint(equalTo: chromeColumn.leadingAnchor),
- sidebar.trailingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- sidebar.topAnchor.constraint(equalTo: chromeHeader.bottomAnchor),
- sidebar.bottomAnchor.constraint(equalTo: chromeColumn.bottomAnchor),
- content.leadingAnchor.constraint(equalTo: chromeColumn.trailingAnchor),
- content.trailingAnchor.constraint(equalTo: shell.trailingAnchor),
- content.topAnchor.constraint(equalTo: shell.topAnchor),
- content.bottomAnchor.constraint(equalTo: shell.bottomAnchor)
- ])
- let brandStack = NSStackView()
- brandStack.orientation = .vertical
- brandStack.spacing = 0
- brandStack.alignment = .leading
- let brandTop = makeLabel("zoom", size: 14, color: primaryText, weight: .semibold, centered: false)
- let brandBottom = makeLabel("Workplace", size: 27, color: primaryText, weight: .bold, centered: false)
- brandTop.font = .systemFont(ofSize: 12, weight: .semibold)
- brandBottom.font = .systemFont(ofSize: 12, weight: .bold)
- [brandTop, brandBottom].forEach { brandStack.addArrangedSubview($0) }
- let topBar = NSView()
- topBar.wantsLayer = true
- topBar.layer?.backgroundColor = chromeUnifiedBackground.cgColor
-
- let topBarDivider = NSView()
- topBarDivider.wantsLayer = true
- topBarDivider.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
- let searchPill = NSView()
- searchPill.wantsLayer = true
- searchPill.layer?.backgroundColor = searchPillBackground.cgColor
- searchPill.layer?.cornerRadius = 10
- searchPill.layer?.borderWidth = 0
- let searchIcon = NSImageView()
- searchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search")
- searchIcon.contentTintColor = mutedText.withAlphaComponent(0.9)
- searchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 16, weight: .regular)
- searchIcon.imageScaling = .scaleProportionallyUpOrDown
- let searchHintLabel = makeLabel("Search ⌘ + E", size: 13, color: mutedText, weight: .regular, centered: true)
- searchHintLabel.isHidden = false
- let searchField = SearchPillTextField()
- searchField.isBordered = false
- searchField.drawsBackground = false
- searchField.backgroundColor = .clear
- searchField.focusRingType = .none
- searchField.isEditable = false
- searchField.isSelectable = false
- searchField.font = .systemFont(ofSize: 13, weight: .regular)
- searchField.textColor = primaryText
- searchField.alignment = .left
- searchField.placeholderString = nil
- if let cell = searchField.cell as? NSTextFieldCell {
- cell.isBezeled = false
- cell.isBordered = false
- cell.backgroundColor = .clear
- }
- let updateSearchHintVisibility = { [weak searchField, weak searchHintLabel] in
- guard let searchField, let searchHintLabel else { return }
- let shouldShow = searchField.isSearchFocused == false && searchField.stringValue.isEmpty
- searchHintLabel.isHidden = shouldShow == false
- }
- searchField.onFocusChange = { [weak self] focused in
- self?.applySearchPillFocusBorder(focused: focused)
- updateSearchHintVisibility()
- }
- updateSearchHintVisibility()
- let searchTap = NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped))
- searchPill.addGestureRecognizer(searchTap)
- searchField.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- searchIcon.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- searchHintLabel.addGestureRecognizer(NSClickGestureRecognizer(target: self, action: #selector(topSearchPillTapped)))
- let searchRow = NSStackView()
- searchRow.orientation = .horizontal
- searchRow.spacing = 14
- searchRow.alignment = .centerY
- searchRow.addArrangedSubview(searchPill)
- let rightTopBarCluster = NSStackView()
- rightTopBarCluster.orientation = .horizontal
- rightTopBarCluster.spacing = 10
- rightTopBarCluster.alignment = .centerY
- let upgradeToProButton = makeUpgradeToProButton(action: #selector(upgradeToProTapped))
- topBarPremiumButton = upgradeToProButton
- updateTopBarPremiumButton()
- let authButton = NSButton(title: "", target: self, action: #selector(topBarAuthButtonTapped))
- authButton.isBordered = false
- authButton.wantsLayer = true
- authButton.layer?.cornerRadius = 11
- authButton.contentTintColor = .white
- authButton.font = .systemFont(ofSize: 12, weight: .semibold)
- authButton.bezelStyle = .shadowlessSquare
- self.topBarAuthButton = authButton
- [upgradeToProButton, authButton].forEach { rightTopBarCluster.addArrangedSubview($0) }
- let timeTitle = makeLabel("--:--", size: 56, color: primaryText, weight: .bold, centered: true)
- let dateTitle = makeLabel("-", size: 16, color: secondaryText, weight: .regular, centered: true)
- let actions = NSStackView(views: [
- makeActionTile(title: "New meeting", symbol: "video.fill", color: accentOrange, action: #selector(newMeetingTapped)),
- makeActionTile(title: "Join", symbol: "plus", color: accentBlue, action: #selector(joinMeetingTapped)),
- makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingTapped))
- ])
- actions.orientation = .horizontal
- actions.spacing = 12
- actions.alignment = .centerY
- actions.distribution = .fillEqually
- let panel = NSView()
- panel.wantsLayer = true
- panel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- panel.layer?.cornerRadius = 16
- panel.layer?.borderWidth = 1
- panel.layer?.borderColor = palette.inputBorder.cgColor
-
- let todaysDateFormatter = DateFormatter()
- todaysDateFormatter.dateFormat = "EEEE, MMM d"
- let panelHeader = makeLabel(todaysDateFormatter.string(from: Date()), size: 18, color: primaryText, weight: .semibold, centered: false)
- let meetingsStatus = makeLabel("Upcoming meetings", size: 11, color: secondaryText, weight: .medium, centered: false)
-
- let meetingsDayNav = NSStackView()
- meetingsDayNav.orientation = .horizontal
- meetingsDayNav.spacing = 4
- meetingsDayNav.alignment = .centerY
- let prevDayButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(meetingsPrevDayTapped), dimension: 14, pointSize: 7, toolTip: "Previous day")
- let todayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(meetingsTodayTapped))
- let nextDayButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(meetingsNextDayTapped), dimension: 14, pointSize: 7, toolTip: "Next day")
- [todayButton, prevDayButton, nextDayButton].forEach { meetingsDayNav.addArrangedSubview($0) }
-
- let noMeeting = makeLabel("No meetings scheduled for today.", size: 15, color: secondaryText, weight: .regular, centered: true)
- let meetingsScrollView = NSScrollView()
- meetingsScrollView.drawsBackground = false
- meetingsScrollView.hasVerticalScroller = true
- meetingsScrollView.hasHorizontalScroller = false
- meetingsScrollView.autohidesScrollers = true
- let meetingsDocument = FlippedView()
- let meetingsStack = NSStackView()
- meetingsStack.orientation = .vertical
- meetingsStack.spacing = 14
- meetingsStack.alignment = .leading
- // Keep meeting cards pinned to the top of the scroll content.
- meetingsStack.setContentHuggingPriority(.required, for: .vertical)
- meetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
- let refreshMeetingsButton = NSButton(title: "Refresh", target: self, action: #selector(refreshMeetingsTapped))
- refreshMeetingsButton.isBordered = false
- refreshMeetingsButton.font = .systemFont(ofSize: 14, weight: .semibold)
- refreshMeetingsButton.contentTintColor = primaryText
- let refreshSymbolConfig = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
- if let base = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh"),
- let image = base.withSymbolConfiguration(refreshSymbolConfig) {
- image.isTemplate = true
- refreshMeetingsButton.image = image
- refreshMeetingsButton.imagePosition = .imageLeading
- refreshMeetingsButton.imageHugsTitle = true
- refreshMeetingsButton.imageScaling = .scaleNone
- }
- refreshMeetingsButton.wantsLayer = true
- refreshMeetingsButton.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor(calibratedRed: 36 / 255, green: 39 / 255, blue: 46 / 255, alpha: 1)
- : NSColor.black.withAlphaComponent(0.06)
- ).cgColor
- refreshMeetingsButton.layer?.cornerRadius = 11
- refreshMeetingsButton.layer?.borderWidth = 1
- refreshMeetingsButton.layer?.borderColor = palette.inputBorder.cgColor
- self.refreshMeetingsButton = refreshMeetingsButton
- let placeholder = makeLabel("Coming soon", size: 22, color: secondaryText, weight: .semibold, centered: true)
- placeholder.isHidden = true
- let settingsView = makeSettingsView()
- settingsView.isHidden = selectedHomeSidebarItem != "Settings"
- let meetingsPageView = makeMeetingsPageView()
- meetingsPageView.isHidden = selectedHomeSidebarItem != "Meetings"
- let widgetsPageView = makeWidgetsPageView()
- widgetsPageView.isHidden = selectedHomeSidebarItem != "Widgets"
- let searchOverlay = NSView()
- searchOverlay.wantsLayer = true
- searchOverlay.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.45).cgColor
- searchOverlay.isHidden = true
- searchOverlay.translatesAutoresizingMaskIntoConstraints = false
- let searchPanel = NSView()
- searchPanel.wantsLayer = true
- searchPanel.layer?.backgroundColor = secondaryCardBackground.cgColor
- searchPanel.layer?.cornerRadius = 14
- searchPanel.layer?.borderWidth = 1
- searchPanel.layer?.borderColor = palette.inputBorder.cgColor
- searchPanel.translatesAutoresizingMaskIntoConstraints = false
- let popupTitle = makeLabel("Search Meetings", size: 18, color: primaryText, weight: .semibold, centered: false)
- popupTitle.translatesAutoresizingMaskIntoConstraints = false
- let popupSubtitle = makeLabel("Find any scheduled meeting instantly", size: 12, color: secondaryText, weight: .regular, centered: false)
- popupSubtitle.translatesAutoresizingMaskIntoConstraints = false
- let popupSearchChrome = NSView()
- popupSearchChrome.translatesAutoresizingMaskIntoConstraints = false
- popupSearchChrome.wantsLayer = true
- popupSearchChrome.layer?.backgroundColor = searchPillBackground.cgColor
- popupSearchChrome.layer?.cornerRadius = 10
- popupSearchChrome.layer?.borderWidth = 1
- popupSearchChrome.layer?.borderColor = palette.inputBorder.cgColor
- let popupSearchIcon = NSImageView()
- popupSearchIcon.translatesAutoresizingMaskIntoConstraints = false
- popupSearchIcon.image = NSImage(systemSymbolName: "magnifyingglass", accessibilityDescription: "Search meetings")
- popupSearchIcon.contentTintColor = secondaryText
- popupSearchIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .regular)
- let popupSearchField = NSTextField()
- popupSearchField.translatesAutoresizingMaskIntoConstraints = false
- popupSearchField.isBordered = false
- popupSearchField.drawsBackground = false
- popupSearchField.backgroundColor = .clear
- popupSearchField.focusRingType = .none
- popupSearchField.font = .systemFont(ofSize: 15, weight: .regular)
- popupSearchField.textColor = primaryText
- popupSearchField.placeholderString = "Search all meetings by title, host, source, ID..."
- let popupResultsScroll = NSScrollView()
- popupResultsScroll.translatesAutoresizingMaskIntoConstraints = false
- popupResultsScroll.drawsBackground = false
- popupResultsScroll.hasVerticalScroller = true
- popupResultsScroll.hasHorizontalScroller = false
- popupResultsScroll.autohidesScrollers = true
- let popupResultsDocument = FlippedView()
- popupResultsDocument.translatesAutoresizingMaskIntoConstraints = false
- let popupResultsStack = NSStackView()
- popupResultsStack.translatesAutoresizingMaskIntoConstraints = false
- popupResultsStack.orientation = .vertical
- popupResultsStack.spacing = 10
- popupResultsStack.alignment = .leading
- let popupEmpty = makeLabel("No meetings match your search.", size: 13, color: secondaryText, weight: .regular, centered: true)
- popupEmpty.translatesAutoresizingMaskIntoConstraints = false
- popupEmpty.isHidden = true
- root.addSubview(searchOverlay)
- searchOverlay.addSubview(searchPanel)
- searchPanel.addSubview(popupTitle)
- searchPanel.addSubview(popupSubtitle)
- searchPanel.addSubview(popupSearchChrome)
- searchPanel.addSubview(popupResultsScroll)
- searchPanel.addSubview(popupEmpty)
- popupSearchChrome.addSubview(popupSearchIcon)
- popupSearchChrome.addSubview(popupSearchField)
- popupResultsScroll.documentView = popupResultsDocument
- popupResultsDocument.addSubview(popupResultsStack)
- let schedulerRoot = NSView()
- schedulerRoot.isHidden = selectedHomeSidebarItem != "Scheduler"
- let schedulerSplit = NSStackView()
- schedulerSplit.orientation = .horizontal
- schedulerSplit.spacing = 14
- schedulerSplit.distribution = .fillEqually
- schedulerSplit.alignment = .top
- let schedulerCalendarPanel = NSView()
- schedulerCalendarPanel.wantsLayer = true
- schedulerCalendarPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- schedulerCalendarPanel.layer?.cornerRadius = 16
- schedulerCalendarPanel.layer?.borderWidth = 1
- schedulerCalendarPanel.layer?.borderColor = palette.inputBorder.cgColor
- let schedulerMeetingsPanel = NSView()
- schedulerMeetingsPanel.wantsLayer = true
- schedulerMeetingsPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- schedulerMeetingsPanel.layer?.cornerRadius = 16
- schedulerMeetingsPanel.layer?.borderWidth = 1
- schedulerMeetingsPanel.layer?.borderColor = palette.inputBorder.cgColor
- let schedulerCalendarTopRow = NSStackView()
- schedulerCalendarTopRow.orientation = .horizontal
- schedulerCalendarTopRow.alignment = .centerY
- schedulerCalendarTopRow.distribution = .fill
- schedulerCalendarTopRow.spacing = 10
- let schedulerConnectLabel = makeLabel("Zoom calendar", size: 18, color: accentBlue, weight: .semibold, centered: false)
- let schedulerPlusButton = NSButton(title: "", target: self, action: #selector(scheduleMeetingTapped))
- schedulerPlusButton.isBordered = false
- schedulerPlusButton.bezelStyle = .shadowlessSquare
- schedulerPlusButton.focusRingType = .none
- schedulerPlusButton.wantsLayer = true
- schedulerPlusButton.layer?.backgroundColor = accentBlue.cgColor
- schedulerPlusButton.layer?.cornerRadius = 14
- schedulerPlusButton.contentTintColor = .white
- schedulerPlusButton.toolTip = "Connect calendar"
- if let image = NSImage(systemSymbolName: "plus", accessibilityDescription: "Connect calendar") {
- schedulerPlusButton.image = image
- schedulerPlusButton.imagePosition = .imageOnly
- }
- schedulerPlusButton.translatesAutoresizingMaskIntoConstraints = false
- schedulerPlusButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
- schedulerPlusButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- let schedulerMonthRow = NSStackView()
- schedulerMonthRow.orientation = .horizontal
- schedulerMonthRow.alignment = .centerY
- schedulerMonthRow.spacing = 8
- let schedulerPrevMonthButton = makeNavGlyphButton(symbol: "chevron.left", action: #selector(schedulerPrevMonthTapped), dimension: 18, pointSize: 10, toolTip: "Previous month")
- let schedulerMonthLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
- let schedulerNextMonthButton = makeNavGlyphButton(symbol: "chevron.right", action: #selector(schedulerNextMonthTapped), dimension: 18, pointSize: 10, toolTip: "Next month")
- [schedulerPrevMonthButton, schedulerMonthLabel, schedulerNextMonthButton].forEach { schedulerMonthRow.addArrangedSubview($0) }
- let schedulerWeekdaysRow = NSStackView()
- schedulerWeekdaysRow.orientation = .horizontal
- schedulerWeekdaysRow.distribution = .fillEqually
- // Match date-row spacing so each date column aligns under weekday labels.
- schedulerWeekdaysRow.spacing = 6
- let weekdaySymbols = ["S", "M", "T", "W", "T", "F", "S"]
- for symbol in weekdaySymbols {
- let weekdayLabel = makeLabel(symbol, size: 12, color: mutedText, weight: .medium, centered: true)
- schedulerWeekdaysRow.addArrangedSubview(weekdayLabel)
- }
- let schedulerCalendarGrid = NSStackView()
- schedulerCalendarGrid.orientation = .vertical
- schedulerCalendarGrid.distribution = .fillEqually
- schedulerCalendarGrid.alignment = .leading
- schedulerCalendarGrid.spacing = 6
- let schedulerRightTopRow = NSStackView()
- schedulerRightTopRow.orientation = .horizontal
- schedulerRightTopRow.alignment = .centerY
- schedulerRightTopRow.spacing = 10
- let schedulerSelectedDateLabel = makeLabel("-", size: 30, color: primaryText, weight: .semibold, centered: false)
- let schedulerTodayButton = makeMeetingsDayChipButton(title: "Today", symbol: "calendar", action: #selector(schedulerTodayTapped))
- schedulerTodayButton.toolTip = "Jump to today"
- [schedulerTodayButton].forEach { schedulerRightTopRow.addArrangedSubview($0) }
- let schedulerMeetingsHeader = makeLabel("Scheduled meetings", size: 13, color: secondaryText, weight: .medium, centered: false)
- let schedulerMeetingsScroll = NSScrollView()
- schedulerMeetingsScroll.drawsBackground = false
- schedulerMeetingsScroll.hasVerticalScroller = true
- schedulerMeetingsScroll.hasHorizontalScroller = false
- schedulerMeetingsScroll.autohidesScrollers = true
- let schedulerMeetingsDocument = FlippedView()
- let schedulerMeetingsStack = NSStackView()
- schedulerMeetingsStack.orientation = .vertical
- schedulerMeetingsStack.spacing = 12
- schedulerMeetingsStack.alignment = .leading
- schedulerMeetingsStack.setContentHuggingPriority(.required, for: .vertical)
- schedulerMeetingsStack.setContentCompressionResistancePriority(.required, for: .vertical)
- schedulerMeetingsScroll.documentView = schedulerMeetingsDocument
- schedulerMeetingsDocument.addSubview(schedulerMeetingsStack)
- let schedulerEmptyLabel = makeLabel("No meetings scheduled for this date.", size: 15, color: secondaryText, weight: .regular, centered: true)
- schedulerEmptyLabel.isHidden = true
- let bodyRow = NSView()
- bodyRow.translatesAutoresizingMaskIntoConstraints = false
- let mainContentColumn = NSView()
- mainContentColumn.translatesAutoresizingMaskIntoConstraints = false
- mainContentColumn.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
- let rightWebPanel = NSView()
- rightWebPanel.translatesAutoresizingMaskIntoConstraints = false
- rightWebPanel.wantsLayer = true
- rightWebPanel.layer?.cornerRadius = 16
- rightWebPanel.layer?.masksToBounds = true
- rightWebPanel.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- rightWebPanel.layer?.borderWidth = 1
- rightWebPanel.layer?.borderColor = palette.inputBorder.cgColor
- let webToolbar = NSView()
- webToolbar.translatesAutoresizingMaskIntoConstraints = false
- webToolbar.wantsLayer = true
- webToolbar.layer?.backgroundColor = chromeUnifiedBackground.withAlphaComponent(0.55).cgColor
- func prepareEmbeddedToolButton(_ button: NSButton, symbol: String) {
- button.translatesAutoresizingMaskIntoConstraints = false
- button.bezelStyle = .toolbar
- if let image = NSImage(systemSymbolName: symbol, accessibilityDescription: nil) {
- button.image = image
- }
- }
- let browserBack = NSButton()
- let browserForward = NSButton()
- let browserReload = NSButton()
- let browserClose = NSButton()
- prepareEmbeddedToolButton(browserBack, symbol: "chevron.left")
- prepareEmbeddedToolButton(browserForward, symbol: "chevron.right")
- prepareEmbeddedToolButton(browserReload, symbol: "arrow.clockwise")
- prepareEmbeddedToolButton(browserClose, symbol: "xmark.circle.fill")
- browserClose.toolTip = "Close web panel"
- browserBack.target = self
- browserBack.action = #selector(embeddedBrowserBackTapped)
- browserForward.target = self
- browserForward.action = #selector(embeddedBrowserForwardTapped)
- browserReload.target = self
- browserReload.action = #selector(embeddedBrowserReloadTapped)
- browserClose.target = self
- browserClose.action = #selector(embeddedBrowserClosePanelTapped)
- let toolbarSpring = NSView()
- toolbarSpring.translatesAutoresizingMaskIntoConstraints = false
- toolbarSpring.setContentHuggingPriority(.defaultLow, for: .horizontal)
- let webToolBarStack = NSStackView(views: [browserBack, browserForward, browserReload, toolbarSpring, browserClose])
- webToolBarStack.orientation = .horizontal
- webToolBarStack.spacing = 6
- webToolBarStack.alignment = .centerY
- webToolBarStack.translatesAutoresizingMaskIntoConstraints = false
- let embeddedBrowserConfiguration = WKWebViewConfiguration()
- embeddedBrowserConfiguration.preferences.javaScriptCanOpenWindowsAutomatically = true
- let embeddedWK = WKWebView(frame: .zero, configuration: embeddedBrowserConfiguration)
- embeddedWK.translatesAutoresizingMaskIntoConstraints = false
- embeddedWK.setContentCompressionResistancePriority(.init(1), for: .horizontal)
- embeddedWK.setContentHuggingPriority(.init(1), for: .horizontal)
- embeddedWK.navigationDelegate = self
- embeddedWK.uiDelegate = self
- content.addSubview(topBar)
- content.addSubview(topBarDivider)
- content.addSubview(bodyRow)
- bodyRow.addSubview(mainContentColumn)
- bodyRow.addSubview(rightWebPanel)
- rightWebPanel.addSubview(webToolbar)
- webToolbar.addSubview(webToolBarStack)
- rightWebPanel.addSubview(embeddedWK)
- let rightPanelWidthConstraint = rightWebPanel.widthAnchor.constraint(equalToConstant: 0)
- let mainColumnCollapsedWidthConstraint = mainContentColumn.widthAnchor.constraint(equalToConstant: 0)
- mainColumnCollapsedWidthConstraint.isActive = false
- [brandStack, searchRow, rightTopBarCluster, searchPill, searchField, searchIcon, searchHintLabel].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- }
- [brandStack].forEach {
- shell.addSubview($0)
- }
- [searchRow, rightTopBarCluster].forEach {
- topBar.addSubview($0)
- }
- [searchIcon, searchField, searchHintLabel].forEach {
- searchPill.addSubview($0)
- }
- [timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView, meetingsPageView, widgetsPageView, schedulerRoot].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- mainContentColumn.addSubview($0)
- }
- [schedulerSplit, schedulerCalendarPanel, schedulerMeetingsPanel, schedulerCalendarTopRow, schedulerConnectLabel, schedulerPlusButton, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid, schedulerRightTopRow, schedulerSelectedDateLabel, schedulerTodayButton, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel, schedulerMeetingsDocument, schedulerMeetingsStack].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- }
- schedulerRoot.addSubview(schedulerSplit)
- [schedulerCalendarPanel, schedulerMeetingsPanel].forEach { schedulerSplit.addArrangedSubview($0) }
- [schedulerCalendarTopRow, schedulerMonthRow, schedulerWeekdaysRow, schedulerCalendarGrid].forEach { schedulerCalendarPanel.addSubview($0) }
- schedulerCalendarTopRow.addArrangedSubview(schedulerConnectLabel)
- schedulerCalendarTopRow.addArrangedSubview(NSView())
- schedulerCalendarTopRow.addArrangedSubview(schedulerPlusButton)
- [schedulerSelectedDateLabel, schedulerRightTopRow, schedulerMeetingsHeader, schedulerMeetingsScroll, schedulerEmptyLabel].forEach { schedulerMeetingsPanel.addSubview($0) }
- topBar.translatesAutoresizingMaskIntoConstraints = false
- topBarDivider.translatesAutoresizingMaskIntoConstraints = false
- meetingsDocument.translatesAutoresizingMaskIntoConstraints = false
- meetingsStack.translatesAutoresizingMaskIntoConstraints = false
- meetingsScrollView.documentView = meetingsDocument
- meetingsDocument.addSubview(meetingsStack)
- let searchRowCenterX = searchRow.centerXAnchor.constraint(equalTo: topBar.centerXAnchor)
- searchRowCenterX.priority = .defaultHigh
- let brandLeading = brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset)
- brandLeadingConstraint = brandLeading
- NSLayoutConstraint.activate([
- brandLeading,
- brandStack.trailingAnchor.constraint(lessThanOrEqualTo: searchRow.leadingAnchor, constant: -12),
- brandStack.centerYAnchor.constraint(equalTo: chromeHeader.centerYAnchor, constant: -1),
- topBar.topAnchor.constraint(equalTo: content.topAnchor),
- topBar.leadingAnchor.constraint(equalTo: content.leadingAnchor),
- topBar.trailingAnchor.constraint(equalTo: content.trailingAnchor),
- topBar.heightAnchor.constraint(equalToConstant: 56),
- topBarDivider.topAnchor.constraint(equalTo: topBar.bottomAnchor),
- topBarDivider.leadingAnchor.constraint(equalTo: content.leadingAnchor),
- topBarDivider.trailingAnchor.constraint(equalTo: content.trailingAnchor),
- topBarDivider.heightAnchor.constraint(equalToConstant: 1),
- bodyRow.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
- bodyRow.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
- bodyRow.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
- bodyRow.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
- mainContentColumn.leadingAnchor.constraint(equalTo: bodyRow.leadingAnchor),
- mainContentColumn.topAnchor.constraint(equalTo: bodyRow.topAnchor),
- mainContentColumn.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
- mainContentColumn.trailingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor, constant: -homeEmbeddedBrowserColumnGap),
- rightWebPanel.topAnchor.constraint(equalTo: bodyRow.topAnchor),
- rightWebPanel.bottomAnchor.constraint(equalTo: bodyRow.bottomAnchor),
- rightWebPanel.trailingAnchor.constraint(equalTo: bodyRow.trailingAnchor),
- rightPanelWidthConstraint,
- webToolbar.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
- webToolbar.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
- webToolbar.topAnchor.constraint(equalTo: rightWebPanel.topAnchor),
- webToolbar.heightAnchor.constraint(equalToConstant: 40),
- webToolBarStack.leadingAnchor.constraint(equalTo: webToolbar.leadingAnchor, constant: 8),
- webToolBarStack.trailingAnchor.constraint(equalTo: webToolbar.trailingAnchor, constant: -8),
- webToolBarStack.centerYAnchor.constraint(equalTo: webToolbar.centerYAnchor),
- embeddedWK.topAnchor.constraint(equalTo: webToolbar.bottomAnchor),
- embeddedWK.leadingAnchor.constraint(equalTo: rightWebPanel.leadingAnchor),
- embeddedWK.trailingAnchor.constraint(equalTo: rightWebPanel.trailingAnchor),
- embeddedWK.bottomAnchor.constraint(equalTo: rightWebPanel.bottomAnchor),
- searchRowCenterX,
- searchRow.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
- searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: topBar.leadingAnchor, constant: 40),
- searchRow.leadingAnchor.constraint(greaterThanOrEqualTo: brandStack.trailingAnchor, constant: 16),
- searchRow.trailingAnchor.constraint(lessThanOrEqualTo: rightTopBarCluster.leadingAnchor, constant: -12),
- rightTopBarCluster.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
- rightTopBarCluster.centerYAnchor.constraint(equalTo: topBar.centerYAnchor),
- searchPill.heightAnchor.constraint(equalToConstant: 32),
- searchPill.widthAnchor.constraint(equalToConstant: 320),
- searchIcon.leadingAnchor.constraint(equalTo: searchPill.leadingAnchor, constant: 12),
- searchIcon.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- searchIcon.widthAnchor.constraint(equalToConstant: 16),
- searchIcon.heightAnchor.constraint(equalToConstant: 16),
- searchHintLabel.centerXAnchor.constraint(equalTo: searchPill.centerXAnchor),
- searchHintLabel.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- searchHintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: searchIcon.trailingAnchor, constant: 8),
- searchField.leadingAnchor.constraint(equalTo: searchIcon.trailingAnchor, constant: 8),
- searchField.trailingAnchor.constraint(equalTo: searchPill.trailingAnchor, constant: -10),
- searchField.centerYAnchor.constraint(equalTo: searchPill.centerYAnchor),
- authButton.heightAnchor.constraint(equalToConstant: 30),
- timeTitle.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 20),
- timeTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
- dateTitle.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
- actions.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- actions.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 12),
- actions.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -12),
- actions.heightAnchor.constraint(equalToConstant: 100),
- panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
- panel.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- panel.widthAnchor.constraint(equalToConstant: 610),
- panel.leadingAnchor.constraint(greaterThanOrEqualTo: mainContentColumn.leadingAnchor, constant: 6),
- panel.trailingAnchor.constraint(lessThanOrEqualTo: mainContentColumn.trailingAnchor, constant: -6),
- panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- panel.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -14),
- panelHeader.topAnchor.constraint(equalTo: panel.topAnchor, constant: 20),
- panelHeader.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 16),
- meetingsStatus.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
- meetingsDayNav.centerYAnchor.constraint(equalTo: panelHeader.centerYAnchor),
- meetingsDayNav.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -16),
- meetingsStatus.trailingAnchor.constraint(equalTo: meetingsDayNav.leadingAnchor, constant: -10),
- noMeeting.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
- noMeeting.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
- noMeeting.centerYAnchor.constraint(equalTo: panel.centerYAnchor),
- meetingsScrollView.topAnchor.constraint(equalTo: panelHeader.bottomAnchor, constant: 12),
- meetingsScrollView.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
- meetingsScrollView.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
- meetingsScrollView.bottomAnchor.constraint(equalTo: refreshMeetingsButton.topAnchor, constant: -14),
- meetingsDocument.widthAnchor.constraint(equalTo: meetingsScrollView.contentView.widthAnchor),
- meetingsStack.topAnchor.constraint(equalTo: meetingsDocument.topAnchor),
- meetingsStack.leadingAnchor.constraint(equalTo: meetingsDocument.leadingAnchor),
- meetingsStack.trailingAnchor.constraint(equalTo: meetingsDocument.trailingAnchor),
- meetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: meetingsDocument.bottomAnchor),
- refreshMeetingsButton.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 14),
- refreshMeetingsButton.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -14),
- refreshMeetingsButton.bottomAnchor.constraint(equalTo: panel.bottomAnchor, constant: -12),
- refreshMeetingsButton.heightAnchor.constraint(equalToConstant: 40),
- placeholder.centerXAnchor.constraint(equalTo: mainContentColumn.centerXAnchor),
- placeholder.centerYAnchor.constraint(equalTo: mainContentColumn.centerYAnchor),
- settingsView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- settingsView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- settingsView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- settingsView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- meetingsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- meetingsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- meetingsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- meetingsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- widgetsPageView.topAnchor.constraint(equalTo: mainContentColumn.topAnchor),
- widgetsPageView.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor),
- widgetsPageView.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor),
- widgetsPageView.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor),
- schedulerRoot.topAnchor.constraint(equalTo: mainContentColumn.topAnchor, constant: 8),
- schedulerRoot.leadingAnchor.constraint(equalTo: mainContentColumn.leadingAnchor, constant: 8),
- schedulerRoot.trailingAnchor.constraint(equalTo: mainContentColumn.trailingAnchor, constant: -8),
- schedulerRoot.bottomAnchor.constraint(equalTo: mainContentColumn.bottomAnchor, constant: -8),
- schedulerSplit.topAnchor.constraint(equalTo: schedulerRoot.topAnchor),
- schedulerSplit.leadingAnchor.constraint(equalTo: schedulerRoot.leadingAnchor),
- schedulerSplit.trailingAnchor.constraint(equalTo: schedulerRoot.trailingAnchor),
- schedulerSplit.bottomAnchor.constraint(equalTo: schedulerRoot.bottomAnchor),
- schedulerCalendarTopRow.topAnchor.constraint(equalTo: schedulerCalendarPanel.topAnchor, constant: 18),
- schedulerCalendarTopRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerCalendarTopRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerMonthRow.topAnchor.constraint(equalTo: schedulerCalendarTopRow.bottomAnchor, constant: 18),
- schedulerMonthRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerMonthRow.trailingAnchor.constraint(lessThanOrEqualTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerWeekdaysRow.topAnchor.constraint(equalTo: schedulerMonthRow.bottomAnchor, constant: 14),
- schedulerWeekdaysRow.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerWeekdaysRow.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerCalendarGrid.topAnchor.constraint(equalTo: schedulerWeekdaysRow.bottomAnchor, constant: 10),
- schedulerCalendarGrid.leadingAnchor.constraint(equalTo: schedulerCalendarPanel.leadingAnchor, constant: 16),
- schedulerCalendarGrid.trailingAnchor.constraint(equalTo: schedulerCalendarPanel.trailingAnchor, constant: -16),
- schedulerCalendarGrid.bottomAnchor.constraint(equalTo: schedulerCalendarPanel.bottomAnchor, constant: -16),
- schedulerCalendarGrid.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- schedulerSelectedDateLabel.topAnchor.constraint(equalTo: schedulerMeetingsPanel.topAnchor, constant: 18),
- schedulerSelectedDateLabel.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
- schedulerSelectedDateLabel.trailingAnchor.constraint(lessThanOrEqualTo: schedulerRightTopRow.leadingAnchor, constant: -10),
- schedulerRightTopRow.centerYAnchor.constraint(equalTo: schedulerSelectedDateLabel.centerYAnchor),
- schedulerRightTopRow.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
- schedulerMeetingsHeader.topAnchor.constraint(equalTo: schedulerSelectedDateLabel.bottomAnchor, constant: 12),
- schedulerMeetingsHeader.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 16),
- schedulerMeetingsHeader.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -16),
- schedulerMeetingsScroll.topAnchor.constraint(equalTo: schedulerMeetingsHeader.bottomAnchor, constant: 10),
- schedulerMeetingsScroll.leadingAnchor.constraint(equalTo: schedulerMeetingsPanel.leadingAnchor, constant: 14),
- schedulerMeetingsScroll.trailingAnchor.constraint(equalTo: schedulerMeetingsPanel.trailingAnchor, constant: -14),
- schedulerMeetingsScroll.bottomAnchor.constraint(equalTo: schedulerMeetingsPanel.bottomAnchor, constant: -14),
- schedulerMeetingsDocument.widthAnchor.constraint(equalTo: schedulerMeetingsScroll.contentView.widthAnchor),
- schedulerMeetingsStack.topAnchor.constraint(equalTo: schedulerMeetingsDocument.topAnchor),
- schedulerMeetingsStack.leadingAnchor.constraint(equalTo: schedulerMeetingsDocument.leadingAnchor),
- schedulerMeetingsStack.trailingAnchor.constraint(equalTo: schedulerMeetingsDocument.trailingAnchor),
- schedulerMeetingsStack.bottomAnchor.constraint(lessThanOrEqualTo: schedulerMeetingsDocument.bottomAnchor),
- schedulerEmptyLabel.centerXAnchor.constraint(equalTo: schedulerMeetingsPanel.centerXAnchor),
- schedulerEmptyLabel.centerYAnchor.constraint(equalTo: schedulerMeetingsPanel.centerYAnchor),
- searchOverlay.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- searchOverlay.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- searchOverlay.topAnchor.constraint(equalTo: root.topAnchor),
- searchOverlay.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- searchPanel.centerXAnchor.constraint(equalTo: searchOverlay.centerXAnchor),
- searchPanel.centerYAnchor.constraint(equalTo: searchOverlay.centerYAnchor),
- searchPanel.widthAnchor.constraint(equalToConstant: 640),
- searchPanel.heightAnchor.constraint(equalToConstant: 460),
- popupTitle.topAnchor.constraint(equalTo: searchPanel.topAnchor, constant: 18),
- popupTitle.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupTitle.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupSubtitle.topAnchor.constraint(equalTo: popupTitle.bottomAnchor, constant: 4),
- popupSubtitle.leadingAnchor.constraint(equalTo: popupTitle.leadingAnchor),
- popupSubtitle.trailingAnchor.constraint(equalTo: popupTitle.trailingAnchor),
- popupSearchChrome.topAnchor.constraint(equalTo: popupSubtitle.bottomAnchor, constant: 12),
- popupSearchChrome.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupSearchChrome.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupSearchChrome.heightAnchor.constraint(equalToConstant: 40),
- popupSearchIcon.leadingAnchor.constraint(equalTo: popupSearchChrome.leadingAnchor, constant: 12),
- popupSearchIcon.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
- popupSearchIcon.widthAnchor.constraint(equalToConstant: 16),
- popupSearchIcon.heightAnchor.constraint(equalToConstant: 16),
- popupSearchField.leadingAnchor.constraint(equalTo: popupSearchIcon.trailingAnchor, constant: 8),
- popupSearchField.trailingAnchor.constraint(equalTo: popupSearchChrome.trailingAnchor, constant: -12),
- popupSearchField.centerYAnchor.constraint(equalTo: popupSearchChrome.centerYAnchor),
- popupResultsScroll.topAnchor.constraint(equalTo: popupSearchChrome.bottomAnchor, constant: 12),
- popupResultsScroll.leadingAnchor.constraint(equalTo: searchPanel.leadingAnchor, constant: 18),
- popupResultsScroll.trailingAnchor.constraint(equalTo: searchPanel.trailingAnchor, constant: -18),
- popupResultsScroll.bottomAnchor.constraint(equalTo: searchPanel.bottomAnchor, constant: -18),
- popupResultsStack.topAnchor.constraint(equalTo: popupResultsDocument.topAnchor),
- popupResultsStack.leadingAnchor.constraint(equalTo: popupResultsDocument.leadingAnchor),
- popupResultsStack.trailingAnchor.constraint(equalTo: popupResultsDocument.trailingAnchor),
- popupResultsStack.bottomAnchor.constraint(lessThanOrEqualTo: popupResultsDocument.bottomAnchor),
- popupResultsStack.widthAnchor.constraint(equalTo: popupResultsScroll.contentView.widthAnchor),
- popupEmpty.centerXAnchor.constraint(equalTo: popupResultsScroll.centerXAnchor),
- popupEmpty.centerYAnchor.constraint(equalTo: popupResultsScroll.centerYAnchor)
- ])
- timeLabel = timeTitle
- dateLabel = dateTitle
- homeWelcomeLabel = nil
- homeTimeLabelView = timeTitle
- homeDateLabelView = dateTitle
- homeActionsRow = actions
- homeMeetingsPanel = panel
- homePlaceholderLabel = placeholder
- homeSettingsView = settingsView
- meetingsPageRootView = meetingsPageView
- widgetsPageRootView = widgetsPageView
- schedulerRootView = schedulerRoot
- self.schedulerMonthLabel = schedulerMonthLabel
- schedulerCalendarGridStack = schedulerCalendarGrid
- self.schedulerSelectedDateLabel = schedulerSelectedDateLabel
- schedulerMeetingsListStack = schedulerMeetingsStack
- schedulerMeetingsEmptyLabel = schedulerEmptyLabel
- self.schedulerTodayButton = schedulerTodayButton
- meetingsDayHeaderLabel = panelHeader
- meetingsListStack = meetingsStack
- meetingsStatusLabel = meetingsStatus
- emptyMeetingLabel = noMeeting
- meetingsPrevDayButton = prevDayButton
- meetingsTodayButton = todayButton
- meetingsNextDayButton = nextDayButton
- observeMeetingsScrollEdges(in: meetingsScrollView)
- updateClock()
- updateMeetingsDayUI()
- applyFilteredMeetings()
- applyMeetingsPageFilters(resetVisible: true)
- renderSchedulerCalendarGrid()
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- homeSearchField = searchField
- homeSearchPill = searchPill
- meetingSearchOverlayView = searchOverlay
- meetingSearchPanelView = searchPanel
- meetingSearchField = popupSearchField
- meetingSearchResultsStack = popupResultsStack
- meetingSearchEmptyLabel = popupEmpty
- removeMeetingSearchObserver()
- meetingSearchTextObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidChangeNotification,
- object: popupSearchField,
- queue: .main
- ) { [weak self] _ in
- guard let self else { return }
- self.applyGlobalMeetingSearch(query: popupSearchField.stringValue)
- }
- applyGlobalMeetingSearch(query: "")
- topBarAuthButtonWidthConstraint = authButton.widthAnchor.constraint(equalToConstant: 120)
- topBarAuthButtonWidthConstraint?.isActive = true
- updateTopBarAuthButton()
- applyAuthenticationUIState(triggerInitialRefresh: false)
- updatePremiumLockedSections()
- embeddedBrowserWebView = embeddedWK
- embeddedBrowserPanel = rightWebPanel
- homeBodyRowView = bodyRow
- homeMainContentColumnView = mainContentColumn
- embeddedBrowserPanelWidthConstraint = rightPanelWidthConstraint
- embeddedBrowserMainColumnCollapsedWidthConstraint = mainColumnCollapsedWidthConstraint
- embeddedBrowserBackButton = browserBack
- embeddedBrowserForwardButton = browserForward
- refreshEmbeddedBrowserNavButtons()
- return root
- }
- @objc private func premiumLockedAreaTapped(_ sender: NSButton) {
- showPaywall()
- }
- @MainActor
- private func updatePremiumLockedSections() {
- let shouldLock = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
- homePremiumLockOverlay = updatePremiumLockOverlay(
- existing: homePremiumLockOverlay,
- in: homeMeetingsPanel,
- locked: shouldLock,
- message: "Upgrade to Premium to access meetings"
- )
- meetingsPremiumLockOverlay = updatePremiumLockOverlay(
- existing: meetingsPremiumLockOverlay,
- in: meetingsPageRootView,
- locked: shouldLock,
- message: "Upgrade to Premium to unlock Meetings"
- )
- schedulerPremiumLockOverlay = updatePremiumLockOverlay(
- existing: schedulerPremiumLockOverlay,
- in: schedulerRootView,
- locked: shouldLock,
- message: "Upgrade to Premium to unlock Scheduler"
- )
- }
- @MainActor
- private func updatePremiumLockOverlay(
- existing: NSButton?,
- in target: NSView?,
- locked: Bool,
- message: String
- ) -> NSButton? {
- guard let target else { return existing }
- let button: NSButton
- if let existing, existing.superview === target {
- button = existing
- } else {
- let created = NSButton(title: message, target: self, action: #selector(premiumLockedAreaTapped(_:)))
- created.translatesAutoresizingMaskIntoConstraints = false
- created.isBordered = false
- created.bezelStyle = .shadowlessSquare
- created.alignment = .center
- created.wantsLayer = true
- created.layer?.cornerRadius = 12
- created.layer?.masksToBounds = true
- created.layer?.backgroundColor = NSColor.clear.cgColor
- created.font = .systemFont(ofSize: 15, weight: .semibold)
- created.contentTintColor = .white
- let blur = NSVisualEffectView()
- blur.translatesAutoresizingMaskIntoConstraints = false
- blur.material = .hudWindow
- blur.blendingMode = .withinWindow
- blur.state = .active
- created.addSubview(blur, positioned: .below, relativeTo: nil)
- let dim = NSView()
- dim.translatesAutoresizingMaskIntoConstraints = false
- dim.wantsLayer = true
- dim.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
- created.addSubview(dim, positioned: .above, relativeTo: blur)
- target.addSubview(created)
- NSLayoutConstraint.activate([
- created.leadingAnchor.constraint(equalTo: target.leadingAnchor),
- created.trailingAnchor.constraint(equalTo: target.trailingAnchor),
- created.topAnchor.constraint(equalTo: target.topAnchor),
- created.bottomAnchor.constraint(equalTo: target.bottomAnchor),
- blur.leadingAnchor.constraint(equalTo: created.leadingAnchor),
- blur.trailingAnchor.constraint(equalTo: created.trailingAnchor),
- blur.topAnchor.constraint(equalTo: created.topAnchor),
- blur.bottomAnchor.constraint(equalTo: created.bottomAnchor),
- dim.leadingAnchor.constraint(equalTo: created.leadingAnchor),
- dim.trailingAnchor.constraint(equalTo: created.trailingAnchor),
- dim.topAnchor.constraint(equalTo: created.topAnchor),
- dim.bottomAnchor.constraint(equalTo: created.bottomAnchor)
- ])
- button = created
- }
- let blurView = button.subviews.compactMap { $0 as? NSVisualEffectView }.first
- let dimView = button.subviews.first { $0 is NSVisualEffectView == false }
- if palette.isDarkMode {
- blurView?.material = .hudWindow
- button.contentTintColor = .white
- dimView?.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.72).cgColor
- } else {
- // Lighter, cleaner lock treatment in light mode.
- blurView?.material = .popover
- button.contentTintColor = NSColor(calibratedWhite: 0.09, alpha: 1.0)
- dimView?.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.38).cgColor
- }
- button.title = message
- button.isHidden = locked == false
- return button
- }
- private func makeWidgetsPageView() -> NSView {
- let canAdd = isUserLoggedIn() && storeKitCoordinator.hasPremiumAccess
- let host = NSHostingView(
- rootView: WidgetsRootView(
- canAddWidgets: canAdd,
- onAddBlocked: { [weak self] in
- self?.showPaywall()
- }
- )
- )
- host.wantsLayer = true
- host.layer?.backgroundColor = NSColor.clear.cgColor
- return host
- }
- @MainActor
- private func updateWidgetMeetingStoreFromCurrentMeetings() {
- let upcoming = allScheduledMeetings
- .filter { $0.start >= Date() }
- .sorted(by: { $0.start < $1.start })
- .prefix(3)
- .map { meeting in
- WidgetMeetingSnapshot(
- id: meeting.meetingID ?? meeting.title,
- title: meeting.title,
- timeText: widgetTimeText(for: meeting),
- joinLink: meeting.webURL?.absoluteString
- )
- }
- WidgetMeetingStore.save(Array(upcoming))
- DesktopWidgetWindowManager.shared.refreshForMeetingsChange()
- }
- private func widgetTimeText(for meeting: ScheduledMeeting) -> String {
- let formatter = DateFormatter()
- formatter.dateFormat = "h:mm a"
- formatter.timeZone = meeting.meetingTimeZone ?? TimeZone.current
- return formatter.string(from: meeting.start)
- }
- private func setupWidgetBridge() {
- let center = NotificationCenter.default
- center.addObserver(forName: .widgetOpenHomePage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.home)
- }
- center.addObserver(forName: .widgetOpenMeetingsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.meetings)
- }
- center.addObserver(forName: .widgetOpenSchedulerPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.scheduler)
- }
- center.addObserver(forName: .widgetOpenWidgetsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.widgets)
- }
- center.addObserver(forName: .widgetOpenSettingsPage, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarShowSection(.settings)
- }
- center.addObserver(forName: .widgetStartNewMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerNewMeeting()
- }
- center.addObserver(forName: .widgetJoinMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerJoinMeeting()
- }
- center.addObserver(forName: .widgetScheduleMeetingRequested, object: nil, queue: .main) { [weak self] _ in
- self?.menuBarTriggerScheduleMeeting()
- }
- center.addObserver(forName: .widgetRefreshRequested, object: nil, queue: .main) { [weak self] _ in
- self?.triggerMeetingsRefresh(force: true)
- }
- center.addObserver(forName: .widgetOpenMeetingLinkRequested, object: nil, queue: .main) { [weak self] notification in
- guard let self else { return }
- guard let link = notification.userInfo?["link"] as? String,
- let url = URL(string: link.trimmingCharacters(in: .whitespacesAndNewlines)) else { return }
- self.openWebURLPreferringInApp(url)
- }
- }
- private func makeMeetingsPageView() -> NSView {
- let root = NSView()
- root.wantsLayer = true
- root.layer?.backgroundColor = NSColor.clear.cgColor
- let container = NSView()
- container.translatesAutoresizingMaskIntoConstraints = false
- container.wantsLayer = true
- container.layer?.backgroundColor = secondaryCardBackground.withAlphaComponent(0.94).cgColor
- container.layer?.cornerRadius = 16
- container.layer?.borderWidth = 1
- container.layer?.borderColor = palette.inputBorder.cgColor
- root.addSubview(container)
- let title = makeLabel("Meetings", size: 28, color: primaryText, weight: .bold, centered: false)
- let subtitle = makeLabel("All scheduled Zoom meetings", size: 13, color: secondaryText, weight: .regular, centered: false)
- let count = makeLabel("0 scheduled", size: 12, color: secondaryText, weight: .medium, centered: false)
- let titleStack = NSStackView(views: [title, subtitle, count])
- titleStack.orientation = .vertical
- titleStack.spacing = 4
- titleStack.alignment = .leading
- titleStack.translatesAutoresizingMaskIntoConstraints = false
- let categoryFilter = NSPopUpButton()
- categoryFilter.translatesAutoresizingMaskIntoConstraints = false
- categoryFilter.addItems(withTitles: ["All", "Today", "This week", "This month", "Custom range"])
- categoryFilter.selectItem(withTitle: "All")
- categoryFilter.target = self
- categoryFilter.action = #selector(meetingsPageRangePresetChanged(_:))
- let fromDatePicker = NSDatePicker()
- fromDatePicker.translatesAutoresizingMaskIntoConstraints = false
- fromDatePicker.datePickerStyle = .textFieldAndStepper
- fromDatePicker.datePickerMode = .single
- fromDatePicker.datePickerElements = [.yearMonthDay]
- fromDatePicker.dateValue = Date()
- fromDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
- let toDatePicker = NSDatePicker()
- toDatePicker.translatesAutoresizingMaskIntoConstraints = false
- toDatePicker.datePickerStyle = .textFieldAndStepper
- toDatePicker.datePickerMode = .single
- toDatePicker.datePickerElements = [.yearMonthDay]
- toDatePicker.dateValue = Date()
- toDatePicker.font = .systemFont(ofSize: 13, weight: .regular)
- let applyFiltersButton = NSButton(title: "Apply", target: self, action: #selector(meetingsPageApplyFiltersTapped))
- applyFiltersButton.translatesAutoresizingMaskIntoConstraints = false
- applyFiltersButton.isBordered = false
- applyFiltersButton.wantsLayer = true
- applyFiltersButton.layer?.backgroundColor = accentBlue.cgColor
- applyFiltersButton.layer?.cornerRadius = 10
- applyFiltersButton.contentTintColor = .white
- applyFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let resetFiltersButton = NSButton(title: "Reset", target: self, action: #selector(meetingsPageResetFiltersTapped))
- resetFiltersButton.translatesAutoresizingMaskIntoConstraints = false
- resetFiltersButton.isBordered = false
- resetFiltersButton.wantsLayer = true
- resetFiltersButton.layer?.backgroundColor = palette.inputBackground.cgColor
- resetFiltersButton.layer?.cornerRadius = 10
- resetFiltersButton.layer?.borderWidth = 1
- resetFiltersButton.layer?.borderColor = palette.inputBorder.cgColor
- resetFiltersButton.contentTintColor = primaryText
- resetFiltersButton.font = .systemFont(ofSize: 13, weight: .semibold)
- let refreshButton = NSButton(title: "", target: self, action: #selector(refreshMeetingsTapped))
- refreshButton.translatesAutoresizingMaskIntoConstraints = false
- refreshButton.isBordered = false
- refreshButton.wantsLayer = true
- refreshButton.layer?.backgroundColor = palette.inputBackground.cgColor
- refreshButton.layer?.cornerRadius = 10
- refreshButton.layer?.borderWidth = 1
- refreshButton.layer?.borderColor = palette.inputBorder.cgColor
- refreshButton.contentTintColor = primaryText
- refreshButton.font = .systemFont(ofSize: 13, weight: .semibold)
- if let image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh") {
- refreshButton.image = image
- refreshButton.imagePosition = .imageOnly
- }
- let filterRow = NSStackView(views: [categoryFilter, fromDatePicker, toDatePicker, applyFiltersButton, resetFiltersButton, refreshButton])
- filterRow.orientation = .horizontal
- filterRow.spacing = 10
- filterRow.alignment = .centerY
- filterRow.translatesAutoresizingMaskIntoConstraints = false
- let statusLabel = makeLabel("Showing 0 meetings", size: 12, color: secondaryText, weight: .medium, centered: false)
- statusLabel.translatesAutoresizingMaskIntoConstraints = false
- let listScroll = NSScrollView()
- listScroll.translatesAutoresizingMaskIntoConstraints = false
- listScroll.drawsBackground = false
- listScroll.hasVerticalScroller = true
- listScroll.hasHorizontalScroller = false
- listScroll.autohidesScrollers = true
- let listDocument = FlippedView()
- listDocument.translatesAutoresizingMaskIntoConstraints = false
- let listStack = NSStackView()
- listStack.translatesAutoresizingMaskIntoConstraints = false
- listStack.orientation = .vertical
- listStack.spacing = 12
- listStack.alignment = .leading
- listStack.setContentHuggingPriority(.required, for: .vertical)
- listStack.setContentCompressionResistancePriority(.required, for: .vertical)
- listScroll.documentView = listDocument
- listDocument.addSubview(listStack)
- let empty = makeLabel("No meetings match the current filters.", size: 14, color: secondaryText, weight: .regular, centered: true)
- empty.translatesAutoresizingMaskIntoConstraints = false
- empty.isHidden = true
- let loadMoreButton = NSButton(title: "", target: nil, action: nil)
- loadMoreButton.translatesAutoresizingMaskIntoConstraints = false
- loadMoreButton.isBordered = false
- loadMoreButton.wantsLayer = true
- loadMoreButton.layer?.backgroundColor = palette.inputBackground.cgColor
- loadMoreButton.layer?.cornerRadius = 10
- loadMoreButton.layer?.borderWidth = 1
- loadMoreButton.layer?.borderColor = palette.inputBorder.cgColor
- loadMoreButton.contentTintColor = primaryText
- loadMoreButton.font = .systemFont(ofSize: 13, weight: .semibold)
- loadMoreButton.isHidden = true
- [titleStack, filterRow, statusLabel, listScroll, empty, loadMoreButton].forEach { container.addSubview($0) }
- NSLayoutConstraint.activate([
- container.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
- container.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
- container.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
- container.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
- titleStack.topAnchor.constraint(equalTo: container.topAnchor, constant: 20),
- titleStack.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
- titleStack.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -18),
- filterRow.topAnchor.constraint(equalTo: titleStack.bottomAnchor, constant: 14),
- filterRow.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 18),
- filterRow.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -18),
- categoryFilter.widthAnchor.constraint(equalToConstant: 170),
- fromDatePicker.widthAnchor.constraint(equalToConstant: 170),
- toDatePicker.widthAnchor.constraint(equalToConstant: 170),
- applyFiltersButton.widthAnchor.constraint(equalToConstant: 96),
- resetFiltersButton.widthAnchor.constraint(equalToConstant: 96),
- refreshButton.widthAnchor.constraint(equalToConstant: 44),
- applyFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- resetFiltersButton.heightAnchor.constraint(equalToConstant: 34),
- refreshButton.heightAnchor.constraint(equalToConstant: 34),
- statusLabel.topAnchor.constraint(equalTo: filterRow.bottomAnchor, constant: 12),
- statusLabel.leadingAnchor.constraint(equalTo: filterRow.leadingAnchor),
- statusLabel.trailingAnchor.constraint(equalTo: filterRow.trailingAnchor),
- listScroll.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10),
- listScroll.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- listScroll.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- listScroll.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
- listDocument.widthAnchor.constraint(equalTo: listScroll.contentView.widthAnchor),
- listStack.topAnchor.constraint(equalTo: listDocument.topAnchor),
- listStack.leadingAnchor.constraint(equalTo: listDocument.leadingAnchor),
- listStack.trailingAnchor.constraint(equalTo: listDocument.trailingAnchor),
- // Pin bottom edge so document height grows with arranged subviews.
- listStack.bottomAnchor.constraint(equalTo: listDocument.bottomAnchor),
- empty.centerXAnchor.constraint(equalTo: listScroll.centerXAnchor),
- empty.centerYAnchor.constraint(equalTo: listScroll.centerYAnchor),
- loadMoreButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16),
- loadMoreButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16),
- loadMoreButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -14),
- loadMoreButton.heightAnchor.constraint(equalToConstant: 1)
- ])
- meetingsPageCategoryFilter = categoryFilter
- meetingsPageFromDatePicker = fromDatePicker
- meetingsPageToDatePicker = toDatePicker
- meetingsPageRefreshButton = refreshButton
- meetingsPageCountLabel = count
- meetingsPageStatusLabel = statusLabel
- meetingsPageListStack = listStack
- meetingsPageEmptyLabel = empty
- meetingsPageScrollView = listScroll
- meetingsPageLoadMoreButton = loadMoreButton
- observeMeetingsPageInfiniteScroll(in: listScroll)
- updateMeetingsDatePickersEnabledState()
- return root
- }
- deinit {
- premiumRatingPromptWorkItem?.cancel()
- usageRatingPromptWorkItem?.cancel()
- removeSearchFieldObserver()
- removeMeetingSearchObserver()
- clearMeetingsPageScrollObserver()
- removeSearchShortcutMonitor()
- for observer in windowFullScreenObservers {
- NotificationCenter.default.removeObserver(observer)
- }
- windowFullScreenObservers.removeAll()
- }
- private func ensureUsageSessionStartDate() {
- if UserDefaults.standard.object(forKey: usageSessionStartedAtKey) == nil {
- UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: usageSessionStartedAtKey)
- }
- }
- private func usageSessionStartedAt() -> Date {
- let storedValue = UserDefaults.standard.double(forKey: usageSessionStartedAtKey)
- if storedValue > 0 {
- return Date(timeIntervalSince1970: storedValue)
- }
- let now = Date()
- UserDefaults.standard.set(now.timeIntervalSince1970, forKey: usageSessionStartedAtKey)
- return now
- }
- private func hasCompletedOrSeenRatingFlow() -> Bool {
- UserDefaults.standard.bool(forKey: ratingCompletedKey)
- }
- private func scheduleUsageBasedRatingPromptIfNeeded() {
- usageRatingPromptWorkItem?.cancel()
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- let elapsed = Date().timeIntervalSince(usageSessionStartedAt())
- let delay = max(0, usageRatingPromptDelay - elapsed)
- let workItem = DispatchWorkItem { [weak self] in
- self?.presentRatingPrompt(source: "usage")
- }
- usageRatingPromptWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem)
- }
- private func schedulePremiumRatingPromptIfNeeded() {
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- guard premiumRatingPromptWorkItem == nil else { return }
- let workItem = DispatchWorkItem { [weak self] in
- self?.premiumRatingPromptWorkItem = nil
- self?.presentRatingPrompt(source: "premium")
- }
- premiumRatingPromptWorkItem = workItem
- DispatchQueue.main.asyncAfter(deadline: .now() + premiumRatingPromptDelay, execute: workItem)
- }
- private func presentRatingPrompt(source: String) {
- guard hasCompletedOrSeenRatingFlow() == false else { return }
- UserDefaults.standard.set(true, forKey: ratingPromptShownKey)
- NSApp.activate(ignoringOtherApps: true)
- let alert = NSAlert()
- alert.alertStyle = .informational
- alert.messageText = "Enjoying Zoom App?"
- if source == "premium" {
- alert.informativeText = "You unlocked Premium. If you're enjoying the app, please rate us."
- } else {
- alert.informativeText = "You've been using the app for a while. If it's helping you, please rate us."
- }
- alert.addButton(withTitle: "Rate Now")
- alert.addButton(withTitle: "Later")
- let response = alert.runModal()
- if response == .alertFirstButtonReturn {
- UserDefaults.standard.set(true, forKey: ratingCompletedKey)
- SKStoreReviewController.requestReview()
- }
- }
- private func startClock() {
- clockTimer?.invalidate()
- clockTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
- self?.updateClock()
- }
- updateClock()
- }
- private func updateClock() {
- let now = Date()
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEEE, d MMM"
- timeLabel?.stringValue = timeFormatter.string(from: now)
- dateLabel?.stringValue = dateFormatter.string(from: now)
- }
- // MARK: - Shared UI
- private func makeSidebar(items: [String], selected: String, style: SidebarStyle = .home) -> NSView {
- let sidebar = NSView()
- sidebar.wantsLayer = true
- sidebar.layer?.backgroundColor = (style == .home ? chromeUnifiedBackground : sidebarBackground).cgColor
- let stack = NSStackView()
- stack.orientation = .vertical
- stack.spacing = style == .home ? 12 : 16
- stack.alignment = .centerX
- stack.distribution = .fill
- // Keep sidebar items pinned to the top; don't let extra height stretch/shift them.
- stack.setContentHuggingPriority(.required, for: .vertical)
- stack.setContentCompressionResistancePriority(.required, for: .vertical)
- stack.translatesAutoresizingMaskIntoConstraints = false
- sidebar.addSubview(stack)
- for item in items {
- let row = NSView()
- row.translatesAutoresizingMaskIntoConstraints = false
- row.wantsLayer = true
- let selectedRow = item == selected
- row.layer?.backgroundColor = selectedRow ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- row.layer?.cornerRadius = style == .home ? 12 : 10
- row.widthAnchor.constraint(equalToConstant: style == .home ? 68 : 70).isActive = true
- // Prevent rows from stretching/collapsing when the window resizes.
- row.setContentHuggingPriority(.required, for: .vertical)
- row.setContentCompressionResistancePriority(.required, for: .vertical)
- if style == .home {
- // Must be tall enough for icon (26) + paddings + label without clipping.
- row.heightAnchor.constraint(equalToConstant: 66).isActive = true
- }
- if style == .home {
- let iconContainer = NSView()
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(iconContainer)
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.contentTintColor = selectedRow ? primaryText : sidebarInactiveText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- let preferredSymbol = sidebarSymbolName(for: item, filled: selectedRow)
- let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
- iconView.image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
- ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item)
- iconContainer.addSubview(iconView)
- let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(label)
- let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
- hit.identifier = NSUserInterfaceItemIdentifier(item)
- hit.isBordered = false
- hit.bezelStyle = .shadowlessSquare
- hit.focusRingType = .none
- hit.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview(hit, positioned: .above, relativeTo: nil)
- NSLayoutConstraint.activate([
- iconContainer.topAnchor.constraint(equalTo: row.topAnchor, constant: 9),
- iconContainer.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- iconContainer.widthAnchor.constraint(equalToConstant: 26),
- iconContainer.heightAnchor.constraint(equalToConstant: 26),
- iconView.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- iconView.centerYAnchor.constraint(equalTo: iconContainer.centerYAnchor),
- iconView.widthAnchor.constraint(equalToConstant: 22),
- iconView.heightAnchor.constraint(equalToConstant: 22),
- label.topAnchor.constraint(equalTo: iconContainer.bottomAnchor, constant: 6),
- label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8),
- hit.leadingAnchor.constraint(equalTo: row.leadingAnchor),
- hit.trailingAnchor.constraint(equalTo: row.trailingAnchor),
- hit.topAnchor.constraint(equalTo: row.topAnchor),
- hit.bottomAnchor.constraint(equalTo: row.bottomAnchor)
- ])
- homeSidebarRowViews[item] = row
- homeSidebarIconViews[item] = iconView
- homeSidebarLabels[item] = label
- if item == "Hub" {
- let badge = makeSidebarBadge(text: "1")
- badge.translatesAutoresizingMaskIntoConstraints = false
- iconContainer.addSubview(badge)
- NSLayoutConstraint.activate([
- badge.centerXAnchor.constraint(equalTo: iconContainer.trailingAnchor, constant: -2),
- badge.centerYAnchor.constraint(equalTo: iconContainer.topAnchor, constant: 5)
- ])
- } else if item == "More" {
- let dot = NSView()
- dot.translatesAutoresizingMaskIntoConstraints = false
- dot.wantsLayer = true
- dot.layer?.backgroundColor = NSColor.systemRed.cgColor
- dot.layer?.cornerRadius = 4
- row.addSubview(dot)
- NSLayoutConstraint.activate([
- dot.widthAnchor.constraint(equalToConstant: 8),
- dot.heightAnchor.constraint(equalToConstant: 8),
- dot.centerXAnchor.constraint(equalTo: iconContainer.centerXAnchor),
- dot.bottomAnchor.constraint(equalTo: iconContainer.topAnchor, constant: -4)
- ])
- }
- } else {
- let icon = makeLabel(selectedRow ? "⌂" : "◻︎", size: 15, color: primaryText, weight: .regular, centered: true)
- let label = makeLabel(item, size: 11, color: selectedRow ? primaryText : sidebarInactiveText, weight: .regular, centered: true)
- [icon, label].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- row.addSubview($0)
- }
- NSLayoutConstraint.activate([
- icon.topAnchor.constraint(equalTo: row.topAnchor, constant: 10),
- icon.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 5),
- label.centerXAnchor.constraint(equalTo: row.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: row.bottomAnchor, constant: -8)
- ])
- }
- stack.addArrangedSubview(row)
- }
- NSLayoutConstraint.activate([
- stack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 4),
- stack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -4)
- ])
- if style == .home {
- NSLayoutConstraint.activate([
- stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 10)
- ])
- }
- stack.bottomAnchor.constraint(lessThanOrEqualTo: sidebar.bottomAnchor, constant: -18).isActive = true
- return sidebar
- }
- @objc private func homeSidebarItemTapped(_ sender: NSButton) {
- guard let item = sender.identifier?.rawValue else { return }
- closeEmbeddedBrowserPanel()
- selectedHomeSidebarItem = item
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- @objc private func newMeetingTapped() {
- menuBarTriggerNewMeeting()
- }
- func menuBarIsUserLoggedIn() -> Bool {
- isUserLoggedIn()
- }
- func menuBarShowSection(_ section: HomeSection) {
- closeEmbeddedBrowserPanel()
- selectedHomeSidebarItem = section.rawValue
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- func menuBarTriggerNewMeeting() {
- guard isUserLoggedIn() else {
- showSimpleAlert(title: "Sign in required", message: "Please sign in with Zoom to start a new meeting.")
- return
- }
- guard let url = URL(string: "https://zoom.us/start/videomeeting") else {
- showSimpleAlert(title: "Unable to open", message: "Could not start a new Zoom meeting right now.")
- return
- }
- openWebURLPreferringInApp(url)
- }
- func menuBarTriggerJoinMeeting() {
- joinMeetingTapped()
- }
- func menuBarTriggerScheduleMeeting() {
- scheduleMeetingTapped()
- }
- func menuBarTriggerAuthAction() {
- if isUserLoggedIn() {
- logoutTapped()
- } else {
- zoomPrimarySignInTapped()
- }
- }
- @MainActor
- private func updateHomeSidebarHighlight() {
- for (item, row) in homeSidebarRowViews {
- let selected = item == selectedHomeSidebarItem
- row.layer?.backgroundColor = selected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- homeSidebarLabels[item]?.textColor = selected ? primaryText : sidebarInactiveText
- homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : sidebarInactiveText
- let preferredSymbol = sidebarSymbolName(for: item, filled: selected)
- let fallbackSymbol = sidebarSymbolName(for: item, filled: false)
- if let image = NSImage(systemSymbolName: preferredSymbol, accessibilityDescription: item)
- ?? NSImage(systemSymbolName: fallbackSymbol, accessibilityDescription: item) {
- homeSidebarIconViews[item]?.image = image
- }
- }
- }
- @MainActor
- private func updateSelectedHomeSectionUI() {
- let isHome = selectedHomeSidebarItem == HomeSection.home.rawValue
- let isMeetings = selectedHomeSidebarItem == HomeSection.meetings.rawValue
- let isSettings = selectedHomeSidebarItem == HomeSection.settings.rawValue
- let isScheduler = selectedHomeSidebarItem == HomeSection.scheduler.rawValue
- let isWidgets = selectedHomeSidebarItem == HomeSection.widgets.rawValue
- let premiumLocked = isUserLoggedIn() && (storeKitCoordinator.hasPremiumAccess == false)
- homeWelcomeLabel?.stringValue = "Home"
- homeWelcomeLabel?.isHidden = (isHome == false) || isSettings || isMeetings
- let dashboardViews: [NSView?] = [
- homeTimeLabelView,
- homeDateLabelView,
- homeActionsRow,
- homeMeetingsPanel,
- meetingsDayHeaderLabel,
- meetingsStatusLabel,
- meetingsPrevDayButton,
- meetingsTodayButton,
- meetingsNextDayButton,
- meetingsScrollView,
- refreshMeetingsButton
- ]
- let hideDashboard = isHome == false || isSettings || isMeetings || isScheduler || isWidgets
- dashboardViews.forEach { $0?.isHidden = hideDashboard }
- // Do not toggle emptyMeetingLabel with other dashboard views — that overrode applyFilteredMeetings()
- // and showed "No meetings…" on top of meeting cards when returning to Home.
- if hideDashboard {
- emptyMeetingLabel?.isHidden = true
- } else {
- let hasMeetingCards = (meetingsListStack?.arrangedSubviews.isEmpty == false)
- emptyMeetingLabel?.isHidden = premiumLocked || hasMeetingCards
- }
- homeSettingsView?.isHidden = isSettings == false
- meetingsPageRootView?.isHidden = isMeetings == false
- schedulerRootView?.isHidden = isScheduler == false
- widgetsPageRootView?.isHidden = isWidgets == false
- if isHome {
- homePlaceholderLabel?.isHidden = true
- } else {
- // Keep non-Home pages empty for now.
- homePlaceholderLabel?.isHidden = true
- }
- if isScheduler {
- updateSchedulerDateHeader()
- applySchedulerMeetingsForSelectedDate()
- renderSchedulerCalendarGrid()
- }
- if isMeetings {
- applyMeetingsPageFilters(resetVisible: false)
- }
- }
- private func sidebarSymbolName(for item: String, filled: Bool = false) -> String {
- switch item {
- case "Home":
- return filled ? "house.fill" : "house"
- case "Meetings":
- return filled ? "video.fill" : "video"
- case "Chat":
- return filled ? "message.fill" : "message"
- case "Scheduler":
- // `calendar.badge.clock.fill` is not available on macOS; keep a stable symbol.
- return "calendar.badge.clock"
- case "Widgets":
- return filled ? "rectangle.grid.2x2.fill" : "rectangle.grid.2x2"
- case "Settings":
- // `gearshape.fill` may not exist on all macOS versions; handled via safe image assignment.
- return filled ? "gearshape.fill" : "gearshape"
- case "Hub":
- return "square.grid.3x3"
- case "More":
- return "ellipsis"
- default:
- return "circle"
- }
- }
- private func makeSidebarBadge(text: String) -> NSView {
- let badge = NSView()
- badge.wantsLayer = true
- badge.layer?.backgroundColor = NSColor.systemRed.cgColor
- badge.layer?.cornerRadius = 12 / 2
- badge.widthAnchor.constraint(equalToConstant: 12).isActive = true
- badge.heightAnchor.constraint(equalToConstant: 12).isActive = true
- let label = makeLabel(text, size: 8, color: .white, weight: .bold, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- badge.addSubview(label)
- NSLayoutConstraint.activate([
- label.centerXAnchor.constraint(equalTo: badge.centerXAnchor),
- label.centerYAnchor.constraint(equalTo: badge.centerYAnchor, constant: -0.3)
- ])
- return badge
- }
- @MainActor
- private func alignNativeTrafficLights() {
- guard let window = view.window else { return }
- guard let closeButton = window.standardWindowButton(.closeButton),
- let miniButton = window.standardWindowButton(.miniaturizeButton),
- let zoomButton = window.standardWindowButton(.zoomButton) else { return }
- guard let buttonContainer = closeButton.superview else { return }
- let buttons = [closeButton, miniButton, zoomButton]
- // Compute from top inset so moving "down" is stable in titlebar coordinates.
- let containerHeight = buttonContainer.bounds.height
- let targetY = max(0, containerHeight - closeButton.frame.height - nativeTrafficLightsTopInset)
- var nextX = nativeTrafficLightsLeading
- for button in buttons {
- button.setFrameOrigin(NSPoint(x: nextX, y: targetY))
- nextX += button.frame.width + 8
- }
- }
- private func makeUpgradeToProButton(action: Selector?) -> NSButton {
- let title = "Upgrade to Pro"
- let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.focusRingType = .none
- button.wantsLayer = true
- button.layer?.backgroundColor = accentBlue.cgColor
- button.layer?.cornerRadius = 14
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: NSColor.white,
- .font: font
- ])
- button.toolTip = title
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 28).isActive = true
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 148).isActive = true
- return button
- }
- /// Back / forward / history: icon-only, no background or border. Back/forward use smaller `dimension` / `pointSize` than history.
- private func makeNavGlyphButton(symbol: String, action: Selector?, dimension: CGFloat = 18, pointSize: CGFloat = 9, toolTip: String? = nil) -> NSButton {
- let button = NSButton(title: "", target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.contentTintColor = palette.isDarkMode
- ? NSColor(calibratedWhite: 0.84, alpha: 1)
- : NSColor(calibratedWhite: 0.22, alpha: 1)
- if let toolTip {
- button.toolTip = toolTip
- }
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
- if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: symbol),
- let image = base.withSymbolConfiguration(symbolConfig) {
- image.isTemplate = true
- button.image = image
- }
- button.imageScaling = .scaleProportionallyUpOrDown
- button.imagePosition = .imageOnly
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: dimension).isActive = true
- button.heightAnchor.constraint(equalToConstant: dimension).isActive = true
- return button
- }
-
- private func makeMeetingsDayChipButton(title: String, symbol: String? = nil, action: Selector?) -> NSButton {
- let button = NSButton(title: title, target: action == nil ? nil : self, action: action)
- button.isBordered = false
- button.bezelStyle = .shadowlessSquare
- button.focusRingType = .none
- button.wantsLayer = true
- button.layer?.backgroundColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.06)
- : NSColor.black.withAlphaComponent(0.06)
- ).cgColor
- button.layer?.cornerRadius = 10
- button.layer?.borderWidth = 1
- button.layer?.borderColor = (palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.08)
- : NSColor.black.withAlphaComponent(0.12)
- ).cgColor
- let tint = palette.isDarkMode
- ? NSColor(calibratedWhite: 0.9, alpha: 1)
- : NSColor(calibratedWhite: 0.18, alpha: 1)
- let font = NSFont.systemFont(ofSize: 11, weight: .semibold)
- button.contentTintColor = tint
- button.font = font
- button.attributedTitle = NSAttributedString(string: title, attributes: [
- .foregroundColor: tint,
- .font: font
- ])
- if let symbol {
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- if let base = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
- let image = base.withSymbolConfiguration(symbolConfig) {
- image.isTemplate = true
- button.image = image
- button.imagePosition = .imageLeading
- }
- button.imageHugsTitle = true
- button.imageScaling = .scaleNone
- }
- button.translatesAutoresizingMaskIntoConstraints = false
- button.heightAnchor.constraint(equalToConstant: 24).isActive = true
- button.widthAnchor.constraint(greaterThanOrEqualToConstant: 92).isActive = true
- return button
- }
- private func makeActionTile(title: String, symbol: String, color: NSColor, action: Selector? = nil) -> NSView {
- let root = HoverTileView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.widthAnchor.constraint(equalToConstant: 88).isActive = true
- // Give caption text a little more vertical room so descenders (e.g. "g") are never clipped.
- root.heightAnchor.constraint(equalToConstant: 76).isActive = true
- let iconButton = NSButton(title: "", target: action == nil ? nil : self, action: action)
- iconButton.isBordered = false
- iconButton.wantsLayer = true
- iconButton.layer?.backgroundColor = color.cgColor
- iconButton.layer?.cornerRadius = 15
- iconButton.layer?.shadowOpacity = 0.2
- iconButton.layer?.shadowRadius = 7
- iconButton.layer?.shadowOffset = NSSize(width: 0, height: -1)
- let symbolConfig = NSImage.SymbolConfiguration(pointSize: 22, weight: .semibold)
- if let baseImage = NSImage(systemSymbolName: symbol, accessibilityDescription: title),
- let configured = baseImage.withSymbolConfiguration(symbolConfig) {
- iconButton.image = configured
- }
- iconButton.contentTintColor = .white
- iconButton.imageScaling = .scaleNone
- let label = makeLabel(title, size: 12, color: secondaryText, weight: .regular, centered: true)
- [iconButton, label].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- root.addSubview($0)
- }
- let iconTopConstraint = iconButton.topAnchor.constraint(equalTo: root.topAnchor)
- NSLayoutConstraint.activate([
- iconTopConstraint,
- iconButton.centerXAnchor.constraint(equalTo: root.centerXAnchor),
- iconButton.widthAnchor.constraint(equalToConstant: 50),
- iconButton.heightAnchor.constraint(equalToConstant: 50),
- label.topAnchor.constraint(equalTo: iconButton.bottomAnchor, constant: 8),
- label.centerXAnchor.constraint(equalTo: root.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- root.onHoverChanged = { [weak iconButton, weak label] hovering in
- NSAnimationContext.runAnimationGroup { context in
- context.duration = hovering ? 0.16 : 0.14
- context.timingFunction = CAMediaTimingFunction(name: hovering ? .easeOut : .easeInEaseOut)
- iconTopConstraint.animator().constant = hovering ? -3 : 0
- label?.animator().alphaValue = hovering ? 1.0 : 0.96
- }
- guard let iconButton else { return }
- iconButton.layer?.shadowOpacity = hovering ? 0.28 : 0.2
- iconButton.layer?.shadowRadius = hovering ? 10 : 7
- iconButton.layer?.shadowOffset = hovering ? NSSize(width: 0, height: -3) : NSSize(width: 0, height: -1)
- }
- return root
- }
- private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
- let hoverBackground = palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.075)
- : NSColor.black.withAlphaComponent(0.10)
- let hoverBorder = palette.isDarkMode
- ? NSColor.white.withAlphaComponent(0.85)
- : NSColor.black.withAlphaComponent(0.22)
- let card = MeetingCardView(
- url: meeting.webURL,
- hoverBackground: hoverBackground,
- hoverBorder: hoverBorder,
- onTapURL: { [weak self] url in
- self?.openWebURLPreferringInApp(url)
- }
- )
- card.wantsLayer = true
- card.layer?.backgroundColor = meetingCardBackground.cgColor
- card.layer?.cornerRadius = 13
- card.layer?.borderWidth = 1
- card.layer?.borderColor = (palette.isDarkMode ? NSColor.white.withAlphaComponent(0.06) : NSColor.black.withAlphaComponent(0.10)).cgColor
- card.translatesAutoresizingMaskIntoConstraints = false
- card.heightAnchor.constraint(equalToConstant: 116).isActive = true
- let dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "EEE, MMM d"
- let timeFormatter = DateFormatter()
- timeFormatter.dateFormat = "h:mm a"
- let displayTimeZone = meeting.meetingTimeZone ?? TimeZone.current
- dateFormatter.timeZone = displayTimeZone
- timeFormatter.timeZone = displayTimeZone
- let startText = timeFormatter.string(from: meeting.start)
- let endText = meeting.end.map { timeFormatter.string(from: $0) } ?? ""
- let range = endText.isEmpty ? startText : "\(startText) - \(endText)"
- let title = makeLabel(meeting.title, size: 18, color: primaryText, weight: .semibold, centered: false)
- let detail = makeLabel("\(dateFormatter.string(from: meeting.start))\n\(range)", size: 12, color: secondaryText, weight: .regular, centered: false)
- detail.maximumNumberOfLines = 2
- let host = makeLabel("Host: \(meeting.host) • \(meeting.source)", size: 11, color: secondaryText, weight: .regular, centered: false)
- [title, detail, host].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- card.addSubview($0)
- }
- NSLayoutConstraint.activate([
- title.topAnchor.constraint(equalTo: card.topAnchor, constant: 11),
- title.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- title.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
- detail.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 3),
- detail.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- detail.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- host.topAnchor.constraint(equalTo: detail.bottomAnchor, constant: 7),
- host.leadingAnchor.constraint(equalTo: title.leadingAnchor),
- host.trailingAnchor.constraint(equalTo: title.trailingAnchor),
- host.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -12)
- ])
- return card
- }
- private func makeLabel(_ text: String, size: CGFloat, color: NSColor, weight: NSFont.Weight, centered: Bool) -> NSTextField {
- let label = NSTextField(labelWithString: text)
- label.font = .systemFont(ofSize: size, weight: weight)
- label.textColor = color
- label.alignment = centered ? .center : .left
- return label
- }
- private func makeSocialButton(icon: String, text: String, action: Selector? = nil) -> (container: NSView, button: NSButton?) {
- let wrapper = NSView()
- let button = NSButton(title: icon, target: action == nil ? nil : self, action: action)
- button.font = .systemFont(ofSize: 20, weight: .medium)
- button.isBordered = false
- button.wantsLayer = true
- button.layer?.cornerRadius = 12
- button.layer?.backgroundColor = cardBackground.cgColor
- button.contentTintColor = primaryText
- button.translatesAutoresizingMaskIntoConstraints = false
- let label = makeLabel(text, size: 12, color: secondaryText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- wrapper.addSubview(button)
- wrapper.addSubview(label)
- NSLayoutConstraint.activate([
- button.topAnchor.constraint(equalTo: wrapper.topAnchor),
- button.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
- button.widthAnchor.constraint(equalToConstant: 52),
- button.heightAnchor.constraint(equalToConstant: 52),
- label.topAnchor.constraint(equalTo: button.bottomAnchor, constant: 6),
- label.centerXAnchor.constraint(equalTo: wrapper.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- return (wrapper, action == nil ? nil : button)
- }
- }
- extension ViewController: EmbeddedOAuthPresenting {}
- extension ViewController: WKNavigationDelegate, WKUIDelegate {
- func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- guard webView === embeddedBrowserWebView else {
- decisionHandler(.allow)
- return
- }
- if let prefix = oauthSessionCallbackPrefix,
- let targetURL = navigationAction.request.url,
- targetURL.absoluteString.hasPrefix(prefix) {
- oauthSessionContinuation?.resume(returning: targetURL)
- oauthSessionContinuation = nil
- oauthSessionCallbackPrefix = nil
- decisionHandler(.cancel)
- return
- }
- decisionHandler(.allow)
- }
- func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- refreshEmbeddedBrowserNavButtons()
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- guard webView === embeddedBrowserWebView else { return }
- refreshEmbeddedBrowserNavButtons()
- }
- func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
- guard webView === embeddedBrowserWebView else { return nil }
- if navigationAction.targetFrame == nil {
- webView.load(navigationAction.request)
- }
- return nil
- }
- }
- extension ViewController: NSWindowDelegate {
- func windowWillClose(_ notification: Notification) {
- guard let window = notification.object as? NSWindow else { return }
- if window === joinMeetingWindow {
- resetJoinMeetingPanelReferences()
- }
- if window === scheduleMeetingWindow {
- resetScheduleMeetingPanelReferences()
- }
- }
- }
- private extension Array {
- subscript(safe index: Int) -> Element? {
- guard index >= 0, index < count else { return nil }
- return self[index]
- }
- }
- /// Vertical centering without changing `drawingRect` (so the field editor keeps a full-height frame and typing works).
- private final class JoinPanelVerticallyCenteredTextFieldCell: NSTextFieldCell {
- private func lineHeight() -> CGFloat {
- guard let font = font else { return 0 }
- return ceil(font.ascender - font.descender + font.leading)
- }
- private func verticalMargin(forBoundsHeight h: CGFloat) -> CGFloat {
- let lh = lineHeight()
- guard lh > 0, h > lh else { return 0 }
- return max(0, floor((h - lh) / 2))
- }
- private func verticallyCenteredInteriorFrame(_ cellFrame: NSRect) -> NSRect {
- let lh = lineHeight()
- guard lh > 0 else { return cellFrame }
- let m = verticalMargin(forBoundsHeight: cellFrame.height)
- var r = cellFrame
- r.origin.y += m
- r.size.height = lh
- return r
- }
- override func drawInterior(withFrame cellFrame: NSRect, in controlView: NSView) {
- super.drawInterior(withFrame: verticallyCenteredInteriorFrame(cellFrame), in: controlView)
- }
- override func setUpFieldEditorAttributes(_ textObj: NSText) -> NSText {
- let text = super.setUpFieldEditorAttributes(textObj)
- guard let tv = text as? NSTextView else { return text }
- // Bounds can be 0 on first editor setup; join fields are laid out at 42pt tall inside the pill.
- let h = max(controlView?.bounds.height ?? 0, 42)
- let margin = verticalMargin(forBoundsHeight: h)
- if margin > 0 {
- // NSTextView still uses `NSSize` here: horizontal inset, vertical inset from bounds origin (symmetric padding).
- tv.textContainerInset = NSSize(width: 0, height: margin)
- }
- return text
- }
- }
- /// Pill-shaped chrome with inset text; focus ring handled via edit notifications (reliable vs. cell overrides).
- private final class JoinPanelFieldContainer: NSView {
- let textField: NSTextField
- private let normalBorder: NSColor
- private let focusBorder: NSColor
- private let fill: NSColor
- private var beginObserver: NSObjectProtocol?
- private var endObserver: NSObjectProtocol?
- init(
- placeholder: String,
- normalBorder: NSColor,
- focusBorder: NSColor,
- fill: NSColor,
- primaryText: NSColor,
- mutedText: NSColor
- ) {
- self.normalBorder = normalBorder
- self.focusBorder = focusBorder
- self.fill = fill
- textField = NSTextField()
- textField.cell = JoinPanelVerticallyCenteredTextFieldCell(textCell: "")
- super.init(frame: .zero)
- translatesAutoresizingMaskIntoConstraints = false
- wantsLayer = true
- // Do not clip subviews: `masksToBounds` + corner radius can clip the field editor (NSTextView) and block typing.
- layer?.masksToBounds = false
- layer?.cornerRadius = 23
- layer?.backgroundColor = fill.cgColor
- applyBorder(focused: false)
- textField.font = .systemFont(ofSize: 14, weight: .regular)
- textField.textColor = primaryText
- textField.alignment = .center
- textField.isEditable = true
- textField.isSelectable = true
- textField.refusesFirstResponder = false
- textField.focusRingType = .none
- textField.isBordered = false
- textField.drawsBackground = false
- textField.translatesAutoresizingMaskIntoConstraints = false
- if let cell = textField.cell as? NSTextFieldCell {
- cell.isBezeled = false
- cell.drawsBackground = false
- cell.alignment = .center
- cell.usesSingleLineMode = true
- cell.lineBreakMode = .byTruncatingTail
- let placeholderParagraph = NSMutableParagraphStyle()
- placeholderParagraph.alignment = .center
- cell.placeholderAttributedString = NSAttributedString(
- string: placeholder,
- attributes: [
- .foregroundColor: mutedText.withAlphaComponent(0.88),
- .font: NSFont.systemFont(ofSize: 14, weight: .regular),
- .paragraphStyle: placeholderParagraph
- ]
- )
- }
- addSubview(textField)
- NSLayoutConstraint.activate([
- textField.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 14),
- textField.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -14),
- textField.topAnchor.constraint(equalTo: topAnchor, constant: 2),
- textField.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -2),
- heightAnchor.constraint(equalToConstant: 46)
- ])
- beginObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidBeginEditingNotification,
- object: textField,
- queue: .main
- ) { [weak self] _ in
- self?.applyBorder(focused: true)
- }
- endObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidEndEditingNotification,
- object: textField,
- queue: .main
- ) { [weak self] _ in
- self?.applyBorder(focused: false)
- }
- }
- required init?(coder: NSCoder) {
- fatalError("init(coder:) has not been implemented")
- }
- deinit {
- if let beginObserver {
- NotificationCenter.default.removeObserver(beginObserver)
- }
- if let endObserver {
- NotificationCenter.default.removeObserver(endObserver)
- }
- }
- private func applyBorder(focused: Bool) {
- layer?.borderColor = (focused ? focusBorder : normalBorder).cgColor
- layer?.borderWidth = focused ? 1.5 : 1
- }
- override func viewDidMoveToWindow() {
- super.viewDidMoveToWindow()
- if window == nil {
- applyBorder(focused: false)
- }
- }
- }
- private final class SearchPillTextField: NSTextField {
- var onFocusChange: ((Bool) -> Void)?
- private(set) var isSearchFocused = false
- func forceClearFocusState() {
- isSearchFocused = false
- onFocusChange?(false)
- }
- override func becomeFirstResponder() -> Bool {
- let ok = super.becomeFirstResponder()
- if ok {
- isSearchFocused = true
- onFocusChange?(true)
- }
- return ok
- }
- override func resignFirstResponder() -> Bool {
- let ok = super.resignFirstResponder()
- if ok {
- isSearchFocused = false
- onFocusChange?(false)
- }
- return ok
- }
- }
- private final class FlippedView: NSView {
- override var isFlipped: Bool { true }
- }
- private final class MeetingCardView: NSView {
- private let url: URL?
- private let hoverBackground: NSColor
- private let hoverBorder: NSColor
- private let onTapURL: ((URL) -> Void)?
- private var tracking: NSTrackingArea?
- private var isHovering = false
- private var normalBackgroundColor: CGColor?
- private var normalBorderColor: CGColor?
- private var normalBorderWidth: CGFloat?
- init(url: URL?, hoverBackground: NSColor, hoverBorder: NSColor, onTapURL: ((URL) -> Void)? = nil) {
- self.url = url
- self.hoverBackground = hoverBackground
- self.hoverBorder = hoverBorder
- self.onTapURL = onTapURL
- super.init(frame: .zero)
- }
- required init?(coder: NSCoder) {
- self.url = nil
- self.hoverBackground = NSColor.white.withAlphaComponent(0.075)
- self.hoverBorder = NSColor.white.withAlphaComponent(0.85)
- self.onTapURL = nil
- super.init(coder: coder)
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- if let tracking { removeTrackingArea(tracking) }
- let options: NSTrackingArea.Options = [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect]
- let area = NSTrackingArea(rect: .zero, options: options, owner: self, userInfo: nil)
- addTrackingArea(area)
- tracking = area
- }
- override func resetCursorRects() {
- super.resetCursorRects()
- if url != nil {
- addCursorRect(bounds, cursor: .pointingHand)
- }
- }
- override func mouseEntered(with event: NSEvent) {
- super.mouseEntered(with: event)
- guard url != nil else { return }
- isHovering = true
- applyHoverState()
- }
- override func mouseExited(with event: NSEvent) {
- super.mouseExited(with: event)
- guard url != nil else { return }
- isHovering = false
- applyHoverState()
- }
- override func mouseUp(with event: NSEvent) {
- super.mouseUp(with: event)
- guard let url else { return }
- if let onTapURL {
- onTapURL(url)
- } else {
- NSWorkspace.shared.open(url)
- }
- }
- private func applyHoverState() {
- guard let layer else { return }
- if normalBackgroundColor == nil { normalBackgroundColor = layer.backgroundColor }
- if normalBorderColor == nil { normalBorderColor = layer.borderColor }
- if normalBorderWidth == nil { normalBorderWidth = layer.borderWidth }
- if isHovering {
- layer.backgroundColor = hoverBackground.cgColor
- layer.borderColor = hoverBorder.cgColor
- layer.borderWidth = max(1.5, layer.borderWidth)
- } else {
- layer.backgroundColor = normalBackgroundColor
- layer.borderColor = normalBorderColor
- if let normalBorderWidth { layer.borderWidth = normalBorderWidth }
- }
- }
- }
- struct GoogleOAuthTokens: Codable, Equatable {
- var accessToken: String
- var refreshToken: String?
- var expiresAt: Date
- var scope: String?
- var tokenType: String?
- }
- struct GoogleUserProfile: Codable, Equatable {
- var name: String?
- var email: String?
- var picture: String?
- }
- struct ZoomOAuthTokens: Codable, Equatable {
- var accessToken: String
- var refreshToken: String?
- var expiresAt: Date
- var scope: String?
- var tokenType: String?
- }
- enum ZoomOAuthError: Error {
- case missingClientId
- case missingClientSecret
- case invalidCallbackURL
- case missingAuthorizationCode
- case tokenExchangeFailed(String)
- case missingRequiredScope(String)
- case rateLimited(retryAfterSeconds: Int?)
- case unableToOpenBrowser
- case authenticationTimedOut
- case authenticationCancelled
- }
- final class ZoomOAuthTokenStore {
- private let defaultsKey: String
- private let defaults: UserDefaults
- init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
- account: String = "zoomOAuthTokens",
- defaults: UserDefaults = .standard) {
- self.defaultsKey = "\(service).\(account)"
- self.defaults = defaults
- }
- func readTokens() throws -> ZoomOAuthTokens? {
- guard let data = defaults.data(forKey: defaultsKey) else { return nil }
- return try JSONDecoder().decode(ZoomOAuthTokens.self, from: data)
- }
- func writeTokens(_ tokens: ZoomOAuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- defaults.set(data, forKey: defaultsKey)
- }
- func clearTokens() {
- defaults.removeObject(forKey: defaultsKey)
- }
- }
- final class ZoomOAuthService: NSObject {
- static let shared = ZoomOAuthService()
- private var inAppOAuthWindowController: InAppOAuthWindowController?
- private let tokenStore = ZoomOAuthTokenStore()
- private let clientIdDefaultsKey = "zoom.oauth.clientId"
- private let clientSecretDefaultsKey = "zoom.oauth.clientSecret"
- private let infoPlistClientIdKey = "ZoomOAuthClientId"
- private let envClientSecretKey = "ZOOM_OAUTH_CLIENT_SECRET"
- // Optional: put OAuth app credentials here for local-only testing (do not ship secrets in release builds).
- /// Fallback if Info.plist `ZoomOAuthClientId` is missing (e.g. mis-quoted build setting).
- private let bundledClientId = "isvIAKPhSPOhBxFUkiY2A"
- /// Prefer `ZOOM_OAUTH_CLIENT_SECRET` env or UserDefaults when distributing; rotate if this value is ever leaked.
- private let bundledClientSecret = "jPfbdvt14CKH48vKEg3NjDpTIgCd2rDq"
- func setClientCredentials(clientId: String, clientSecret: String) {
- UserDefaults.standard.set(clientId, forKey: clientIdDefaultsKey)
- UserDefaults.standard.set(clientSecret, forKey: clientSecretDefaultsKey)
- }
- func configuredClientId() -> String? {
- if let plist = Bundle.main.object(forInfoDictionaryKey: infoPlistClientIdKey) as? String {
- let trimmed = plist.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false { return trimmed }
- }
- let value = UserDefaults.standard.string(forKey: clientIdDefaultsKey)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let value, value.isEmpty == false { return value }
- return bundledClientId.isEmpty ? nil : bundledClientId
- }
- func configuredClientSecret() -> String? {
- if let env = ProcessInfo.processInfo.environment[envClientSecretKey] {
- let trimmed = env.trimmingCharacters(in: .whitespacesAndNewlines)
- if trimmed.isEmpty == false { return trimmed }
- }
- let value = UserDefaults.standard.string(forKey: clientSecretDefaultsKey)?
- .trimmingCharacters(in: .whitespacesAndNewlines)
- if let value, value.isEmpty == false { return value }
- return bundledClientSecret.isEmpty ? nil : bundledClientSecret
- }
- func clearSavedTokens() {
- tokenStore.clearTokens()
- }
- func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- tokenHasRequiredScope(tokens.scope) {
- return tokens.accessToken
- } else if var tokens = try tokenStore.readTokens(),
- let refreshed = try await refreshTokens(tokens) {
- tokens = refreshed
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> ZoomOAuthTokens {
- guard let presentingWindow else { throw ZoomOAuthError.unableToOpenBrowser }
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
- let state = UUID().uuidString
- var components = URLComponents(string: "https://zoom.us/oauth/authorize")!
- // Omit `scope` so Zoom uses the OAuth app’s enabled scopes from the Marketplace (avoids mismatch errors).
- components.queryItems = [
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "client_id", value: clientId),
- URLQueryItem(name: "redirect_uri", value: redirectURI),
- URLQueryItem(name: "state", value: state)
- ]
- guard let authURL = components.url else { throw ZoomOAuthError.invalidCallbackURL }
- do {
- _ = presentingWindow // keep signature stable; used for parity with Google flow + future UI alerts
- let listener = try OAuthRedirectListener(port: 8742, pathPrefix: "/oauth2redirect")
- guard NSWorkspace.shared.open(authURL) else {
- throw ZoomOAuthError.unableToOpenBrowser
- }
- let callbackURL = try await listener.waitForRedirect(timeoutSeconds: 180)
- let queryItems = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
- guard queryItems?.first(where: { $0.name == "state" })?.value == state else { throw ZoomOAuthError.invalidCallbackURL }
- guard let code = queryItems?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
- throw ZoomOAuthError.missingAuthorizationCode
- }
- return try await exchangeCodeForTokens(code: code, redirectURI: redirectURI, clientId: clientId, clientSecret: clientSecret)
- } catch OAuthRedirectListenerError.timedOut {
- throw ZoomOAuthError.authenticationTimedOut
- } catch GoogleOAuthError.authenticationTimedOut {
- throw ZoomOAuthError.authenticationTimedOut
- } catch GoogleOAuthError.authenticationCancelled {
- throw ZoomOAuthError.authenticationCancelled
- } catch {
- throw error
- }
- }
- @MainActor
- private func makeOAuthWindowController() -> InAppOAuthWindowController {
- if let existing = inAppOAuthWindowController { return existing }
- let controller = InAppOAuthWindowController(title: "Zoom Sign-In")
- inAppOAuthWindowController = controller
- return controller
- }
- private func exchangeCodeForTokens(code: String, redirectURI: String, clientId: String, clientSecret: String) async throws -> ZoomOAuthTokens {
- var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
- request.httpBody = Self.formURLEncoded([
- "grant_type": "authorization_code",
- "code": code,
- "redirect_uri": redirectURI
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw ZoomOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- struct TokenResponse: Decodable {
- let access_token: String
- let refresh_token: String?
- let expires_in: Double
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
- return ZoomOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope,
- tokenType: decoded.token_type
- )
- }
- private func refreshTokens(_ tokens: ZoomOAuthTokens) async throws -> ZoomOAuthTokens? {
- guard let refreshToken = tokens.refreshToken else { return nil }
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- var request = URLRequest(url: URL(string: "https://zoom.us/oauth/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.setValue("Basic \(Self.basicAuth(clientId: clientId, clientSecret: clientSecret))", forHTTPHeaderField: "Authorization")
- request.httpBody = Self.formURLEncoded([
- "grant_type": "refresh_token",
- "refresh_token": refreshToken
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- return nil
- }
- struct RefreshResponse: Decodable {
- let access_token: String
- let refresh_token: String?
- let expires_in: Double
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(RefreshResponse.self, from: data)
- return ZoomOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token ?? refreshToken,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope ?? tokens.scope,
- tokenType: decoded.token_type ?? tokens.tokenType
- )
- }
- private func tokenHasRequiredScope(_ scopeValue: String?) -> Bool {
- guard let scopeValue, scopeValue.isEmpty == false else { return false }
- let parts = scopeValue.split { $0 == " " || $0 == "," }.map(String.init)
- return parts.contains { part in
- part == "meeting:read"
- || part == "meeting:read:admin"
- || part == "meeting:write"
- || part == "meeting:write:admin"
- || part.contains("meeting:read")
- || part.contains("meeting:write")
- || part.contains("list_meetings")
- || part.contains("list_user_meetings")
- }
- }
- private static func basicAuth(clientId: String, clientSecret: String) -> String {
- let joined = "\(clientId):\(clientSecret)"
- return Data(joined.utf8).base64EncodedString()
- }
- private static func formURLEncoded(_ params: [String: String]) -> Data {
- let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
- let pairs = params.map { key, value in
- let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
- let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
- return "\(k)=\(v)"
- }.joined(separator: "&")
- return Data(pairs.utf8)
- }
- }
- enum GoogleOAuthError: Error {
- case missingClientId
- case missingClientSecret
- case invalidCallbackURL
- case missingAuthorizationCode
- case tokenExchangeFailed(String)
- case unableToOpenBrowser
- case authenticationTimedOut
- case authenticationCancelled
- }
- final class GoogleOAuthService: NSObject {
- static let shared = GoogleOAuthService()
- private var inAppOAuthWindowController: InAppOAuthWindowController?
- private let clientId = "1058191714408-i7dlicarppj0rt0ghn9loou606lmm0dr.apps.googleusercontent.com"
- private let clientSecret = "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"
- private let requiredCalendarScope = "https://www.googleapis.com/auth/calendar.readonly"
- private let scopes = ["openid", "email", "profile", "https://www.googleapis.com/auth/calendar.readonly"]
- private lazy var tokenStore = KeychainTokenStore(account: "googleOAuthTokens.\(clientId)")
- func loadTokens() -> GoogleOAuthTokens? { try? tokenStore.readTokens() }
- func clearSavedTokens() {
- tokenStore.clearTokens()
- }
- func fetchUserProfile(accessToken: String) async throws -> GoogleUserProfile {
- var request = URLRequest(url: URL(string: "https://openidconnect.googleapis.com/v1/userinfo")!)
- request.httpMethod = "GET"
- request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- return try JSONDecoder().decode(GoogleUserProfile.self, from: data)
- }
- func validAccessToken(presentingWindow: NSWindow?) async throws -> String {
- if let tokens = try tokenStore.readTokens(),
- tokens.expiresAt.timeIntervalSinceNow > 60,
- tokenHasCalendarScope(tokens.scope) {
- return tokens.accessToken
- }
- let tokens = try await interactiveSignIn(presentingWindow: presentingWindow)
- try tokenStore.writeTokens(tokens)
- return tokens.accessToken
- }
- private func interactiveSignIn(presentingWindow: NSWindow?) async throws -> GoogleOAuthTokens {
- guard let presentingWindow else { throw GoogleOAuthError.unableToOpenBrowser }
- let oauthWindow = await MainActor.run { makeOAuthWindowController() }
- let codeVerifier = Self.randomURLSafeString(length: 64)
- let codeChallenge = Self.pkceChallenge(for: codeVerifier)
- let state = Self.randomURLSafeString(length: 32)
- // Google OAuth app settings often already include this callback from prior loopback setup.
- let redirectURI = "http://127.0.0.1:8742/oauth2redirect"
- var components = URLComponents(string: "https://accounts.google.com/o/oauth2/v2/auth")!
- components.queryItems = [
- URLQueryItem(name: "client_id", value: clientId),
- URLQueryItem(name: "redirect_uri", value: redirectURI),
- URLQueryItem(name: "response_type", value: "code"),
- URLQueryItem(name: "scope", value: scopes.joined(separator: " ")),
- URLQueryItem(name: "state", value: state),
- URLQueryItem(name: "code_challenge", value: codeChallenge),
- URLQueryItem(name: "code_challenge_method", value: "S256"),
- URLQueryItem(name: "access_type", value: "offline"),
- URLQueryItem(name: "prompt", value: "consent")
- ]
- guard let authURL = components.url else { throw GoogleOAuthError.invalidCallbackURL }
- let callbackURL = try await oauthWindow.authenticate(
- startURL: authURL,
- callbackURLPrefix: redirectURI,
- presentingWindow: presentingWindow
- )
- let query = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?.queryItems
- guard query?.first(where: { $0.name == "state" })?.value == state else { throw GoogleOAuthError.invalidCallbackURL }
- guard let code = query?.first(where: { $0.name == "code" })?.value, code.isEmpty == false else {
- throw GoogleOAuthError.missingAuthorizationCode
- }
- return try await exchangeCodeForTokens(code: code, codeVerifier: codeVerifier, redirectURI: redirectURI)
- }
- @MainActor
- private func makeOAuthWindowController() -> InAppOAuthWindowController {
- if let existing = inAppOAuthWindowController { return existing }
- let controller = InAppOAuthWindowController(title: "Google Sign-In")
- inAppOAuthWindowController = controller
- return controller
- }
- private func exchangeCodeForTokens(code: String, codeVerifier: String, redirectURI: String) async throws -> GoogleOAuthTokens {
- var request = URLRequest(url: URL(string: "https://oauth2.googleapis.com/token")!)
- request.httpMethod = "POST"
- request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
- request.httpBody = Self.formURLEncoded([
- "client_id": clientId,
- "client_secret": clientSecret,
- "code": code,
- "code_verifier": codeVerifier,
- "redirect_uri": redirectURI,
- "grant_type": "authorization_code"
- ])
- let (data, response) = try await URLSession.shared.data(for: request)
- guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
- throw GoogleOAuthError.tokenExchangeFailed(String(data: data, encoding: .utf8) ?? "Failed")
- }
- struct TokenResponse: Decodable {
- let access_token: String
- let expires_in: Double
- let refresh_token: String?
- let scope: String?
- let token_type: String?
- }
- let decoded = try JSONDecoder().decode(TokenResponse.self, from: data)
- return GoogleOAuthTokens(
- accessToken: decoded.access_token,
- refreshToken: decoded.refresh_token,
- expiresAt: Date().addingTimeInterval(decoded.expires_in),
- scope: decoded.scope,
- tokenType: decoded.token_type
- )
- }
- private static func pkceChallenge(for verifier: String) -> String {
- let digest = SHA256.hash(data: Data(verifier.utf8))
- return Data(digest).base64URLEncodedString()
- }
- private static func randomURLSafeString(length: Int) -> String {
- var bytes = [UInt8](repeating: 0, count: length)
- _ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
- return Data(bytes).base64URLEncodedString()
- }
- private static func formURLEncoded(_ params: [String: String]) -> Data {
- let allowed = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-._~")
- let pairs = params.map { key, value in
- let k = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key
- let v = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value
- return "\(k)=\(v)"
- }.joined(separator: "&")
- return Data(pairs.utf8)
- }
- private func tokenHasCalendarScope(_ scopeValue: String?) -> Bool {
- guard let scopeValue else { return false }
- return scopeValue.split(separator: " ").contains(where: { $0 == Substring(requiredCalendarScope) })
- }
- }
- private extension Data {
- func base64URLEncodedString() -> String {
- base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
- }
- }
- final class KeychainTokenStore {
- private let defaultsKey: String
- private let defaults: UserDefaults
- init(service: String = Bundle.main.bundleIdentifier ?? "zoom_app",
- account: String = "googleOAuthTokens",
- defaults: UserDefaults = .standard) {
- self.defaultsKey = "\(service).\(account)"
- self.defaults = defaults
- }
- func readTokens() throws -> GoogleOAuthTokens? {
- guard let data = defaults.data(forKey: defaultsKey) else { return nil }
- return try JSONDecoder().decode(GoogleOAuthTokens.self, from: data)
- }
- func writeTokens(_ tokens: GoogleOAuthTokens) throws {
- let data = try JSONEncoder().encode(tokens)
- defaults.set(data, forKey: defaultsKey)
- }
- func clearTokens() {
- defaults.removeObject(forKey: defaultsKey)
- }
- }
- // MARK: - OAuth window (previous sign-in behavior)
- @MainActor
- private final class OAuthWebViewContainerView: NSView {
- private let webView: WKWebView
- init(webView: WKWebView) {
- self.webView = webView
- super.init(frame: .zero)
- addSubview(webView)
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- override func layout() {
- super.layout()
- webView.frame = bounds
- }
- }
- /// Separate titled window matching the app’s default content size (see `AppWindowMetrics`); when a parent window is provided, the OAuth content area is aligned to the parent’s content rect.
- @MainActor
- private final class InAppOAuthWindowController: NSWindowController, WKNavigationDelegate, NSWindowDelegate {
- private let webView: WKWebView
- private var callbackPrefix: String?
- private var callbackContinuation: CheckedContinuation<URL, Error>?
- private weak var parentWindowForChild: NSWindow?
- /// When true, `windowWillClose` does not treat the close as user cancellation (we closed after success/timeout/error).
- private var suppressUserCloseCancellation = false
- init(title: String) {
- let configuration = WKWebViewConfiguration()
- configuration.websiteDataStore = .nonPersistent()
- self.webView = WKWebView(frame: .zero, configuration: configuration)
- let container = OAuthWebViewContainerView(webView: webView)
- let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- let window = NSWindow(contentRect: NSRect(origin: .zero, size: contentSize), styleMask: [.titled, .closable, .miniaturizable], backing: .buffered, defer: false)
- window.title = title
- window.contentView = container
- window.contentMinSize = contentSize
- window.contentMaxSize = contentSize
- window.isReleasedWhenClosed = false
- super.init(window: window)
- window.delegate = self
- webView.navigationDelegate = self
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- func windowWillClose(_ notification: Notification) {
- guard suppressUserCloseCancellation == false else { return }
- removeFromParentIfNeeded()
- webView.stopLoading()
- if let continuation = callbackContinuation {
- callbackContinuation = nil
- continuation.resume(throwing: GoogleOAuthError.authenticationCancelled)
- }
- }
- func authenticate(startURL: URL, callbackURLPrefix: String, presentingWindow: NSWindow?, timeoutSeconds: Double = 120) async throws -> URL {
- self.callbackPrefix = callbackURLPrefix
- parentWindowForChild = presentingWindow
- applyFixedOAuthFrame(presentingWindow: presentingWindow)
- if let parent = presentingWindow, let w = window {
- parent.addChildWindow(w, ordered: .above)
- }
- showWindow(nil)
- window?.makeKeyAndOrderFront(nil)
- webView.load(URLRequest(url: startURL))
- do {
- return try await withThrowingTaskGroup(of: URL.self) { group in
- group.addTask { [weak self] in
- guard let self else { throw GoogleOAuthError.invalidCallbackURL }
- return try await self.awaitCallback()
- }
- group.addTask {
- try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
- throw GoogleOAuthError.authenticationTimedOut
- }
- let url = try await group.next()!
- group.cancelAll()
- self.closeOAuthWindowAfterFlow()
- return url
- }
- } catch {
- self.closeOAuthWindowAfterFlow()
- throw error
- }
- }
- private func closeOAuthWindowAfterFlow() {
- suppressUserCloseCancellation = true
- webView.stopLoading()
- removeFromParentIfNeeded()
- close()
- suppressUserCloseCancellation = false
- }
- private func awaitCallback() async throws -> URL {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- callbackContinuation = continuation
- }
- }
- private func removeFromParentIfNeeded() {
- if let parent = parentWindowForChild, let w = window {
- parent.removeChildWindow(w)
- }
- parentWindowForChild = nil
- }
- private func applyFixedOAuthFrame(presentingWindow: NSWindow?) {
- guard let window else { return }
- if let parent = presentingWindow {
- let parentContent = parent.contentRect(forFrameRect: parent.frame)
- let oauthFrame = window.frameRect(forContentRect: parentContent)
- window.setFrame(oauthFrame, display: true)
- } else {
- let contentSize = NSSize(width: AppWindowMetrics.defaultContentWidth, height: AppWindowMetrics.defaultContentHeight)
- window.setContentSize(contentSize)
- window.center()
- }
- let lockedContentSize = window.contentRect(forFrameRect: window.frame).size
- window.contentMinSize = lockedContentSize
- window.contentMaxSize = lockedContentSize
- }
- func webView(_ webView: WKWebView,
- decidePolicyFor navigationAction: WKNavigationAction,
- decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
- guard let targetURL = navigationAction.request.url,
- let callbackPrefix,
- targetURL.absoluteString.hasPrefix(callbackPrefix) else {
- decisionHandler(.allow)
- return
- }
- callbackContinuation?.resume(returning: targetURL)
- callbackContinuation = nil
- decisionHandler(.cancel)
- }
- }
- extension GoogleOAuthError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .missingClientId: return "Missing Google OAuth Client ID."
- case .missingClientSecret: return "Missing Google OAuth Client Secret."
- case .invalidCallbackURL: return "Invalid OAuth callback URL."
- case .missingAuthorizationCode: return "Google did not return an authorization code."
- case .tokenExchangeFailed(let details): return "Token exchange failed: \(details)"
- case .unableToOpenBrowser: return "Could not open browser for Google sign-in."
- case .authenticationTimedOut: return "Google sign-in timed out."
- case .authenticationCancelled: return "Sign-in was cancelled."
- }
- }
- }
- extension ZoomOAuthError: LocalizedError {
- var errorDescription: String? {
- switch self {
- case .missingClientId:
- return "Zoom OAuth Client ID is not set (Info.plist ZoomOAuthClientId, UserDefaults, or the setup prompt)."
- case .missingClientSecret:
- return "Zoom OAuth Client Secret is not set (environment ZOOM_OAUTH_CLIENT_SECRET, UserDefaults, or the setup prompt)."
- case .invalidCallbackURL:
- return "The OAuth redirect URL was invalid. In your Zoom app OAuth allow list, add exactly http://127.0.0.1:8742/oauth2redirect."
- case .missingAuthorizationCode:
- return "Zoom did not return an authorization code."
- case .tokenExchangeFailed(let details):
- return details
- case .missingRequiredScope(let details):
- return "The Zoom access token is missing required scopes. \(details)"
- case .rateLimited(let retryAfterSeconds):
- if let retryAfterSeconds {
- return "Zoom rate limit reached. Try again in \(retryAfterSeconds) seconds."
- }
- return "Zoom rate limit reached. Try again later."
- case .unableToOpenBrowser:
- return "Could not open the system browser for Zoom sign-in."
- case .authenticationTimedOut:
- return "Zoom sign-in timed out waiting for the browser redirect."
- case .authenticationCancelled:
- return "Zoom sign-in was cancelled."
- }
- }
- }
- // MARK: - Desktop Widgets (Zoom)
- private struct WidgetQuickAction: Identifiable, Hashable {
- enum Destination: String, Hashable {
- case home
- case meetings
- case scheduler
- case widgets
- case settings
- case newMeeting
- case joinMeeting
- case scheduleMeeting
- case refreshMeetings
- }
- let id: String
- let title: String
- let systemImage: String
- let destination: Destination
- }
- private struct WidgetVariant: Identifiable, Hashable {
- let id: String
- let title: String
- let subtitle: String
- let size: WidgetSize
- let quickActions: [WidgetQuickAction]
- }
- private enum WidgetTemplates {
- static let zoomVariants: [WidgetVariant] = [
- WidgetVariant(
- id: "zoom_small",
- title: "Zoom",
- subtitle: "Quick actions",
- size: .small,
- quickActions: [
- WidgetQuickAction(id: "new", title: "New meeting", systemImage: "video.fill", destination: .newMeeting),
- WidgetQuickAction(id: "join", title: "Join", systemImage: "plus", destination: .joinMeeting),
- WidgetQuickAction(id: "schedule", title: "Schedule", systemImage: "calendar", destination: .scheduleMeeting),
- WidgetQuickAction(id: "open", title: "Meetings", systemImage: "list.bullet.rectangle", destination: .meetings)
- ]
- ),
- WidgetVariant(
- id: "zoom_medium",
- title: "Zoom",
- subtitle: "Upcoming meetings",
- size: .medium,
- quickActions: [
- WidgetQuickAction(id: "meetings", title: "Meetings", systemImage: "video", destination: .meetings),
- WidgetQuickAction(id: "scheduler", title: "Scheduler", systemImage: "calendar.badge.clock", destination: .scheduler),
- WidgetQuickAction(id: "widgets", title: "Widgets", systemImage: "rectangle.grid.2x2", destination: .widgets),
- WidgetQuickAction(id: "refresh", title: "Refresh", systemImage: "arrow.clockwise", destination: .refreshMeetings)
- ]
- )
- ]
- static func variant(for variantID: String) -> WidgetVariant {
- zoomVariants.first(where: { $0.id == variantID }) ?? zoomVariants[0]
- }
- }
- private extension WidgetVariant {
- var previewCardSize: CGSize {
- switch size {
- case .small:
- return CGSize(width: 196, height: 196)
- case .medium:
- return CGSize(width: 340, height: 288)
- }
- }
- }
- private enum WidgetSize: String, CaseIterable, Codable, Hashable {
- case small
- case medium
- var title: String {
- switch self {
- case .small: return "Small"
- case .medium: return "Medium"
- }
- }
- }
- private struct WidgetInstance: Identifiable, Codable, Hashable {
- struct Origin: Codable, Hashable {
- var x: Double
- var y: Double
- }
- let id: UUID
- let variantID: String
- var size: WidgetSize
- var origin: Origin?
- init(id: UUID = UUID(), variantID: String, size: WidgetSize, origin: Origin?) {
- self.id = id
- self.variantID = variantID
- self.size = size
- self.origin = origin
- }
- }
- private struct WidgetMeetingSnapshot: Codable, Identifiable {
- let id: String
- let title: String
- let timeText: String
- let joinLink: String?
- }
- private enum WidgetMeetingStore {
- static let key = "zoom_app.widget.topMeetings"
- static func load() -> [WidgetMeetingSnapshot] {
- guard let data = UserDefaults.standard.data(forKey: key) else { return [] }
- return (try? JSONDecoder().decode([WidgetMeetingSnapshot].self, from: data)) ?? []
- }
- static func save(_ meetings: [WidgetMeetingSnapshot]) {
- guard let data = try? JSONEncoder().encode(meetings) else { return }
- UserDefaults.standard.set(data, forKey: key)
- }
- }
- private enum WidgetAppNavigator {
- static func open(target: WidgetQuickAction.Destination) {
- DispatchQueue.main.async {
- bringAppToFront()
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
- NotificationCenter.default.post(name: notificationName(for: target), object: nil)
- }
- }
- }
- static func openMeetingLink(_ link: String) {
- DispatchQueue.main.async {
- bringAppToFront()
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.24) {
- NotificationCenter.default.post(
- name: .widgetOpenMeetingLinkRequested,
- object: nil,
- userInfo: ["link": link]
- )
- }
- }
- }
- private static func notificationName(for target: WidgetQuickAction.Destination) -> Notification.Name {
- switch target {
- case .home: return .widgetOpenHomePage
- case .meetings: return .widgetOpenMeetingsPage
- case .scheduler: return .widgetOpenSchedulerPage
- case .widgets: return .widgetOpenWidgetsPage
- case .settings: return .widgetOpenSettingsPage
- case .newMeeting: return .widgetStartNewMeetingRequested
- case .joinMeeting: return .widgetJoinMeetingRequested
- case .scheduleMeeting: return .widgetScheduleMeetingRequested
- case .refreshMeetings: return .widgetRefreshRequested
- }
- }
- private static func bringAppToFront() {
- NSApp.unhide(nil)
- NSRunningApplication.current.activate(options: [.activateAllWindows, .activateIgnoringOtherApps])
- NSApp.activate(ignoringOtherApps: true)
- NSApp.arrangeInFront(nil)
- for window in NSApp.windows where window.contentViewController is ViewController {
- if window.isMiniaturized {
- window.deminiaturize(nil)
- }
- window.orderFrontRegardless()
- window.makeKeyAndOrderFront(nil)
- }
- }
- }
- private extension Notification.Name {
- static let widgetOpenHomePage = Notification.Name("widgetOpenHomePage")
- static let widgetOpenMeetingsPage = Notification.Name("widgetOpenMeetingsPage")
- static let widgetOpenSchedulerPage = Notification.Name("widgetOpenSchedulerPage")
- static let widgetOpenWidgetsPage = Notification.Name("widgetOpenWidgetsPage")
- static let widgetOpenSettingsPage = Notification.Name("widgetOpenSettingsPage")
- static let widgetStartNewMeetingRequested = Notification.Name("widgetStartNewMeetingRequested")
- static let widgetJoinMeetingRequested = Notification.Name("widgetJoinMeetingRequested")
- static let widgetScheduleMeetingRequested = Notification.Name("widgetScheduleMeetingRequested")
- static let widgetRefreshRequested = Notification.Name("widgetRefreshRequested")
- static let widgetOpenMeetingLinkRequested = Notification.Name("widgetOpenMeetingLinkRequested")
- }
- private final class DesktopWidgetWindowManager: ObservableObject {
- static let shared = DesktopWidgetWindowManager()
- private let storageKey = "zoom_app.widget.instancesData"
- private var windowsByInstanceID: [UUID: NSPanel] = [:]
- @Published private(set) var instancesChangeToken: Int = 0
- @Published private(set) var meetingsRefreshToken: Int = 0
- private init() {}
- func hasInstance(variantID: String) -> Bool {
- loadInstances().contains { $0.variantID == variantID }
- }
- func show(instance: WidgetInstance) {
- let variant = WidgetTemplates.variant(for: instance.variantID)
- var resolved = instance
- if resolved.origin == nil {
- resolved.origin = nextAutoPlacement(for: variant)
- }
- let panel = makePanel(for: resolved, variant: variant)
- windowsByInstanceID[resolved.id] = panel
- persistUpsert(instance: resolved)
- if let origin = resolved.origin {
- panel.setFrameOrigin(NSPoint(x: origin.x, y: origin.y))
- persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
- } else {
- panel.center()
- persistOrigin(instanceID: resolved.id, origin: panel.frame.origin)
- }
- panel.orderFront(nil)
- panel.orderFrontRegardless()
- instancesChangeToken += 1
- }
- @discardableResult
- func removeInstances(variantID: String) -> Int {
- let instances = loadInstances()
- let removed = instances.filter { $0.variantID == variantID }
- let remaining = instances.filter { $0.variantID != variantID }
- for instance in removed {
- if let panel = windowsByInstanceID.removeValue(forKey: instance.id) {
- panel.close()
- }
- }
- saveInstances(remaining)
- instancesChangeToken += 1
- return removed.count
- }
- func restore() {
- for instance in loadInstances() where windowsByInstanceID[instance.id] == nil {
- show(instance: instance)
- }
- }
- func refreshForMeetingsChange() {
- resizeVisiblePanelsForCurrentContent()
- meetingsRefreshToken += 1
- }
- private func makePanel(for instance: WidgetInstance, variant: WidgetVariant) -> NSPanel {
- let size = panelSize(for: variant.size)
- let panel = DesktopWidgetPanel(
- contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height),
- styleMask: [.nonactivatingPanel, .borderless],
- backing: .buffered,
- defer: false
- )
- panel.becomesKeyOnlyIfNeeded = false
- panel.isReleasedWhenClosed = false
- panel.isFloatingPanel = false
- panel.isMovable = true
- panel.hasShadow = false
- panel.isOpaque = false
- panel.backgroundColor = .clear
- panel.isMovableByWindowBackground = true
- panel.hidesOnDeactivate = false
- panel.ignoresMouseEvents = false
- let desktopIconLevel = Int(CGWindowLevelForKey(.desktopIconWindow))
- panel.level = NSWindow.Level(rawValue: desktopIconLevel + 1)
- panel.collectionBehavior = [.canJoinAllSpaces, .stationary, .ignoresCycle]
- let host = NSHostingView(rootView: DesktopWidgetHostView(instanceID: instance.id, variant: variant))
- host.translatesAutoresizingMaskIntoConstraints = false
- let container = NSView()
- container.wantsLayer = true
- container.layer?.backgroundColor = NSColor.clear.cgColor
- container.layer?.cornerRadius = 22
- container.layer?.masksToBounds = true
- container.addSubview(host)
- NSLayoutConstraint.activate([
- host.leadingAnchor.constraint(equalTo: container.leadingAnchor),
- host.trailingAnchor.constraint(equalTo: container.trailingAnchor),
- host.topAnchor.constraint(equalTo: container.topAnchor),
- host.bottomAnchor.constraint(equalTo: container.bottomAnchor)
- ])
- panel.contentView = container
- NotificationCenter.default.addObserver(
- forName: NSWindow.didMoveNotification,
- object: panel,
- queue: .main
- ) { [weak self, weak panel] _ in
- guard let self, let panel else { return }
- self.persistOrigin(instanceID: instance.id, origin: panel.frame.origin)
- }
- return panel
- }
- private func panelSize(for size: WidgetSize) -> CGSize {
- switch size {
- case .small:
- return CGSize(width: 220, height: 204)
- case .medium:
- return CGSize(width: 390, height: mediumPanelHeight())
- }
- }
- private func mediumPanelHeight() -> CGFloat {
- let visibleMeetingCount = min(WidgetMeetingStore.load().count, 2)
- switch visibleMeetingCount {
- case 0:
- return 252
- case 1:
- return 270
- default:
- return 288
- }
- }
- private func resizeVisiblePanelsForCurrentContent() {
- let instancesByID = Dictionary(uniqueKeysWithValues: loadInstances().map { ($0.id, $0) })
- for (instanceID, panel) in windowsByInstanceID {
- guard let instance = instancesByID[instanceID] else { continue }
- let variant = WidgetTemplates.variant(for: instance.variantID)
- let targetSize = panelSize(for: variant.size)
- let currentSize = panel.frame.size
- guard abs(currentSize.height - targetSize.height) > 0.5 || abs(currentSize.width - targetSize.width) > 0.5 else {
- continue
- }
- var frame = panel.frame
- let oldMaxY = frame.maxY
- frame.size = targetSize
- frame.origin.y = oldMaxY - targetSize.height
- panel.setFrame(frame, display: true)
- persistOrigin(instanceID: instanceID, origin: frame.origin)
- }
- }
- private func nextAutoPlacement(for variant: WidgetVariant) -> WidgetInstance.Origin {
- let size = panelSize(for: variant.size)
- let frame = NSScreen.main?.visibleFrame ?? NSRect(x: 100, y: 100, width: 1200, height: 800)
- let margin: CGFloat = 18
- let gapX: CGFloat = 22
- let gapY: CGFloat = 18
- let cellW = size.width + gapX
- let cellH = size.height + gapY
- let existingRects = loadInstances().compactMap { instance -> NSRect? in
- guard let origin = instance.origin else { return nil }
- let instanceSize = panelSize(for: instance.size)
- return NSRect(x: origin.x, y: origin.y, width: instanceSize.width, height: instanceSize.height)
- }
- let cols = max(1, Int((frame.width - margin * 2) / max(cellW, 1)))
- let rows = max(1, Int((frame.height - margin * 2) / max(cellH, 1)))
- let maxSlots = max(120, cols * rows)
- for slot in 0..<maxSlots {
- let row = slot / cols
- let col = cols - 1 - (slot % cols)
- let x = frame.minX + margin + CGFloat(col) * cellW
- let y = frame.maxY - margin - size.height - CGFloat(row) * cellH
- let candidate = NSRect(x: x, y: y, width: size.width, height: size.height)
- guard candidate.minX >= frame.minX + margin,
- candidate.maxX <= frame.maxX - margin,
- candidate.minY >= frame.minY + margin else {
- continue
- }
- if !existingRects.contains(where: { $0.intersects(candidate.insetBy(dx: -8, dy: -8)) }) {
- return WidgetInstance.Origin(x: candidate.minX, y: candidate.minY)
- }
- }
- return WidgetInstance.Origin(
- x: frame.midX - size.width / 2,
- y: frame.midY - size.height / 2
- )
- }
- private func close(instanceID: UUID) {
- if let panel = windowsByInstanceID.removeValue(forKey: instanceID) {
- panel.close()
- }
- let remaining = loadInstances().filter { $0.id != instanceID }
- saveInstances(remaining)
- instancesChangeToken += 1
- }
- private func loadInstances() -> [WidgetInstance] {
- guard let data = UserDefaults.standard.data(forKey: storageKey) else { return [] }
- let decoded = (try? JSONDecoder().decode([WidgetInstance].self, from: data)) ?? []
- let allowedByVariantID = Dictionary(uniqueKeysWithValues: WidgetTemplates.zoomVariants.map { ($0.id, $0.size) })
- let sanitized = decoded.compactMap { instance -> WidgetInstance? in
- guard let expectedSize = allowedByVariantID[instance.variantID] else { return nil }
- var normalized = instance
- normalized.size = expectedSize
- return normalized
- }
- if sanitized != decoded {
- saveInstances(sanitized)
- }
- return sanitized
- }
- private func saveInstances(_ instances: [WidgetInstance]) {
- guard let data = try? JSONEncoder().encode(instances) else { return }
- UserDefaults.standard.set(data, forKey: storageKey)
- }
- private func persistUpsert(instance: WidgetInstance) {
- var instances = loadInstances()
- if let idx = instances.firstIndex(where: { $0.id == instance.id }) {
- instances[idx] = instance
- } else {
- instances.append(instance)
- }
- saveInstances(instances)
- }
- private func persistOrigin(instanceID: UUID, origin: NSPoint) {
- var instances = loadInstances()
- guard let idx = instances.firstIndex(where: { $0.id == instanceID }) else { return }
- instances[idx].origin = WidgetInstance.Origin(x: origin.x, y: origin.y)
- saveInstances(instances)
- }
- private struct DesktopWidgetHostView: View {
- let instanceID: UUID
- let variant: WidgetVariant
- @ObservedObject private var manager = DesktopWidgetWindowManager.shared
- var body: some View {
- DesktopWidgetView(variant: variant, isPreview: false)
- .id(manager.meetingsRefreshToken)
- .contextMenu {
- Button("Remove Widget", role: .destructive) {
- DesktopWidgetWindowManager.shared.close(instanceID: instanceID)
- }
- }
- }
- }
- }
- private final class DesktopWidgetPanel: NSPanel {
- override var canBecomeKey: Bool { true }
- override var canBecomeMain: Bool { false }
- override func mouseDown(with event: NSEvent) {
- NSApp.activate(ignoringOtherApps: true)
- makeKeyAndOrderFront(nil)
- super.mouseDown(with: event)
- }
- }
- private struct WidgetsRootView: View {
- @ObservedObject private var desktopWidgetManager = DesktopWidgetWindowManager.shared
- @Environment(\.colorScheme) private var colorScheme
- @State private var toastMessage: String?
- @State private var toastTask: Task<Void, Never>?
- let canAddWidgets: Bool
- let onAddBlocked: (() -> Void)?
- private let variants = WidgetTemplates.zoomVariants
- init(canAddWidgets: Bool = true, onAddBlocked: (() -> Void)? = nil) {
- self.canAddWidgets = canAddWidgets
- self.onAddBlocked = onAddBlocked
- }
- var body: some View {
- VStack(alignment: .leading, spacing: 18) {
- HStack {
- VStack(alignment: .leading, spacing: 3) {
- Text("Add Widget to Desktop")
- .font(.system(size: 20, weight: .bold))
- Text("Choose a size and add your Zoom desktop widget.")
- .font(.system(size: 13, weight: .medium))
- .foregroundStyle(secondaryTextColor)
- }
- Spacer()
- }
- GeometryReader { geo in
- let count = max(variants.count, 1)
- let spacing: CGFloat = 24
- let baseRowWidth = variants.reduce(CGFloat(0)) { $0 + $1.previewCardSize.width }
- let usableWidth = max(1, geo.size.width)
- let usableHeight = max(1, geo.size.height)
- let maxPreviewHeight = variants.map(\.previewCardSize.height).max() ?? 320
- let widthScale = (usableWidth - CGFloat(count - 1) * spacing) / max(1, baseRowWidth)
- let heightScale = usableHeight / max(1, maxPreviewHeight)
- let scale = max(0.45, min(1.0, min(widthScale, heightScale)))
- HStack(alignment: .top, spacing: spacing) {
- ForEach(variants) { variant in
- WidgetPreviewAddCard(
- variant: variant,
- layoutScale: scale,
- isAdded: desktopWidgetManager.hasInstance(variantID: variant.id),
- onToggle: { toggleWidget(variant: variant) }
- )
- }
- Spacer(minLength: 0)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity)
- Text("Tip: Right-click a desktop widget to remove it.")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(secondaryTextColor)
- }
- .padding(22)
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- .background(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .fill(colorScheme == .dark ? Color.white.opacity(0.05) : Color.white.opacity(0.93))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(colorScheme == .dark ? Color.white.opacity(0.09) : Color.black.opacity(0.08), lineWidth: 1)
- )
- .padding(.horizontal, 12)
- .padding(.vertical, 12)
- .overlay(alignment: .top) {
- if let toastMessage {
- Text(toastMessage)
- .font(.system(size: 13, weight: .semibold))
- .foregroundStyle(colorScheme == .dark ? .white.opacity(0.95) : .primary.opacity(0.95))
- .padding(.horizontal, 14)
- .padding(.vertical, 9)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(colorScheme == .dark ? Color.black.opacity(0.82) : Color.white.opacity(0.95))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(colorScheme == .dark ? Color.white.opacity(0.08) : Color.black.opacity(0.08), lineWidth: 1)
- )
- .padding(.top, 8)
- }
- }
- .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
- }
- private var secondaryTextColor: Color {
- colorScheme == .dark ? .white.opacity(0.72) : .primary.opacity(0.65)
- }
- private func toggleWidget(variant: WidgetVariant) {
- if desktopWidgetManager.hasInstance(variantID: variant.id) {
- let removed = desktopWidgetManager.removeInstances(variantID: variant.id)
- guard removed > 0 else { return }
- showToast("Removed \(variant.size.title) widget from desktop.")
- } else {
- guard canAddWidgets else {
- showToast("Widgets are Premium only. Upgrade to add widgets.")
- onAddBlocked?()
- return
- }
- desktopWidgetManager.show(
- instance: WidgetInstance(variantID: variant.id, size: variant.size, origin: nil)
- )
- showToast("Added \(variant.size.title) widget to desktop.")
- }
- }
- private func showToast(_ message: String) {
- toastTask?.cancel()
- toastMessage = message
- toastTask = Task { @MainActor in
- try? await Task.sleep(nanoseconds: 1_800_000_000)
- guard !Task.isCancelled else { return }
- toastMessage = nil
- }
- }
- }
- private struct WidgetPreviewAddCard: View {
- let variant: WidgetVariant
- var layoutScale: CGFloat = 1
- let isAdded: Bool
- let onToggle: () -> Void
- @State private var hovering = false
- var body: some View {
- ZStack(alignment: .topTrailing) {
- Button(action: onToggle) {
- WidgetPreviewCard(variant: variant, layoutScale: layoutScale)
- .contentShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- }
- .buttonStyle(.plain)
- Button(action: onToggle) {
- Image(systemName: isAdded ? "minus" : "plus")
- .font(.system(size: 12, weight: .bold))
- .foregroundStyle(.white.opacity(0.95))
- .frame(width: 26, height: 26)
- .background(Circle().fill(isAdded ? Color.red.opacity(0.92) : Color.green.opacity(0.95)))
- .shadow(color: .black.opacity(0.28), radius: 10, x: 0, y: 6)
- }
- .buttonStyle(.plain)
- .offset(x: 10 * layoutScale, y: -10 * layoutScale)
- .opacity(isAdded ? 1 : (hovering ? 1 : 0))
- .animation(.easeOut(duration: 0.12), value: hovering)
- }
- .onHover { hovering = $0 }
- }
- }
- private struct WidgetPreviewCard: View {
- let variant: WidgetVariant
- var layoutScale: CGFloat = 1
- var body: some View {
- let size = variant.previewCardSize
- let scale = max(0.01, layoutScale)
- DesktopWidgetView(variant: variant, isPreview: true)
- .frame(width: size.width, height: size.height)
- .scaleEffect(scale)
- .frame(width: size.width * scale, height: size.height * scale)
- .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous))
- .overlay(
- RoundedRectangle(cornerRadius: 18, style: .continuous)
- .stroke(Color.white.opacity(0.12), lineWidth: 1)
- )
- }
- }
- private struct DesktopWidgetView: View {
- let variant: WidgetVariant
- var isPreview: Bool
- private var topMeetings: [WidgetMeetingSnapshot] {
- WidgetMeetingStore.load().prefix(3).map { $0 }
- }
- var body: some View {
- ZStack {
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .fill(
- LinearGradient(
- colors: [
- Color(red: 0.11, green: 0.45, blue: 0.98),
- Color(red: 0.14, green: 0.72, blue: 0.98)
- ],
- startPoint: .topLeading,
- endPoint: .bottomTrailing
- )
- )
- .overlay(
- RoundedRectangle(cornerRadius: 22, style: .continuous)
- .stroke(Color.white.opacity(0.14), lineWidth: 1)
- )
- VStack(alignment: .leading, spacing: 12) {
- widgetHeader
- contentBody
- if variant.size != .small {
- Spacer(minLength: 0)
- }
- }
- .padding(.horizontal, 14)
- .padding(.bottom, variant.size == .medium ? 16 : 14)
- .padding(.top, variant.size == .medium ? 18 : 10)
- }
- }
- @ViewBuilder
- private var contentBody: some View {
- switch variant.size {
- case .small:
- VStack(alignment: .leading, spacing: 10) {
- Text("Quick actions")
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.78))
- compactActionButton(title: "New meeting", icon: "video.fill", destination: .newMeeting)
- HStack(spacing: 8) {
- compactActionButton(title: "Join", icon: "plus", destination: .joinMeeting)
- compactActionButton(title: "Schedule", icon: "calendar", destination: .scheduleMeeting)
- }
- }
- case .medium:
- meetingBlock(maxRows: 2)
- LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 0), spacing: 8), count: 2), spacing: 8) {
- ForEach(variant.quickActions.prefix(4)) { action in
- actionTile(action)
- }
- }
- }
- }
- private var widgetHeader: some View {
- HStack(spacing: 10) {
- Image(systemName: "video.fill")
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(.white.opacity(0.92))
- .frame(width: 30, height: 30)
- .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color.black.opacity(0.18)))
- VStack(alignment: .leading, spacing: 2) {
- Text(variant.title)
- .font(.system(size: 16, weight: .bold))
- .foregroundStyle(.white.opacity(0.95))
- Text(variant.subtitle)
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.8))
- }
- Spacer(minLength: 0)
- }
- }
- private func meetingBlock(maxRows: Int) -> some View {
- VStack(alignment: .leading, spacing: 9) {
- Text("Upcoming meetings")
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.80))
- if topMeetings.isEmpty {
- Text("No upcoming meetings")
- .font(.system(size: 12, weight: .medium))
- .foregroundStyle(.white.opacity(0.82))
- } else {
- VStack(alignment: .leading, spacing: 8) {
- ForEach(topMeetings.prefix(maxRows)) { meeting in
- meetingRow(meeting)
- }
- }
- }
- }
- }
- private func meetingRow(_ meeting: WidgetMeetingSnapshot) -> some View {
- Button(action: {
- if let link = meeting.joinLink?.trimmingCharacters(in: .whitespacesAndNewlines), !link.isEmpty {
- WidgetAppNavigator.openMeetingLink(link)
- } else {
- WidgetAppNavigator.open(target: .meetings)
- }
- }) {
- HStack(spacing: 8) {
- Image(systemName: "video.fill")
- .font(.system(size: 11, weight: .semibold))
- .foregroundStyle(.white.opacity(0.88))
- VStack(alignment: .leading, spacing: 1) {
- Text(meeting.title)
- .font(.system(size: 11.5, weight: .semibold))
- .foregroundStyle(.white.opacity(0.93))
- .lineLimit(1)
- Text(meeting.timeText)
- .font(.system(size: 10.5, weight: .medium))
- .foregroundStyle(.white.opacity(0.74))
- .lineLimit(1)
- }
- Spacer(minLength: 0)
- }
- .padding(.horizontal, 10)
- .padding(.vertical, 7)
- .background(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .fill(Color.black.opacity(0.20))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 10, style: .continuous)
- .stroke(Color.white.opacity(0.08), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func compactActionButton(title: String, icon: String, destination: WidgetQuickAction.Destination) -> some View {
- Button(action: { WidgetAppNavigator.open(target: destination) }) {
- HStack(spacing: 5) {
- Image(systemName: icon)
- .font(.system(size: 10.5, weight: .semibold))
- .frame(width: 14)
- Text(title)
- .font(.system(size: 11.5, weight: .bold))
- .lineLimit(1)
- .minimumScaleFactor(0.82)
- .layoutPriority(1)
- }
- .foregroundStyle(.white.opacity(0.97))
- .padding(.horizontal, 10)
- .padding(.vertical, 8)
- .frame(maxWidth: .infinity)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color.black.opacity(0.30))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.24), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func actionTile(_ action: WidgetQuickAction) -> some View {
- Button(action: { open(action: action) }) {
- HStack(spacing: 8) {
- Image(systemName: action.systemImage)
- .font(.system(size: 11, weight: .semibold))
- Text(action.title)
- .font(.system(size: 12, weight: .bold))
- .lineLimit(1)
- Spacer(minLength: 0)
- }
- .foregroundStyle(.white.opacity(0.92))
- .padding(.horizontal, 10)
- .padding(.vertical, 9)
- .background(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .fill(Color.black.opacity(0.24))
- )
- .overlay(
- RoundedRectangle(cornerRadius: 12, style: .continuous)
- .stroke(Color.white.opacity(0.10), lineWidth: 1)
- )
- }
- .buttonStyle(.plain)
- .disabled(isPreview)
- .allowsHitTesting(!isPreview)
- }
- private func open(action: WidgetQuickAction) {
- guard !isPreview else { return }
- WidgetAppNavigator.open(target: action.destination)
- }
- }
|