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