diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 408123f4..9c0ad241 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -195,6 +195,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var windowKeyObserver: NSObjectProtocol? private var shortcutMonitor: Any? private var shortcutDefaultsObserver: NSObjectProtocol? + private var splitButtonTooltipRefreshScheduled = false private var ghosttyConfigObserver: NSObjectProtocol? private var ghosttyGotoSplitLeftShortcut: StoredShortcut? private var ghosttyGotoSplitRightShortcut: StoredShortcut? @@ -1479,7 +1480,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent object: nil, queue: .main ) { [weak self] _ in - self?.refreshSplitButtonTooltipsAcrossWorkspaces() + self?.scheduleSplitButtonTooltipRefreshAcrossWorkspaces() + } + } + + /// Coalesce shortcut-default changes and refresh on the next runloop turn to + /// avoid mutating Bonsplit/SwiftUI-observed state during an active update pass. + private func scheduleSplitButtonTooltipRefreshAcrossWorkspaces() { + guard !splitButtonTooltipRefreshScheduled else { return } + splitButtonTooltipRefreshScheduled = true + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.splitButtonTooltipRefreshScheduled = false + self.refreshSplitButtonTooltipsAcrossWorkspaces() } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 12fa1c75..889798f3 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1029,6 +1029,9 @@ final class BrowserPanel: Panel, ObservableObject { /// Published loading state @Published private(set) var isLoading: Bool = false + /// Published download state for browser downloads (navigation + context menu). + @Published private(set) var isDownloading: Bool = false + /// Published can go back state @Published private(set) var canGoBack: Bool = false @@ -1048,7 +1051,9 @@ final class BrowserPanel: Panel, ObservableObject { private var cancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? + private var downloadDelegate: BrowserDownloadDelegate? private var webViewObservers: [NSKeyValueObservation] = [] + private var activeDownloadCount: Int = 0 // Avoid flickering the loading indicator for very fast navigations. private let minLoadingIndicatorDuration: TimeInterval = 0.35 @@ -1152,6 +1157,30 @@ final class BrowserPanel: Panel, ObservableObject { navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] url, intent in self?.presentInsecureHTTPAlert(for: url, intent: intent, recordTypedNavigation: false) } + navDelegate.onDownloadDetected = { [weak self] _ in + self?.beginDownloadActivity() + } + // Set up download delegate for navigation-based downloads. + // Downloads save to a temp file synchronously (no NSSavePanel during WebKit + // callbacks), then show NSSavePanel after the download completes. + let dlDelegate = BrowserDownloadDelegate() + // Download activity is already started at policy-detection time. + dlDelegate.onDownloadStarted = { _ in } + dlDelegate.onDownloadReadyToSave = { [weak self] in + self?.endDownloadActivity() + } + dlDelegate.onDownloadFailed = { [weak self] _ in + self?.endDownloadActivity() + } + navDelegate.downloadDelegate = dlDelegate + self.downloadDelegate = dlDelegate + webView.onContextMenuDownloadStateChanged = { [weak self] downloading in + if downloading { + self?.beginDownloadActivity() + } else { + self?.endDownloadActivity() + } + } webView.navigationDelegate = navDelegate self.navigationDelegate = navDelegate @@ -1176,6 +1205,30 @@ final class BrowserPanel: Panel, ObservableObject { } } + private func beginDownloadActivity() { + let apply = { + self.activeDownloadCount += 1 + self.isDownloading = self.activeDownloadCount > 0 + } + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } + + private func endDownloadActivity() { + let apply = { + self.activeDownloadCount = max(0, self.activeDownloadCount - 1) + self.isDownloading = self.activeDownloadCount > 0 + } + if Thread.isMainThread { + apply() + } else { + DispatchQueue.main.async(execute: apply) + } + } + func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId } @@ -2076,6 +2129,133 @@ private extension NSObject { } } +// MARK: - Download Delegate + +/// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI +/// during WebKit callbacks), then showing NSSavePanel after the download finishes. +private class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { + private struct DownloadState { + let tempURL: URL + let suggestedFilename: String + } + + /// Tracks active downloads keyed by WKDownload identity. + private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] + private let activeDownloadsLock = NSLock() + var onDownloadStarted: ((String) -> Void)? + var onDownloadReadyToSave: (() -> Void)? + var onDownloadFailed: ((Error) -> Void)? + + private static let tempDir: URL = { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + }() + + private static func sanitizedFilename(_ raw: String, fallbackURL: URL?) -> String { + let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) + let candidate = (trimmed as NSString).lastPathComponent + let fromURL = fallbackURL?.lastPathComponent ?? "" + let base = candidate.isEmpty ? fromURL : candidate + let replaced = base.replacingOccurrences(of: ":", with: "-") + let safe = replaced.trimmingCharacters(in: .whitespacesAndNewlines) + return safe.isEmpty ? "download" : safe + } + + private func storeState(_ state: DownloadState, for download: WKDownload) { + activeDownloadsLock.lock() + activeDownloads[ObjectIdentifier(download)] = state + activeDownloadsLock.unlock() + } + + private func removeState(for download: WKDownload) -> DownloadState? { + activeDownloadsLock.lock() + let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) + activeDownloadsLock.unlock() + return state + } + + private func notifyOnMain(_ action: @escaping () -> Void) { + if Thread.isMainThread { + action() + } else { + DispatchQueue.main.async(execute: action) + } + } + + func download( + _ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String, + completionHandler: @escaping (URL?) -> Void + ) { + // Save to a temp file — return synchronously so WebKit is never blocked. + let safeFilename = Self.sanitizedFilename(suggestedFilename, fallbackURL: response.url) + let tempFilename = "\(UUID().uuidString)-\(safeFilename)" + let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) + try? FileManager.default.removeItem(at: destURL) + storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename), for: download) + notifyOnMain { [weak self] in + self?.onDownloadStarted?(safeFilename) + } + #if DEBUG + dlog("download.decideDestination file=\(safeFilename)") + #endif + NSLog("BrowserPanel download: temp path=%@", destURL.path) + completionHandler(destURL) + } + + func downloadDidFinish(_ download: WKDownload) { + guard let info = removeState(for: download) else { + #if DEBUG + dlog("download.finished missing-state") + #endif + return + } + #if DEBUG + dlog("download.finished file=\(info.suggestedFilename)") + #endif + NSLog("BrowserPanel download finished: %@", info.suggestedFilename) + + // Show NSSavePanel on the next runloop iteration (safe context). + DispatchQueue.main.async { + self.onDownloadReadyToSave?() + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = info.suggestedFilename + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { + try? FileManager.default.removeItem(at: info.tempURL) + return + } + do { + try? FileManager.default.removeItem(at: destURL) + try FileManager.default.moveItem(at: info.tempURL, to: destURL) + NSLog("BrowserPanel download saved: %@", destURL.path) + } catch { + NSLog("BrowserPanel download move failed: %@", error.localizedDescription) + try? FileManager.default.removeItem(at: info.tempURL) + } + } + } + } + + func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + if let info = removeState(for: download) { + try? FileManager.default.removeItem(at: info.tempURL) + } + notifyOnMain { [weak self] in + self?.onDownloadFailed?(error) + } + #if DEBUG + dlog("download.failed error=\(error.localizedDescription)") + #endif + NSLog("BrowserPanel download failed: %@", error.localizedDescription) + } +} + // MARK: - Navigation Delegate private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { @@ -2084,6 +2264,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { var openInNewTab: ((URL) -> Void)? var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URL, BrowserInsecureHTTPNavigationIntent) -> Void)? + /// Called when navigation response policy decides to route to WKDownload. + var onDownloadDetected: ((String?) -> Void)? + /// Direct reference to the download delegate — must be set synchronously in didBecome callbacks. + var downloadDelegate: WKDownloadDelegate? /// The URL of the last navigation that was attempted. Used to preserve the omnibar URL /// when a provisional navigation fails (e.g. connection refused on localhost:3000). var lastAttemptedURL: URL? @@ -2109,6 +2293,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { return } + // "Frame load interrupted" (WebKitErrorDomain code 102) fires when a + // navigation response is converted into a download via .download policy. + // This is expected and should not show an error page. + if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { + return + } + let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String ?? lastAttemptedURL?.absoluteString ?? "" @@ -2237,6 +2428,76 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.allow) } + + func webView( + _ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void + ) { + if !navigationResponse.isForMainFrame { + decisionHandler(.allow) + return + } + + let mime = navigationResponse.response.mimeType ?? "unknown" + let canShow = navigationResponse.canShowMIMEType + let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" + + // Only classify HTTP(S) top-level responses as downloads. + if let scheme = navigationResponse.response.url?.scheme?.lowercased(), + scheme != "http", scheme != "https" { + decisionHandler(.allow) + return + } + + NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", + responseURL, mime, canShow ? 1 : 0, + navigationResponse.isForMainFrame ? 1 : 0) + + // Check if this response should be treated as a download. + // Criteria: explicit Content-Disposition: attachment, or a MIME type + // that WebKit cannot render inline. + if let response = navigationResponse.response as? HTTPURLResponse { + let contentDisposition = response.value(forHTTPHeaderField: "Content-Disposition") ?? "" + if contentDisposition.lowercased().hasPrefix("attachment") { + NSLog("BrowserPanel download: content-disposition=attachment mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=content-disposition mime=\(mime)") + #endif + onDownloadDetected?(response.suggestedFilename) + decisionHandler(.download) + return + } + } + + if !canShow { + NSLog("BrowserPanel download: cannotShowMIME mime=%@ url=%@", mime, responseURL) + #if DEBUG + dlog("download.policy=download reason=cannotShowMIME mime=\(mime)") + #endif + onDownloadDetected?(navigationResponse.response.suggestedFilename) + decisionHandler(.download) + return + } + + decisionHandler(.allow) + } + + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationAction") + #endif + NSLog("BrowserPanel download didBecome from navigationAction") + download.delegate = downloadDelegate + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + #if DEBUG + dlog("download.didBecome source=navigationResponse") + #endif + NSLog("BrowserPanel download didBecome from navigationResponse") + download.delegate = downloadDelegate + } } // MARK: - UI Delegate diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index ef747d75..bda555bf 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -395,6 +395,18 @@ struct BrowserPanelView: View { } .buttonStyle(.plain) .help(panel.isLoading ? "Stop" : "Reload") + + if panel.isDownloading { + HStack(spacing: 4) { + ProgressView() + .controlSize(.small) + Text("Downloading...") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + .padding(.leading, 6) + .help("Download in progress") + } } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 08843c0f..239c641a 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1,4 +1,5 @@ import AppKit +import ObjectiveC import WebKit /// WKWebView tends to consume some Command-key equivalents (e.g. Cmd+N/Cmd+W), @@ -6,6 +7,20 @@ import WebKit /// key equivalents first so app-level shortcuts continue to work when WebKit is /// the first responder. final class CmuxWebView: WKWebView { + private final class ContextMenuFallbackBox: NSObject { + weak var target: AnyObject? + let action: Selector? + + init(target: AnyObject?, action: Selector?) { + self.target = target + self.action = action + } + } + + private static var contextMenuFallbackKey: UInt8 = 0 + + var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + override func performKeyEquivalent(with event: NSEvent) -> Bool { // Preserve Cmd+Return/Enter for web content (e.g. editors/forms). Do not // route it through app/menu key equivalents, which can trigger unintended actions. @@ -109,6 +124,306 @@ final class CmuxWebView: WKWebView { } } + // MARK: - Context menu download support + + /// The last context-menu point in view coordinates. + private var lastContextMenuPoint: NSPoint = .zero + /// Saved native WebKit action for "Download Image". + private var fallbackDownloadImageTarget: AnyObject? + private var fallbackDownloadImageAction: Selector? + /// Saved native WebKit action for "Download Linked File". + private var fallbackDownloadLinkedFileTarget: AnyObject? + private var fallbackDownloadLinkedFileAction: Selector? + + private func isDownloadableScheme(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() ?? "" + return scheme == "http" || scheme == "https" || scheme == "file" + } + + private func isOurDownloadMenuAction(target: AnyObject?, action: Selector?) -> Bool { + guard target === self else { return false } + return action == #selector(contextMenuDownloadImage(_:)) + || action == #selector(contextMenuDownloadLinkedFile(_:)) + } + + private func resolveGoogleRedirectURL(_ url: URL) -> URL? { + guard let host = url.host?.lowercased(), host.contains("google.") else { return nil } + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false), + let queryItems = comps.queryItems else { return nil } + let map = Dictionary(uniqueKeysWithValues: queryItems.map { ($0.name.lowercased(), $0.value ?? "") }) + let candidates = ["imgurl", "mediaurl", "url", "q"] + for key in candidates { + guard let raw = map[key], !raw.isEmpty, + let decoded = raw.removingPercentEncoding ?? raw as String?, + let candidate = URL(string: decoded), + isDownloadableScheme(candidate) else { + continue + } + return candidate + } + // Some links are wrapped as /url?... + if comps.path.lowercased() == "/url" { + for key in ["url", "q"] { + if let raw = map[key], let candidate = URL(string: raw), isDownloadableScheme(candidate) { + return candidate + } + } + } + return nil + } + + private func normalizedLinkedDownloadURL(_ url: URL) -> URL { + resolveGoogleRedirectURL(url) ?? url + } + + private func captureFallbackForMenuItemIfNeeded(_ item: NSMenuItem) { + let target = item.target as AnyObject? + let action = item.action + if isOurDownloadMenuAction(target: target, action: action) { + return + } + let box = ContextMenuFallbackBox(target: target, action: action) + objc_setAssociatedObject( + item, + &Self.contextMenuFallbackKey, + box, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + + private func fallbackFromSender( + _ sender: Any?, + defaultAction: Selector?, + defaultTarget: AnyObject? + ) -> (action: Selector?, target: AnyObject?) { + if let item = sender as? NSMenuItem, + let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + return (box.action, box.target) + } + return (defaultAction, defaultTarget) + } + + /// Resolve the topmost image URL near a point, accounting for overlay layers. + private func findImageURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { + let flippedY = bounds.height - point.y + let js = """ + (() => { + const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); + for (const start of nodes) { + let elChain = []; + let seen = new Set(); + let walk = (node) => { + let chain = []; + let localSeen = new Set(); + let visit = (n) => { + while (n && !localSeen.has(n)) { + localSeen.add(n); + chain.push(n); + n = n.parentElement; + } + }; + visit(node); + if (node && node.tagName === 'PICTURE') { + const img = node.querySelector('img'); + if (img) visit(img); + } + return chain; + }; + for (const el of walk(start)) { + if (!seen.has(el)) { + seen.add(el); + elChain.push(el); + } + } + + for (const el of elChain) { + if (el.tagName === 'IMG') { + if (el.currentSrc) return el.currentSrc; + if (el.src) return el.src; + } + if (el.tagName === 'PICTURE') { + const img = el.querySelector('img'); + if (img) { + if (img.currentSrc) return img.currentSrc; + if (img.src) return img.src; + } + } + } + } + return ''; + })(); + """ + evaluateJavaScript(js) { result, _ in + guard let src = result as? String, !src.isEmpty, + let url = URL(string: src) else { + completion(nil) + return + } + completion(url) + } + } + + /// Resolve the topmost link URL near a point, accounting for overlay layers. + private func findLinkURLAtPoint(_ point: NSPoint, completion: @escaping (URL?) -> Void) { + let flippedY = bounds.height - point.y + let js = """ + (() => { + const nodes = document.elementsFromPoint(\(point.x), \(flippedY)); + for (const start of nodes) { + let el = start; + let seen = new Set(); + let cur = (() => { + let n = start; + return n; + })(); + let walk = (node) => { + let chain = []; + while (node && !seen.has(node)) { + seen.add(node); + chain.push(node); + node = node.parentElement; + } + return chain; + }; + for (const n of walk(cur)) { + if (n.tagName === 'A' && n.href) return n.href; + } + } + return ''; + })(); + """ + evaluateJavaScript(js) { result, _ in + guard let href = result as? String, !href.isEmpty, + let url = URL(string: href) else { + completion(nil) + return + } + completion(url) + } + } + + private func runContextMenuFallback(action: Selector?, target: AnyObject?, sender: Any?) { + guard let action else { return } + // Guard against accidental self-recursion if fallback gets overwritten. + if target === self, + action == #selector(contextMenuDownloadImage(_:)) + || action == #selector(contextMenuDownloadLinkedFile(_:)) { + NSLog("CmuxWebView context fallback skipped (recursive self action)") + return + } + _ = NSApp.sendAction(action, to: target, from: sender) + } + + private func notifyContextMenuDownloadState(_ downloading: Bool) { + if Thread.isMainThread { + onContextMenuDownloadStateChanged?(downloading) + } else { + DispatchQueue.main.async { [weak self] in + self?.onContextMenuDownloadStateChanged?(downloading) + } + } + } + + private func downloadURLViaSession( + _ url: URL, + suggestedFilename: String?, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject? + ) { + guard isDownloadableScheme(url) else { + runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + return + } + let scheme = url.scheme?.lowercased() ?? "" + notifyContextMenuDownloadState(true) + + if scheme == "file" { + DispatchQueue.main.async { + do { + let data = try Data(contentsOf: url) + let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) + let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + // Download is already complete; we're now waiting for user save choice. + self.notifyContextMenuDownloadState(false) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { return } + try? data.write(to: destURL, options: .atomic) + } + } catch { + self.notifyContextMenuDownloadState(false) + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + } + } + return + } + + let cookieStore = configuration.websiteDataStore.httpCookieStore + cookieStore.getAllCookies { cookies in + var request = URLRequest(url: url) + request.httpMethod = "GET" + let cookieHeaders = HTTPCookie.requestHeaderFields(with: cookies) + for (key, value) in cookieHeaders { + request.setValue(value, forHTTPHeaderField: key) + } + if let referer = self.url?.absoluteString, !referer.isEmpty { + request.setValue(referer, forHTTPHeaderField: "Referer") + } + if let ua = self.customUserAgent, !ua.isEmpty { + request.setValue(ua, forHTTPHeaderField: "User-Agent") + } + + URLSession.shared.dataTask(with: request) { data, response, error in + DispatchQueue.main.async { + guard let data, error == nil else { + self.notifyContextMenuDownloadState(false) + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + return + } + let filenameCandidate = suggestedFilename + ?? response?.suggestedFilename + ?? url.lastPathComponent + let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate + + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + // Download is already complete; we're now waiting for user save choice. + self.notifyContextMenuDownloadState(false) + savePanel.begin { result in + guard result == .OK, let destURL = savePanel.url else { return } + do { + try data.write(to: destURL, options: .atomic) + } catch { + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender) + } + } + } + }.resume() + } + } + + private func startContextMenuDownload( + _ url: URL, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject? + ) { + NSLog("CmuxWebView context download start: %@", url.absoluteString) + downloadURLViaSession( + url, + suggestedFilename: nil, + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget + ) + } + // MARK: - Drag-and-drop passthrough // WKWebView inherently calls registerForDraggedTypes with public.text (and others). @@ -136,6 +451,7 @@ final class CmuxWebView: WKWebView { override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { super.willOpenMenu(menu, with: event) + lastContextMenuPoint = convert(event.locationInWindow, from: nil) for item in menu.items { // Rename "Open Link in New Window" to "Open Link in New Tab". @@ -145,6 +461,164 @@ final class CmuxWebView: WKWebView { || item.title.contains("Open Link in New Window") { item.title = "Open Link in New Tab" } + + if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" + || item.title == "Download Image" { + NSLog("CmuxWebView context menu hook: download image") + captureFallbackForMenuItemIfNeeded(item) + // Keep global fallback as a secondary safety net. + if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + fallbackDownloadImageTarget = box.target + fallbackDownloadImageAction = box.action + } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + fallbackDownloadImageTarget = item.target as AnyObject? + fallbackDownloadImageAction = item.action + } + item.target = self + item.action = #selector(contextMenuDownloadImage(_:)) + } + + if item.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" + || item.title == "Download Linked File" { + NSLog("CmuxWebView context menu hook: download linked file") + captureFallbackForMenuItemIfNeeded(item) + // Keep global fallback as a secondary safety net. + if let box = objc_getAssociatedObject(item, &Self.contextMenuFallbackKey) as? ContextMenuFallbackBox { + fallbackDownloadLinkedFileTarget = box.target + fallbackDownloadLinkedFileAction = box.action + } else if !isOurDownloadMenuAction(target: item.target as AnyObject?, action: item.action) { + fallbackDownloadLinkedFileTarget = item.target as AnyObject? + fallbackDownloadLinkedFileAction = item.action + } + item.target = self + item.action = #selector(contextMenuDownloadLinkedFile(_:)) + } + } + } + + @objc private func contextMenuDownloadImage(_ sender: Any?) { + let point = lastContextMenuPoint + let fallback = fallbackFromSender( + sender, + defaultAction: fallbackDownloadImageAction, + defaultTarget: fallbackDownloadImageTarget + ) + findImageURLAtPoint(point) { [weak self] url in + guard let self else { return } + if let url { + let scheme = url.scheme?.lowercased() ?? "" + if scheme == "http" || scheme == "https" || scheme == "file" { + NSLog("CmuxWebView context download image URL: %@", url.absoluteString) + self.startContextMenuDownload( + url, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + } + + // Google Images and similar sites often expose blob:/data: image URLs. + // If image URL is not directly downloadable, fall back to the nearby link URL. + self.findLinkURLAtPoint(point) { linkURL in + guard let linkURL else { + NSLog("CmuxWebView context download image: no downloadable image/link URL, using fallback action") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + let linkScheme = linkURL.scheme?.lowercased() ?? "" + guard linkScheme == "http" || linkScheme == "https" || linkScheme == "file" else { + NSLog("CmuxWebView context download image: link URL not downloadable (%@), using fallback action", linkURL.absoluteString) + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + + NSLog("CmuxWebView context download image fallback to link URL: %@", linkURL.absoluteString) + self.startContextMenuDownload( + linkURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + } + } + } + + @objc private func contextMenuDownloadLinkedFile(_ sender: Any?) { + let point = lastContextMenuPoint + let fallback = fallbackFromSender( + sender, + defaultAction: fallbackDownloadLinkedFileAction, + defaultTarget: fallbackDownloadLinkedFileTarget + ) + findLinkURLAtPoint(point) { [weak self] url in + guard let self else { return } + if let url { + let normalized = self.normalizedLinkedDownloadURL(url) + if self.isDownloadableScheme(normalized) { + NSLog("CmuxWebView context download linked file URL: %@ (normalized=%@)", url.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + } + + // Fallback 1: image URL under cursor (useful on image-heavy result pages). + self.findImageURLAtPoint(point) { imageURL in + if let imageURL, self.isDownloadableScheme(imageURL) { + NSLog("CmuxWebView context download linked file fallback image URL: %@", imageURL.absoluteString) + self.startContextMenuDownload( + imageURL, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + return + } + + // Fallback 2: simpler nearest-anchor lookup. + self.findLinkAtPoint(point) { fallbackURL in + guard let fallbackURL else { + NSLog("CmuxWebView context download linked file: URL nil, using fallback action") + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + let normalized = self.normalizedLinkedDownloadURL(fallbackURL) + guard self.isDownloadableScheme(normalized) else { + NSLog("CmuxWebView context download linked file: unsupported URL %@, using fallback action", fallbackURL.absoluteString) + self.runContextMenuFallback( + action: fallback.action, + target: fallback.target, + sender: sender + ) + return + } + NSLog("CmuxWebView context download linked file fallback URL: %@ (normalized=%@)", fallbackURL.absoluteString, normalized.absoluteString) + self.startContextMenuDownload( + normalized, + sender: sender, + fallbackAction: fallback.action, + fallbackTarget: fallback.target + ) + } + } } } } diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 2cc9cb0d..a530af87 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -230,8 +230,10 @@ final class Workspace: Identifiable, ObservableObject { } func refreshSplitButtonTooltips() { + let tooltips = Self.currentSplitButtonTooltips() var configuration = bonsplitController.configuration - configuration.appearance.splitButtonTooltips = Self.currentSplitButtonTooltips() + guard configuration.appearance.splitButtonTooltips != tooltips else { return } + configuration.appearance.splitButtonTooltips = tooltips bonsplitController.configuration = configuration }