diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index bb221ba2..e913c119 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -4,6 +4,85 @@ import ObjectiveC import UniformTypeIdentifiers import WebKit +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 menu /// key equivalents first so app-level shortcuts continue to work when WebKit is @@ -311,6 +390,9 @@ final class CmuxWebView: WKWebView { /// 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? @@ -474,6 +556,29 @@ final class CmuxWebView: WKWebView { 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" @@ -488,8 +593,11 @@ final class CmuxWebView: WKWebView { return isDownloadableScheme(url) || isDataURLScheme(url) } - private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { + 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(_:)) } @@ -564,7 +672,7 @@ final class CmuxWebView: WKWebView { private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { let target = item.target as AnyObject? let action = item.action - if isOurDownloadMenuAction(target: target, action: action) { + if isOurContextMenuAction(target: target, action: action) { return } let box = ContextMenuFallbackBox(target: target, action: action) @@ -879,9 +987,7 @@ final class CmuxWebView: WKWebView { return } // Guard against accidental self-recursion if fallback gets overwritten. - if target === self, - action == #selector(contextMenuDownloadImage(_:)) - || action == #selector(contextMenuDownloadLinkedFile(_:)) { + if isOurContextMenuAction(target: target, action: action) { debugContextDownload( "browser.ctxdl.fallback trace=\(trace) reason=\(reason ?? "none") skipped=recursive action=\(Self.selectorName(action))" ) @@ -1151,6 +1257,192 @@ final class CmuxWebView: WKWebView { ) } + 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). @@ -1249,7 +1541,7 @@ final class CmuxWebView: WKWebView { if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { fallbackDownloadImageTarget = box.target fallbackDownloadImageAction = box.action - } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + } else if !isOurContextMenuAction(target: item.target as AnyObject?, action: item.action) { fallbackDownloadImageTarget = item.target as AnyObject? fallbackDownloadImageAction = item.action } @@ -1257,6 +1549,22 @@ final class CmuxWebView: WKWebView { 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))" @@ -1266,7 +1574,7 @@ final class CmuxWebView: WKWebView { if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { fallbackDownloadLinkedFileTarget = box.target fallbackDownloadLinkedFileAction = box.action - } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + } else if !isOurContextMenuAction(target: item.target as AnyObject?, action: item.action) { fallbackDownloadLinkedFileTarget = item.target as AnyObject? fallbackDownloadLinkedFileAction = item.action } @@ -1303,6 +1611,81 @@ final class CmuxWebView: WKWebView { } } + @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 @@ -1417,7 +1800,7 @@ final class CmuxWebView: WKWebView { return } - if let linkURL { + if linkURL != nil { self.debugInspectElementsAtPoint(point, traceID: traceID, kind: "image") self.runContextMenuFallback( action: fallback.action,