diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index d7c4cb9aa..99f0407f5 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; A5001403 /* TerminalPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001413 /* TerminalPanelView.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; + A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; @@ -180,6 +181,7 @@ A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; A5001413 /* TerminalPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/TerminalPanelView.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; + A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; @@ -387,6 +389,7 @@ A5001412 /* BrowserPanel.swift */, A5001413 /* TerminalPanelView.swift */, A5001414 /* BrowserPanelView.swift */, + A5007421 /* BrowserPopupWindowController.swift */, A5001418 /* MarkdownPanel.swift */, A5001419 /* MarkdownPanelView.swift */, A5001510 /* CmuxWebView.swift */, @@ -659,6 +662,7 @@ A5001402 /* BrowserPanel.swift in Sources */, A5001403 /* TerminalPanelView.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index e425a9676..69f4d1b8b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -7276,6 +7276,23 @@ } } }, + "browser.popup.loadingTitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Loading\u2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\u8aad\u307f\u8fbc\u307f\u4e2d\u2026" + } + } + } + }, "browser.proceedInCmux": { "extractionState": "manual", "localizations": { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6fe2b6984..92e50a6b2 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8350,7 +8350,18 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent event: event, shortcut: StoredShortcut(key: "w", command: true, shift: false, option: false, control: false) ) { - if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, + // Browser popup windows primarily intercept Cmd+W in BrowserPopupPanel. + // This AppDelegate path is a fallback for cases where AppKit routes the + // event through the global shortcut handler first. + if let targetWindow = [NSApp.keyWindow, event.window] + .compactMap({ $0 }) + .first(where: { $0.identifier?.rawValue == "cmux.browser-popup" }) { +#if DEBUG + dlog("shortcut.cmdW route=browserPopup") +#endif + targetWindow.performClose(nil) + return true + } else if let targetWindow = event.window ?? NSApp.keyWindow ?? NSApp.mainWindow, cmuxWindowShouldOwnCloseShortcut(targetWindow) { targetWindow.performClose(nil) } else { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2ea023ea1..833fe93c7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1268,6 +1268,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Shared process pool for cookie sharing across all browser panels private static let sharedProcessPool = WKProcessPool() + /// Popup windows owned by this panel (for lifecycle cleanup) + private var popupControllers: [BrowserPopupWindowController] = [] + static let telemetryHookBootstrapScriptSource = """ (() => { if (window.__cmuxHooksInstalled) return true; @@ -2014,6 +2017,9 @@ final class BrowserPanel: Panel, ObservableObject { self?.endDownloadActivity() } } + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + self?.openLinkInNewTab(url: url) + } webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) @@ -2097,6 +2103,9 @@ final class BrowserPanel: Panel, ObservableObject { browserUIDelegate.requestNavigation = { [weak self] request, intent in self?.requestNavigation(request, intent: intent) } + browserUIDelegate.openPopup = { [weak self] configuration, windowFeatures in + self?.createFloatingPopup(configuration: configuration, windowFeatures: windowFeatures) + } self.uiDelegate = browserUIDelegate bindWebView(webView) @@ -2373,6 +2382,17 @@ final class BrowserPanel: Panel, ObservableObject { // Ensure we don't keep a hidden WKWebView (or its content view) as first responder while // bonsplit/SwiftUI reshuffles views during close. unfocus() + + // Snapshot first: popup close unregisters itself from popupControllers. + let popupsToClose = popupControllers + popupControllers.removeAll() + + // Close all owned popup windows before tearing down delegates + for popup in popupsToClose { + popup.closeAllChildPopups() + popup.closePopup() + } + webView.stopLoading() webView.navigationDelegate = nil webView.uiDelegate = nil @@ -2384,6 +2404,25 @@ final class BrowserPanel: Panel, ObservableObject { faviconTask = nil } + // MARK: - Popup window management + + func createFloatingPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let controller = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: self + ) + popupControllers.append(controller) + return controller.webView + } + + func removePopupController(_ controller: BrowserPopupWindowController) { + popupControllers.removeAll { $0 === controller } + } + private func refreshFavicon(from webView: WKWebView) { faviconTask?.cancel() faviconTask = nil @@ -4438,7 +4477,7 @@ private extension NSObject { /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI /// during WebKit callbacks), then showing NSSavePanel after the download finishes. -private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { +class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState { let tempURL: URL let suggestedFilename: String @@ -4596,6 +4635,25 @@ func browserNavigationShouldOpenInNewTab( return false } +func browserNavigationShouldCreatePopup( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + let isUserNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) + return navigationType == .other && !isUserNewTab +} + private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var didFinish: ((WKWebView) -> Void)? var didFailNavigation: ((WKWebView, String) -> Void)? @@ -4833,7 +4891,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } - // target=_blank or window.open() — open in a new tab. + // Catch-all for nil-target navigations where createWebViewWith + // returned nil: external URLs, user-initiated new-window actions + // (target=_blank, context menu) that fall through the classifier, + // or when popup creation is unavailable. if navigationAction.targetFrame == nil, let url = navigationAction.request.url { #if DEBUG @@ -4925,6 +4986,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? private func javaScriptDialogTitle(for webView: WKWebView) -> String { if let absolute = webView.url?.absoluteString, !absolute.isEmpty { @@ -4945,17 +5007,17 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { completion(alert.runModal()) } - /// Returning nil tells WebKit not to open a new window. - /// createWebViewWith is only called when the page requests a new window - /// (window.open(), target=_blank, etc.). Always open in a new tab. + /// Called when the page requests a new window (window.open(), target=_blank, etc.). + /// + /// Returns a live popup WKWebView created with WebKit's supplied configuration + /// to preserve popup browsing-context semantics (window.opener, postMessage). + /// Falls back to new-tab behavior only if popup creation is unavailable. func webView( _ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures ) -> WKWebView? { - // createWebViewWith is only called when the page requests a new window, - // so always treat as new-tab intent regardless of modifiers/button. #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -4963,21 +5025,45 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { dlog( "browser.nav.createWebView navType=\(navType) button=\(navigationAction.buttonNumber) " + "mods=\(navigationAction.modifierFlags.rawValue) targetNil=\(navigationAction.targetFrame == nil ? 1 : 0) " + - "eventType=\(currentEventType) eventButton=\(currentEventButton) " + - "openInNewTab=1" + "eventType=\(currentEventType) eventButton=\(currentEventButton)" ) #endif - if let url = navigationAction.request.url { - if browserShouldOpenURLExternally(url) { - let opened = NSWorkspace.shared.open(url) - if !opened { - NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) - } - #if DEBUG - dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") - #endif - return nil + // External URL schemes → hand off to macOS, don't create a popup + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + let opened = NSWorkspace.shared.open(url) + if !opened { + NSLog("BrowserPanel external navigation failed to open URL: %@", url.absoluteString) } + #if DEBUG + dlog("browser.navigation.external source=uiDelegate opened=\(opened ? 1 : 0) url=\(url.absoluteString)") + #endif + return nil + } + + // Classifier: only scripted requests (window.open()) get popup windows. + // User-initiated actions (link clicks, context menu "Open Link in New Tab", + // Cmd+click, middle-click) fall through to existing new-tab behavior. + // + // WebKit sometimes delivers .other for Cmd+click / middle-click, so we + // reuse browserNavigationShouldOpenInNewTab to recover user intent before + // treating .other as a scripted popup. + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup, let popupWebView = openPopup?(configuration, windowFeatures) { +#if DEBUG + dlog("browser.nav.createWebView.action kind=popup") +#endif + return popupWebView + } + + // Fallback: open in new tab (no opener linkage) + if let url = navigationAction.request.url { if let requestNavigation { let intent: BrowserInsecureHTTPNavigationIntent = .newTab #if DEBUG diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift new file mode 100644 index 000000000..692e63767 --- /dev/null +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -0,0 +1,619 @@ +import AppKit +import Bonsplit +import ObjectiveC +import WebKit + +func browserPopupContentRect( + requestedWidth: CGFloat?, + requestedHeight: CGFloat?, + requestedX: CGFloat?, + requestedTopY: CGFloat?, + visibleFrame: NSRect, + defaultWidth: CGFloat = 800, + defaultHeight: CGFloat = 600, + minWidth: CGFloat = 200, + minHeight: CGFloat = 150 +) -> NSRect { + let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width) + let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height) + + let x: CGFloat + let y: CGFloat + if let requestedX, let requestedTopY { + x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth)) + + // Web content expresses popup Y as distance from the screen's top edge, + // while AppKit window origins are bottom-up. + let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight + y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight)) + } else { + x = visibleFrame.midX - clampedWidth / 2 + y = visibleFrame.midY - clampedHeight / 2 + } + + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) +} + +/// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page +/// calls `window.open()` (scripted new-window requests). +/// +/// Lifecycle: +/// - The controller self-retains via `objc_setAssociatedObject` on its panel. +/// - Released in `windowWillClose(_:)` when the panel closes. +/// - The opener `BrowserPanel` also keeps a strong reference for deterministic +/// cleanup when the opener tab or workspace is closed. +/// NSPanel subclass that intercepts Cmd+W before the swizzled +/// `cmux_performKeyEquivalent` can dispatch it to the main menu's +/// "Close Tab" action (which would close the parent browser tab). +private class BrowserPopupPanel: NSPanel { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + // Cmd+W: close this popup panel only + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags == .command, + event.charactersIgnoringModifiers == "w" { + #if DEBUG + dlog("popup.panel.cmdW close") + #endif + performClose(nil) + return true + } + return super.performKeyEquivalent(with: event) + } +} + +@MainActor +final class BrowserPopupWindowController: NSObject, NSWindowDelegate { + + static let maxNestingDepth = 3 + + let webView: CmuxWebView + private let panel: NSPanel + private let urlLabel: NSTextField + private weak var openerPanel: BrowserPanel? + private weak var parentPopupController: BrowserPopupWindowController? + private let nestingDepth: Int + private var titleObservation: NSKeyValueObservation? + private var urlObservation: NSKeyValueObservation? + private var childPopups: [BrowserPopupWindowController] = [] + private let popupUIDelegate: PopupUIDelegate + private let popupNavigationDelegate: PopupNavigationDelegate + private let downloadDelegate: BrowserDownloadDelegate + + private static var associatedObjectKey: UInt8 = 0 + + init( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures, + openerPanel: BrowserPanel?, + parentPopupController: BrowserPopupWindowController? = nil, + nestingDepth: Int = 0 + ) { + self.openerPanel = openerPanel + self.parentPopupController = parentPopupController + self.nestingDepth = nestingDepth + + // Create popup web view with WebKit's supplied configuration (preserves + // internal browsing-context state for opener linkage / postMessage). + let webView = CmuxWebView(frame: .zero, configuration: configuration) + webView.allowsBackForwardNavigationGestures = true + if #available(macOS 13.3, *) { + webView.isInspectable = true + } + webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent + self.webView = webView + + // --- Window sizing from WKWindowFeatures --- + let defaultWidth: CGFloat = 800 + let defaultHeight: CGFloat = 600 + let minWidth: CGFloat = 200 + let minHeight: CGFloat = 150 + + let w = max(windowFeatures.width?.doubleValue ?? defaultWidth, minWidth) + let h = max(windowFeatures.height?.doubleValue ?? defaultHeight, minHeight) + + // Screen-clamping: use opener's screen or main screen + let screen = openerPanel?.webView.window?.screen ?? NSScreen.main ?? NSScreen.screens.first + let visibleFrame = screen?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900) + let contentRect = browserPopupContentRect( + requestedWidth: w, + requestedHeight: h, + requestedX: windowFeatures.x.map { CGFloat($0.doubleValue) }, + requestedTopY: windowFeatures.y.map { CGFloat($0.doubleValue) }, + visibleFrame: visibleFrame, + defaultWidth: defaultWidth, + defaultHeight: defaultHeight, + minWidth: minWidth, + minHeight: minHeight + ) + + // Style mask: titled + closable + resizable by default. + // allowsResizing is a separate property from chrome-visibility flags + // (toolbarsVisibility, menuBarVisibility, statusBarVisibility). + var styleMask: NSWindow.StyleMask = [.titled, .closable, .miniaturizable] + if windowFeatures.allowsResizing?.boolValue != false { + styleMask.insert(.resizable) + } + + let panel = BrowserPopupPanel( + contentRect: contentRect, + styleMask: styleMask, + backing: .buffered, + defer: false + ) + panel.identifier = NSUserInterfaceItemIdentifier("cmux.browser-popup") + panel.level = NSWindow.Level.normal + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.minSize = NSSize(width: minWidth, height: minHeight) + panel.title = String(localized: "browser.popup.loadingTitle", defaultValue: "Loading\u{2026}") + self.panel = panel + + let urlLabel = NSTextField(labelWithString: "") + self.urlLabel = urlLabel + + // Build delegate objects before super.init so they can be assigned + let uiDel = PopupUIDelegate() + let navDel = PopupNavigationDelegate() + let dlDel = BrowserDownloadDelegate() + self.popupUIDelegate = uiDel + self.popupNavigationDelegate = navDel + self.downloadDelegate = dlDel + + super.init() + + // --- URL label for phishing protection --- + urlLabel.translatesAutoresizingMaskIntoConstraints = false + urlLabel.font = .systemFont(ofSize: 11) + urlLabel.textColor = .secondaryLabelColor + urlLabel.lineBreakMode = .byTruncatingMiddle + urlLabel.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + + let containerView = NSView() + containerView.translatesAutoresizingMaskIntoConstraints = false + containerView.addSubview(urlLabel) + containerView.addSubview(webView) + webView.translatesAutoresizingMaskIntoConstraints = false + + panel.contentView = containerView + NSLayoutConstraint.activate([ + urlLabel.topAnchor.constraint(equalTo: containerView.topAnchor, constant: 4), + urlLabel.leadingAnchor.constraint(equalTo: containerView.leadingAnchor, constant: 8), + urlLabel.trailingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: -8), + urlLabel.heightAnchor.constraint(equalToConstant: 16), + + webView.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 2), + webView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + webView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + webView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + ]) + + // --- Delegates --- + uiDel.controller = self + navDel.controller = self + navDel.downloadDelegate = dlDel + webView.uiDelegate = uiDel + webView.navigationDelegate = navDel + + // Context menu "Open Link in New Tab" → open in opener's workspace, + // not as a nested popup. Falls back to system browser if opener is gone. + webView.onContextMenuOpenLinkInNewTab = { [weak self] url in + if let opener = self?.openerPanel { + opener.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // --- KVO for title and URL --- + titleObservation = webView.observe(\.title, options: [.new]) { [weak self] _, change in + guard let newTitle = change.newValue ?? nil, !newTitle.isEmpty else { return } + Task { @MainActor [weak self] in + self?.panel.title = newTitle + } + } + urlObservation = webView.observe(\.url, options: [.new]) { [weak self] _, change in + let displayURL = change.newValue??.absoluteString ?? "" + Task { @MainActor [weak self] in + self?.urlLabel.stringValue = displayURL + } + } + + // --- Self-retention via associated object on panel --- + objc_setAssociatedObject(panel, &Self.associatedObjectKey, self, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + + panel.delegate = self + + #if DEBUG + dlog("popup.init depth=\(nestingDepth) size=\(Int(contentRect.width))x\(Int(contentRect.height)) opener=\(openerPanel?.id.uuidString.prefix(5) ?? "nil")") + #endif + + panel.makeKeyAndOrderFront(self) + } + + // MARK: - Child popup tracking + + func addChildPopup(_ child: BrowserPopupWindowController) { + childPopups.append(child) + } + + func removeChildPopup(_ child: BrowserPopupWindowController) { + childPopups.removeAll { $0 === child } + } + + // MARK: - Popup lifecycle + + func closePopup() { + panel.close() // triggers windowWillClose + } + + func closeAllChildPopups() { + let children = childPopups + childPopups.removeAll() + for child in children { + child.closeAllChildPopups() + child.closePopup() + } + } + + // MARK: - NSWindowDelegate + + func windowWillClose(_ notification: Notification) { + #if DEBUG + dlog("popup.close depth=\(nestingDepth)") + #endif + + closeAllChildPopups() + + // Invalidate observations + titleObservation?.invalidate() + titleObservation = nil + urlObservation?.invalidate() + urlObservation = nil + + // Tear down web view + webView.stopLoading() + webView.navigationDelegate = nil + webView.uiDelegate = nil + + // Unregister from parent (opener panel or parent popup) + openerPanel?.removePopupController(self) + parentPopupController?.removeChildPopup(self) + + // Release self-retention + objc_setAssociatedObject(panel, &Self.associatedObjectKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + // MARK: - Nested popup creation + + func createNestedPopup( + configuration: WKWebViewConfiguration, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + let nextDepth = nestingDepth + 1 + if nextDepth > Self.maxNestingDepth { + #if DEBUG + dlog("popup.nested.blocked depth=\(nextDepth) max=\(Self.maxNestingDepth)") + #endif + return nil + } + let child = BrowserPopupWindowController( + configuration: configuration, + windowFeatures: windowFeatures, + openerPanel: openerPanel, + parentPopupController: self, + nestingDepth: nextDepth + ) + addChildPopup(child) + return child.webView + } + + func openInOpenerTab(_ url: URL) { + if let openerPanel { + openerPanel.openLinkInNewTab(url: url) + } else { + NSWorkspace.shared.open(url) + } + } + + // MARK: - Insecure HTTP prompt (parity with main browser) + + /// Shows the same 3-button insecure HTTP alert as the main browser. + /// Reuses the global helpers from BrowserPanel.swift. + fileprivate func presentInsecureHTTPAlert( + for url: URL, + in webView: WKWebView, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { + decisionHandler(.cancel) + return + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure") + alert.informativeText = String(localized: "browser.error.insecure.message", defaultValue: "\(host) uses plain HTTP, so traffic can be read or modified on the network.\n\nOpen this URL in your default browser, or proceed in cmux.") + alert.addButton(withTitle: String(localized: "browser.openInDefaultBrowser", defaultValue: "Open in Default Browser")) + alert.addButton(withTitle: String(localized: "browser.proceedInCmux", defaultValue: "Proceed in cmux")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + alert.showsSuppressionButton = true + alert.suppressionButton?.title = String(localized: "browser.alwaysAllowHost", defaultValue: "Always allow this host in cmux") + + let handleResponse: (NSApplication.ModalResponse) -> Void = { [weak alert] response in + if browserShouldPersistInsecureHTTPAllowlistSelection( + response: response, + suppressionEnabled: alert?.suppressionButton?.state == .on + ) { + BrowserInsecureHTTPSettings.addAllowedHost(host) + } + switch response { + case .alertFirstButtonReturn: + // Open in default browser, cancel popup navigation + NSWorkspace.shared.open(url) + decisionHandler(.cancel) + case .alertSecondButtonReturn: + // Proceed in popup + decisionHandler(.allow) + default: + decisionHandler(.cancel) + } + } + + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: handleResponse) + return + } + handleResponse(alert.runModal()) + } +} + +// MARK: - PopupUIDelegate + +private class PopupUIDelegate: NSObject, WKUIDelegate { + weak var controller: BrowserPopupWindowController? + + func webViewDidClose(_ webView: WKWebView) { + #if DEBUG + dlog("popup.webViewDidClose") + #endif + controller?.closePopup() + } + + func webView( + _ webView: WKWebView, + createWebViewWith configuration: WKWebViewConfiguration, + for navigationAction: WKNavigationAction, + windowFeatures: WKWindowFeatures + ) -> WKWebView? { + // External URL check + if let url = navigationAction.request.url, + browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + return nil + } + + let isScriptedPopup = browserNavigationShouldCreatePopup( + navigationType: navigationAction.navigationType, + modifierFlags: navigationAction.modifierFlags, + buttonNumber: navigationAction.buttonNumber, + hasRecentMiddleClickIntent: CmuxWebView.hasRecentMiddleClickIntent(for: webView) + ) + + if isScriptedPopup { + return controller?.createNestedPopup( + configuration: configuration, + windowFeatures: windowFeatures + ) + } + + if let url = navigationAction.request.url { + controller?.openInOpenerTab(url) + } + return nil + } + + // MARK: - JS Dialogs (parity with main browser) + + private func javaScriptDialogTitle(for webView: WKWebView) -> String { + if let absolute = webView.url?.absoluteString, !absolute.isEmpty { + return String(localized: "browser.dialog.pageSaysAt", defaultValue: "The page at \(absolute) says:") + } + return String(localized: "browser.dialog.pageSays", defaultValue: "This page says:") + } + + private func presentDialog( + _ alert: NSAlert, + for webView: WKWebView, + completion: @escaping (NSApplication.ModalResponse) -> Void + ) { + if let window = webView.window { + alert.beginSheetModal(for: window, completionHandler: completion) + return + } + completion(alert.runModal()) + } + + func webView( + _ webView: WKWebView, + runJavaScriptAlertPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping () -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + presentDialog(alert, for: webView) { _ in completionHandler() } + } + + func webView( + _ webView: WKWebView, + runJavaScriptConfirmPanelWithMessage message: String, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (Bool) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = message + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + presentDialog(alert, for: webView) { response in + completionHandler(response == .alertFirstButtonReturn) + } + } + + func webView( + _ webView: WKWebView, + runJavaScriptTextInputPanelWithPrompt prompt: String, + defaultText: String?, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping (String?) -> Void + ) { + let alert = NSAlert() + alert.alertStyle = .informational + alert.messageText = javaScriptDialogTitle(for: webView) + alert.informativeText = prompt + alert.addButton(withTitle: String(localized: "common.ok", defaultValue: "OK")) + alert.addButton(withTitle: String(localized: "common.cancel", defaultValue: "Cancel")) + + let field = NSTextField(frame: NSRect(x: 0, y: 0, width: 320, height: 24)) + field.stringValue = defaultText ?? "" + alert.accessoryView = field + + presentDialog(alert, for: webView) { response in + if response == .alertFirstButtonReturn { + completionHandler(field.stringValue) + } else { + completionHandler(nil) + } + } + } + + func webView( + _ webView: WKWebView, + runOpenPanelWith parameters: WKOpenPanelParameters, + initiatedByFrame frame: WKFrameInfo, + completionHandler: @escaping ([URL]?) -> Void + ) { + let panel = NSOpenPanel() + panel.allowsMultipleSelection = parameters.allowsMultipleSelection + panel.canChooseDirectories = parameters.allowsDirectories + panel.canChooseFiles = true + panel.begin { result in + completionHandler(result == .OK ? panel.urls : nil) + } + } + + func webView( + _ webView: WKWebView, + requestMediaCapturePermissionFor origin: WKSecurityOrigin, + initiatedByFrame frame: WKFrameInfo, + type: WKMediaCaptureType, + decisionHandler: @escaping (WKPermissionDecision) -> Void + ) { + decisionHandler(.prompt) + } +} + +// MARK: - PopupNavigationDelegate + +private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { + weak var controller: BrowserPopupWindowController? + var downloadDelegate: WKDownloadDelegate? + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationAction: WKNavigationAction, + decisionHandler: @escaping (WKNavigationActionPolicy) -> Void + ) { + // Only guard main-frame navigations + guard navigationAction.targetFrame?.isMainFrame != false else { + decisionHandler(.allow) + return + } + + guard let url = navigationAction.request.url else { + decisionHandler(.allow) + return + } + + // External URL schemes → hand off to macOS + if browserShouldOpenURLExternally(url) { + NSWorkspace.shared.open(url) + #if DEBUG + dlog("popup.nav.external url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } + + // Insecure HTTP → show same prompt as main browser + if browserShouldBlockInsecureHTTPURL(url) { + #if DEBUG + dlog("popup.nav.insecureHTTP url=\(url.absoluteString)") + #endif + controller?.presentInsecureHTTPAlert(for: url, in: webView, decisionHandler: decisionHandler) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + decisionHandler(.allow) + return + } + + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + decisionHandler(.download) + return + } + } + + if !navigationResponse.canShowMIMEType { + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView( + _ webView: WKWebView, + didReceive challenge: URLAuthenticationChallenge, + completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void + ) { + // Parity with main browser: performDefaultHandling enables system keychain + // lookups, MDM client certs, and SSO extensions (e.g. Microsoft Entra ID). + completionHandler(.performDefaultHandling, nil) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationAction") + #endif + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("popup.download.didBecome source=navigationResponse") + #endif + download.delegate = downloadDelegate + } +} diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index aaf751d96..8990e6855 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -53,6 +53,9 @@ final class CmuxWebView: WKWebView { private static var contextMenuFallbackKey: UInt8 = 0 var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + /// Called when "Open Link in New Tab" context menu is selected. + /// Bypasses createWebViewWith so the link opens as a tab, not a popup. + var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? /// Guard against background panes stealing first responder (e.g. page autofocus). @@ -1212,12 +1215,15 @@ final class CmuxWebView: WKWebView { openLinkInsertionIndex = index + 1 } - // Rename "Open Link in New Window" to "Open Link in New Tab". - // The UIDelegate's createWebViewWith already handles the action - // by opening the link as a new surface in the same pane. + // Retarget "Open Link in New Window" to open as a tab, not a popup. + // Without this, WebKit's default action calls createWebViewWith with + // navigationType .other, which our classifier would treat as a scripted + // popup request. if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow" || item.title.contains("Open Link in New Window") { item.title = String(localized: "browser.contextMenu.openLinkInNewTab", defaultValue: "Open Link in New Tab") + item.target = self + item.action = #selector(contextMenuOpenLinkInNewTab(_:)) } if isDownloadImageMenuItem(item) { @@ -1275,6 +1281,14 @@ final class CmuxWebView: WKWebView { } } + @objc private func contextMenuOpenLinkInNewTab(_ sender: Any?) { + let point = lastContextMenuPoint + resolveContextMenuLinkURL(at: point) { [weak self] url in + guard let self, let url else { return } + self.onContextMenuOpenLinkInNewTab?(url) + } + } + @objc private func contextMenuDownloadImage(_ sender: Any?) { let traceID = Self.makeContextDownloadTraceID(prefix: "img") let point = lastContextMenuPoint diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 20739849d..2768c037c 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -1064,6 +1064,7 @@ private let cmuxAuxiliaryWindowIdentifiers: Set = [ "cmux.settings", "cmux.about", "cmux.licenses", + "cmux.browser-popup", "cmux.settingsAboutTitlebarDebug", "cmux.debugWindowControls", "cmux.sidebarDebug", diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 244e30be2..313d03d44 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -2617,6 +2617,95 @@ final class BrowserNavigationNewTabDecisionTests: XCTestCase { } } +final class BrowserPopupDecisionTests: XCTestCase { + func testLinkActivatedPlainLeftClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationPlainLeftClickCreatesPopup() { + XCTAssertTrue( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 0 + ) + ) + } + + func testOtherNavigationMiddleClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .other, + modifierFlags: [], + buttonNumber: 2 + ) + ) + } + + func testLinkActivatedCmdClickDoesNotCreatePopup() { + XCTAssertFalse( + browserNavigationShouldCreatePopup( + navigationType: .linkActivated, + modifierFlags: [.command], + buttonNumber: 0 + ) + ) + } +} + +final class BrowserPopupContentRectTests: XCTestCase { + func testExplicitTopOriginCoordinatesConvertToAppKitBottomOrigin() { + let rect = browserPopupContentRect( + requestedWidth: 400, + requestedHeight: 300, + requestedX: 150, + requestedTopY: 120, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 150, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 430, accuracy: 0.01) + XCTAssertEqual(rect.width, 400, accuracy: 0.01) + XCTAssertEqual(rect.height, 300, accuracy: 0.01) + } + + func testExplicitCoordinatesClampToVisibleFrame() { + let rect = browserPopupContentRect( + requestedWidth: 1400, + requestedHeight: 1200, + requestedX: 900, + requestedTopY: -25, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 100, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 50, accuracy: 0.01) + XCTAssertEqual(rect.width, 1000, accuracy: 0.01) + XCTAssertEqual(rect.height, 800, accuracy: 0.01) + } + + func testMissingCoordinatesCentersPopup() { + let rect = browserPopupContentRect( + requestedWidth: 300, + requestedHeight: 200, + requestedX: nil, + requestedTopY: nil, + visibleFrame: NSRect(x: 100, y: 50, width: 1000, height: 800) + ) + + XCTAssertEqual(rect.origin.x, 450, accuracy: 0.01) + XCTAssertEqual(rect.origin.y, 350, accuracy: 0.01) + XCTAssertEqual(rect.width, 300, accuracy: 0.01) + XCTAssertEqual(rect.height, 200, accuracy: 0.01) + } +} + @MainActor final class BrowserJavaScriptDialogDelegateTests: XCTestCase { func testBrowserPanelUIDelegateImplementsJavaScriptDialogSelectors() {