| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671 |
- import AppKit
- 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
- 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
- layer?.masksToBounds = true
- }
- 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 {
- 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
- }
- }
- }
- final class InAppBrowserWindowManager {
- static let shared = InAppBrowserWindowManager()
- private var controllers: [InAppBrowserWindowController] = []
- private init() {}
- func open(url: URL, title: String? = nil) {
- let controller = InAppBrowserWindowController(initialURL: url, windowTitle: title)
- 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
- 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 widgetButton: 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 func setToggledStyle(_ button: TitlebarIconButton, isOn: Bool) {
- button.setToggleActive(isOn)
- }
- init(initialURL: URL, windowTitle: String?) {
- self.initialURL = initialURL
- let config = WKWebViewConfiguration()
- config.defaultWebpagePreferences.allowsContentJavaScript = true
- self.webView = WKWebView(frame: .zero, configuration: config)
- 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)
- )
- // 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"
- )
- // 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
- widgetButton.target = self
- handButton.target = self
- downloadButton.target = self
- moreButton.target = self
- 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 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,
- widgetButton,
- 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) {
- onClose?()
- }
- @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 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() {
- 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)
- }
- }
- @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()
- }
- 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()
- }
- }
- 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()
- }
- 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
- }
- }
|