cmux/Sources/Panels/CmuxWebView.swift
Lawrence Chen 2499ba1bb2
Fix browser-surface click focus without regressing open (#355)
* Allow click-to-focus for unfocused browser surfaces

* Add browser click-focus diagnostics and guard fix

* Allow pointer-initiated browser focus through responder guard
2026-02-23 03:09:19 -08:00

746 lines
31 KiB
Swift

import AppKit
import Bonsplit
import ObjectiveC
import WebKit
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
/// preventing the app menu/SwiftUI Commands from receiving them. Route menu
/// key equivalents first so app-level shortcuts continue to work when WebKit is
/// the first responder.
final class CmuxWebView: WKWebView {
private final class ContextMenuFallbackBox: NSObject {
weak var target: AnyObject?
let action: Selector?
init(target: AnyObject?, action: Selector?) {
self.target = target
self.action = action
}
}
private static var contextMenuFallbackKey: UInt8 = 0
var onContextMenuDownloadStateChanged: ((Bool) -> Void)?
var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)?
var contextMenuDefaultBrowserOpener: ((URL) -> Bool)?
/// Guard against background panes stealing first responder (e.g. page autofocus).
/// BrowserPanelView updates this as pane focus state changes.
var allowsFirstResponderAcquisition: Bool = true
private var pointerFocusAllowanceDepth: Int = 0
var allowsFirstResponderAcquisitionEffective: Bool {
allowsFirstResponderAcquisition || pointerFocusAllowanceDepth > 0
}
var debugPointerFocusAllowanceDepth: Int { pointerFocusAllowanceDepth }
override func becomeFirstResponder() -> Bool {
guard allowsFirstResponderAcquisitionEffective else {
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.blockedBecome web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return false
}
let result = super.becomeFirstResponder()
#if DEBUG
let eventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil"
dlog(
"browser.focus.become web=\(ObjectIdentifier(self)) result=\(result ? 1 : 0) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) eventType=\(eventType)"
)
#endif
return result
}
/// Temporarily permits focus acquisition for explicit pointer-driven interactions
/// (mouse click into this webview) while keeping background autofocus blocked.
func withPointerFocusAllowance(_ body: () -> Void) {
pointerFocusAllowanceDepth += 1
#if DEBUG
dlog(
"browser.focus.pointerAllowance.enter web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
defer {
pointerFocusAllowanceDepth = max(0, pointerFocusAllowanceDepth - 1)
#if DEBUG
dlog(
"browser.focus.pointerAllowance.exit web=\(ObjectIdentifier(self)) " +
"depth=\(pointerFocusAllowanceDepth)"
)
#endif
}
body()
}
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not
// route it through app/menu key equivalents, which can trigger unintended actions.
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
if flags.contains(.command), event.keyCode == 36 || event.keyCode == 76 {
return false
}
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
return true
}
// Handle app-level shortcuts that are not menu-backed (for example split commands).
// Without this, WebKit can consume Cmd-based shortcuts before the app monitor sees them.
if AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
return true
}
return super.performKeyEquivalent(with: event)
}
override func keyDown(with event: NSEvent) {
// Some Cmd-based key paths in WebKit don't consistently invoke performKeyEquivalent.
// Route them through the same app-level shortcut handler as a fallback.
if event.modifierFlags.intersection(.deviceIndependentFlagsMask).contains(.command),
AppDelegate.shared?.handleBrowserSurfaceKeyEquivalent(event) == true {
return
}
super.keyDown(with: event)
}
// MARK: - Focus on click
// The SwiftUI Color.clear overlay (.onTapGesture) that focuses panes can't receive
// clicks when a WKWebView is underneath AppKit delivers the click to the deepest
// NSView (WKWebView), not to sibling SwiftUI overlays. Notify the panel system so
// bonsplit focus tracks which pane the user clicked in.
override func mouseDown(with event: NSEvent) {
#if DEBUG
let windowNumber = window?.windowNumber ?? -1
let firstResponderType = window?.firstResponder.map { String(describing: type(of: $0)) } ?? "nil"
dlog(
"browser.focus.mouseDown web=\(ObjectIdentifier(self)) " +
"policy=\(allowsFirstResponderAcquisition ? 1 : 0) " +
"pointerDepth=\(pointerFocusAllowanceDepth) win=\(windowNumber) fr=\(firstResponderType)"
)
#endif
NotificationCenter.default.post(name: .webViewDidReceiveClick, object: self)
withPointerFocusAllowance {
super.mouseDown(with: event)
}
}
// MARK: - Mouse back/forward buttons & middle-click
override func otherMouseDown(with event: NSEvent) {
// Button 3 = back, button 4 = forward (multi-button mice like Logitech).
// Consume the event so WebKit doesn't handle it.
switch event.buttonNumber {
case 3:
goBack()
return
case 4:
goForward()
return
default:
break
}
super.otherMouseDown(with: event)
}
override func otherMouseUp(with event: NSEvent) {
// Middle-click (button 2) on a link opens it in a new tab.
if event.buttonNumber == 2 {
let point = convert(event.locationInWindow, from: nil)
findLinkAtPoint(point) { [weak self] url in
guard let self, let url else { return }
NotificationCenter.default.post(
name: .webViewMiddleClickedLink,
object: self,
userInfo: ["url": url]
)
}
return
}
super.otherMouseUp(with: event)
}
/// Use JavaScript to find the nearest anchor element at the given view-local point.
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
// WKWebView's coordinate system is flipped (origin top-left for web content).
let flippedY = bounds.height - point.y
let js = """
(() => {
let el = document.elementFromPoint(\(point.x), \(flippedY));
while (el) {
if (el.tagName === 'A' && el.href) return el.href;
el = el.parentElement;
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let href = result as? String, !href.isEmpty,
let url = URL(string: href) else {
completion(nil)
return
}
completion(url)
}
}
// MARK: - Context menu download support
/// The last context-menu point in view coordinates.
private var lastContextMenuPoint: NSPoint = .zero
/// Saved native WebKit action for "Download Image".
private var fallbackDownloadImageTarget: AnyObject?
private var fallbackDownloadImageAction: Selector?
/// Saved native WebKit action for "Download Linked File".
private var fallbackDownloadLinkedFileTarget: AnyObject?
private var fallbackDownloadLinkedFileAction: Selector?
private func isDownloadableScheme(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme == "http" || scheme == "https" || scheme == "file"
}
private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool {
guard target === self else { return false }
return action == #selector(contextMenuDownloadImage(_:))
|| action == #selector(contextMenuDownloadLinkedFile(_:))
}
private func resolveGoogleRedirectURL(_ url: URL) -> URL? {
guard let host = url.host?.lowercased(), host.contains("google.") else { return nil }
guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false),
let queryItems = comps.queryItems else { return nil }
let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") })
let candidates = ["imgurl", "mediaurl", "url", "q"]
for key in candidates {
guard let raw = map[key], !raw.isEmpty,
let decoded = raw.removingPercentEncoding ?? raw as String?,
let candidate = URL(string: decoded),
isDownloadableScheme(candidate) else {
continue
}
return candidate
}
// Some links are wrapped as /url?...
if comps.path.lowercased() == "/url" {
for key in ["url", "q"] {
if let raw = map[key], let candidate = URL(string: raw), isDownloadableScheme(candidate) {
return candidate
}
}
}
return nil
}
private func normalizedLinkedDownloadURL(_ url: URL) -> URL {
resolveGoogleRedirectURL(url) ?? url
}
private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) {
let target = item.target as AnyObject?
let action = item.action
if isOurDownloadMenuAction(target: target, action: action) {
return
}
let box = ContextMenuFallbackBox(target: target, action: action)
objc_setAssociatedObject(
item,
&Self.contextMenuFallbackKey,
box,
.OBJC_ASSOCIATION_RETAIN_NONATOMIC
)
}
private func fallbackFromSender(
_ sender: Any?,
defaultAction: Selector?,
defaultTarget: AnyObject?
) -> (action: Selector?, target: AnyObject?) {
if let item = sender as? NSMenuItem,
let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
return (box.action, box.target)
}
return (defaultAction, defaultTarget)
}
/// Resolve the topmost image URL near a point, accounting for overlay layers.
private func findImageURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
let flippedY = bounds.height - point.y
let js = """
(() => {
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
for (const start of nodes) {
let elChain = [];
let seen = new Set();
let walk = (node) => {
let chain = [];
let localSeen = new Set();
let visit = (n) => {
while (n && !localSeen.has(n)) {
localSeen.add(n);
chain.push(n);
n = n.parentElement;
}
};
visit(node);
if (node && node.tagName === 'PICTURE') {
const img = node.querySelector('img');
if (img) visit(img);
}
return chain;
};
for (const el of walk(start)) {
if (!seen.has(el)) {
seen.add(el);
elChain.push(el);
}
}
for (const el of elChain) {
if (el.tagName === 'IMG') {
if (el.currentSrc) return el.currentSrc;
if (el.src) return el.src;
}
if (el.tagName === 'PICTURE') {
const img = el.querySelector('img');
if (img) {
if (img.currentSrc) return img.currentSrc;
if (img.src) return img.src;
}
}
}
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let src = result as? String, !src.isEmpty,
let url = URL(string: src) else {
completion(nil)
return
}
completion(url)
}
}
/// Resolve the topmost link URL near a point, accounting for overlay layers.
private func findLinkURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
let flippedY = bounds.height - point.y
let js = """
(() => {
const nodes = document.elementsFromPoint(\(point.x), \(flippedY));
for (const start of nodes) {
let el = start;
let seen = new Set();
let cur = (() => {
let n = start;
return n;
})();
let walk = (node) => {
let chain = [];
while (node && !seen.has(node)) {
seen.add(node);
chain.push(node);
node = node.parentElement;
}
return chain;
};
for (const n of walk(cur)) {
if (n.tagName === 'A' && n.href) return n.href;
}
}
return '';
})();
"""
evaluateJavaScript(js) { result, _ in
guard let href = result as? String, !href.isEmpty,
let url = URL(string: href) else {
completion(nil)
return
}
completion(url)
}
}
private func resolveContextMenuLinkURL(at point: NSPoint, completion: @escaping (URL?) -> Void) {
if let contextMenuLinkURLProvider {
contextMenuLinkURLProvider(self, point, completion)
return
}
findLinkURLAtPoint(point, completion: completion)
}
private func canOpenInDefaultBrowser(_ url: URL) -> Bool {
let scheme = url.scheme?.lowercased() ?? ""
return scheme == "http" || scheme == "https"
}
private func openContextMenuLinkInDefaultBrowser(_ url: URL) {
if let contextMenuDefaultBrowserOpener {
_ = contextMenuDefaultBrowserOpener(url)
return
}
_ = NSWorkspace.shared.open(url)
}
private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) {
guard let action else { return }
// Guard against accidental self-recursion if fallback gets overwritten.
if target === self,
action == #selector(contextMenuDownloadImage(_:))
|| action == #selector(contextMenuDownloadLinkedFile(_:)) {
NSLog("CmuxWebView context fallback skipped (recursive self action)")
return
}
_ = NSApp.sendAction(action, to: target, from: sender)
}
private func notifyContextMenuDownloadState(_ downloading: Bool) {
if Thread.isMainThread {
onContextMenuDownloadStateChanged?(downloading)
} else {
DispatchQueue.main.async { [weak self] in
self?.onContextMenuDownloadStateChanged?(downloading)
}
}
}
private func downloadURLViaSession(
_ url: URL,
suggestedFilename: String?,
sender: Any?,
fallbackAction: Selector?,
fallbackTarget: AnyObject?
) {
guard isDownloadableScheme(url) else {
runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
return
}
let scheme = url.scheme?.lowercased() ?? ""
notifyContextMenuDownloadState(true)
if scheme == "file" {
DispatchQueue.main.async {
do {
let data = try Data(contentsOf: url)
let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines)
let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent)
let savePanel = NSSavePanel()
savePanel.nameFieldStringValue = saveName
savePanel.canCreateDirectories = true
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
// Download is already complete; we're now waiting for user save choice.
self.notifyContextMenuDownloadState(false)
savePanel.begin { result in
guard result == .OK, let destURL = savePanel.url else { return }
try? data.write(to: destURL, options: .atomic)
}
} catch {
self.notifyContextMenuDownloadState(false)
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
}
}
return
}
let cookieStore = configuration.websiteDataStore.httpCookieStore
cookieStore.getAllCookies { cookies in
var request = URLRequest(url: url)
request.httpMethod = "GET"
let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies)
for (key, value) in cookieHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
if let referer = self.url?.absoluteString, !referer.isEmpty {
request.setValue(referer, forHTTPHeaderField: "Referer")
}
if let ua = self.customUserAgent, !ua.isEmpty {
request.setValue(ua, forHTTPHeaderField: "User-Agent")
}
URLSession.shared.dataTask(with: request) { data, response, error in
DispatchQueue.main.async {
guard let data, error == nil else {
self.notifyContextMenuDownloadState(false)
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
return
}
let filenameCandidate = suggestedFilename
?? response?.suggestedFilename
?? url.lastPathComponent
let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate
let savePanel = NSSavePanel()
savePanel.nameFieldStringValue = saveName
savePanel.canCreateDirectories = true
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
// Download is already complete; we're now waiting for user save choice.
self.notifyContextMenuDownloadState(false)
savePanel.begin { result in
guard result == .OK, let destURL = savePanel.url else { return }
do {
try data.write(to: destURL, options: .atomic)
} catch {
self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender)
}
}
}
}.resume()
}
}
private func startContextMenuDownload(
_ url: URL,
sender: Any?,
fallbackAction: Selector?,
fallbackTarget: AnyObject?
) {
NSLog("CmuxWebView context download start: %@", url.absoluteString)
downloadURLViaSession(
url,
suggestedFilename: nil,
sender: sender,
fallbackAction: fallbackAction,
fallbackTarget: fallbackTarget
)
}
// MARK: - Drag-and-drop passthrough
// WKWebView inherently calls registerForDraggedTypes with public.text (and others).
// Bonsplit tab drags use NSString (public.utf8-plain-text) which conforms to public.text,
// so AppKit's view-hierarchy-based drag routing delivers the session to WKWebView instead
// of SwiftUI's sibling .onDrop overlays. Rejecting in draggingEntered doesn't help because
// AppKit only bubbles up through superviews, not siblings.
//
// Fix: filter out text-based types that conflict with bonsplit tab drags, but keep
// file URL types so Finder file drops and HTML drag-and-drop work.
private static let blockedDragTypes: Set<NSPasteboard.PasteboardType> = [
.string, // public.utf8-plain-text matches bonsplit's NSString tab drags
NSPasteboard.PasteboardType("public.text"),
NSPasteboard.PasteboardType("public.plain-text"),
NSPasteboard.PasteboardType("com.splittabbar.tabtransfer"),
NSPasteboard.PasteboardType("com.cmux.sidebar-tab-reorder"),
]
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
if !filtered.isEmpty {
super.registerForDraggedTypes(filtered)
}
}
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
super.willOpenMenu(menu, with: event)
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
var openLinkInsertionIndex: Int?
var hasDefaultBrowserOpenLinkItem = false
for (index, item) in menu.items.enumerated() {
if !hasDefaultBrowserOpenLinkItem,
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|| item.title == "Open Link in Default Browser") {
hasDefaultBrowserOpenLinkItem = true
}
if openLinkInsertionIndex == nil,
(item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink"
|| item.title == "Open Link") {
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.
if item.identifier?.rawValue == "WKMenuItemIdentifierOpenLinkInNewWindow"
|| item.title.contains("Open Link in New Window") {
item.title = "Open Link in New Tab"
}
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage"
|| item.title == "Download Image" {
NSLog("CmuxWebView context menu hook: download image")
captureFallbackForMenuItemIfNeeded(item)
// Keep global fallback as a secondary safety net.
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
fallbackDownloadImageTarget = box.target
fallbackDownloadImageAction = box.action
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
fallbackDownloadImageTarget = item.target as AnyObject?
fallbackDownloadImageAction = item.action
}
item.target = self
item.action = #selector(contextMenuDownloadImage(_:))
}
if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile"
|| item.title == "Download Linked File" {
NSLog("CmuxWebView context menu hook: download linked file")
captureFallbackForMenuItemIfNeeded(item)
// Keep global fallback as a secondary safety net.
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
fallbackDownloadLinkedFileTarget = box.target
fallbackDownloadLinkedFileAction = box.action
} else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) {
fallbackDownloadLinkedFileTarget = item.target as AnyObject?
fallbackDownloadLinkedFileAction = item.action
}
item.target = self
item.action = #selector(contextMenuDownloadLinkedFile(_:))
}
}
if let openLinkInsertionIndex, !hasDefaultBrowserOpenLinkItem {
let item = NSMenuItem(
title: "Open Link in Default Browser",
action: #selector(contextMenuOpenLinkInDefaultBrowser(_:)),
keyEquivalent: ""
)
item.target = self
menu.insertItem(item, at: min(openLinkInsertionIndex, menu.items.count))
}
}
@objc private func contextMenuOpenLinkInDefaultBrowser(_ sender: Any?) {
_ = sender
let point = lastContextMenuPoint
resolveContextMenuLinkURL(at: point) { [weak self] url in
guard let self, let url, self.canOpenInDefaultBrowser(url) else { return }
self.openContextMenuLinkInDefaultBrowser(url)
}
}
@objc private func contextMenuDownloadImage(_ sender: Any?) {
let point = lastContextMenuPoint
let fallback = fallbackFromSender(
sender,
defaultAction: fallbackDownloadImageAction,
defaultTarget: fallbackDownloadImageTarget
)
findImageURLAtPoint(point) { [weak self] url in
guard let self else { return }
if let url {
let scheme = url.scheme?.lowercased() ?? ""
if scheme == "http" || scheme == "https" || scheme == "file" {
NSLog("CmuxWebView context download image URL: %@", url.absoluteString)
self.startContextMenuDownload(
url,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
}
// Google Images and similar sites often expose blob:/data: image URLs.
// If image URL is not directly downloadable, fall back to the nearby link URL.
self.findLinkURLAtPoint(point) { linkURL in
guard let linkURL else {
NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action")
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
let linkScheme = linkURL.scheme?.lowercased() ?? ""
guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else {
NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString)
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString)
self.startContextMenuDownload(
linkURL,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
}
}
}
@objc private func contextMenuDownloadLinkedFile(_ sender: Any?) {
let point = lastContextMenuPoint
let fallback = fallbackFromSender(
sender,
defaultAction: fallbackDownloadLinkedFileAction,
defaultTarget: fallbackDownloadLinkedFileTarget
)
findLinkURLAtPoint(point) { [weak self] url in
guard let self else { return }
if let url {
let normalized = self.normalizedLinkedDownloadURL(url)
if self.isDownloadableScheme(normalized) {
NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString)
self.startContextMenuDownload(
normalized,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
}
// Fallback 1: image URL under cursor (useful on image-heavy result pages).
self.findImageURLAtPoint(point) { imageURL in
if let imageURL, self.isDownloadableScheme(imageURL) {
NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString)
self.startContextMenuDownload(
imageURL,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
return
}
// Fallback 2: simpler nearest-anchor lookup.
self.findLinkAtPoint(point) { fallbackURL in
guard let fallbackURL else {
NSLog("CmuxWebView context download linked file: URL nil, using fallback action")
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
let normalized = self.normalizedLinkedDownloadURL(fallbackURL)
guard self.isDownloadableScheme(normalized) else {
NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString)
self.runContextMenuFallback(
action: fallback.action,
target: fallback.target,
sender: sender
)
return
}
NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString)
self.startContextMenuDownload(
normalized,
sender: sender,
fallbackAction: fallback.action,
fallbackTarget: fallback.target
)
}
}
}
}
}