Без опису

ViewController.swift 179KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132
  1. //
  2. // ViewController.swift
  3. // meetings_app
  4. //
  5. // Created by Dev Mac 1 on 06/04/2026.
  6. //
  7. import Cocoa
  8. import QuartzCore
  9. import WebKit
  10. import AuthenticationServices
  11. private enum SidebarPage: Int {
  12. case joinMeetings = 0
  13. case photo = 1
  14. case video = 2
  15. case tutorials = 3
  16. case settings = 4
  17. }
  18. private enum ZoomJoinMode: Int {
  19. case id = 0
  20. case url = 1
  21. }
  22. private enum SettingsAction: Int {
  23. case restore = 0
  24. case rateUs = 1
  25. case support = 2
  26. case moreApps = 3
  27. case shareApp = 4
  28. }
  29. private enum PremiumPlan: Int {
  30. case weekly = 0
  31. case monthly = 1
  32. case yearly = 2
  33. case lifetime = 3
  34. }
  35. final class ViewController: NSViewController {
  36. private struct GoogleProfileDisplay {
  37. let name: String
  38. let email: String
  39. let pictureURL: URL?
  40. }
  41. private var palette = Palette(isDarkMode: true)
  42. private let typography = Typography()
  43. private let launchContentSize = NSSize(width: 920, height: 690)
  44. private let launchMinContentSize = NSSize(width: 760, height: 600)
  45. private var mainContentHost: NSView?
  46. private var sidebarRowViews: [SidebarPage: NSView] = [:]
  47. private var selectedSidebarPage: SidebarPage = .joinMeetings
  48. private var selectedZoomJoinMode: ZoomJoinMode = .id
  49. private var pageCache: [SidebarPage: NSView] = [:]
  50. private var sidebarPageByView = [ObjectIdentifier: SidebarPage]()
  51. private var zoomJoinModeByView = [ObjectIdentifier: ZoomJoinMode]()
  52. private var zoomJoinModeViews: [ZoomJoinMode: NSView] = [:]
  53. private var settingsActionByView = [ObjectIdentifier: SettingsAction]()
  54. private weak var centeredTitleLabel: NSTextField?
  55. private var paywallWindow: NSWindow?
  56. private let paywallContentWidth: CGFloat = 520
  57. private var selectedPremiumPlan: PremiumPlan = .monthly
  58. private var paywallPlanViews: [PremiumPlan: NSView] = [:]
  59. private var premiumPlanByView = [ObjectIdentifier: PremiumPlan]()
  60. private weak var paywallOfferLabel: NSTextField?
  61. private weak var meetLinkField: NSTextField?
  62. private weak var browseAddressField: NSTextField?
  63. private var inAppBrowserWindowController: InAppBrowserWindowController?
  64. private let googleOAuth = GoogleOAuthService.shared
  65. private let calendarClient = GoogleCalendarClient()
  66. private enum ScheduleFilter: Int {
  67. case all = 0
  68. case today = 1
  69. case week = 2
  70. }
  71. private var scheduleFilter: ScheduleFilter = .all
  72. private weak var scheduleDateHeadingLabel: NSTextField?
  73. private weak var scheduleCardsStack: NSStackView?
  74. private weak var scheduleCardsScrollView: NSScrollView?
  75. private weak var scheduleScrollLeftButton: NSView?
  76. private weak var scheduleScrollRightButton: NSView?
  77. private weak var scheduleFilterDropdown: NSPopUpButton?
  78. private weak var scheduleGoogleAuthButton: NSButton?
  79. private weak var scheduleGoogleAuthHostView: GoogleProfileAuthHostView?
  80. private var scheduleGoogleAuthHostPadWidthConstraint: NSLayoutConstraint?
  81. private var scheduleGoogleAuthHostPadHeightConstraint: NSLayoutConstraint?
  82. private var scheduleGoogleAuthButtonWidthConstraint: NSLayoutConstraint?
  83. private var scheduleGoogleAuthButtonHeightConstraint: NSLayoutConstraint?
  84. /// Circular avatar size when signed in (top-right, Google-style).
  85. private let scheduleGoogleSignedInAvatarSize: CGFloat = 36
  86. private var scheduleGoogleAuthHovering = false
  87. private var scheduleCurrentProfile: GoogleProfileDisplay?
  88. /// Larger copy of the header avatar for the account popover (optional).
  89. private var scheduleProfileMenuAvatar: NSImage?
  90. private var scheduleProfileImageTask: Task<Void, Never>?
  91. private var googleAccountPopover: NSPopover?
  92. /// In-app browser navigation: `.allowAll` or `.whitelist(hostSuffixes:)` (e.g. `["google.com"]` matches `meet.google.com`).
  93. private let inAppBrowserDefaultPolicy: InAppBrowserURLPolicy = .allowAll
  94. private let darkModeDefaultsKey = "settings.darkModeEnabled"
  95. private var darkModeEnabled: Bool {
  96. get {
  97. let hasValue = UserDefaults.standard.object(forKey: darkModeDefaultsKey) != nil
  98. return hasValue ? UserDefaults.standard.bool(forKey: darkModeDefaultsKey) : systemPrefersDarkMode()
  99. }
  100. set { UserDefaults.standard.set(newValue, forKey: darkModeDefaultsKey) }
  101. }
  102. private func makeSettingsPopover() -> NSPopover {
  103. let popover = NSPopover()
  104. popover.behavior = .transient
  105. popover.animates = true
  106. popover.contentViewController = SettingsMenuViewController(
  107. palette: palette,
  108. typography: typography,
  109. darkModeEnabled: darkModeEnabled,
  110. onToggleDarkMode: { [weak self] enabled in
  111. self?.setDarkMode(enabled)
  112. },
  113. onAction: { [weak self] action in
  114. self?.handleSettingsAction(action)
  115. }
  116. )
  117. return popover
  118. }
  119. private var settingsPopover: NSPopover?
  120. override func viewDidLoad() {
  121. super.viewDidLoad()
  122. // Sync toggle + palette with current macOS appearance on launch.
  123. darkModeEnabled = systemPrefersDarkMode()
  124. palette = Palette(isDarkMode: darkModeEnabled)
  125. setupRootView()
  126. buildMainLayout()
  127. }
  128. override func viewDidAppear() {
  129. super.viewDidAppear()
  130. applyWindowTitle(for: selectedSidebarPage)
  131. guard let window = view.window else { return }
  132. // Ensure launch size is applied even when macOS tries to restore prior window state.
  133. window.isRestorable = false
  134. window.setFrameAutosaveName("")
  135. DispatchQueue.main.async { [weak self, weak window] in
  136. guard let self, let window else { return }
  137. let frameSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchContentSize)).size
  138. var newFrame = window.frame
  139. newFrame.size = frameSize
  140. window.setFrame(newFrame, display: true)
  141. window.center()
  142. window.minSize = window.frameRect(forContentRect: NSRect(origin: .zero, size: self.launchMinContentSize)).size
  143. self.installCenteredTitleIfNeeded(on: window)
  144. }
  145. }
  146. override var representedObject: Any? {
  147. didSet {}
  148. }
  149. }
  150. private extension ViewController {
  151. func setupRootView() {
  152. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  153. view.wantsLayer = true
  154. view.layer?.backgroundColor = palette.pageBackground.cgColor
  155. }
  156. func systemPrefersDarkMode() -> Bool {
  157. // Use the system-wide appearance setting (not app/window effective appearance).
  158. // When the key is missing, macOS is in Light mode.
  159. let global = UserDefaults.standard.persistentDomain(forName: UserDefaults.globalDomain)
  160. let style = global?["AppleInterfaceStyle"] as? String
  161. return style?.lowercased() == "dark"
  162. }
  163. func buildMainLayout() {
  164. let splitContainer = NSStackView()
  165. splitContainer.translatesAutoresizingMaskIntoConstraints = false
  166. splitContainer.orientation = .horizontal
  167. splitContainer.spacing = 0
  168. splitContainer.alignment = .top
  169. view.addSubview(splitContainer)
  170. NSLayoutConstraint.activate([
  171. splitContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor),
  172. splitContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor),
  173. splitContainer.topAnchor.constraint(equalTo: view.topAnchor),
  174. splitContainer.bottomAnchor.constraint(equalTo: view.bottomAnchor)
  175. ])
  176. let sidebar = makeSidebar()
  177. let mainPanel = makeMainPanel()
  178. splitContainer.addArrangedSubview(sidebar)
  179. splitContainer.addArrangedSubview(mainPanel)
  180. }
  181. @objc private func sidebarItemClicked(_ sender: NSClickGestureRecognizer) {
  182. guard let view = sender.view else { return }
  183. activateSidebarItem(view)
  184. }
  185. private func activateSidebarItem(_ view: NSView) {
  186. guard let page = sidebarPageByView[ObjectIdentifier(view)],
  187. page != selectedSidebarPage || page == .settings else { return }
  188. if page == .settings {
  189. showSettingsPopover()
  190. return
  191. }
  192. showSidebarPage(page)
  193. }
  194. @objc private func zoomJoinModeClicked(_ sender: NSClickGestureRecognizer) {
  195. guard let view = sender.view,
  196. let mode = zoomJoinModeByView[ObjectIdentifier(view)],
  197. mode != selectedZoomJoinMode else { return }
  198. selectedZoomJoinMode = mode
  199. updateZoomJoinModeAppearance()
  200. if selectedSidebarPage == .joinMeetings {
  201. pageCache[.joinMeetings] = nil
  202. showSidebarPage(.joinMeetings)
  203. }
  204. }
  205. @objc private func premiumButtonClicked(_ sender: NSClickGestureRecognizer) {
  206. showPaywall()
  207. }
  208. @objc private func sidebarButtonClicked(_ sender: NSButton) {
  209. guard let page = SidebarPage(rawValue: sender.tag),
  210. page != selectedSidebarPage || page == .settings else { return }
  211. if page == .settings {
  212. showSettingsPopover()
  213. return
  214. }
  215. showSidebarPage(page)
  216. }
  217. @objc private func joinMeetClicked(_ sender: Any?) {
  218. let rawInput = meetLinkField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  219. guard let url = normalizedMeetJoinURL(from: rawInput) else {
  220. showSimpleAlert(
  221. title: "Invalid Meet link",
  222. message: "Enter a valid Google Meet link or meeting code (for example nkd-grps-duv, meet.google.com/nkd-grps-duv, or https://meet.google.com/nkd-grps-duv)."
  223. )
  224. return
  225. }
  226. openInDefaultBrowser(url: url)
  227. }
  228. @objc private func cancelMeetJoinClicked(_ sender: Any?) {
  229. meetLinkField?.stringValue = ""
  230. }
  231. @objc private func browseOpenAddressClicked(_ sender: Any?) {
  232. let raw = browseAddressField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  233. guard raw.isEmpty == false else {
  234. showSimpleAlert(title: "Browse", message: "Enter a web address (for example meet.google.com).")
  235. return
  236. }
  237. let normalized = normalizedURLString(from: raw)
  238. guard let url = URL(string: normalized), url.scheme == "http" || url.scheme == "https" else {
  239. showSimpleAlert(title: "Invalid address", message: "Enter a valid http or https URL.")
  240. return
  241. }
  242. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  243. }
  244. @objc private func browseQuickLinkMeetClicked(_ sender: Any?) {
  245. guard let url = URL(string: "https://meet.google.com/") else { return }
  246. openInDefaultBrowser(url: url)
  247. }
  248. @objc private func browseQuickLinkMeetHelpClicked(_ sender: Any?) {
  249. guard let url = URL(string: "https://support.google.com/meet") else { return }
  250. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  251. }
  252. @objc private func browseQuickLinkZoomHelpClicked(_ sender: Any?) {
  253. guard let url = URL(string: "https://support.zoom.us") else { return }
  254. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  255. }
  256. @objc private func instantMeetClicked(_ sender: NSClickGestureRecognizer) {
  257. guard let url = URL(string: "https://meet.google.com/new") else { return }
  258. openInDefaultBrowser(url: url)
  259. }
  260. private func normalizedURLString(from value: String) -> String {
  261. if value.lowercased().hasPrefix("http://") || value.lowercased().hasPrefix("https://") {
  262. return value
  263. }
  264. return "https://\(value)"
  265. }
  266. /// Typical Meet meeting code shape: three hyphen-separated groups (e.g. `nkd-grps-duv`).
  267. private func isValidMeetMeetingCode(_ code: String) -> Bool {
  268. let trimmed = code.trimmingCharacters(in: .whitespacesAndNewlines)
  269. guard trimmed.isEmpty == false else { return false }
  270. let pattern = "^[a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{3}$"
  271. return trimmed.range(of: pattern, options: [.regularExpression, .caseInsensitive]) != nil
  272. }
  273. /// Accepts `https://meet.google.com/...`, `meet.google.com/...`, or a bare code; returns canonical Meet URL or `nil`.
  274. private func normalizedMeetJoinURL(from rawInput: String) -> URL? {
  275. let trimmed = rawInput.trimmingCharacters(in: .whitespacesAndNewlines)
  276. guard trimmed.isEmpty == false else { return nil }
  277. let lower = trimmed.lowercased()
  278. if lower.hasPrefix("http://") || lower.hasPrefix("https://") {
  279. guard let url = URL(string: trimmed),
  280. let host = url.host?.lowercased(),
  281. host == "meet.google.com" || host.hasSuffix(".meet.google.com") else {
  282. return nil
  283. }
  284. let path = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
  285. guard path.isEmpty == false else { return nil }
  286. let firstSegment = path.split(separator: "/").first.map(String.init) ?? path
  287. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  288. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  289. }
  290. if lower.hasPrefix("meet.google.com/") {
  291. let afterHost = trimmed.dropFirst("meet.google.com/".count)
  292. let beforeQuery = String(afterHost).split(separator: "?").first.map(String.init) ?? String(afterHost)
  293. let firstSegment = beforeQuery.split(separator: "/").first.map(String.init) ?? beforeQuery
  294. guard isValidMeetMeetingCode(firstSegment) else { return nil }
  295. return URL(string: "https://meet.google.com/\(firstSegment.lowercased())")
  296. }
  297. if isValidMeetMeetingCode(trimmed) {
  298. return URL(string: "https://meet.google.com/\(trimmed.lowercased())")
  299. }
  300. return nil
  301. }
  302. private func openInAppBrowser(with url: URL, policy: InAppBrowserURLPolicy = .allowAll) {
  303. let browserController: InAppBrowserWindowController
  304. if let existing = inAppBrowserWindowController {
  305. browserController = existing
  306. } else {
  307. browserController = InAppBrowserWindowController()
  308. inAppBrowserWindowController = browserController
  309. }
  310. browserController.load(url: url, policy: policy)
  311. browserController.applyDefaultFrameCenteredOnVisibleScreen()
  312. browserController.showWindow(nil)
  313. browserController.window?.makeKeyAndOrderFront(nil)
  314. browserController.window?.orderFrontRegardless()
  315. NSApp.activate(ignoringOtherApps: true)
  316. }
  317. private func openInDefaultBrowser(url: URL) {
  318. NSWorkspace.shared.open(url, configuration: NSWorkspace.OpenConfiguration()) { [weak self] _, error in
  319. if let error {
  320. DispatchQueue.main.async {
  321. self?.showSimpleAlert(title: "Unable to open browser", message: error.localizedDescription)
  322. }
  323. }
  324. }
  325. }
  326. private func openInSafari(url: URL) {
  327. guard let safariAppURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: "com.apple.Safari") else {
  328. NSWorkspace.shared.open(url)
  329. return
  330. }
  331. let configuration = NSWorkspace.OpenConfiguration()
  332. NSWorkspace.shared.open([url], withApplicationAt: safariAppURL, configuration: configuration) { _, error in
  333. if let error {
  334. self.showSimpleAlert(title: "Unable to Open Safari", message: error.localizedDescription)
  335. }
  336. }
  337. }
  338. private func showSidebarPage(_ page: SidebarPage) {
  339. selectedSidebarPage = page
  340. updateSidebarAppearance()
  341. applyWindowTitle(for: page)
  342. guard let host = mainContentHost else { return }
  343. host.subviews.forEach { $0.removeFromSuperview() }
  344. let child = viewForPage(page)
  345. child.translatesAutoresizingMaskIntoConstraints = false
  346. host.addSubview(child)
  347. NSLayoutConstraint.activate([
  348. child.leadingAnchor.constraint(equalTo: host.leadingAnchor),
  349. child.trailingAnchor.constraint(equalTo: host.trailingAnchor),
  350. child.topAnchor.constraint(equalTo: host.topAnchor),
  351. child.bottomAnchor.constraint(equalTo: host.bottomAnchor)
  352. ])
  353. }
  354. private func showSettingsPopover() {
  355. guard let anchor = sidebarRowViews[.settings] else { return }
  356. if settingsPopover?.isShown == true {
  357. settingsPopover?.performClose(nil)
  358. return
  359. }
  360. settingsPopover = makeSettingsPopover()
  361. if let menu = settingsPopover?.contentViewController as? SettingsMenuViewController {
  362. menu.setDarkModeEnabled(darkModeEnabled)
  363. }
  364. settingsPopover?.show(relativeTo: anchor.bounds, of: anchor, preferredEdge: .maxX)
  365. }
  366. private func setDarkMode(_ enabled: Bool) {
  367. darkModeEnabled = enabled
  368. NSApp.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  369. view.appearance = NSAppearance(named: enabled ? .darkAqua : .aqua)
  370. palette = Palette(isDarkMode: enabled)
  371. settingsPopover?.performClose(nil)
  372. settingsPopover = nil
  373. reloadTheme()
  374. }
  375. private func reloadTheme() {
  376. pageCache.removeAll()
  377. sidebarRowViews.removeAll()
  378. sidebarPageByView.removeAll()
  379. zoomJoinModeByView.removeAll()
  380. zoomJoinModeViews.removeAll()
  381. settingsActionByView.removeAll()
  382. paywallPlanViews.removeAll()
  383. premiumPlanByView.removeAll()
  384. googleAccountPopover?.performClose(nil)
  385. googleAccountPopover = nil
  386. mainContentHost = nil
  387. view.subviews.forEach { $0.removeFromSuperview() }
  388. setupRootView()
  389. buildMainLayout()
  390. showSidebarPage(selectedSidebarPage)
  391. }
  392. private func handleSettingsAction(_ action: SettingsAction) {
  393. switch action {
  394. case .restore:
  395. showSimpleAlert(title: "Restore", message: "Restore action tapped.")
  396. case .rateUs:
  397. settingsPopover?.performClose(nil)
  398. settingsPopover = nil
  399. // Replace with your App Store product URL when the app is listed.
  400. if let url = URL(string: "https://apps.apple.com/app/id0000000000") {
  401. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  402. }
  403. case .support:
  404. settingsPopover?.performClose(nil)
  405. settingsPopover = nil
  406. if let url = URL(string: "https://support.google.com/meet") {
  407. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  408. }
  409. case .moreApps:
  410. settingsPopover?.performClose(nil)
  411. settingsPopover = nil
  412. // Replace with your App Store developer page URL.
  413. if let url = URL(string: "https://apps.apple.com/developer/id0000000000") {
  414. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  415. }
  416. case .shareApp:
  417. let urlString = "https://example.com"
  418. NSPasteboard.general.clearContents()
  419. NSPasteboard.general.setString(urlString, forType: .string)
  420. showSimpleAlert(title: "Share App", message: "Link copied to clipboard:\n\(urlString)")
  421. }
  422. }
  423. private func showSimpleAlert(title: String, message: String) {
  424. let alert = NSAlert()
  425. alert.messageText = title
  426. alert.informativeText = message
  427. alert.addButton(withTitle: "OK")
  428. alert.runModal()
  429. }
  430. private func showPaywall() {
  431. if let existing = paywallWindow {
  432. existing.makeKeyAndOrderFront(nil)
  433. NSApp.activate(ignoringOtherApps: true)
  434. return
  435. }
  436. let content = makePaywallContent()
  437. let controller = NSViewController()
  438. controller.view = content
  439. let panel = NSPanel(
  440. contentRect: NSRect(x: 0, y: 0, width: 640, height: 820),
  441. styleMask: [.titled, .closable, .fullSizeContentView],
  442. backing: .buffered,
  443. defer: false
  444. )
  445. panel.title = "Get Premium"
  446. panel.titleVisibility = .hidden
  447. panel.titlebarAppearsTransparent = true
  448. panel.isFloatingPanel = false
  449. panel.level = .normal
  450. panel.hidesOnDeactivate = true
  451. panel.isReleasedWhenClosed = false
  452. panel.delegate = self
  453. panel.standardWindowButton(.closeButton)?.isHidden = true
  454. panel.standardWindowButton(.miniaturizeButton)?.isHidden = true
  455. panel.standardWindowButton(.zoomButton)?.isHidden = true
  456. panel.center()
  457. panel.contentViewController = controller
  458. panel.makeKeyAndOrderFront(nil)
  459. NSApp.activate(ignoringOtherApps: true)
  460. paywallWindow = panel
  461. }
  462. @objc private func closePaywallClicked(_ sender: Any?) {
  463. if let win = paywallWindow {
  464. win.performClose(nil)
  465. return
  466. }
  467. if let gesture = sender as? NSGestureRecognizer, let win = gesture.view?.window {
  468. win.performClose(nil)
  469. return
  470. }
  471. if let view = sender as? NSView, let win = view.window {
  472. win.performClose(nil)
  473. return
  474. }
  475. }
  476. @objc private func paywallFooterLinkClicked(_ sender: NSClickGestureRecognizer) {
  477. guard let view = sender.view else { return }
  478. let text = (view.subviews.first { $0 is NSTextField } as? NSTextField)?.stringValue ?? "Link"
  479. let map: [String: String] = [
  480. "Privacy Policy": "https://policies.google.com/privacy",
  481. "Support": "https://support.google.com/meet",
  482. "Terms of Services": "https://policies.google.com/terms"
  483. ]
  484. if let urlString = map[text], let url = URL(string: urlString) {
  485. openInAppBrowser(with: url, policy: inAppBrowserDefaultPolicy)
  486. return
  487. }
  488. showSimpleAlert(title: text, message: "\(text) tapped.")
  489. }
  490. @objc private func paywallPlanClicked(_ sender: NSClickGestureRecognizer) {
  491. guard let view = sender.view,
  492. let plan = premiumPlanByView[ObjectIdentifier(view)] else { return }
  493. selectedPremiumPlan = plan
  494. updatePaywallPlanSelection()
  495. }
  496. @objc private func paywallPlanButtonClicked(_ sender: NSButton) {
  497. guard let plan = PremiumPlan(rawValue: sender.tag) else { return }
  498. selectedPremiumPlan = plan
  499. updatePaywallPlanSelection()
  500. }
  501. private func updatePaywallPlanSelection() {
  502. for (plan, view) in paywallPlanViews {
  503. applyPaywallPlanStyle(view, isSelected: plan == selectedPremiumPlan)
  504. }
  505. paywallOfferLabel?.stringValue = paywallOfferText(for: selectedPremiumPlan)
  506. }
  507. private func paywallOfferText(for plan: PremiumPlan) -> String {
  508. switch plan {
  509. case .weekly:
  510. return "Rs 1,100.00/week"
  511. case .monthly:
  512. return "Free for 3 Days then Rs 2,500.00/month"
  513. case .yearly:
  514. return "Rs 9,900.00/year (about 190.38/week)"
  515. case .lifetime:
  516. return "Rs 14,900.00 one-time purchase"
  517. }
  518. }
  519. private func applyPaywallPlanStyle(_ card: NSView, isSelected: Bool, hovering: Bool = false) {
  520. let selectedBorder = NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1)
  521. let idleBorder = palette.inputBorder
  522. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  523. let hoverIdleBackground =
  524. palette.sectionCard.blended(withFraction: 0.10, of: hoverBlend) ?? palette.sectionCard
  525. let selectedBackground = darkModeEnabled
  526. ? NSColor(calibratedRed: 30.0 / 255.0, green: 34.0 / 255.0, blue: 42.0 / 255.0, alpha: 1)
  527. : NSColor(calibratedRed: 255.0 / 255.0, green: 246.0 / 255.0, blue: 236.0 / 255.0, alpha: 1)
  528. card.layer?.backgroundColor = (isSelected ? selectedBackground : (hovering ? hoverIdleBackground : palette.sectionCard)).cgColor
  529. card.layer?.borderColor = (isSelected ? selectedBorder : (hovering ? selectedBorder.withAlphaComponent(0.55) : idleBorder)).cgColor
  530. card.layer?.borderWidth = isSelected ? 2 : 1
  531. card.layer?.shadowColor = NSColor.black.cgColor
  532. card.layer?.shadowOpacity = isSelected ? (darkModeEnabled ? 0.26 : 0.10) : (hovering ? 0.18 : 0.12)
  533. card.layer?.shadowOffset = CGSize(width: 0, height: -1)
  534. card.layer?.shadowRadius = isSelected ? (darkModeEnabled ? 10 : 6) : (hovering ? 7 : 5)
  535. }
  536. private func viewForPage(_ page: SidebarPage) -> NSView {
  537. if let cached = pageCache[page] { return cached }
  538. let built: NSView
  539. switch page {
  540. case .joinMeetings:
  541. built = makeJoinMeetingsContent()
  542. case .photo:
  543. built = makePlaceholderPage(title: "Photo", subtitle: "Backgrounds — choose a photo background for your meetings.")
  544. case .video:
  545. built = makePlaceholderPage(title: "Video", subtitle: "Backgrounds — video background options.")
  546. case .tutorials:
  547. built = makePlaceholderPage(title: "Tutorials", subtitle: "Learn how to use the app.")
  548. case .settings:
  549. built = makePlaceholderPage(title: "Settings", subtitle: "Preferences and account options.")
  550. }
  551. pageCache[page] = built
  552. return built
  553. }
  554. private func makePlaceholderPage(title: String, subtitle: String) -> NSView {
  555. let panel = NSView()
  556. panel.translatesAutoresizingMaskIntoConstraints = false
  557. let titleLabel = textLabel(title, font: typography.pageTitle, color: palette.textPrimary)
  558. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  559. let sub = textLabel(subtitle, font: typography.fieldLabel, color: palette.textSecondary)
  560. sub.translatesAutoresizingMaskIntoConstraints = false
  561. panel.addSubview(titleLabel)
  562. panel.addSubview(sub)
  563. NSLayoutConstraint.activate([
  564. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  565. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  566. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  567. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 8)
  568. ])
  569. return panel
  570. }
  571. func makeBrowseWebContent() -> NSView {
  572. let panel = NSView()
  573. panel.translatesAutoresizingMaskIntoConstraints = false
  574. let titleLabel = textLabel("Browse the web", font: typography.pageTitle, color: palette.textPrimary)
  575. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  576. let sub = textLabel(
  577. "Open sites in the in-app browser (back, forward, reload, address bar). OAuth and “Continue in browser” flows stay inside the app.",
  578. font: typography.fieldLabel,
  579. color: palette.textSecondary
  580. )
  581. sub.translatesAutoresizingMaskIntoConstraints = false
  582. sub.maximumNumberOfLines = 0
  583. sub.lineBreakMode = .byWordWrapping
  584. let fieldShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  585. fieldShell.translatesAutoresizingMaskIntoConstraints = false
  586. fieldShell.heightAnchor.constraint(equalToConstant: 44).isActive = true
  587. styleSurface(fieldShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  588. let field = NSTextField(string: "")
  589. field.translatesAutoresizingMaskIntoConstraints = false
  590. field.isEditable = true
  591. field.isBordered = false
  592. field.drawsBackground = false
  593. field.focusRingType = .none
  594. field.font = NSFont.systemFont(ofSize: 14, weight: .regular)
  595. field.textColor = palette.textPrimary
  596. field.placeholderString = "https://example.com or example.com"
  597. field.delegate = self
  598. browseAddressField = field
  599. fieldShell.addSubview(field)
  600. let openBtn = meetActionButton(
  601. title: "Open in app browser",
  602. color: palette.primaryBlue,
  603. textColor: .white,
  604. width: 220,
  605. action: #selector(browseOpenAddressClicked(_:))
  606. )
  607. let quickTitle = textLabel("Quick links", font: typography.joinWithURLTitle, color: palette.textPrimary)
  608. quickTitle.translatesAutoresizingMaskIntoConstraints = false
  609. let quickRow = NSStackView()
  610. quickRow.translatesAutoresizingMaskIntoConstraints = false
  611. quickRow.orientation = .horizontal
  612. quickRow.spacing = 10
  613. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Google Meet", action: #selector(browseQuickLinkMeetClicked(_:))))
  614. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Meet help", action: #selector(browseQuickLinkMeetHelpClicked(_:))))
  615. quickRow.addArrangedSubview(browseQuickLinkButton(title: "Zoom help", action: #selector(browseQuickLinkZoomHelpClicked(_:))))
  616. panel.addSubview(titleLabel)
  617. panel.addSubview(sub)
  618. panel.addSubview(fieldShell)
  619. panel.addSubview(openBtn)
  620. panel.addSubview(quickTitle)
  621. panel.addSubview(quickRow)
  622. NSLayoutConstraint.activate([
  623. titleLabel.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  624. titleLabel.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26),
  625. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  626. sub.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  627. sub.trailingAnchor.constraint(lessThanOrEqualTo: panel.trailingAnchor, constant: -28),
  628. sub.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 10),
  629. fieldShell.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  630. fieldShell.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  631. fieldShell.topAnchor.constraint(equalTo: sub.bottomAnchor, constant: 18),
  632. field.leadingAnchor.constraint(equalTo: fieldShell.leadingAnchor, constant: 12),
  633. field.trailingAnchor.constraint(equalTo: fieldShell.trailingAnchor, constant: -12),
  634. field.centerYAnchor.constraint(equalTo: fieldShell.centerYAnchor),
  635. openBtn.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  636. openBtn.topAnchor.constraint(equalTo: fieldShell.bottomAnchor, constant: 12),
  637. openBtn.heightAnchor.constraint(equalToConstant: 36),
  638. quickTitle.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  639. quickTitle.topAnchor.constraint(equalTo: openBtn.bottomAnchor, constant: 28),
  640. quickRow.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor),
  641. quickRow.topAnchor.constraint(equalTo: quickTitle.bottomAnchor, constant: 10)
  642. ])
  643. return panel
  644. }
  645. private func browseQuickLinkButton(title: String, action: Selector) -> NSButton {
  646. let b = NSButton(title: title, target: self, action: action)
  647. b.translatesAutoresizingMaskIntoConstraints = false
  648. b.bezelStyle = .rounded
  649. b.font = NSFont.systemFont(ofSize: 13, weight: .medium)
  650. return b
  651. }
  652. private func applyWindowTitle(for page: SidebarPage) {
  653. let title: String
  654. switch page {
  655. case .joinMeetings:
  656. title = "App for Google Meet"
  657. case .photo:
  658. title = "Backgrounds — Photo"
  659. case .video:
  660. title = "Backgrounds — Video"
  661. case .tutorials:
  662. title = "Tutorials"
  663. case .settings:
  664. title = "Settings"
  665. }
  666. view.window?.title = title
  667. centeredTitleLabel?.stringValue = title
  668. }
  669. private func installCenteredTitleIfNeeded(on window: NSWindow) {
  670. guard centeredTitleLabel == nil else { return }
  671. guard let titlebarView = window.standardWindowButton(.closeButton)?.superview else { return }
  672. let label = NSTextField(labelWithString: window.title)
  673. label.translatesAutoresizingMaskIntoConstraints = false
  674. label.alignment = .center
  675. label.font = NSFont.titleBarFont(ofSize: 0)
  676. label.textColor = .labelColor
  677. label.lineBreakMode = .byTruncatingTail
  678. label.maximumNumberOfLines = 1
  679. titlebarView.addSubview(label)
  680. NSLayoutConstraint.activate([
  681. label.centerXAnchor.constraint(equalTo: titlebarView.centerXAnchor),
  682. label.centerYAnchor.constraint(equalTo: titlebarView.centerYAnchor),
  683. label.leadingAnchor.constraint(greaterThanOrEqualTo: titlebarView.leadingAnchor, constant: 90),
  684. label.trailingAnchor.constraint(lessThanOrEqualTo: titlebarView.trailingAnchor, constant: -90)
  685. ])
  686. window.titleVisibility = .hidden
  687. centeredTitleLabel = label
  688. }
  689. private func updateSidebarAppearance() {
  690. for (page, row) in sidebarRowViews {
  691. applySidebarRowStyle(row, page: page, logoTemplate: logoTemplateForSidebarPage(page))
  692. }
  693. }
  694. private func logoTemplateForSidebarPage(_ page: SidebarPage) -> Bool {
  695. switch page {
  696. case .photo, .tutorials: return false
  697. case .joinMeetings, .video, .settings: return true
  698. }
  699. }
  700. func makeSidebar() -> NSView {
  701. let sidebar = NSView()
  702. sidebar.translatesAutoresizingMaskIntoConstraints = false
  703. sidebar.wantsLayer = true
  704. sidebar.layer?.backgroundColor = palette.sidebarBackground.cgColor
  705. sidebar.layer?.borderColor = palette.separator.cgColor
  706. sidebar.layer?.borderWidth = 1
  707. sidebar.layer?.shadowColor = NSColor.black.cgColor
  708. sidebar.layer?.shadowOpacity = 0.18
  709. sidebar.layer?.shadowOffset = CGSize(width: 2, height: 0)
  710. sidebar.layer?.shadowRadius = 10
  711. sidebar.widthAnchor.constraint(equalToConstant: 210).isActive = true
  712. let titleRow = NSStackView(views: [
  713. iconLabel("📅", size: 24),
  714. textLabel("Meetings", font: typography.sidebarBrand, color: palette.textPrimary)
  715. ])
  716. titleRow.translatesAutoresizingMaskIntoConstraints = false
  717. titleRow.orientation = .horizontal
  718. titleRow.alignment = .centerY
  719. titleRow.spacing = 8
  720. let menuStack = NSStackView()
  721. menuStack.translatesAutoresizingMaskIntoConstraints = false
  722. menuStack.orientation = .vertical
  723. menuStack.alignment = .leading
  724. menuStack.spacing = 10
  725. menuStack.addArrangedSubview(sidebarSectionTitle("Meetings"))
  726. let joinRow = sidebarItem("Join Meetings", icon: "􀉣", page: .joinMeetings, logoImageName: "JoinMeetingsLogo", logoIconWidth: 24, logoHeightMultiplier: 56.0 / 52.0)
  727. menuStack.addArrangedSubview(joinRow)
  728. sidebarRowViews[.joinMeetings] = joinRow
  729. menuStack.addArrangedSubview(sidebarSectionTitle("Backgrounds"))
  730. let photoRow = sidebarItem("Photo", icon: "􀏂", page: .photo, logoImageName: "SidebarPhotoLogo", logoIconWidth: 24, logoHeightMultiplier: 82.0 / 62.0)
  731. menuStack.addArrangedSubview(photoRow)
  732. sidebarRowViews[.photo] = photoRow
  733. let videoRow = sidebarItem("Video", icon: "􀎚", page: .video, logoImageName: "SidebarVideoLogo", logoIconWidth: 28, logoHeightMultiplier: 52.0 / 60.0)
  734. menuStack.addArrangedSubview(videoRow)
  735. sidebarRowViews[.video] = videoRow
  736. menuStack.addArrangedSubview(sidebarSectionTitle("Additional"))
  737. let tutorialsRow = sidebarItem("Tutorials", icon: "􀛩", page: .tutorials, logoImageName: "SidebarTutorialsLogo", logoIconWidth: 24, logoHeightMultiplier: 50.0 / 60.0)
  738. menuStack.addArrangedSubview(tutorialsRow)
  739. sidebarRowViews[.tutorials] = tutorialsRow
  740. let settingsRow = sidebarItem("Settings", icon: "􀍟", page: .settings, logoImageName: "SidebarSettingsLogo", logoIconWidth: 28, logoHeightMultiplier: 68.0 / 62.0, showsDisclosure: true)
  741. menuStack.addArrangedSubview(settingsRow)
  742. sidebarRowViews[.settings] = settingsRow
  743. let premiumButton = sidebarPremiumButton()
  744. sidebar.addSubview(titleRow)
  745. sidebar.addSubview(menuStack)
  746. sidebar.addSubview(premiumButton)
  747. NSLayoutConstraint.activate([
  748. titleRow.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 16),
  749. titleRow.topAnchor.constraint(equalTo: sidebar.topAnchor, constant: 24),
  750. titleRow.trailingAnchor.constraint(lessThanOrEqualTo: sidebar.trailingAnchor, constant: -16),
  751. menuStack.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  752. menuStack.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  753. menuStack.topAnchor.constraint(equalTo: titleRow.bottomAnchor, constant: 20),
  754. menuStack.bottomAnchor.constraint(lessThanOrEqualTo: premiumButton.topAnchor, constant: -16),
  755. premiumButton.leadingAnchor.constraint(equalTo: sidebar.leadingAnchor, constant: 12),
  756. premiumButton.trailingAnchor.constraint(equalTo: sidebar.trailingAnchor, constant: -12),
  757. premiumButton.bottomAnchor.constraint(equalTo: sidebar.bottomAnchor, constant: -14)
  758. ])
  759. for subview in menuStack.arrangedSubviews {
  760. subview.widthAnchor.constraint(equalTo: menuStack.widthAnchor).isActive = true
  761. }
  762. return sidebar
  763. }
  764. func sidebarPremiumButton() -> NSView {
  765. let button = HoverTrackingView()
  766. button.translatesAutoresizingMaskIntoConstraints = false
  767. button.wantsLayer = true
  768. button.layer?.cornerRadius = 17
  769. button.layer?.backgroundColor = palette.primaryBlue.cgColor
  770. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  771. styleSurface(button, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  772. let icon = textLabel("★", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: .white)
  773. let title = textLabel("Get Premium", font: NSFont.systemFont(ofSize: 14, weight: .semibold), color: .white)
  774. button.addSubview(icon)
  775. button.addSubview(title)
  776. NSLayoutConstraint.activate([
  777. icon.leadingAnchor.constraint(equalTo: button.leadingAnchor, constant: 12),
  778. icon.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  779. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 8),
  780. title.centerYAnchor.constraint(equalTo: button.centerYAnchor),
  781. title.trailingAnchor.constraint(lessThanOrEqualTo: button.trailingAnchor, constant: -12)
  782. ])
  783. let baseColor = palette.primaryBlue
  784. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  785. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  786. button.onHoverChanged = { hovering in
  787. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  788. }
  789. button.onHoverChanged?(false)
  790. let click = NSClickGestureRecognizer(target: self, action: #selector(premiumButtonClicked(_:)))
  791. button.addGestureRecognizer(click)
  792. return button
  793. }
  794. func makeMainPanel() -> NSView {
  795. let panel = NSView()
  796. panel.translatesAutoresizingMaskIntoConstraints = false
  797. panel.wantsLayer = true
  798. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  799. let host = NSView()
  800. host.translatesAutoresizingMaskIntoConstraints = false
  801. panel.addSubview(host)
  802. NSLayoutConstraint.activate([
  803. host.leadingAnchor.constraint(equalTo: panel.leadingAnchor),
  804. host.trailingAnchor.constraint(equalTo: panel.trailingAnchor),
  805. host.topAnchor.constraint(equalTo: panel.topAnchor),
  806. host.bottomAnchor.constraint(equalTo: panel.bottomAnchor)
  807. ])
  808. mainContentHost = host
  809. showSidebarPage(.joinMeetings)
  810. return panel
  811. }
  812. func makeJoinMeetingsContent() -> NSView {
  813. let panel = NSView()
  814. panel.translatesAutoresizingMaskIntoConstraints = false
  815. let contentStack = NSStackView()
  816. contentStack.translatesAutoresizingMaskIntoConstraints = false
  817. contentStack.orientation = .vertical
  818. contentStack.spacing = 14
  819. contentStack.alignment = .leading
  820. contentStack.addArrangedSubview(scheduleTopAuthRow())
  821. if let authRow = contentStack.arrangedSubviews.last {
  822. contentStack.setCustomSpacing(20, after: authRow)
  823. }
  824. let joinActions = meetJoinActionsRow()
  825. contentStack.addArrangedSubview(textLabel("Join Meetings", font: typography.pageTitle, color: palette.textPrimary))
  826. contentStack.addArrangedSubview(meetJoinSectionRow())
  827. contentStack.addArrangedSubview(joinActions)
  828. contentStack.setCustomSpacing(26, after: joinActions)
  829. contentStack.addArrangedSubview(scheduleHeader())
  830. let dateHeading = textLabel(scheduleInitialHeadingText(), font: typography.dateHeading, color: palette.textSecondary)
  831. scheduleDateHeadingLabel = dateHeading
  832. contentStack.addArrangedSubview(dateHeading)
  833. let cardsRow = scheduleCardsRow(meetings: [])
  834. contentStack.addArrangedSubview(cardsRow)
  835. panel.addSubview(contentStack)
  836. NSLayoutConstraint.activate([
  837. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 28),
  838. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -28),
  839. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 26)
  840. ])
  841. Task { [weak self] in
  842. await self?.loadSchedule()
  843. }
  844. return panel
  845. }
  846. func meetJoinSectionRow() -> NSView {
  847. let row = NSStackView()
  848. row.translatesAutoresizingMaskIntoConstraints = false
  849. row.orientation = .horizontal
  850. row.spacing = 12
  851. row.alignment = .top
  852. row.distribution = .fillEqually
  853. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  854. row.heightAnchor.constraint(equalToConstant: 140).isActive = true
  855. let instant = HoverSurfaceView()
  856. instant.translatesAutoresizingMaskIntoConstraints = false
  857. instant.wantsLayer = true
  858. instant.layer?.cornerRadius = 14
  859. instant.layer?.backgroundColor = palette.sectionCard.cgColor
  860. styleSurface(instant, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  861. let iconWrap = roundedContainer(cornerRadius: 12, color: NSColor.clear)
  862. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  863. iconWrap.widthAnchor.constraint(equalToConstant: 58).isActive = true
  864. iconWrap.heightAnchor.constraint(equalToConstant: 58).isActive = true
  865. iconWrap.layer?.borderWidth = 0
  866. let meetLogoImage = NSImage(named: "MeetLogo") ?? NSImage()
  867. meetLogoImage.isTemplate = false
  868. let meetLogo = NSImageView(image: meetLogoImage)
  869. meetLogo.translatesAutoresizingMaskIntoConstraints = false
  870. meetLogo.imageScaling = .scaleProportionallyDown
  871. meetLogo.contentTintColor = nil
  872. iconWrap.addSubview(meetLogo)
  873. let instantTitle = textLabel("New Instant Meet", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  874. let instantSub = textLabel("Start instant Meet in more section with\nGoogle Meet meet.", font: NSFont.systemFont(ofSize: 16 / 2, weight: .medium), color: palette.textSecondary)
  875. instantSub.maximumNumberOfLines = 2
  876. instant.addSubview(iconWrap)
  877. instant.addSubview(instantTitle)
  878. instant.addSubview(instantSub)
  879. let codeCard = HoverSurfaceView()
  880. codeCard.translatesAutoresizingMaskIntoConstraints = false
  881. codeCard.wantsLayer = true
  882. codeCard.layer?.cornerRadius = 14
  883. codeCard.layer?.backgroundColor = palette.sectionCard.cgColor
  884. styleSurface(codeCard, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  885. let codeTitle = textLabel("Join with Link", font: NSFont.systemFont(ofSize: 40 / 2, weight: .semibold), color: palette.textPrimary)
  886. let codeInputShell = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  887. codeInputShell.translatesAutoresizingMaskIntoConstraints = false
  888. codeInputShell.heightAnchor.constraint(equalToConstant: 52).isActive = true
  889. styleSurface(codeInputShell, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  890. let codeField = NSTextField(string: "")
  891. codeField.translatesAutoresizingMaskIntoConstraints = false
  892. codeField.isEditable = true
  893. codeField.isBordered = false
  894. codeField.drawsBackground = false
  895. codeField.focusRingType = .none
  896. codeField.font = NSFont.systemFont(ofSize: 36 / 2, weight: .regular)
  897. codeField.textColor = palette.textPrimary
  898. codeField.placeholderString = "Code or meet.google.com/…"
  899. codeInputShell.addSubview(codeField)
  900. meetLinkField = codeField
  901. codeCard.addSubview(codeTitle)
  902. codeCard.addSubview(codeInputShell)
  903. NSLayoutConstraint.activate([
  904. meetLogo.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  905. meetLogo.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor),
  906. meetLogo.widthAnchor.constraint(equalToConstant: 46),
  907. meetLogo.heightAnchor.constraint(equalToConstant: 46),
  908. iconWrap.leadingAnchor.constraint(equalTo: instant.leadingAnchor, constant: 18),
  909. iconWrap.topAnchor.constraint(equalTo: instant.topAnchor, constant: 22),
  910. instantTitle.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 14),
  911. instantTitle.topAnchor.constraint(equalTo: instant.topAnchor, constant: 24),
  912. instantSub.leadingAnchor.constraint(equalTo: instantTitle.leadingAnchor),
  913. instantSub.topAnchor.constraint(equalTo: instantTitle.bottomAnchor, constant: 6),
  914. instantSub.trailingAnchor.constraint(lessThanOrEqualTo: instant.trailingAnchor, constant: -16),
  915. codeTitle.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  916. codeTitle.topAnchor.constraint(equalTo: codeCard.topAnchor, constant: 22),
  917. codeInputShell.leadingAnchor.constraint(equalTo: codeCard.leadingAnchor, constant: 18),
  918. codeInputShell.trailingAnchor.constraint(equalTo: codeCard.trailingAnchor, constant: -18),
  919. codeInputShell.topAnchor.constraint(equalTo: codeTitle.bottomAnchor, constant: 12),
  920. codeField.leadingAnchor.constraint(equalTo: codeInputShell.leadingAnchor, constant: 14),
  921. codeField.trailingAnchor.constraint(equalTo: codeInputShell.trailingAnchor, constant: -14),
  922. codeField.centerYAnchor.constraint(equalTo: codeInputShell.centerYAnchor)
  923. ])
  924. let baseColor = palette.sectionCard
  925. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  926. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  927. instant.onHoverChanged = { hovering in
  928. instant.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  929. }
  930. codeCard.onHoverChanged = { hovering in
  931. codeCard.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  932. }
  933. instant.onHoverChanged?(false)
  934. codeCard.onHoverChanged?(false)
  935. let instantClick = NSClickGestureRecognizer(target: self, action: #selector(instantMeetClicked(_:)))
  936. instant.addGestureRecognizer(instantClick)
  937. row.addArrangedSubview(instant)
  938. row.addArrangedSubview(codeCard)
  939. return row
  940. }
  941. func meetJoinActionsRow() -> NSView {
  942. let row = NSStackView()
  943. row.translatesAutoresizingMaskIntoConstraints = false
  944. row.orientation = .horizontal
  945. row.spacing = 12
  946. row.alignment = .centerY
  947. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 880).isActive = true
  948. let spacer = NSView()
  949. spacer.translatesAutoresizingMaskIntoConstraints = false
  950. row.addArrangedSubview(spacer)
  951. row.addArrangedSubview(meetActionButton(
  952. title: "Cancel",
  953. color: palette.cancelButton,
  954. textColor: palette.textSecondary,
  955. width: 110,
  956. action: #selector(cancelMeetJoinClicked(_:))
  957. ))
  958. row.addArrangedSubview(meetActionButton(
  959. title: "Join",
  960. color: palette.primaryBlue,
  961. textColor: .white,
  962. width: 116,
  963. action: #selector(joinMeetClicked(_:))
  964. ))
  965. return row
  966. }
  967. func meetActionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat, action: Selector) -> NSButton {
  968. let button = HoverButton(title: title, target: self, action: action)
  969. button.translatesAutoresizingMaskIntoConstraints = false
  970. button.isBordered = false
  971. button.bezelStyle = .regularSquare
  972. button.wantsLayer = true
  973. button.layer?.cornerRadius = 9
  974. let baseBackground = color
  975. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  976. let hoverBackground = baseBackground.blended(withFraction: 0.10, of: hoverBlend) ?? baseBackground
  977. let baseBorder = (title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder)
  978. let hoverBorder = baseBorder.blended(withFraction: 0.18, of: hoverBlend) ?? baseBorder
  979. button.layer?.backgroundColor = baseBackground.cgColor
  980. button.layer?.borderColor = baseBorder.cgColor
  981. button.layer?.borderWidth = 1
  982. button.font = typography.buttonText
  983. button.contentTintColor = textColor
  984. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  985. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  986. button.onHoverChanged = { [weak self, weak button] hovering in
  987. guard let self, let button else { return }
  988. button.layer?.backgroundColor = (hovering ? hoverBackground : baseBackground).cgColor
  989. button.layer?.borderColor = (hovering ? hoverBorder : baseBorder).cgColor
  990. if title == "Cancel" {
  991. button.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : textColor
  992. }
  993. }
  994. button.onHoverChanged?(false)
  995. return button
  996. }
  997. func makePaywallContent() -> NSView {
  998. paywallPlanViews.removeAll()
  999. premiumPlanByView.removeAll()
  1000. let panel = NSView()
  1001. panel.translatesAutoresizingMaskIntoConstraints = false
  1002. panel.wantsLayer = true
  1003. panel.layer?.backgroundColor = palette.pageBackground.cgColor
  1004. let contentStack = NSStackView()
  1005. contentStack.translatesAutoresizingMaskIntoConstraints = false
  1006. contentStack.orientation = .vertical
  1007. contentStack.spacing = 12
  1008. contentStack.alignment = .leading
  1009. panel.addSubview(contentStack)
  1010. let topRow = NSStackView()
  1011. topRow.translatesAutoresizingMaskIntoConstraints = false
  1012. topRow.orientation = .horizontal
  1013. topRow.alignment = .centerY
  1014. topRow.distribution = .fill
  1015. topRow.spacing = 10
  1016. topRow.addArrangedSubview(textLabel("Get Premium", font: NSFont.systemFont(ofSize: 24, weight: .bold), color: palette.textPrimary))
  1017. let topSpacer = NSView()
  1018. topSpacer.translatesAutoresizingMaskIntoConstraints = false
  1019. topRow.addArrangedSubview(topSpacer)
  1020. let closeButton = HoverButton(title: "✕", target: self, action: #selector(closePaywallClicked(_:)))
  1021. closeButton.translatesAutoresizingMaskIntoConstraints = false
  1022. closeButton.isBordered = false
  1023. closeButton.bezelStyle = .regularSquare
  1024. closeButton.wantsLayer = true
  1025. closeButton.layer?.cornerRadius = 14
  1026. closeButton.layer?.backgroundColor = palette.inputBackground.cgColor
  1027. closeButton.layer?.borderColor = palette.inputBorder.cgColor
  1028. closeButton.layer?.borderWidth = 1
  1029. closeButton.font = typography.iconButton
  1030. closeButton.contentTintColor = palette.textSecondary
  1031. closeButton.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1032. closeButton.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1033. closeButton.onHoverChanged = { [weak closeButton, weak self] hovering in
  1034. guard let closeButton, let self else { return }
  1035. let base = self.palette.inputBackground
  1036. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  1037. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  1038. closeButton.layer?.backgroundColor = (hovering ? hover : base).cgColor
  1039. closeButton.contentTintColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  1040. }
  1041. topRow.addArrangedSubview(closeButton)
  1042. topRow.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1043. contentStack.addArrangedSubview(topRow)
  1044. contentStack.addArrangedSubview(textLabel("Upgrade to unlock premium features.", font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.textSecondary))
  1045. let benefits = paywallBenefitsSection()
  1046. contentStack.addArrangedSubview(benefits)
  1047. contentStack.setCustomSpacing(18, after: benefits)
  1048. let weeklyCard = paywallPlanCard(
  1049. title: "Weekly",
  1050. price: "Rs 1,100.00",
  1051. badge: "Basic Deal",
  1052. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1053. subtitle: nil,
  1054. plan: .weekly,
  1055. strikePrice: nil
  1056. )
  1057. contentStack.addArrangedSubview(weeklyCard)
  1058. let monthlyCard = paywallPlanCard(
  1059. title: "Monthly",
  1060. price: "Rs 2,500.00",
  1061. badge: "Free Trial",
  1062. badgeColor: NSColor(calibratedRed: 0.19, green: 0.82, blue: 0.39, alpha: 1),
  1063. subtitle: "625.00/week",
  1064. plan: .monthly,
  1065. strikePrice: nil
  1066. )
  1067. contentStack.addArrangedSubview(monthlyCard)
  1068. let yearlyCard = paywallPlanCard(
  1069. title: "Yearly",
  1070. price: "Rs 9,900.00",
  1071. badge: "Best Deal",
  1072. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1073. subtitle: "190.38/week",
  1074. plan: .yearly,
  1075. strikePrice: nil
  1076. )
  1077. contentStack.addArrangedSubview(yearlyCard)
  1078. let lifetimeCard = paywallPlanCard(
  1079. title: "Lifetime",
  1080. price: "Rs 14,900.00",
  1081. badge: "Save 50%",
  1082. badgeColor: NSColor(calibratedRed: 1.0, green: 0.60, blue: 0.20, alpha: 1),
  1083. subtitle: nil,
  1084. plan: .lifetime,
  1085. strikePrice: "Rs 29,800.00"
  1086. )
  1087. contentStack.addArrangedSubview(lifetimeCard)
  1088. updatePaywallPlanSelection()
  1089. contentStack.setCustomSpacing(20, after: lifetimeCard)
  1090. let offer = textLabel(paywallOfferText(for: selectedPremiumPlan), font: NSFont.systemFont(ofSize: 13, weight: .semibold), color: palette.textPrimary)
  1091. offer.alignment = .center
  1092. paywallOfferLabel = offer
  1093. let offerWrap = NSView()
  1094. offerWrap.translatesAutoresizingMaskIntoConstraints = false
  1095. offerWrap.addSubview(offer)
  1096. NSLayoutConstraint.activate([
  1097. offerWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1098. offer.centerXAnchor.constraint(equalTo: offerWrap.centerXAnchor),
  1099. offer.topAnchor.constraint(equalTo: offerWrap.topAnchor, constant: 6),
  1100. offer.bottomAnchor.constraint(equalTo: offerWrap.bottomAnchor, constant: -2)
  1101. ])
  1102. contentStack.addArrangedSubview(offerWrap)
  1103. contentStack.setCustomSpacing(18, after: offerWrap)
  1104. let continueButton = HoverTrackingView()
  1105. continueButton.translatesAutoresizingMaskIntoConstraints = false
  1106. continueButton.wantsLayer = true
  1107. continueButton.layer?.cornerRadius = 14
  1108. continueButton.layer?.backgroundColor = palette.primaryBlue.cgColor
  1109. continueButton.heightAnchor.constraint(equalToConstant: 44).isActive = true
  1110. continueButton.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1111. styleSurface(continueButton, borderColor: palette.primaryBlueBorder, borderWidth: 1, shadow: true)
  1112. let continueLabel = textLabel("Continue", font: NSFont.systemFont(ofSize: 16, weight: .bold), color: .white)
  1113. continueButton.addSubview(continueLabel)
  1114. NSLayoutConstraint.activate([
  1115. continueLabel.centerXAnchor.constraint(equalTo: continueButton.centerXAnchor),
  1116. continueLabel.centerYAnchor.constraint(equalTo: continueButton.centerYAnchor)
  1117. ])
  1118. let baseBlue = palette.primaryBlue
  1119. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1120. let hoverBlue = baseBlue.blended(withFraction: 0.10, of: hoverBlend) ?? baseBlue
  1121. continueButton.onHoverChanged = { hovering in
  1122. continueButton.layer?.backgroundColor = (hovering ? hoverBlue : baseBlue).cgColor
  1123. }
  1124. continueButton.onHoverChanged?(false)
  1125. contentStack.addArrangedSubview(continueButton)
  1126. contentStack.setCustomSpacing(16, after: continueButton)
  1127. let secure = textLabel("Secured by Apple. Cancel anytime.", font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  1128. secure.alignment = .center
  1129. let secureWrap = NSView()
  1130. secureWrap.translatesAutoresizingMaskIntoConstraints = false
  1131. secureWrap.addSubview(secure)
  1132. NSLayoutConstraint.activate([
  1133. secureWrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth),
  1134. secure.centerXAnchor.constraint(equalTo: secureWrap.centerXAnchor),
  1135. secure.topAnchor.constraint(equalTo: secureWrap.topAnchor, constant: 4),
  1136. secure.bottomAnchor.constraint(equalTo: secureWrap.bottomAnchor, constant: -8)
  1137. ])
  1138. contentStack.addArrangedSubview(secureWrap)
  1139. contentStack.setCustomSpacing(16, after: secureWrap)
  1140. let footer = paywallFooterLinks()
  1141. contentStack.addArrangedSubview(footer)
  1142. NSLayoutConstraint.activate([
  1143. contentStack.leadingAnchor.constraint(equalTo: panel.leadingAnchor, constant: 18),
  1144. contentStack.trailingAnchor.constraint(equalTo: panel.trailingAnchor, constant: -18),
  1145. contentStack.topAnchor.constraint(equalTo: panel.topAnchor, constant: 16),
  1146. contentStack.bottomAnchor.constraint(lessThanOrEqualTo: panel.bottomAnchor, constant: -12)
  1147. ])
  1148. return panel
  1149. }
  1150. func paywallPlanCard(
  1151. title: String,
  1152. price: String,
  1153. badge: String,
  1154. badgeColor: NSColor,
  1155. subtitle: String?,
  1156. plan: PremiumPlan,
  1157. strikePrice: String?
  1158. ) -> NSView {
  1159. let wrapper = HoverButton(title: "", target: self, action: #selector(paywallPlanButtonClicked(_:)))
  1160. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1161. wrapper.isBordered = false
  1162. wrapper.bezelStyle = .regularSquare
  1163. wrapper.wantsLayer = true
  1164. wrapper.layer?.backgroundColor = NSColor.clear.cgColor
  1165. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1166. wrapper.heightAnchor.constraint(equalToConstant: 94).isActive = true
  1167. wrapper.tag = plan.rawValue
  1168. let card = HoverTrackingView()
  1169. card.translatesAutoresizingMaskIntoConstraints = false
  1170. card.wantsLayer = true
  1171. card.layer?.cornerRadius = 16
  1172. card.layer?.backgroundColor = palette.sectionCard.cgColor
  1173. card.heightAnchor.constraint(equalToConstant: 82).isActive = true
  1174. wrapper.addSubview(card)
  1175. NSLayoutConstraint.activate([
  1176. card.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1177. card.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1178. card.topAnchor.constraint(equalTo: wrapper.topAnchor, constant: 12),
  1179. card.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1180. ])
  1181. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1182. let badgeLabel = textLabel(badge, font: NSFont.systemFont(ofSize: 10, weight: .bold), color: .white)
  1183. let badgeWrap = roundedContainer(cornerRadius: 10, color: badgeColor)
  1184. badgeWrap.translatesAutoresizingMaskIntoConstraints = false
  1185. badgeWrap.wantsLayer = true
  1186. badgeWrap.layer?.borderColor = NSColor(calibratedWhite: 1, alpha: 0.22).cgColor
  1187. badgeWrap.layer?.borderWidth = 1
  1188. badgeWrap.layer?.shadowColor = NSColor.black.cgColor
  1189. badgeWrap.layer?.shadowOpacity = 0.20
  1190. badgeWrap.layer?.shadowOffset = CGSize(width: 0, height: -1)
  1191. badgeWrap.layer?.shadowRadius = 3
  1192. badgeWrap.addSubview(badgeLabel)
  1193. NSLayoutConstraint.activate([
  1194. badgeLabel.leadingAnchor.constraint(equalTo: badgeWrap.leadingAnchor, constant: 8),
  1195. badgeLabel.trailingAnchor.constraint(equalTo: badgeWrap.trailingAnchor, constant: -8),
  1196. badgeLabel.topAnchor.constraint(equalTo: badgeWrap.topAnchor, constant: 2),
  1197. badgeLabel.bottomAnchor.constraint(equalTo: badgeWrap.bottomAnchor, constant: -2)
  1198. ])
  1199. wrapper.addSubview(badgeWrap)
  1200. let titleLabel = textLabel(title, font: NSFont.systemFont(ofSize: 15, weight: .bold), color: palette.primaryBlue)
  1201. card.addSubview(titleLabel)
  1202. let priceLabel = textLabel(price, font: NSFont.systemFont(ofSize: 12, weight: .bold), color: palette.textPrimary)
  1203. card.addSubview(priceLabel)
  1204. NSLayoutConstraint.activate([
  1205. badgeWrap.centerXAnchor.constraint(equalTo: card.centerXAnchor),
  1206. badgeWrap.centerYAnchor.constraint(equalTo: card.topAnchor),
  1207. titleLabel.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 16),
  1208. titleLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 34),
  1209. priceLabel.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -16),
  1210. priceLabel.topAnchor.constraint(equalTo: card.topAnchor, constant: 32)
  1211. ])
  1212. if let subtitle {
  1213. let sub = textLabel(subtitle, font: NSFont.systemFont(ofSize: 10, weight: .semibold), color: palette.textSecondary)
  1214. card.addSubview(sub)
  1215. NSLayoutConstraint.activate([
  1216. sub.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1217. sub.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 0)
  1218. ])
  1219. }
  1220. if let strikePrice {
  1221. let strike = textLabel(strikePrice, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: NSColor.systemRed)
  1222. card.addSubview(strike)
  1223. NSLayoutConstraint.activate([
  1224. strike.trailingAnchor.constraint(equalTo: priceLabel.trailingAnchor),
  1225. strike.topAnchor.constraint(equalTo: priceLabel.bottomAnchor, constant: 4)
  1226. ])
  1227. }
  1228. paywallPlanViews[plan] = card
  1229. wrapper.onHoverChanged = { [weak self, weak card] hovering in
  1230. guard let self, let card else { return }
  1231. self.applyPaywallPlanStyle(card, isSelected: plan == self.selectedPremiumPlan, hovering: hovering)
  1232. }
  1233. wrapper.onHoverChanged?(false)
  1234. return wrapper
  1235. }
  1236. func paywallFooterLinks() -> NSView {
  1237. let wrap = NSView()
  1238. wrap.translatesAutoresizingMaskIntoConstraints = false
  1239. wrap.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1240. wrap.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1241. let row = NSStackView()
  1242. row.translatesAutoresizingMaskIntoConstraints = false
  1243. row.orientation = .horizontal
  1244. row.distribution = .fillEqually
  1245. row.alignment = .centerY
  1246. row.spacing = 0
  1247. wrap.addSubview(row)
  1248. row.addArrangedSubview(footerLink("Privacy Policy"))
  1249. row.addArrangedSubview(footerLink("Support"))
  1250. row.addArrangedSubview(footerLink("Terms of Services"))
  1251. NSLayoutConstraint.activate([
  1252. row.leadingAnchor.constraint(equalTo: wrap.leadingAnchor),
  1253. row.trailingAnchor.constraint(equalTo: wrap.trailingAnchor),
  1254. row.topAnchor.constraint(equalTo: wrap.topAnchor),
  1255. row.bottomAnchor.constraint(equalTo: wrap.bottomAnchor)
  1256. ])
  1257. return wrap
  1258. }
  1259. func footerLink(_ title: String) -> NSView {
  1260. let container = HoverTrackingView()
  1261. container.translatesAutoresizingMaskIntoConstraints = false
  1262. let label = textLabel(title, font: NSFont.systemFont(ofSize: 12, weight: .semibold), color: palette.textSecondary)
  1263. label.alignment = .center
  1264. container.addSubview(label)
  1265. NSLayoutConstraint.activate([
  1266. label.centerXAnchor.constraint(equalTo: container.centerXAnchor),
  1267. label.centerYAnchor.constraint(equalTo: container.centerYAnchor)
  1268. ])
  1269. let click = NSClickGestureRecognizer(target: self, action: #selector(paywallFooterLinkClicked(_:)))
  1270. container.addGestureRecognizer(click)
  1271. container.onHoverChanged = { hovering in
  1272. label.textColor = hovering ? (self.darkModeEnabled ? .white : self.palette.textPrimary) : self.palette.textSecondary
  1273. }
  1274. container.onHoverChanged?(false)
  1275. return container
  1276. }
  1277. func paywallBenefitsSection() -> NSView {
  1278. let stack = NSStackView()
  1279. stack.translatesAutoresizingMaskIntoConstraints = false
  1280. stack.orientation = .vertical
  1281. stack.spacing = 8
  1282. stack.alignment = .leading
  1283. stack.widthAnchor.constraint(greaterThanOrEqualToConstant: paywallContentWidth).isActive = true
  1284. let rowOne = NSStackView()
  1285. rowOne.translatesAutoresizingMaskIntoConstraints = false
  1286. rowOne.orientation = .horizontal
  1287. rowOne.spacing = 10
  1288. rowOne.distribution = .fillEqually
  1289. rowOne.alignment = .centerY
  1290. rowOne.addArrangedSubview(paywallBenefitItem(icon: "📅", text: "Manage meetings"))
  1291. rowOne.addArrangedSubview(paywallBenefitItem(icon: "🖼️", text: "Virtual backgrounds"))
  1292. let rowTwo = NSStackView()
  1293. rowTwo.translatesAutoresizingMaskIntoConstraints = false
  1294. rowTwo.orientation = .horizontal
  1295. rowTwo.spacing = 10
  1296. rowTwo.distribution = .fillEqually
  1297. rowTwo.alignment = .centerY
  1298. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "⚡", text: "Tools for productivity"))
  1299. rowTwo.addArrangedSubview(paywallBenefitItem(icon: "🛟", text: "24/7 support"))
  1300. stack.addArrangedSubview(rowOne)
  1301. stack.addArrangedSubview(rowTwo)
  1302. return stack
  1303. }
  1304. func paywallBenefitItem(icon: String, text: String) -> NSView {
  1305. let card = HoverTrackingView()
  1306. card.translatesAutoresizingMaskIntoConstraints = false
  1307. card.wantsLayer = true
  1308. card.layer?.cornerRadius = 10
  1309. card.layer?.backgroundColor = palette.inputBackground.cgColor
  1310. card.heightAnchor.constraint(equalToConstant: 36).isActive = true
  1311. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1312. let iconWrap = roundedContainer(cornerRadius: 8, color: palette.inputBackground)
  1313. iconWrap.translatesAutoresizingMaskIntoConstraints = false
  1314. iconWrap.widthAnchor.constraint(equalToConstant: 24).isActive = true
  1315. iconWrap.heightAnchor.constraint(equalToConstant: 24).isActive = true
  1316. styleSurface(iconWrap, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1317. let iconLabel = textLabel(icon, font: NSFont.systemFont(ofSize: 12, weight: .medium), color: palette.primaryBlue)
  1318. iconWrap.addSubview(iconLabel)
  1319. NSLayoutConstraint.activate([
  1320. iconLabel.centerXAnchor.constraint(equalTo: iconWrap.centerXAnchor),
  1321. iconLabel.centerYAnchor.constraint(equalTo: iconWrap.centerYAnchor)
  1322. ])
  1323. let title = textLabel(text, font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textPrimary)
  1324. card.addSubview(iconWrap)
  1325. card.addSubview(title)
  1326. NSLayoutConstraint.activate([
  1327. iconWrap.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 8),
  1328. iconWrap.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1329. title.leadingAnchor.constraint(equalTo: iconWrap.trailingAnchor, constant: 10),
  1330. title.centerYAnchor.constraint(equalTo: card.centerYAnchor),
  1331. title.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -8)
  1332. ])
  1333. let base = palette.inputBackground
  1334. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  1335. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  1336. let hoverBorder = palette.primaryBlueBorder.withAlphaComponent(0.55)
  1337. card.onHoverChanged = { [weak card, weak iconWrap] hovering in
  1338. guard let card else { return }
  1339. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  1340. card.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  1341. iconWrap?.layer?.borderColor = (hovering ? hoverBorder : self.palette.inputBorder).cgColor
  1342. }
  1343. card.onHoverChanged?(false)
  1344. return card
  1345. }
  1346. func zoomJoinModeTabs() -> NSView {
  1347. let row = NSStackView()
  1348. row.translatesAutoresizingMaskIntoConstraints = false
  1349. row.orientation = .horizontal
  1350. row.alignment = .centerY
  1351. row.spacing = 28
  1352. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  1353. let idTab = joinModeTab("Join with ID", mode: .id)
  1354. let urlTab = joinModeTab("Join with URL", mode: .url)
  1355. row.addArrangedSubview(idTab)
  1356. row.addArrangedSubview(urlTab)
  1357. let spacer = NSView()
  1358. spacer.translatesAutoresizingMaskIntoConstraints = false
  1359. row.addArrangedSubview(spacer)
  1360. zoomJoinModeViews[.id] = idTab
  1361. zoomJoinModeViews[.url] = urlTab
  1362. updateZoomJoinModeAppearance()
  1363. return row
  1364. }
  1365. func joinModeTab(_ title: String, mode: ZoomJoinMode) -> NSView {
  1366. let tab = HoverTrackingView()
  1367. tab.translatesAutoresizingMaskIntoConstraints = false
  1368. tab.wantsLayer = true
  1369. tab.layer?.cornerRadius = 6
  1370. tab.layer?.backgroundColor = NSColor.clear.cgColor
  1371. tab.heightAnchor.constraint(equalToConstant: 30).isActive = true
  1372. zoomJoinModeByView[ObjectIdentifier(tab)] = mode
  1373. let label = textLabel(title, font: NSFont.systemFont(ofSize: 33 / 2, weight: .medium), color: palette.textPrimary)
  1374. tab.addSubview(label)
  1375. NSLayoutConstraint.activate([
  1376. label.leadingAnchor.constraint(equalTo: tab.leadingAnchor, constant: 4),
  1377. label.trailingAnchor.constraint(equalTo: tab.trailingAnchor, constant: -4),
  1378. label.topAnchor.constraint(equalTo: tab.topAnchor, constant: 4),
  1379. label.bottomAnchor.constraint(equalTo: tab.bottomAnchor, constant: -6)
  1380. ])
  1381. let click = NSClickGestureRecognizer(target: self, action: #selector(zoomJoinModeClicked(_:)))
  1382. tab.addGestureRecognizer(click)
  1383. return tab
  1384. }
  1385. func updateZoomJoinModeAppearance() {
  1386. for (mode, tab) in zoomJoinModeViews {
  1387. let selected = (mode == selectedZoomJoinMode)
  1388. let textColor = selected ? palette.textPrimary : palette.textSecondary
  1389. let label = tab.subviews.first { $0 is NSTextField } as? NSTextField
  1390. label?.textColor = textColor
  1391. // Keep the active tab visually underlined like the reference.
  1392. if selected {
  1393. if tab.subviews.contains(where: { $0.identifier?.rawValue == "modeUnderline" }) == false {
  1394. let underline = NSView()
  1395. underline.identifier = NSUserInterfaceItemIdentifier("modeUnderline")
  1396. underline.translatesAutoresizingMaskIntoConstraints = false
  1397. underline.wantsLayer = true
  1398. underline.layer?.backgroundColor = palette.primaryBlue.cgColor
  1399. tab.addSubview(underline)
  1400. NSLayoutConstraint.activate([
  1401. underline.leadingAnchor.constraint(equalTo: tab.leadingAnchor),
  1402. underline.trailingAnchor.constraint(equalTo: tab.trailingAnchor),
  1403. underline.bottomAnchor.constraint(equalTo: tab.bottomAnchor),
  1404. underline.heightAnchor.constraint(equalToConstant: 2)
  1405. ])
  1406. }
  1407. } else {
  1408. tab.subviews
  1409. .filter { $0.identifier?.rawValue == "modeUnderline" }
  1410. .forEach { $0.removeFromSuperview() }
  1411. }
  1412. }
  1413. }
  1414. func joinWithIDHeading() -> NSView {
  1415. let container = NSView()
  1416. container.translatesAutoresizingMaskIntoConstraints = false
  1417. let title = textLabel("Join with ID", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1418. title.alignment = .left
  1419. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  1420. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  1421. let bar = NSView()
  1422. bar.translatesAutoresizingMaskIntoConstraints = false
  1423. bar.wantsLayer = true
  1424. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  1425. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  1426. container.addSubview(title)
  1427. container.addSubview(bar)
  1428. NSLayoutConstraint.activate([
  1429. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1430. title.topAnchor.constraint(equalTo: container.topAnchor),
  1431. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1432. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  1433. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  1434. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1435. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  1436. ])
  1437. return container
  1438. }
  1439. func zoomMeetingIDSection() -> NSView {
  1440. let wrapper = NSView()
  1441. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1442. let fieldsRow = NSStackView()
  1443. fieldsRow.translatesAutoresizingMaskIntoConstraints = false
  1444. fieldsRow.orientation = .horizontal
  1445. fieldsRow.alignment = .top
  1446. fieldsRow.distribution = .fillEqually
  1447. fieldsRow.spacing = 12
  1448. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting ID", placeholder: "Enter meeting ID..."))
  1449. fieldsRow.addArrangedSubview(zoomInputField(title: "Meeting Passcode", placeholder: "Enter meeting passcode..."))
  1450. let actions = NSStackView()
  1451. actions.orientation = .horizontal
  1452. actions.spacing = 10
  1453. actions.translatesAutoresizingMaskIntoConstraints = false
  1454. actions.alignment = .centerY
  1455. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  1456. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  1457. wrapper.addSubview(fieldsRow)
  1458. wrapper.addSubview(actions)
  1459. NSLayoutConstraint.activate([
  1460. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  1461. fieldsRow.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1462. fieldsRow.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1463. fieldsRow.topAnchor.constraint(equalTo: wrapper.topAnchor),
  1464. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1465. actions.topAnchor.constraint(equalTo: fieldsRow.bottomAnchor, constant: 14),
  1466. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1467. ])
  1468. return wrapper
  1469. }
  1470. func zoomInputField(title: String, placeholder: String) -> NSView {
  1471. let wrapper = NSView()
  1472. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1473. let heading = textLabel(title, font: typography.fieldLabel, color: palette.textPrimary)
  1474. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1475. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  1476. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  1477. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1478. let field = NSTextField(string: "")
  1479. field.translatesAutoresizingMaskIntoConstraints = false
  1480. field.isEditable = true
  1481. field.isSelectable = true
  1482. field.isBordered = false
  1483. field.drawsBackground = false
  1484. field.placeholderString = placeholder
  1485. field.font = typography.inputPlaceholder
  1486. field.textColor = palette.textPrimary
  1487. field.focusRingType = .none
  1488. textFieldContainer.addSubview(field)
  1489. wrapper.addSubview(heading)
  1490. wrapper.addSubview(textFieldContainer)
  1491. NSLayoutConstraint.activate([
  1492. heading.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1493. heading.topAnchor.constraint(equalTo: wrapper.topAnchor),
  1494. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1495. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1496. textFieldContainer.topAnchor.constraint(equalTo: heading.bottomAnchor, constant: 10),
  1497. textFieldContainer.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor),
  1498. field.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  1499. field.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  1500. field.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor)
  1501. ])
  1502. return wrapper
  1503. }
  1504. func joinWithURLHeading() -> NSView {
  1505. let container = NSView()
  1506. container.translatesAutoresizingMaskIntoConstraints = false
  1507. let title = textLabel("Join with URL", font: typography.joinWithURLTitle, color: palette.textPrimary)
  1508. title.alignment = .left
  1509. title.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  1510. title.setContentCompressionResistancePriority(.required, for: .horizontal)
  1511. let bar = NSView()
  1512. bar.translatesAutoresizingMaskIntoConstraints = false
  1513. bar.wantsLayer = true
  1514. bar.layer?.backgroundColor = palette.primaryBlue.cgColor
  1515. bar.heightAnchor.constraint(equalToConstant: 3).isActive = true
  1516. container.addSubview(title)
  1517. container.addSubview(bar)
  1518. NSLayoutConstraint.activate([
  1519. title.leadingAnchor.constraint(equalTo: container.leadingAnchor),
  1520. title.topAnchor.constraint(equalTo: container.topAnchor),
  1521. bar.leadingAnchor.constraint(equalTo: title.leadingAnchor),
  1522. bar.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 6),
  1523. bar.widthAnchor.constraint(equalTo: title.widthAnchor),
  1524. bar.bottomAnchor.constraint(equalTo: container.bottomAnchor),
  1525. container.trailingAnchor.constraint(equalTo: title.trailingAnchor)
  1526. ])
  1527. return container
  1528. }
  1529. func meetingUrlSection() -> NSView {
  1530. let wrapper = NSView()
  1531. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1532. let title = textLabel("Meeting URL", font: typography.fieldLabel, color: palette.textSecondary)
  1533. let textFieldContainer = roundedContainer(cornerRadius: 10, color: palette.inputBackground)
  1534. textFieldContainer.translatesAutoresizingMaskIntoConstraints = false
  1535. textFieldContainer.heightAnchor.constraint(equalToConstant: 40).isActive = true
  1536. styleSurface(textFieldContainer, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  1537. let urlField = NSTextField(string: "")
  1538. urlField.translatesAutoresizingMaskIntoConstraints = false
  1539. urlField.isEditable = true
  1540. urlField.isSelectable = true
  1541. urlField.isBordered = false
  1542. urlField.drawsBackground = false
  1543. urlField.placeholderString = "Enter meeting URL..."
  1544. urlField.font = typography.inputPlaceholder
  1545. urlField.textColor = palette.textPrimary
  1546. urlField.focusRingType = .none
  1547. textFieldContainer.addSubview(urlField)
  1548. let actions = NSStackView()
  1549. actions.orientation = .horizontal
  1550. actions.spacing = 10
  1551. actions.translatesAutoresizingMaskIntoConstraints = false
  1552. actions.alignment = .centerY
  1553. actions.addArrangedSubview(actionButton(title: "Cancel", color: palette.cancelButton, textColor: palette.textSecondary, width: 110))
  1554. actions.addArrangedSubview(actionButton(title: "Join", color: palette.primaryBlue, textColor: .white, width: 116))
  1555. wrapper.addSubview(title)
  1556. wrapper.addSubview(textFieldContainer)
  1557. wrapper.addSubview(actions)
  1558. NSLayoutConstraint.activate([
  1559. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780),
  1560. title.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1561. title.topAnchor.constraint(equalTo: wrapper.topAnchor),
  1562. textFieldContainer.leadingAnchor.constraint(equalTo: wrapper.leadingAnchor),
  1563. textFieldContainer.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1564. textFieldContainer.topAnchor.constraint(equalTo: title.bottomAnchor, constant: 10),
  1565. urlField.leadingAnchor.constraint(equalTo: textFieldContainer.leadingAnchor, constant: 12),
  1566. urlField.trailingAnchor.constraint(equalTo: textFieldContainer.trailingAnchor, constant: -12),
  1567. urlField.centerYAnchor.constraint(equalTo: textFieldContainer.centerYAnchor),
  1568. actions.trailingAnchor.constraint(equalTo: wrapper.trailingAnchor),
  1569. actions.topAnchor.constraint(equalTo: textFieldContainer.bottomAnchor, constant: 14),
  1570. actions.bottomAnchor.constraint(equalTo: wrapper.bottomAnchor)
  1571. ])
  1572. return wrapper
  1573. }
  1574. func scheduleHeader() -> NSView {
  1575. let row = NSStackView()
  1576. row.translatesAutoresizingMaskIntoConstraints = false
  1577. row.orientation = .horizontal
  1578. row.alignment = .centerY
  1579. row.distribution = .fill
  1580. row.spacing = 12
  1581. row.addArrangedSubview(textLabel("Schedule", font: typography.sectionTitleBold, color: palette.textPrimary))
  1582. let spacer = NSView()
  1583. spacer.translatesAutoresizingMaskIntoConstraints = false
  1584. row.addArrangedSubview(spacer)
  1585. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1586. row.addArrangedSubview(makeScheduleRefreshButton())
  1587. row.addArrangedSubview(makeScheduleFilterDropdown())
  1588. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  1589. return row
  1590. }
  1591. private func scheduleTopAuthRow() -> NSView {
  1592. let row = NSStackView()
  1593. row.translatesAutoresizingMaskIntoConstraints = false
  1594. row.orientation = .horizontal
  1595. row.alignment = .centerY
  1596. row.spacing = 10
  1597. let spacer = NSView()
  1598. spacer.translatesAutoresizingMaskIntoConstraints = false
  1599. row.addArrangedSubview(spacer)
  1600. spacer.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1601. let host = GoogleProfileAuthHostView()
  1602. host.translatesAutoresizingMaskIntoConstraints = false
  1603. let authButton = makeGoogleAuthButton()
  1604. host.authButton = authButton
  1605. scheduleGoogleAuthHostView = host
  1606. scheduleGoogleAuthButton = authButton
  1607. host.addSubview(authButton)
  1608. NSLayoutConstraint.activate([
  1609. authButton.centerXAnchor.constraint(equalTo: host.centerXAnchor),
  1610. authButton.centerYAnchor.constraint(equalTo: host.centerYAnchor)
  1611. ])
  1612. let hostPadW = host.widthAnchor.constraint(equalTo: authButton.widthAnchor, constant: 0)
  1613. let hostPadH = host.heightAnchor.constraint(equalTo: authButton.heightAnchor, constant: 0)
  1614. hostPadW.isActive = true
  1615. hostPadH.isActive = true
  1616. scheduleGoogleAuthHostPadWidthConstraint = hostPadW
  1617. scheduleGoogleAuthHostPadHeightConstraint = hostPadH
  1618. updateGoogleAuthButtonTitle()
  1619. row.addArrangedSubview(host)
  1620. row.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  1621. return row
  1622. }
  1623. private func makeScheduleFilterDropdown() -> NSPopUpButton {
  1624. let button = NSPopUpButton(frame: .zero, pullsDown: false)
  1625. button.translatesAutoresizingMaskIntoConstraints = false
  1626. button.autoenablesItems = false
  1627. button.wantsLayer = true
  1628. button.layer?.cornerRadius = 8
  1629. button.layer?.backgroundColor = palette.inputBackground.cgColor
  1630. button.layer?.borderColor = palette.inputBorder.cgColor
  1631. button.layer?.borderWidth = 1
  1632. button.font = typography.filterText
  1633. button.contentTintColor = palette.textSecondary
  1634. button.target = self
  1635. button.action = #selector(scheduleFilterDropdownChanged(_:))
  1636. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1637. button.widthAnchor.constraint(equalToConstant: 156).isActive = true
  1638. button.removeAllItems()
  1639. button.addItems(withTitles: ["All", "Today", "This week"])
  1640. button.selectItem(at: scheduleFilter.rawValue)
  1641. if let menu = button.menu {
  1642. for (index, item) in menu.items.enumerated() {
  1643. item.tag = index
  1644. }
  1645. }
  1646. scheduleFilterDropdown = button
  1647. return button
  1648. }
  1649. private func makeSchedulePillButton(title: String) -> NSButton {
  1650. let button = NSButton(title: title, target: nil, action: nil)
  1651. button.translatesAutoresizingMaskIntoConstraints = false
  1652. button.isBordered = false
  1653. button.bezelStyle = .regularSquare
  1654. button.wantsLayer = true
  1655. button.layer?.cornerRadius = 8
  1656. button.layer?.backgroundColor = palette.inputBackground.cgColor
  1657. button.layer?.borderColor = palette.inputBorder.cgColor
  1658. button.layer?.borderWidth = 1
  1659. button.font = typography.filterText
  1660. button.contentTintColor = palette.textSecondary
  1661. button.setButtonType(.momentaryChange)
  1662. button.heightAnchor.constraint(equalToConstant: 34).isActive = true
  1663. button.widthAnchor.constraint(greaterThanOrEqualToConstant: 132).isActive = true
  1664. return button
  1665. }
  1666. private func makeGoogleAuthButton() -> NSButton {
  1667. let button = HoverButton(title: "", target: self, action: #selector(scheduleConnectButtonPressed(_:)))
  1668. button.translatesAutoresizingMaskIntoConstraints = false
  1669. button.isBordered = false
  1670. button.bezelStyle = .regularSquare
  1671. button.wantsLayer = true
  1672. button.layer?.cornerRadius = 21
  1673. button.layer?.borderWidth = 1
  1674. button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
  1675. button.imagePosition = .imageLeading
  1676. button.alignment = .center
  1677. button.imageHugsTitle = true
  1678. button.lineBreakMode = .byTruncatingTail
  1679. button.contentTintColor = palette.textPrimary
  1680. button.imageScaling = .scaleNone
  1681. button.layer?.masksToBounds = true
  1682. let heightConstraint = button.heightAnchor.constraint(equalToConstant: 42)
  1683. heightConstraint.isActive = true
  1684. scheduleGoogleAuthButtonHeightConstraint = heightConstraint
  1685. let widthConstraint = button.widthAnchor.constraint(equalToConstant: 248)
  1686. widthConstraint.isActive = true
  1687. scheduleGoogleAuthButtonWidthConstraint = widthConstraint
  1688. button.onHoverChanged = { [weak self] hovering in
  1689. self?.scheduleGoogleAuthHovering = hovering
  1690. self?.scheduleGoogleAuthHostView?.setProfileHoverActive(hovering)
  1691. self?.applyGoogleAuthButtonSurface()
  1692. }
  1693. button.onHoverChanged?(false)
  1694. return button
  1695. }
  1696. private func makeScheduleRefreshButton() -> NSButton {
  1697. let button = NSButton(title: "", target: self, action: #selector(scheduleReloadButtonPressed(_:)))
  1698. button.translatesAutoresizingMaskIntoConstraints = false
  1699. button.isBordered = false
  1700. button.bezelStyle = .regularSquare
  1701. button.wantsLayer = true
  1702. button.layer?.cornerRadius = 21
  1703. button.layer?.backgroundColor = palette.inputBackground.cgColor
  1704. button.layer?.borderColor = palette.inputBorder.cgColor
  1705. button.layer?.borderWidth = 1
  1706. button.setButtonType(.momentaryChange)
  1707. button.contentTintColor = palette.textSecondary
  1708. button.image = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Refresh meetings")
  1709. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  1710. button.imagePosition = .imageOnly
  1711. button.imageScaling = .scaleProportionallyDown
  1712. button.focusRingType = .none
  1713. button.heightAnchor.constraint(equalToConstant: 42).isActive = true
  1714. button.widthAnchor.constraint(equalToConstant: 42).isActive = true
  1715. return button
  1716. }
  1717. func scheduleCardsRow(meetings: [ScheduledMeeting]) -> NSView {
  1718. let cardWidth: CGFloat = 240
  1719. let cardsPerViewport: CGFloat = 3
  1720. let viewportWidth = (cardWidth * cardsPerViewport) + (12 * (cardsPerViewport - 1))
  1721. let wrapper = NSStackView()
  1722. wrapper.translatesAutoresizingMaskIntoConstraints = false
  1723. wrapper.orientation = .horizontal
  1724. wrapper.alignment = .centerY
  1725. wrapper.spacing = 10
  1726. let leftButton = makeScheduleScrollButton(systemSymbol: "chevron.left", action: #selector(scheduleScrollLeftPressed(_:)))
  1727. scheduleScrollLeftButton = leftButton
  1728. wrapper.addArrangedSubview(leftButton)
  1729. let scroll = NSScrollView()
  1730. scheduleCardsScrollView = scroll
  1731. scroll.translatesAutoresizingMaskIntoConstraints = false
  1732. scroll.drawsBackground = false
  1733. scroll.hasHorizontalScroller = false
  1734. scroll.hasVerticalScroller = false
  1735. scroll.horizontalScrollElasticity = .allowed
  1736. scroll.verticalScrollElasticity = .none
  1737. scroll.autohidesScrollers = false
  1738. scroll.borderType = .noBorder
  1739. scroll.heightAnchor.constraint(equalToConstant: 150).isActive = true
  1740. scroll.widthAnchor.constraint(equalToConstant: viewportWidth).isActive = true
  1741. let row = NSStackView()
  1742. row.translatesAutoresizingMaskIntoConstraints = false
  1743. row.orientation = .horizontal
  1744. row.spacing = 12
  1745. row.alignment = .top
  1746. row.distribution = .gravityAreas
  1747. row.setContentHuggingPriority(.defaultHigh, for: .horizontal)
  1748. row.heightAnchor.constraint(equalToConstant: 150).isActive = true
  1749. scheduleCardsStack = row
  1750. scroll.documentView = row
  1751. scroll.contentView.postsBoundsChangedNotifications = true
  1752. // Ensure the stack view determines content size for horizontal scrolling.
  1753. NSLayoutConstraint.activate([
  1754. row.leadingAnchor.constraint(equalTo: scroll.contentView.leadingAnchor),
  1755. row.trailingAnchor.constraint(greaterThanOrEqualTo: scroll.contentView.trailingAnchor),
  1756. row.topAnchor.constraint(equalTo: scroll.contentView.topAnchor),
  1757. row.bottomAnchor.constraint(equalTo: scroll.contentView.bottomAnchor),
  1758. row.heightAnchor.constraint(equalToConstant: 150)
  1759. ])
  1760. renderScheduleCards(into: row, meetings: meetings)
  1761. wrapper.addArrangedSubview(scroll)
  1762. let rightButton = makeScheduleScrollButton(systemSymbol: "chevron.right", action: #selector(scheduleScrollRightPressed(_:)))
  1763. scheduleScrollRightButton = rightButton
  1764. wrapper.addArrangedSubview(rightButton)
  1765. scroll.setContentHuggingPriority(.defaultLow, for: .horizontal)
  1766. scroll.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1767. wrapper.widthAnchor.constraint(greaterThanOrEqualToConstant: 780).isActive = true
  1768. return wrapper
  1769. }
  1770. func scheduleCard(meeting: ScheduledMeeting) -> NSView {
  1771. let cardWidth: CGFloat = 240
  1772. let card = roundedContainer(cornerRadius: 12, color: palette.sectionCard)
  1773. styleSurface(card, borderColor: palette.inputBorder, borderWidth: 1, shadow: true)
  1774. card.translatesAutoresizingMaskIntoConstraints = false
  1775. card.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  1776. card.heightAnchor.constraint(equalToConstant: 150).isActive = true
  1777. card.setContentHuggingPriority(.required, for: .horizontal)
  1778. card.setContentCompressionResistancePriority(.required, for: .horizontal)
  1779. let icon = roundedContainer(cornerRadius: 8, color: palette.meetingBadge)
  1780. icon.translatesAutoresizingMaskIntoConstraints = false
  1781. icon.widthAnchor.constraint(equalToConstant: 28).isActive = true
  1782. icon.heightAnchor.constraint(equalToConstant: 28).isActive = true
  1783. let iconView = NSImageView()
  1784. iconView.translatesAutoresizingMaskIntoConstraints = false
  1785. iconView.image = NSImage(systemSymbolName: "video.circle.fill", accessibilityDescription: "Meeting")
  1786. iconView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 18, weight: .semibold)
  1787. iconView.contentTintColor = .white
  1788. icon.addSubview(iconView)
  1789. NSLayoutConstraint.activate([
  1790. iconView.centerXAnchor.constraint(equalTo: icon.centerXAnchor),
  1791. iconView.centerYAnchor.constraint(equalTo: icon.centerYAnchor)
  1792. ])
  1793. let title = textLabel(meeting.title, font: typography.cardTitle, color: palette.textPrimary)
  1794. title.lineBreakMode = .byTruncatingTail
  1795. title.maximumNumberOfLines = 1
  1796. title.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  1797. let subtitle = textLabel(meeting.subtitle ?? "Google Calendar", font: typography.cardSubtitle, color: palette.textPrimary)
  1798. let time = textLabel(scheduleTimeText(for: meeting), font: typography.cardTime, color: palette.textSecondary)
  1799. let duration = textLabel(scheduleDurationText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .medium), color: palette.textMuted)
  1800. let dayChip = roundedContainer(cornerRadius: 7, color: palette.inputBackground)
  1801. dayChip.translatesAutoresizingMaskIntoConstraints = false
  1802. dayChip.layer?.borderWidth = 1
  1803. dayChip.layer?.borderColor = palette.inputBorder.withAlphaComponent(0.8).cgColor
  1804. let dayText = textLabel(scheduleDayText(for: meeting), font: NSFont.systemFont(ofSize: 11, weight: .semibold), color: palette.textSecondary)
  1805. dayText.translatesAutoresizingMaskIntoConstraints = false
  1806. dayChip.addSubview(dayText)
  1807. NSLayoutConstraint.activate([
  1808. dayText.leadingAnchor.constraint(equalTo: dayChip.leadingAnchor, constant: 8),
  1809. dayText.trailingAnchor.constraint(equalTo: dayChip.trailingAnchor, constant: -8),
  1810. dayText.topAnchor.constraint(equalTo: dayChip.topAnchor, constant: 4),
  1811. dayText.bottomAnchor.constraint(equalTo: dayChip.bottomAnchor, constant: -4)
  1812. ])
  1813. card.addSubview(icon)
  1814. card.addSubview(dayChip)
  1815. card.addSubview(title)
  1816. card.addSubview(subtitle)
  1817. card.addSubview(time)
  1818. card.addSubview(duration)
  1819. NSLayoutConstraint.activate([
  1820. icon.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  1821. icon.topAnchor.constraint(equalTo: card.topAnchor, constant: 10),
  1822. dayChip.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -10),
  1823. dayChip.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  1824. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 6),
  1825. title.centerYAnchor.constraint(equalTo: icon.centerYAnchor),
  1826. title.trailingAnchor.constraint(lessThanOrEqualTo: dayChip.leadingAnchor, constant: -8),
  1827. title.widthAnchor.constraint(lessThanOrEqualToConstant: 130),
  1828. subtitle.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  1829. subtitle.topAnchor.constraint(equalTo: icon.bottomAnchor, constant: 10),
  1830. subtitle.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  1831. time.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  1832. time.topAnchor.constraint(equalTo: subtitle.bottomAnchor, constant: 5),
  1833. time.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10),
  1834. duration.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 10),
  1835. duration.topAnchor.constraint(equalTo: time.bottomAnchor, constant: 4),
  1836. duration.trailingAnchor.constraint(lessThanOrEqualTo: card.trailingAnchor, constant: -10)
  1837. ])
  1838. let hit = HoverButton(title: "", target: self, action: #selector(scheduleCardButtonPressed(_:)))
  1839. hit.translatesAutoresizingMaskIntoConstraints = false
  1840. hit.isBordered = false
  1841. hit.bezelStyle = .regularSquare
  1842. hit.identifier = NSUserInterfaceItemIdentifier(meeting.meetURL.absoluteString)
  1843. hit.widthAnchor.constraint(equalToConstant: cardWidth).isActive = true
  1844. hit.heightAnchor.constraint(equalToConstant: 150).isActive = true
  1845. hit.setContentHuggingPriority(.required, for: .horizontal)
  1846. hit.setContentCompressionResistancePriority(.required, for: .horizontal)
  1847. hit.addSubview(card)
  1848. NSLayoutConstraint.activate([
  1849. card.leadingAnchor.constraint(equalTo: hit.leadingAnchor),
  1850. card.trailingAnchor.constraint(equalTo: hit.trailingAnchor),
  1851. card.topAnchor.constraint(equalTo: hit.topAnchor),
  1852. card.bottomAnchor.constraint(equalTo: hit.bottomAnchor)
  1853. ])
  1854. hit.onHoverChanged = { [weak self] hovering in
  1855. guard let self else { return }
  1856. let base = self.palette.sectionCard
  1857. let hoverBlend = self.darkModeEnabled ? NSColor.white : NSColor.black
  1858. let hover = base.blended(withFraction: 0.10, of: hoverBlend) ?? base
  1859. card.layer?.backgroundColor = (hovering ? hover : base).cgColor
  1860. }
  1861. hit.onHoverChanged?(false)
  1862. return hit
  1863. }
  1864. private func makeScheduleScrollButton(systemSymbol: String, action: Selector) -> NSButton {
  1865. let button = NSButton(title: "", target: self, action: action)
  1866. button.translatesAutoresizingMaskIntoConstraints = false
  1867. button.isBordered = false
  1868. button.bezelStyle = .regularSquare
  1869. button.wantsLayer = true
  1870. button.layer?.cornerRadius = 16
  1871. button.layer?.backgroundColor = palette.inputBackground.cgColor
  1872. button.layer?.borderColor = palette.inputBorder.cgColor
  1873. button.layer?.borderWidth = 1
  1874. button.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Scroll meetings")
  1875. button.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 13, weight: .semibold)
  1876. button.imagePosition = .imageOnly
  1877. button.imageScaling = .scaleProportionallyDown
  1878. button.contentTintColor = palette.textSecondary
  1879. button.focusRingType = .none
  1880. button.heightAnchor.constraint(equalToConstant: 32).isActive = true
  1881. button.widthAnchor.constraint(equalToConstant: 32).isActive = true
  1882. return button
  1883. }
  1884. }
  1885. extension ViewController: NSTextFieldDelegate {
  1886. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  1887. if control === browseAddressField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  1888. browseOpenAddressClicked(nil)
  1889. return true
  1890. }
  1891. return false
  1892. }
  1893. }
  1894. extension ViewController: NSWindowDelegate {
  1895. func windowWillClose(_ notification: Notification) {
  1896. guard let closingWindow = notification.object as? NSWindow else { return }
  1897. if closingWindow === paywallWindow {
  1898. paywallWindow = nil
  1899. }
  1900. }
  1901. }
  1902. /// Wraps the Google auth control and draws a circular accent ring with a light pulse while the signed-in avatar is hovered.
  1903. private final class GoogleProfileAuthHostView: NSView {
  1904. weak var authButton: NSButton? {
  1905. didSet { needsLayout = true }
  1906. }
  1907. private let ringLayer = CAShapeLayer()
  1908. private var avatarRingMode = false
  1909. private static let ringLineWidth: CGFloat = 2.25
  1910. override init(frame frameRect: NSRect) {
  1911. super.init(frame: frameRect)
  1912. wantsLayer = true
  1913. layer?.masksToBounds = false
  1914. ringLayer.fillColor = nil
  1915. ringLayer.strokeColor = NSColor.clear.cgColor
  1916. ringLayer.lineWidth = Self.ringLineWidth
  1917. ringLayer.lineCap = .round
  1918. ringLayer.opacity = 0
  1919. ringLayer.anchorPoint = CGPoint(x: 0.5, y: 0.5)
  1920. layer?.insertSublayer(ringLayer, at: 0)
  1921. }
  1922. @available(*, unavailable)
  1923. required init?(coder: NSCoder) {
  1924. nil
  1925. }
  1926. func setAvatarRingMode(_ enabled: Bool) {
  1927. avatarRingMode = enabled
  1928. if enabled == false {
  1929. ringLayer.removeAllAnimations()
  1930. ringLayer.opacity = 0
  1931. ringLayer.lineWidth = Self.ringLineWidth
  1932. }
  1933. needsLayout = true
  1934. }
  1935. func updateRingAppearance(isDark: Bool, accent: NSColor) {
  1936. let stroke = isDark
  1937. ? accent.blended(withFraction: 0.22, of: NSColor.white) ?? accent
  1938. : accent
  1939. CATransaction.begin()
  1940. CATransaction.setDisableActions(true)
  1941. ringLayer.strokeColor = stroke.withAlphaComponent(0.95).cgColor
  1942. CATransaction.commit()
  1943. }
  1944. func setProfileHoverActive(_ active: Bool) {
  1945. guard avatarRingMode else { return }
  1946. ringLayer.removeAnimation(forKey: "pulse")
  1947. if active {
  1948. layoutRingPathIfNeeded()
  1949. CATransaction.begin()
  1950. CATransaction.setAnimationDuration(0.22)
  1951. ringLayer.opacity = 1
  1952. CATransaction.commit()
  1953. let pulse = CABasicAnimation(keyPath: "lineWidth")
  1954. pulse.fromValue = Self.ringLineWidth * 0.88
  1955. pulse.toValue = Self.ringLineWidth * 1.45
  1956. pulse.duration = 0.72
  1957. pulse.autoreverses = true
  1958. pulse.repeatCount = .infinity
  1959. pulse.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
  1960. ringLayer.add(pulse, forKey: "pulse")
  1961. } else {
  1962. CATransaction.begin()
  1963. CATransaction.setAnimationDuration(0.18)
  1964. ringLayer.opacity = 0
  1965. CATransaction.commit()
  1966. ringLayer.lineWidth = Self.ringLineWidth
  1967. }
  1968. }
  1969. private func layoutRingPathIfNeeded() {
  1970. guard avatarRingMode, let btn = authButton else { return }
  1971. let f = btn.frame
  1972. guard f.width > 1, f.height > 1 else { return }
  1973. let center = CGPoint(x: f.midX, y: f.midY)
  1974. let avatarR = min(f.width, f.height) / 2
  1975. let gap: CGFloat = 3.5
  1976. let ringRadius = avatarR + gap
  1977. let d = ringRadius * 2
  1978. CATransaction.begin()
  1979. CATransaction.setDisableActions(true)
  1980. ringLayer.bounds = CGRect(x: 0, y: 0, width: d, height: d)
  1981. ringLayer.position = center
  1982. ringLayer.path = CGPath(ellipseIn: CGRect(origin: .zero, size: CGSize(width: d, height: d)), transform: nil)
  1983. CATransaction.commit()
  1984. }
  1985. override func layout() {
  1986. super.layout()
  1987. layoutRingPathIfNeeded()
  1988. }
  1989. }
  1990. /// Ensures `NSClickGestureRecognizer` on the row receives clicks instead of child label/image views swallowing them.
  1991. private class RowHitTestView: NSView {
  1992. override func hitTest(_ point: NSPoint) -> NSView? {
  1993. return bounds.contains(point) ? self : nil
  1994. }
  1995. override func acceptsFirstMouse(for event: NSEvent?) -> Bool {
  1996. true
  1997. }
  1998. }
  1999. private final class HoverTrackingView: RowHitTestView {
  2000. var onHoverChanged: ((Bool) -> Void)?
  2001. var onClick: (() -> Void)?
  2002. var showsHandCursor = true
  2003. private var trackingAreaRef: NSTrackingArea?
  2004. private var isHovering = false {
  2005. didSet {
  2006. guard isHovering != oldValue else { return }
  2007. onHoverChanged?(isHovering)
  2008. }
  2009. }
  2010. override func updateTrackingAreas() {
  2011. super.updateTrackingAreas()
  2012. if let trackingAreaRef {
  2013. removeTrackingArea(trackingAreaRef)
  2014. }
  2015. let options: NSTrackingArea.Options = [
  2016. .activeInKeyWindow,
  2017. .inVisibleRect,
  2018. .mouseEnteredAndExited
  2019. ]
  2020. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2021. addTrackingArea(area)
  2022. trackingAreaRef = area
  2023. }
  2024. override func mouseEntered(with event: NSEvent) {
  2025. super.mouseEntered(with: event)
  2026. isHovering = true
  2027. }
  2028. override func mouseExited(with event: NSEvent) {
  2029. super.mouseExited(with: event)
  2030. isHovering = false
  2031. }
  2032. override func resetCursorRects() {
  2033. super.resetCursorRects()
  2034. guard showsHandCursor else { return }
  2035. addCursorRect(bounds, cursor: .pointingHand)
  2036. }
  2037. override func mouseUp(with event: NSEvent) {
  2038. super.mouseUp(with: event)
  2039. guard event.type == .leftMouseUp else { return }
  2040. onClick?()
  2041. }
  2042. }
  2043. /// Hover tracking without overriding hit-testing; keeps controls like text fields interactive.
  2044. private final class HoverSurfaceView: NSView {
  2045. var onHoverChanged: ((Bool) -> Void)?
  2046. private var trackingAreaRef: NSTrackingArea?
  2047. private var isHovering = false {
  2048. didSet {
  2049. guard isHovering != oldValue else { return }
  2050. onHoverChanged?(isHovering)
  2051. }
  2052. }
  2053. override func updateTrackingAreas() {
  2054. super.updateTrackingAreas()
  2055. if let trackingAreaRef {
  2056. removeTrackingArea(trackingAreaRef)
  2057. }
  2058. let options: NSTrackingArea.Options = [
  2059. .activeInKeyWindow,
  2060. .inVisibleRect,
  2061. .mouseEnteredAndExited
  2062. ]
  2063. let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2064. addTrackingArea(area)
  2065. trackingAreaRef = area
  2066. }
  2067. override func mouseEntered(with event: NSEvent) {
  2068. super.mouseEntered(with: event)
  2069. isHovering = true
  2070. }
  2071. override func mouseExited(with event: NSEvent) {
  2072. super.mouseExited(with: event)
  2073. isHovering = false
  2074. }
  2075. }
  2076. private final class HoverButton: NSButton {
  2077. var onHoverChanged: ((Bool) -> Void)?
  2078. var showsHandCursor = true
  2079. private var trackingAreaRef: NSTrackingArea?
  2080. private var isHovering = false {
  2081. didSet {
  2082. guard isHovering != oldValue else { return }
  2083. onHoverChanged?(isHovering)
  2084. }
  2085. }
  2086. override func updateTrackingAreas() {
  2087. super.updateTrackingAreas()
  2088. if let trackingAreaRef {
  2089. removeTrackingArea(trackingAreaRef)
  2090. }
  2091. let options: NSTrackingArea.Options = [
  2092. .activeInKeyWindow,
  2093. .inVisibleRect,
  2094. .mouseEnteredAndExited
  2095. ]
  2096. let tracking = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
  2097. addTrackingArea(tracking)
  2098. trackingAreaRef = tracking
  2099. }
  2100. override func mouseEntered(with event: NSEvent) {
  2101. super.mouseEntered(with: event)
  2102. if showsHandCursor {
  2103. NSCursor.pointingHand.set()
  2104. }
  2105. isHovering = true
  2106. }
  2107. override func mouseExited(with event: NSEvent) {
  2108. super.mouseExited(with: event)
  2109. isHovering = false
  2110. }
  2111. }
  2112. private func circularNSImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  2113. let size = NSSize(width: diameter, height: diameter)
  2114. let result = NSImage(size: size)
  2115. result.lockFocus()
  2116. if let ctx = NSGraphicsContext.current {
  2117. ctx.imageInterpolation = .high
  2118. }
  2119. let rect = NSRect(origin: .zero, size: size)
  2120. let path = NSBezierPath(ovalIn: rect)
  2121. path.addClip()
  2122. let src = image.size.width > 0 && image.size.height > 0
  2123. ? NSRect(origin: .zero, size: image.size)
  2124. : NSRect(origin: .zero, size: size)
  2125. image.draw(in: rect, from: src, operation: .copy, fraction: 1.0)
  2126. result.unlockFocus()
  2127. result.isTemplate = false
  2128. return result
  2129. }
  2130. private final class GoogleAccountMenuViewController: NSViewController {
  2131. private let palette: Palette
  2132. private let darkModeEnabled: Bool
  2133. private let displayName: String
  2134. private let email: String
  2135. private let avatar: NSImage?
  2136. private let onSignOut: () -> Void
  2137. init(
  2138. palette: Palette,
  2139. darkModeEnabled: Bool,
  2140. displayName: String,
  2141. email: String,
  2142. avatar: NSImage?,
  2143. onSignOut: @escaping () -> Void
  2144. ) {
  2145. self.palette = palette
  2146. self.darkModeEnabled = darkModeEnabled
  2147. self.displayName = displayName
  2148. self.email = email
  2149. self.avatar = avatar
  2150. self.onSignOut = onSignOut
  2151. super.init(nibName: nil, bundle: nil)
  2152. view = makeContentView()
  2153. view.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  2154. preferredContentSize = NSSize(width: 300, height: 158)
  2155. }
  2156. @available(*, unavailable)
  2157. required init?(coder: NSCoder) {
  2158. nil
  2159. }
  2160. private func makeContentView() -> NSView {
  2161. let root = NSView()
  2162. root.translatesAutoresizingMaskIntoConstraints = false
  2163. let card = NSView()
  2164. card.translatesAutoresizingMaskIntoConstraints = false
  2165. card.wantsLayer = true
  2166. card.layer?.cornerRadius = 14
  2167. card.layer?.backgroundColor = palette.sectionCard.cgColor
  2168. card.layer?.borderColor = palette.inputBorder.cgColor
  2169. card.layer?.borderWidth = 1
  2170. card.layer?.shadowColor = NSColor.black.cgColor
  2171. card.layer?.shadowOpacity = darkModeEnabled ? 0.5 : 0.2
  2172. card.layer?.shadowOffset = CGSize(width: 0, height: 6)
  2173. card.layer?.shadowRadius = 18
  2174. card.layer?.masksToBounds = false
  2175. root.addSubview(card)
  2176. NSLayoutConstraint.activate([
  2177. card.leadingAnchor.constraint(equalTo: root.leadingAnchor, constant: 8),
  2178. card.trailingAnchor.constraint(equalTo: root.trailingAnchor, constant: -8),
  2179. card.topAnchor.constraint(equalTo: root.topAnchor, constant: 8),
  2180. card.bottomAnchor.constraint(equalTo: root.bottomAnchor, constant: -8),
  2181. root.widthAnchor.constraint(equalToConstant: 300)
  2182. ])
  2183. let inner = NSStackView()
  2184. inner.translatesAutoresizingMaskIntoConstraints = false
  2185. inner.orientation = .vertical
  2186. inner.spacing = 0
  2187. inner.alignment = .leading
  2188. card.addSubview(inner)
  2189. NSLayoutConstraint.activate([
  2190. inner.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 18),
  2191. inner.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -18),
  2192. inner.topAnchor.constraint(equalTo: card.topAnchor, constant: 18),
  2193. inner.bottomAnchor.constraint(equalTo: card.bottomAnchor, constant: -10)
  2194. ])
  2195. let headerRow = NSView()
  2196. headerRow.translatesAutoresizingMaskIntoConstraints = false
  2197. let avatarBox = NSView()
  2198. avatarBox.translatesAutoresizingMaskIntoConstraints = false
  2199. avatarBox.wantsLayer = true
  2200. avatarBox.layer?.cornerRadius = 24
  2201. avatarBox.layer?.masksToBounds = true
  2202. avatarBox.layer?.borderColor = palette.inputBorder.cgColor
  2203. avatarBox.layer?.borderWidth = 1
  2204. let avatarView = NSImageView()
  2205. avatarView.translatesAutoresizingMaskIntoConstraints = false
  2206. avatarView.imageScaling = .scaleAxesIndependently
  2207. avatarView.image = resolvedAvatarImage()
  2208. avatarBox.addSubview(avatarView)
  2209. NSLayoutConstraint.activate([
  2210. avatarBox.widthAnchor.constraint(equalToConstant: 48),
  2211. avatarBox.heightAnchor.constraint(equalToConstant: 48),
  2212. avatarView.leadingAnchor.constraint(equalTo: avatarBox.leadingAnchor),
  2213. avatarView.trailingAnchor.constraint(equalTo: avatarBox.trailingAnchor),
  2214. avatarView.topAnchor.constraint(equalTo: avatarBox.topAnchor),
  2215. avatarView.bottomAnchor.constraint(equalTo: avatarBox.bottomAnchor)
  2216. ])
  2217. let textColumn = NSStackView()
  2218. textColumn.translatesAutoresizingMaskIntoConstraints = false
  2219. textColumn.orientation = .vertical
  2220. textColumn.spacing = 3
  2221. textColumn.alignment = .leading
  2222. let nameField = NSTextField(labelWithString: displayName)
  2223. nameField.translatesAutoresizingMaskIntoConstraints = false
  2224. nameField.font = NSFont.systemFont(ofSize: 15, weight: .semibold)
  2225. nameField.textColor = palette.textPrimary
  2226. nameField.lineBreakMode = .byTruncatingTail
  2227. nameField.maximumNumberOfLines = 1
  2228. nameField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2229. let emailField = NSTextField(labelWithString: email)
  2230. emailField.translatesAutoresizingMaskIntoConstraints = false
  2231. emailField.font = NSFont.systemFont(ofSize: 12, weight: .regular)
  2232. emailField.textColor = palette.textTertiary
  2233. emailField.lineBreakMode = .byTruncatingTail
  2234. emailField.maximumNumberOfLines = 1
  2235. emailField.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
  2236. textColumn.addArrangedSubview(nameField)
  2237. textColumn.addArrangedSubview(emailField)
  2238. headerRow.addSubview(avatarBox)
  2239. headerRow.addSubview(textColumn)
  2240. NSLayoutConstraint.activate([
  2241. avatarBox.leadingAnchor.constraint(equalTo: headerRow.leadingAnchor),
  2242. avatarBox.topAnchor.constraint(equalTo: headerRow.topAnchor),
  2243. avatarBox.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor),
  2244. textColumn.leadingAnchor.constraint(equalTo: avatarBox.trailingAnchor, constant: 14),
  2245. textColumn.trailingAnchor.constraint(equalTo: headerRow.trailingAnchor),
  2246. textColumn.centerYAnchor.constraint(equalTo: avatarBox.centerYAnchor),
  2247. textColumn.topAnchor.constraint(greaterThanOrEqualTo: headerRow.topAnchor),
  2248. textColumn.bottomAnchor.constraint(lessThanOrEqualTo: headerRow.bottomAnchor)
  2249. ])
  2250. inner.addArrangedSubview(headerRow)
  2251. headerRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2252. inner.setCustomSpacing(14, after: headerRow)
  2253. let separator = NSView()
  2254. separator.translatesAutoresizingMaskIntoConstraints = false
  2255. separator.wantsLayer = true
  2256. separator.layer?.backgroundColor = palette.separator.cgColor
  2257. separator.heightAnchor.constraint(equalToConstant: 1).isActive = true
  2258. inner.addArrangedSubview(separator)
  2259. separator.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2260. inner.setCustomSpacing(6, after: separator)
  2261. let signOutRow = HoverTrackingView()
  2262. signOutRow.translatesAutoresizingMaskIntoConstraints = false
  2263. signOutRow.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2264. signOutRow.wantsLayer = true
  2265. signOutRow.layer?.cornerRadius = 10
  2266. let signOutIcon = NSImageView()
  2267. signOutIcon.translatesAutoresizingMaskIntoConstraints = false
  2268. signOutIcon.imageScaling = .scaleProportionallyDown
  2269. if let sym = NSImage(systemSymbolName: "rectangle.portrait.and.arrow.right", accessibilityDescription: nil) {
  2270. signOutIcon.image = sym
  2271. signOutIcon.contentTintColor = palette.textSecondary
  2272. signOutIcon.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 14, weight: .medium)
  2273. }
  2274. let signOutLabel = NSTextField(labelWithString: "Log out")
  2275. signOutLabel.translatesAutoresizingMaskIntoConstraints = false
  2276. signOutLabel.font = NSFont.systemFont(ofSize: 14, weight: .medium)
  2277. signOutLabel.textColor = palette.textPrimary
  2278. signOutRow.addSubview(signOutIcon)
  2279. signOutRow.addSubview(signOutLabel)
  2280. NSLayoutConstraint.activate([
  2281. signOutIcon.leadingAnchor.constraint(equalTo: signOutRow.leadingAnchor, constant: 10),
  2282. signOutIcon.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  2283. signOutIcon.widthAnchor.constraint(equalToConstant: 20),
  2284. signOutIcon.heightAnchor.constraint(equalToConstant: 20),
  2285. signOutLabel.leadingAnchor.constraint(equalTo: signOutIcon.trailingAnchor, constant: 10),
  2286. signOutLabel.centerYAnchor.constraint(equalTo: signOutRow.centerYAnchor),
  2287. signOutLabel.trailingAnchor.constraint(lessThanOrEqualTo: signOutRow.trailingAnchor, constant: -10)
  2288. ])
  2289. let signOutClick = NSClickGestureRecognizer(target: self, action: #selector(signOutClicked))
  2290. signOutRow.addGestureRecognizer(signOutClick)
  2291. signOutRow.onHoverChanged = { [weak self] hovering in
  2292. guard let self else { return }
  2293. signOutRow.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  2294. }
  2295. signOutRow.onHoverChanged?(false)
  2296. inner.addArrangedSubview(signOutRow)
  2297. signOutRow.widthAnchor.constraint(equalTo: inner.widthAnchor).isActive = true
  2298. return root
  2299. }
  2300. private func resolvedAvatarImage() -> NSImage {
  2301. if let a = avatar {
  2302. return circularNSImage(a, diameter: 48)
  2303. }
  2304. return initialLetterAvatar()
  2305. }
  2306. private func initialLetterAvatar() -> NSImage {
  2307. let d: CGFloat = 48
  2308. let letter = displayName.trimmingCharacters(in: .whitespacesAndNewlines).first.map { String($0).uppercased() } ?? "?"
  2309. let img = NSImage(size: NSSize(width: d, height: d))
  2310. img.lockFocus()
  2311. palette.primaryBlue.setFill()
  2312. NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: d, height: d)).fill()
  2313. let attrs: [NSAttributedString.Key: Any] = [
  2314. .font: NSFont.systemFont(ofSize: 20, weight: .semibold),
  2315. .foregroundColor: NSColor.white
  2316. ]
  2317. let sz = (letter as NSString).size(withAttributes: attrs)
  2318. let origin = NSPoint(x: (d - sz.width) / 2, y: (d - sz.height) / 2)
  2319. (letter as NSString).draw(at: origin, withAttributes: attrs)
  2320. img.unlockFocus()
  2321. img.isTemplate = false
  2322. return img
  2323. }
  2324. @objc private func signOutClicked() {
  2325. onSignOut()
  2326. }
  2327. }
  2328. private final class SettingsMenuViewController: NSViewController {
  2329. private let palette: Palette
  2330. private let typography: Typography
  2331. private let onToggleDarkMode: (Bool) -> Void
  2332. private let onAction: (SettingsAction) -> Void
  2333. private var darkToggle: NSSwitch?
  2334. init(
  2335. palette: Palette,
  2336. typography: Typography,
  2337. darkModeEnabled: Bool,
  2338. onToggleDarkMode: @escaping (Bool) -> Void,
  2339. onAction: @escaping (SettingsAction) -> Void
  2340. ) {
  2341. self.palette = palette
  2342. self.typography = typography
  2343. self.onToggleDarkMode = onToggleDarkMode
  2344. self.onAction = onAction
  2345. super.init(nibName: nil, bundle: nil)
  2346. self.view = makeView(darkModeEnabled: darkModeEnabled)
  2347. }
  2348. @available(*, unavailable)
  2349. required init?(coder: NSCoder) {
  2350. nil
  2351. }
  2352. func setDarkModeEnabled(_ enabled: Bool) {
  2353. darkToggle?.state = enabled ? .on : .off
  2354. }
  2355. private func makeView(darkModeEnabled: Bool) -> NSView {
  2356. let root = NSView()
  2357. root.translatesAutoresizingMaskIntoConstraints = false
  2358. let card = roundedCard()
  2359. root.addSubview(card)
  2360. NSLayoutConstraint.activate([
  2361. card.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  2362. card.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  2363. card.topAnchor.constraint(equalTo: root.topAnchor),
  2364. card.bottomAnchor.constraint(equalTo: root.bottomAnchor),
  2365. root.widthAnchor.constraint(equalToConstant: 260)
  2366. ])
  2367. let stack = NSStackView()
  2368. stack.translatesAutoresizingMaskIntoConstraints = false
  2369. stack.orientation = .vertical
  2370. stack.spacing = 6
  2371. stack.alignment = .leading
  2372. card.addSubview(stack)
  2373. NSLayoutConstraint.activate([
  2374. stack.leadingAnchor.constraint(equalTo: card.leadingAnchor, constant: 14),
  2375. stack.trailingAnchor.constraint(equalTo: card.trailingAnchor, constant: -14),
  2376. stack.topAnchor.constraint(equalTo: card.topAnchor, constant: 14),
  2377. stack.bottomAnchor.constraint(lessThanOrEqualTo: card.bottomAnchor, constant: -14)
  2378. ])
  2379. stack.addArrangedSubview(settingsDarkModeRow(enabled: darkModeEnabled))
  2380. stack.addArrangedSubview(settingsActionRow(icon: "⟳", title: "Restore", action: .restore))
  2381. stack.addArrangedSubview(settingsActionRow(icon: "★", title: "Rate Us", action: .rateUs))
  2382. stack.addArrangedSubview(settingsActionRow(icon: "💬", title: "Support", action: .support))
  2383. stack.addArrangedSubview(settingsActionRow(icon: "⋯", title: "More Apps", action: .moreApps))
  2384. stack.addArrangedSubview(settingsActionRow(icon: "⤴︎", title: "Share App", action: .shareApp))
  2385. for v in stack.arrangedSubviews {
  2386. v.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
  2387. }
  2388. return root
  2389. }
  2390. private func roundedCard() -> NSView {
  2391. let view = NSView()
  2392. view.translatesAutoresizingMaskIntoConstraints = false
  2393. view.wantsLayer = true
  2394. view.layer?.cornerRadius = 12
  2395. view.layer?.backgroundColor = palette.sectionCard.cgColor
  2396. view.layer?.borderColor = palette.inputBorder.cgColor
  2397. view.layer?.borderWidth = 1
  2398. view.layer?.shadowColor = NSColor.black.cgColor
  2399. view.layer?.shadowOpacity = 0.28
  2400. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2401. view.layer?.shadowRadius = 10
  2402. return view
  2403. }
  2404. private func settingsDarkModeRow(enabled: Bool) -> NSView {
  2405. let row = NSView()
  2406. row.translatesAutoresizingMaskIntoConstraints = false
  2407. row.heightAnchor.constraint(equalToConstant: 44).isActive = true
  2408. row.wantsLayer = true
  2409. row.layer?.cornerRadius = 10
  2410. let icon = NSTextField(labelWithString: "◐")
  2411. icon.translatesAutoresizingMaskIntoConstraints = false
  2412. icon.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  2413. icon.textColor = palette.textPrimary
  2414. let title = NSTextField(labelWithString: "Dark Mode")
  2415. title.translatesAutoresizingMaskIntoConstraints = false
  2416. title.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  2417. title.textColor = palette.textPrimary
  2418. let toggle = NSSwitch()
  2419. toggle.translatesAutoresizingMaskIntoConstraints = false
  2420. toggle.state = enabled ? .on : .off
  2421. toggle.target = self
  2422. toggle.action = #selector(darkModeToggled(_:))
  2423. darkToggle = toggle
  2424. row.addSubview(icon)
  2425. row.addSubview(title)
  2426. row.addSubview(toggle)
  2427. row.layer?.backgroundColor = NSColor.clear.cgColor
  2428. NSLayoutConstraint.activate([
  2429. icon.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  2430. icon.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  2431. title.leadingAnchor.constraint(equalTo: icon.trailingAnchor, constant: 10),
  2432. title.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  2433. toggle.trailingAnchor.constraint(equalTo: row.trailingAnchor, constant: -2),
  2434. toggle.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  2435. ])
  2436. return row
  2437. }
  2438. private func settingsActionRow(icon: String, title: String, action: SettingsAction) -> NSView {
  2439. let row = HoverTrackingView()
  2440. row.translatesAutoresizingMaskIntoConstraints = false
  2441. row.heightAnchor.constraint(equalToConstant: 42).isActive = true
  2442. let iconLabel = NSTextField(labelWithString: icon)
  2443. iconLabel.translatesAutoresizingMaskIntoConstraints = false
  2444. iconLabel.font = NSFont.systemFont(ofSize: 18, weight: .medium)
  2445. iconLabel.textColor = palette.textPrimary
  2446. let titleLabel = NSTextField(labelWithString: title)
  2447. titleLabel.translatesAutoresizingMaskIntoConstraints = false
  2448. titleLabel.font = NSFont.systemFont(ofSize: 16, weight: .semibold)
  2449. titleLabel.textColor = palette.textPrimary
  2450. row.addSubview(iconLabel)
  2451. row.addSubview(titleLabel)
  2452. NSLayoutConstraint.activate([
  2453. iconLabel.leadingAnchor.constraint(equalTo: row.leadingAnchor, constant: 4),
  2454. iconLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor),
  2455. titleLabel.leadingAnchor.constraint(equalTo: iconLabel.trailingAnchor, constant: 10),
  2456. titleLabel.centerYAnchor.constraint(equalTo: row.centerYAnchor)
  2457. ])
  2458. let click = NSClickGestureRecognizer(target: self, action: #selector(settingsActionClicked(_:)))
  2459. row.addGestureRecognizer(click)
  2460. row.identifier = NSUserInterfaceItemIdentifier(rawValue: "\(action.rawValue)")
  2461. row.onHoverChanged = { hovering in
  2462. row.wantsLayer = true
  2463. row.layer?.cornerRadius = 10
  2464. row.layer?.backgroundColor = (hovering ? self.palette.inputBackground : NSColor.clear).cgColor
  2465. }
  2466. row.onHoverChanged?(false)
  2467. return row
  2468. }
  2469. @objc private func darkModeToggled(_ sender: NSSwitch) {
  2470. onToggleDarkMode(sender.state == .on)
  2471. }
  2472. @objc private func settingsActionClicked(_ sender: NSClickGestureRecognizer) {
  2473. guard let view = sender.view,
  2474. let raw = Int(view.identifier?.rawValue ?? ""),
  2475. let action = SettingsAction(rawValue: raw) else { return }
  2476. onAction(action)
  2477. }
  2478. }
  2479. private extension ViewController {
  2480. func roundedContainer(cornerRadius: CGFloat, color: NSColor) -> NSView {
  2481. let view = NSView()
  2482. view.wantsLayer = true
  2483. view.layer?.backgroundColor = color.cgColor
  2484. view.layer?.cornerRadius = cornerRadius
  2485. return view
  2486. }
  2487. func styleSurface(_ view: NSView, borderColor: NSColor, borderWidth: CGFloat, shadow: Bool) {
  2488. view.layer?.borderColor = borderColor.cgColor
  2489. view.layer?.borderWidth = borderWidth
  2490. if shadow {
  2491. view.layer?.shadowColor = NSColor.black.cgColor
  2492. view.layer?.shadowOpacity = 0.18
  2493. view.layer?.shadowOffset = CGSize(width: 0, height: -1)
  2494. view.layer?.shadowRadius = 5
  2495. }
  2496. }
  2497. func textLabel(_ text: String, font: NSFont, color: NSColor) -> NSTextField {
  2498. let label = NSTextField(labelWithString: text)
  2499. label.translatesAutoresizingMaskIntoConstraints = false
  2500. label.textColor = color
  2501. label.font = font
  2502. return label
  2503. }
  2504. func iconLabel(_ text: String, size: CGFloat) -> NSTextField {
  2505. let label = NSTextField(labelWithString: text)
  2506. label.translatesAutoresizingMaskIntoConstraints = false
  2507. label.font = NSFont.systemFont(ofSize: size)
  2508. return label
  2509. }
  2510. func sidebarSectionTitle(_ text: String) -> NSTextField {
  2511. let field = textLabel(text, font: typography.sidebarSection, color: palette.textMuted)
  2512. field.alignment = .left
  2513. return field
  2514. }
  2515. func sidebarItem(_ text: String, icon: String, page: SidebarPage, logoImageName: String? = nil, logoIconWidth: CGFloat = 18, logoHeightMultiplier: CGFloat = 1, logoTemplate: Bool = true, showsDisclosure: Bool = false) -> NSView {
  2516. let item = HoverButton(title: "", target: self, action: #selector(sidebarButtonClicked(_:)))
  2517. item.tag = page.rawValue
  2518. item.isBordered = false
  2519. item.wantsLayer = true
  2520. item.layer?.cornerRadius = 10
  2521. item.layer?.backgroundColor = NSColor.clear.cgColor
  2522. item.translatesAutoresizingMaskIntoConstraints = false
  2523. item.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2524. item.layer?.borderWidth = 0
  2525. sidebarPageByView[ObjectIdentifier(item)] = page
  2526. let leadingView: NSView
  2527. if let name = logoImageName, let logo = NSImage(named: name) {
  2528. logo.isTemplate = true
  2529. let imageView = NSImageView(image: logo)
  2530. imageView.translatesAutoresizingMaskIntoConstraints = false
  2531. imageView.imageScaling = .scaleProportionallyDown
  2532. imageView.imageAlignment = .alignCenter
  2533. imageView.isEditable = false
  2534. leadingView = imageView
  2535. } else {
  2536. leadingView = textLabel(icon, font: typography.sidebarIcon, color: palette.textSecondary)
  2537. }
  2538. let titleLabel = textLabel(text, font: typography.sidebarItem, color: palette.textSecondary)
  2539. titleLabel.alignment = .left
  2540. item.addSubview(leadingView)
  2541. item.addSubview(titleLabel)
  2542. var constraints: [NSLayoutConstraint] = [
  2543. leadingView.leadingAnchor.constraint(equalTo: item.leadingAnchor, constant: 12),
  2544. leadingView.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  2545. titleLabel.leadingAnchor.constraint(equalTo: leadingView.trailingAnchor, constant: 8),
  2546. titleLabel.centerYAnchor.constraint(equalTo: item.centerYAnchor)
  2547. ]
  2548. if showsDisclosure {
  2549. let chevron = textLabel("›", font: NSFont.systemFont(ofSize: 22, weight: .semibold), color: palette.textSecondary)
  2550. chevron.translatesAutoresizingMaskIntoConstraints = false
  2551. chevron.alignment = .right
  2552. item.addSubview(chevron)
  2553. constraints.append(contentsOf: [
  2554. chevron.trailingAnchor.constraint(equalTo: item.trailingAnchor, constant: -10),
  2555. chevron.centerYAnchor.constraint(equalTo: item.centerYAnchor),
  2556. titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: chevron.leadingAnchor, constant: -8)
  2557. ])
  2558. } else {
  2559. constraints.append(titleLabel.trailingAnchor.constraint(lessThanOrEqualTo: item.trailingAnchor, constant: -10))
  2560. }
  2561. if logoImageName != nil {
  2562. let h = logoIconWidth * logoHeightMultiplier
  2563. constraints.append(contentsOf: [
  2564. leadingView.widthAnchor.constraint(equalToConstant: logoIconWidth),
  2565. leadingView.heightAnchor.constraint(equalToConstant: h)
  2566. ])
  2567. }
  2568. NSLayoutConstraint.activate(constraints)
  2569. applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate)
  2570. item.onHoverChanged = { [weak self, weak item] hovering in
  2571. guard let self, let item else { return }
  2572. self.applySidebarRowStyle(item, page: page, logoTemplate: logoTemplate, hovering: hovering)
  2573. }
  2574. return item
  2575. }
  2576. func applySidebarRowStyle(_ item: NSView, page: SidebarPage, logoTemplate: Bool, hovering: Bool = false) {
  2577. let selected = (page == selectedSidebarPage)
  2578. let hoverColor = darkModeEnabled ? NSColor(calibratedWhite: 1, alpha: 0.07) : NSColor(calibratedWhite: 0, alpha: 0.08)
  2579. item.layer?.backgroundColor = (selected ? palette.primaryBlue : (hovering ? hoverColor : NSColor.clear)).cgColor
  2580. let tint = selected ? NSColor.white : palette.textSecondary
  2581. let sidebarIconTint = darkModeEnabled ? tint : NSColor.black
  2582. guard item.subviews.count >= 2 else { return }
  2583. let leading = item.subviews[0]
  2584. let title = item.subviews.first { $0 is NSTextField } as? NSTextField
  2585. title?.textColor = tint
  2586. // Optional disclosure chevron (if present) is the last text field.
  2587. if let chevron = item.subviews.last as? NSTextField, chevron !== title {
  2588. chevron.textColor = sidebarIconTint
  2589. }
  2590. if let imageView = leading as? NSImageView {
  2591. if logoTemplate {
  2592. imageView.contentTintColor = sidebarIconTint
  2593. }
  2594. } else if let iconField = leading as? NSTextField {
  2595. iconField.textColor = sidebarIconTint
  2596. }
  2597. }
  2598. func actionButton(title: String, color: NSColor, textColor: NSColor, width: CGFloat) -> NSView {
  2599. let button = HoverTrackingView()
  2600. button.wantsLayer = true
  2601. button.layer?.cornerRadius = 9
  2602. button.layer?.backgroundColor = color.cgColor
  2603. button.translatesAutoresizingMaskIntoConstraints = false
  2604. button.widthAnchor.constraint(equalToConstant: width).isActive = true
  2605. button.heightAnchor.constraint(equalToConstant: 36).isActive = true
  2606. styleSurface(button, borderColor: title == "Cancel" ? palette.inputBorder : palette.primaryBlueBorder, borderWidth: 1, shadow: false)
  2607. if title == "Cancel" {
  2608. button.layer?.backgroundColor = palette.cancelButton.cgColor
  2609. }
  2610. let label = textLabel(title, font: typography.buttonText, color: textColor)
  2611. button.addSubview(label)
  2612. NSLayoutConstraint.activate([
  2613. label.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  2614. label.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  2615. ])
  2616. let baseColor = (title == "Cancel") ? palette.cancelButton : color
  2617. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2618. let hoverColor = baseColor.blended(withFraction: 0.12, of: hoverBlend) ?? baseColor
  2619. button.onHoverChanged = { hovering in
  2620. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  2621. }
  2622. button.onHoverChanged?(false)
  2623. return button
  2624. }
  2625. func iconRoundButton(systemSymbol: String, size: CGFloat, iconPointSize: CGFloat = 16, onClick: (() -> Void)? = nil) -> NSView {
  2626. let button = HoverTrackingView()
  2627. button.wantsLayer = true
  2628. button.layer?.cornerRadius = size / 2
  2629. button.layer?.backgroundColor = palette.inputBackground.cgColor
  2630. button.translatesAutoresizingMaskIntoConstraints = false
  2631. button.widthAnchor.constraint(equalToConstant: size).isActive = true
  2632. button.heightAnchor.constraint(equalToConstant: size).isActive = true
  2633. styleSurface(button, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2634. let symbolConfig = NSImage.SymbolConfiguration(pointSize: iconPointSize, weight: .semibold)
  2635. let iconView = NSImageView()
  2636. iconView.translatesAutoresizingMaskIntoConstraints = false
  2637. iconView.image = NSImage(systemSymbolName: systemSymbol, accessibilityDescription: "Refresh")
  2638. iconView.symbolConfiguration = symbolConfig
  2639. iconView.contentTintColor = palette.textSecondary
  2640. button.addSubview(iconView)
  2641. NSLayoutConstraint.activate([
  2642. iconView.centerXAnchor.constraint(equalTo: button.centerXAnchor),
  2643. iconView.centerYAnchor.constraint(equalTo: button.centerYAnchor)
  2644. ])
  2645. let baseColor = palette.inputBackground
  2646. let hoverBlend = darkModeEnabled ? NSColor.white : NSColor.black
  2647. let hoverColor = baseColor.blended(withFraction: 0.10, of: hoverBlend) ?? baseColor
  2648. button.onHoverChanged = { hovering in
  2649. button.layer?.backgroundColor = (hovering ? hoverColor : baseColor).cgColor
  2650. }
  2651. button.onHoverChanged?(false)
  2652. button.onClick = onClick
  2653. return button
  2654. }
  2655. }
  2656. // MARK: - Schedule actions (OAuth entry)
  2657. private extension ViewController {
  2658. @objc func scheduleReloadButtonPressed(_ sender: NSButton) {
  2659. scheduleReloadClicked()
  2660. }
  2661. @objc func scheduleScrollLeftPressed(_ sender: NSButton) {
  2662. scrollScheduleCards(direction: -1)
  2663. }
  2664. @objc func scheduleScrollRightPressed(_ sender: NSButton) {
  2665. scrollScheduleCards(direction: 1)
  2666. }
  2667. @objc func scheduleCardButtonPressed(_ sender: NSButton) {
  2668. guard let raw = sender.identifier?.rawValue,
  2669. let url = URL(string: raw) else { return }
  2670. openMeetingURL(url)
  2671. }
  2672. @objc func scheduleConnectButtonPressed(_ sender: NSButton) {
  2673. scheduleConnectClicked()
  2674. }
  2675. private func scheduleInitialHeadingText() -> String {
  2676. googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "Loading…"
  2677. }
  2678. @objc func scheduleFilterDropdownChanged(_ sender: NSPopUpButton) {
  2679. guard let selectedItem = sender.selectedItem,
  2680. let filter = ScheduleFilter(rawValue: selectedItem.tag) else { return }
  2681. applyScheduleFilter(filter)
  2682. }
  2683. private func applyScheduleFilter(_ filter: ScheduleFilter) {
  2684. scheduleFilter = filter
  2685. scheduleFilterDropdown?.selectItem(at: filter.rawValue)
  2686. Task { [weak self] in
  2687. await self?.loadSchedule()
  2688. }
  2689. }
  2690. private func scheduleTimeText(for meeting: ScheduledMeeting) -> String {
  2691. if meeting.isAllDay { return "All day" }
  2692. let f = DateFormatter()
  2693. f.locale = Locale.current
  2694. f.timeZone = TimeZone.current
  2695. f.dateStyle = .none
  2696. f.timeStyle = .short
  2697. return "\(f.string(from: meeting.startDate)) - \(f.string(from: meeting.endDate))"
  2698. }
  2699. private func scheduleDayText(for meeting: ScheduledMeeting) -> String {
  2700. let f = DateFormatter()
  2701. f.locale = Locale.current
  2702. f.timeZone = TimeZone.current
  2703. f.dateFormat = "EEE, d MMM"
  2704. return f.string(from: meeting.startDate)
  2705. }
  2706. private func scheduleDurationText(for meeting: ScheduledMeeting) -> String {
  2707. if meeting.isAllDay { return "Duration: all day" }
  2708. let duration = max(0, meeting.endDate.timeIntervalSince(meeting.startDate))
  2709. let totalMinutes = Int(duration / 60)
  2710. let hours = totalMinutes / 60
  2711. let minutes = totalMinutes % 60
  2712. if hours > 0, minutes > 0 { return "Duration: \(hours)h \(minutes)m" }
  2713. if hours > 0 { return "Duration: \(hours)h" }
  2714. return "Duration: \(minutes)m"
  2715. }
  2716. private func scheduleHeadingText(for meetings: [ScheduledMeeting]) -> String {
  2717. guard let first = meetings.first else {
  2718. return googleOAuth.loadTokens() == nil ? "Connect Google to see meetings" : "No upcoming meetings"
  2719. }
  2720. let day = Calendar.current.startOfDay(for: first.startDate)
  2721. let f = DateFormatter()
  2722. f.locale = Locale.current
  2723. f.timeZone = TimeZone.current
  2724. f.dateFormat = "EEEE, d MMM"
  2725. return f.string(from: day)
  2726. }
  2727. private func openMeetingURL(_ url: URL) {
  2728. NSWorkspace.shared.open(url)
  2729. }
  2730. private func renderScheduleCards(into stack: NSStackView, meetings: [ScheduledMeeting]) {
  2731. let shouldShowScrollControls = meetings.count > 3
  2732. scheduleScrollLeftButton?.isHidden = !shouldShowScrollControls
  2733. scheduleScrollRightButton?.isHidden = !shouldShowScrollControls
  2734. scheduleCardsScrollView?.contentView.setBoundsOrigin(.zero)
  2735. if let scroll = scheduleCardsScrollView {
  2736. scroll.reflectScrolledClipView(scroll.contentView)
  2737. }
  2738. stack.arrangedSubviews.forEach { v in
  2739. stack.removeArrangedSubview(v)
  2740. v.removeFromSuperview()
  2741. }
  2742. if meetings.isEmpty {
  2743. let empty = roundedContainer(cornerRadius: 10, color: palette.sectionCard)
  2744. empty.translatesAutoresizingMaskIntoConstraints = false
  2745. empty.widthAnchor.constraint(equalToConstant: 240).isActive = true
  2746. empty.heightAnchor.constraint(equalToConstant: 150).isActive = true
  2747. styleSurface(empty, borderColor: palette.inputBorder, borderWidth: 1, shadow: false)
  2748. let label = textLabel(googleOAuth.loadTokens() == nil ? "Connect to load schedule" : "No meetings", font: typography.cardSubtitle, color: palette.textSecondary)
  2749. label.translatesAutoresizingMaskIntoConstraints = false
  2750. empty.addSubview(label)
  2751. NSLayoutConstraint.activate([
  2752. label.centerXAnchor.constraint(equalTo: empty.centerXAnchor),
  2753. label.centerYAnchor.constraint(equalTo: empty.centerYAnchor)
  2754. ])
  2755. stack.addArrangedSubview(empty)
  2756. return
  2757. }
  2758. for meeting in meetings {
  2759. stack.addArrangedSubview(scheduleCard(meeting: meeting))
  2760. }
  2761. }
  2762. private func filteredMeetings(_ meetings: [ScheduledMeeting]) -> [ScheduledMeeting] {
  2763. switch scheduleFilter {
  2764. case .all:
  2765. return meetings
  2766. case .today:
  2767. let start = Calendar.current.startOfDay(for: Date())
  2768. let end = Calendar.current.date(byAdding: .day, value: 1, to: start) ?? start.addingTimeInterval(86400)
  2769. return meetings.filter { $0.startDate >= start && $0.startDate < end }
  2770. case .week:
  2771. let now = Date()
  2772. let end = Calendar.current.date(byAdding: .day, value: 7, to: now) ?? now.addingTimeInterval(7 * 86400)
  2773. return meetings.filter { $0.startDate >= now && $0.startDate <= end }
  2774. }
  2775. }
  2776. private func scrollScheduleCards(direction: Int) {
  2777. guard let scroll = scheduleCardsScrollView else { return }
  2778. let contentBounds = scroll.contentView.bounds
  2779. let step = max(220, contentBounds.width * 0.7)
  2780. let proposedX = contentBounds.origin.x + (CGFloat(direction) * step)
  2781. let maxX = max(0, scroll.documentView?.bounds.width ?? 0 - contentBounds.width)
  2782. let nextX = min(max(0, proposedX), maxX)
  2783. scroll.contentView.animator().setBoundsOrigin(NSPoint(x: nextX, y: 0))
  2784. scroll.reflectScrolledClipView(scroll.contentView)
  2785. }
  2786. private func loadSchedule() async {
  2787. do {
  2788. if googleOAuth.loadTokens() == nil {
  2789. await MainActor.run {
  2790. updateGoogleAuthButtonTitle()
  2791. applyGoogleProfile(nil)
  2792. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  2793. if let stack = scheduleCardsStack {
  2794. renderScheduleCards(into: stack, meetings: [])
  2795. }
  2796. }
  2797. return
  2798. }
  2799. let token = try await googleOAuth.validAccessToken(presentingWindow: view.window)
  2800. let profile = try? await googleOAuth.fetchUserProfile(accessToken: token)
  2801. let meetings = try await calendarClient.fetchUpcomingMeetings(accessToken: token)
  2802. let filtered = filteredMeetings(meetings)
  2803. await MainActor.run {
  2804. updateGoogleAuthButtonTitle()
  2805. applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  2806. scheduleDateHeadingLabel?.stringValue = scheduleHeadingText(for: filtered)
  2807. if let stack = scheduleCardsStack {
  2808. renderScheduleCards(into: stack, meetings: filtered)
  2809. }
  2810. }
  2811. } catch {
  2812. await MainActor.run {
  2813. updateGoogleAuthButtonTitle()
  2814. if googleOAuth.loadTokens() == nil {
  2815. applyGoogleProfile(nil)
  2816. }
  2817. scheduleDateHeadingLabel?.stringValue = "Couldn’t load schedule"
  2818. if let stack = scheduleCardsStack {
  2819. renderScheduleCards(into: stack, meetings: [])
  2820. }
  2821. showSimpleError("Couldn’t load schedule.", error: error)
  2822. }
  2823. }
  2824. }
  2825. func showScheduleHelp() {
  2826. let alert = NSAlert()
  2827. alert.messageText = "Google schedule"
  2828. alert.informativeText = "To show scheduled meetings, connect your Google account. You’ll need a Google OAuth client ID (Desktop) and a redirect URI matching the app’s callback scheme."
  2829. alert.addButton(withTitle: "OK")
  2830. alert.runModal()
  2831. }
  2832. func scheduleReloadClicked() {
  2833. Task { [weak self] in
  2834. guard let self else { return }
  2835. do {
  2836. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  2837. _ = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  2838. await MainActor.run {
  2839. self.scheduleDateHeadingLabel?.stringValue = "Refreshing…"
  2840. self.pageCache[.joinMeetings] = nil
  2841. self.showSidebarPage(.joinMeetings)
  2842. }
  2843. await self.loadSchedule()
  2844. } catch {
  2845. await MainActor.run {
  2846. self.showSimpleError("Couldn’t refresh schedule.", error: error)
  2847. }
  2848. }
  2849. }
  2850. }
  2851. func scheduleConnectClicked() {
  2852. Task { [weak self] in
  2853. guard let self else { return }
  2854. do {
  2855. if self.googleOAuth.loadTokens() != nil {
  2856. await MainActor.run { self.showGoogleAccountMenu() }
  2857. return
  2858. }
  2859. try await self.ensureGoogleClientIdConfigured(presentingWindow: self.view.window)
  2860. let token = try await self.googleOAuth.validAccessToken(presentingWindow: self.view.window)
  2861. let profile = try? await self.googleOAuth.fetchUserProfile(accessToken: token)
  2862. await MainActor.run {
  2863. self.updateGoogleAuthButtonTitle()
  2864. self.applyGoogleProfile(profile.map { self.makeGoogleProfileDisplay(from: $0) })
  2865. self.pageCache[.joinMeetings] = nil
  2866. self.showSidebarPage(.joinMeetings)
  2867. }
  2868. } catch {
  2869. self.showSimpleError("Couldn’t connect Google account.", error: error)
  2870. }
  2871. }
  2872. }
  2873. private func showGoogleAccountMenu() {
  2874. guard let button = scheduleGoogleAuthButton else { return }
  2875. if googleAccountPopover?.isShown == true {
  2876. googleAccountPopover?.performClose(nil)
  2877. googleAccountPopover = nil
  2878. return
  2879. }
  2880. let popover = NSPopover()
  2881. popover.behavior = .transient
  2882. popover.animates = true
  2883. popover.appearance = NSAppearance(named: darkModeEnabled ? .darkAqua : .aqua)
  2884. let name = scheduleCurrentProfile?.name ?? "Google account"
  2885. let email = scheduleCurrentProfile?.email ?? "Signed in"
  2886. let avatar = scheduleProfileMenuAvatar
  2887. popover.contentViewController = GoogleAccountMenuViewController(
  2888. palette: palette,
  2889. darkModeEnabled: darkModeEnabled,
  2890. displayName: name,
  2891. email: email,
  2892. avatar: avatar,
  2893. onSignOut: { [weak self] in
  2894. self?.googleAccountPopover?.performClose(nil)
  2895. self?.googleAccountPopover = nil
  2896. self?.performGoogleSignOut()
  2897. }
  2898. )
  2899. googleAccountPopover = popover
  2900. popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
  2901. }
  2902. private func performGoogleSignOut() {
  2903. do {
  2904. try googleOAuth.signOut()
  2905. applyGoogleProfile(nil)
  2906. scheduleDateHeadingLabel?.stringValue = "Connect Google to see meetings"
  2907. if let stack = scheduleCardsStack {
  2908. renderScheduleCards(into: stack, meetings: [])
  2909. }
  2910. } catch {
  2911. showSimpleError("Couldn’t logout Google account.", error: error)
  2912. }
  2913. }
  2914. private func updateGoogleAuthButtonTitle() {
  2915. let signedIn = (googleOAuth.loadTokens() != nil)
  2916. guard let button = scheduleGoogleAuthButton else { return }
  2917. let profileName = scheduleCurrentProfile?.name ?? "Google account"
  2918. let ringHostInset: CGFloat = signedIn ? 14 : 0
  2919. scheduleGoogleAuthHostPadWidthConstraint?.constant = ringHostInset
  2920. scheduleGoogleAuthHostPadHeightConstraint?.constant = ringHostInset
  2921. scheduleGoogleAuthHostView?.setAvatarRingMode(signedIn)
  2922. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: darkModeEnabled, accent: palette.primaryBlue)
  2923. if signedIn == false {
  2924. scheduleGoogleAuthHostView?.setProfileHoverActive(false)
  2925. }
  2926. if signedIn {
  2927. button.setAccessibilityLabel("\(profileName), Google account")
  2928. button.attributedTitle = NSAttributedString(string: "")
  2929. button.imagePosition = .imageOnly
  2930. button.imageScaling = .scaleProportionallyDown
  2931. button.symbolConfiguration = nil
  2932. scheduleGoogleAuthButtonHeightConstraint?.constant = scheduleGoogleSignedInAvatarSize
  2933. scheduleGoogleAuthButtonWidthConstraint?.constant = scheduleGoogleSignedInAvatarSize
  2934. button.layer?.cornerRadius = scheduleGoogleSignedInAvatarSize / 2
  2935. let symbol = NSImage(systemSymbolName: "person.crop.circle.fill", accessibilityDescription: "Profile")
  2936. if let symbol {
  2937. let sized = resizedImage(symbol, to: NSSize(width: scheduleGoogleSignedInAvatarSize, height: scheduleGoogleSignedInAvatarSize))
  2938. button.image = sized
  2939. button.contentTintColor = palette.textSecondary
  2940. } else {
  2941. button.image = nil
  2942. button.contentTintColor = nil
  2943. }
  2944. scheduleProfileMenuAvatar = button.image
  2945. } else {
  2946. button.setAccessibilityLabel("Sign in with Google")
  2947. let title = "Sign in with Google"
  2948. let titleFont = NSFont.systemFont(ofSize: 14, weight: .medium)
  2949. let titleColor = darkModeEnabled ? NSColor(calibratedWhite: 0.96, alpha: 1) : NSColor(calibratedRed: 0.13, green: 0.14, blue: 0.16, alpha: 1)
  2950. button.attributedTitle = NSAttributedString(string: title, attributes: [
  2951. .font: titleFont,
  2952. .foregroundColor: titleColor
  2953. ])
  2954. let textWidth = (title as NSString).size(withAttributes: [.font: titleFont]).width
  2955. let idealWidth = ceil(textWidth + 80)
  2956. scheduleGoogleAuthButtonWidthConstraint?.constant = min(320, max(188, idealWidth))
  2957. scheduleGoogleAuthButtonHeightConstraint?.constant = 42
  2958. button.layer?.cornerRadius = 21
  2959. button.imagePosition = .imageLeading
  2960. button.imageScaling = .scaleNone
  2961. if let g = NSImage(named: "GoogleGLogo") {
  2962. button.image = paddedTrailingImage(g, iconSize: NSSize(width: 22, height: 22), trailingPadding: 8)
  2963. } else {
  2964. button.image = nil
  2965. }
  2966. button.contentTintColor = nil
  2967. }
  2968. applyGoogleAuthButtonSurface()
  2969. }
  2970. private func makeGoogleProfileDisplay(from profile: GoogleUserProfile) -> GoogleProfileDisplay {
  2971. let cleanedName = profile.name?.trimmingCharacters(in: .whitespacesAndNewlines)
  2972. let cleanedEmail = profile.email?.trimmingCharacters(in: .whitespacesAndNewlines)
  2973. return GoogleProfileDisplay(
  2974. name: (cleanedName?.isEmpty == false ? cleanedName : nil) ?? "Google User",
  2975. email: (cleanedEmail?.isEmpty == false ? cleanedEmail : nil) ?? "Signed in",
  2976. pictureURL: profile.picture.flatMap(URL.init(string:))
  2977. )
  2978. }
  2979. private func applyGoogleProfile(_ profile: GoogleProfileDisplay?) {
  2980. scheduleProfileImageTask?.cancel()
  2981. scheduleProfileImageTask = nil
  2982. if profile == nil {
  2983. scheduleProfileMenuAvatar = nil
  2984. }
  2985. scheduleCurrentProfile = profile
  2986. updateGoogleAuthButtonTitle()
  2987. guard let profile, let pictureURL = profile.pictureURL else { return }
  2988. let avatarDiameter = scheduleGoogleSignedInAvatarSize
  2989. scheduleProfileImageTask = Task { [weak self] in
  2990. do {
  2991. let (data, _) = try await URLSession.shared.data(from: pictureURL)
  2992. if Task.isCancelled { return }
  2993. guard let image = NSImage(data: data) else { return }
  2994. await MainActor.run { [weak self] in
  2995. guard let self else { return }
  2996. let rounded = self.circularProfileImage(image, diameter: avatarDiameter)
  2997. self.scheduleProfileMenuAvatar = circularNSImage(rounded, diameter: 48)
  2998. self.scheduleGoogleAuthButton?.image = rounded
  2999. self.scheduleGoogleAuthButton?.contentTintColor = nil
  3000. }
  3001. } catch {
  3002. // Keep placeholder avatar if image fetch fails.
  3003. }
  3004. }
  3005. }
  3006. private func resizedImage(_ image: NSImage, to size: NSSize) -> NSImage {
  3007. let result = NSImage(size: size)
  3008. result.lockFocus()
  3009. image.draw(in: NSRect(origin: .zero, size: size),
  3010. from: NSRect(origin: .zero, size: image.size),
  3011. operation: .copy,
  3012. fraction: 1.0)
  3013. result.unlockFocus()
  3014. result.isTemplate = false
  3015. return result
  3016. }
  3017. /// Clips a photo to a circle for the signed-in avatar (Google userinfo `picture` URLs are usually square).
  3018. private func circularProfileImage(_ image: NSImage, diameter: CGFloat) -> NSImage {
  3019. circularNSImage(image, diameter: diameter)
  3020. }
  3021. private func paddedTrailingImage(_ image: NSImage, iconSize: NSSize, trailingPadding: CGFloat) -> NSImage {
  3022. let base = resizedImage(image, to: iconSize)
  3023. let canvas = NSSize(width: iconSize.width + trailingPadding, height: iconSize.height)
  3024. let result = NSImage(size: canvas)
  3025. result.lockFocus()
  3026. base.draw(in: NSRect(x: 0, y: 0, width: iconSize.width, height: iconSize.height),
  3027. from: NSRect(origin: .zero, size: base.size),
  3028. operation: .copy,
  3029. fraction: 1.0)
  3030. result.unlockFocus()
  3031. result.isTemplate = false
  3032. return result
  3033. }
  3034. private func applyGoogleAuthButtonSurface() {
  3035. guard let button = scheduleGoogleAuthButton else { return }
  3036. let signedIn = (googleOAuth.loadTokens() != nil)
  3037. let isDark = darkModeEnabled
  3038. if signedIn {
  3039. button.layer?.backgroundColor = NSColor.clear.cgColor
  3040. button.layer?.borderWidth = 0
  3041. scheduleGoogleAuthHostView?.updateRingAppearance(isDark: isDark, accent: palette.primaryBlue)
  3042. return
  3043. }
  3044. let baseBackground = isDark
  3045. ? NSColor(calibratedRed: 8.0 / 255.0, green: 14.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3046. : NSColor.white
  3047. let hoverBlend = isDark ? NSColor.white : NSColor.black
  3048. let hoverBackground = baseBackground.blended(withFraction: 0.07, of: hoverBlend) ?? baseBackground
  3049. let baseBorder = isDark
  3050. ? NSColor(calibratedWhite: 0.50, alpha: 1)
  3051. : NSColor(calibratedWhite: 0.72, alpha: 1)
  3052. let hoverBorder = isDark
  3053. ? NSColor(calibratedWhite: 0.62, alpha: 1)
  3054. : NSColor(calibratedWhite: 0.56, alpha: 1)
  3055. button.layer?.borderWidth = 1
  3056. button.layer?.backgroundColor = (scheduleGoogleAuthHovering ? hoverBackground : baseBackground).cgColor
  3057. button.layer?.borderColor = (scheduleGoogleAuthHovering ? hoverBorder : baseBorder).cgColor
  3058. }
  3059. @MainActor
  3060. func ensureGoogleClientIdConfigured(presentingWindow: NSWindow?) async throws {
  3061. if googleOAuth.configuredClientId() != nil, googleOAuth.configuredClientSecret() != nil { return }
  3062. let alert = NSAlert()
  3063. alert.messageText = "Enter Google OAuth credentials"
  3064. alert.informativeText = "Paste the OAuth Client ID and Client Secret from your downloaded Desktop OAuth JSON."
  3065. let accessory = NSStackView()
  3066. accessory.orientation = .vertical
  3067. accessory.spacing = 8
  3068. accessory.alignment = .leading
  3069. let idField = NSTextField(string: googleOAuth.configuredClientId() ?? "")
  3070. idField.placeholderString = "Client ID (....apps.googleusercontent.com)"
  3071. idField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  3072. let secretField = NSSecureTextField(string: googleOAuth.configuredClientSecret() ?? "")
  3073. secretField.placeholderString = "Client Secret (GOCSPX-...)"
  3074. secretField.frame = NSRect(x: 0, y: 0, width: 460, height: 24)
  3075. accessory.addArrangedSubview(idField)
  3076. accessory.addArrangedSubview(secretField)
  3077. alert.accessoryView = accessory
  3078. alert.addButton(withTitle: "Save")
  3079. alert.addButton(withTitle: "Cancel")
  3080. // Keep this synchronous to avoid additional sheet state handling.
  3081. let response = alert.runModal()
  3082. if response != .alertFirstButtonReturn { throw GoogleOAuthError.missingClientId }
  3083. let idValue = idField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3084. let secretValue = secretField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  3085. if idValue.isEmpty { throw GoogleOAuthError.missingClientId }
  3086. if secretValue.isEmpty { throw GoogleOAuthError.missingClientSecret }
  3087. googleOAuth.setClientIdForTesting(idValue)
  3088. googleOAuth.setClientSecretForTesting(secretValue)
  3089. }
  3090. func showSimpleError(_ title: String, error: Error) {
  3091. DispatchQueue.main.async {
  3092. let alert = NSAlert()
  3093. alert.alertStyle = .warning
  3094. alert.messageText = title
  3095. alert.informativeText = error.localizedDescription
  3096. alert.addButton(withTitle: "OK")
  3097. alert.runModal()
  3098. }
  3099. }
  3100. }
  3101. private struct Palette {
  3102. let pageBackground: NSColor
  3103. let sidebarBackground: NSColor
  3104. let sectionCard: NSColor
  3105. let tabBarBackground: NSColor
  3106. let tabIdleBackground: NSColor
  3107. let inputBackground: NSColor
  3108. let inputBorder: NSColor
  3109. let primaryBlue: NSColor
  3110. let primaryBlueBorder: NSColor
  3111. let cancelButton: NSColor
  3112. let meetingBadge: NSColor
  3113. let separator: NSColor
  3114. let textPrimary: NSColor
  3115. let textSecondary: NSColor
  3116. let textTertiary: NSColor
  3117. let textMuted: NSColor
  3118. init(isDarkMode: Bool) {
  3119. if isDarkMode {
  3120. pageBackground = NSColor(calibratedRed: 10.0 / 255.0, green: 11.0 / 255.0, blue: 12.0 / 255.0, alpha: 1)
  3121. sidebarBackground = NSColor(calibratedRed: 16.0 / 255.0, green: 17.0 / 255.0, blue: 19.0 / 255.0, alpha: 1)
  3122. sectionCard = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3123. tabBarBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3124. tabIdleBackground = NSColor(calibratedRed: 22.0 / 255.0, green: 23.0 / 255.0, blue: 26.0 / 255.0, alpha: 1)
  3125. inputBackground = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3126. inputBorder = NSColor(calibratedRed: 38.0 / 255.0, green: 40.0 / 255.0, blue: 44.0 / 255.0, alpha: 1)
  3127. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  3128. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  3129. cancelButton = NSColor(calibratedRed: 20.0 / 255.0, green: 21.0 / 255.0, blue: 24.0 / 255.0, alpha: 1)
  3130. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  3131. separator = NSColor(calibratedRed: 26.0 / 255.0, green: 27.0 / 255.0, blue: 30.0 / 255.0, alpha: 1)
  3132. textPrimary = NSColor(calibratedWhite: 0.98, alpha: 1)
  3133. textSecondary = NSColor(calibratedWhite: 0.78, alpha: 1)
  3134. textTertiary = NSColor(calibratedWhite: 0.66, alpha: 1)
  3135. textMuted = NSColor(calibratedWhite: 0.44, alpha: 1)
  3136. } else {
  3137. pageBackground = NSColor(calibratedRed: 244.0 / 255.0, green: 246.0 / 255.0, blue: 249.0 / 255.0, alpha: 1)
  3138. sidebarBackground = NSColor(calibratedRed: 232.0 / 255.0, green: 236.0 / 255.0, blue: 242.0 / 255.0, alpha: 1)
  3139. sectionCard = NSColor.white
  3140. tabBarBackground = NSColor.white
  3141. tabIdleBackground = NSColor.white
  3142. inputBackground = NSColor(calibratedRed: 247.0 / 255.0, green: 249.0 / 255.0, blue: 252.0 / 255.0, alpha: 1)
  3143. inputBorder = NSColor(calibratedRed: 211.0 / 255.0, green: 218.0 / 255.0, blue: 228.0 / 255.0, alpha: 1)
  3144. primaryBlue = NSColor(calibratedRed: 27.0 / 255.0, green: 115.0 / 255.0, blue: 232.0 / 255.0, alpha: 1)
  3145. primaryBlueBorder = NSColor(calibratedRed: 42.0 / 255.0, green: 118.0 / 255.0, blue: 220.0 / 255.0, alpha: 1)
  3146. cancelButton = NSColor(calibratedRed: 240.0 / 255.0, green: 243.0 / 255.0, blue: 248.0 / 255.0, alpha: 1)
  3147. meetingBadge = NSColor(calibratedRed: 0.88, green: 0.66, blue: 0.14, alpha: 1)
  3148. separator = NSColor(calibratedRed: 212.0 / 255.0, green: 219.0 / 255.0, blue: 229.0 / 255.0, alpha: 1)
  3149. textPrimary = NSColor(calibratedRed: 32.0 / 255.0, green: 38.0 / 255.0, blue: 47.0 / 255.0, alpha: 1)
  3150. textSecondary = NSColor(calibratedRed: 82.0 / 255.0, green: 92.0 / 255.0, blue: 107.0 / 255.0, alpha: 1)
  3151. textTertiary = NSColor(calibratedRed: 110.0 / 255.0, green: 120.0 / 255.0, blue: 136.0 / 255.0, alpha: 1)
  3152. textMuted = NSColor(calibratedRed: 134.0 / 255.0, green: 145.0 / 255.0, blue: 162.0 / 255.0, alpha: 1)
  3153. }
  3154. }
  3155. }
  3156. private struct Typography {
  3157. let sidebarBrand = NSFont.systemFont(ofSize: 26, weight: .bold)
  3158. let sidebarSection = NSFont.systemFont(ofSize: 11, weight: .medium)
  3159. let sidebarIcon = NSFont.systemFont(ofSize: 12, weight: .medium)
  3160. let sidebarItem = NSFont.systemFont(ofSize: 16, weight: .medium)
  3161. let pageTitle = NSFont.systemFont(ofSize: 27, weight: .semibold)
  3162. let joinWithURLTitle = NSFont.systemFont(ofSize: 17, weight: .semibold)
  3163. let sectionTitleBold = NSFont.systemFont(ofSize: 25, weight: .bold)
  3164. let dateHeading = NSFont.systemFont(ofSize: 18, weight: .medium)
  3165. let tabIcon = NSFont.systemFont(ofSize: 13, weight: .regular)
  3166. let tabTitle = NSFont.systemFont(ofSize: 31 / 2, weight: .semibold)
  3167. let fieldLabel = NSFont.systemFont(ofSize: 15, weight: .medium)
  3168. let inputPlaceholder = NSFont.systemFont(ofSize: 14, weight: .regular)
  3169. let buttonText = NSFont.systemFont(ofSize: 16, weight: .medium)
  3170. let filterText = NSFont.systemFont(ofSize: 15, weight: .regular)
  3171. let filterArrow = NSFont.systemFont(ofSize: 12, weight: .regular)
  3172. let iconButton = NSFont.systemFont(ofSize: 14, weight: .medium)
  3173. let cardIcon = NSFont.systemFont(ofSize: 8, weight: .bold)
  3174. let cardTitle = NSFont.systemFont(ofSize: 15, weight: .semibold)
  3175. let cardSubtitle = NSFont.systemFont(ofSize: 13, weight: .bold)
  3176. let cardTime = NSFont.systemFont(ofSize: 12, weight: .regular)
  3177. }
  3178. // MARK: - In-app browser (macOS WKWebView + chrome)
  3179. // Note: This target is AppKit/macOS. iOS would use WKWebView or SFSafariViewController; Android would use WebView or Custom Tabs.
  3180. private enum InAppBrowserURLPolicy: Equatable {
  3181. case allowAll
  3182. case whitelist(hostSuffixes: [String])
  3183. }
  3184. private func inAppBrowserURLAllowed(_ url: URL, policy: InAppBrowserURLPolicy) -> Bool {
  3185. let scheme = (url.scheme ?? "").lowercased()
  3186. if scheme == "about" { return true }
  3187. guard scheme == "http" || scheme == "https" else { return false }
  3188. guard let host = url.host?.lowercased() else { return false }
  3189. switch policy {
  3190. case .allowAll:
  3191. return true
  3192. case .whitelist(let suffixes):
  3193. for suffix in suffixes {
  3194. let s = suffix.lowercased()
  3195. if host == s || host.hasSuffix("." + s) { return true }
  3196. }
  3197. return false
  3198. }
  3199. }
  3200. private enum InAppBrowserWebKitSupport {
  3201. static func makeWebViewConfiguration() -> WKWebViewConfiguration {
  3202. let config = WKWebViewConfiguration()
  3203. config.websiteDataStore = .default()
  3204. config.preferences.javaScriptCanOpenWindowsAutomatically = true
  3205. if #available(macOS 12.3, *) {
  3206. config.preferences.isElementFullscreenEnabled = true
  3207. }
  3208. config.mediaTypesRequiringUserActionForPlayback = []
  3209. if #available(macOS 11.0, *) {
  3210. config.defaultWebpagePreferences.allowsContentJavaScript = true
  3211. }
  3212. config.applicationNameForUserAgent = "MeetingsApp/1.0"
  3213. return config
  3214. }
  3215. }
  3216. private final class InAppBrowserWindowController: NSWindowController {
  3217. private static let defaultContentSize = NSSize(width: 1100, height: 760)
  3218. private static let minimumContentSize = NSSize(width: 800, height: 520)
  3219. private let browserViewController = InAppBrowserContainerViewController()
  3220. init() {
  3221. let browserWindow = NSWindow(
  3222. contentRect: NSRect(origin: .zero, size: Self.defaultContentSize),
  3223. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  3224. backing: .buffered,
  3225. defer: false
  3226. )
  3227. browserWindow.title = "Browser"
  3228. browserWindow.isRestorable = false
  3229. browserWindow.setFrameAutosaveName("")
  3230. browserWindow.minSize = browserWindow.frameRect(forContentRect: NSRect(origin: .zero, size: Self.minimumContentSize)).size
  3231. browserWindow.center()
  3232. browserWindow.contentViewController = browserViewController
  3233. super.init(window: browserWindow)
  3234. }
  3235. @available(*, unavailable)
  3236. required init?(coder: NSCoder) {
  3237. nil
  3238. }
  3239. /// Resets size and position each time the browser is shown so a previously tiny window is never reused.
  3240. func applyDefaultFrameCenteredOnVisibleScreen() {
  3241. guard let w = window, let screen = w.screen ?? NSScreen.main else { return }
  3242. let windowFrame = w.frameRect(forContentRect: NSRect(origin: .zero, size: Self.defaultContentSize))
  3243. let vf = screen.visibleFrame
  3244. var frame = windowFrame
  3245. frame.origin.x = vf.midX - frame.width / 2
  3246. frame.origin.y = vf.midY - frame.height / 2
  3247. if frame.maxX > vf.maxX { frame.origin.x = vf.maxX - frame.width }
  3248. if frame.minX < vf.minX { frame.origin.x = vf.minX }
  3249. if frame.maxY > vf.maxY { frame.origin.y = vf.maxY - frame.height }
  3250. if frame.minY < vf.minY { frame.origin.y = vf.minY }
  3251. w.setFrame(frame, display: true)
  3252. }
  3253. func load(url: URL, policy: InAppBrowserURLPolicy) {
  3254. browserViewController.setNavigationPolicy(policy)
  3255. browserViewController.load(url: url)
  3256. }
  3257. }
  3258. private final class InAppBrowserContainerViewController: NSViewController, WKNavigationDelegate, WKUIDelegate, NSTextFieldDelegate {
  3259. private var webView: WKWebView!
  3260. private var webContainerView: NSView!
  3261. private weak var urlField: NSTextField?
  3262. private var backButton: NSButton!
  3263. private var forwardButton: NSButton!
  3264. private var reloadStopButton: NSButton!
  3265. private var goButton: NSButton!
  3266. private var progressBar: NSProgressIndicator!
  3267. private var lastLoadedURL: URL?
  3268. private var navigationPolicy: InAppBrowserURLPolicy = .allowAll
  3269. private var processTerminateRetryCount = 0
  3270. /// Includes fresh WKWebView instances so each retry gets a new WebContent process after a crash.
  3271. private let maxProcessTerminateRetries = 3
  3272. private var kvoTokens: [NSKeyValueObservation] = []
  3273. deinit {
  3274. kvoTokens.removeAll()
  3275. }
  3276. func setNavigationPolicy(_ policy: InAppBrowserURLPolicy) {
  3277. navigationPolicy = policy
  3278. }
  3279. override func loadView() {
  3280. let root = NSView()
  3281. root.translatesAutoresizingMaskIntoConstraints = false
  3282. let wv = makeWebView()
  3283. webView = wv
  3284. let webHost = NSView()
  3285. webHost.translatesAutoresizingMaskIntoConstraints = false
  3286. webHost.wantsLayer = true
  3287. webHost.addSubview(wv)
  3288. NSLayoutConstraint.activate([
  3289. wv.leadingAnchor.constraint(equalTo: webHost.leadingAnchor),
  3290. wv.trailingAnchor.constraint(equalTo: webHost.trailingAnchor),
  3291. wv.topAnchor.constraint(equalTo: webHost.topAnchor),
  3292. wv.bottomAnchor.constraint(equalTo: webHost.bottomAnchor)
  3293. ])
  3294. webContainerView = webHost
  3295. let toolbar = NSStackView()
  3296. toolbar.translatesAutoresizingMaskIntoConstraints = false
  3297. toolbar.orientation = .horizontal
  3298. toolbar.spacing = 8
  3299. toolbar.alignment = .centerY
  3300. toolbar.edgeInsets = NSEdgeInsets(top: 6, left: 10, bottom: 6, right: 10)
  3301. backButton = makeToolbarButton(title: "◀", symbolName: "chevron.backward", accessibilityDescription: "Back")
  3302. backButton.target = self
  3303. backButton.action = #selector(goBack)
  3304. forwardButton = makeToolbarButton(title: "▶", symbolName: "chevron.forward", accessibilityDescription: "Forward")
  3305. forwardButton.target = self
  3306. forwardButton.action = #selector(goForward)
  3307. reloadStopButton = makeToolbarButton(title: "Reload", symbolName: "arrow.clockwise", accessibilityDescription: "Reload")
  3308. reloadStopButton.target = self
  3309. reloadStopButton.action = #selector(reloadOrStop)
  3310. let field = NSTextField(string: "")
  3311. field.translatesAutoresizingMaskIntoConstraints = false
  3312. field.font = NSFont.systemFont(ofSize: 13, weight: .regular)
  3313. field.placeholderString = "Address"
  3314. field.cell?.sendsActionOnEndEditing = false
  3315. field.delegate = self
  3316. urlField = field
  3317. goButton = NSButton(title: "Go", target: self, action: #selector(addressFieldSubmitted))
  3318. goButton.translatesAutoresizingMaskIntoConstraints = false
  3319. goButton.bezelStyle = .rounded
  3320. toolbar.addArrangedSubview(backButton)
  3321. toolbar.addArrangedSubview(forwardButton)
  3322. toolbar.addArrangedSubview(reloadStopButton)
  3323. toolbar.addArrangedSubview(field)
  3324. toolbar.addArrangedSubview(goButton)
  3325. field.widthAnchor.constraint(greaterThanOrEqualToConstant: 240).isActive = true
  3326. let bar = NSProgressIndicator()
  3327. bar.translatesAutoresizingMaskIntoConstraints = false
  3328. bar.style = .bar
  3329. bar.isIndeterminate = false
  3330. bar.minValue = 0
  3331. bar.maxValue = 1
  3332. bar.doubleValue = 0
  3333. bar.isHidden = true
  3334. progressBar = bar
  3335. let separator = NSBox()
  3336. separator.translatesAutoresizingMaskIntoConstraints = false
  3337. separator.boxType = .separator
  3338. webView.navigationDelegate = self
  3339. webView.uiDelegate = self
  3340. root.addSubview(toolbar)
  3341. root.addSubview(bar)
  3342. root.addSubview(separator)
  3343. root.addSubview(webHost)
  3344. NSLayoutConstraint.activate([
  3345. toolbar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3346. toolbar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3347. toolbar.topAnchor.constraint(equalTo: root.topAnchor),
  3348. bar.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3349. bar.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3350. bar.topAnchor.constraint(equalTo: toolbar.bottomAnchor),
  3351. bar.heightAnchor.constraint(equalToConstant: 3),
  3352. separator.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3353. separator.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3354. separator.topAnchor.constraint(equalTo: bar.bottomAnchor),
  3355. webHost.leadingAnchor.constraint(equalTo: root.leadingAnchor),
  3356. webHost.trailingAnchor.constraint(equalTo: root.trailingAnchor),
  3357. webHost.topAnchor.constraint(equalTo: separator.bottomAnchor),
  3358. webHost.bottomAnchor.constraint(equalTo: root.bottomAnchor)
  3359. ])
  3360. view = root
  3361. installWebViewObservers()
  3362. syncToolbarFromWebView()
  3363. }
  3364. private func makeWebView() -> WKWebView {
  3365. let wv = WKWebView(frame: .zero, configuration: InAppBrowserWebKitSupport.makeWebViewConfiguration())
  3366. wv.translatesAutoresizingMaskIntoConstraints = false
  3367. return wv
  3368. }
  3369. private func teardownWebViewObservers() {
  3370. kvoTokens.removeAll()
  3371. }
  3372. /// New `WKWebView` = new WebContent process (helps after GPU/JS crashes on heavy sites like Meet).
  3373. private func replaceWebViewAndLoad(url: URL) {
  3374. teardownWebViewObservers()
  3375. webView.navigationDelegate = nil
  3376. webView.uiDelegate = nil
  3377. webView.removeFromSuperview()
  3378. let wv = makeWebView()
  3379. webView = wv
  3380. webContainerView.addSubview(wv)
  3381. NSLayoutConstraint.activate([
  3382. wv.leadingAnchor.constraint(equalTo: webContainerView.leadingAnchor),
  3383. wv.trailingAnchor.constraint(equalTo: webContainerView.trailingAnchor),
  3384. wv.topAnchor.constraint(equalTo: webContainerView.topAnchor),
  3385. wv.bottomAnchor.constraint(equalTo: webContainerView.bottomAnchor)
  3386. ])
  3387. webView.navigationDelegate = self
  3388. webView.uiDelegate = self
  3389. installWebViewObservers()
  3390. syncToolbarFromWebView()
  3391. webView.load(URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData))
  3392. }
  3393. private func makeToolbarButton(title: String, symbolName: String, accessibilityDescription: String) -> NSButton {
  3394. let b = NSButton()
  3395. b.translatesAutoresizingMaskIntoConstraints = false
  3396. b.bezelStyle = .texturedRounded
  3397. b.setAccessibilityLabel(accessibilityDescription)
  3398. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibilityDescription) {
  3399. b.image = img
  3400. b.imagePosition = .imageOnly
  3401. } else {
  3402. b.title = title
  3403. }
  3404. b.widthAnchor.constraint(greaterThanOrEqualToConstant: 32).isActive = true
  3405. return b
  3406. }
  3407. private func installWebViewObservers() {
  3408. kvoTokens.append(webView.observe(\.canGoBack, options: [.new]) { [weak self] _, _ in
  3409. self?.syncToolbarFromWebView()
  3410. })
  3411. kvoTokens.append(webView.observe(\.canGoForward, options: [.new]) { [weak self] _, _ in
  3412. self?.syncToolbarFromWebView()
  3413. })
  3414. kvoTokens.append(webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
  3415. self?.syncToolbarFromWebView()
  3416. })
  3417. kvoTokens.append(webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, _ in
  3418. self?.syncProgressFromWebView()
  3419. })
  3420. kvoTokens.append(webView.observe(\.title, options: [.new]) { [weak self] _, _ in
  3421. self?.syncWindowTitle()
  3422. })
  3423. kvoTokens.append(webView.observe(\.url, options: [.new]) { [weak self] _, _ in
  3424. self?.syncAddressFieldFromWebView()
  3425. })
  3426. }
  3427. private func syncToolbarFromWebView() {
  3428. backButton?.isEnabled = webView.canGoBack
  3429. forwardButton?.isEnabled = webView.canGoForward
  3430. if webView.isLoading {
  3431. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "xmark", accessibilityDescription: "Stop") {
  3432. reloadStopButton.image = img
  3433. reloadStopButton.imagePosition = .imageOnly
  3434. reloadStopButton.title = ""
  3435. } else {
  3436. reloadStopButton.title = "Stop"
  3437. }
  3438. } else {
  3439. if #available(macOS 11.0, *), let img = NSImage(systemSymbolName: "arrow.clockwise", accessibilityDescription: "Reload") {
  3440. reloadStopButton.image = img
  3441. reloadStopButton.imagePosition = .imageOnly
  3442. reloadStopButton.title = ""
  3443. } else {
  3444. reloadStopButton.title = "Reload"
  3445. }
  3446. }
  3447. syncProgressFromWebView()
  3448. }
  3449. private func syncProgressFromWebView() {
  3450. guard let progressBar else { return }
  3451. if webView.isLoading {
  3452. progressBar.isHidden = false
  3453. progressBar.doubleValue = webView.estimatedProgress
  3454. } else {
  3455. progressBar.isHidden = true
  3456. progressBar.doubleValue = 0
  3457. }
  3458. }
  3459. private func syncAddressFieldFromWebView() {
  3460. guard let urlField, urlField.currentEditor() == nil, let url = webView.url else { return }
  3461. urlField.stringValue = url.absoluteString
  3462. }
  3463. private func syncWindowTitle() {
  3464. let t = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  3465. let host = webView.url?.host ?? ""
  3466. view.window?.title = t.isEmpty ? (host.isEmpty ? "Browser" : host) : t
  3467. }
  3468. func load(url: URL) {
  3469. lastLoadedURL = url
  3470. processTerminateRetryCount = 0
  3471. urlField?.stringValue = url.absoluteString
  3472. webView.load(URLRequest(url: url))
  3473. syncWindowTitle()
  3474. }
  3475. @objc private func goBack() {
  3476. webView.goBack()
  3477. }
  3478. @objc private func goForward() {
  3479. webView.goForward()
  3480. }
  3481. @objc private func reloadOrStop() {
  3482. if webView.isLoading {
  3483. webView.stopLoading()
  3484. } else {
  3485. webView.reload()
  3486. }
  3487. }
  3488. @objc private func addressFieldSubmitted() {
  3489. let raw = urlField?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  3490. guard raw.isEmpty == false else { return }
  3491. var normalized = raw
  3492. if normalized.lowercased().hasPrefix("http://") == false && normalized.lowercased().hasPrefix("https://") == false {
  3493. normalized = "https://\(normalized)"
  3494. }
  3495. guard let url = URL(string: normalized),
  3496. let scheme = url.scheme?.lowercased(),
  3497. scheme == "http" || scheme == "https",
  3498. url.host != nil
  3499. else {
  3500. let alert = NSAlert()
  3501. alert.messageText = "Invalid address"
  3502. alert.informativeText = "Enter a valid web address, for example https://example.com"
  3503. alert.addButton(withTitle: "OK")
  3504. alert.runModal()
  3505. return
  3506. }
  3507. guard inAppBrowserURLAllowed(url, policy: navigationPolicy) else {
  3508. presentBlockedHostAlert()
  3509. return
  3510. }
  3511. load(url: url)
  3512. }
  3513. private func presentBlockedHostAlert() {
  3514. let alert = NSAlert()
  3515. alert.messageText = "Address not allowed"
  3516. alert.informativeText = "This URL is not permitted with the current in-app browser policy (whitelist)."
  3517. alert.addButton(withTitle: "OK")
  3518. alert.runModal()
  3519. }
  3520. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  3521. processTerminateRetryCount = 0
  3522. syncAddressFieldFromWebView()
  3523. syncWindowTitle()
  3524. }
  3525. func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
  3526. let nsError = error as NSError
  3527. if nsError.code == NSURLErrorCancelled {
  3528. return
  3529. }
  3530. let alert = NSAlert()
  3531. alert.messageText = "Unable to load page"
  3532. alert.informativeText = "Could not load this page in the in-app browser.\n\n\(error.localizedDescription)"
  3533. alert.addButton(withTitle: "Try Again")
  3534. alert.addButton(withTitle: "OK")
  3535. if alert.runModal() == .alertFirstButtonReturn, let url = lastLoadedURL {
  3536. processTerminateRetryCount = 0
  3537. webView.load(URLRequest(url: url))
  3538. }
  3539. }
  3540. func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
  3541. guard let url = lastLoadedURL else { return }
  3542. if processTerminateRetryCount < maxProcessTerminateRetries {
  3543. processTerminateRetryCount += 1
  3544. replaceWebViewAndLoad(url: url)
  3545. return
  3546. }
  3547. let alert = NSAlert()
  3548. alert.messageText = "Page stopped loading"
  3549. alert.informativeText =
  3550. "The in-app browser closed this page unexpectedly. You can try loading it again in this same window."
  3551. alert.addButton(withTitle: "Try Again")
  3552. alert.addButton(withTitle: "OK")
  3553. if alert.runModal() == .alertFirstButtonReturn {
  3554. processTerminateRetryCount = 0
  3555. replaceWebViewAndLoad(url: url)
  3556. }
  3557. }
  3558. func webView(
  3559. _ webView: WKWebView,
  3560. decidePolicyFor navigationAction: WKNavigationAction,
  3561. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  3562. ) {
  3563. guard let url = navigationAction.request.url else {
  3564. decisionHandler(.allow)
  3565. return
  3566. }
  3567. let scheme = (url.scheme ?? "").lowercased()
  3568. if scheme == "mailto" || scheme == "tel" {
  3569. decisionHandler(.cancel)
  3570. return
  3571. }
  3572. if inAppBrowserURLAllowed(url, policy: navigationPolicy) == false {
  3573. if navigationAction.targetFrame?.isMainFrame != false {
  3574. DispatchQueue.main.async { [weak self] in
  3575. self?.presentBlockedHostAlert()
  3576. }
  3577. }
  3578. decisionHandler(.cancel)
  3579. return
  3580. }
  3581. decisionHandler(.allow)
  3582. }
  3583. func webView(
  3584. _ webView: WKWebView,
  3585. createWebViewWith configuration: WKWebViewConfiguration,
  3586. for navigationAction: WKNavigationAction,
  3587. windowFeatures: WKWindowFeatures
  3588. ) -> WKWebView? {
  3589. if navigationAction.targetFrame == nil, let requestURL = navigationAction.request.url {
  3590. if inAppBrowserURLAllowed(requestURL, policy: navigationPolicy) {
  3591. webView.load(URLRequest(url: requestURL))
  3592. } else {
  3593. presentBlockedHostAlert()
  3594. }
  3595. }
  3596. return nil
  3597. }
  3598. func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
  3599. if control === urlField, commandSelector == #selector(NSResponder.insertNewline(_:)) {
  3600. addressFieldSubmitted()
  3601. return true
  3602. }
  3603. return false
  3604. }
  3605. }