eleven / ocho Public

mac: download file attachments and save-image via WKDownload (#195)

The Mac webview coordinator implemented WKNavigationDelegate but had no
download handling, so clicking a file chip — a download-attributed anchor
pointing at an in-page blob: URL — silently did nothing (macOS WKWebView
shows no download UI without it). The same gap left right-click "Save Image"
on lightboxed photos broken.

Route download-attributed navigations through WKDownload: decidePolicyFor
returns .download when shouldPerformDownload is set, and a WKDownloadDelegate
saves straight to ~/Downloads (Safari-like, matching a click in the web app),
de-duplicating the filename since WebKit refuses to overwrite, then reveals the
file in Finder. Failures surface a native alert rather than failing silently.

Verified the blob:-URL -> WKDownload path end-to-end with a standalone WebKit
harness (blob behind a download anchor reaches the delegate; bytes land on
disk with the suggested filename). Mac-only; iOS has no Downloads model.
Co-authored-by: Claude <noreply@anthropic.com>
James Adam 8b3ae3b29e79626de3961466e0096fb2b66fdd1b parent c63a886
2 files changed, +80 −1
  • changelog/2026-07-05-mac-file-download.md +4 −0
  • ios/LChatMac/ChatFeedWebView.swift +76 −1
diff --git a/changelog/2026-07-05-mac-file-download.md b/changelog/2026-07-05-mac-file-download.md
new file mode 100644
index 0000000..41b09bd
--- /dev/null
+++ b/changelog/2026-07-05-mac-file-download.md
@@ -0,0 +1,4 @@
+Downloading a file someone sent you now works in the Mac app. Clicking a file
+attachment saves it straight to your Downloads folder and reveals it in Finder,
+just like a browser — previously the click did nothing. The same fix makes
+right-click "Save Image" work on photos you open full-screen.
diff --git a/ios/LChatMac/ChatFeedWebView.swift b/ios/LChatMac/ChatFeedWebView.swift
index f2b8317..78e708a 100644
--- a/ios/LChatMac/ChatFeedWebView.swift
+++ b/ios/LChatMac/ChatFeedWebView.swift
@@ -154,11 +154,14 @@ struct ChatFeedWebView: NSViewRepresentable {
})();
"""
- final class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler {
+ final class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, WKScriptMessageHandler, WKDownloadDelegate {
private let chat: SavedChat
private let app: MacAppState
private var didAttemptJoin = false
weak var webView: WKWebView?
+ // Where each in-flight download is being written, so we can reveal it in
+ // Finder on finish (WKDownload doesn't hand the final URL back to didFinish).
+ private var downloadDestinations: [WKDownload: URL] = [:]
init(chat: SavedChat, app: MacAppState) {
self.chat = chat
@@ -405,6 +408,78 @@ struct ChatFeedWebView: NSViewRepresentable {
}
return nil
}
+
+ // A file chip (chat.js) and the lightbox's "Save Image" both render as
+ // download-attributed anchors pointing at an in-page blob: URL. WKWebView
+ // routes those here with shouldPerformDownload set; without turning them into
+ // a real WKDownload the click silently does nothing (there's no download UI at
+ // all on macOS). Everything else — the join redirect, in-app navigation — is a
+ // normal load and passes through.
+ func webView(_ webView: WKWebView,
+ decidePolicyFor navigationAction: WKNavigationAction,
+ decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
+ decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
+ }
+
+ func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
+ download.delegate = self
+ }
+
+ func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
+ download.delegate = self
+ }
+
+ // Save straight to ~/Downloads (Safari-like), matching what a click does in the
+ // web app — no per-file save panel. WebKit fails the download if the URL already
+ // exists, so a colliding name gets " 2", " 3", … appended (as Finder would).
+ func download(_ download: WKDownload,
+ decideDestinationUsing response: URLResponse,
+ suggestedFilename: String,
+ completionHandler: @escaping (URL?) -> Void) {
+ let dir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first
+ ?? FileManager.default.temporaryDirectory
+ let dest = Self.uniqueDestination(in: dir, filename: suggestedFilename)
+ downloadDestinations[download] = dest
+ completionHandler(dest)
+ }
+
+ // No download shelf in the app, so reveal the saved file in Finder — otherwise a
+ // silent write leaves no sign the download worked.
+ func downloadDidFinish(_ download: WKDownload) {
+ guard let url = downloadDestinations.removeValue(forKey: download) else { return }
+ NSWorkspace.shared.activateFileViewerSelecting([url])
+ }
+
+ func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
+ downloadDestinations.removeValue(forKey: download)
+ let alert = NSAlert()
+ alert.messageText = "Download failed"
+ alert.informativeText = error.localizedDescription
+ alert.addButton(withTitle: "OK")
+ if let window = webView?.window {
+ alert.beginSheetModal(for: window, completionHandler: nil)
+ } else {
+ alert.runModal()
+ }
+ }
+
+ // A destination that doesn't yet exist (WebKit refuses to overwrite): "name.ext",
+ // then "name 2.ext", "name 3.ext", … — the Finder convention.
+ private static func uniqueDestination(in dir: URL, filename: String) -> URL {
+ let fm = FileManager.default
+ let name = filename.isEmpty ? "download" : filename
+ var candidate = dir.appendingPathComponent(name)
+ guard fm.fileExists(atPath: candidate.path) else { return candidate }
+ let ext = (name as NSString).pathExtension
+ let base = (name as NSString).deletingPathExtension
+ var n = 2
+ repeat {
+ let next = ext.isEmpty ? "\(base) \(n)" : "\(base) \(n).\(ext)"
+ candidate = dir.appendingPathComponent(next)
+ n += 1
+ } while fm.fileExists(atPath: candidate.path)
+ return candidate
+ }
}
}