Geen omschrijving

ViewController.swift 151KB

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