| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817 |
- //
- // ViewController.swift
- // zoom_app
- //
- // Created by Dev Mac 1 on 14/04/2026.
- //
- import Cocoa
- import CryptoKit
- import Network
- import StoreKit
- import WebKit
- class ViewController: NSViewController {
- private let googleOAuth = GoogleOAuthService.shared
- private let zoomOAuth = ZoomOAuthService.shared
- private let loginStateKey = "zoom_app.isLoggedIn"
- private let darkModeDefaultsKey = "settings.darkModeEnabled"
- 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) }
- }
- 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 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 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 let rootContainer = NSView()
- private var loginView: NSView?
- private var homeView: NSView?
- private weak var googleButton: NSButton?
- private weak var nextSignInButton: NSButton?
- private weak var zoomSocialButton: 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 homeSettingsView: NSView?
- private weak var settingsDarkModeSwitch: NSSwitch?
- private weak var settingsUpgradeButton: NSButton?
- private weak var settingsRestoreButton: NSButton?
- private weak var settingsGoogleActionButton: NSButton?
- private weak var topBarPremiumButton: NSButton?
- private var paywallWindow: NSWindow?
- private let paywallContentWidth: CGFloat = 520
- private var selectedPremiumPlan: PremiumPlan = .monthly
- private var paywallPlanViews: [PremiumPlan: NSView] = [:]
- private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
- 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 allScheduledMeetings: [ScheduledMeeting] = []
- private var selectedMeetingsDayStart: Date = Calendar.current.startOfDay(for: Date())
- 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 clockTimer: Timer?
- private var meetingsRefreshTimer: Timer?
- private var isSigningIn = false
- private var isPromptingZoomCredentials = false
- private var isLoadingMeetings = false
- private var meetingsScrollObserver: NSObjectProtocol?
- private var lastMeetingsRefreshAt = Date.distantPast
- private var lastScrollEdgeRefreshAt = Date.distantPast
- // Keep this conservative to avoid Zoom API rate limits.
- private let meetingsRefreshInterval: TimeInterval = 60
- private let scrollRefreshCooldown: TimeInterval = 3
- private var meetingsRateLimitedUntil: Date?
-
- private enum SidebarStyle {
- case login
- 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 }
- private var transactionUpdatesTask: Task<Void, Never>?
- var onEntitlementsChanged: ((Bool) -> Void)?
- func start() async {
- await refreshProducts()
- await refreshEntitlements()
- observeTransactionUpdatesIfNeeded()
- }
- func refreshProducts() async {
- do {
- let products = try await Product.products(for: PremiumPlan.allCases.map(\.rawValue))
- productsByID = Dictionary(uniqueKeysWithValues: products.map { ($0.id, $0) })
- } catch {
- productsByID = [:]
- }
- }
- 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 }
- if PremiumPlan.allCases.map(\.rawValue).contains(transaction.productID) {
- 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,
- Self.isTransactionActive(transaction) else { continue }
- active.insert(productID)
- }
- activeProductIDs = active
- let newHasPremiumAccess = hasPremiumAccess
- if newHasPremiumAccess != previousHasPremiumAccess {
- onEntitlementsChanged?(newHasPremiumAccess)
- }
- }
- private static func isTransactionActive(_ transaction: 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>?
- override func viewDidLoad() {
- super.viewDidLoad()
- palette = Palette(isDarkMode: darkModeEnabled)
- NSApp.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
- setupUI()
- startStoreKit()
- }
- override func viewDidAppear() {
- super.viewDidAppear()
- if let window = view.window {
- window.setContentSize(NSSize(width: 1020, height: 690))
- 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)
- }
- alignNativeTrafficLights()
- if isUserLoggedIn() {
- showHomeView(profile: nil)
- } else {
- showLoginView()
- }
- }
- override func viewDidLayout() {
- super.viewDidLayout()
- alignNativeTrafficLights()
- }
- 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 showLoginView() {
- clockTimer?.invalidate()
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- clearMeetingsScrollObserver()
- removeSearchFieldObserver()
- removeSearchShortcutMonitor()
- homeSearchField = nil
- homeSearchPill = nil
- allScheduledMeetings = []
- homeView?.removeFromSuperview()
- homeView = nil
- isSigningIn = false
- nextSignInButton?.title = "Next"
- nextSignInButton?.isEnabled = true
- zoomSocialButton?.isEnabled = true
- if loginView == nil {
- loginView = makeLoginView()
- }
- guard let loginView else { return }
- attachToRoot(loginView)
- }
- private func showHomeView(profile: GoogleUserProfile?) {
- loginView?.removeFromSuperview()
- clearMeetingsScrollObserver()
- removeSearchFieldObserver()
- removeSearchShortcutMonitor()
- homeSearchField = nil
- homeSearchPill = nil
- homeView?.removeFromSuperview()
- selectedMeetingsDayStart = Calendar.current.startOfDay(for: Date())
- homeView = makeHomeView(profile: profile)
- if let homeView { attachToRoot(homeView) }
- installSearchShortcutMonitor()
- persistLoggedInState(true)
- startClock()
- startMeetingsAutoRefresh()
- triggerMeetingsRefresh(force: 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.isSigningIn = false
- self.googleButton?.title = "G"
- self.googleButton?.isEnabled = true
- self.showHomeView(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.isSigningIn = false
- self.googleButton?.title = "G"
- self.googleButton?.isEnabled = true
- 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
- nextSignInButton?.title = "Signing in…"
- nextSignInButton?.isEnabled = false
- zoomSocialButton?.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) }
- await MainActor.run {
- self.resetLoginSigningInState()
- self.showHomeView(profile: profile)
- }
- } catch {
- await MainActor.run {
- self.resetLoginSigningInState()
- self.showSimpleError("Zoom sign-in failed", error: error)
- }
- }
- }
- }
- @objc private func scheduleMeetingWebTapped() {
- guard let url = URL(string: "https://zoom.us/meeting/schedule") else { return }
- let opened = NSWorkspace.shared.open(url)
- if opened == false {
- meetingsStatusLabel?.stringValue = "Unable to open Zoom schedule page."
- }
- }
- @objc private func logoutTapped() {
- meetingsRefreshTimer?.invalidate()
- meetingsRefreshTimer = nil
- clearMeetingsScrollObserver()
- googleOAuth.clearSavedTokens()
- zoomOAuth.clearSavedTokens()
- persistLoggedInState(false)
- showLoginView()
- }
-
- @objc private func topBarPlaceholderTapped() {
- // Reserved for future titlebar control actions.
- }
- @objc private func upgradeToProTapped() {
- if storeKitCoordinator.hasPremiumAccess {
- openManageSubscriptions()
- } else {
- showPaywall()
- }
- }
- private func openManageSubscriptions() {
- if let url = URL(string: "https://apps.apple.com/account/subscriptions") {
- NSWorkspace.shared.open(url)
- }
- }
- @objc private func refreshMeetingsTapped() {
- Task { @MainActor in
- self.animateMeetingsRefresh()
- self.setMeetingsLoadingUI(true)
- }
- triggerMeetingsRefresh(force: true)
- }
- @MainActor
- private func setMeetingsLoadingUI(_ loading: Bool) {
- refreshMeetingsButton?.isEnabled = loading == false
- }
- @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() {
- 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) {
- 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 removeSearchFieldObserver() {
- if let searchTextObserver {
- NotificationCenter.default.removeObserver(searchTextObserver)
- }
- searchTextObserver = nil
- }
- private func installSearchShortcutMonitor() {
- removeSearchShortcutMonitor()
- searchShortcutMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
- guard let self else { return event }
- guard event.modifierFlags.contains(.command),
- event.charactersIgnoringModifiers?.lowercased() == "e" else { return event }
- guard self.homeSearchField != nil else { return event }
- DispatchQueue.main.async {
- self.focusHomeSearchField()
- }
- 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 let field = self.homeSearchField else { return event }
- guard self.isSearchFieldActive(field, in: window) else { return event }
- let location = event.locationInWindow
- let pill = self.homeSearchPill ?? field
- let rectInWindow = pill.convert(pill.bounds, to: nil)
- if rectInWindow.contains(location) { return event }
- DispatchQueue.main.async {
- window.makeFirstResponder(nil)
- (field as? SearchPillTextField)?.forceClearFocusState()
- self.applySearchPillFocusBorder(focused: false)
- }
- 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)
- }
- 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)
- }
- @MainActor
- private func resetLoginSigningInState() {
- isSigningIn = false
- nextSignInButton?.title = "Next"
- nextSignInButton?.isEnabled = true
- zoomSocialButton?.isEnabled = true
- googleButton?.isEnabled = true
- }
- /// 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 host: String
- let source: String
- let webURL: URL?
- }
- @MainActor
- private func applyMeetings(_ meetings: [ScheduledMeeting]) {
- allScheduledMeetings = meetings
- applyFilteredMeetings()
- }
- @MainActor
- private func applyFilteredMeetings() {
- guard let stack = meetingsListStack else { return }
- stack.arrangedSubviews.forEach { view in
- stack.removeArrangedSubview(view)
- view.removeFromSuperview()
- }
- 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 = 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."
- }
- 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
- }
- }
- }
- @MainActor
- private func setSelectedMeetingsDayStart(_ newDayStart: Date) {
- selectedMeetingsDayStart = Calendar.current.startOfDay(for: newDayStart)
- 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
- }
- 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 loadScheduledMeetings() async {
- 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)
- let zoomMeetings = try await fetchZoomScheduledMeetings(accessToken: zoomToken)
- 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.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingClientSecret = error {
- self.meetingsStatusLabel?.stringValue = "Zoom OAuth app not configured."
- self.promptForZoomOAuthCredentialsIfNeeded()
- } else if case ZoomOAuthError.missingRequiredScope(let scopeMessage) = error {
- self.zoomOAuth.clearSavedTokens()
- self.meetingsStatusLabel?.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."
- } else {
- self.meetingsStatusLabel?.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."
- 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?
- 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 func fetchZoomScheduledMeetings(accessToken: String) async throws -> [ScheduledMeeting] {
- struct ZoomMeeting: Decodable {
- let id: Int?
- let topic: String?
- let start_time: String?
- let duration: Int?
- let host_id: 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 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 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,
- host: meeting.host_id ?? "Zoom Host",
- source: "Zoom",
- 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)
- }
- // MARK: - Login UI
- private func makeLoginView() -> NSView {
- let root = NSView()
- let sidebar = makeSidebar(items: ["Home", "Chat", "Phone", "Docs", "Whiteboards", "Clips", "More"], selected: "Home", style: .login)
- let content = NSView()
- root.addSubview(sidebar)
- root.addSubview(content)
- sidebar.translatesAutoresizingMaskIntoConstraints = false
- content.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- sidebar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
- sidebar.topAnchor.constraint(equalTo: root.topAnchor),
- sidebar.bottomAnchor.constraint(equalTo: root.bottomAnchor),
- sidebar.widthAnchor.constraint(equalToConstant: sidebarWidth),
- content.leadingAnchor.constraint(equalTo: sidebar.trailingAnchor),
- content.trailingAnchor.constraint(equalTo: root.trailingAnchor),
- content.topAnchor.constraint(equalTo: root.topAnchor),
- content.bottomAnchor.constraint(equalTo: root.bottomAnchor)
- ])
- let back = makeLabel("‹ Back", size: 32, color: accentBlue, weight: .regular, centered: false)
- let logo = makeLabel("zoom\nWorkplace", size: 24, color: primaryText, weight: .bold, centered: true)
- logo.maximumNumberOfLines = 2
- let domain = makeLabel("us05web.zoom.us", size: 16, color: primaryText, weight: .semibold, centered: true)
- let emailField = NSTextField()
- emailField.placeholderString = "Email or phone number"
- emailField.font = .systemFont(ofSize: 20, weight: .regular)
- emailField.textColor = .white
- emailField.wantsLayer = true
- emailField.layer?.cornerRadius = 10
- emailField.layer?.borderWidth = 1.5
- emailField.layer?.borderColor = accentBlue.cgColor
- emailField.layer?.backgroundColor = cardBackground.cgColor
- emailField.focusRingType = .none
- let nextButton = NSButton(title: "Next", target: self, action: #selector(zoomPrimarySignInTapped))
- nextButton.font = .systemFont(ofSize: 20, weight: .semibold)
- nextButton.isBordered = false
- nextButton.wantsLayer = true
- nextButton.layer?.cornerRadius = 10
- nextButton.layer?.backgroundColor = cardBackground.cgColor
- nextButton.contentTintColor = primaryText
- let divider = NSBox()
- divider.boxType = .separator
- let socialText = makeLabel("or sign in with", size: 14, color: secondaryText, weight: .regular, centered: true)
- let sso = makeSocialButton(icon: "🔑", text: "SSO")
- let google = makeSocialButton(icon: "G", text: "Google", action: #selector(googleLoginTapped))
- let apple = makeSocialButton(icon: "", text: "Apple")
- let facebook = makeSocialButton(icon: "f", text: "Facebook")
- let zoomSocial = makeSocialButton(icon: "Z", text: "Zoom", action: #selector(zoomPrimarySignInTapped))
- self.googleButton = google.button
- self.nextSignInButton = nextButton
- self.zoomSocialButton = zoomSocial.button
- let social = NSStackView(views: [sso.container, google.container, apple.container, facebook.container, zoomSocial.container])
- social.orientation = .horizontal
- social.spacing = 14
- social.distribution = .fillEqually
- let signup = makeLabel("Don't have an account? Sign up", size: 15, color: primaryText, weight: .regular, centered: true)
- let footer = makeLabel("Help Terms Privacy", size: 14, color: accentBlue, weight: .regular, centered: true)
- [back, logo, domain, emailField, nextButton, divider, socialText, social, signup, footer].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- content.addSubview($0)
- }
- NSLayoutConstraint.activate([
- back.topAnchor.constraint(equalTo: content.topAnchor, constant: 24),
- back.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 34),
- logo.topAnchor.constraint(equalTo: content.topAnchor, constant: 118),
- logo.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- domain.topAnchor.constraint(equalTo: logo.bottomAnchor, constant: 12),
- domain.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- emailField.topAnchor.constraint(equalTo: domain.bottomAnchor, constant: 30),
- emailField.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- emailField.widthAnchor.constraint(equalToConstant: 520),
- emailField.heightAnchor.constraint(equalToConstant: 52),
- nextButton.topAnchor.constraint(equalTo: emailField.bottomAnchor, constant: 20),
- nextButton.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- nextButton.widthAnchor.constraint(equalTo: emailField.widthAnchor),
- nextButton.heightAnchor.constraint(equalToConstant: 52),
- divider.topAnchor.constraint(equalTo: nextButton.bottomAnchor, constant: 28),
- divider.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- divider.widthAnchor.constraint(equalTo: emailField.widthAnchor),
- socialText.centerYAnchor.constraint(equalTo: divider.centerYAnchor),
- socialText.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- social.topAnchor.constraint(equalTo: divider.bottomAnchor, constant: 18),
- social.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- social.widthAnchor.constraint(equalTo: emailField.widthAnchor),
- signup.topAnchor.constraint(equalTo: social.bottomAnchor, constant: 14),
- signup.centerXAnchor.constraint(equalTo: content.centerXAnchor),
- footer.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -16),
- footer.centerXAnchor.constraint(equalTo: content.centerXAnchor)
- ])
- return root
- }
- 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
- let restoreButton = makeSettingsActionButton(icon: "↺", title: "Restore Purchases", action: .restorePurchases)
- stack.addArrangedSubview(restoreButton)
- restoreButton.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
- settingsRestoreButton = restoreButton
- stack.setCustomSpacing(24, after: restoreButton)
- 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: 36),
- 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 restorePurchases = 3
- 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 ? "Google account connected" : "Google 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)
- 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 .restorePurchases:
- settingsRestorePurchasesTapped()
- 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
- }
- showSimpleAlert(title: "Sign in", message: "Please sign in from the login screen to connect your account.")
- }
- 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 startStoreKit() {
- storeKitStartupTask?.cancel()
- storeKitCoordinator.onEntitlementsChanged = { [weak self] _ in
- DispatchQueue.main.async {
- self?.updatePremiumButtons()
- }
- }
- storeKitStartupTask = Task { [weak self] in
- await self?.storeKitCoordinator.start()
- await MainActor.run {
- self?.updatePremiumButtons()
- }
- }
- }
- @MainActor
- private func updatePremiumButtons() {
- let isPremium = storeKitCoordinator.hasPremiumAccess
- settingsUpgradeButton?.title = isPremium ? "Premium Active" : "Upgrade"
- settingsUpgradeButton?.isEnabled = isPremium == false
- settingsUpgradeButton?.alphaValue = isPremium ? 0.6 : 1.0
- settingsRestoreButton?.isEnabled = true
- 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)
- }
- if isUserLoggedIn() {
- let keepSelected = selectedHomeSidebarItem
- selectedHomeSidebarItem = keepSelected
- showHomeView(profile: nil)
- } else {
- showLoginView()
- }
- }
- 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.hasPremiumAccess == false else { return }
- settingsUpgradeButton?.isEnabled = false
- settingsUpgradeButton?.alphaValue = 0.6
- Task { [weak self] in
- guard let self else { return }
- let result = await self.storeKitCoordinator.purchase(plan: .lifetime)
- await MainActor.run {
- self.updatePremiumButtons()
- switch result {
- case .success:
- self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
- 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):
- self.showSimpleAlert(title: "Purchase Failed", message: message)
- }
- }
- }
- }
- @objc private func settingsRestorePurchasesTapped() {
- Task { [weak self] in
- guard let self else { return }
- let message = await self.storeKitCoordinator.restorePurchases()
- await MainActor.run {
- self.updatePremiumButtons()
- self.showSimpleAlert(title: "Restore Purchases", message: message)
- }
- }
- }
- // MARK: - Paywall (ported from meetings_app)
- private func showPaywall() {
- if let existing = paywallWindow {
- refreshPaywallStoreUI()
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let content = makePaywallContent()
- let controller = NSViewController()
- controller.view = content
- let panel = NSPanel(
- contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
- styleMask: [.titled, .closable, .fullSizeContentView],
- backing: .buffered,
- defer: false
- )
- panel.title = "Get Premium"
- 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.center()
- panel.contentViewController = controller
- panel.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- paywallWindow = panel
- Task { [weak self] in
- guard let self else { return }
- await self.storeKitCoordinator.refreshProducts()
- await MainActor.run {
- self.refreshPaywallStoreUI()
- }
- }
- }
- @objc private func closePaywallClicked(_ sender: Any?) {
- paywallWindow?.performClose(nil)
- paywallWindow = nil
- }
- private func makePaywallContent() -> NSView {
- paywallPlanViews.removeAll()
- premiumPlanByView.removeAll()
- paywallOfferLabel = nil
- paywallContinueLabel = nil
- paywallContinueButton = nil
- paywallContinueEnabled = true
- let panel = NSView()
- panel.translatesAutoresizingMaskIntoConstraints = false
- panel.wantsLayer = true
- panel.layer?.backgroundColor = appBackground.cgColor
- let contentStack = NSStackView()
- contentStack.translatesAutoresizingMaskIntoConstraints = false
- contentStack.orientation = .vertical
- contentStack.spacing = 12
- 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("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), 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.widthAnchor.constraint(equalToConstant: 28).isActive = true
- closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
- topRow.addArrangedSubview(closeButton)
- topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- contentStack.addArrangedSubview(topRow)
- contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: secondaryText))
- let benefits = paywallBenefitsSection()
- contentStack.addArrangedSubview(benefits)
- contentStack.setCustomSpacing(18, after: benefits)
- let weeklyCard = paywallPlanCard(
- title: "Weekly",
- price: "PKR 1,100.00",
- badge: "Basic Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: nil,
- plan: .weekly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(weeklyCard)
- let monthlyCard = paywallPlanCard(
- title: "Monthly",
- price: "PKR 2,500.00",
- badge: "Free Trial",
- badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
- subtitle: "625.00/week",
- plan: .monthly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(monthlyCard)
- let yearlyCard = paywallPlanCard(
- title: "Yearly",
- price: "PKR 9,900.00",
- badge: "Best Deal",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: "190.38/week",
- plan: .yearly,
- strikePrice: nil
- )
- contentStack.addArrangedSubview(yearlyCard)
- let lifetimeCard = paywallPlanCard(
- title: "Lifetime",
- price: "PKR 14,900.00",
- badge: "Save 50%",
- badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
- subtitle: nil,
- plan: .lifetime,
- strikePrice: "PKR 29,800.00"
- )
- contentStack.addArrangedSubview(lifetimeCard)
- updatePaywallPlanSelection()
- contentStack.setCustomSpacing(20, after: lifetimeCard)
- 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([
- offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
- offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
- offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
- offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
- ])
- contentStack.addArrangedSubview(offerWrap)
- contentStack.setCustomSpacing(18, 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 = accentBlue
- continueButton.hoverColor = accentBlue.withAlphaComponent(0.92)
- continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
- continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- styleSurface(continueButton, borderColor: accentBlue.withAlphaComponent(0.85), 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
- contentStack.addArrangedSubview(continueButton)
- contentStack.setCustomSpacing(16, after: continueButton)
- 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([
- secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
- secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
- secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
- secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
- ])
- contentStack.addArrangedSubview(secureWrap)
- NSLayoutConstraint.activate([
- contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
- contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
- contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
- contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
- ])
- refreshPaywallStoreUI()
- return panel
- }
- private func paywallBenefitsSection() -> NSView {
- let stack = NSStackView()
- stack.translatesAutoresizingMaskIntoConstraints = false
- stack.orientation = .vertical
- stack.spacing = 8
- stack.alignment = .leading
- stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).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 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: 12, 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: 11, 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.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
- wrapper.heightAnchor.constraint(equalToConstant: 94).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
- card.heightAnchor.constraint(equalToConstant: 82).isActive = true
- wrapper.addSubview(card)
- NSLayoutConstraint.activate([
- card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
- card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
- card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
- card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
- ])
- styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
- let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
- 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: 15, weight: .bold), color: accentBlue)
- card.addSubview(titleLabel)
- let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: primaryText)
- card.addSubview(priceLabel)
- NSLayoutConstraint.activate([
- badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
- badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
- titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
- titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
- priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
- priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
- ])
- if let subtitle {
- let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: secondaryText)
- card.addSubview(sub)
- NSLayoutConstraint.activate([
- sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
- ])
- }
- if let strikePrice {
- let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
- card.addSubview(strike)
- NSLayoutConstraint.activate([
- strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
- strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
- ])
- }
- paywallPlanViews[plan] = 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, view) in paywallPlanViews {
- applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
- }
- paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
- }
- private func paywallOfferText(for plan: PremiumPlan) -> String {
- if storeKitCoordinator.hasPremiumAccess {
- return "Premium is active on this Apple ID."
- }
- if let product = storeKitCoordinator.productsByID[plan.rawValue] {
- return "\(product.displayPrice) purchase"
- }
- switch plan {
- case .weekly: return "PKR 1,100.00/week"
- case .monthly: return "PKR 2,500.00/month"
- case .yearly: return "PKR 9,900.00/year"
- case .lifetime: return "PKR 14,900.00 one-time purchase"
- }
- }
- private func refreshPaywallStoreUI() {
- updatePaywallPlanSelection()
- updatePaywallContinueState(isLoading: false)
- }
- @objc private func paywallContinueClicked(_ sender: Any?) {
- startSelectedPlanPurchase()
- }
- private func startSelectedPlanPurchase() {
- guard paywallContinueEnabled else { return }
- paywallPurchaseTask?.cancel()
- updatePaywallContinueState(isLoading: true)
- let selectedPlan = selectedPremiumPlan
- paywallPurchaseTask = Task { [weak self] in
- guard let self else { return }
- let result = await self.storeKitCoordinator.purchase(plan: selectedPlan)
- await MainActor.run {
- self.updatePaywallContinueState(isLoading: false)
- self.refreshPaywallStoreUI()
- self.updatePremiumButtons()
- switch result {
- case .success:
- self.showSimpleAlert(title: "Purchase Complete", message: "Premium has been unlocked successfully.")
- self.paywallWindow?.performClose(nil)
- self.paywallWindow = nil
- 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):
- 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.hasPremiumAccess {
- paywallContinueEnabled = false
- paywallContinueLabel?.stringValue = "Premium Active"
- paywallContinueButton?.alphaValue = 0.75
- } else {
- paywallContinueEnabled = true
- 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"], 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.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 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 profileChip = NSButton(title: String((profile?.name ?? "H").prefix(1)).uppercased(), target: self, action: #selector(logoutTapped))
- profileChip.isBordered = false
- profileChip.wantsLayer = true
- profileChip.layer?.backgroundColor = accentBlue.withAlphaComponent(0.75).cgColor
- profileChip.layer?.cornerRadius = 10
- profileChip.contentTintColor = primaryText
- profileChip.font = .systemFont(ofSize: 14, weight: .bold)
- profileChip.toolTip = "Profile (click to logout)"
- [upgradeToProButton, profileChip].forEach { rightTopBarCluster.addArrangedSubview($0) }
- let welcome = makeLabel("Home", size: 15, color: secondaryText, weight: .medium, centered: false)
- 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),
- makeActionTile(title: "Join", symbol: "plus", color: accentBlue),
- makeActionTile(title: "Schedule", symbol: "calendar", color: accentBlue, action: #selector(scheduleMeetingWebTapped))
- ])
- 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 contentColumn = NSView()
- contentColumn.translatesAutoresizingMaskIntoConstraints = false
- content.addSubview(topBar)
- content.addSubview(topBarDivider)
- content.addSubview(contentColumn)
- [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)
- }
- [welcome, timeTitle, dateTitle, actions, panel, panelHeader, meetingsStatus, meetingsDayNav, noMeeting, meetingsScrollView, refreshMeetingsButton, placeholder, settingsView].forEach {
- $0.translatesAutoresizingMaskIntoConstraints = false
- contentColumn.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
- NSLayoutConstraint.activate([
- brandStack.leadingAnchor.constraint(equalTo: shell.leadingAnchor, constant: brandLeadingInset),
- 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),
- contentColumn.topAnchor.constraint(equalTo: topBarDivider.bottomAnchor, constant: 14),
- contentColumn.bottomAnchor.constraint(equalTo: content.bottomAnchor, constant: -10),
- contentColumn.leadingAnchor.constraint(equalTo: content.leadingAnchor, constant: 8),
- contentColumn.trailingAnchor.constraint(equalTo: content.trailingAnchor, constant: -8),
- 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),
- profileChip.widthAnchor.constraint(equalToConstant: 34),
- profileChip.heightAnchor.constraint(equalToConstant: 34),
- welcome.topAnchor.constraint(equalTo: contentColumn.topAnchor, constant: 18),
- welcome.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- timeTitle.topAnchor.constraint(equalTo: welcome.bottomAnchor, constant: 12),
- timeTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- dateTitle.topAnchor.constraint(equalTo: timeTitle.bottomAnchor, constant: 6),
- dateTitle.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- actions.topAnchor.constraint(equalTo: dateTitle.bottomAnchor, constant: 28),
- actions.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- actions.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 12),
- actions.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -12),
- actions.heightAnchor.constraint(equalToConstant: 100),
- panel.topAnchor.constraint(equalTo: actions.bottomAnchor, constant: 18),
- panel.centerXAnchor.constraint(equalTo: contentColumn.centerXAnchor),
- panel.widthAnchor.constraint(equalToConstant: 610),
- panel.leadingAnchor.constraint(greaterThanOrEqualTo: contentColumn.leadingAnchor, constant: 6),
- panel.trailingAnchor.constraint(lessThanOrEqualTo: contentColumn.trailingAnchor, constant: -6),
- panel.heightAnchor.constraint(greaterThanOrEqualToConstant: 280),
- panel.bottomAnchor.constraint(equalTo: contentColumn.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: contentColumn.centerXAnchor),
- placeholder.centerYAnchor.constraint(equalTo: contentColumn.centerYAnchor),
- settingsView.topAnchor.constraint(equalTo: contentColumn.topAnchor),
- settingsView.leadingAnchor.constraint(equalTo: contentColumn.leadingAnchor),
- settingsView.trailingAnchor.constraint(equalTo: contentColumn.trailingAnchor),
- settingsView.bottomAnchor.constraint(equalTo: contentColumn.bottomAnchor)
- ])
- timeLabel = timeTitle
- dateLabel = dateTitle
- homeWelcomeLabel = welcome
- homeTimeLabelView = timeTitle
- homeDateLabelView = dateTitle
- homeActionsRow = actions
- homeMeetingsPanel = panel
- homePlaceholderLabel = placeholder
- homeSettingsView = settingsView
- meetingsDayHeaderLabel = panelHeader
- meetingsListStack = meetingsStack
- meetingsStatusLabel = meetingsStatus
- emptyMeetingLabel = noMeeting
- meetingsPrevDayButton = prevDayButton
- meetingsTodayButton = todayButton
- meetingsNextDayButton = nextDayButton
- observeMeetingsScrollEdges(in: meetingsScrollView)
- updateClock()
- updateMeetingsDayUI()
- applyFilteredMeetings()
- homeSearchField = searchField
- homeSearchPill = searchPill
- searchTextObserver = NotificationCenter.default.addObserver(
- forName: NSControl.textDidChangeNotification,
- object: searchField,
- queue: .main
- ) { [weak self] _ in
- self?.applyFilteredMeetings()
- updateSearchHintVisibility()
- }
- return root
- }
- deinit {
- removeSearchFieldObserver()
- removeSearchShortcutMonitor()
- }
- 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 = .login) -> 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 : secondaryText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- iconView.image = NSImage(systemSymbolName: sidebarSymbolName(for: item, filled: selectedRow), accessibilityDescription: item)
- iconContainer.addSubview(iconView)
- let label = makeLabel(item, size: 10, color: selectedRow ? primaryText : secondaryText, 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 : secondaryText, 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)
- }
-
- if style == .home {
- let spacer = NSView()
- spacer.translatesAutoresizingMaskIntoConstraints = false
- // Keep Settings in a stable position (no vertical shifting on resize).
- spacer.heightAnchor.constraint(equalToConstant: 12).isActive = true
- spacer.setContentHuggingPriority(.required, for: .vertical)
- spacer.setContentCompressionResistancePriority(.required, for: .vertical)
- stack.addArrangedSubview(spacer)
-
- let settingsRow = NSView()
- settingsRow.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.wantsLayer = true
- let settingsSelected = selected == "Settings"
- settingsRow.layer?.backgroundColor = settingsSelected ? sidebarActiveBackground.withAlphaComponent(0.95).cgColor : NSColor.clear.cgColor
- settingsRow.layer?.cornerRadius = 12
- settingsRow.widthAnchor.constraint(equalToConstant: 68).isActive = true
- settingsRow.heightAnchor.constraint(equalToConstant: 66).isActive = true
- let iconContainer = NSView()
- iconContainer.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(iconContainer)
- let iconView = NSImageView()
- iconView.translatesAutoresizingMaskIntoConstraints = false
- iconView.contentTintColor = settingsSelected ? primaryText : secondaryText
- iconView.imageScaling = .scaleProportionallyUpOrDown
- iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 20, weight: .regular)
- let settingsSymbolPreferred = settingsSelected ? "gearshape.fill" : "gearshape"
- iconView.image = NSImage(systemSymbolName: settingsSymbolPreferred, accessibilityDescription: "Settings")
- ?? NSImage(systemSymbolName: "gearshape", accessibilityDescription: "Settings")
- iconContainer.addSubview(iconView)
- let label = makeLabel("Settings", size: 10, color: settingsSelected ? primaryText : secondaryText, weight: .regular, centered: true)
- label.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(label)
- let hit = NSButton(title: "", target: self, action: #selector(homeSidebarItemTapped(_:)))
- hit.identifier = NSUserInterfaceItemIdentifier("Settings")
- hit.isBordered = false
- hit.bezelStyle = .shadowlessSquare
- hit.focusRingType = .none
- hit.translatesAutoresizingMaskIntoConstraints = false
- settingsRow.addSubview(hit, positioned: .above, relativeTo: nil)
- NSLayoutConstraint.activate([
- iconContainer.topAnchor.constraint(equalTo: settingsRow.topAnchor, constant: 9),
- iconContainer.centerXAnchor.constraint(equalTo: settingsRow.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: settingsRow.centerXAnchor),
- label.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor, constant: -8),
- hit.leadingAnchor.constraint(equalTo: settingsRow.leadingAnchor),
- hit.trailingAnchor.constraint(equalTo: settingsRow.trailingAnchor),
- hit.topAnchor.constraint(equalTo: settingsRow.topAnchor),
- hit.bottomAnchor.constraint(equalTo: settingsRow.bottomAnchor)
- ])
- homeSidebarRowViews["Settings"] = settingsRow
- homeSidebarIconViews["Settings"] = iconView
- homeSidebarLabels["Settings"] = label
- stack.addArrangedSubview(settingsRow)
- }
- 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
- } else {
- stack.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 18).isActive = true
- 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 }
- selectedHomeSidebarItem = item
- updateHomeSidebarHighlight()
- updateSelectedHomeSectionUI()
- }
- @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 : secondaryText
- homeSidebarIconViews[item]?.contentTintColor = selected ? primaryText : secondaryText
- let symbolName = sidebarSymbolName(for: item, filled: selected)
- if let image = NSImage(systemSymbolName: symbolName, accessibilityDescription: item) {
- homeSidebarIconViews[item]?.image = image
- }
- }
- }
- @MainActor
- private func updateSelectedHomeSectionUI() {
- let isHome = selectedHomeSidebarItem == "Home"
- let isSettings = selectedHomeSidebarItem == "Settings"
- let title = selectedHomeSidebarItem
- homeWelcomeLabel?.stringValue = title
- homeWelcomeLabel?.isHidden = isSettings
- let dashboardViews: [NSView?] = [
- homeTimeLabelView,
- homeDateLabelView,
- homeActionsRow,
- homeMeetingsPanel,
- meetingsDayHeaderLabel,
- meetingsStatusLabel,
- meetingsPrevDayButton,
- meetingsTodayButton,
- meetingsNextDayButton,
- emptyMeetingLabel,
- meetingsScrollView,
- refreshMeetingsButton
- ]
- dashboardViews.forEach { $0?.isHidden = isHome == false || isSettings }
- homeSettingsView?.isHidden = isSettings == false
- if isHome {
- homePlaceholderLabel?.isHidden = true
- } else {
- // Keep non-Home pages empty for now.
- homePlaceholderLabel?.isHidden = true
- }
- }
- 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 "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: 124).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 = NSView()
- root.translatesAutoresizingMaskIntoConstraints = false
- root.widthAnchor.constraint(equalToConstant: 88).isActive = true
- root.heightAnchor.constraint(equalToConstant: 70).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)
- }
- NSLayoutConstraint.activate([
- iconButton.topAnchor.constraint(equalTo: root.topAnchor),
- 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)
- ])
- return root
- }
- private func makeMeetingRowCard(_ meeting: ScheduledMeeting) -> NSView {
- let card = MeetingCardView(url: meeting.webURL)
- card.wantsLayer = true
- card.layer?.backgroundColor = meetingCardBackground.cgColor
- card.layer?.cornerRadius = 13
- card.layer?.borderWidth = 1
- card.layer?.borderColor = NSColor.white.withAlphaComponent(0.06).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 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)
- }
- }
- private extension Array {
- subscript(safe index: Int) -> Element? {
- guard index >= 0, index < count else { return nil }
- return self[index]
- }
- }
- 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 var tracking: NSTrackingArea?
- private var isHovering = false
- private var normalBackgroundColor: CGColor?
- private var normalBorderColor: CGColor?
- private var normalBorderWidth: CGFloat?
- init(url: URL?) {
- self.url = url
- super.init(frame: .zero)
- }
- required init?(coder: NSCoder) {
- self.url = 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 }
- 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 = NSColor.white.withAlphaComponent(0.075).cgColor
- layer.borderColor = NSColor.white.withAlphaComponent(0.85).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
- }
- 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 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 {
- _ = presentingWindow
- guard let clientId = configuredClientId() else { throw ZoomOAuthError.missingClientId }
- guard let clientSecret = configuredClientSecret() else { throw ZoomOAuthError.missingClientSecret }
- let loopback = try await OAuthLoopbackServer.start()
- defer { loopback.stop() }
- let redirectURI = loopback.redirectURI
- 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 }
- let opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
- guard opened else { throw ZoomOAuthError.unableToOpenBrowser }
- let callbackURL = try await loopback.waitForCallback()
- 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)
- }
- 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.contains("meeting:read")
- || 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
- }
- 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 {
- _ = presentingWindow
- let codeVerifier = Self.randomURLSafeString(length: 64)
- let codeChallenge = Self.pkceChallenge(for: codeVerifier)
- let state = Self.randomURLSafeString(length: 32)
- let loopback = try await OAuthLoopbackServer.start()
- defer { loopback.stop() }
- let redirectURI = loopback.redirectURI
- 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 opened = await MainActor.run { NSWorkspace.shared.open(authURL) }
- guard opened else { throw GoogleOAuthError.unableToOpenBrowser }
- let callbackURL = try await loopback.waitForCallback()
- 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)
- }
- 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)
- }
- }
- private final class OAuthLoopbackServer {
- /// Fixed port so Zoom/Google OAuth redirect URLs can be registered exactly (Zoom allow list does not support wildcards for ports).
- private static let loopbackOAuthPort: UInt16 = 8742
- private let queue = DispatchQueue(label: "google.oauth.loopback.server")
- private let listener: NWListener
- private var readyContinuation: CheckedContinuation<Void, Error>?
- private var callbackContinuation: CheckedContinuation<URL, Error>?
- private var callbackURL: URL?
- private init(listener: NWListener) {
- self.listener = listener
- }
- static func start() async throws -> OAuthLoopbackServer {
- guard let port = NWEndpoint.Port(rawValue: loopbackOAuthPort) else {
- throw GoogleOAuthError.invalidCallbackURL
- }
- let listener = try NWListener(using: .tcp, on: port)
- let server = OAuthLoopbackServer(listener: listener)
- try await server.startListening()
- return server
- }
- var redirectURI: String {
- let port = listener.port?.rawValue ?? 0
- return "http://127.0.0.1:\(port)/oauth2redirect"
- }
- func waitForCallback(timeoutSeconds: Double = 120) async throws -> URL {
- 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()
- return url
- }
- }
- func stop() {
- listener.cancel()
- }
- private func startListening() async throws {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
- queue.async {
- self.readyContinuation = continuation
- self.listener.stateUpdateHandler = { [weak self] state in
- guard let self else { return }
- switch state {
- case .ready:
- if let readyContinuation = self.readyContinuation {
- self.readyContinuation = nil
- readyContinuation.resume()
- }
- case .failed(let error):
- if let readyContinuation = self.readyContinuation {
- self.readyContinuation = nil
- readyContinuation.resume(throwing: error)
- }
- case .cancelled:
- if let readyContinuation = self.readyContinuation {
- self.readyContinuation = nil
- readyContinuation.resume(throwing: GoogleOAuthError.invalidCallbackURL)
- }
- default:
- break
- }
- }
- self.listener.newConnectionHandler = { [weak self] connection in
- self?.handle(connection: connection)
- }
- self.listener.start(queue: self.queue)
- }
- }
- }
- private func awaitCallback() async throws -> URL {
- if let callbackURL { return callbackURL }
- return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<URL, Error>) in
- queue.async { self.callbackContinuation = continuation }
- }
- }
- private func handle(connection: NWConnection) {
- connection.start(queue: queue)
- connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
- guard let self else { return }
- let requestLine = data.flatMap { String(data: $0, encoding: .utf8) }?.split(separator: "\r\n").first.map(String.init)
- var parsedURL: URL?
- if let requestLine {
- let parts = requestLine.split(separator: " ")
- if parts.count >= 2 {
- parsedURL = URL(string: "http://127.0.0.1\(parts[1])")
- }
- }
- self.sendHTTPResponse(connection: connection, success: parsedURL != nil)
- if let parsedURL {
- self.callbackURL = parsedURL
- self.callbackContinuation?.resume(returning: parsedURL)
- self.callbackContinuation = nil
- self.listener.cancel()
- }
- connection.cancel()
- }
- }
- private func sendHTTPResponse(connection: NWConnection, success: Bool) {
- let body = success ? "<html><body><h3>Authentication complete</h3><p>You can return to the app.</p></body></html>" : "<html><body><h3>Authentication failed</h3></body></html>"
- let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: \(body.utf8.count)\r\nConnection: close\r\n\r\n\(body)"
- connection.send(content: Data(response.utf8), completion: .contentProcessed { _ in })
- }
- }
- @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
- }
- }
- @MainActor
- private final class InAppOAuthWindowController: NSWindowController {
- private let webView: WKWebView
- init() {
- self.webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
- let container = OAuthWebViewContainerView(webView: webView)
- let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 980, height: 760), styleMask: [.titled, .closable, .miniaturizable, .resizable], backing: .buffered, defer: false)
- window.title = "Google Sign-In"
- window.contentView = container
- super.init(window: window)
- }
- @available(*, unavailable) required init?(coder: NSCoder) { nil }
- func load(url: URL) { webView.load(URLRequest(url: url)) }
- }
- 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."
- }
- }
- }
- 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 (must match OAuthLoopbackServer.loopbackOAuthPort in this target)."
- 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."
- }
- }
- }
|