* Allow click-to-focus for unfocused browser surfaces * Add browser click-focus diagnostics and guard fix * Allow pointer-initiated browser focus through responder guard
746 lines
31 KiB
Swift
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
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|