| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061 |
- import AppKit
- import SwiftUI
- import WebKit
- /// Titlebar icon control: white/neutral tint (no accent blue), subtle hover and selected backgrounds.
- private final class TitlebarIconButton: NSButton {
- private var isHovering = false
- private var hoverTrackingArea: NSTrackingArea?
- var isToggleActive = false
- /// Looks disabled (gray) but keeps `isEnabled == true` so the control still receives clicks (e.g. paywall).
- var lockedForPaywallAppearance = false {
- didSet { refreshVisuals() }
- }
- override func awakeFromNib() {
- super.awakeFromNib()
- commonInit()
- }
- override init(frame frameRect: NSRect) {
- super.init(frame: frameRect)
- commonInit()
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
- private func commonInit() {
- wantsLayer = true
- layer?.cornerRadius = 5
- // Do not mask the button’s bounds to the rounded rect: on a ~10×10 pt control,
- // cornerRadius 5 + masksToBounds clips to a *circle* and cuts off corner-heavy
- // bitmap icons (e.g. layout grid). SF Symbols stay fine due to built-in padding.
- layer?.masksToBounds = false
- }
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- // Do NOT remove all tracking areas here: AppKit uses its own tracking area for tooltips.
- // We only manage our own hover tracking area.
- if let hoverTrackingArea {
- removeTrackingArea(hoverTrackingArea)
- }
- let area = NSTrackingArea(
- rect: bounds,
- options: [.activeInKeyWindow, .mouseEnteredAndExited, .inVisibleRect],
- owner: self,
- userInfo: nil
- )
- hoverTrackingArea = area
- addTrackingArea(area)
- }
- override func mouseEntered(with event: NSEvent) {
- isHovering = true
- refreshVisuals()
- }
- override func mouseExited(with event: NSEvent) {
- isHovering = false
- refreshVisuals()
- }
- override var isEnabled: Bool {
- didSet { refreshVisuals() }
- }
- func setToggleActive(_ on: Bool) {
- isToggleActive = on
- refreshVisuals()
- }
- func refreshVisuals() {
- let white = NSColor.white
- if !isEnabled || lockedForPaywallAppearance {
- contentTintColor = white.withAlphaComponent(0.35)
- layer?.backgroundColor = NSColor.clear.cgColor
- return
- }
- if isToggleActive {
- contentTintColor = white
- let bg = isHovering ? white.withAlphaComponent(0.22) : white.withAlphaComponent(0.16)
- layer?.backgroundColor = bg.cgColor
- return
- }
- if isHovering {
- contentTintColor = white
- layer?.backgroundColor = white.withAlphaComponent(0.12).cgColor
- } else {
- contentTintColor = white.withAlphaComponent(0.88)
- layer?.backgroundColor = NSColor.clear.cgColor
- }
- }
- }
- /// 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).
- private func makeTitlebarSizedTemplateImage(named name: String, pointSize: CGFloat, layoutMarginFraction: CGFloat = 0) -> NSImage? {
- guard let source = NSImage(named: name) else { return nil }
- let logicalSize = NSSize(width: pointSize, height: pointSize)
- let image = NSImage(size: logicalSize, flipped: false) { rect in
- NSGraphicsContext.current?.imageInterpolation = .high
- let inset = min(rect.width, rect.height) * layoutMarginFraction
- let drawRect = rect.insetBy(dx: inset, dy: inset)
- source.draw(
- in: drawRect,
- from: NSRect(origin: .zero, size: source.size),
- operation: .sourceOver,
- fraction: 1.0
- )
- return true
- }
- image.isTemplate = true
- return image
- }
- final class InAppBrowserWindowManager {
- static let shared = InAppBrowserWindowManager()
- private var controllers: [InAppBrowserWindowController] = []
- private init() {}
- func open(url: URL, title: String? = nil, injectComposerPrompt: String? = nil) {
- let controller = InAppBrowserWindowController(
- initialURL: url,
- windowTitle: title,
- injectComposerPrompt: injectComposerPrompt
- )
- controllers.append(controller)
- controller.onClose = { [weak self, weak controller] in
- guard let self, let controller else { return }
- self.controllers.removeAll { $0 === controller }
- }
- controller.showWindow(nil)
- controller.window?.makeKeyAndOrderFront(nil)
- }
- }
- final class InAppBrowserWindowController: NSWindowController, NSWindowDelegate {
- var onClose: (() -> Void)?
- private let webView: WKWebView
- private let initialURL: URL
- /// When opening Gemini from the widget, filled into the page composer after load (Gemini ignores URL query params).
- private let injectComposerPrompt: String?
- private var geminiPromptWasInjected = false
- private var geminiInjectionIsActive = false
- private var didLoadInitialURL = false
- // Titlebar controls
- private var backButton: TitlebarIconButton!
- private var forwardButton: TitlebarIconButton!
- private var reloadButton: TitlebarIconButton!
- private var pinButton: TitlebarIconButton!
- private var homeButton: TitlebarIconButton!
- private var addToStatusBarButton: TitlebarIconButton!
- private var widgetButton: TitlebarIconButton!
- private var addToDesktopButton: TitlebarIconButton!
- private var handButton: TitlebarIconButton!
- private var downloadButton: TitlebarIconButton!
- private var moreButton: TitlebarIconButton!
- private var siteNameLabel: NSTextField!
- private var siteIconView: NSImageView!
- private var currentSiteIconURLKey: String?
- /// Keeps the center title in sync when `document.title` changes after load (e.g. SPAs).
- private var titleObservation: NSKeyValueObservation?
- /// Refreshes site identity when loading starts/stops.
- private var loadingObservation: NSKeyValueObservation?
- private var isPinnedOnTop = false
- private var isGestureModeEnabled = true
- private var statusBarShortcutObserver: NSObjectProtocol?
- private var premiumSheetWindow: NSWindow?
- /// Google endpoints are more likely to trigger human-verification in embedded webviews.
- /// Present a Safari-like UA for Google-family hosts to reduce bot checks.
- private static let safariLikeUserAgent =
- "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"
- private func setToggledStyle(_ button: TitlebarIconButton, isOn: Bool) {
- button.setToggleActive(isOn)
- }
- deinit {
- if let statusBarShortcutObserver {
- NotificationCenter.default.removeObserver(statusBarShortcutObserver)
- }
- }
- init(initialURL: URL, windowTitle: String?, injectComposerPrompt: String? = nil) {
- self.initialURL = initialURL
- self.injectComposerPrompt = injectComposerPrompt
- let config = WKWebViewConfiguration()
- config.defaultWebpagePreferences.allowsContentJavaScript = true
- self.webView = WKWebView(frame: .zero, configuration: config)
- self.webView.customUserAgent = Self.preferredUserAgent(for: initialURL)
- let window = NSWindow(
- contentRect: NSRect(x: 180, y: 140, width: 1200, height: 800),
- styleMask: [.titled, .closable, .miniaturizable, .resizable],
- backing: .buffered,
- defer: false
- )
- window.title = windowTitle ?? "Browser"
- window.isReleasedWhenClosed = false
- window.titleVisibility = .hidden
- window.titlebarAppearsTransparent = true
- window.styleMask.insert(.fullSizeContentView)
- // Build UI: web view fills content; controls live in titlebar.
- let containerView = NSView(frame: .zero)
- containerView.wantsLayer = true
- containerView.layer?.backgroundColor = NSColor.clear.cgColor
- window.contentView = containerView
- func makeIconButton(symbolName: String, accessibility: String, action: Selector, fallbackSymbolName: String? = nil) -> TitlebarIconButton {
- let button = TitlebarIconButton()
- let primary = NSImage(systemSymbolName: symbolName, accessibilityDescription: accessibility)
- let fallback = fallbackSymbolName.flatMap { NSImage(systemSymbolName: $0, accessibilityDescription: accessibility) }
- button.image = primary ?? fallback
- button.image?.isTemplate = true
- button.imagePosition = .imageOnly
- button.imageScaling = .scaleProportionallyDown
- button.isBordered = false
- button.bezelStyle = .regularSquare
- button.controlSize = .small
- button.font = NSFont.systemFont(ofSize: 12, weight: .semibold)
- button.setButtonType(.momentaryPushIn)
- button.action = action
- button.toolTip = accessibility
- if button.image == nil {
- button.title = "⋮"
- } else {
- button.title = ""
- }
- // Consistent symbol sizing.
- let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- button.image = button.image?.withSymbolConfiguration(cfg)
- button.refreshVisuals()
- return button
- }
- // Back
- backButton = makeIconButton(
- symbolName: "chevron.left",
- accessibility: "Back",
- action: #selector(goBack)
- )
- // Forward
- forwardButton = makeIconButton(
- symbolName: "chevron.right",
- accessibility: "Forward",
- action: #selector(goForward)
- )
- // Reload
- reloadButton = makeIconButton(
- symbolName: "arrow.clockwise",
- accessibility: "Reload",
- action: #selector(reloadPage)
- )
- // Pin (always on top)
- pinButton = makeIconButton(
- symbolName: "pin",
- accessibility: "Pin (Always on Top)",
- action: #selector(togglePinOnTop)
- )
- // Add to menu bar (matches launcher tile behavior; requires Premium + known app host).
- addToStatusBarButton = makeIconButton(
- symbolName: "arrow.up.square",
- accessibility: "Add to Status Bar",
- action: #selector(toggleCurrentPageStatusBar),
- fallbackSymbolName: "square.and.arrow.up"
- )
- // Home
- homeButton = makeIconButton(
- symbolName: "house",
- accessibility: "Home",
- action: #selector(goHome)
- )
- // Widget (placeholder — hook up when widget feature ships)
- widgetButton = makeIconButton(
- symbolName: "widget.small",
- accessibility: "Widget",
- action: #selector(widgetTapped),
- fallbackSymbolName: "square.on.square"
- )
- // Bundled PNG: 1x = layout-fluid-3, 2x = layout-fluid-4 @ 48px — slightly larger than 12pt symbols so the full glyph reads clearly.
- widgetButton.image = makeTitlebarSizedTemplateImage(named: "widget_custom_icon", pointSize: 14, layoutMarginFraction: 0)
- // Add desktop shortcut (.webloc); matches launcher tile behavior (Premium).
- addToDesktopButton = makeIconButton(
- symbolName: "arrow.up.right.square",
- accessibility: "Add to Desktop",
- action: #selector(addCurrentPageToDesktop),
- fallbackSymbolName: "square.and.arrow.up"
- )
- // Center site identity (logo + app/site name).
- siteIconView = NSImageView()
- siteIconView.translatesAutoresizingMaskIntoConstraints = false
- siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
- .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
- siteIconView.contentTintColor = NSColor.secondaryLabelColor
- siteIconView.imageScaling = .scaleProportionallyDown
- siteIconView.wantsLayer = true
- siteIconView.layer?.cornerRadius = 3
- siteIconView.layer?.masksToBounds = true
- siteNameLabel = NSTextField(labelWithString: windowTitle ?? "Browser")
- siteNameLabel.font = NSFont.systemFont(ofSize: 13, weight: .semibold)
- siteNameLabel.textColor = NSColor.labelColor
- siteNameLabel.alignment = .left
- siteNameLabel.lineBreakMode = .byTruncatingTail
- let headerStack = NSStackView(views: [siteIconView, siteNameLabel])
- headerStack.orientation = .horizontal
- headerStack.alignment = .centerY
- headerStack.spacing = 8
- headerStack.translatesAutoresizingMaskIntoConstraints = false
- NSLayoutConstraint.activate([
- siteIconView.widthAnchor.constraint(equalToConstant: 14),
- siteIconView.heightAnchor.constraint(equalToConstant: 14),
- ])
- // Hand tool (toggle navigation gestures)
- handButton = makeIconButton(
- symbolName: "hand.raised",
- accessibility: "Gesture Mode",
- action: #selector(toggleGestureMode)
- )
- // Downloads (open Downloads folder)
- downloadButton = makeIconButton(
- symbolName: "arrow.down.circle",
- accessibility: "Downloads",
- action: #selector(openDownloads)
- )
- // More menu
- moreButton = makeIconButton(
- symbolName: "ellipsis.vertical",
- accessibility: "More",
- action: #selector(showMoreMenu),
- fallbackSymbolName: "ellipsis"
- )
- // Web view fills content.
- webView.translatesAutoresizingMaskIntoConstraints = false
- webView.setValue(false, forKey: "drawsBackground")
- containerView.addSubview(webView)
- // Use the window content layout guide so content starts below titlebar.
- let contentGuide = (window.contentLayoutGuide as? NSLayoutGuide) ?? NSLayoutGuide()
- NSLayoutConstraint.activate([
- webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
- webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
- webView.topAnchor.constraint(equalTo: contentGuide.topAnchor),
- ])
- super.init(window: window)
- window.delegate = self
- // Build titlebar layout after super.init.
- configureTitlebarLayout(centerView: headerStack)
- // Safe to reference `self` only after super.init.
- backButton.target = self
- forwardButton.target = self
- reloadButton.target = self
- pinButton.target = self
- homeButton.target = self
- addToStatusBarButton.target = self
- widgetButton.target = self
- addToDesktopButton.target = self
- handButton.target = self
- downloadButton.target = self
- moreButton.target = self
- statusBarShortcutObserver = NotificationCenter.default.addObserver(
- forName: .statusBarShortcutIDsChanged,
- object: nil,
- queue: .main
- ) { [weak self] _ in
- self?.updateAddToStatusBarButtonAppearance()
- }
- webView.uiDelegate = self
- webView.navigationDelegate = self
- webView.allowsBackForwardNavigationGestures = true
- titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, _ in
- self?.updateSiteNameFromWebView()
- }
- loadingObservation = webView.observe(\.isLoading, options: [.new]) { [weak self] _, _ in
- self?.updateSiteNameFromWebView()
- }
- isGestureModeEnabled = true
- // Initialize toolbar state.
- setToggledStyle(pinButton, isOn: isPinnedOnTop)
- setToggledStyle(handButton, isOn: isGestureModeEnabled)
- siteNameLabel.stringValue = windowTitle ?? "Browser"
- updateToolbarState()
- }
- private static func preferredUserAgent(for url: URL) -> String? {
- guard let host = url.host?.lowercased() else { return nil }
- if host.contains("google.") || host.contains("youtube.com") {
- return safariLikeUserAgent
- }
- return nil
- }
- private func configureTitlebarLayout(centerView: NSView) {
- guard let window else { return }
- centerView.translatesAutoresizingMaskIntoConstraints = false
- let leftStack = NSStackView(views: [backButton, forwardButton, reloadButton, homeButton])
- leftStack.orientation = .horizontal
- leftStack.alignment = .centerY
- leftStack.spacing = 6
- let rightStack = NSStackView(views: [
- pinButton,
- addToStatusBarButton,
- widgetButton,
- addToDesktopButton,
- handButton,
- downloadButton,
- moreButton,
- ])
- rightStack.orientation = .horizontal
- rightStack.alignment = .centerY
- rightStack.spacing = 6
- func accessory(_ view: NSView, layout: NSLayoutConstraint.Attribute) -> NSTitlebarAccessoryViewController {
- let vc = NSTitlebarAccessoryViewController()
- vc.layoutAttribute = layout
- vc.view = view
- return vc
- }
- // Single top accessory with left / center / right.
- let topBar = NSView(frame: NSRect(x: 0, y: 0, width: 1000, height: 34))
- topBar.translatesAutoresizingMaskIntoConstraints = false
- let leftContainer = NSView()
- leftContainer.translatesAutoresizingMaskIntoConstraints = false
- leftContainer.addSubview(leftStack)
- leftStack.translatesAutoresizingMaskIntoConstraints = false
- let centerContainer = NSView()
- centerContainer.translatesAutoresizingMaskIntoConstraints = false
- centerContainer.addSubview(centerView)
- let rightContainer = NSView()
- rightContainer.translatesAutoresizingMaskIntoConstraints = false
- rightContainer.addSubview(rightStack)
- rightStack.translatesAutoresizingMaskIntoConstraints = false
- let barStack = NSStackView(views: [leftContainer, centerContainer, rightContainer])
- barStack.orientation = .horizontal
- barStack.alignment = .centerY
- barStack.distribution = .fill
- barStack.spacing = 10
- barStack.translatesAutoresizingMaskIntoConstraints = false
- topBar.addSubview(barStack)
- NSLayoutConstraint.activate([
- topBar.heightAnchor.constraint(equalToConstant: 34),
- barStack.leadingAnchor.constraint(equalTo: topBar.leadingAnchor, constant: 12),
- barStack.trailingAnchor.constraint(equalTo: topBar.trailingAnchor, constant: -12),
- barStack.topAnchor.constraint(equalTo: topBar.topAnchor, constant: 2),
- barStack.bottomAnchor.constraint(equalTo: topBar.bottomAnchor, constant: -2),
- leftContainer.widthAnchor.constraint(equalTo: rightContainer.widthAnchor),
- leftStack.leadingAnchor.constraint(equalTo: leftContainer.leadingAnchor),
- leftStack.trailingAnchor.constraint(equalTo: leftContainer.trailingAnchor),
- leftStack.centerYAnchor.constraint(equalTo: leftContainer.centerYAnchor),
- rightStack.leadingAnchor.constraint(equalTo: rightContainer.leadingAnchor),
- rightStack.trailingAnchor.constraint(equalTo: rightContainer.trailingAnchor),
- rightStack.centerYAnchor.constraint(equalTo: rightContainer.centerYAnchor),
- centerView.centerXAnchor.constraint(equalTo: centerContainer.centerXAnchor),
- centerView.centerYAnchor.constraint(equalTo: centerContainer.centerYAnchor),
- centerView.leadingAnchor.constraint(greaterThanOrEqualTo: centerContainer.leadingAnchor, constant: 6),
- centerView.trailingAnchor.constraint(lessThanOrEqualTo: centerContainer.trailingAnchor, constant: -6),
- ])
- window.addTitlebarAccessoryViewController(accessory(topBar, layout: .top))
- }
- @available(*, unavailable)
- required init?(coder: NSCoder) {
- nil
- }
- override func showWindow(_ sender: Any?) {
- super.showWindow(sender)
- guard !didLoadInitialURL else { return }
- didLoadInitialURL = true
- webView.load(URLRequest(url: initialURL))
- }
- func windowWillClose(_ notification: Notification) {
- guard let w = notification.object as? NSWindow else { return }
- if w === premiumSheetWindow {
- premiumSheetWindow = nil
- updateToolbarState()
- return
- }
- if w === window {
- onClose?()
- NotificationCenter.default.post(name: .refocusLauncherWindowRequested, object: nil)
- }
- }
- func windowDidMiniaturize(_ notification: Notification) {
- guard let w = notification.object as? NSWindow else { return }
- // Ignore the premium child window; only refocus when the primary browser window is minimized.
- guard w === window else { return }
- NotificationCenter.default.post(name: .refocusLauncherWindowRequested, object: nil)
- }
- @objc private func goBack() {
- webView.goBack()
- }
- @objc private func goForward() {
- webView.goForward()
- }
- @objc private func reloadPage() {
- webView.reload()
- }
- @objc private func goHome() {
- webView.load(URLRequest(url: initialURL))
- }
- @objc private func widgetTapped() {
- let candidateTitle = siteNameLabel?.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
- let matched = LauncherApp.sampleApps.first { $0.name.caseInsensitiveCompare(candidateTitle) == .orderedSame }
- NotificationCenter.default.post(
- name: .openWidgetsPage,
- object: nil,
- userInfo: matched.map { ["appID": $0.id] }
- )
- }
- @objc private func toggleCurrentPageStatusBar() {
- guard PremiumStore.shared.isPremiumUnlocked else {
- presentPremiumPaywall()
- return
- }
- guard let app = resolvedLauncherAppForCurrentPage() else {
- showAlert(
- title: "Can’t add to menu bar",
- message: "This site isn’t in your launcher list. Open it from the home screen first, then use this button."
- )
- return
- }
- var ids = LauncherApp.statusBarShortcutIDSet()
- if ids.contains(app.id) {
- ids.remove(app.id)
- LauncherApp.persistStatusBarShortcutIDs(ids)
- showAlert(
- title: "Removed from Status Bar",
- message: "“\(app.name)” was removed from the menu bar."
- )
- } else {
- ids.insert(app.id)
- LauncherApp.persistStatusBarShortcutIDs(ids)
- showAlert(
- title: "Added to Status Bar",
- message: "“\(app.name)” now has its own icon in the menu bar. Click it to open, or right‑click to remove."
- )
- }
- updateAddToStatusBarButtonAppearance()
- }
- @objc private func addCurrentPageToDesktop() {
- guard PremiumStore.shared.isPremiumUnlocked else {
- presentPremiumPaywall()
- return
- }
- let url = currentPageURL()
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else {
- showAlert(title: "Unavailable", message: "Desktop shortcuts need a web address (http or https).")
- return
- }
- let name = displayNameForDesktopShortcut()
- DesktopWeblocShortcutPresenter.beginSavePanel(forAppNamed: name, webURL: url)
- }
- private func currentPageURL() -> URL {
- webView.url ?? initialURL
- }
- private func normalizedHost(_ host: String) -> String {
- let h = host.lowercased()
- return h.hasPrefix("www.") ? String(h.dropFirst(4)) : h
- }
- private func resolvedLauncherAppForCurrentPage() -> LauncherApp? {
- let url = currentPageURL()
- guard let host = url.host else { return nil }
- let nh = normalizedHost(host)
- let apps = LauncherApp.allLaunchableAppsFromUserDefaults()
- return apps.first { app in
- guard let appURL = app.webURL, let appHost = appURL.host else { return false }
- return normalizedHost(appHost) == nh
- }
- }
- private func displayNameForDesktopShortcut() -> String {
- if let app = resolvedLauncherAppForCurrentPage() {
- return app.name
- }
- let t = siteNameLabel.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- if !t.isEmpty, !isLikelyURLString(t) {
- return t
- }
- if let host = currentPageURL().host {
- return host
- }
- return "Shortcut"
- }
- private func updateAddToStatusBarButtonAppearance() {
- let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- guard PremiumStore.shared.isPremiumUnlocked else {
- addToStatusBarButton.isEnabled = true
- addToStatusBarButton.toolTip = "Add to Status Bar — Premium"
- let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
- .withSymbolConfiguration(cfg)
- addToStatusBarButton.image = img
- addToStatusBarButton.image?.isTemplate = true
- setToggledStyle(addToStatusBarButton, isOn: false)
- addToStatusBarButton.refreshVisuals()
- return
- }
- guard let app = resolvedLauncherAppForCurrentPage() else {
- addToStatusBarButton.isEnabled = false
- addToStatusBarButton.toolTip = "Add to Status Bar — open this site from the launcher first"
- let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
- .withSymbolConfiguration(cfg)
- addToStatusBarButton.image = img
- addToStatusBarButton.image?.isTemplate = true
- setToggledStyle(addToStatusBarButton, isOn: false)
- return
- }
- addToStatusBarButton.isEnabled = true
- let inBar = LauncherApp.statusBarShortcutIDSet().contains(app.id)
- if inBar {
- let img = NSImage(systemSymbolName: "arrow.down.square", accessibilityDescription: "Remove from Status Bar")?
- .withSymbolConfiguration(cfg)
- ?? NSImage(systemSymbolName: "minus.square", accessibilityDescription: "Remove from Status Bar")?
- .withSymbolConfiguration(cfg)
- addToStatusBarButton.image = img
- addToStatusBarButton.image?.isTemplate = true
- addToStatusBarButton.toolTip = "Remove from Status Bar"
- setToggledStyle(addToStatusBarButton, isOn: true)
- } else {
- let img = NSImage(systemSymbolName: "arrow.up.square", accessibilityDescription: "Add to Status Bar")?
- .withSymbolConfiguration(cfg)
- addToStatusBarButton.image = img
- addToStatusBarButton.image?.isTemplate = true
- addToStatusBarButton.toolTip = "Add to Status Bar"
- setToggledStyle(addToStatusBarButton, isOn: false)
- }
- addToStatusBarButton.refreshVisuals()
- }
- @objc private func togglePinOnTop() {
- isPinnedOnTop.toggle()
- window?.level = isPinnedOnTop ? .floating : .normal
- if let button = pinButton {
- let symbol = isPinnedOnTop ? "pin.fill" : "pin"
- button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Pin (Always on Top)")
- button.image?.isTemplate = true
- let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- button.image = button.image?.withSymbolConfiguration(cfg)
- setToggledStyle(button, isOn: isPinnedOnTop)
- }
- }
- @objc private func toggleGestureMode() {
- guard PremiumStore.shared.isPremiumUnlocked else {
- presentPremiumPaywall()
- return
- }
- isGestureModeEnabled.toggle()
- webView.allowsBackForwardNavigationGestures = isGestureModeEnabled
- if let button = handButton {
- let symbol = isGestureModeEnabled ? "hand.raised.fill" : "hand.raised"
- button.image = NSImage(systemSymbolName: symbol, accessibilityDescription: "Gesture Mode")
- button.image?.isTemplate = true
- let cfg = NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold)
- button.image = button.image?.withSymbolConfiguration(cfg)
- setToggledStyle(button, isOn: isGestureModeEnabled)
- }
- }
- /// Borderless `NSWindow` is square; SwiftUI `.sheet` gets rounded chrome automatically — match that look.
- private func configurePaywallWindowRoundedCorners(_ win: NSWindow) {
- guard let cv = win.contentView else { return }
- cv.wantsLayer = true
- let r: CGFloat = 16
- cv.layer?.cornerRadius = r
- if #available(macOS 11.0, *) {
- cv.layer?.cornerCurve = .continuous
- }
- cv.layer?.masksToBounds = true
- }
- private func presentPremiumPaywall() {
- guard let parentWindow = window else { return }
- if let existing = premiumSheetWindow, existing.isVisible {
- existing.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- return
- }
- let paywallFrame = PremiumPaywallWindowSizing.paywallFrame(forParentWindow: parentWindow)
- let paywallWindow = NSWindow(
- contentRect: paywallFrame,
- styleMask: [.borderless],
- backing: .buffered,
- defer: false
- )
- paywallWindow.isReleasedWhenClosed = false
- paywallWindow.delegate = self
- paywallWindow.isMovable = false
- paywallWindow.isMovableByWindowBackground = false
- paywallWindow.hasShadow = true
- paywallWindow.isOpaque = false
- paywallWindow.backgroundColor = .clear
- paywallWindow.minSize = paywallFrame.size
- paywallWindow.maxSize = paywallFrame.size
- let hosting = NSHostingController(
- rootView: PremiumFeaturesView(onDismissFromAppKitHost: { [weak self] in
- self?.closePremiumPaywallWindow()
- })
- .environmentObject(PremiumStore.shared)
- )
- hosting.view.frame = NSRect(origin: .zero, size: paywallFrame.size)
- hosting.view.autoresizingMask = [.width, .height]
- paywallWindow.contentViewController = hosting
- configurePaywallWindowRoundedCorners(paywallWindow)
- paywallWindow.setFrame(paywallFrame, display: false)
- premiumSheetWindow = paywallWindow
- parentWindow.addChildWindow(paywallWindow, ordered: .above)
- paywallWindow.makeKeyAndOrderFront(nil)
- NSApp.activate(ignoringOtherApps: true)
- }
- private func closePremiumPaywallWindow() {
- guard let w = premiumSheetWindow else { return }
- w.parent?.removeChildWindow(w)
- w.close()
- // `premiumSheetWindow` and toolbar refresh happen in `windowWillClose`.
- }
- /// Non‑Premium: only gesture, status bar, and desktop look locked; they stay enabled so taps open the paywall.
- private func applyPremiumLockedTitlebarAppearance() {
- let locked = !PremiumStore.shared.isPremiumUnlocked
- handButton.lockedForPaywallAppearance = locked
- addToDesktopButton.lockedForPaywallAppearance = locked
- addToStatusBarButton.lockedForPaywallAppearance = locked
- handButton.alphaValue = 1
- addToDesktopButton.alphaValue = 1
- addToStatusBarButton.alphaValue = 1
- if locked {
- handButton.isEnabled = true
- addToDesktopButton.isEnabled = true
- addToStatusBarButton.isEnabled = true
- }
- handButton.refreshVisuals()
- addToDesktopButton.refreshVisuals()
- addToStatusBarButton.refreshVisuals()
- }
- @objc private func openDownloads() {
- guard let downloads = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first else {
- showAlert(title: "Unavailable", message: "Couldn’t find your Downloads folder.")
- return
- }
- NSWorkspace.shared.open(downloads)
- }
- @objc private func showMoreMenu() {
- let menu = NSMenu()
- let reload = NSMenuItem(title: "Reload", action: #selector(reloadPage), keyEquivalent: "r")
- reload.keyEquivalentModifierMask = [.command]
- menu.addItem(reload)
- let home = NSMenuItem(title: "Home", action: #selector(goHome), keyEquivalent: "h")
- home.keyEquivalentModifierMask = [.command]
- menu.addItem(home)
- let downloads = NSMenuItem(title: "Open Downloads", action: #selector(openDownloads), keyEquivalent: "d")
- downloads.keyEquivalentModifierMask = [.command]
- menu.addItem(downloads)
- menu.addItem(NSMenuItem.separator())
- let close = NSMenuItem(title: "Close Window", action: #selector(closeWindowFromMenu), keyEquivalent: "w")
- close.keyEquivalentModifierMask = [.command]
- menu.addItem(close)
- menu.items.forEach { $0.target = self }
- let location = NSPoint(x: moreButton.bounds.midX, y: moreButton.bounds.minY - 4)
- menu.popUp(positioning: nil, at: location, in: moreButton)
- }
- @objc private func closeWindowFromMenu() {
- window?.performClose(nil)
- }
- /// WKWebView often reports the requested URL as `title` until the real document title is available.
- private func isLikelyURLString(_ raw: String) -> Bool {
- let t = raw.trimmingCharacters(in: .whitespacesAndNewlines)
- if t.isEmpty { return false }
- if t.hasPrefix("http://") || t.hasPrefix("https://") { return true }
- if t.contains("://") { return true }
- if let u = URL(string: t), u.scheme != nil, u.host != nil { return true }
- return false
- }
- private func updateSiteNameFromWebView() {
- let loading = webView.isLoading
- let host = webView.url?.host?.trimmingCharacters(in: .whitespacesAndNewlines)
- let rawTitle = webView.title?.trimmingCharacters(in: .whitespacesAndNewlines)
- let pageTitle: String? = {
- guard let rawTitle, !rawTitle.isEmpty else { return nil }
- return isLikelyURLString(rawTitle) ? nil : rawTitle
- }()
- // Prefer document title; never show hostname or URL-like strings while a load is in progress.
- let preferred: String
- if let pageTitle {
- preferred = pageTitle
- } else if loading {
- preferred = "Loading…"
- } else if let host, !host.isEmpty {
- preferred = host
- } else {
- preferred = "Browser"
- }
- siteNameLabel.stringValue = preferred
- window?.title = preferred
- guard let url = webView.url else {
- siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
- .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
- return
- }
- let key = (url.host ?? url.absoluteString).lowercased()
- guard currentSiteIconURLKey != key else { return }
- currentSiteIconURLKey = key
- Task { [weak self] in
- guard let self else { return }
- let fetched = await FaviconImageCache.shared.image(for: url)
- await MainActor.run {
- guard self.currentSiteIconURLKey == key else { return }
- if let fetched {
- self.siteIconView.image = fetched
- } else {
- self.siteIconView.image = NSImage(systemSymbolName: "globe", accessibilityDescription: "Site")?
- .withSymbolConfiguration(NSImage.SymbolConfiguration(pointSize: 12, weight: .semibold))
- self.siteIconView.contentTintColor = NSColor.secondaryLabelColor
- }
- }
- }
- }
- private func updateToolbarState() {
- backButton.isEnabled = webView.canGoBack
- forwardButton.isEnabled = webView.canGoForward
- // Keep reload enabled; it matches typical browser behavior.
- reloadButton.isEnabled = true
- // Sync center site name.
- updateSiteNameFromWebView()
- updateAddToStatusBarButtonAppearance()
- applyPremiumLockedTitlebarAppearance()
- }
- private func showAlert(title: String, message: String) {
- let alert = NSAlert()
- alert.messageText = title
- alert.informativeText = message
- alert.addButton(withTitle: "OK")
- alert.alertStyle = .informational
- alert.runModal()
- }
- /// Gemini’s SPA does not honor `?prompt=` on `/app`; after navigation we fill the visible composer (retries for async UI / sign-in).
- private func injectGeminiComposerPromptIfNeeded(webView: WKWebView) {
- guard let prompt = injectComposerPrompt, !geminiPromptWasInjected, !geminiInjectionIsActive else { return }
- guard let host = webView.url?.host?.lowercased(), host.contains("gemini.google.com") else { return }
- guard let jsonData = try? JSONSerialization.data(withJSONObject: [prompt]),
- let jsonArrayLiteral = String(data: jsonData, encoding: .utf8) else { return }
- geminiInjectionIsActive = true
- let script = """
- (function() {
- return new Promise(function(resolve) {
- var text = \(jsonArrayLiteral)[0];
- var n = 0;
- var timer = setInterval(function() {
- n++;
- function fillTextarea(el) {
- if (!el || !el.offsetParent) return false;
- var r = el.getBoundingClientRect();
- if (r.width < 8 || r.height < 8) return false;
- el.focus();
- el.value = text;
- el.dispatchEvent(new Event('input', { bubbles: true }));
- el.dispatchEvent(new Event('change', { bubbles: true }));
- return true;
- }
- function fillContentEditable(ce) {
- if (!ce || !ce.isContentEditable || !ce.offsetParent) return false;
- ce.focus();
- ce.innerText = text;
- ce.dispatchEvent(new InputEvent('input', { bubbles: true, data: text }));
- return true;
- }
- function tryFillInRoot(root) {
- var areas = root.querySelectorAll('textarea');
- var i, el;
- for (i = 0; i < areas.length; i++) {
- if (fillTextarea(areas[i])) return true;
- }
- var editables = root.querySelectorAll('[contenteditable="true"]');
- for (i = 0; i < editables.length; i++) {
- if (fillContentEditable(editables[i])) return true;
- }
- var nodes = root.querySelectorAll('*');
- for (i = 0; i < nodes.length; i++) {
- var sh = nodes[i].shadowRoot;
- if (sh && tryFillInRoot(sh)) return true;
- }
- return false;
- }
- function tryFill() {
- if (tryFillInRoot(document)) return true;
- return false;
- }
- if (tryFill()) { clearInterval(timer); resolve(true); }
- else if (n >= 80) { clearInterval(timer); resolve(false); }
- }, 250);
- });
- })();
- """
- webView.evaluateJavaScript(script) { [weak self] result, _ in
- guard let self else { return }
- self.geminiInjectionIsActive = false
- if (result as? NSNumber)?.boolValue == true {
- self.geminiPromptWasInjected = true
- }
- }
- }
- }
- extension InAppBrowserWindowController: WKNavigationDelegate {
- func webView(
- _ webView: WKWebView,
- decidePolicyFor navigationAction: WKNavigationAction,
- decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
- ) {
- if navigationAction.targetFrame == nil, let url = navigationAction.request.url {
- InAppBrowserWindowManager.shared.open(url: url)
- decisionHandler(.cancel)
- return
- }
- decisionHandler(.allow)
- }
- func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
- updateToolbarState()
- injectGeminiComposerPromptIfNeeded(webView: webView)
- }
- func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
- updateToolbarState()
- }
- func webView(_ webView: WKWebView, didCommit navigation: WKNavigation!) {
- updateToolbarState()
- }
- func webView(
- _ webView: WKWebView,
- didFailProvisionalNavigation navigation: WKNavigation!,
- withError error: Error
- ) {
- updateToolbarState()
- showLoadError(error)
- }
- func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
- updateToolbarState()
- showLoadError(error)
- }
- private func showLoadError(_ error: Error) {
- let message = """
- <html>
- <body style="background:#111;color:#EEE;font-family:-apple-system, sans-serif;padding:24px;">
- <h2>Page failed to load</h2>
- <p>Could not open <b>\(initialURL.absoluteString)</b></p>
- <p>\(error.localizedDescription)</p>
- </body>
- </html>
- """
- webView.loadHTMLString(message, baseURL: nil)
- window?.title = "Load Error"
- }
- }
- extension InAppBrowserWindowController: WKUIDelegate {
- func webView(
- _ webView: WKWebView,
- createWebViewWith configuration: WKWebViewConfiguration,
- for navigationAction: WKNavigationAction,
- windowFeatures: WKWindowFeatures
- ) -> WKWebView? {
- guard let url = navigationAction.request.url else { return nil }
- InAppBrowserWindowManager.shared.open(url: url)
- return nil
- }
- }
|