diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c6984ff791f4..1ae3e7e6c6b6 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -34450,6 +34450,131 @@ } } }, + "browser.download.defaultFilename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "download" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "下载" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "下載" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "다운로드" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Download" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "descarga" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "téléchargement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "download" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "download" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "pobieranie" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "загрузка" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "preuzimanje" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تنزيل" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "nedlasting" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "download" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ดาวน์โหลด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "indirme" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "завантаження" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទាញយក" + } + } + } + }, "browser.downloading": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift new file mode 100644 index 000000000000..d2ee4487f474 --- /dev/null +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -0,0 +1,158 @@ +import Foundation +import ImageIO +import UniformTypeIdentifiers + +nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { + case allow + case reject(statusCode: Int) +} + +nonisolated struct BrowserDownloadFilenameResolver: Sendable { + func httpStatusDecision(for response: URLResponse?) -> BrowserDownloadHTTPStatusDecision { + guard let httpResponse = response as? HTTPURLResponse else { + return .allow + } + guard (200...299).contains(httpResponse.statusCode) else { + return .reject(statusCode: httpResponse.statusCode) + } + return .allow + } + + func imageType(forImageData data: Data) -> UTType? { + guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil), + let typeIdentifier = CGImageSourceGetType(imageSource) as String?, + let type = UTType(typeIdentifier), + type.conforms(to: .image) else { + return nil + } + return type + } + + func imageType(forDownloadedFileAt fileURL: URL) -> UTType? { + guard let imageSource = CGImageSourceCreateWithURL(fileURL as CFURL, nil), + let typeIdentifier = CGImageSourceGetType(imageSource) as String?, + let type = UTType(typeIdentifier), + type.conforms(to: .image) else { + return nil + } + return type + } + + func suggestedFilename( + suggestedFilename: String?, + response: URLResponse?, + sourceURL: URL, + imageType: UTType? + ) -> String { + let fallbackURL = response?.url ?? sourceURL + let filenameCandidate = suggestedFilename + ?? response?.suggestedFilename + ?? fallbackURL.lastPathComponent + let safeCandidate = sanitizedFilename(filenameCandidate, fallbackURL: fallbackURL) + + guard let imageType else { + return safeCandidate + } + + return imageFilename( + candidate: safeCandidate, + imageType: imageType + ) + } + + func suggestedFilename( + suggestedFilename: String?, + response: URLResponse?, + sourceURL: URL, + imageData: Data + ) -> String { + self.suggestedFilename( + suggestedFilename: suggestedFilename, + response: response, + sourceURL: sourceURL, + imageType: imageType(forImageData: imageData) + ) + } + + func suggestedFilename( + suggestedFilename: String?, + sourceURL: URL, + imageFileURL: URL + ) -> String { + self.suggestedFilename( + suggestedFilename: suggestedFilename, + response: nil, + sourceURL: sourceURL, + imageType: imageType(forDownloadedFileAt: imageFileURL) + ) + } + + private func imageFilename( + candidate: String, + imageType: UTType + ) -> String { + if hasImageExtension(candidate, matching: imageType) { + return candidate + } + + let strippedCandidate = strippingNonImageExtensions(from: candidate, matching: imageType) + if strippedCandidate != candidate { + return strippedCandidate + } + + let filenameExtension = preferredFilenameExtension(for: imageType) + let base = baseNameByRemovingFinalExtension(from: candidate) + return "\(base).\(filenameExtension)" + } + + private 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 ? defaultFilename : safe + } + + private func strippingNonImageExtensions(from filename: String, matching imageType: UTType) -> String { + var candidate = filename + while !hasImageExtension(candidate, matching: imageType) { + let next = baseNameByRemovingFinalExtension(from: candidate) + guard next != candidate else { break } + candidate = next + } + return hasImageExtension(candidate, matching: imageType) ? candidate : filename + } + + private func baseNameByRemovingFinalExtension(from filename: String) -> String { + let nsFilename = filename as NSString + let base = nsFilename.deletingPathExtension + return base.isEmpty ? defaultFilename : base + } + + private var defaultFilename: String { + String(localized: "browser.download.defaultFilename", defaultValue: "download") + } + + private func hasImageExtension(_ filename: String, matching imageType: UTType) -> Bool { + let pathExtension = (filename as NSString).pathExtension + guard !pathExtension.isEmpty, + let extensionType = UTType(filenameExtension: pathExtension), + extensionType.conforms(to: .image) else { + return false + } + + return extensionType.conforms(to: imageType) || imageType.conforms(to: extensionType) + } + + private func preferredFilenameExtension(for imageType: UTType) -> String { + if imageType.conforms(to: .jpeg) { + return "jpg" + } + if let preferred = imageType.preferredFilenameExtension, !preferred.isEmpty { + return preferred + } + return "img" + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 212c56dacc5b..53db122aa0bd 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8808,9 +8808,10 @@ private extension NSObject { /// Handles WKDownload lifecycle by saving to a temp file synchronously (no UI /// during WebKit callbacks), then showing NSSavePanel after the download finishes. class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { - private struct DownloadState { + private struct DownloadState: Sendable { let tempURL: URL let suggestedFilename: String + let sourceURL: URL } /// Tracks active downloads keyed by WKDownload identity. @@ -8826,16 +8827,6 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { 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 @@ -8864,18 +8855,23 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { 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 filenameResolver = BrowserDownloadFilenameResolver() + if case .reject = filenameResolver.httpStatusDecision(for: response) { + completionHandler(nil) + return + } + let sourceURL = response.url ?? URL(fileURLWithPath: suggestedFilename) + let safeFilename = filenameResolver.suggestedFilename(suggestedFilename: suggestedFilename, response: response, sourceURL: sourceURL, imageType: nil) 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) + storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename, sourceURL: sourceURL), for: download) notifyOnMain { [weak self] in self?.onDownloadStarted?(safeFilename) } #if DEBUG cmuxDebugLog("download.decideDestination file=\(safeFilename)") #endif - NSLog("BrowserPanel download: temp path=%@", destURL.path) completionHandler(destURL) } @@ -8889,27 +8885,29 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { #if DEBUG cmuxDebugLog("download.finished file=\(info.suggestedFilename)") #endif - NSLog("BrowserPanel download finished: %@", info.suggestedFilename) - - // Show NSSavePanel on the next runloop iteration (safe context). - DispatchQueue.main.async { + let filenameResolver = BrowserDownloadFilenameResolver() + Task { @MainActor in + let imageType = await Task.detached(priority: .utility) { + filenameResolver.imageType(forDownloadedFileAt: info.tempURL) + }.value self.onDownloadReadyToSave?() + let suggestedFilename = filenameResolver.suggestedFilename(suggestedFilename: info.suggestedFilename, response: nil, sourceURL: info.sourceURL, imageType: imageType) let savePanel = NSSavePanel() - savePanel.nameFieldStringValue = info.suggestedFilename + savePanel.nameFieldStringValue = 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) + if FileManager.default.fileExists(atPath: destURL.path) { + _ = try FileManager.default.replaceItemAt(destURL, withItemAt: info.tempURL) + } else { + try FileManager.default.moveItem(at: info.tempURL, to: destURL) + } } catch { - NSLog("BrowserPanel download move failed: %@", error.localizedDescription) try? FileManager.default.removeItem(at: info.tempURL) } } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 4dc12afa54be..7787e2cfe3c0 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1788,19 +1788,19 @@ final class CmuxWebView: WKWebView { let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1 let mime = response?.mimeType ?? "nil" let expectedLength = response?.expectedContentLength ?? -1 - self.debugContextDownload( - "browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)" - ) - let filenameCandidate = suggestedFilename - ?? response?.suggestedFilename - ?? url.lastPathComponent - let saveName = filenameCandidate.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? "download" : filenameCandidate + self.debugContextDownload("browser.ctxdl.response trace=\(traceID) stage=success hasResponse=1 status=\(statusCode) mime=\(mime) bytes=\(data.count) expected=\(expectedLength)") + let filenameResolver = BrowserDownloadFilenameResolver() + if case .reject = filenameResolver.httpStatusDecision(for: response) { + self.notifyContextMenuDownloadState(false) + self.runContextMenuFallback(action: fallbackAction, target: fallbackTarget, sender: sender, traceID: traceID, reason: "http_status") + return + } + let saveName = filenameResolver.suggestedFilename(suggestedFilename: suggestedFilename, response: response, sourceURL: url, imageData: data) 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) self.debugContextDownload( "browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6f2ae0972afe..8619e681da72 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -77,6 +77,8 @@ BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */; }; BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */; }; E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */; }; + C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; }; + C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; }; A5008373 /* BrowserFindJavaScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindJavaScript.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; 7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */; }; @@ -892,6 +894,8 @@ BCBC0A0E0000000000000C02 /* BrowserChromeMetrics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserChromeMetrics.swift; sourceTree = ""; }; BCBC0A0E0000000000000D02 /* BrowserChromeMetricsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserChromeMetricsTests.swift; sourceTree = ""; }; 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserConfigTests.swift; sourceTree = ""; }; + C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadFilenameResolver.swift; sourceTree = ""; }; + C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDownloadFilenameResolverTests.swift; sourceTree = ""; }; A5008372 /* BrowserFindJavaScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindJavaScript.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFixtureInteractionUITests.swift; sourceTree = ""; }; @@ -1984,6 +1988,7 @@ B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */, B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */, A5001412 /* BrowserPanel.swift */, + C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */, B3770BA00000000000000002 /* BrowserAutomation.swift */, 4472B0014472B0014472B001 /* BrowserScreenshot.swift */, 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */, @@ -2251,6 +2256,7 @@ A50019B3 /* SettingsSearchIndexTests.swift */, D36090010000000000000004 /* SettingsWindowPresenterTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, + C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */, C0DE58990000000000000002 /* CmuxWebViewKeyDownReentryTests.swift */, C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */, 43F90FAF3FD44F11BF547BE9 /* CmuxWebViewMouseNavigationButtonTests.swift */, @@ -2826,6 +2832,7 @@ D0B10002A1B2C3D4E5F60001 /* BonsplitTabBarPassThrough.swift in Sources */, B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */, BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */, + C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */, A5008373 /* BrowserFindJavaScript.swift in Sources */, B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */, B42450010000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift in Sources */, @@ -3316,6 +3323,7 @@ D3622000A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift in Sources */, BCBC0A0E0000000000000D01 /* BrowserChromeMetricsTests.swift in Sources */, E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, + C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, 4E1F28554F1B908F18559390 /* BrowserHistorySuggestionCacheTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift new file mode 100644 index 000000000000..8dfacb388acf --- /dev/null +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -0,0 +1,108 @@ +import Foundation +import Testing +import UniformTypeIdentifiers + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite struct BrowserDownloadFilenameResolverTests { + private let resolver = BrowserDownloadFilenameResolver() + + @Test func rejectsNonSuccessHTTPStatusBeforeSavePanelNaming() throws { + let url = try #require(URL(string: "https://example.test/logo.jpg")) + let response = try #require(HTTPURLResponse( + url: url, + statusCode: 403, + httpVersion: "HTTP/1.1", + headerFields: ["Content-Type": "application/xml"] + )) + + #expect(resolver.httpStatusDecision(for: response) == .reject(statusCode: 403)) + } + + @Test func detectsPNGBytesForImageFilenameDerivation() throws { + let imageType = try #require(resolver.imageType(forImageData: Self.onePixelPNG)) + + #expect(imageType.conforms(to: .png)) + } + + @Test func imageBytesServedAsTextKeepImagePathExtension() throws { + let url = try #require(URL(string: "https://example.test/logo.png")) + let response = URLResponse( + url: url, + mimeType: "text/plain", + expectedContentLength: Self.onePixelPNG.count, + textEncodingName: nil + ) + + let filename = resolver.suggestedFilename( + suggestedFilename: nil, + response: response, + sourceURL: url, + imageType: .png + ) + + #expect(filename == "logo.png") + } + + @Test func imageBytesStripServerMIMEExtensionFromSuggestedFilename() throws { + let url = try #require(URL(string: "https://cdn.example.test/assets/logo")) + let response = URLResponse( + url: url, + mimeType: "text/plain", + expectedContentLength: Self.onePixelPNG.count, + textEncodingName: nil + ) + + let filename = resolver.suggestedFilename( + suggestedFilename: "logo.png.txt", + response: response, + sourceURL: url, + imageType: .png + ) + + #expect(filename == "logo.png") + } + + @Test func imageBytesPreserveExplicitSuggestedFilenameBase() throws { + let url = try #require(URL(string: "https://cdn.example.test/assets/hash.png")) + + let filename = resolver.suggestedFilename( + suggestedFilename: "avatar", + response: nil, + sourceURL: url, + imageType: .png + ) + + #expect(filename == "avatar.png") + } + + @Test func imageBytesReplaceExplicitNonImageSuggestedExtension() throws { + let url = try #require(URL(string: "https://cdn.example.test/assets/hash.png")) + + let filename = resolver.suggestedFilename( + suggestedFilename: "avatar.txt", + response: nil, + sourceURL: url, + imageType: .png + ) + + #expect(filename == "avatar.png") + } + + private static let onePixelPNG = Data([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4, + 0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41, + 0x54, 0x78, 0x9C, 0x63, 0x60, 0x00, 0x00, 0x02, + 0x00, 0x01, 0x00, 0xFF, 0xFF, 0x03, 0x00, 0x00, + 0x06, 0x00, 0x05, 0x57, 0xBF, 0xAB, 0x7D, 0x00, + 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, + 0x42, 0x60, 0x82, + ]) +}