Sen descrición

ViewController.swift 126KB

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