From 589153b1b263cf7da0d9f826780a3f21a0ce7027 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 20:31:45 -0800 Subject: [PATCH 1/2] Fix browser context-menu download targeting and debug traces --- Sources/Panels/CmuxWebView.swift | 877 +++++++++++++++++++++++++++---- 1 file changed, 768 insertions(+), 109 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index bcd77ed2..c8244dd2 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,6 +1,7 @@ import AppKit import Bonsplit import ObjectiveC +import UniformTypeIdentifiers import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -258,11 +259,179 @@ final class CmuxWebView: WKWebView { 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.. 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 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 isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { guard target === self else { return false } return action == #selector(contextMenuDownloadImage(_:)) @@ -271,7 +440,7 @@ final class CmuxWebView: WKWebView { 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), + 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"] @@ -299,6 +468,43 @@ final class CmuxWebView: WKWebView { 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 @@ -331,49 +537,121 @@ final class CmuxWebView: WKWebView { 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); + 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; } - return chain; }; - for (const el of walk(start)) { - if (!seen.has(el)) { - seen.add(el); - elChain.push(el); + 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; } } - - for (const el of elChain) { - if (el.tagName === 'IMG') { - if (el.currentSrc) return el.currentSrc; - if (el.src) return el.src; + return ''; + }; + const tryNodes = (nodes) => { + for (const start of nodes) { + for (const el of collectChain(start)) { + const found = candidateFromElement(el); + if (found) return found; } - if (el.tagName === 'PICTURE') { - const img = el.querySelector('img'); - if (img) { - if (img.currentSrc) return img.currentSrc; - if (img.src) return img.src; + 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 ''; + 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 @@ -391,28 +669,69 @@ final class CmuxWebView: WKWebView { 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; + 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 ''; + 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 @@ -425,6 +744,49 @@ final class CmuxWebView: WKWebView { } } + 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) @@ -446,16 +808,34 @@ final class CmuxWebView: WKWebView { _ = NSWorkspace.shared.open(url) } - private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { - guard let action else { return } + 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 target === self, action == #selector(contextMenuDownloadImage(_:)) || action == #selector(contextMenuDownloadLinkedFile(_:)) { NSLog("CmuxWebView context fallback skipped (recursive self action)") + debugContextDownload( + "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" + ) return } - _ = NSApp.sendAction(action, to: target, from: sender) + 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) { @@ -473,19 +853,98 @@ final class CmuxWebView: WKWebView { suggestedFilename: String?, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { - guard isDownloadableScheme(url) else { - runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + 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() @@ -494,13 +953,39 @@ final class CmuxWebView: WKWebView { 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 { return } - try? data.write(to: destURL, options: .atomic) + 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.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + 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 @@ -520,14 +1005,35 @@ final class CmuxWebView: WKWebView { 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) + 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 @@ -539,12 +1045,32 @@ final class CmuxWebView: WKWebView { 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 { return } + 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.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + 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" + ) } } } @@ -556,15 +1082,18 @@ final class CmuxWebView: WKWebView { _ url: URL, sender: Any?, fallbackAction: Selector?, - fallbackTarget: AnyObject? + fallbackTarget: AnyObject?, + traceID: String ) { NSLog("CmuxWebView context download start: %@", url.absoluteString) + debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, suggestedFilename: nil, sender: sender, fallbackAction: fallbackAction, - fallbackTarget: fallbackTarget + fallbackTarget: fallbackTarget, + traceID: traceID ) } @@ -596,10 +1125,14 @@ final class CmuxWebView: WKWebView { 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 == "Open Link in Default Browser") { @@ -620,9 +1153,11 @@ final class CmuxWebView: WKWebView { item.title = "Open Link in New Tab" } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" - || item.title == "Download Image" { + if isDownloadImageMenuItem(item) { NSLog("CmuxWebView context menu hook: download image") + 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 { @@ -636,9 +1171,11 @@ final class CmuxWebView: WKWebView { item.action = #selector(contextMenuDownloadImage(_:)) } - if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" - || item.title == "Download Linked File" { + if isDownloadLinkedFileMenuItem(item) { NSLog("CmuxWebView context menu hook: download linked file") + 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 { @@ -674,80 +1211,152 @@ final class CmuxWebView: WKWebView { } @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? 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 + 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.isLikelyFaviconURL(normalized) && self.isLikelyImageURL(normalized) { + NSLog("CmuxWebView context download image URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + return + } + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" ) - 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 + 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)" ) - return + if self.isDownloadableScheme(normalizedLink), + self.isLikelyImageURL(normalizedLink), + !self.isLikelyFaviconURL(normalizedLink) { + NSLog("CmuxWebView context download image fallback to link URL: %@ (normalized=%@)", linkURL.absoluteString, normalizedLink.absoluteString) + self.startContextMenuDownload( + normalizedLink, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target, + traceID: traceID + ) + 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 + + 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 } - NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) - self.startContextMenuDownload( - linkURL, + if let linkURL { + NSLog("CmuxWebView context download image: link URL not image-like (%@), using fallback action", linkURL.absoluteString) + 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 + } + + NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target + 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) - if self.isDownloadableScheme(normalized) { + self.debugContextDownload( + "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" + ) + if self.isDownloadSupportedScheme(normalized) { NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) return } @@ -755,35 +1364,84 @@ final class CmuxWebView: WKWebView { // 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) { NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) self.startContextMenuDownload( imageURL, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + 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 + } NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "no_link_or_image_url" ) return } let normalized = self.normalizedLinkedDownloadURL(fallbackURL) - guard self.isDownloadableScheme(normalized) else { + 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 + } NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) + self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, target: fallback.target, - sender: sender + sender: sender, + traceID: traceID, + reason: "nearest_anchor_unsupported_scheme" ) return } @@ -792,7 +1450,8 @@ final class CmuxWebView: WKWebView { normalized, sender: sender, fallbackAction: fallback.action, - fallbackTarget: fallback.target + fallbackTarget: fallback.target, + traceID: traceID ) } } From 7610e586b77a58b5c0230cf1b10a2c792b4cb52e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 24 Feb 2026 20:53:12 -0800 Subject: [PATCH 2/2] Browser download: keep logs debug-only and harden menu fallback --- Sources/Panels/CmuxWebView.swift | 56 ++++++++++++------- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 34 +++++++++++ 2 files changed, 69 insertions(+), 21 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index c8244dd2..c330f9ea 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -826,7 +826,6 @@ final class CmuxWebView: WKWebView { if target === self, action == #selector(contextMenuDownloadImage(_:)) || action == #selector(contextMenuDownloadLinkedFile(_:)) { - NSLog("CmuxWebView context fallback skipped (recursive self action)") debugContextDownload( "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" ) @@ -1085,7 +1084,6 @@ final class CmuxWebView: WKWebView { fallbackTarget: AnyObject?, traceID: String ) { - NSLog("CmuxWebView context download start: %@", url.absoluteString) debugContextDownload("browser.ctxdl.start trace=\(traceID) url=\(url.absoluteString)") downloadURLViaSession( url, @@ -1154,7 +1152,6 @@ final class CmuxWebView: WKWebView { } if isDownloadImageMenuItem(item) { - NSLog("CmuxWebView context menu hook: download image") debugContextDownload( "browser.ctxdl.menu hook kind=image index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" ) @@ -1172,7 +1169,6 @@ final class CmuxWebView: WKWebView { } if isDownloadLinkedFileMenuItem(item) { - NSLog("CmuxWebView context menu hook: download linked file") debugContextDownload( "browser.ctxdl.menu hook kind=linked index=\(index) id=\(item.identifier?.rawValue ?? "nil") title=\(item.title) action=\(Self.selectorName(item.action))" ) @@ -1230,6 +1226,7 @@ final class CmuxWebView: WKWebView { "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" { @@ -1242,16 +1239,27 @@ final class CmuxWebView: WKWebView { self.debugContextDownload( "browser.ctxdl.resolve trace=\(traceID) kind=image normalizedImageURL=\(normalized.absoluteString)" ) - if !self.isLikelyFaviconURL(normalized) && self.isLikelyImageURL(normalized) { - NSLog("CmuxWebView context download image URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) - self.startContextMenuDownload( - normalized, - sender: sender, - fallbackAction: fallback.action, - fallbackTarget: fallback.target, - traceID: traceID + 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" ) - return } self.debugContextDownload( "browser.ctxdl.resolve trace=\(traceID) kind=image rejectedPrimaryImageURL=\(normalized.absoluteString)" @@ -1273,7 +1281,6 @@ final class CmuxWebView: WKWebView { if self.isDownloadableScheme(normalizedLink), self.isLikelyImageURL(normalizedLink), !self.isLikelyFaviconURL(normalizedLink) { - NSLog("CmuxWebView context download image fallback to link URL: %@ (normalized=%@)", linkURL.absoluteString, normalizedLink.absoluteString) self.startContextMenuDownload( normalizedLink, sender: sender, @@ -1299,8 +1306,21 @@ final class CmuxWebView: WKWebView { 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 let linkURL { - NSLog("CmuxWebView context download image: link URL not image-like (%@), using fallback action", linkURL.absoluteString) self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") self.runContextMenuFallback( action: fallback.action, @@ -1312,7 +1332,6 @@ final class CmuxWebView: WKWebView { return } - NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") self.runContextMenuFallback( action: fallback.action, @@ -1350,7 +1369,6 @@ final class CmuxWebView: WKWebView { "browser.ctxdl.resolve trace=\(traceID) kind=linked normalizedLinkURL=\(normalized.absoluteString)" ) if self.isDownloadSupportedScheme(normalized) { - NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, @@ -1369,7 +1387,6 @@ final class CmuxWebView: WKWebView { ) var dataImageURL: URL? if let imageURL, self.isDownloadableScheme(imageURL) { - NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) self.startContextMenuDownload( imageURL, sender: sender, @@ -1405,7 +1422,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file: URL nil, using fallback action") self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, @@ -1434,7 +1450,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "linked") self.runContextMenuFallback( action: fallback.action, @@ -1445,7 +1460,6 @@ final class CmuxWebView: WKWebView { ) return } - NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString) self.startContextMenuDownload( normalized, sender: sender, diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 9a0e10b6..b610b14f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -641,6 +641,40 @@ final class CmuxWebViewContextMenuTests: XCTestCase { XCTAssertFalse(menu.items.contains { $0.title == "Open Link in Default Browser" }) } + + func testWillOpenMenuHooksDownloadImageToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadImageToDisk:") + let downloadItem = NSMenuItem(title: "Download Image As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadImageToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } + + func testWillOpenMenuHooksDownloadLinkedFileToDiskMenuVariant() { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let menu = NSMenu() + let originalTarget = NSObject() + let originalAction = NSSelectorFromString("downloadLinkToDisk:") + let downloadItem = NSMenuItem(title: "Download Linked File As...", action: originalAction, keyEquivalent: "") + downloadItem.identifier = NSUserInterfaceItemIdentifier("WKMenuItemIdentifierDownloadLinkToDisk") + downloadItem.target = originalTarget + menu.addItem(downloadItem) + + webView.willOpenMenu(menu, with: makeRightMouseDownEvent()) + + XCTAssertTrue(downloadItem.target === webView) + XCTAssertNotNil(downloadItem.action) + XCTAssertNotEqual(downloadItem.action, originalAction) + } } final class BrowserDevToolsButtonDebugSettingsTests: XCTestCase {