* Route browser Find shortcuts through web content first * Keep cmux browser Find shortcuts authoritative * Add browser Find inspector regression test * Fix browser Find routing follow-ups
1996 lines
84 KiB
Swift
1996 lines
84 KiB
Swift
import AppKit
|
|
import Bonsplit
|
|
import ObjectiveC
|
|
import UniformTypeIdentifiers
|
|
import WebKit
|
|
|
|
extension WKWebView {
|
|
var cmuxIsElementFullscreenActiveOrTransitioning: Bool {
|
|
switch fullscreenState {
|
|
case .notInFullscreen:
|
|
return false
|
|
case .enteringFullscreen, .inFullscreen, .exitingFullscreen:
|
|
return true
|
|
@unknown default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func cmuxIsManagedByExternalFullscreenWindow(relativeTo expectedWindow: NSWindow?) -> Bool {
|
|
guard cmuxIsElementFullscreenActiveOrTransitioning else { return false }
|
|
guard let expectedWindow else { return true }
|
|
return window !== expectedWindow
|
|
}
|
|
}
|
|
|
|
struct BrowserImageCopyPasteboardPayload {
|
|
let imageData: Data
|
|
let mimeType: String?
|
|
let sourceURL: URL?
|
|
}
|
|
|
|
enum BrowserImageCopyPasteboardBuilder {
|
|
private static let pngPasteboardType = NSPasteboard.PasteboardType(UTType.png.identifier)
|
|
private static let tiffPasteboardType = NSPasteboard.PasteboardType(UTType.tiff.identifier)
|
|
private static let urlPasteboardType = NSPasteboard.PasteboardType(UTType.url.identifier)
|
|
|
|
static func makePasteboardItems(from payload: BrowserImageCopyPasteboardPayload) -> [NSPasteboardItem] {
|
|
guard let imageItem = imagePasteboardItem(from: payload) else { return [] }
|
|
|
|
var items = [imageItem]
|
|
if let sourceURL = payload.sourceURL {
|
|
// Keep the URL as a secondary item so image-aware paste targets can
|
|
// prefer the binary image payload without losing the textual fallback.
|
|
items.append(urlPasteboardItem(for: sourceURL))
|
|
}
|
|
return items
|
|
}
|
|
|
|
private static func imagePasteboardItem(from payload: BrowserImageCopyPasteboardPayload) -> NSPasteboardItem? {
|
|
let item = NSPasteboardItem()
|
|
var wroteImageType = false
|
|
|
|
if let image = NSImage(data: payload.imageData) {
|
|
if let tiffData = image.tiffRepresentation, !tiffData.isEmpty {
|
|
item.setData(tiffData, forType: tiffPasteboardType)
|
|
wroteImageType = true
|
|
}
|
|
if let pngData = pngData(for: image), !pngData.isEmpty {
|
|
item.setData(pngData, forType: pngPasteboardType)
|
|
wroteImageType = true
|
|
}
|
|
}
|
|
|
|
if let sourceType = sourceImageType(mimeType: payload.mimeType, sourceURL: payload.sourceURL) {
|
|
item.setData(payload.imageData, forType: NSPasteboard.PasteboardType(sourceType.identifier))
|
|
wroteImageType = true
|
|
}
|
|
|
|
return wroteImageType ? item : nil
|
|
}
|
|
|
|
private static func urlPasteboardItem(for url: URL) -> NSPasteboardItem {
|
|
let item = NSPasteboardItem()
|
|
item.setString(url.absoluteString, forType: .string)
|
|
item.setString(url.absoluteString, forType: urlPasteboardType)
|
|
return item
|
|
}
|
|
|
|
private static func sourceImageType(mimeType: String?, sourceURL: URL?) -> UTType? {
|
|
if let mimeType,
|
|
let type = UTType(mimeType: mimeType),
|
|
type.conforms(to: .image) {
|
|
return type
|
|
}
|
|
|
|
if let pathExtension = sourceURL?.pathExtension,
|
|
!pathExtension.isEmpty,
|
|
let type = UTType(filenameExtension: pathExtension),
|
|
type.conforms(to: .image) {
|
|
return type
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
private static func pngData(for image: NSImage) -> Data? {
|
|
guard let tiffData = image.tiffRepresentation,
|
|
let bitmap = NSBitmapImageRep(data: tiffData) else {
|
|
return nil
|
|
}
|
|
return bitmap.representation(using: .png, properties: [:])
|
|
}
|
|
}
|
|
|
|
/// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W),
|
|
/// preventing the app menu/SwiftUI Commands from receiving them. Route app/menu
|
|
/// shortcuts first by default, but allow browser content to try the Find command
|
|
/// family before cmux falls back to its own browser find overlay.
|
|
final class CmuxWebView: WKWebView {
|
|
// Some sites/WebKit paths report middle-click link activations as
|
|
// WKNavigationAction.buttonNumber=4 instead of 2. Track a recent local
|
|
// middle-click so navigation delegates can recover intent reliably.
|
|
private struct MiddleClickIntent {
|
|
let webViewID: ObjectIdentifier
|
|
let uptime: TimeInterval
|
|
}
|
|
|
|
private static var lastMiddleClickIntent: MiddleClickIntent?
|
|
private static let middleClickIntentMaxAge: TimeInterval = 0.8
|
|
|
|
static func hasRecentMiddleClickIntent(for webView: WKWebView) -> Bool {
|
|
guard let webView = webView as? CmuxWebView else { return false }
|
|
guard let intent = lastMiddleClickIntent else { return false }
|
|
|
|
let age = ProcessInfo.processInfo.systemUptime - intent.uptime
|
|
if age > middleClickIntentMaxAge {
|
|
lastMiddleClickIntent = nil
|
|
return false
|
|
}
|
|
|
|
return intent.webViewID == ObjectIdentifier(webView)
|
|
}
|
|
|
|
private static func recordMiddleClickIntent(for webView: CmuxWebView) {
|
|
lastMiddleClickIntent = MiddleClickIntent(
|
|
webViewID: ObjectIdentifier(webView),
|
|
uptime: ProcessInfo.processInfo.systemUptime
|
|
)
|
|
}
|
|
|
|
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)?
|
|
/// 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).
|
|
/// 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 acceptsFirstMouse(for event: NSEvent?) -> Bool {
|
|
PaneFirstClickFocusSettings.isEnabled()
|
|
}
|
|
|
|
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 result {
|
|
NotificationCenter.default.post(name: .browserDidBecomeFirstResponderWebView, object: self)
|
|
}
|
|
#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<T>(_ body: () -> T) -> T {
|
|
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
|
|
}
|
|
return body()
|
|
}
|
|
|
|
override func performKeyEquivalent(with event: NSEvent) -> Bool {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
var handled = false
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "browser.web.performKeyEquivalent",
|
|
startedAt: typingTimingStart,
|
|
event: event,
|
|
extra: "handled=\(handled ? 1 : 0)"
|
|
)
|
|
}
|
|
#endif
|
|
if event.keyCode == 36 || event.keyCode == 76 {
|
|
// Always bypass app/menu key-equivalent routing for Return/Enter so WebKit
|
|
// receives the keyDown path used by form submission handlers.
|
|
return false
|
|
}
|
|
|
|
let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
|
|
// Menu/app shortcut routing is only needed for Command equivalents
|
|
// (New Tab, Close Tab, tab switching, split commands, etc).
|
|
guard flags.contains(.command) else {
|
|
let result = super.performKeyEquivalent(with: event)
|
|
#if DEBUG
|
|
handled = result
|
|
#endif
|
|
return result
|
|
}
|
|
|
|
var replayedBrowserFindShortcutIntoWebContent = false
|
|
if shouldRouteBrowserFindCommandEquivalentThroughWebContentFirst(
|
|
event,
|
|
responder: window?.firstResponder,
|
|
owningWebView: self
|
|
) {
|
|
replayedBrowserFindShortcutIntoWebContent = true
|
|
let result = super.performKeyEquivalent(with: event)
|
|
#if DEBUG
|
|
handled = result
|
|
#endif
|
|
if result {
|
|
return true
|
|
}
|
|
}
|
|
|
|
if !shouldRouteCommandEquivalentDirectlyToMainMenu(event) {
|
|
let result = super.performKeyEquivalent(with: event)
|
|
#if DEBUG
|
|
handled = result
|
|
#endif
|
|
return result
|
|
}
|
|
|
|
// Let the app menu handle key equivalents first (New Tab, Close Tab, tab switching, etc).
|
|
if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) {
|
|
#if DEBUG
|
|
handled = true
|
|
#endif
|
|
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 {
|
|
#if DEBUG
|
|
handled = true
|
|
#endif
|
|
return true
|
|
}
|
|
|
|
let result: Bool
|
|
if replayedBrowserFindShortcutIntoWebContent {
|
|
// A browser-first Find preflight has already exposed this shortcut to WebKit once.
|
|
// Avoid a second `super.performKeyEquivalent` replay when menu/app fallback does not claim it.
|
|
result = false
|
|
} else {
|
|
result = super.performKeyEquivalent(with: event)
|
|
}
|
|
#if DEBUG
|
|
handled = result
|
|
#endif
|
|
return result
|
|
}
|
|
|
|
override func keyDown(with event: NSEvent) {
|
|
#if DEBUG
|
|
let typingTimingStart = CmuxTypingTiming.start()
|
|
var route = "super"
|
|
defer {
|
|
CmuxTypingTiming.logDuration(
|
|
path: "browser.web.keyDown",
|
|
startedAt: typingTimingStart,
|
|
event: event,
|
|
extra: "route=\(route)"
|
|
)
|
|
}
|
|
#endif
|
|
// 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 {
|
|
#if DEBUG
|
|
route = "appShortcut"
|
|
#endif
|
|
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
|
|
|
|
override func otherMouseDown(with event: NSEvent) {
|
|
if event.buttonNumber == 2 {
|
|
Self.recordMiddleClickIntent(for: self)
|
|
}
|
|
#if DEBUG
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
|
dlog(
|
|
"browser.mouse.otherDown web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
|
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
|
)
|
|
#endif
|
|
// 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:
|
|
#if DEBUG
|
|
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goBack canGoBack=\(canGoBack ? 1 : 0)")
|
|
#endif
|
|
goBack()
|
|
return
|
|
case 4:
|
|
#if DEBUG
|
|
dlog("browser.mouse.otherDown.action web=\(ObjectIdentifier(self)) kind=goForward canGoForward=\(canGoForward ? 1 : 0)")
|
|
#endif
|
|
goForward()
|
|
return
|
|
default:
|
|
break
|
|
}
|
|
super.otherMouseDown(with: event)
|
|
}
|
|
|
|
override func otherMouseUp(with event: NSEvent) {
|
|
if event.buttonNumber == 2 {
|
|
Self.recordMiddleClickIntent(for: self)
|
|
}
|
|
#if DEBUG
|
|
let point = convert(event.locationInWindow, from: nil)
|
|
let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask).rawValue
|
|
dlog(
|
|
"browser.mouse.otherUp web=\(ObjectIdentifier(self)) button=\(event.buttonNumber) " +
|
|
"clicks=\(event.clickCount) mods=\(mods) point=(\(Int(point.x)),\(Int(point.y)))"
|
|
)
|
|
#endif
|
|
super.otherMouseUp(with: event)
|
|
}
|
|
|
|
/// Finds the nearest anchor element at a given view-local point.
|
|
/// Used as a context-menu download fallback.
|
|
private func findLinkAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) {
|
|
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 "Copy Image".
|
|
private var fallbackCopyImageTarget: AnyObject?
|
|
private var fallbackCopyImageAction: Selector?
|
|
/// Saved native WebKit action for "Download Linked File".
|
|
private var fallbackDownloadLinkedFileTarget: AnyObject?
|
|
private var fallbackDownloadLinkedFileAction: Selector?
|
|
|
|
private static func makeContextDownloadTraceID(prefix: String) -> String {
|
|
#if DEBUG
|
|
return "\(prefix)-\(UUID().uuidString.prefix(8))"
|
|
#else
|
|
return prefix
|
|
#endif
|
|
}
|
|
|
|
private func debugContextDownload(_ message: @autoclosure () -> String) {
|
|
#if DEBUG
|
|
dlog(message())
|
|
#endif
|
|
}
|
|
|
|
private static func selectorName(_ selector: Selector?) -> String {
|
|
guard let selector else { return "nil" }
|
|
return NSStringFromSelector(selector)
|
|
}
|
|
|
|
private func debugLogContextMenuDownloadCandidate(_ item: NSMenuItem, index: Int) {
|
|
let identifier = item.identifier?.rawValue ?? "nil"
|
|
let title = item.title
|
|
let actionName = Self.selectorName(item.action)
|
|
let idToken = Self.normalizedContextMenuToken(identifier)
|
|
let titleToken = Self.normalizedContextMenuToken(title)
|
|
let actionToken = Self.normalizedContextMenuToken(actionName)
|
|
guard idToken.contains("download")
|
|
|| titleToken.contains("download")
|
|
|| actionToken.contains("download") else {
|
|
return
|
|
}
|
|
debugContextDownload(
|
|
"browser.ctxdl.menu item index=\(index) id=\(identifier) title=\(title) action=\(actionName)"
|
|
)
|
|
}
|
|
|
|
private struct ParsedDataURL {
|
|
let data: Data
|
|
let mimeType: String?
|
|
}
|
|
|
|
private static func parseDataURL(_ url: URL) -> ParsedDataURL? {
|
|
let absolute = url.absoluteString
|
|
guard absolute.hasPrefix("data:"),
|
|
let commaIndex = absolute.firstIndex(of: ",") else {
|
|
return nil
|
|
}
|
|
|
|
let headerStart = absolute.index(absolute.startIndex, offsetBy: 5)
|
|
let header = String(absolute[headerStart..<commaIndex])
|
|
let payloadStart = absolute.index(after: commaIndex)
|
|
let payload = String(absolute[payloadStart...])
|
|
|
|
let segments = header.split(separator: ";", omittingEmptySubsequences: false).map(String.init)
|
|
let mimeType = segments.first.flatMap { $0.isEmpty ? nil : $0 }
|
|
let isBase64 = segments.dropFirst().contains { $0.caseInsensitiveCompare("base64") == .orderedSame }
|
|
|
|
if isBase64 {
|
|
guard let data = Data(base64Encoded: payload, options: [.ignoreUnknownCharacters]) else {
|
|
return nil
|
|
}
|
|
return ParsedDataURL(data: data, mimeType: mimeType)
|
|
}
|
|
|
|
guard let decoded = payload.removingPercentEncoding else { return nil }
|
|
return ParsedDataURL(data: Data(decoded.utf8), mimeType: mimeType)
|
|
}
|
|
|
|
private static func filenameExtension(forMIMEType mimeType: String?) -> String? {
|
|
guard let mimeType, !mimeType.isEmpty else { return nil }
|
|
if #available(macOS 11.0, *) {
|
|
if let preferred = UTType(mimeType: mimeType)?.preferredFilenameExtension, !preferred.isEmpty {
|
|
return preferred
|
|
}
|
|
}
|
|
switch mimeType.lowercased() {
|
|
case "image/jpeg":
|
|
return "jpg"
|
|
case "image/png":
|
|
return "png"
|
|
case "image/webp":
|
|
return "webp"
|
|
case "image/gif":
|
|
return "gif"
|
|
case "text/html":
|
|
return "html"
|
|
case "text/plain":
|
|
return "txt"
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func suggestedFilenameForDataURL(
|
|
mimeType: String?,
|
|
suggestedFilename: String?
|
|
) -> String {
|
|
if let suggested = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!suggested.isEmpty {
|
|
return suggested
|
|
}
|
|
let ext = filenameExtension(forMIMEType: mimeType) ?? "bin"
|
|
let base = (mimeType?.lowercased().hasPrefix("image/") ?? false) ? "image" : "download"
|
|
return "\(base).\(ext)"
|
|
}
|
|
|
|
private static func normalizedContextMenuToken(_ value: String?) -> String {
|
|
guard let value else { return "" }
|
|
let lowered = value.lowercased()
|
|
let alphanumerics = CharacterSet.alphanumerics
|
|
let scalars = lowered.unicodeScalars.filter { alphanumerics.contains($0) }
|
|
return String(String.UnicodeScalarView(scalars))
|
|
}
|
|
|
|
private func isDownloadImageMenuItem(_ item: NSMenuItem) -> Bool {
|
|
let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue)
|
|
if identifier.contains("downloadimage") {
|
|
return true
|
|
}
|
|
|
|
let title = Self.normalizedContextMenuToken(item.title)
|
|
if title.contains("downloadimage") {
|
|
return true
|
|
}
|
|
|
|
if let action = item.action {
|
|
let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action))
|
|
if actionName.contains("downloadimage") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func isDownloadLinkedFileMenuItem(_ item: NSMenuItem) -> Bool {
|
|
let identifier = Self.normalizedContextMenuToken(item.identifier?.rawValue)
|
|
if identifier.contains("downloadlinkedfile")
|
|
|| identifier.contains("downloadlinktodisk") {
|
|
return true
|
|
}
|
|
|
|
let title = Self.normalizedContextMenuToken(item.title)
|
|
if title.contains("downloadlinkedfile")
|
|
|| title.contains("downloadlinktodisk") {
|
|
return true
|
|
}
|
|
|
|
if let action = item.action {
|
|
let actionName = Self.normalizedContextMenuToken(NSStringFromSelector(action))
|
|
if actionName.contains("downloadlinkedfile")
|
|
|| actionName.contains("downloadlinktodisk") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func isCopyImageMenuItem(_ item: NSMenuItem) -> Bool {
|
|
let tokens = [
|
|
Self.normalizedContextMenuToken(item.identifier?.rawValue),
|
|
Self.normalizedContextMenuToken(item.title),
|
|
item.action.map { Self.normalizedContextMenuToken(NSStringFromSelector($0)) } ?? "",
|
|
]
|
|
|
|
for token in tokens where !token.isEmpty {
|
|
if token.contains("copyimageaddress")
|
|
|| token.contains("copyimageurl")
|
|
|| token.contains("copyimagelocation") {
|
|
return false
|
|
}
|
|
if token == "copyimage"
|
|
|| token.contains("copyimagetoclipboard")
|
|
|| token.contains("copyimage") {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
private func isDownloadableScheme(_ url: URL) -> Bool {
|
|
let scheme = url.scheme?.lowercased() ?? ""
|
|
return scheme == "http" || scheme == "https" || scheme == "file"
|
|
}
|
|
|
|
private func isDataURLScheme(_ url: URL) -> Bool {
|
|
let scheme = url.scheme?.lowercased() ?? ""
|
|
return scheme == "data"
|
|
}
|
|
|
|
private func isDownloadSupportedScheme(_ url: URL) -> Bool {
|
|
return isDownloadableScheme(url) || isDataURLScheme(url)
|
|
}
|
|
|
|
private func isOurContextMenuAction(target: AnyObject?, action: Selector?) -> Bool {
|
|
guard target === self else { return false }
|
|
if action == #selector(contextMenuCopyImage(_:)) {
|
|
return true
|
|
}
|
|
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 let 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 isLikelyFaviconURL(_ url: URL) -> Bool {
|
|
let lower = url.absoluteString.lowercased()
|
|
if lower.contains("favicon") { return true }
|
|
let name = url.lastPathComponent.lowercased()
|
|
return name.hasPrefix("favicon")
|
|
}
|
|
|
|
private func isLikelyImageURL(_ url: URL) -> Bool {
|
|
if isDataURLScheme(url) {
|
|
guard let parsed = Self.parseDataURL(url),
|
|
let mime = parsed.mimeType?.lowercased() else {
|
|
return false
|
|
}
|
|
return mime.hasPrefix("image/")
|
|
}
|
|
guard isDownloadableScheme(url) else { return false }
|
|
let ext = url.pathExtension.lowercased()
|
|
if [
|
|
"jpg", "jpeg", "png", "webp", "gif", "bmp",
|
|
"svg", "avif", "heic", "heif", "tif", "tiff", "ico"
|
|
].contains(ext) {
|
|
return true
|
|
}
|
|
let lower = url.absoluteString.lowercased()
|
|
if lower.contains("imgurl=")
|
|
|| lower.contains("mediaurl=")
|
|
|| lower.contains("encrypted-tbn")
|
|
|| lower.contains("format=jpg")
|
|
|| lower.contains("format=jpeg")
|
|
|| lower.contains("format=png")
|
|
|| lower.contains("format=webp")
|
|
|| lower.contains("format=gif") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) {
|
|
let target = item.target as AnyObject?
|
|
let action = item.action
|
|
if isOurContextMenuAction(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 x = \(point.x);
|
|
const y = \(flippedY);
|
|
const normalize = (raw) => {
|
|
if (!raw || typeof raw !== 'string') return '';
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return '';
|
|
if (trimmed.startsWith('//')) return window.location.protocol + trimmed;
|
|
return trimmed;
|
|
};
|
|
const firstSrcsetURL = (srcset) => {
|
|
if (!srcset || typeof srcset !== 'string') return '';
|
|
const first = srcset.split(',').map((part) => part.trim()).find(Boolean);
|
|
if (!first) return '';
|
|
const urlPart = first.split(/\\s+/)[0];
|
|
return normalize(urlPart);
|
|
};
|
|
const firstBackgroundURL = (value) => {
|
|
if (!value || value === 'none') return '';
|
|
const match = /url\\((['"]?)(.*?)\\1\\)/.exec(value);
|
|
if (!match || !match[2]) return '';
|
|
return normalize(match[2]);
|
|
};
|
|
const collectChain = (start) => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
const pushParents = (node) => {
|
|
while (node && !seen.has(node)) {
|
|
seen.add(node);
|
|
out.push(node);
|
|
node = node.parentElement;
|
|
}
|
|
};
|
|
pushParents(start);
|
|
if (start && start.tagName === 'PICTURE' && start.querySelector) {
|
|
const img = start.querySelector('img');
|
|
if (img) pushParents(img);
|
|
}
|
|
return out;
|
|
};
|
|
const candidateFromElement = (el) => {
|
|
if (!el) return '';
|
|
const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : '');
|
|
if (el.tagName === 'IMG') {
|
|
const imageCandidates = [
|
|
normalize(el.currentSrc || ''),
|
|
attr('src'),
|
|
firstSrcsetURL(attr('srcset')),
|
|
attr('data-src'),
|
|
attr('data-iurl'),
|
|
attr('data-lazy-src'),
|
|
attr('data-original'),
|
|
];
|
|
const foundImage = imageCandidates.find(Boolean);
|
|
if (foundImage) return foundImage;
|
|
}
|
|
const genericAttrs = [
|
|
'src', 'data-src', 'data-iurl', 'data-lazy-src',
|
|
'data-original', 'data-image', 'data-image-url',
|
|
'data-thumb', 'data-thumbnail-url', 'content'
|
|
];
|
|
for (const name of genericAttrs) {
|
|
const v = attr(name);
|
|
if (v) return v;
|
|
}
|
|
const inlineBg = firstBackgroundURL(el.style && el.style.backgroundImage ? el.style.backgroundImage : '');
|
|
if (inlineBg) return inlineBg;
|
|
try {
|
|
const computed = window.getComputedStyle(el);
|
|
const computedBg = firstBackgroundURL(computed ? computed.backgroundImage : '');
|
|
if (computedBg) return computedBg;
|
|
} catch (_) {}
|
|
if (el.querySelector) {
|
|
const nestedImg = el.querySelector('img[src],img[srcset],img[data-src],img[data-iurl],source[srcset]');
|
|
if (nestedImg) {
|
|
const nestedCandidates = [
|
|
normalize(nestedImg.currentSrc || ''),
|
|
normalize(nestedImg.getAttribute ? nestedImg.getAttribute('src') : ''),
|
|
firstSrcsetURL(nestedImg.getAttribute ? nestedImg.getAttribute('srcset') : ''),
|
|
normalize(nestedImg.getAttribute ? (nestedImg.getAttribute('data-src') || nestedImg.getAttribute('data-iurl') || '') : '')
|
|
];
|
|
const foundNested = nestedCandidates.find(Boolean);
|
|
if (foundNested) return foundNested;
|
|
}
|
|
const nestedBg = el.querySelector('[style*="background-image"]');
|
|
if (nestedBg) {
|
|
const styleValue = nestedBg.getAttribute ? nestedBg.getAttribute('style') : '';
|
|
const bgURL = firstBackgroundURL(styleValue || '');
|
|
if (bgURL) return bgURL;
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
const tryNodes = (nodes) => {
|
|
for (const start of nodes) {
|
|
for (const el of collectChain(start)) {
|
|
const found = candidateFromElement(el);
|
|
if (found) return found;
|
|
}
|
|
if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) {
|
|
const inner = start.shadowRoot.elementFromPoint(x, y);
|
|
if (inner) {
|
|
for (const el of collectChain(inner)) {
|
|
const found = candidateFromElement(el);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
const all = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [];
|
|
const foundFromAll = tryNodes(all);
|
|
if (foundFromAll) return foundFromAll;
|
|
const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
|
return candidateFromElement(single) || '';
|
|
})();
|
|
"""
|
|
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 x = \(point.x);
|
|
const y = \(flippedY);
|
|
const normalize = (raw) => {
|
|
if (!raw || typeof raw !== 'string') return '';
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) return '';
|
|
if (trimmed.startsWith('//')) return window.location.protocol + trimmed;
|
|
return trimmed;
|
|
};
|
|
const collectChain = (start) => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
while (start && !seen.has(start)) {
|
|
seen.add(start);
|
|
out.push(start);
|
|
start = start.parentElement;
|
|
}
|
|
return out;
|
|
};
|
|
const linkFromElement = (el) => {
|
|
if (!el) return '';
|
|
const attr = (name) => normalize(el.getAttribute ? el.getAttribute(name) : '');
|
|
if (el.closest) {
|
|
const closestLink = el.closest('a[href],area[href]');
|
|
if (closestLink && closestLink.href) return normalize(closestLink.href);
|
|
}
|
|
if ((el.tagName === 'A' || el.tagName === 'AREA') && el.href) {
|
|
return normalize(el.href);
|
|
}
|
|
const attrCandidates = ['href', 'data-href', 'data-url', 'data-link', 'data-link-url'];
|
|
for (const name of attrCandidates) {
|
|
const v = attr(name);
|
|
if (v) return v;
|
|
}
|
|
if (el.querySelector) {
|
|
const nestedLink = el.querySelector('a[href],area[href]');
|
|
if (nestedLink && nestedLink.href) return normalize(nestedLink.href);
|
|
}
|
|
return '';
|
|
};
|
|
const tryNodes = (nodes) => {
|
|
for (const start of nodes) {
|
|
for (const node of collectChain(start)) {
|
|
const found = linkFromElement(node);
|
|
if (found) return found;
|
|
}
|
|
if (start && start.shadowRoot && start.shadowRoot.elementFromPoint) {
|
|
const inner = start.shadowRoot.elementFromPoint(x, y);
|
|
if (inner) {
|
|
for (const node of collectChain(inner)) {
|
|
const found = linkFromElement(node);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return '';
|
|
};
|
|
const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [];
|
|
const found = tryNodes(nodes);
|
|
if (found) return found;
|
|
const single = document.elementFromPoint ? document.elementFromPoint(x, y) : null;
|
|
return linkFromElement(single) || '';
|
|
})();
|
|
"""
|
|
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 debugInspectElementsAtPoint(_ point: NSPoint, traceID: String, kind: String) {
|
|
#if DEBUG
|
|
let flippedY = bounds.height - point.y
|
|
let js = """
|
|
(() => {
|
|
const clip = (value, max = 180) => {
|
|
if (value == null) return '';
|
|
const s = String(value);
|
|
return s.length > max ? s.slice(0, max) + '…' : s;
|
|
};
|
|
const x = \(point.x);
|
|
const y = \(flippedY);
|
|
const nodes = document.elementsFromPoint ? document.elementsFromPoint(x, y) : [];
|
|
const entries = [];
|
|
const limit = Math.min(nodes.length, 8);
|
|
for (let i = 0; i < limit; i++) {
|
|
const el = nodes[i];
|
|
if (!el) continue;
|
|
entries.push({
|
|
tag: clip((el.tagName || '').toLowerCase()),
|
|
id: clip(el.id || ''),
|
|
cls: clip(typeof el.className === 'string' ? el.className : ''),
|
|
href: clip(el.href || ''),
|
|
src: clip(el.src || ''),
|
|
currentSrc: clip(el.currentSrc || ''),
|
|
dataHref: clip(el.getAttribute ? el.getAttribute('data-href') : ''),
|
|
dataSrc: clip(el.getAttribute ? el.getAttribute('data-src') : '')
|
|
});
|
|
}
|
|
return JSON.stringify({count: nodes.length, entries});
|
|
})();
|
|
"""
|
|
evaluateJavaScript(js) { [weak self] result, _ in
|
|
guard let self,
|
|
let payload = result as? String,
|
|
!payload.isEmpty else { return }
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.inspect trace=\(traceID) kind=\(kind) payload=\(payload)"
|
|
)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
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?,
|
|
traceID: String? = nil,
|
|
reason: String? = nil
|
|
) {
|
|
let trace = traceID ?? "unknown"
|
|
guard let action else {
|
|
debugContextDownload(
|
|
"browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") action=nil target=\(String(describing: target))"
|
|
)
|
|
return
|
|
}
|
|
// Guard against accidental self-recursion if fallback gets overwritten.
|
|
if isOurContextMenuAction(target: target, action: action) {
|
|
debugContextDownload(
|
|
"browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))"
|
|
)
|
|
return
|
|
}
|
|
let dispatched = NSApp.sendAction(action, to: target, from: sender)
|
|
debugContextDownload(
|
|
"browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") dispatched=\(dispatched ? 1 : 0) action=\(Self.selectorName(action)) target=\(String(describing: target))"
|
|
)
|
|
}
|
|
|
|
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?,
|
|
traceID: String
|
|
) {
|
|
guard isDownloadSupportedScheme(url) else {
|
|
debugContextDownload(
|
|
"browser.ctxdl.request trace=\(traceID) stage=rejectUnsupportedScheme url=\(url.absoluteString)"
|
|
)
|
|
runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "unsupported_scheme"
|
|
)
|
|
return
|
|
}
|
|
let scheme = url.scheme?.lowercased() ?? ""
|
|
debugContextDownload(
|
|
"browser.ctxdl.request trace=\(traceID) stage=start scheme=\(scheme) url=\(url.absoluteString)"
|
|
)
|
|
notifyContextMenuDownloadState(true)
|
|
debugContextDownload("browser.ctxdl.state trace=\(traceID) downloading=1")
|
|
|
|
if scheme == "data" {
|
|
DispatchQueue.main.async {
|
|
guard let parsed = Self.parseDataURL(url) else {
|
|
self.notifyContextMenuDownloadState(false)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=parseFailure urlLength=\(url.absoluteString.count)"
|
|
)
|
|
self.runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "data_url_parse_error"
|
|
)
|
|
return
|
|
}
|
|
|
|
let saveName = Self.suggestedFilenameForDataURL(
|
|
mimeType: parsed.mimeType,
|
|
suggestedFilename: suggestedFilename
|
|
)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=parseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)"
|
|
)
|
|
|
|
let savePanel = NSSavePanel()
|
|
savePanel.nameFieldStringValue = saveName
|
|
savePanel.canCreateDirectories = true
|
|
savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
|
|
self.notifyContextMenuDownloadState(false)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)"
|
|
)
|
|
savePanel.begin { result in
|
|
guard result == .OK, let destURL = savePanel.url else {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=savePrompt result=cancel"
|
|
)
|
|
return
|
|
}
|
|
do {
|
|
try parsed.data.write(to: destURL, options: .atomic)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=saveSuccess path=\(destURL.path)"
|
|
)
|
|
} catch {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.data trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)"
|
|
)
|
|
self.runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "data_save_write_error"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
if scheme == "file" {
|
|
DispatchQueue.main.async {
|
|
do {
|
|
let data = try Data(contentsOf: url)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=\(url.path)"
|
|
)
|
|
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)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)"
|
|
)
|
|
savePanel.begin { result in
|
|
guard result == .OK, let destURL = savePanel.url else {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=savePrompt result=cancel"
|
|
)
|
|
return
|
|
}
|
|
do {
|
|
try data.write(to: destURL, options: .atomic)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=saveSuccess path=\(destURL.path)"
|
|
)
|
|
} catch {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)"
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
self.notifyContextMenuDownloadState(false)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.file trace=\(traceID) stage=readFailure error=\(error.localizedDescription)"
|
|
)
|
|
self.runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "file_read_error"
|
|
)
|
|
}
|
|
}
|
|
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")
|
|
}
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.request trace=\(traceID) stage=dispatch method=\(request.httpMethod ?? "GET") cookies=\(cookies.count) referer=\(request.value(forHTTPHeaderField: "Referer") ?? "nil") uaSet=\(request.value(forHTTPHeaderField: "User-Agent") == nil ? 0 : 1)"
|
|
)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
DispatchQueue.main.async {
|
|
guard let data, error == nil else {
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
let mime = response?.mimeType ?? "nil"
|
|
let hasResponse = response == nil ? 0 : 1
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=failure hasResponse=\(hasResponse) status=\(statusCode) mime=\(mime) error=\(error?.localizedDescription ?? "unknown")"
|
|
)
|
|
self.notifyContextMenuDownloadState(false)
|
|
self.runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "network_error"
|
|
)
|
|
return
|
|
}
|
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
|
let mime = response?.mimeType ?? "nil"
|
|
let expectedLength = response?.expectedContentLength ?? -1
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)"
|
|
)
|
|
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)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)"
|
|
)
|
|
savePanel.begin { result in
|
|
guard result == .OK, let destURL = savePanel.url else {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=savePrompt result=cancel"
|
|
)
|
|
return
|
|
}
|
|
do {
|
|
try data.write(to: destURL, options: .atomic)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=saveSuccess path=\(destURL.path)"
|
|
)
|
|
} catch {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.response trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)"
|
|
)
|
|
self.runContextMenuFallback(
|
|
action: fallbackAction,
|
|
target: fallbackTarget,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "save_write_error"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}.resume()
|
|
}
|
|
}
|
|
|
|
private func startContextMenuDownload(
|
|
_ url: URL,
|
|
sender: Any?,
|
|
fallbackAction: Selector?,
|
|
fallbackTarget: AnyObject?,
|
|
traceID: String
|
|
) {
|
|
debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)")
|
|
downloadURLViaSession(
|
|
url,
|
|
suggestedFilename: nil,
|
|
sender: sender,
|
|
fallbackAction: fallbackAction,
|
|
fallbackTarget: fallbackTarget,
|
|
traceID: traceID
|
|
)
|
|
}
|
|
|
|
private func inferredImageMIMEType(from url: URL) -> String? {
|
|
guard !url.pathExtension.isEmpty,
|
|
let type = UTType(filenameExtension: url.pathExtension),
|
|
type.conforms(to: .image) else {
|
|
return nil
|
|
}
|
|
return type.preferredMIMEType
|
|
}
|
|
|
|
private func resolveContextMenuCopyImageSourceURL(
|
|
at point: NSPoint,
|
|
completion: @escaping (URL?) -> Void
|
|
) {
|
|
findImageURLAtPoint(point) { [weak self] imageURL in
|
|
guard let self else { return completion(nil) }
|
|
|
|
if let imageURL {
|
|
let normalized = self.normalizedLinkedDownloadURL(imageURL)
|
|
if self.isDownloadSupportedScheme(normalized) {
|
|
completion(normalized)
|
|
return
|
|
}
|
|
}
|
|
|
|
self.findLinkURLAtPoint(point) { fallbackLinkURL in
|
|
guard let fallbackLinkURL else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let normalized = self.normalizedLinkedDownloadURL(fallbackLinkURL)
|
|
guard self.isDownloadSupportedScheme(normalized),
|
|
self.isLikelyImageURL(normalized) else {
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
completion(normalized)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func fetchContextMenuImageCopyPayload(
|
|
from sourceURL: URL,
|
|
traceID: String,
|
|
completion: @escaping (BrowserImageCopyPasteboardPayload?) -> Void
|
|
) {
|
|
let scheme = sourceURL.scheme?.lowercased() ?? ""
|
|
debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=start scheme=\(scheme) url=\(sourceURL.absoluteString)"
|
|
)
|
|
|
|
if scheme == "data" {
|
|
guard let parsed = Self.parseDataURL(sourceURL), !parsed.data.isEmpty else {
|
|
debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=dataParseFailure"
|
|
)
|
|
completion(nil)
|
|
return
|
|
}
|
|
debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=dataParseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)"
|
|
)
|
|
completion(
|
|
BrowserImageCopyPasteboardPayload(
|
|
imageData: parsed.data,
|
|
mimeType: parsed.mimeType,
|
|
sourceURL: nil
|
|
)
|
|
)
|
|
return
|
|
}
|
|
|
|
if scheme == "file" {
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
let data = try? Data(contentsOf: sourceURL)
|
|
DispatchQueue.main.async {
|
|
guard let data, !data.isEmpty else {
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=fileReadFailure path=\(sourceURL.path)"
|
|
)
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=fileReadSuccess bytes=\(data.count) path=\(sourceURL.path)"
|
|
)
|
|
completion(
|
|
BrowserImageCopyPasteboardPayload(
|
|
imageData: data,
|
|
mimeType: self.inferredImageMIMEType(from: sourceURL),
|
|
sourceURL: nil
|
|
)
|
|
)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
guard scheme == "http" || scheme == "https" else {
|
|
debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=unsupportedScheme url=\(sourceURL.absoluteString)"
|
|
)
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let cookieStore = configuration.websiteDataStore.httpCookieStore
|
|
cookieStore.getAllCookies { cookies in
|
|
var request = URLRequest(url: sourceURL)
|
|
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")
|
|
}
|
|
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=dispatch cookies=\(cookies.count) referer=\(request.value(forHTTPHeaderField: "Referer") ?? "nil") uaSet=\(request.value(forHTTPHeaderField: "User-Agent") == nil ? 0 : 1)"
|
|
)
|
|
|
|
URLSession.shared.dataTask(with: request) { data, response, error in
|
|
DispatchQueue.main.async {
|
|
guard let data, !data.isEmpty, error == nil else {
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=networkFailure status=\((response as? HTTPURLResponse)?.statusCode ?? -1) mime=\(response?.mimeType ?? "nil") error=\(error?.localizedDescription ?? "unknown")"
|
|
)
|
|
completion(nil)
|
|
return
|
|
}
|
|
|
|
let resolvedURL = response?.url.flatMap {
|
|
let scheme = $0.scheme?.lowercased() ?? ""
|
|
return (scheme == "http" || scheme == "https") ? $0 : nil
|
|
} ?? sourceURL
|
|
let mimeType = response?.mimeType ?? self.inferredImageMIMEType(from: resolvedURL)
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.fetch trace=\(traceID) stage=networkSuccess status=\((response as? HTTPURLResponse)?.statusCode ?? -1) mime=\(mimeType ?? "nil") bytes=\(data.count)"
|
|
)
|
|
completion(
|
|
BrowserImageCopyPasteboardPayload(
|
|
imageData: data,
|
|
mimeType: mimeType,
|
|
sourceURL: resolvedURL
|
|
)
|
|
)
|
|
}
|
|
}.resume()
|
|
}
|
|
}
|
|
|
|
private func writeContextMenuImageCopyPayload(
|
|
_ payload: BrowserImageCopyPasteboardPayload,
|
|
expectedPasteboardChangeCount: Int,
|
|
traceID: String
|
|
) -> (wrote: Bool, shouldFallback: Bool) {
|
|
let pasteboard = NSPasteboard.general
|
|
if pasteboard.changeCount != expectedPasteboardChangeCount {
|
|
debugContextDownload(
|
|
"browser.ctxcopy.write trace=\(traceID) stage=skipPasteboardRace expected=\(expectedPasteboardChangeCount) actual=\(pasteboard.changeCount)"
|
|
)
|
|
return (false, false)
|
|
}
|
|
|
|
let items = BrowserImageCopyPasteboardBuilder.makePasteboardItems(from: payload)
|
|
guard !items.isEmpty else {
|
|
debugContextDownload(
|
|
"browser.ctxcopy.write trace=\(traceID) stage=buildFailure mime=\(payload.mimeType ?? "nil") url=\(payload.sourceURL?.absoluteString ?? "nil") bytes=\(payload.imageData.count)"
|
|
)
|
|
return (false, true)
|
|
}
|
|
|
|
_ = pasteboard.clearContents()
|
|
let wrote = pasteboard.writeObjects(items)
|
|
debugContextDownload(
|
|
"browser.ctxcopy.write trace=\(traceID) stage=finish wrote=\(wrote ? 1 : 0) itemCount=\(items.count) types=\(items.map { $0.types.map(\.rawValue).joined(separator: ",") }.joined(separator: "|"))"
|
|
)
|
|
return (wrote, !wrote)
|
|
}
|
|
|
|
// 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"),
|
|
]
|
|
|
|
static func shouldRejectInternalPaneDrag(_ pasteboardTypes: [NSPasteboard.PasteboardType]?) -> Bool {
|
|
DragOverlayRoutingPolicy.hasBonsplitTabTransfer(pasteboardTypes)
|
|
|| DragOverlayRoutingPolicy.hasSidebarTabReorder(pasteboardTypes)
|
|
}
|
|
|
|
override func registerForDraggedTypes(_ newTypes: [NSPasteboard.PasteboardType]) {
|
|
let filtered = newTypes.filter { !Self.blockedDragTypes.contains($0) }
|
|
if !filtered.isEmpty {
|
|
super.registerForDraggedTypes(filtered)
|
|
}
|
|
}
|
|
|
|
override func draggingEntered(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
|
|
return super.draggingEntered(sender)
|
|
}
|
|
|
|
override func draggingUpdated(_ sender: any NSDraggingInfo) -> NSDragOperation {
|
|
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return [] }
|
|
return super.draggingUpdated(sender)
|
|
}
|
|
|
|
override func performDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
|
|
return super.performDragOperation(sender)
|
|
}
|
|
|
|
override func prepareForDragOperation(_ sender: any NSDraggingInfo) -> Bool {
|
|
guard !Self.shouldRejectInternalPaneDrag(sender.draggingPasteboard.types) else { return false }
|
|
return super.prepareForDragOperation(sender)
|
|
}
|
|
|
|
override func concludeDragOperation(_ sender: (any NSDraggingInfo)?) {
|
|
guard !Self.shouldRejectInternalPaneDrag(sender?.draggingPasteboard.types) else { return }
|
|
super.concludeDragOperation(sender)
|
|
}
|
|
|
|
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
|
|
super.willOpenMenu(menu, with: event)
|
|
lastContextMenuPoint = convert(event.locationInWindow, from: nil)
|
|
debugContextDownload(
|
|
"browser.ctxdl.menu open itemCount=\(menu.items.count) point=(\(Int(lastContextMenuPoint.x)),\(Int(lastContextMenuPoint.y)))"
|
|
)
|
|
var openLinkInsertionIndex: Int?
|
|
var hasDefaultBrowserOpenLinkItem = false
|
|
|
|
for (index, item) in menu.items.enumerated() {
|
|
debugLogContextMenuDownloadCandidate(item, index: index)
|
|
if !hasDefaultBrowserOpenLinkItem,
|
|
(item.action == #selector(contextMenuOpenLinkInDefaultBrowser(_:))
|
|
|| item.title == String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "Open Link in Default Browser")) {
|
|
hasDefaultBrowserOpenLinkItem = true
|
|
}
|
|
|
|
if openLinkInsertionIndex == nil,
|
|
(item.identifier?.rawValue == "WKMenuItemIdentifierOpenLink"
|
|
|| item.title == "Open Link") {
|
|
openLinkInsertionIndex = index + 1
|
|
}
|
|
|
|
// 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) {
|
|
debugContextDownload(
|
|
"browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))"
|
|
)
|
|
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 !isOurContextMenuAction(target: item.target as AnyObject?, action: item.action) {
|
|
fallbackDownloadImageTarget = item.target as AnyObject?
|
|
fallbackDownloadImageAction = item.action
|
|
}
|
|
item.target = self
|
|
item.action = #selector(contextMenuDownloadImage(_:))
|
|
}
|
|
|
|
if isCopyImageMenuItem(item) {
|
|
debugContextDownload(
|
|
"browser.ctxcopy.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))"
|
|
)
|
|
captureFallbackForMenuItemIfNeeded(item)
|
|
if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox {
|
|
fallbackCopyImageTarget = box.target
|
|
fallbackCopyImageAction = box.action
|
|
} else if !isOurContextMenuAction(target: item.target as AnyObject?, action: item.action) {
|
|
fallbackCopyImageTarget = item.target as AnyObject?
|
|
fallbackCopyImageAction = item.action
|
|
}
|
|
item.target = self
|
|
item.action = #selector(contextMenuCopyImage(_:))
|
|
}
|
|
|
|
if isDownloadLinkedFileMenuItem(item) {
|
|
debugContextDownload(
|
|
"browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))"
|
|
)
|
|
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 !isOurContextMenuAction(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: String(localized: "browser.contextMenu.openLinkInDefaultBrowser", defaultValue: "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 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 contextMenuCopyImage(_ sender: Any?) {
|
|
let traceID = Self.makeContextDownloadTraceID(prefix: "cpy")
|
|
let point = lastContextMenuPoint
|
|
let pasteboardChangeCount = NSPasteboard.general.changeCount
|
|
debugContextDownload(
|
|
"browser.ctxcopy.click trace=\(traceID) point=(\(Int(point.x)),\(Int(point.y)))"
|
|
)
|
|
|
|
let fallback = fallbackFromSender(
|
|
sender,
|
|
defaultAction: fallbackCopyImageAction,
|
|
defaultTarget: fallbackCopyImageTarget
|
|
)
|
|
debugContextDownload(
|
|
"browser.ctxcopy.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))"
|
|
)
|
|
|
|
resolveContextMenuCopyImageSourceURL(at: point) { [weak self] sourceURL in
|
|
guard let self else { return }
|
|
guard let sourceURL else {
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.resolve trace=\(traceID) stage=noSourceURL"
|
|
)
|
|
self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "copy")
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "no_copy_image_url"
|
|
)
|
|
return
|
|
}
|
|
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.resolve trace=\(traceID) stage=resolved url=\(sourceURL.absoluteString)"
|
|
)
|
|
self.fetchContextMenuImageCopyPayload(from: sourceURL, traceID: traceID) { payload in
|
|
guard let payload else {
|
|
self.debugContextDownload(
|
|
"browser.ctxcopy.resolve trace=\(traceID) stage=noPayload"
|
|
)
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "copy_image_fetch_failed"
|
|
)
|
|
return
|
|
}
|
|
|
|
let writeResult = self.writeContextMenuImageCopyPayload(
|
|
payload,
|
|
expectedPasteboardChangeCount: pasteboardChangeCount,
|
|
traceID: traceID
|
|
)
|
|
if writeResult.wrote {
|
|
return
|
|
}
|
|
if !writeResult.shouldFallback {
|
|
return
|
|
}
|
|
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "copy_image_write_failed"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func contextMenuDownloadImage(_ sender: Any?) {
|
|
let traceID = Self.makeContextDownloadTraceID(prefix: "img")
|
|
let point = lastContextMenuPoint
|
|
debugContextDownload(
|
|
"browser.ctxdl.click trace=\(traceID) kind=image point=(\(Int(point.x)),\(Int(point.y)))"
|
|
)
|
|
let fallback = fallbackFromSender(
|
|
sender,
|
|
defaultAction: fallbackDownloadImageAction,
|
|
defaultTarget: fallbackDownloadImageTarget
|
|
)
|
|
debugContextDownload(
|
|
"browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))"
|
|
)
|
|
findImageURLAtPoint(point) { [weak self] url in
|
|
guard let self else { return }
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image imageURL=\(url?.absoluteString ?? "nil")"
|
|
)
|
|
var dataImageURL: URL?
|
|
var weakImageURL: URL?
|
|
if let url {
|
|
let scheme = url.scheme?.lowercased() ?? ""
|
|
if scheme == "data" {
|
|
dataImageURL = url
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image dataURLDetected length=\(url.absoluteString.count)"
|
|
)
|
|
} else if scheme == "http" || scheme == "https" || scheme == "file" {
|
|
let normalized = self.normalizedLinkedDownloadURL(url)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)"
|
|
)
|
|
if self.isLikelyImageURL(normalized) {
|
|
if !self.isLikelyFaviconURL(normalized) {
|
|
self.startContextMenuDownload(
|
|
normalized,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
weakImageURL = normalized
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=favicon_or_low_confidence"
|
|
)
|
|
} else if self.isDownloadableScheme(normalized), !self.isLikelyFaviconURL(normalized) {
|
|
// Some image CDNs use extensionless URLs; keep as last-resort candidate.
|
|
weakImageURL = normalized
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image weakCandidateURL=\(normalized.absoluteString) reason=unclassified_direct_image_src"
|
|
)
|
|
}
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)"
|
|
)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image fallbackLinkURL=\(linkURL?.absoluteString ?? "nil")"
|
|
)
|
|
if let linkURL {
|
|
let normalizedLink = self.normalizedLinkedDownloadURL(linkURL)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image normalizedFallbackLinkURL=\(normalizedLink.absoluteString)"
|
|
)
|
|
if self.isDownloadableScheme(normalizedLink),
|
|
self.isLikelyImageURL(normalizedLink),
|
|
!self.isLikelyFaviconURL(normalizedLink) {
|
|
self.startContextMenuDownload(
|
|
normalizedLink,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
if let dataImageURL {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToDataURL=1"
|
|
)
|
|
self.startContextMenuDownload(
|
|
dataImageURL,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
|
|
if let weakImageURL {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=image fallbackToWeakCandidate=1"
|
|
)
|
|
self.startContextMenuDownload(
|
|
weakImageURL,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
|
|
if linkURL != nil {
|
|
self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image")
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "fallback_link_not_image"
|
|
)
|
|
return
|
|
}
|
|
|
|
self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image")
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "no_image_or_link_url"
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc private func contextMenuDownloadLinkedFile(_ sender: Any?) {
|
|
let traceID = Self.makeContextDownloadTraceID(prefix: "lnk")
|
|
let point = lastContextMenuPoint
|
|
debugContextDownload(
|
|
"browser.ctxdl.click trace=\(traceID) kind=linked point=(\(Int(point.x)),\(Int(point.y)))"
|
|
)
|
|
let fallback = fallbackFromSender(
|
|
sender,
|
|
defaultAction: fallbackDownloadLinkedFileAction,
|
|
defaultTarget: fallbackDownloadLinkedFileTarget
|
|
)
|
|
debugContextDownload(
|
|
"browser.ctxdl.click trace=\(traceID) fallback action=\(Self.selectorName(fallback.action)) target=\(String(describing: fallback.target))"
|
|
)
|
|
findLinkURLAtPoint(point) { [weak self] url in
|
|
guard let self else { return }
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked linkURL=\(url?.absoluteString ?? "nil")"
|
|
)
|
|
if let url {
|
|
let normalized = self.normalizedLinkedDownloadURL(url)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)"
|
|
)
|
|
if self.isDownloadSupportedScheme(normalized) {
|
|
self.startContextMenuDownload(
|
|
normalized,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
// Fallback 1: image URL under cursor (useful on image-heavy result pages).
|
|
self.findImageURLAtPoint(point) { imageURL in
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackImageURL=\(imageURL?.absoluteString ?? "nil")"
|
|
)
|
|
var dataImageURL: URL?
|
|
if let imageURL, self.isDownloadableScheme(imageURL) {
|
|
self.startContextMenuDownload(
|
|
imageURL,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
if let imageURL, self.isDataURLScheme(imageURL) {
|
|
dataImageURL = imageURL
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackDataURLDetected length=\(imageURL.absoluteString.count)"
|
|
)
|
|
}
|
|
|
|
// Fallback 2: simpler nearest-anchor lookup.
|
|
self.findLinkAtPoint(point) { fallbackURL in
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked nearestAnchorURL=\(fallbackURL?.absoluteString ?? "nil")"
|
|
)
|
|
guard let fallbackURL else {
|
|
if let dataImageURL {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1"
|
|
)
|
|
self.startContextMenuDownload(
|
|
dataImageURL,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked")
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "no_link_or_image_url"
|
|
)
|
|
return
|
|
}
|
|
let normalized = self.normalizedLinkedDownloadURL(fallbackURL)
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedNearestAnchorURL=\(normalized.absoluteString)"
|
|
)
|
|
guard self.isDownloadSupportedScheme(normalized) else {
|
|
if let dataImageURL {
|
|
self.debugContextDownload(
|
|
"browser.ctxdl.resolve trace=\(traceID) kind=linked fallbackToDataURL=1"
|
|
)
|
|
self.startContextMenuDownload(
|
|
dataImageURL,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
return
|
|
}
|
|
self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked")
|
|
self.runContextMenuFallback(
|
|
action: fallback.action,
|
|
target: fallback.target,
|
|
sender: sender,
|
|
traceID: traceID,
|
|
reason: "nearest_anchor_unsupported_scheme"
|
|
)
|
|
return
|
|
}
|
|
self.startContextMenuDownload(
|
|
normalized,
|
|
sender: sender,
|
|
fallbackAction: fallback.action,
|
|
fallbackTarget: fallback.target,
|
|
traceID: traceID
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|