Fix browser download UX and stabilize browser crash path (#235)

* Fix browser download UX and stabilize download crash path

* Fix context menu image/link download target resolution

* Restore native WebKit context-menu download actions

* Improve browser download feedback and context menu downloads

* Fix flaky alternating context-menu downloads

* Stabilize linked-file context downloads

* Use per-menu-item fallback for context downloads

* Harden linked-file URL resolution for context downloads
This commit is contained in:
Austin Wang 2026-02-20 21:21:03 -08:00 committed by GitHub
parent b0e6d11f6a
commit 95ac588bb2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 764 additions and 2 deletions

View file

@ -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()
}
}

View file

@ -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<AnyCancellable>()
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

View file

@ -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")
}
}
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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
}