| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630 |
- import AppKit
- import WebKit
- 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
- // Toolbar UI (top navigation bar)
- private var backButton: NSButton!
- private var forwardButton: NSButton!
- private var reloadButton: NSButton!
- private var pinButton: NSButton!
- private var homeButton: NSButton!
- private var gridButton: NSButton!
- private var openInBrowserButton: NSButton!
- private var handButton: NSButton!
- private var downloadButton: NSButton!
- private var moreButton: NSButton!
- private var urlField: NSTextField!
- private var isUpdatingURLField = false
- private var didSubmitURLFromAction = false
- private var isPinnedOnTop = false
- private var isGestureModeEnabled = true
- private func setToggledStyle(_ button: NSButton, isOn: Bool) {
- button.layer?.backgroundColor = isOn
- ? NSColor.systemBlue.withAlphaComponent(0.28).cgColor
- : NSColor.white.withAlphaComponent(0.06).cgColor
- button.layer?.borderColor = isOn
- ? NSColor.systemBlue.withAlphaComponent(0.55).cgColor
- : NSColor.white.withAlphaComponent(0.10).cgColor
- }
- 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
- // Build UI: top toolbar + web view underneath.
- let containerView = NSView(frame: .zero)
- containerView.wantsLayer = true
- containerView.layer?.backgroundColor = NSColor.clear.cgColor
- window.contentView = containerView
- let toolbarHeight: CGFloat = 46
- let toolbarContainer = NSView()
- toolbarContainer.translatesAutoresizingMaskIntoConstraints = false
- toolbarContainer.wantsLayer = true
- toolbarContainer.layer?.cornerRadius = 14
- toolbarContainer.layer?.masksToBounds = true
- containerView.addSubview(toolbarContainer)
- // Blurred background like a modern browser toolbar.
- let toolbarBlur = NSVisualEffectView()
- toolbarBlur.material = .hudWindow
- toolbarBlur.blendingMode = .behindWindow
- toolbarBlur.state = .active
- toolbarBlur.translatesAutoresizingMaskIntoConstraints = false
- toolbarContainer.addSubview(toolbarBlur)
- let toolbarView = NSStackView()
- toolbarView.orientation = .horizontal
- toolbarView.alignment = .centerY
- toolbarView.spacing = 10
- toolbarView.translatesAutoresizingMaskIntoConstraints = false
- toolbarContainer.addSubview(toolbarView)
- toolbarBlur.wantsLayer = true
- toolbarBlur.layer?.cornerRadius = 14
- toolbarBlur.layer?.masksToBounds = true
- func makeIconButton(symbolName: String, accessibility: String, action: Selector, fallbackSymbolName: String? = nil) -> NSButton {
- let button = NSButton()
- 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.isBordered = false
- button.contentTintColor = .white
- button.imagePosition = .imageOnly
- button.imageScaling = .scaleProportionallyDown
- button.setButtonType(.momentaryChange)
- button.action = action
- button.setButtonType(.momentaryChange)
- // Subtle circular hit target like the screenshot.
- button.wantsLayer = true
- button.layer?.cornerRadius = 12
- button.layer?.masksToBounds = true
- button.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.06).cgColor
- button.layer?.borderColor = NSColor.white.withAlphaComponent(0.10).cgColor
- button.layer?.borderWidth = 1
- button.translatesAutoresizingMaskIntoConstraints = false
- button.widthAnchor.constraint(equalToConstant: 26).isActive = true
- button.heightAnchor.constraint(equalToConstant: 26).isActive = true
- if button.image == nil {
- // Last-resort fallback if SF Symbols aren’t available for some reason.
- button.title = "⋮"
- button.font = NSFont.systemFont(ofSize: 14, weight: .semibold)
- button.contentTintColor = NSColor.white.withAlphaComponent(0.9)
- } else {
- button.title = ""
- }
- button.toolTip = accessibility
- 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)
- )
- // Grid (show launcher)
- gridButton = makeIconButton(
- symbolName: "square.grid.2x2",
- accessibility: "Show Launcher",
- action: #selector(showLauncher)
- )
- // URL "pill" wrapper.
- let urlWrapperView = NSView()
- urlWrapperView.translatesAutoresizingMaskIntoConstraints = false
- urlWrapperView.wantsLayer = true
- urlWrapperView.layer?.cornerRadius = 12
- urlWrapperView.layer?.masksToBounds = true
- urlWrapperView.layer?.backgroundColor = NSColor.white.withAlphaComponent(0.07).cgColor
- urlWrapperView.layer?.borderColor = NSColor.white.withAlphaComponent(0.18).cgColor
- urlWrapperView.layer?.borderWidth = 1
- // URL field (inside pill)
- urlField = NSTextField()
- urlField.translatesAutoresizingMaskIntoConstraints = false
- urlField.action = #selector(urlFieldDidSubmit)
- urlField.placeholderString = "Search or enter website name"
- urlField.font = NSFont.systemFont(ofSize: 13, weight: .medium)
- urlField.textColor = .white
- urlField.alignment = .left
- urlField.isEditable = true
- urlField.isBordered = false
- urlField.drawsBackground = false
- urlField.focusRingType = .none
- // Optional "lock" glyph like browsers (purely visual).
- let lockImageView = NSImageView()
- lockImageView.translatesAutoresizingMaskIntoConstraints = false
- lockImageView.image = NSImage(systemSymbolName: "lock.fill", accessibilityDescription: "Secure")
- lockImageView.symbolConfiguration = NSImage.SymbolConfiguration(pointSize: 11, weight: .semibold)
- lockImageView.contentTintColor = NSColor.white.withAlphaComponent(0.7)
- lockImageView.imageScaling = .scaleProportionallyDown
- let urlInnerStack = NSStackView(views: [lockImageView, urlField])
- urlInnerStack.orientation = .horizontal
- urlInnerStack.alignment = .centerY
- urlInnerStack.spacing = 8
- urlInnerStack.translatesAutoresizingMaskIntoConstraints = false
- urlWrapperView.addSubview(urlInnerStack)
- NSLayoutConstraint.activate([
- lockImageView.widthAnchor.constraint(equalToConstant: 14),
- lockImageView.heightAnchor.constraint(equalToConstant: 14),
- urlInnerStack.leadingAnchor.constraint(equalTo: urlWrapperView.leadingAnchor, constant: 12),
- urlInnerStack.trailingAnchor.constraint(equalTo: urlWrapperView.trailingAnchor, constant: -12),
- urlInnerStack.topAnchor.constraint(equalTo: urlWrapperView.topAnchor, constant: 6),
- urlInnerStack.bottomAnchor.constraint(equalTo: urlWrapperView.bottomAnchor, constant: -6),
- urlWrapperView.heightAnchor.constraint(equalToConstant: 30),
- ])
- // Open in external browser
- openInBrowserButton = makeIconButton(
- symbolName: "arrow.up.right.square",
- accessibility: "Open in Browser",
- action: #selector(openInBrowser)
- )
- // 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"
- )
- toolbarView.addArrangedSubview(backButton)
- toolbarView.addArrangedSubview(forwardButton)
- toolbarView.addArrangedSubview(reloadButton)
- toolbarView.addArrangedSubview(urlWrapperView)
- toolbarView.addArrangedSubview(pinButton)
- toolbarView.addArrangedSubview(homeButton)
- toolbarView.addArrangedSubview(gridButton)
- toolbarView.addArrangedSubview(openInBrowserButton)
- toolbarView.addArrangedSubview(handButton)
- toolbarView.addArrangedSubview(downloadButton)
- toolbarView.addArrangedSubview(moreButton)
- // Give the URL field more space than the icon buttons.
- urlWrapperView.setContentHuggingPriority(.defaultLow, for: .horizontal)
- urlWrapperView.setContentCompressionResistancePriority(.defaultHigh, for: .horizontal)
- // (URL layout constraints are defined via urlInnerStack above)
- // Web view below toolbar.
- webView.translatesAutoresizingMaskIntoConstraints = false
- webView.setValue(false, forKey: "drawsBackground")
- containerView.addSubview(webView)
- NSLayoutConstraint.activate([
- toolbarContainer.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 10),
- toolbarContainer.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -10),
- toolbarContainer.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 10),
- toolbarContainer.heightAnchor.constraint(equalToConstant: toolbarHeight),
- toolbarBlur.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor),
- toolbarBlur.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor),
- toolbarBlur.topAnchor.constraint(equalTo: toolbarContainer.topAnchor),
- toolbarBlur.bottomAnchor.constraint(equalTo: toolbarContainer.bottomAnchor),
- toolbarView.leadingAnchor.constraint(equalTo: toolbarContainer.leadingAnchor, constant: 12),
- toolbarView.trailingAnchor.constraint(equalTo: toolbarContainer.trailingAnchor, constant: -12),
- toolbarView.topAnchor.constraint(equalTo: toolbarContainer.topAnchor, constant: 6),
- toolbarView.bottomAnchor.constraint(equalTo: toolbarContainer.bottomAnchor, constant: -6),
- webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
- webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
- webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor),
- webView.topAnchor.constraint(equalTo: toolbarContainer.bottomAnchor, constant: 0),
- ])
- super.init(window: window)
- window.delegate = self
- // Safe to reference `self` only after super.init.
- backButton.target = self
- forwardButton.target = self
- reloadButton.target = self
- pinButton.target = self
- homeButton.target = self
- gridButton.target = self
- openInBrowserButton.target = self
- handButton.target = self
- downloadButton.target = self
- moreButton.target = self
- urlField.delegate = self
- urlField.target = self
- webView.uiDelegate = self
- webView.navigationDelegate = self
- webView.allowsBackForwardNavigationGestures = true
- isGestureModeEnabled = true
- // Initialize toolbar state.
- setToggledStyle(pinButton, isOn: isPinnedOnTop)
- setToggledStyle(handButton, isOn: isGestureModeEnabled)
- urlField.stringValue = initialURL.absoluteString
- updateToolbarState()
- }
- @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 openInBrowser() {
- guard let url = webView.url else { return }
- NSWorkspace.shared.open(url)
- }
- @objc private func goHome() {
- webView.load(URLRequest(url: initialURL))
- }
- @objc private func showLauncher() {
- // Bring the main app window (launcher) to front.
- if let main = NSApp.windows.first {
- main.makeKeyAndOrderFront(nil)
- }
- NSApp.activate(ignoringOtherApps: true)
- }
- @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
- 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
- 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 copyURL = NSMenuItem(title: "Copy URL", action: #selector(copyCurrentURL), keyEquivalent: "c")
- copyURL.keyEquivalentModifierMask = [.command]
- menu.addItem(copyURL)
- menu.addItem(NSMenuItem.separator())
- 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)
- let openExternal = NSMenuItem(title: "Open in Default Browser", action: #selector(openInBrowser), keyEquivalent: "o")
- openExternal.keyEquivalentModifierMask = [.command]
- menu.addItem(openExternal)
- 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 copyCurrentURL() {
- guard let s = webView.url?.absoluteString, !s.isEmpty else { return }
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(s, forType: .string)
- }
- @objc private func closeWindowFromMenu() {
- window?.performClose(nil)
- }
- @objc private func urlFieldDidSubmit() {
- didSubmitURLFromAction = true
- loadFromURLField()
- }
- private func loadFromURLField() {
- let raw = urlField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines)
- guard !raw.isEmpty else { return }
- let normalized: String
- if raw.lowercased().hasPrefix("http://") || raw.lowercased().hasPrefix("https://") {
- normalized = raw
- } else {
- normalized = "https://\(raw)"
- }
- guard let url = URL(string: normalized) else {
- revertURLFieldToCurrentWebURL()
- return
- }
- // Keep normalized URL visible immediately after submit.
- isUpdatingURLField = true
- urlField.stringValue = normalized
- isUpdatingURLField = false
- webView.load(URLRequest(url: url))
- }
- private func revertURLFieldToCurrentWebURL() {
- guard let url = webView.url?.absoluteString else { return }
- isUpdatingURLField = true
- urlField.stringValue = url
- isUpdatingURLField = false
- }
- private var isURLFieldBeingEdited: Bool {
- urlField.currentEditor() != nil
- }
- private func setURLFieldToCurrentWebURL() {
- guard let url = webView.url?.absoluteString else { return }
- guard !isUpdatingURLField else { return }
- // Avoid overriding manual typing unless this update follows a submit.
- // However, right after navigation starts the text field can still be
- // considered "editing" (first responder), which would otherwise keep
- // the URL pill blank/stale. If it's empty, we should populate it.
- if isURLFieldBeingEdited && !didSubmitURLFromAction {
- if urlField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- // proceed
- } else {
- return
- }
- }
- isUpdatingURLField = true
- urlField.stringValue = url
- isUpdatingURLField = false
- didSubmitURLFromAction = false
- }
- private func updateToolbarState() {
- backButton.isEnabled = webView.canGoBack
- forwardButton.isEnabled = webView.canGoForward
- // Keep reload enabled; it matches typical browser behavior.
- reloadButton.isEnabled = true
- // Sync URL field and window title.
- setURLFieldToCurrentWebURL()
- }
- 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!) {
- if let host = webView.url?.host, !host.isEmpty {
- window?.title = host
- }
- 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: NSTextFieldDelegate {
- func controlTextDidEndEditing(_ obj: Notification) {
- guard obj.object as? NSTextField === urlField else { return }
- guard !isUpdatingURLField else { return }
- // Return key triggers both action and end-editing callbacks.
- // Prevent duplicate loads so history/back-forward stays stable.
- if didSubmitURLFromAction {
- didSubmitURLFromAction = false
- return
- }
- loadFromURLField()
- }
- }
- 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
- }
- }
|