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:
parent
b0e6d11f6a
commit
95ac588bb2
5 changed files with 764 additions and 2 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue