638 lines
24 KiB
Swift
638 lines
24 KiB
Swift
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,
|
|
KeyboardLayout.normalizedCharacters(for: event) == "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
|
|
|
|
let browserContextSource = parentPopupController?.webView.configuration ?? openerPanel?.webView.configuration
|
|
if let browserContextSource {
|
|
BrowserPanel.configureWebViewConfiguration(
|
|
configuration,
|
|
websiteDataStore: browserContextSource.websiteDataStore,
|
|
processPool: browserContextSource.processPool
|
|
)
|
|
}
|
|
|
|
// Create popup web view with WebKit's supplied configuration after
|
|
// overlaying the opener's browser context so OAuth popups keep cmux's
|
|
// shared cookie/storage scope and opener linkage.
|
|
let webView = CmuxWebView(frame: .zero, configuration: configuration)
|
|
webView.allowsBackForwardNavigationGestures = true
|
|
if #available(macOS 13.3, *) {
|
|
webView.isInspectable = true
|
|
}
|
|
webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor()
|
|
webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent
|
|
BrowserThemeSettings.apply(openerPanel?.currentBrowserThemeMode ?? BrowserThemeSettings.mode(), to: webView)
|
|
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 }
|
|
}
|
|
|
|
func setBrowserThemeMode(_ mode: BrowserThemeMode) {
|
|
BrowserThemeSettings.apply(mode, to: webView)
|
|
for child in childPopups {
|
|
child.setBrowserThemeMode(mode)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|