Brak opisu

ViewController.swift 121KB

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