InAppBrowserWindow.swift 41 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061
  1. import AppKit
  2. import SwiftUI
  3. import WebKit
  4. /// Titlebar icon control: white/neutral tint (no accent blue), subtle hover and selected backgrounds.
  5. private final class TitlebarIconButton: NSButton {
  6. private var isHovering = false
  7. private var hoverTrackingArea: NSTrackingArea?
  8. var isToggleActive = false
  9. /// Looks disabled (gray) but keeps `isEnabled == true` so the control still receives clicks (e.g. paywall).
  10. var lockedForPaywallAppearance = false {
  11. didSet { refreshVisuals() }
  12. }
  13. override func awakeFromNib() {
  14. super.awakeFromNib()
  15. commonInit()
  16. }
  17. override init(frame frameRect: NSRect) {
  18. super.init(frame: frameRect)
  19. commonInit()
  20. }
  21. @available(*, unavailable)
  22. required init?(coder: NSCoder) {
  23. nil
  24. }
  25. private func commonInit() {
  26. wantsLayer = true
  27. layer?.cornerRadius = 5
  28. // Do not mask the button’s bounds to the rounded rect: on a ~10×10 pt control,
  29. // cornerRadius 5 + masksToBounds clips to a *circle* and cuts off corner-heavy
  30. // bitmap icons (e.g. layout grid). SF Symbols stay fine due to built-in padding.
  31. layer?.masksToBounds = false
  32. }
  33. override func updateTrackingAreas() {
  34. super.updateTrackingAreas()
  35. // Do NOT remove all tracking areas here: AppKit uses its own tracking area for tooltips.
  36. // We only manage our own hover tracking area.
  37. if let hoverTrackingArea {
  38. removeTrackingArea(hoverTrackingArea)
  39. }
  40. let area = NSTrackingArea(
  41. rect: bounds,
  42. options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
  43. owner: self,
  44. userInfo: nil
  45. )
  46. hoverTrackingArea = area
  47. addTrackingArea(area)
  48. }
  49. override func mouseEntered(with event: NSEvent) {
  50. isHovering = true
  51. refreshVisuals()
  52. }
  53. override func mouseExited(with event: NSEvent) {
  54. isHovering = false
  55. refreshVisuals()
  56. }
  57. override var isEnabled: Bool {
  58. didSet { refreshVisuals() }
  59. }
  60. func setToggleActive(_ on: Bool) {
  61. isToggleActive = on
  62. refreshVisuals()
  63. }
  64. func refreshVisuals() {
  65. let white = NSColor.white
  66. if !isEnabled || lockedForPaywallAppearance {
  67. contentTintColor = white.withAlphaComponent(0.35)
  68. layer?.backgroundColor = NSColor.clear.cgColor
  69. return
  70. }
  71. if isToggleActive {
  72. contentTintColor = white
  73. let bg = isHovering ? white.withAlphaComponent(0.22) : white.withAlphaComponent(0.16)
  74. layer?.backgroundColor = bg.cgColor
  75. return
  76. }
  77. if isHovering {
  78. contentTintColor = white
  79. layer?.backgroundColor = white.withAlphaComponent(0.12).cgColor
  80. } else {
  81. contentTintColor = white.withAlphaComponent(0.88)
  82. layer?.backgroundColor = NSColor.clear.cgColor
  83. }
  84. }
  85. }
  86. /// Scales a bundled template image to a logical point size for the titlebar (SF Symbols here use 12pt; bitmap icons often need ~14pt to read the same optically).
  87. private func makeTitlebarSizedTemplateImage(named name: String, pointSize: CGFloat, layoutMarginFraction: CGFloat = 0) -> NSImage? {
  88. guard let source = NSImage(named: name) else { return nil }
  89. let logicalSize = NSSize(width: pointSize, height: pointSize)
  90. let image = NSImage(size: logicalSize, flipped: false) { rect in
  91. NSGraphicsContext.current?.imageInterpolation = .high
  92. let inset = min(rect.width, rect.height) * layoutMarginFraction
  93. let drawRect = rect.insetBy(dx: inset, dy: inset)
  94. source.draw(
  95. in: drawRect,
  96. from: NSRect(origin: .zero, size: source.size),
  97. operation: .sourceOver,
  98. fraction: 1.0
  99. )
  100. return true
  101. }
  102. image.isTemplate = true
  103. return image
  104. }
  105. final class InAppBrowserWindowManager {
  106. static let shared = InAppBrowserWindowManager()
  107. private var controllers: [InAppBrowserWindowController] = []
  108. private init() {}
  109. func open(url: URL, title: String? = nil, injectComposerPrompt: String? = nil) {
  110. let controller = InAppBrowserWindowController(
  111. initialURL: url,
  112. windowTitle: title,
  113. injectComposerPrompt: injectComposerPrompt
  114. )
  115. controllers.append(controller)
  116. controller.onClose = { [weak self, weak controller] in
  117. guard let self, let controller else { return }
  118. self.controllers.removeAll { $0 === controller }
  119. }
  120. controller.showWindow(nil)
  121. controller.window?.makeKeyAndOrderFront(nil)
  122. }
  123. }
  124. final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
  125. var onClose: (() -> Void)?
  126. private let webView: WKWebView
  127. private let initialURL: URL
  128. /// When opening Gemini from the widget, filled into the page composer after load (Gemini ignores URL query params).
  129. private let injectComposerPrompt: String?
  130. private var geminiPromptWasInjected = false
  131. private var geminiInjectionIsActive = false
  132. private var didLoadInitialURL = false
  133. // Titlebar controls
  134. private var backButton: TitlebarIconButton!
  135. private var forwardButton: TitlebarIconButton!
  136. private var reloadButton: TitlebarIconButton!
  137. private var pinButton: TitlebarIconButton!
  138. private var homeButton: TitlebarIconButton!
  139. private var addToStatusBarButton: TitlebarIconButton!
  140. private var widgetButton: TitlebarIconButton!
  141. private var addToDesktopButton: TitlebarIconButton!
  142. private var handButton: TitlebarIconButton!
  143. private var downloadButton: TitlebarIconButton!
  144. private var moreButton: TitlebarIconButton!
  145. private var siteNameLabel: NSTextField!
  146. private var siteIconView: NSImageView!
  147. private var currentSiteIconURLKey: String?
  148. /// Keeps the center title in sync when `document.title` changes after load (e.g. SPAs).
  149. private var titleObservation: NSKeyValueObservation?
  150. /// Refreshes site identity when loading starts/stops.
  151. private var loadingObservation: NSKeyValueObservation?
  152. private var isPinnedOnTop = false
  153. private var isGestureModeEnabled = true
  154. private var statusBarShortcutObserver: NSObjectProtocol?
  155. private var premiumSheetWindow: NSWindow?
  156. /// Google endpoints are more likely to trigger human-verification in embedded webviews.
  157. /// Present a Safari-like UA for Google-family hosts to reduce bot checks.
  158. private static let safariLikeUserAgent =
  159. "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.3 Safari/605.1.15"
  160. private func setToggledStyle(_ button: TitlebarIconButton, isOn: Bool) {
  161. button.setToggleActive(isOn)
  162. }
  163. deinit {
  164. if let statusBarShortcutObserver {
  165. NotificationCenter.default.removeObserver(statusBarShortcutObserver)
  166. }
  167. }
  168. init(initialURL: URL, windowTitle: String?, injectComposerPrompt: String? = nil) {
  169. self.initialURL = initialURL
  170. self.injectComposerPrompt = injectComposerPrompt
  171. let config = WKWebViewConfiguration()
  172. config.defaultWebpagePreferences.allowsContentJavaScript = true
  173. self.webView = WKWebView(frame: .zero, configuration: config)
  174. self.webView.customUserAgent = Self.preferredUserAgent(for: initialURL)
  175. let window = NSWindow(
  176. contentRect: NSRect(x: 180, y: 140, width: 1200, height: 800),
  177. styleMask: [.titled, .closable, .miniaturizable, .resizable],
  178. backing: .buffered,
  179. defer: false
  180. )
  181. window.title = windowTitle ?? "Browser"
  182. window.isReleasedWhenClosed = false
  183. window.titleVisibility = .hidden
  184. window.titlebarAppearsTransparent = true
  185. window.styleMask.insert(.fullSizeContentView)
  186. // Build UI: web view fills content; controls live in titlebar.
  187. let containerView = NSView(frame: .zero)
  188. containerView.wantsLayer = true
  189. containerView.layer?.backgroundColor = NSColor.clear.cgColor
  190. window.contentView = containerView
  191. func makeIconButton(symbolName: String, accessibility: String, action: Selector, fallbackSymbolName: String? = nil) -> TitlebarIconButton {
  192. let button = TitlebarIconButton()
  193. let primary = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibility)
  194. let fallback = fallbackSymbolName.flatMap { NSImage(systemSymbolName: $0, accessibilityDescription: accessibility) }
  195. button.image = primary ?? fallback
  196. button.image?.isTemplate = true
  197. button.imagePosition = .imageOnly
  198. button.imageScaling = .scaleProportionallyDown
  199. button.isBordered = false
  200. button.bezelStyle = .regularSquare
  201. button.controlSize = .small
  202. button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
  203. button.setButtonType(.momentaryPushIn)
  204. button.action = action
  205. button.toolTip = accessibility
  206. if button.image == nil {
  207. button.title = "⋮"
  208. } else {
  209. button.title = ""
  210. }
  211. // Consistent symbol sizing.
  212. let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  213. button.image = button.image?.withSymbolConfiguration(cfg)
  214. button.refreshVisuals()
  215. return button
  216. }
  217. // Back
  218. backButton = makeIconButton(
  219. symbolName: "chevron.left",
  220. accessibility: "Back",
  221. action: #selector(goBack)
  222. )
  223. // Forward
  224. forwardButton = makeIconButton(
  225. symbolName: "chevron.right",
  226. accessibility: "Forward",
  227. action: #selector(goForward)
  228. )
  229. // Reload
  230. reloadButton = makeIconButton(
  231. symbolName: "arrow.clockwise",
  232. accessibility: "Reload",
  233. action: #selector(reloadPage)
  234. )
  235. // Pin (always on top)
  236. pinButton = makeIconButton(
  237. symbolName: "pin",
  238. accessibility: "Pin (Always on Top)",
  239. action: #selector(togglePinOnTop)
  240. )
  241. // Add to menu bar (matches launcher tile behavior; requires Premium + known app host).
  242. addToStatusBarButton = makeIconButton(
  243. symbolName: "arrow.up.square",
  244. accessibility: "Add to Status Bar",
  245. action: #selector(toggleCurrentPageStatusBar),
  246. fallbackSymbolName: "square.and.arrow.up"
  247. )
  248. // Home
  249. homeButton = makeIconButton(
  250. symbolName: "house",
  251. accessibility: "Home",
  252. action: #selector(goHome)
  253. )
  254. // Widget (placeholder — hook up when widget feature ships)
  255. widgetButton = makeIconButton(
  256. symbolName: "widget.small",
  257. accessibility: "Widget",
  258. action: #selector(widgetTapped),
  259. fallbackSymbolName: "square.on.square"
  260. )
  261. // Bundled PNG: 1x = layout-fluid-3, 2x = layout-fluid-4 @ 48px — slightly larger than 12pt symbols so the full glyph reads clearly.
  262. widgetButton.image = makeTitlebarSizedTemplateImage(named: "widget_custom_icon", pointSize: 14, layoutMarginFraction: 0)
  263. // Add desktop shortcut (.webloc); matches launcher tile behavior (Premium).
  264. addToDesktopButton = makeIconButton(
  265. symbolName: "arrow.up.right.square",
  266. accessibility: "Add to Desktop",
  267. action: #selector(addCurrentPageToDesktop),
  268. fallbackSymbolName: "square.and.arrow.up"
  269. )
  270. // Center site identity (logo + app/site name).
  271. siteIconView = NSImageView()
  272. siteIconView.translatesAutoresizingMaskIntoConstraints = false
  273. siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
  274. .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
  275. siteIconView.contentTintColor = NSColor.secondaryLabelColor
  276. siteIconView.imageScaling = .scaleProportionallyDown
  277. siteIconView.wantsLayer = true
  278. siteIconView.layer?.cornerRadius = 3
  279. siteIconView.layer?.masksToBounds = true
  280. siteNameLabel = NSTextField(labelWithString: windowTitle ?? "Browser")
  281. siteNameLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
  282. siteNameLabel.textColor = NSColor.labelColor
  283. siteNameLabel.alignment = .left
  284. siteNameLabel.lineBreakMode = .byTruncatingTail
  285. let headerStack = NSStackView(views: [siteIconView, siteNameLabel])
  286. headerStack.orientation = .horizontal
  287. headerStack.alignment = .centerY
  288. headerStack.spacing = 8
  289. headerStack.translatesAutoresizingMaskIntoConstraints = false
  290. NSLayoutConstraint.activate([
  291. siteIconView.widthAnchor.constraint(equalToConstant: 14),
  292. siteIconView.heightAnchor.constraint(equalToConstant: 14),
  293. ])
  294. // Hand tool (toggle navigation gestures)
  295. handButton = makeIconButton(
  296. symbolName: "hand.raised",
  297. accessibility: "Gesture Mode",
  298. action: #selector(toggleGestureMode)
  299. )
  300. // Downloads (open Downloads folder)
  301. downloadButton = makeIconButton(
  302. symbolName: "arrow.down.circle",
  303. accessibility: "Downloads",
  304. action: #selector(openDownloads)
  305. )
  306. // More menu
  307. moreButton = makeIconButton(
  308. symbolName: "ellipsis.vertical",
  309. accessibility: "More",
  310. action: #selector(showMoreMenu),
  311. fallbackSymbolName: "ellipsis"
  312. )
  313. // Web view fills content.
  314. webView.translatesAutoresizingMaskIntoConstraints = false
  315. webView.setValue(false, forKey: "drawsBackground")
  316. containerView.addSubview(webView)
  317. // Use the window content layout guide so content starts below titlebar.
  318. let contentGuide = (window.contentLayoutGuide as? NSLayoutGuide) ?? NSLayoutGuide()
  319. NSLayoutConstraint.activate([
  320. webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
  321. webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
  322. webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
  323. webView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
  324. ])
  325. super.init(window: window)
  326. window.delegate = self
  327. // Build titlebar layout after super.init.
  328. configureTitlebarLayout(centerView: headerStack)
  329. // Safe to reference `self` only after super.init.
  330. backButton.target = self
  331. forwardButton.target = self
  332. reloadButton.target = self
  333. pinButton.target = self
  334. homeButton.target = self
  335. addToStatusBarButton.target = self
  336. widgetButton.target = self
  337. addToDesktopButton.target = self
  338. handButton.target = self
  339. downloadButton.target = self
  340. moreButton.target = self
  341. statusBarShortcutObserver = NotificationCenter.default.addObserver(
  342. forName: .statusBarShortcutIDsChanged,
  343. object: nil,
  344. queue: .main
  345. ) { [weak self] _ in
  346. self?.updateAddToStatusBarButtonAppearance()
  347. }
  348. webView.uiDelegate = self
  349. webView.navigationDelegate = self
  350. webView.allowsBackForwardNavigationGestures = true
  351. titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, _ in
  352. self?.updateSiteNameFromWebView()
  353. }
  354. loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
  355. self?.updateSiteNameFromWebView()
  356. }
  357. isGestureModeEnabled = true
  358. // Initialize toolbar state.
  359. setToggledStyle(pinButton, isOn: isPinnedOnTop)
  360. setToggledStyle(handButton, isOn: isGestureModeEnabled)
  361. siteNameLabel.stringValue = windowTitle ?? "Browser"
  362. updateToolbarState()
  363. }
  364. private static func preferredUserAgent(for url: URL) -> String? {
  365. guard let host = url.host?.lowercased() else { return nil }
  366. if host.contains("google.") || host.contains("youtube.com") {
  367. return safariLikeUserAgent
  368. }
  369. return nil
  370. }
  371. private func configureTitlebarLayout(centerView: NSView) {
  372. guard let window else { return }
  373. centerView.translatesAutoresizingMaskIntoConstraints = false
  374. let leftStack = NSStackView(views: [backButton, forwardButton, reloadButton, homeButton])
  375. leftStack.orientation = .horizontal
  376. leftStack.alignment = .centerY
  377. leftStack.spacing = 6
  378. let rightStack = NSStackView(views: [
  379. pinButton,
  380. addToStatusBarButton,
  381. widgetButton,
  382. addToDesktopButton,
  383. handButton,
  384. downloadButton,
  385. moreButton,
  386. ])
  387. rightStack.orientation = .horizontal
  388. rightStack.alignment = .centerY
  389. rightStack.spacing = 6
  390. func accessory(_ view: NSView, layout: NSLayoutConstraint.Attribute) -> NSTitlebarAccessoryViewController {
  391. let vc = NSTitlebarAccessoryViewController()
  392. vc.layoutAttribute = layout
  393. vc.view = view
  394. return vc
  395. }
  396. // Single top accessory with left / center / right.
  397. let topBar = NSView(frame: NSRect(x: 0, y: 0, width: 1000, height: 34))
  398. topBar.translatesAutoresizingMaskIntoConstraints = false
  399. let leftContainer = NSView()
  400. leftContainer.translatesAutoresizingMaskIntoConstraints = false
  401. leftContainer.addSubview(leftStack)
  402. leftStack.translatesAutoresizingMaskIntoConstraints = false
  403. let centerContainer = NSView()
  404. centerContainer.translatesAutoresizingMaskIntoConstraints = false
  405. centerContainer.addSubview(centerView)
  406. let rightContainer = NSView()
  407. rightContainer.translatesAutoresizingMaskIntoConstraints = false
  408. rightContainer.addSubview(rightStack)
  409. rightStack.translatesAutoresizingMaskIntoConstraints = false
  410. let barStack = NSStackView(views: [leftContainer, centerContainer, rightContainer])
  411. barStack.orientation = .horizontal
  412. barStack.alignment = .centerY
  413. barStack.distribution = .fill
  414. barStack.spacing = 10
  415. barStack.translatesAutoresizingMaskIntoConstraints = false
  416. topBar.addSubview(barStack)
  417. NSLayoutConstraint.activate([
  418. topBar.heightAnchor.constraint(equalToConstant: 34),
  419. barStack.leadingAnchor.constraint(equalTo: topBar.leadingAnchor, constant: 12),
  420. barStack.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
  421. barStack.topAnchor.constraint(equalTo: topBar.topAnchor, constant: 2),
  422. barStack.bottomAnchor.constraint(equalTo: topBar.bottomAnchor, constant: -2),
  423. leftContainer.widthAnchor.constraint(equalTo: rightContainer.widthAnchor),
  424. leftStack.leadingAnchor.constraint(equalTo: leftContainer.leadingAnchor),
  425. leftStack.trailingAnchor.constraint(equalTo: leftContainer.trailingAnchor),
  426. leftStack.centerYAnchor.constraint(equalTo: leftContainer.centerYAnchor),
  427. rightStack.leadingAnchor.constraint(equalTo: rightContainer.leadingAnchor),
  428. rightStack.trailingAnchor.constraint(equalTo: rightContainer.trailingAnchor),
  429. rightStack.centerYAnchor.constraint(equalTo: rightContainer.centerYAnchor),
  430. centerView.centerXAnchor.constraint(equalTo: centerContainer.centerXAnchor),
  431. centerView.centerYAnchor.constraint(equalTo: centerContainer.centerYAnchor),
  432. centerView.leadingAnchor.constraint(greaterThanOrEqualTo: centerContainer.leadingAnchor, constant: 6),
  433. centerView.trailingAnchor.constraint(lessThanOrEqualTo: centerContainer.trailingAnchor, constant: -6),
  434. ])
  435. window.addTitlebarAccessoryViewController(accessory(topBar, layout: .top))
  436. }
  437. @available(*, unavailable)
  438. required init?(coder: NSCoder) {
  439. nil
  440. }
  441. override func showWindow(_ sender: Any?) {
  442. super.showWindow(sender)
  443. guard !didLoadInitialURL else { return }
  444. didLoadInitialURL = true
  445. webView.load(URLRequest(url: initialURL))
  446. }
  447. func windowWillClose(_ notification: Notification) {
  448. guard let w = notification.object as? NSWindow else { return }
  449. if w === premiumSheetWindow {
  450. premiumSheetWindow = nil
  451. updateToolbarState()
  452. return
  453. }
  454. if w === window {
  455. onClose?()
  456. NotificationCenter.default.post(name: .refocusLauncherWindowRequested, object: nil)
  457. }
  458. }
  459. func windowDidMiniaturize(_ notification: Notification) {
  460. guard let w = notification.object as? NSWindow else { return }
  461. // Ignore the premium child window; only refocus when the primary browser window is minimized.
  462. guard w === window else { return }
  463. NotificationCenter.default.post(name: .refocusLauncherWindowRequested, object: nil)
  464. }
  465. @objc private func goBack() {
  466. webView.goBack()
  467. }
  468. @objc private func goForward() {
  469. webView.goForward()
  470. }
  471. @objc private func reloadPage() {
  472. webView.reload()
  473. }
  474. @objc private func goHome() {
  475. webView.load(URLRequest(url: initialURL))
  476. }
  477. @objc private func widgetTapped() {
  478. let candidateTitle = siteNameLabel?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
  479. let matched = LauncherApp.sampleApps.first { $0.name.caseInsensitiveCompare(candidateTitle) == .orderedSame }
  480. NotificationCenter.default.post(
  481. name: .openWidgetsPage,
  482. object: nil,
  483. userInfo: matched.map { ["appID": $0.id] }
  484. )
  485. }
  486. @objc private func toggleCurrentPageStatusBar() {
  487. guard PremiumStore.shared.isPremiumUnlocked else {
  488. presentPremiumPaywall()
  489. return
  490. }
  491. guard let app = resolvedLauncherAppForCurrentPage() else {
  492. showAlert(
  493. title: "Can’t add to menu bar",
  494. message: "This site isn’t in your launcher list. Open it from the home screen first, then use this button."
  495. )
  496. return
  497. }
  498. var ids = LauncherApp.statusBarShortcutIDSet()
  499. if ids.contains(app.id) {
  500. ids.remove(app.id)
  501. LauncherApp.persistStatusBarShortcutIDs(ids)
  502. showAlert(
  503. title: "Removed from Status Bar",
  504. message: "“\(app.name)” was removed from the menu bar."
  505. )
  506. } else {
  507. ids.insert(app.id)
  508. LauncherApp.persistStatusBarShortcutIDs(ids)
  509. showAlert(
  510. title: "Added to Status Bar",
  511. message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
  512. )
  513. }
  514. updateAddToStatusBarButtonAppearance()
  515. }
  516. @objc private func addCurrentPageToDesktop() {
  517. guard PremiumStore.shared.isPremiumUnlocked else {
  518. presentPremiumPaywall()
  519. return
  520. }
  521. let url = currentPageURL()
  522. guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
  523. showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address (http or https).")
  524. return
  525. }
  526. let name = displayNameForDesktopShortcut()
  527. DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: name, webURL: url)
  528. }
  529. private func currentPageURL() -> URL {
  530. webView.url ?? initialURL
  531. }
  532. private func normalizedHost(_ host: String) -> String {
  533. let h = host.lowercased()
  534. return h.hasPrefix("www.") ? String(h.dropFirst(4)) : h
  535. }
  536. private func resolvedLauncherAppForCurrentPage() -> LauncherApp? {
  537. let url = currentPageURL()
  538. guard let host = url.host else { return nil }
  539. let nh = normalizedHost(host)
  540. let apps = LauncherApp.allLaunchableAppsFromUserDefaults()
  541. return apps.first { app in
  542. guard let appURL = app.webURL, let appHost = appURL.host else { return false }
  543. return normalizedHost(appHost) == nh
  544. }
  545. }
  546. private func displayNameForDesktopShortcut() -> String {
  547. if let app = resolvedLauncherAppForCurrentPage() {
  548. return app.name
  549. }
  550. let t = siteNameLabel.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
  551. if !t.isEmpty, !isLikelyURLString(t) {
  552. return t
  553. }
  554. if let host = currentPageURL().host {
  555. return host
  556. }
  557. return "Shortcut"
  558. }
  559. private func updateAddToStatusBarButtonAppearance() {
  560. let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  561. guard PremiumStore.shared.isPremiumUnlocked else {
  562. addToStatusBarButton.isEnabled = true
  563. addToStatusBarButton.toolTip = "Add to Status Bar — Premium"
  564. let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
  565. .withSymbolConfiguration(cfg)
  566. addToStatusBarButton.image = img
  567. addToStatusBarButton.image?.isTemplate = true
  568. setToggledStyle(addToStatusBarButton, isOn: false)
  569. addToStatusBarButton.refreshVisuals()
  570. return
  571. }
  572. guard let app = resolvedLauncherAppForCurrentPage() else {
  573. addToStatusBarButton.isEnabled = false
  574. addToStatusBarButton.toolTip = "Add to Status Bar — open this site from the launcher first"
  575. let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
  576. .withSymbolConfiguration(cfg)
  577. addToStatusBarButton.image = img
  578. addToStatusBarButton.image?.isTemplate = true
  579. setToggledStyle(addToStatusBarButton, isOn: false)
  580. return
  581. }
  582. addToStatusBarButton.isEnabled = true
  583. let inBar = LauncherApp.statusBarShortcutIDSet().contains(app.id)
  584. if inBar {
  585. let img = NSImage(systemSymbolName: "arrow.down.square", accessibilityDescription: "Remove from Status Bar")?
  586. .withSymbolConfiguration(cfg)
  587. ?? NSImage(systemSymbolName: "minus.square", accessibilityDescription: "Remove from Status Bar")?
  588. .withSymbolConfiguration(cfg)
  589. addToStatusBarButton.image = img
  590. addToStatusBarButton.image?.isTemplate = true
  591. addToStatusBarButton.toolTip = "Remove from Status Bar"
  592. setToggledStyle(addToStatusBarButton, isOn: true)
  593. } else {
  594. let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
  595. .withSymbolConfiguration(cfg)
  596. addToStatusBarButton.image = img
  597. addToStatusBarButton.image?.isTemplate = true
  598. addToStatusBarButton.toolTip = "Add to Status Bar"
  599. setToggledStyle(addToStatusBarButton, isOn: false)
  600. }
  601. addToStatusBarButton.refreshVisuals()
  602. }
  603. @objc private func togglePinOnTop() {
  604. isPinnedOnTop.toggle()
  605. window?.level = isPinnedOnTop ? .floating : .normal
  606. if let button = pinButton {
  607. let symbol = isPinnedOnTop ? "pin.fill" : "pin"
  608. button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Pin (Always on Top)")
  609. button.image?.isTemplate = true
  610. let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  611. button.image = button.image?.withSymbolConfiguration(cfg)
  612. setToggledStyle(button, isOn: isPinnedOnTop)
  613. }
  614. }
  615. @objc private func toggleGestureMode() {
  616. guard PremiumStore.shared.isPremiumUnlocked else {
  617. presentPremiumPaywall()
  618. return
  619. }
  620. isGestureModeEnabled.toggle()
  621. webView.allowsBackForwardNavigationGestures = isGestureModeEnabled
  622. if let button = handButton {
  623. let symbol = isGestureModeEnabled ? "hand.raised.fill" : "hand.raised"
  624. button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Gesture Mode")
  625. button.image?.isTemplate = true
  626. let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
  627. button.image = button.image?.withSymbolConfiguration(cfg)
  628. setToggledStyle(button, isOn: isGestureModeEnabled)
  629. }
  630. }
  631. /// Borderless `NSWindow` is square; SwiftUI `.sheet` gets rounded chrome automatically — match that look.
  632. private func configurePaywallWindowRoundedCorners(_ win: NSWindow) {
  633. guard let cv = win.contentView else { return }
  634. cv.wantsLayer = true
  635. let r: CGFloat = 16
  636. cv.layer?.cornerRadius = r
  637. if #available(macOS 11.0, *) {
  638. cv.layer?.cornerCurve = .continuous
  639. }
  640. cv.layer?.masksToBounds = true
  641. }
  642. private func presentPremiumPaywall() {
  643. guard let parentWindow = window else { return }
  644. if let existing = premiumSheetWindow, existing.isVisible {
  645. existing.makeKeyAndOrderFront(nil)
  646. NSApp.activate(ignoringOtherApps: true)
  647. return
  648. }
  649. let paywallFrame = PremiumPaywallWindowSizing.paywallFrame(forParentWindow: parentWindow)
  650. let paywallWindow = NSWindow(
  651. contentRect: paywallFrame,
  652. styleMask: [.borderless],
  653. backing: .buffered,
  654. defer: false
  655. )
  656. paywallWindow.isReleasedWhenClosed = false
  657. paywallWindow.delegate = self
  658. paywallWindow.isMovable = false
  659. paywallWindow.isMovableByWindowBackground = false
  660. paywallWindow.hasShadow = true
  661. paywallWindow.isOpaque = false
  662. paywallWindow.backgroundColor = .clear
  663. paywallWindow.minSize = paywallFrame.size
  664. paywallWindow.maxSize = paywallFrame.size
  665. let hosting = NSHostingController(
  666. rootView: PremiumFeaturesView(onDismissFromAppKitHost: { [weak self] in
  667. self?.closePremiumPaywallWindow()
  668. })
  669. .environmentObject(PremiumStore.shared)
  670. )
  671. hosting.view.frame = NSRect(origin: .zero, size: paywallFrame.size)
  672. hosting.view.autoresizingMask = [.width, .height]
  673. paywallWindow.contentViewController = hosting
  674. configurePaywallWindowRoundedCorners(paywallWindow)
  675. paywallWindow.setFrame(paywallFrame, display: false)
  676. premiumSheetWindow = paywallWindow
  677. parentWindow.addChildWindow(paywallWindow, ordered: .above)
  678. paywallWindow.makeKeyAndOrderFront(nil)
  679. NSApp.activate(ignoringOtherApps: true)
  680. }
  681. private func closePremiumPaywallWindow() {
  682. guard let w = premiumSheetWindow else { return }
  683. w.parent?.removeChildWindow(w)
  684. w.close()
  685. // `premiumSheetWindow` and toolbar refresh happen in `windowWillClose`.
  686. }
  687. /// Non‑Premium: only gesture, status bar, and desktop look locked; they stay enabled so taps open the paywall.
  688. private func applyPremiumLockedTitlebarAppearance() {
  689. let locked = !PremiumStore.shared.isPremiumUnlocked
  690. handButton.lockedForPaywallAppearance = locked
  691. addToDesktopButton.lockedForPaywallAppearance = locked
  692. addToStatusBarButton.lockedForPaywallAppearance = locked
  693. handButton.alphaValue = 1
  694. addToDesktopButton.alphaValue = 1
  695. addToStatusBarButton.alphaValue = 1
  696. if locked {
  697. handButton.isEnabled = true
  698. addToDesktopButton.isEnabled = true
  699. addToStatusBarButton.isEnabled = true
  700. }
  701. handButton.refreshVisuals()
  702. addToDesktopButton.refreshVisuals()
  703. addToStatusBarButton.refreshVisuals()
  704. }
  705. @objc private func openDownloads() {
  706. guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
  707. showAlert(title: "Unavailable", message: "Couldn’t find your Downloads folder.")
  708. return
  709. }
  710. NSWorkspace.shared.open(downloads)
  711. }
  712. @objc private func showMoreMenu() {
  713. let menu = NSMenu()
  714. let reload = NSMenuItem(title: "Reload", action: #selector(reloadPage), keyEquivalent: "r")
  715. reload.keyEquivalentModifierMask = [.command]
  716. menu.addItem(reload)
  717. let home = NSMenuItem(title: "Home", action: #selector(goHome), keyEquivalent: "h")
  718. home.keyEquivalentModifierMask = [.command]
  719. menu.addItem(home)
  720. let downloads = NSMenuItem(title: "Open Downloads", action: #selector(openDownloads), keyEquivalent: "d")
  721. downloads.keyEquivalentModifierMask = [.command]
  722. menu.addItem(downloads)
  723. menu.addItem(NSMenuItem.separator())
  724. let close = NSMenuItem(title: "Close Window", action: #selector(closeWindowFromMenu), keyEquivalent: "w")
  725. close.keyEquivalentModifierMask = [.command]
  726. menu.addItem(close)
  727. menu.items.forEach { $0.target = self }
  728. let location = NSPoint(x: moreButton.bounds.midX, y: moreButton.bounds.minY - 4)
  729. menu.popUp(positioning: nil, at: location, in: moreButton)
  730. }
  731. @objc private func closeWindowFromMenu() {
  732. window?.performClose(nil)
  733. }
  734. /// WKWebView often reports the requested URL as `title` until the real document title is available.
  735. private func isLikelyURLString(_ raw: String) -> Bool {
  736. let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
  737. if t.isEmpty { return false }
  738. if t.hasPrefix("http://") || t.hasPrefix("https://") { return true }
  739. if t.contains("://") { return true }
  740. if let u = URL(string: t), u.scheme != nil, u.host != nil { return true }
  741. return false
  742. }
  743. private func updateSiteNameFromWebView() {
  744. let loading = webView.isLoading
  745. let host = webView.url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
  746. let rawTitle = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines)
  747. let pageTitle: String? = {
  748. guard let rawTitle, !rawTitle.isEmpty else { return nil }
  749. return isLikelyURLString(rawTitle) ? nil : rawTitle
  750. }()
  751. // Prefer document title; never show hostname or URL-like strings while a load is in progress.
  752. let preferred: String
  753. if let pageTitle {
  754. preferred = pageTitle
  755. } else if loading {
  756. preferred = "Loading…"
  757. } else if let host, !host.isEmpty {
  758. preferred = host
  759. } else {
  760. preferred = "Browser"
  761. }
  762. siteNameLabel.stringValue = preferred
  763. window?.title = preferred
  764. guard let url = webView.url else {
  765. siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
  766. .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
  767. return
  768. }
  769. let key = (url.host ?? url.absoluteString).lowercased()
  770. guard currentSiteIconURLKey != key else { return }
  771. currentSiteIconURLKey = key
  772. Task { [weak self] in
  773. guard let self else { return }
  774. let fetched = await FaviconImageCache.shared.image(for: url)
  775. await MainActor.run {
  776. guard self.currentSiteIconURLKey == key else { return }
  777. if let fetched {
  778. self.siteIconView.image = fetched
  779. } else {
  780. self.siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
  781. .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
  782. self.siteIconView.contentTintColor = NSColor.secondaryLabelColor
  783. }
  784. }
  785. }
  786. }
  787. private func updateToolbarState() {
  788. backButton.isEnabled = webView.canGoBack
  789. forwardButton.isEnabled = webView.canGoForward
  790. // Keep reload enabled; it matches typical browser behavior.
  791. reloadButton.isEnabled = true
  792. // Sync center site name.
  793. updateSiteNameFromWebView()
  794. updateAddToStatusBarButtonAppearance()
  795. applyPremiumLockedTitlebarAppearance()
  796. }
  797. private func showAlert(title: String, message: String) {
  798. let alert = NSAlert()
  799. alert.messageText = title
  800. alert.informativeText = message
  801. alert.addButton(withTitle: "OK")
  802. alert.alertStyle = .informational
  803. alert.runModal()
  804. }
  805. /// Gemini’s SPA does not honor `?prompt=` on `/app`; after navigation we fill the visible composer (retries for async UI / sign-in).
  806. private func injectGeminiComposerPromptIfNeeded(webView: WKWebView) {
  807. guard let prompt = injectComposerPrompt, !geminiPromptWasInjected, !geminiInjectionIsActive else { return }
  808. guard let host = webView.url?.host?.lowercased(), host.contains("gemini.google.com") else { return }
  809. guard let jsonData = try? JSONSerialization.data(withJSONObject: [prompt]),
  810. let jsonArrayLiteral = String(data: jsonData, encoding: .utf8) else { return }
  811. geminiInjectionIsActive = true
  812. let script = """
  813. (function() {
  814. return new Promise(function(resolve) {
  815. var text = \(jsonArrayLiteral)[0];
  816. var n = 0;
  817. var timer = setInterval(function() {
  818. n++;
  819. function fillTextarea(el) {
  820. if (!el || !el.offsetParent) return false;
  821. var r = el.getBoundingClientRect();
  822. if (r.width < 8 || r.height < 8) return false;
  823. el.focus();
  824. el.value = text;
  825. el.dispatchEvent(new Event('input', { bubbles: true }));
  826. el.dispatchEvent(new Event('change', { bubbles: true }));
  827. return true;
  828. }
  829. function fillContentEditable(ce) {
  830. if (!ce || !ce.isContentEditable || !ce.offsetParent) return false;
  831. ce.focus();
  832. ce.innerText = text;
  833. ce.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }));
  834. return true;
  835. }
  836. function tryFillInRoot(root) {
  837. var areas = root.querySelectorAll('textarea');
  838. var i, el;
  839. for (i = 0; i < areas.length; i++) {
  840. if (fillTextarea(areas[i])) return true;
  841. }
  842. var editables = root.querySelectorAll('[contenteditable="true"]');
  843. for (i = 0; i < editables.length; i++) {
  844. if (fillContentEditable(editables[i])) return true;
  845. }
  846. var nodes = root.querySelectorAll('*');
  847. for (i = 0; i < nodes.length; i++) {
  848. var sh = nodes[i].shadowRoot;
  849. if (sh && tryFillInRoot(sh)) return true;
  850. }
  851. return false;
  852. }
  853. function tryFill() {
  854. if (tryFillInRoot(document)) return true;
  855. return false;
  856. }
  857. if (tryFill()) { clearInterval(timer); resolve(true); }
  858. else if (n >= 80) { clearInterval(timer); resolve(false); }
  859. }, 250);
  860. });
  861. })();
  862. """
  863. webView.evaluateJavaScript(script) { [weak self] result, _ in
  864. guard let self else { return }
  865. self.geminiInjectionIsActive = false
  866. if (result as? NSNumber)?.boolValue == true {
  867. self.geminiPromptWasInjected = true
  868. }
  869. }
  870. }
  871. }
  872. extension InAppBrowserWindowController: WKNavigationDelegate {
  873. func webView(
  874. _ webView: WKWebView,
  875. decidePolicyFor navigationAction: WKNavigationAction,
  876. decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
  877. ) {
  878. if navigationAction.targetFrame == nil, let url = navigationAction.request.url {
  879. InAppBrowserWindowManager.shared.open(url: url)
  880. decisionHandler(.cancel)
  881. return
  882. }
  883. decisionHandler(.allow)
  884. }
  885. func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
  886. updateToolbarState()
  887. injectGeminiComposerPromptIfNeeded(webView: webView)
  888. }
  889. func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
  890. updateToolbarState()
  891. }
  892. func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
  893. updateToolbarState()
  894. }
  895. func webView(
  896. _ webView: WKWebView,
  897. didFailProvisionalNavigation navigation: WKNavigation!,
  898. withError error: Error
  899. ) {
  900. updateToolbarState()
  901. showLoadError(error)
  902. }
  903. func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
  904. updateToolbarState()
  905. showLoadError(error)
  906. }
  907. private func showLoadError(_ error: Error) {
  908. let message = """
  909. <html>
  910. <body style="background:#111;color:#EEE;font-family:-apple-system, sans-serif;padding:24px;">
  911. <h2>Page failed to load</h2>
  912. <p>Could not open <b>\(initialURL.absoluteString)</b></p>
  913. <p>\(error.localizedDescription)</p>
  914. </body>
  915. </html>
  916. """
  917. webView.loadHTMLString(message, baseURL: nil)
  918. window?.title = "Load Error"
  919. }
  920. }
  921. extension InAppBrowserWindowController: WKUIDelegate {
  922. func webView(
  923. _ webView: WKWebView,
  924. createWebViewWith configuration: WKWebViewConfiguration,
  925. for navigationAction: WKNavigationAction,
  926. windowFeatures: WKWindowFeatures
  927. ) -> WKWebView? {
  928. guard let url = navigationAction.request.url else { return nil }
  929. InAppBrowserWindowManager.shared.open(url: url)
  930. return nil
  931. }
  932. }