From 05af4afc8d0d4e17d0c27cf4207edb3e857fc1f1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 16:31:20 -0700 Subject: [PATCH 01/59] Add browser subframe download regression test --- .../BrowserDownloadFilenameResolverTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 671bcd3fa611..2de60376e8fa 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import UniformTypeIdentifiers +import WebKit #if canImport(cmux_DEV) @testable import cmux_DEV @@ -38,6 +39,18 @@ import UniformTypeIdentifiers )) } + @MainActor + @Test func scriptedDownloadInterceptionRunsInSubframes() throws { + let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + + let script = try #require( + webView.configuration.userContentController.userScripts.first { + $0.source.contains("cmuxScriptedDownload") + } + ) + #expect(script.isForMainFrameOnly == false) + } + @Test func rejectsNonSuccessHTTPStatusBeforeSavePanelNaming() throws { let url = try #require(URL(string: "https://example.test/logo.jpg")) let response = try #require(HTTPURLResponse( From 05fdea51eba154052cf6c9328100391e2fb73f08 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 16:43:01 -0700 Subject: [PATCH 02/59] Fix browser downloads from subframes --- .../Keys/BrowserCatalogSection.swift | 8 + .../CuratedSettingEntry+Default.swift | 7 + .../Sections/BrowserSection.swift | 17 +- .../SettingsRowAnchorResolutionTests.swift | 1 + Resources/Localizable.xcstrings | 51 ++++ Sources/CmuxSettingsJSONPathSupport.swift | 5 + .../CommandPaletteSettingsToggle.swift | 24 ++ ...rdShortcutSettingsFileStore+Template.swift | 1 + .../BrowserDownloadFilenameResolver.swift | 46 +++- Sources/Panels/BrowserPanel.swift | 149 +++++++++--- .../Panels/BrowserPopupWindowController.swift | 14 +- .../CmuxWebView+ScriptedDownloads.swift | 64 ++++- Sources/Panels/CmuxWebView.swift | 224 +++++++++++------- Sources/SettingsNavigation.swift | 2 + Sources/SettingsSearchAliases.swift | 1 + ...BrowserDownloadFilenameResolverTests.swift | 52 ++++ web/data/cmux.schema.json | 6 + web/messages/en.json | 3 +- web/messages/ja.json | 3 +- 19 files changed, 535 insertions(+), 143 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift index 140f415830e2..353dbfb0e080 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift @@ -44,6 +44,14 @@ public struct BrowserCatalogSection: SettingCatalogSection { userDefaultsKey: "browserHiddenWebViewDiscardDelaySeconds" ) + /// Shows a save panel for each browser download instead of saving directly + /// to the user's Downloads folder. + public let askWhereToSaveDownloads = DefaultsKey( + id: "browser.askWhereToSaveDownloads", + defaultValue: false, + userDefaultsKey: "browserAskWhereToSaveDownloads" + ) + public let openTerminalLinksInCmuxBrowser = DefaultsKey( id: "browser.openTerminalLinksInCmuxBrowser", defaultValue: true, diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 9f97fd0e74b6..dd72a8e3f26b 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -185,6 +185,13 @@ extension Array where Element == CuratedSettingEntry { .init(section: .browser, id: "theme", title: "Browser Theme", synonyms: "browser.theme web page theme color scheme light dark system"), .init(section: .browser, id: "hidden-webview-discard", title: "Browser Memory Saver", synonyms: "browser.discardHiddenWebViews memory hidden tabs webview discard unload reclaim"), .init(section: .browser, id: "hidden-webview-discard-delay", title: "Memory Saver Delay", synonyms: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), + .init( + section: .browser, + id: "ask-where-to-save-downloads", + title: String(localized: "settings.browser.askWhereToSaveDownloads", defaultValue: "Ask Where to Save Downloads"), + detailText: String(localized: "settings.browser.askWhereToSaveDownloads.subtitle", defaultValue: "When off, browser downloads save directly to Downloads without a save panel."), + synonyms: String(localized: "settings.search.alias.setting.browser.ask-where-to-save-downloads", defaultValue: "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail") + ), .init(section: .browser, id: "terminal-links", title: "Open Terminal Links in cmux Browser", synonyms: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), .init(section: .browser, id: "intercept-open", title: "Intercept open http(s) in Terminal", synonyms: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), .init(section: .browser, id: "host-whitelist", title: "Hosts to Open in Embedded Browser", synonyms: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift index 408b81f25c2a..9c38b72bbe78 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -25,6 +25,7 @@ public struct BrowserSection: View { @State private var theme: DefaultsValueModel @State private var discardEnabled: DefaultsValueModel @State private var discardDelay: DefaultsValueModel + @State private var askWhereToSaveDownloads: DefaultsValueModel @State private var openTermLinks: DefaultsValueModel @State private var interceptOpen: DefaultsValueModel @State private var hosts: DefaultsValueModel @@ -55,6 +56,7 @@ public struct BrowserSection: View { _theme = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.theme)) _discardEnabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.discardHiddenWebViews)) _discardDelay = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hiddenWebViewDiscardDelaySeconds)) + _askWhereToSaveDownloads = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.askWhereToSaveDownloads)) _openTermLinks = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.openTerminalLinksInCmuxBrowser)) _interceptOpen = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.interceptTerminalOpenCommandInCmuxBrowser)) _hosts = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.hostsToOpenInEmbeddedBrowser)) @@ -83,7 +85,7 @@ public struct BrowserSection: View { Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} } message: { Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) - }.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } + }.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } } @ViewBuilder @@ -214,6 +216,19 @@ public struct BrowserSection: View { } SettingsCardDivider() + // Download Save Prompt + SettingsCardRow( + configurationReview: .json("browser.askWhereToSaveDownloads"), + String(localized: "settings.browser.askWhereToSaveDownloads", defaultValue: "Ask Where to Save Downloads"), + subtitle: String(localized: "settings.browser.askWhereToSaveDownloads.subtitle", defaultValue: "When off, browser downloads save directly to Downloads without a save panel.") + ) { + Toggle("", isOn: Binding(get: { askWhereToSaveDownloads.current }, set: { askWhereToSaveDownloads.set($0) })) + .labelsHidden() + .controlSize(.small) + .accessibilityIdentifier("SettingsBrowserAskWhereToSaveDownloadsToggle") + } + SettingsCardDivider() + // Open Terminal Links SettingsCardRow( configurationReview: .json("browser.openTerminalLinksInCmuxBrowser"), diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 6cf4f7ccab61..1325e4a631d0 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -69,6 +69,7 @@ struct SettingsRowAnchorResolutionTests { "browser.defaultSearchEngine", "browser.discardHiddenWebViews", "browser.hiddenWebViewDiscardDelaySeconds", + "browser.askWhereToSaveDownloads", "browser.hostsToOpenInEmbeddedBrowser", "browser.interceptTerminalOpenCommandInCmuxBrowser", "browser.openTerminalLinksInCmuxBrowser", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 2b03fa6dd4d1..8f260b006665 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -111111,6 +111111,40 @@ } } }, + "settings.browser.askWhereToSaveDownloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask Where to Save Downloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードごとに保存先を確認" + } + } + } + }, + "settings.browser.askWhereToSaveDownloads.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "When off, browser downloads save directly to Downloads without a save panel." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "オフの場合、ブラウザのダウンロードは保存パネルなしで直接「ダウンロード」に保存されます。" + } + } + } + }, "settings.browser.hiddenWebViewDiscard": { "extractionState": "manual", "localizations": { @@ -128096,6 +128130,23 @@ } } }, + "settings.search.alias.setting.browser.ask-where-to-save-downloads": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail ダウンロード 保存先 保存パネル 添付ファイル PDF" + } + } + } + }, "settings.search.alias.setting.browser.terminal-links": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 85925e7464ef..acc794fdb275 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -281,6 +281,10 @@ enum BrowserSettingsFileMapping { static let booleanSettings: [SettingsFileBooleanMapping] = [ .init(jsonKey: "showSearchSuggestions", defaultsKey: BrowserSearchSettingsStore.searchSuggestionsEnabledKey), .init(jsonKey: "discardHiddenWebViews", defaultsKey: BrowserHiddenWebViewDiscardPolicy.enabledKey), + .init( + jsonKey: "askWhereToSaveDownloads", + defaultsKey: SettingCatalog().browser.askWhereToSaveDownloads.userDefaultsKey + ), .init( jsonKey: "openTerminalLinksInCmuxBrowser", defaultsKey: BrowserLinkOpenSettings.openTerminalLinksInCmuxBrowserKey @@ -418,6 +422,7 @@ extension CmuxSettingsFileStore { "browser.theme", "browser.discardHiddenWebViews", "browser.hiddenWebViewDiscardDelaySeconds", + "browser.askWhereToSaveDownloads", "browser.openTerminalLinksInCmuxBrowser", "browser.interceptTerminalOpenCommandInCmuxBrowser", "browser.hostsToOpenInEmbeddedBrowser", diff --git a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift index 1cd6abf1de4b..f3b739adf34d 100644 --- a/Sources/CommandPalette/CommandPaletteSettingsToggle.swift +++ b/Sources/CommandPalette/CommandPaletteSettingsToggle.swift @@ -826,6 +826,30 @@ enum CommandPaletteSettingsToggleCommands { defaultValue: BrowserSearchSettingsStore.defaultSearchSuggestionsEnabled, defaultsKey: BrowserSearchSettingsStore.searchSuggestionsEnabledKey ), + CommandPaletteSettingToggleDescriptor( + commandId: commandIdPrefix + "askWhereToSaveBrowserDownloads", + settingsKey: "browser.askWhereToSaveDownloads", + title: { + String( + localized: "settings.browser.askWhereToSaveDownloads", + defaultValue: "Ask Where to Save Downloads" + ) + }, + sectionTitle: browser, + keywords: [ + "browser.askWhereToSaveDownloads", + "browser", + "downloads", + "save", + "panel", + "folder", + "attachments", + "files", + "pdf", + ], + defaultValue: SettingCatalog().browser.askWhereToSaveDownloads.defaultValue, + defaultsKey: SettingCatalog().browser.askWhereToSaveDownloads.userDefaultsKey + ), CommandPaletteSettingToggleDescriptor( commandId: commandIdPrefix + "openTerminalLinksInCmuxBrowser", settingsKey: "browser.openTerminalLinksInCmuxBrowser", diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index 8be440770be7..0342c7fcadf6 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -189,6 +189,7 @@ extension CmuxSettingsFileStore { "theme": BrowserThemeSettings.defaultMode.rawValue, "discardHiddenWebViews": BrowserHiddenWebViewDiscardPolicy.defaultEnabled, "hiddenWebViewDiscardDelaySeconds": BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay, + "askWhereToSaveDownloads": SettingCatalog().browser.askWhereToSaveDownloads.defaultValue, "openTerminalLinksInCmuxBrowser": BrowserLinkOpenSettings.defaultOpenTerminalLinksInCmuxBrowser, "interceptTerminalOpenCommandInCmuxBrowser": BrowserLinkOpenSettings.defaultInterceptTerminalOpenCommandInCmuxBrowser, "hostsToOpenInEmbeddedBrowser": [String](), diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index a31ba02cf16b..889a8ce957db 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -1,5 +1,6 @@ import Foundation import ImageIO +import CmuxSettings import UniformTypeIdentifiers nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { @@ -24,7 +25,8 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { func navigationResponseDownloadReason( mimeType: String?, canShowMIMEType: Bool, - contentDisposition: String? + contentDisposition: String?, + isForMainFrame: Bool = true ) -> String? { if shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { return "content-disposition" @@ -32,6 +34,7 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { if shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { return "forceDownloadMIME" } + guard isForMainFrame else { return nil } return canShowMIMEType ? nil : "cannotShowMIME" } @@ -114,6 +117,47 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { ) } + func shouldAskWhereToSaveDownloads(defaults: UserDefaults = .standard) -> Bool { + let setting = SettingCatalog().browser.askWhereToSaveDownloads + if defaults.object(forKey: setting.userDefaultsKey) == nil { + return setting.defaultValue + } + return defaults.bool(forKey: setting.userDefaultsKey) + } + + func downloadsDirectory(fileManager: FileManager = .default) -> URL { + if let directory = fileManager.urls(for: .downloadsDirectory, in: .userDomainMask).first { + return directory + } + return URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent("Downloads", isDirectory: true) + } + + func uniqueDownloadDestination( + suggestedFilename: String, + in directory: URL, + fileManager: FileManager = .default + ) -> URL { + let safeFilename = sanitizedFilename(suggestedFilename, fallbackURL: nil) + let candidate = directory.appendingPathComponent(safeFilename, isDirectory: false) + guard fileManager.fileExists(atPath: candidate.path) else { + return candidate + } + + let nsFilename = safeFilename as NSString + let base = nsFilename.deletingPathExtension.isEmpty ? defaultFilename : nsFilename.deletingPathExtension + let ext = nsFilename.pathExtension + var index = 1 + while true { + let dedupedName = ext.isEmpty ? "\(base) (\(index))" : "\(base) (\(index)).\(ext)" + let url = directory.appendingPathComponent(dedupedName, isDirectory: false) + if !fileManager.fileExists(atPath: url.path) { + return url + } + index += 1 + } + } + private func imageFilename( candidate: String, imageType: UTType diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bf7a0004a951..485f0863b973 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3979,9 +3979,13 @@ final class BrowserPanel: Panel, ObservableObject { self?.replaceWebViewAfterContentProcessTermination(for: webView) } // 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. + // Downloads save to a temp file synchronously (no UI during WebKit + // callbacks), then auto-save to Downloads unless the prompt setting is enabled. let dlDelegate = BrowserDownloadDelegate() + webView.cmuxDownloadDelegate = dlDelegate + dlDelegate.savePanelParentWindow = { [weak self] in + self?.webView.window + } dlDelegate.onDownloadStarted = { [weak self] filename in guard let self else { return } self.beginDownloadActivity() @@ -3998,7 +4002,23 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } - dlDelegate.onDownloadReadyToSave = { [weak self] in + dlDelegate.onDownloadReadyToSave = { [weak self] filename in + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "ready_to_save", + "filename": filename + ] + ] + ) + } + dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL in guard let self else { return } self.endDownloadActivity() NotificationCenter.default.post( @@ -4008,7 +4028,9 @@ final class BrowserPanel: Panel, ObservableObject { "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ - "type": "ready_to_save" + "type": "saved", + "filename": filename, + "path": destinationURL.path ] ] ) @@ -8249,7 +8271,8 @@ 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. +/// during WebKit callbacks), then moving the finished file to the user's +/// Downloads folder unless the browser save-panel setting is enabled. class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private struct DownloadState: Sendable { let tempURL: URL @@ -8261,8 +8284,10 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] private let activeDownloadsLock = NSLock() var onDownloadStarted: ((String) -> Void)? - var onDownloadReadyToSave: (() -> Void)? + var onDownloadReadyToSave: ((String) -> Void)? + var onDownloadSaved: ((String, URL) -> Void)? var onDownloadFailed: ((Error) -> Void)? + var savePanelParentWindow: (() -> NSWindow?)? private static let tempDir: URL = { let dir = FileManager.default.temporaryDirectory.appendingPathComponent("cmux-downloads", isDirectory: true) @@ -8291,6 +8316,59 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } } + private static func moveTemporaryDownloadToDownloads( + tempURL: URL, + suggestedFilename: String, + filenameResolver: BrowserDownloadFilenameResolver, + fileManager: FileManager = .default + ) throws -> URL { + let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let destinationURL = filenameResolver.uniqueDownloadDestination( + suggestedFilename: suggestedFilename, + in: directory, + fileManager: fileManager + ) + try fileManager.moveItem(at: tempURL, to: destinationURL) + return destinationURL + } + + @MainActor + private func presentSavePanel( + tempURL: URL, + suggestedFilename: String, + filenameResolver: BrowserDownloadFilenameResolver + ) { + onDownloadReadyToSave?(suggestedFilename) + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = suggestedFilename + savePanel.canCreateDirectories = true + savePanel.directoryURL = filenameResolver.downloadsDirectory() + let completion: (NSApplication.ModalResponse) -> Void = { [weak self] result in + guard let self else { return } + guard result == .OK, let destURL = savePanel.url else { + try? FileManager.default.removeItem(at: tempURL) + return + } + do { + if FileManager.default.fileExists(atPath: destURL.path) { + _ = try FileManager.default.replaceItemAt(destURL, withItemAt: tempURL) + } else { + try FileManager.default.moveItem(at: tempURL, to: destURL) + } + self.onDownloadSaved?(suggestedFilename, destURL) + } catch { + try? FileManager.default.removeItem(at: tempURL) + self.onDownloadFailed?(error) + } + } + if let parentWindow = savePanelParentWindow?() { + savePanel.beginSheetModal(for: parentWindow, completionHandler: completion) + } else { + savePanel.begin(completionHandler: completion) + } + } + func download( _ download: WKDownload, decideDestinationUsing response: URLResponse, @@ -8333,26 +8411,35 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { 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 = 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 { - 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 { - try? FileManager.default.removeItem(at: info.tempURL) + + if filenameResolver.shouldAskWhereToSaveDownloads() { + self.presentSavePanel( + tempURL: info.tempURL, + suggestedFilename: suggestedFilename, + filenameResolver: filenameResolver + ) + return + } + + let saveResult = await Task.detached(priority: .utility) { + Result { + try Self.moveTemporaryDownloadToDownloads( + tempURL: info.tempURL, + suggestedFilename: suggestedFilename, + filenameResolver: filenameResolver + ) } + }.value + switch saveResult { + case .success(let destinationURL): + self.onDownloadSaved?(suggestedFilename, destinationURL) + #if DEBUG + cmuxDebugLog("download.saved path=\(destinationURL.path)") + #endif + case .failure(let error): + try? FileManager.default.removeItem(at: info.tempURL) + self.onDownloadFailed?(error) } } } @@ -8882,16 +8969,13 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { 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. + // Only classify HTTP(S) responses as downloads. Subframes are eligible + // only for explicit attachment/force-download MIME decisions; the + // resolver keeps cannot-show MIME fallback scoped to main-frame loads. if let scheme = navigationResponse.response.url?.scheme?.lowercased(), scheme != "http", scheme != "https" { decisionHandler(.allow) @@ -8907,11 +8991,12 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, - contentDisposition: contentDisposition + contentDisposition: contentDisposition, + isForMainFrame: navigationResponse.isForMainFrame ) { NSLog("BrowserPanel download: %@ mime=%@ url=%@", reason, mime, responseURL) #if DEBUG - cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime)") + cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif decisionHandler(.download) return diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index b673646b0c72..b71a566d7b74 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -217,6 +217,10 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { uiDel.controller = self navDel.controller = self navDel.downloadDelegate = dlDel + dlDel.savePanelParentWindow = { [weak panel] in + panel + } + webView.cmuxDownloadDelegate = dlDel webView.uiDelegate = uiDel webView.navigationDelegate = navDel webAuthnCoordinator.install(on: webView) @@ -653,11 +657,6 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { decidePolicyFor navigationResponse: WKNavigationResponse, decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void ) { - if !navigationResponse.isForMainFrame { - decisionHandler(.allow) - return - } - if let scheme = navigationResponse.response.url?.scheme?.lowercased(), scheme != "http", scheme != "https" { decisionHandler(.allow) @@ -666,7 +665,10 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( - mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, contentDisposition: contentDisposition + mimeType: navigationResponse.response.mimeType, + canShowMIMEType: navigationResponse.canShowMIMEType, + contentDisposition: contentDisposition, + isForMainFrame: navigationResponse.isForMainFrame ) != nil { decisionHandler(.download) return diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index e48a77e8e3fc..2a67873cf204 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -84,11 +84,14 @@ extension CmuxWebView { } catch (_) {} }; - const readBlobForDownload = (blob, suggestedFilename) => { + const readBlobForDownload = (blob, suggestedFilename, fallbackURL) => { try { if (!blob) return false; if (blobDownloadInFlight) return false; - if (typeof blob.size === "number" && blob.size > maxPayloadBytes) return false; + if (typeof blob.size === "number" && blob.size > maxPayloadBytes) { + postURLDownload(fallbackURL, suggestedFilename); + return true; + } blobDownloadInFlight = true; const filename = String(suggestedFilename || blob.name || ""); const reader = new FileReader(); @@ -97,12 +100,22 @@ extension CmuxWebView { }; reader.onload = () => { if (typeof reader.result === "string" && reader.result.length > 0) { - postDataURLDownload(reader.result, filename); + if (reader.result.length > maxDataURLCharacters) { + postURLDownload(fallbackURL, filename); + } else { + postDataURLDownload(reader.result, filename); + } } finish(); }; - reader.onerror = finish; - reader.onabort = finish; + reader.onerror = () => { + postURLDownload(fallbackURL, filename); + finish(); + }; + reader.onabort = () => { + postURLDownload(fallbackURL, filename); + finish(); + }; reader.readAsDataURL(blob); return true; } catch (_) { @@ -115,13 +128,12 @@ extension CmuxWebView { try { const storedBlob = objectURLs.get(String(url)); if (storedBlob) { - if (typeof storedBlob.size === "number" && storedBlob.size > maxPayloadBytes) return false; - return readBlobForDownload(storedBlob, suggestedFilename); + return readBlobForDownload(storedBlob, suggestedFilename, url); } fetch(url) .then((response) => response.blob()) - .then((blob) => readBlobForDownload(blob, suggestedFilename)) - .catch(() => {}); + .then((blob) => readBlobForDownload(blob, suggestedFilename, url)) + .catch(() => postURLDownload(url, suggestedFilename)); return true; } catch (_) {} return false; @@ -197,6 +209,11 @@ extension CmuxWebView { postURLDownload(href, suggestedFilename); return true; } + if (scheme === "http" || scheme === "https" || scheme === "file") { + if (!reserveDownloadPost()) return false; + postURLDownload(href, suggestedFilename); + return true; + } } catch (_) {} return false; }; @@ -240,7 +257,7 @@ extension CmuxWebView { WKUserScript( source: Self.scriptedDownloadInterceptionBootstrapScriptSource(token: token), injectionTime: .atDocumentStart, - forMainFrameOnly: true + forMainFrameOnly: false ) ) userContentController.add( @@ -318,6 +335,10 @@ extension CmuxWebView { } let traceID = Self.makeContextDownloadTraceID(prefix: "scriptdl") debugContextDownload("browser.scriptdl.start trace=\(traceID) scheme=\(url.scheme ?? "nil")") + if url.scheme?.caseInsensitiveCompare("blob") == .orderedSame { + startScriptedWebKitDownload(url, traceID: traceID) + return + } downloadURLViaSession( url, suggestedFilename: suggestedFilename, @@ -330,7 +351,28 @@ extension CmuxWebView { private static func isScriptedDownloadSupportedURL(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" - return scheme == "data" + return scheme == "data" || scheme == "http" || scheme == "https" || scheme == "file" || scheme == "blob" + } + + private func startScriptedWebKitDownload(_ url: URL, traceID: String) { + guard let downloadDelegate = cmuxDownloadDelegate else { +#if DEBUG + debugContextDownload("browser.scriptdl.webkit trace=\(traceID) stage=rejectMissingDelegate") +#endif + return + } + if #available(macOS 11.3, *) { + startDownload(using: URLRequest(url: url)) { download in +#if DEBUG + self.debugContextDownload("browser.scriptdl.webkit trace=\(traceID) stage=didStart") +#endif + download.delegate = downloadDelegate + } + } else { +#if DEBUG + debugContextDownload("browser.scriptdl.webkit trace=\(traceID) stage=rejectUnavailable") +#endif + } } static func cookiesForDownloadRequest(_ cookies: [HTTPCookie], url: URL) -> [HTTPCookie] { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index ddb63e550820..601c5e2ae8ea 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -377,6 +377,7 @@ final class CmuxWebView: WKWebView { } private static var contextMenuFallbackKey: UInt8 = 0 + private static var cmuxDownloadDelegateKey: UInt8 = 0 private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) var onContextMenuDownloadStateChanged: ((Bool) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. @@ -389,6 +390,19 @@ final class CmuxWebView: WKWebView { var contextMenuLinkURLProvider: ((CmuxWebView, NSPoint, @escaping (URL?) -> Void) -> Void)? var contextMenuDefaultBrowserOpener: ((URL) -> Bool)? var contextMenuCanMoveTabToNewWorkspace: (() -> Bool)?; var contextMenuMoveTabToNewWorkspace: (() -> Bool)? + var cmuxDownloadDelegate: WKDownloadDelegate? { + get { + objc_getAssociatedObject(self, &Self.cmuxDownloadDelegateKey) as? WKDownloadDelegate + } + set { + objc_setAssociatedObject( + self, + &Self.cmuxDownloadDelegateKey, + newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } /// Guard against background panes stealing first responder (e.g. page autofocus). /// BrowserPanelView updates this as pane focus state changes. var allowsFirstResponderAcquisition: Bool = true @@ -1605,6 +1619,99 @@ final class CmuxWebView: WKWebView { } } + private func finishSessionDownload( + data: Data, + saveName: String, + traceID: String, + logCategory: String, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject?, + failureFallbackReason: String? + ) { + let filenameResolver = BrowserDownloadFilenameResolver() + let writeData: (URL) -> Bool = { destinationURL in + do { + try data.write(to: destinationURL, options: .atomic) + self.debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=\(destinationURL.path)" + ) + return true + } catch { + self.debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + if let failureFallbackReason { + self.runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: failureFallbackReason + ) + } + return false + } + } + + if filenameResolver.shouldAskWhereToSaveDownloads() { + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = filenameResolver.downloadsDirectory() + notifyContextMenuDownloadState(false) + debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + ) + let completion: (NSApplication.ModalResponse) -> Void = { result in + guard result == .OK, let destURL = savePanel.url else { + self.debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel" + ) + return + } + _ = writeData(destURL) + } + if let parentWindow = window { + savePanel.beginSheetModal(for: parentWindow, completionHandler: completion) + } else { + savePanel.begin(completionHandler: completion) + } + return + } + + do { + let directory = filenameResolver.downloadsDirectory() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let destinationURL = filenameResolver.uniqueDownloadDestination( + suggestedFilename: saveName, + in: directory + ) + debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=\(destinationURL.path)" + ) + let didWrite = writeData(destinationURL) + notifyContextMenuDownloadState(false) + if !didWrite, failureFallbackReason == nil { + return + } + } catch { + notifyContextMenuDownloadState(false) + debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" + ) + if let failureFallbackReason { + runContextMenuFallback( + action: fallbackAction, + target: fallbackTarget, + sender: sender, + traceID: traceID, + reason: failureFallbackReason + ) + } + } + } + func downloadURLViaSession( _ url: URL, suggestedFilename: String?, @@ -1658,39 +1765,16 @@ final class CmuxWebView: WKWebView { "browser.ctxdl.data trace=\(traceID) stage=parseSuccess mime=\(parsed.mimeType ?? "nil") bytes=\(parsed.data.count)" ) - let savePanel = NSSavePanel() - savePanel.nameFieldStringValue = saveName - savePanel.canCreateDirectories = true - savePanel.directoryURL = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first - self.notifyContextMenuDownloadState(false) - self.debugContextDownload( - "browser.ctxdl.data trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + self.finishSessionDownload( + data: parsed.data, + saveName: saveName, + traceID: traceID, + logCategory: "data", + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget, + failureFallbackReason: "data_save_write_error" ) - savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { - self.debugContextDownload( - "browser.ctxdl.data trace=\(traceID) stage=savePrompt result=cancel" - ) - return - } - do { - try parsed.data.write(to: destURL, options: .atomic) - self.debugContextDownload( - "browser.ctxdl.data trace=\(traceID) stage=saveSuccess path=\(destURL.path)" - ) - } catch { - self.debugContextDownload( - "browser.ctxdl.data trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" - ) - self.runContextMenuFallback( - action: fallbackAction, - target: fallbackTarget, - sender: sender, - traceID: traceID, - reason: "data_save_write_error" - ) - } - } } return } @@ -1704,33 +1788,16 @@ final class CmuxWebView: WKWebView { ) 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) - self.debugContextDownload( - "browser.ctxdl.file trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + self.finishSessionDownload( + data: data, + saveName: saveName, + traceID: traceID, + logCategory: "file", + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget, + failureFallbackReason: nil ) - savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { - self.debugContextDownload( - "browser.ctxdl.file trace=\(traceID) stage=savePrompt result=cancel" - ) - return - } - do { - try data.write(to: destURL, options: .atomic) - self.debugContextDownload( - "browser.ctxdl.file trace=\(traceID) stage=saveSuccess path=\(destURL.path)" - ) - } catch { - self.debugContextDownload( - "browser.ctxdl.file trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" - ) - } - } } catch { self.notifyContextMenuDownloadState(false) self.debugContextDownload( @@ -1797,39 +1864,16 @@ final class CmuxWebView: WKWebView { } 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 - self.notifyContextMenuDownloadState(false) - self.debugContextDownload( - "browser.ctxdl.response trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + self.finishSessionDownload( + data: data, + saveName: saveName, + traceID: traceID, + logCategory: "response", + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget, + failureFallbackReason: "save_write_error" ) - savePanel.begin { result in - guard result == .OK, let destURL = savePanel.url else { - self.debugContextDownload( - "browser.ctxdl.response trace=\(traceID) stage=savePrompt result=cancel" - ) - return - } - do { - try data.write(to: destURL, options: .atomic) - self.debugContextDownload( - "browser.ctxdl.response trace=\(traceID) stage=saveSuccess path=\(destURL.path)" - ) - } catch { - self.debugContextDownload( - "browser.ctxdl.response trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" - ) - self.runContextMenuFallback( - action: fallbackAction, - target: fallbackTarget, - sender: sender, - traceID: traceID, - reason: "save_write_error" - ) - } - } } }.resume() } diff --git a/Sources/SettingsNavigation.swift b/Sources/SettingsNavigation.swift index eb7bb9f208e5..56eb320f287b 100644 --- a/Sources/SettingsNavigation.swift +++ b/Sources/SettingsNavigation.swift @@ -430,6 +430,7 @@ enum SettingsSearchIndex { setting(.browser, "theme", String(localized: "settings.browser.theme", defaultValue: "Browser Theme"), "web appearance light dark system"), setting(.browser, "hidden-webview-discard", String(localized: "settings.browser.hiddenWebViewDiscard", defaultValue: "Discard Hidden Browser WebViews"), "memory hidden tabs webview discard unload"), setting(.browser, "hidden-webview-discard-delay", String(localized: "settings.browser.hiddenWebViewDiscardDelay", defaultValue: "Hidden WebView Discard Delay"), "memory hidden tabs delay seconds discard"), + setting(.browser, "ask-where-to-save-downloads", String(localized: "settings.browser.askWhereToSaveDownloads", defaultValue: "Ask Where to Save Downloads"), "downloads save panel download folder attachments files pdf gmail"), setting(.browser, "terminal-links", String(localized: "settings.browser.openTerminalLinks", defaultValue: "Open Terminal Links in cmux Browser"), "click links browser"), setting(.browser, "intercept-open", String(localized: "settings.browser.interceptOpen", defaultValue: "Intercept open http(s) in Terminal"), "open command urls"), setting(.browser, "host-whitelist", String(localized: "settings.browser.hostWhitelist", defaultValue: "Hosts to Open in Embedded Browser"), "hosts wildcard terminal links"), @@ -561,6 +562,7 @@ enum SettingsSearchIndex { "browser.theme": settingID(for: .browser, idSuffix: "theme"), "browser.discardHiddenWebViews": settingID(for: .browser, idSuffix: "hidden-webview-discard"), "browser.hiddenWebViewDiscardDelaySeconds": settingID(for: .browser, idSuffix: "hidden-webview-discard-delay"), + "browser.askWhereToSaveDownloads": settingID(for: .browser, idSuffix: "ask-where-to-save-downloads"), "browser.openTerminalLinksInCmuxBrowser": settingID(for: .browser, idSuffix: "terminal-links"), "browser.interceptTerminalOpenCommandInCmuxBrowser": settingID(for: .browser, idSuffix: "intercept-open"), "browser.hostsToOpenInEmbeddedBrowser": settingID(for: .browser, idSuffix: "host-whitelist"), diff --git a/Sources/SettingsSearchAliases.swift b/Sources/SettingsSearchAliases.swift index c449619730a3..c8e8b066d52b 100644 --- a/Sources/SettingsSearchAliases.swift +++ b/Sources/SettingsSearchAliases.swift @@ -144,6 +144,7 @@ enum SettingsSearchAliasIndex { "browser:theme": localized("settings.search.alias.setting.browser.theme", defaultValue: "browser.theme web page theme color scheme light dark system"), "browser:hidden-webview-discard": localized("settings.search.alias.setting.browser.hidden-webview-discard", defaultValue: "browser.discardHiddenWebViews memory hidden tabs webview discard unload reclaim"), "browser:hidden-webview-discard-delay": localized("settings.search.alias.setting.browser.hidden-webview-discard-delay", defaultValue: "browser.hiddenWebViewDiscardDelaySeconds memory hidden tabs delay seconds discard unload"), + "browser:ask-where-to-save-downloads": localized("settings.search.alias.setting.browser.ask-where-to-save-downloads", defaultValue: "browser.askWhereToSaveDownloads downloads save panel folder attachments files pdf gmail"), "browser:terminal-links": localized("settings.search.alias.setting.browser.terminal-links", defaultValue: "browser.openTerminalLinksInCmuxBrowser click url terminal links open in browser href"), "browser:intercept-open": localized("settings.search.alias.setting.browser.intercept-open", defaultValue: "browser.interceptTerminalOpenCommandInCmuxBrowser open command http https url terminal intercept"), "browser:host-whitelist": localized("settings.search.alias.setting.browser.host-whitelist", defaultValue: "browser.hostsToOpenInEmbeddedBrowser allowlist whitelist host wildcard domain embedded browser"), diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 2de60376e8fa..ec34ca1c703a 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -39,6 +39,58 @@ import WebKit )) } + @Test func navigationResponseClassifiesExplicitSubframeDownloads() { + #expect(resolver.navigationResponseDownloadReason( + mimeType: "text/html", + canShowMIMEType: true, + contentDisposition: "attachment; filename=index.html", + isForMainFrame: false + ) == "content-disposition") + #expect(resolver.navigationResponseDownloadReason( + mimeType: "text/csv", + canShowMIMEType: true, + contentDisposition: nil, + isForMainFrame: false + ) == "forceDownloadMIME") + } + + @Test func navigationResponseKeepsUnshowableSubframesInlineWithoutExplicitDownloadSignal() { + #expect(resolver.navigationResponseDownloadReason( + mimeType: "application/x-custom", + canShowMIMEType: false, + contentDisposition: nil, + isForMainFrame: false + ) == nil) + #expect(resolver.navigationResponseDownloadReason( + mimeType: "application/x-custom", + canShowMIMEType: false, + contentDisposition: nil, + isForMainFrame: true + ) == "cannotShowMIME") + } + + @Test func uniqueDownloadDestinationDedupesExistingFiles() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-download-resolver-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: directory) } + + let existing = directory.appendingPathComponent("report.pdf", isDirectory: false) + try Data("existing".utf8).write(to: existing) + + let destination = resolver.uniqueDownloadDestination( + suggestedFilename: "report.pdf", + in: directory, + fileManager: fileManager + ) + + #expect(destination.deletingLastPathComponent().path == directory.path) + #expect(destination.lastPathComponent == "report (1).pdf") + } + @MainActor @Test func scriptedDownloadInterceptionRunsInSubframes() throws { let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 4e640706037b..5d948fed9c30 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1039,6 +1039,12 @@ "default": 300, "description": "Seconds a browser tab must stay hidden before cmux frees its page memory." }, + "askWhereToSaveDownloads": { + "type": "boolean", + "default": false, + "descriptionKey": "schemaDescriptions.browser.askWhereToSaveDownloads", + "description": "Show a save panel for browser downloads instead of saving directly to Downloads." + }, "openTerminalLinksInCmuxBrowser": { "type": "boolean", "default": true, diff --git a/web/messages/en.json b/web/messages/en.json index b540d9b966f3..be350cf47fee 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1008,7 +1008,8 @@ "browser": { "defaultSearchEngine": "Default search engine for non-URL browser address bar queries. Use custom with customSearchEngineURLTemplate for arbitrary providers.", "customSearchEngineName": "Display name used when defaultSearchEngine is custom.", - "customSearchEngineURLTemplate": "Search URL used when defaultSearchEngine is custom. Include the query placeholder or %s for the encoded query. If omitted, cmux appends q= to the URL." + "customSearchEngineURLTemplate": "Search URL used when defaultSearchEngine is custom. Include the query placeholder or %s for the encoded query. If omitted, cmux appends q= to the URL.", + "askWhereToSaveDownloads": "Show a save panel for browser downloads instead of saving directly to Downloads." }, "workspaceGroups": { "newWorkspacePlacement": "Global default for where Cmd-N inside a group, the group header + button, and configured group actions place the new workspace: `afterCurrent` (after the active in-group workspace, falling back to top), `top` (second slot, just after the anchor), or `end` (after the last member).", diff --git a/web/messages/ja.json b/web/messages/ja.json index 26459ce28a53..04f68e98f90c 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -967,7 +967,8 @@ "browser": { "defaultSearchEngine": "ブラウザのアドレスバーでURLではない入力に使う既定の検索エンジンです。任意のプロバイダには custom と customSearchEngineURLTemplate を使います。", "customSearchEngineName": "defaultSearchEngine が custom のときに使う表示名です。", - "customSearchEngineURLTemplate": "defaultSearchEngine が custom のときに使う検索URLです。エンコード済み検索語にはクエリ用プレースホルダーまたは %s を含めます。省略すると cmux が URL に q= を追加します。" + "customSearchEngineURLTemplate": "defaultSearchEngine が custom のときに使う検索URLです。エンコード済み検索語にはクエリ用プレースホルダーまたは %s を含めます。省略すると cmux が URL に q= を追加します。", + "askWhereToSaveDownloads": "ブラウザのダウンロードを直接「ダウンロード」に保存せず、保存パネルを表示します。" }, "workspaceGroups": { "newWorkspacePlacement": "グループ内で Cmd-N、グループヘッダーの + ボタン、設定済みグループアクションが新しいワークスペースを配置する場所のグローバル既定値です: `afterCurrent` (アクティブなグループ内ワークスペースの直後。ない場合は top)、`top` (アンカー直後の2番目の位置)、`end` (最後のメンバーの後)。", From 8e36fd9991f257c5136ff27795516b50365ad06f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 16:46:42 -0700 Subject: [PATCH 03/59] Refresh Swift file length budget --- .github/swift-file-length-budget.tsv | 31 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d1774893423e..4f6aa9a35896 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -2,15 +2,15 @@ # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. 34590 CLI/cmux.swift -17841 Sources/AppDelegate.swift -16132 Sources/ContentView.swift +17827 Sources/AppDelegate.swift +16128 Sources/ContentView.swift 13832 Sources/TerminalController.swift -12894 Sources/Workspace.swift +12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11929 Sources/Panels/BrowserPanel.swift +12005 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift -8017 CLI/cmux_open.swift +8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -23,7 +23,7 @@ 5573 cmuxTests/BrowserConfigTests.swift 4487 Sources/Panels/FilePreviewPanel.swift 4478 Sources/cmuxApp.swift -4401 cmuxTests/BrowserPanelTests.swift +4367 cmuxTests/BrowserPanelTests.swift 4187 Sources/BrowserWindowPortal.swift 3934 Sources/Feed/FeedPanelView.swift 3926 cmuxTests/TabManagerUnitTests.swift @@ -35,8 +35,8 @@ 3058 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift -2608 Sources/KeyboardShortcutSettings.swift -2565 Sources/Panels/CmuxWebView.swift +2609 Sources/Panels/CmuxWebView.swift +2606 Sources/KeyboardShortcutSettings.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2524 cmuxTests/CommandPaletteSearchEngineTests.swift 2395 Sources/Mobile/MobileHostService.swift @@ -92,16 +92,16 @@ 951 Sources/App/TerminalDirectoryOpenSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift +944 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 937 Sources/TextBoxMentionIndexStore.swift 934 Sources/App/ShortcutRoutingSupport.swift 928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift 926 Sources/DockPanelView.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 918 cmuxTests/WorkspaceGroupTests.swift 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift 885 Sources/Panels/TerminalPanel.swift -881 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +878 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 877 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift 871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift @@ -132,8 +132,8 @@ 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift +707 Sources/Panels/BrowserPopupWindowController.swift 706 CLI/CMUXCLI+Config.swift -705 Sources/Panels/BrowserPopupWindowController.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift 696 cmuxTests/UpdatePillReleaseVisibilityTests.swift @@ -159,13 +159,13 @@ 636 Sources/TerminalController+ControlPaneContext.swift 632 cmuxTests/AgentSessionAutoResumeSwiftTests.swift 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift -629 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift +627 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 621 cmuxUITests/RightSidebarChromeHeightUITests.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift 612 cmuxUITests/FeedSidebarUITests.swift 608 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift -606 Sources/SettingsNavigation.swift +608 Sources/SettingsNavigation.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift 601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 599 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -184,13 +184,13 @@ 577 cmuxTests/AppearanceSettingsTests.swift 572 Sources/Feed/FeedTextEditorDebugWindowController.swift 568 Packages/Shared/CMUXMobileCore/Sources/CMUXMobileCore/MobileTerminalRenderGrid.swift +567 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 567 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/ConfigDiscovery/GhosttyConfigDiscovery.swift 562 Packages/iOS/CmuxAgentChatUI/Sources/CmuxAgentChatUI/Transcript/ChatTranscriptTableView.swift 562 cmuxTests/AgentExecutableResolverTests.swift 561 cmuxTests/GhosttyConfigPathResolverTests.swift 560 cmuxTests/CLISSHPTYResizeInputTests.swift 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift -552 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift @@ -214,7 +214,6 @@ 520 cmuxTests/MainWindowVisibilityControllerTests.swift 519 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-two-column-cockpit-sidebar.swift 519 Sources/CmuxConfigExecutor.swift -518 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift 518 Packages/macOS/CmuxSwiftRender/Tests/CmuxSwiftRenderTests/Corpus/stress-git-review-queue-command-deck.swift 516 Sources/TerminalImageTransfer.swift 514 Packages/macOS/CmuxSwiftRender/Sources/CmuxSwiftRender/ExpressionEvaluator.swift @@ -222,11 +221,11 @@ 509 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerAdditionalPolicies.swift 507 Sources/TerminalControllerTopSupport.swift 506 Sources/App/MainWindowVisibilityController.swift +505 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/CMUXMobileRootView.swift 505 cmuxUITests/DisplayResolutionRegressionUITests.swift 504 cmuxTests/TerminalNotificationSocketActionTests.swift 503 Sources/Settings/ConfigSource.swift 502 Sources/CmuxEventPublishing.swift -502 Sources/KeyboardShortcutContext.swift 502 Sources/RemoteTmuxSessionMirror.swift 501 Packages/macOS/CmuxControlSocket/Tests/CmuxControlSocketTests/ControlCommandContextTestStubs.swift 500 Sources/KeyboardShortcutRecorder.swift From 2ae496e0bba8cc58d55324732a1a563d3af00a9b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 16:54:07 -0700 Subject: [PATCH 04/59] Address browser download review findings --- Sources/Panels/BrowserPanel.swift | 21 +++++++++++++++++-- .../CmuxWebView+ScriptedDownloads.swift | 7 +------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 485f0863b973..9f277a1b94c0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3741,6 +3741,7 @@ final class BrowserPanel: Panel, ObservableObject { webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } + webView.cmuxDownloadDelegate = downloadDelegate configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks() webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate @@ -3982,7 +3983,6 @@ final class BrowserPanel: Panel, ObservableObject { // Downloads save to a temp file synchronously (no UI during WebKit // callbacks), then auto-save to Downloads unless the prompt setting is enabled. let dlDelegate = BrowserDownloadDelegate() - webView.cmuxDownloadDelegate = dlDelegate dlDelegate.savePanelParentWindow = { [weak self] in self?.webView.window } @@ -4004,7 +4004,6 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadReadyToSave = { [weak self] filename in guard let self else { return } - self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4035,6 +4034,22 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } + dlDelegate.onDownloadCancelled = { [weak self] filename in + guard let self else { return } + self.endDownloadActivity() + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "cancelled", + "filename": filename + ] + ] + ) + } dlDelegate.onDownloadFailed = { [weak self] error in guard let self else { return } self.endDownloadActivity() @@ -8286,6 +8301,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { var onDownloadStarted: ((String) -> Void)? var onDownloadReadyToSave: ((String) -> Void)? var onDownloadSaved: ((String, URL) -> Void)? + var onDownloadCancelled: ((String) -> Void)? var onDownloadFailed: ((Error) -> Void)? var savePanelParentWindow: (() -> NSWindow?)? @@ -8348,6 +8364,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { guard let self else { return } guard result == .OK, let destURL = savePanel.url else { try? FileManager.default.removeItem(at: tempURL) + self.onDownloadCancelled?(suggestedFilename) return } do { diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 2a67873cf204..9ce496fb53b4 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -209,11 +209,6 @@ extension CmuxWebView { postURLDownload(href, suggestedFilename); return true; } - if (scheme === "http" || scheme === "https" || scheme === "file") { - if (!reserveDownloadPost()) return false; - postURLDownload(href, suggestedFilename); - return true; - } } catch (_) {} return false; }; @@ -351,7 +346,7 @@ extension CmuxWebView { private static func isScriptedDownloadSupportedURL(_ url: URL) -> Bool { let scheme = url.scheme?.lowercased() ?? "" - return scheme == "data" || scheme == "http" || scheme == "https" || scheme == "file" || scheme == "blob" + return scheme == "data" || scheme == "blob" } private func startScriptedWebKitDownload(_ url: URL, traceID: String) { From 53a7b184d72519be3f3326b3810e25ef4c9ef30f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:02:03 -0700 Subject: [PATCH 05/59] Harden browser download completion paths --- .github/swift-file-length-budget.tsv | 4 +- .../BrowserDownloadFilenameResolver.swift | 10 ++- Sources/Panels/BrowserPanel.swift | 79 +++++++++++++++++-- .../Panels/BrowserPopupWindowController.swift | 52 +++++++++++- ...BrowserDownloadFilenameResolverTests.swift | 21 ++++- 5 files changed, 150 insertions(+), 16 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 4f6aa9a35896..63356bb04dfe 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -12005 Sources/Panels/BrowserPanel.swift +12085 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -121,6 +121,7 @@ 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 759 Sources/App/MenuBarExtraController.swift +757 Sources/Panels/BrowserPopupWindowController.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 755 CLI/CMUXCLI+AgentHookDefinitions.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift @@ -132,7 +133,6 @@ 716 Sources/TaskManagerSnapshot.swift 715 Sources/AppleScriptSupport.swift 710 Sources/TerminalSSHSessionDetector.swift -707 Sources/Panels/BrowserPopupWindowController.swift 706 CLI/CMUXCLI+Config.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 889a8ce957db..600e7ab58a4f 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -26,12 +26,16 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { mimeType: String?, canShowMIMEType: Bool, contentDisposition: String?, - isForMainFrame: Bool = true + isForMainFrame: Bool = true, + allowsSubframeDownload: Bool = false ) -> String? { - if shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { + let canUseExplicitDownloadSignals = isForMainFrame || allowsSubframeDownload + if canUseExplicitDownloadSignals, + shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { return "content-disposition" } - if shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { + if canUseExplicitDownloadSignals, + shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { return "forceDownloadMIME" } guard isForMainFrame else { return nil } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 9f277a1b94c0..87063df8fc1d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8289,6 +8289,8 @@ private extension NSObject { /// during WebKit callbacks), then moving the finished file to the user's /// Downloads folder unless the browser save-panel setting is enabled. class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { + private static let maxDownloadDestinationCollisionRetries = 100 + private struct DownloadState: Sendable { let tempURL: URL let suggestedFilename: String @@ -8340,13 +8342,24 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { ) throws -> URL { let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - let destinationURL = filenameResolver.uniqueDownloadDestination( - suggestedFilename: suggestedFilename, - in: directory, - fileManager: fileManager - ) - try fileManager.moveItem(at: tempURL, to: destinationURL) - return destinationURL + var lastCollisionError: Error? + for _ in 0.. Void)? var didCommit: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? @@ -8881,6 +8897,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) + recordSubframeDownloadIntentIfNeeded(navigationAction) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -9005,11 +9022,14 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Content-Disposition") + let allowsSubframeDownload = navigationResponse.isForMainFrame + || consumeRecentSubframeDownloadIntent(for: navigationResponse.response.url) if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, contentDisposition: contentDisposition, - isForMainFrame: navigationResponse.isForMainFrame + isForMainFrame: navigationResponse.isForMainFrame, + allowsSubframeDownload: allowsSubframeDownload ) { NSLog("BrowserPanel download: %@ mime=%@ url=%@", reason, mime, responseURL) #if DEBUG @@ -9022,6 +9042,49 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.allow) } + private func recordSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { + guard navigationAction.targetFrame?.isMainFrame == false, + let url = navigationAction.request.url, + Self.isHTTPDownloadIntentURL(url) else { return } + guard navigationAction.navigationType == .linkActivated + || browserNavigationHasSimpleUserActivation() else { return } + let now = ProcessInfo.processInfo.systemUptime + pruneSubframeDownloadIntents(now: now) + recentSubframeDownloadIntentKeys.append((Self.downloadIntentKey(for: url), now)) + } + + private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { + guard let responseURL, + Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime + pruneSubframeDownloadIntents(now: now) + let key = Self.downloadIntentKey(for: responseURL) + guard let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) else { + return false + } + recentSubframeDownloadIntentKeys.remove(at: index) + return true + } + + private func pruneSubframeDownloadIntents(now: TimeInterval) { + recentSubframeDownloadIntentKeys.removeAll { + now - $0.recordedAt > Self.subframeDownloadIntentLifetime + } + } + + private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + private static func downloadIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.fragment = nil + return components.string ?? url.absoluteString + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG cmuxDebugLog("download.didBecome source=navigationAction") diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index b71a566d7b74..7a909980008c 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -602,8 +602,11 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // MARK: - PopupNavigationDelegate private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { + private static let subframeDownloadIntentLifetime: TimeInterval = 10 + weak var controller: BrowserPopupWindowController? var downloadDelegate: WKDownloadDelegate? + private var recentSubframeDownloadIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] func webView( _ webView: WKWebView, @@ -628,6 +631,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.cancel) return } + recordSubframeDownloadIntentIfNeeded(navigationAction) // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { @@ -664,11 +668,14 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { } let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") + let allowsSubframeDownload = navigationResponse.isForMainFrame + || consumeRecentSubframeDownloadIntent(for: navigationResponse.response.url) if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, contentDisposition: contentDisposition, - isForMainFrame: navigationResponse.isForMainFrame + isForMainFrame: navigationResponse.isForMainFrame, + allowsSubframeDownload: allowsSubframeDownload ) != nil { decisionHandler(.download) return @@ -691,6 +698,49 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { controller?.handleWebContentProcessTermination(for: webView) } + private func recordSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { + guard navigationAction.targetFrame?.isMainFrame == false, + let url = navigationAction.request.url, + Self.isHTTPDownloadIntentURL(url) else { return } + guard navigationAction.navigationType == .linkActivated + || browserNavigationHasSimpleUserActivation() else { return } + let now = ProcessInfo.processInfo.systemUptime + pruneSubframeDownloadIntents(now: now) + recentSubframeDownloadIntentKeys.append((Self.downloadIntentKey(for: url), now)) + } + + private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { + guard let responseURL, + Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime + pruneSubframeDownloadIntents(now: now) + let key = Self.downloadIntentKey(for: responseURL) + guard let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) else { + return false + } + recentSubframeDownloadIntentKeys.remove(at: index) + return true + } + + private func pruneSubframeDownloadIntents(now: TimeInterval) { + recentSubframeDownloadIntentKeys.removeAll { + now - $0.recordedAt > Self.subframeDownloadIntentLifetime + } + } + + private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + private static func downloadIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.fragment = nil + return components.string ?? url.absoluteString + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG cmuxDebugLog("popup.download.didBecome source=navigationAction") diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index ec34ca1c703a..02a01aae6a53 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -44,16 +44,33 @@ import WebKit mimeType: "text/html", canShowMIMEType: true, contentDisposition: "attachment; filename=index.html", - isForMainFrame: false + isForMainFrame: false, + allowsSubframeDownload: true ) == "content-disposition") #expect(resolver.navigationResponseDownloadReason( mimeType: "text/csv", canShowMIMEType: true, contentDisposition: nil, - isForMainFrame: false + isForMainFrame: false, + allowsSubframeDownload: true ) == "forceDownloadMIME") } + @Test func navigationResponseRejectsUnactivatedSubframeDownloads() { + #expect(resolver.navigationResponseDownloadReason( + mimeType: "text/html", + canShowMIMEType: true, + contentDisposition: "attachment; filename=index.html", + isForMainFrame: false + ) == nil) + #expect(resolver.navigationResponseDownloadReason( + mimeType: "text/csv", + canShowMIMEType: true, + contentDisposition: nil, + isForMainFrame: false + ) == nil) + } + @Test func navigationResponseKeepsUnshowableSubframesInlineWithoutExplicitDownloadSignal() { #expect(resolver.navigationResponseDownloadReason( mimeType: "application/x-custom", From a4e37afd650d8524beeb06601e34be59f847e194 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:04:55 -0700 Subject: [PATCH 06/59] Move session download writes off main thread --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/CmuxWebView.swift | 152 +++++++++++++++++++++------ 2 files changed, 119 insertions(+), 35 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 63356bb04dfe..5d664ffc3345 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -35,7 +35,7 @@ 3058 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift -2609 Sources/Panels/CmuxWebView.swift +2693 Sources/Panels/CmuxWebView.swift 2606 Sources/KeyboardShortcutSettings.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2524 cmuxTests/CommandPaletteSearchEngineTests.swift diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 601c5e2ae8ea..6ef801cc474d 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1630,14 +1630,15 @@ final class CmuxWebView: WKWebView { failureFallbackReason: String? ) { let filenameResolver = BrowserDownloadFilenameResolver() - let writeData: (URL) -> Bool = { destinationURL in - do { - try data.write(to: destinationURL, options: .atomic) + let completeWrite: (Result) -> Void = { [weak self] result in + guard let self else { return } + self.notifyContextMenuDownloadState(false) + switch result { + case .success(let destinationURL): self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=\(destinationURL.path)" ) - return true - } catch { + case .failure(let error): self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" ) @@ -1650,7 +1651,6 @@ final class CmuxWebView: WKWebView { reason: failureFallbackReason ) } - return false } } @@ -1659,7 +1659,6 @@ final class CmuxWebView: WKWebView { savePanel.nameFieldStringValue = saveName savePanel.canCreateDirectories = true savePanel.directoryURL = filenameResolver.downloadsDirectory() - notifyContextMenuDownloadState(false) debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" ) @@ -1668,9 +1667,15 @@ final class CmuxWebView: WKWebView { self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel" ) + self.notifyContextMenuDownloadState(false) return } - _ = writeData(destURL) + self.writeSessionDownloadDataInBackground( + data, + destinationURL: destURL, + replaceExisting: true, + completion: completeWrite + ) } if let parentWindow = window { savePanel.beginSheetModal(for: parentWindow, completionHandler: completion) @@ -1680,35 +1685,114 @@ final class CmuxWebView: WKWebView { return } - do { - let directory = filenameResolver.downloadsDirectory() - try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - let destinationURL = filenameResolver.uniqueDownloadDestination( - suggestedFilename: saveName, - in: directory - ) - debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=\(destinationURL.path)" - ) - let didWrite = writeData(destinationURL) - notifyContextMenuDownloadState(false) - if !didWrite, failureFallbackReason == nil { - return + autoSaveSessionDownloadDataInBackground( + data, + saveName: saveName, + filenameResolver: filenameResolver, + traceID: traceID, + logCategory: logCategory, + completion: completeWrite + ) + } + + private func writeSessionDownloadDataInBackground( + _ data: Data, + destinationURL: URL, + replaceExisting: Bool, + completion: @escaping (Result) -> Void + ) { + Task { @MainActor in + let result = await Task.detached(priority: .utility) { + Self.writeSessionDownloadData(data, to: destinationURL, replaceExisting: replaceExisting) + }.value + completion(result) + } + } + + private func autoSaveSessionDownloadDataInBackground( + _ data: Data, + saveName: String, + filenameResolver: BrowserDownloadFilenameResolver, + traceID: String, + logCategory: String, + completion: @escaping (Result) -> Void + ) { + Task { @MainActor in + let result = await Task.detached(priority: .utility) { + Self.autoSaveSessionDownloadData( + data, + saveName: saveName, + filenameResolver: filenameResolver + ) + }.value + if case .success(let destinationURL) = result { + self.debugContextDownload( + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=\(destinationURL.path)" + ) } - } catch { - notifyContextMenuDownloadState(false) - debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" - ) - if let failureFallbackReason { - runContextMenuFallback( - action: fallbackAction, - target: fallbackTarget, - sender: sender, - traceID: traceID, - reason: failureFallbackReason + completion(result) + } + } + + private static func autoSaveSessionDownloadData( + _ data: Data, + saveName: String, + filenameResolver: BrowserDownloadFilenameResolver + ) -> Result { + Result { + let fileManager = FileManager.default + let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + var lastCollisionError: Error? + for _ in 0..<100 { + let destinationURL = filenameResolver.uniqueDownloadDestination( + suggestedFilename: saveName, + in: directory, + fileManager: fileManager ) + do { + try writeSessionDownloadDataWithoutReplacing(data, to: destinationURL, fileManager: fileManager) + return destinationURL + } catch { + guard fileManager.fileExists(atPath: destinationURL.path) else { + throw error + } + lastCollisionError = error + } + } + throw lastCollisionError ?? CocoaError(.fileWriteUnknown) + } + } + + private static func writeSessionDownloadData( + _ data: Data, + to destinationURL: URL, + replaceExisting: Bool + ) -> Result { + Result { + if replaceExisting { + try data.write(to: destinationURL, options: .atomic) + } else { + try writeSessionDownloadDataWithoutReplacing(data, to: destinationURL, fileManager: .default) } + return destinationURL + } + } + + private static func writeSessionDownloadDataWithoutReplacing( + _ data: Data, + to destinationURL: URL, + fileManager: FileManager + ) throws { + let tempURL = destinationURL + .deletingLastPathComponent() + .appendingPathComponent(".cmux-\(UUID().uuidString).download", isDirectory: false) + do { + try data.write(to: tempURL, options: .atomic) + try fileManager.moveItem(at: tempURL, to: destinationURL) + } catch { + try? fileManager.removeItem(at: tempURL) + throw error } } From db86301b6df60ea322f0a7d98ce7d72a4db4774b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:10:35 -0700 Subject: [PATCH 07/59] Preserve scripted blob download filenames --- .github/swift-file-length-budget.tsv | 2 +- .../BrowserDownloadFilenameResolver.swift | 8 +++++- Sources/Panels/BrowserPanel.swift | 19 ++++++++++++- .../CmuxWebView+ScriptedDownloads.swift | 11 ++++++-- ...BrowserDownloadFilenameResolverTests.swift | 27 +++++++++++++++++++ 5 files changed, 62 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5d664ffc3345..3d3815135752 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -12085 Sources/Panels/BrowserPanel.swift +12102 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 600e7ab58a4f..ed7179917f1b 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -9,6 +9,8 @@ nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { } nonisolated struct BrowserDownloadFilenameResolver: Sendable { + private static let maxFilenameCollisionAttempts = 100 + func shouldForceDownload( mimeType: String?, contentDisposition: String? @@ -152,7 +154,7 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { let base = nsFilename.deletingPathExtension.isEmpty ? defaultFilename : nsFilename.deletingPathExtension let ext = nsFilename.pathExtension var index = 1 - while true { + while index <= Self.maxFilenameCollisionAttempts { let dedupedName = ext.isEmpty ? "\(base) (\(index))" : "\(base) (\(index)).\(ext)" let url = directory.appendingPathComponent(dedupedName, isDirectory: false) if !fileManager.fileExists(atPath: url.path) { @@ -160,6 +162,10 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { } index += 1 } + + let uuid = UUID().uuidString + let fallbackName = ext.isEmpty ? "\(base)-\(uuid)" : "\(base)-\(uuid).\(ext)" + return directory.appendingPathComponent(fallbackName, isDirectory: false) } private func imageFilename( diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 87063df8fc1d..d0b36e21a78b 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8299,6 +8299,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { /// Tracks active downloads keyed by WKDownload identity. private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] + private var suggestedFilenameOverrides: [ObjectIdentifier: String] = [:] private let activeDownloadsLock = NSLock() var onDownloadStarted: ((String) -> Void)? var onDownloadReadyToSave: ((String) -> Void)? @@ -8319,6 +8320,21 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { activeDownloadsLock.unlock() } + func setSuggestedFilenameOverride(_ suggestedFilename: String?, for download: WKDownload) { + let trimmed = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) + guard let trimmed, !trimmed.isEmpty else { return } + activeDownloadsLock.lock() + suggestedFilenameOverrides[ObjectIdentifier(download)] = trimmed + activeDownloadsLock.unlock() + } + + private func takeSuggestedFilenameOverride(for download: WKDownload) -> String? { + activeDownloadsLock.lock() + let filename = suggestedFilenameOverrides.removeValue(forKey: ObjectIdentifier(download)) + activeDownloadsLock.unlock() + return filename + } + private func removeState(for download: WKDownload) -> DownloadState? { activeDownloadsLock.lock() let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) @@ -8411,8 +8427,9 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { completionHandler(nil) return } + let preferredSuggestedFilename = takeSuggestedFilenameOverride(for: download) ?? suggestedFilename let sourceURL = response.url ?? URL(fileURLWithPath: suggestedFilename) - let safeFilename = filenameResolver.suggestedFilename(suggestedFilename: suggestedFilename, response: response, sourceURL: sourceURL, imageType: nil) + let safeFilename = filenameResolver.suggestedFilename(suggestedFilename: preferredSuggestedFilename, 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) diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 9ce496fb53b4..b7a49daa9cd0 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -331,7 +331,7 @@ extension CmuxWebView { let traceID = Self.makeContextDownloadTraceID(prefix: "scriptdl") debugContextDownload("browser.scriptdl.start trace=\(traceID) scheme=\(url.scheme ?? "nil")") if url.scheme?.caseInsensitiveCompare("blob") == .orderedSame { - startScriptedWebKitDownload(url, traceID: traceID) + startScriptedWebKitDownload(url, suggestedFilename: suggestedFilename, traceID: traceID) return } downloadURLViaSession( @@ -349,7 +349,11 @@ extension CmuxWebView { return scheme == "data" || scheme == "blob" } - private func startScriptedWebKitDownload(_ url: URL, traceID: String) { + private func startScriptedWebKitDownload( + _ url: URL, + suggestedFilename: String?, + traceID: String + ) { guard let downloadDelegate = cmuxDownloadDelegate else { #if DEBUG debugContextDownload("browser.scriptdl.webkit trace=\(traceID) stage=rejectMissingDelegate") @@ -361,6 +365,9 @@ extension CmuxWebView { #if DEBUG self.debugContextDownload("browser.scriptdl.webkit trace=\(traceID) stage=didStart") #endif + if let browserDownloadDelegate = downloadDelegate as? BrowserDownloadDelegate { + browserDownloadDelegate.setSuggestedFilenameOverride(suggestedFilename, for: download) + } download.delegate = downloadDelegate } } else { diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 02a01aae6a53..e261a46faa90 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -108,6 +108,33 @@ import WebKit #expect(destination.lastPathComponent == "report (1).pdf") } + @Test func uniqueDownloadDestinationFallsBackAfterBoundedCollisionScan() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-download-resolver-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: directory) } + + try Data("existing".utf8).write(to: directory.appendingPathComponent("report.pdf", isDirectory: false)) + for index in 1...100 { + try Data("existing".utf8).write( + to: directory.appendingPathComponent("report (\(index)).pdf", isDirectory: false) + ) + } + + let destination = resolver.uniqueDownloadDestination( + suggestedFilename: "report.pdf", + in: directory, + fileManager: fileManager + ) + + #expect(destination.deletingLastPathComponent().path == directory.path) + #expect(destination.lastPathComponent.hasPrefix("report-")) + #expect(destination.pathExtension == "pdf") + } + @MainActor @Test func scriptedDownloadInterceptionRunsInSubframes() throws { let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) From 52d149e3e6b6f27b88f0fb4bfb37509a1c07dad0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:18:01 -0700 Subject: [PATCH 08/59] Bound subframe download interception --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/Panels/BrowserPanel.swift | 10 +++++++++- Sources/Panels/BrowserPopupWindowController.swift | 10 +++++++++- Sources/Panels/CmuxWebView+ScriptedDownloads.swift | 13 ++++++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 3d3815135752..819561b83235 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -12102 Sources/Panels/BrowserPanel.swift +12110 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -118,10 +118,10 @@ 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift +765 Sources/Panels/BrowserPopupWindowController.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 759 Sources/App/MenuBarExtraController.swift -757 Sources/Panels/BrowserPopupWindowController.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 755 CLI/CMUXCLI+AgentHookDefinitions.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d0b36e21a78b..2e0535b9bf87 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8715,6 +8715,7 @@ func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private static let subframeDownloadIntentLifetime: TimeInterval = 10 + private static let maxSubframeDownloadIntentCount = 64 private var recentSubframeDownloadIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] var didStartProvisionalNavigation: ((WKWebView) -> Void)? @@ -9067,7 +9068,14 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { || browserNavigationHasSimpleUserActivation() else { return } let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) - recentSubframeDownloadIntentKeys.append((Self.downloadIntentKey(for: url), now)) + let key = Self.downloadIntentKey(for: url) + recentSubframeDownloadIntentKeys.removeAll { $0.key == key } + recentSubframeDownloadIntentKeys.append((key, now)) + if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { + recentSubframeDownloadIntentKeys.removeFirst( + recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount + ) + } } private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 7a909980008c..3b7a7f3211ef 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -603,6 +603,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { private static let subframeDownloadIntentLifetime: TimeInterval = 10 + private static let maxSubframeDownloadIntentCount = 64 weak var controller: BrowserPopupWindowController? var downloadDelegate: WKDownloadDelegate? @@ -706,7 +707,14 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { || browserNavigationHasSimpleUserActivation() else { return } let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) - recentSubframeDownloadIntentKeys.append((Self.downloadIntentKey(for: url), now)) + let key = Self.downloadIntentKey(for: url) + recentSubframeDownloadIntentKeys.removeAll { $0.key == key } + recentSubframeDownloadIntentKeys.append((key, now)) + if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { + recentSubframeDownloadIntentKeys.removeFirst( + recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount + ) + } } private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index b7a49daa9cd0..3af0bb86c898 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -19,10 +19,17 @@ extension CmuxWebView { const maxPayloadBytes = \(maxScriptedDownloadPayloadBytes); const maxDataURLCharacters = \(maxScriptedDownloadDataURLCharacters); const trustedActivationWindowMs = 2000; + let isMainFrame = false; let blobDownloadInFlight = false; let lastTrustedActivationMs = 0; let lastDownloadPostMs = 0; + try { + isMainFrame = window.top === window; + } catch (_) { + isMainFrame = false; + } + const handler = (() => { try { return window.webkit?.messageHandlers?.\(scriptedDownloadMessageHandlerName) ?? null; @@ -139,7 +146,7 @@ extension CmuxWebView { return false; }; - if (typeof originalCreateObjectURL === "function") { + if (isMainFrame && typeof originalCreateObjectURL === "function") { URLCtor.createObjectURL = function(object) { const url = originalCreateObjectURL.apply(this, arguments); try { @@ -151,7 +158,7 @@ extension CmuxWebView { }; } - if (typeof originalRevokeObjectURL === "function") { + if (isMainFrame && typeof originalRevokeObjectURL === "function") { URLCtor.revokeObjectURL = function(url) { try { objectURLs.delete(String(url)); @@ -222,7 +229,7 @@ extension CmuxWebView { const anchorPrototype = window.HTMLAnchorElement?.prototype ?? null; const originalAnchorClick = anchorPrototype?.click ?? null; - if (typeof originalAnchorClick === "function") { + if (isMainFrame && typeof originalAnchorClick === "function") { anchorPrototype.click = function() { if (interceptAnchorDownload(this, null)) return; return originalAnchorClick.apply(this, arguments); From 9262538cffc51542dcbdd0b45772e26e7c37ecbc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:27:12 -0700 Subject: [PATCH 09/59] Handle redirected subframe downloads --- .github/swift-file-length-budget.tsv | 4 +-- Sources/Panels/BrowserPanel.swift | 8 +++--- .../Panels/BrowserPopupWindowController.swift | 8 +++--- .../CmuxWebView+ScriptedDownloads.swift | 26 +++++++++++++++++++ 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 819561b83235..d4205270aa7c 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -12110 Sources/Panels/BrowserPanel.swift +12112 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -118,7 +118,7 @@ 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift -765 Sources/Panels/BrowserPopupWindowController.swift +767 Sources/Panels/BrowserPopupWindowController.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 759 Sources/App/MenuBarExtraController.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2e0535b9bf87..bdb5906141be 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -9084,10 +9084,12 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) let key = Self.downloadIntentKey(for: responseURL) - guard let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) else { - return false + if let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) { + recentSubframeDownloadIntentKeys.remove(at: index) + return true } - recentSubframeDownloadIntentKeys.remove(at: index) + guard !recentSubframeDownloadIntentKeys.isEmpty else { return false } + recentSubframeDownloadIntentKeys.removeFirst() return true } diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 3b7a7f3211ef..d0b2a3e889d7 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -723,10 +723,12 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) let key = Self.downloadIntentKey(for: responseURL) - guard let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) else { - return false + if let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) { + recentSubframeDownloadIntentKeys.remove(at: index) + return true } - recentSubframeDownloadIntentKeys.remove(at: index) + guard !recentSubframeDownloadIntentKeys.isEmpty else { return false } + recentSubframeDownloadIntentKeys.removeFirst() return true } diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 3af0bb86c898..5450eb80a543 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -227,6 +227,32 @@ extension CmuxWebView { event.stopPropagation(); }, true); + if (!isMainFrame && typeof MutationObserver === "function") { + const inspectAddedNode = (node) => { + try { + if (!node || node.nodeType !== 1) return; + const candidates = []; + const tag = String(node.tagName || "").toUpperCase(); + if ((tag === "A" || tag === "AREA") && node.href) candidates.push(node); + const nested = node.querySelectorAll?.("a[href][download],area[href][download]") ?? []; + for (const anchor of nested) candidates.push(anchor); + for (const anchor of candidates) { + if (interceptAnchorDownload(anchor, null)) return; + } + } catch (_) {} + }; + const observer = new MutationObserver((mutations) => { + try { + for (const mutation of mutations) { + for (const node of mutation.addedNodes || []) { + inspectAddedNode(node); + } + } + } catch (_) {} + }); + observer.observe(document.documentElement || document, { childList: true, subtree: true }); + } + const anchorPrototype = window.HTMLAnchorElement?.prototype ?? null; const originalAnchorClick = anchorPrototype?.click ?? null; if (isMainFrame && typeof originalAnchorClick === "function") { From 2d06aeaa4e30d53b320da5c7ad626e7b4c4cd85c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 17:34:56 -0700 Subject: [PATCH 10/59] Tighten subframe download intent handling --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/Panels/BrowserPanel.swift | 17 ++++++++++------- .../Panels/BrowserPopupWindowController.swift | 17 ++++++++++------- .../Panels/CmuxWebView+ScriptedDownloads.swift | 11 ++++++++++- 4 files changed, 32 insertions(+), 17 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index d4205270aa7c..2ae0b0d44f59 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12845 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -12112 Sources/Panels/BrowserPanel.swift +12115 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -116,9 +116,9 @@ 802 Sources/WorkspaceContentView.swift 797 Sources/ClosedItemHistory.swift 779 cmuxUITests/BrowserOmnibarSuggestionsUITests.swift +770 Sources/Panels/BrowserPopupWindowController.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift -767 Sources/Panels/BrowserPopupWindowController.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 759 Sources/App/MenuBarExtraController.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index bdb5906141be..fd4d042f1f85 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8915,7 +8915,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) - recordSubframeDownloadIntentIfNeeded(navigationAction) + updateSubframeDownloadIntentIfNeeded(navigationAction) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -9060,14 +9060,19 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.allow) } - private func recordSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { + private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url) else { return } - guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { return } let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) + guard navigationAction.navigationType == .linkActivated + || browserNavigationHasSimpleUserActivation() else { + if recentSubframeDownloadIntentKeys.count == 1 { + recentSubframeDownloadIntentKeys[0] = (Self.downloadIntentKey(for: url), now) + } + return + } let key = Self.downloadIntentKey(for: url) recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) @@ -9088,9 +9093,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { recentSubframeDownloadIntentKeys.remove(at: index) return true } - guard !recentSubframeDownloadIntentKeys.isEmpty else { return false } - recentSubframeDownloadIntentKeys.removeFirst() - return true + return false } private func pruneSubframeDownloadIntents(now: TimeInterval) { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index d0b2a3e889d7..f51fe0aecbd7 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -632,7 +632,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.cancel) return } - recordSubframeDownloadIntentIfNeeded(navigationAction) + updateSubframeDownloadIntentIfNeeded(navigationAction) // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { @@ -699,14 +699,19 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { controller?.handleWebContentProcessTermination(for: webView) } - private func recordSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { + private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url) else { return } - guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { return } let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) + guard navigationAction.navigationType == .linkActivated + || browserNavigationHasSimpleUserActivation() else { + if recentSubframeDownloadIntentKeys.count == 1 { + recentSubframeDownloadIntentKeys[0] = (Self.downloadIntentKey(for: url), now) + } + return + } let key = Self.downloadIntentKey(for: url) recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) @@ -727,9 +732,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { recentSubframeDownloadIntentKeys.remove(at: index) return true } - guard !recentSubframeDownloadIntentKeys.isEmpty else { return false } - recentSubframeDownloadIntentKeys.removeFirst() - return true + return false } private func pruneSubframeDownloadIntents(now: TimeInterval) { diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 5450eb80a543..d318bc245d7d 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -23,6 +23,7 @@ extension CmuxWebView { let blobDownloadInFlight = false; let lastTrustedActivationMs = 0; let lastDownloadPostMs = 0; + const handledAnchors = typeof WeakSet === "function" ? new WeakSet() : null; try { isMainFrame = window.top === window; @@ -222,6 +223,11 @@ extension CmuxWebView { document.addEventListener("click", (event) => { const anchor = anchorForEvent(event); + if (anchor && handledAnchors?.has(anchor)) { + event.preventDefault(); + event.stopPropagation(); + return; + } if (!interceptAnchorDownload(anchor, event)) return; event.preventDefault(); event.stopPropagation(); @@ -237,7 +243,10 @@ extension CmuxWebView { const nested = node.querySelectorAll?.("a[href][download],area[href][download]") ?? []; for (const anchor of nested) candidates.push(anchor); for (const anchor of candidates) { - if (interceptAnchorDownload(anchor, null)) return; + if (interceptAnchorDownload(anchor, null)) { + handledAnchors?.add(anchor); + return; + } } } catch (_) {} }; From 30c0d563428f7b1855420403c10a0f61eae8f910 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:04:17 -0700 Subject: [PATCH 11/59] Route subframe attachment downloads through session --- Sources/Panels/BrowserPanel.swift | 11 ++++---- .../Panels/BrowserPopupWindowController.swift | 15 +++++----- .../CmuxWebView+ScriptedDownloads.swift | 28 +++++++++++++++++-- 3 files changed, 38 insertions(+), 16 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index fd4d042f1f85..d82fef44be20 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -9053,6 +9053,10 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { #if DEBUG cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif + if !navigationResponse.isForMainFrame, + (webView as? CmuxWebView)?.startSubframeResponseSessionDownload(navigationResponse: navigationResponse, reason: reason) == true { + decisionHandler(.cancel); return + } decisionHandler(.download) return } @@ -9067,12 +9071,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { - if recentSubframeDownloadIntentKeys.count == 1 { - recentSubframeDownloadIntentKeys[0] = (Self.downloadIntentKey(for: url), now) - } - return - } + || browserNavigationHasSimpleUserActivation() else { return } let key = Self.downloadIntentKey(for: url) recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index f51fe0aecbd7..84631c89d0b4 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -671,13 +671,17 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame || consumeRecentSubframeDownloadIntent(for: navigationResponse.response.url) - if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( + if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, contentDisposition: contentDisposition, isForMainFrame: navigationResponse.isForMainFrame, allowsSubframeDownload: allowsSubframeDownload - ) != nil { + ) { + if !navigationResponse.isForMainFrame, + (webView as? CmuxWebView)?.startSubframeResponseSessionDownload(navigationResponse: navigationResponse, reason: reason) == true { + decisionHandler(.cancel); return + } decisionHandler(.download) return } @@ -706,12 +710,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { let now = ProcessInfo.processInfo.systemUptime pruneSubframeDownloadIntents(now: now) guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { - if recentSubframeDownloadIntentKeys.count == 1 { - recentSubframeDownloadIntentKeys[0] = (Self.downloadIntentKey(for: url), now) - } - return - } + || browserNavigationHasSimpleUserActivation() else { return } let key = Self.downloadIntentKey(for: url) recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index d318bc245d7d..33e4f88070f3 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -147,7 +147,7 @@ extension CmuxWebView { return false; }; - if (isMainFrame && typeof originalCreateObjectURL === "function") { + if (typeof originalCreateObjectURL === "function") { URLCtor.createObjectURL = function(object) { const url = originalCreateObjectURL.apply(this, arguments); try { @@ -159,7 +159,7 @@ extension CmuxWebView { }; } - if (isMainFrame && typeof originalRevokeObjectURL === "function") { + if (typeof originalRevokeObjectURL === "function") { URLCtor.revokeObjectURL = function(url) { try { objectURLs.delete(String(url)); @@ -309,6 +309,30 @@ extension CmuxWebView { ) } + @discardableResult + func startSubframeResponseSessionDownload( + navigationResponse: WKNavigationResponse, + reason: String + ) -> Bool { + guard let url = navigationResponse.response.url, + ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + return false + } + let traceID = Self.makeContextDownloadTraceID(prefix: "subframe") +#if DEBUG + debugContextDownload("download.subframeSession trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") +#endif + downloadURLViaSession( + url, + suggestedFilename: navigationResponse.response.suggestedFilename, + sender: nil, + fallbackAction: nil, + fallbackTarget: nil, + traceID: traceID + ) + return true + } + fileprivate func handleScriptedDownloadMessage(_ body: [String: Any]) { let expectedToken = objc_getAssociatedObject( configuration.userContentController, From b49f3f088b7079e355aaa5cf073479237db2ec97 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:11:45 -0700 Subject: [PATCH 12/59] Use tokenized subframe download intent --- .../BrowserDownloadFilenameResolver.swift | 27 ++++++++++ Sources/Panels/BrowserPanel.swift | 46 ++++++++--------- .../Panels/BrowserPopupWindowController.swift | 19 +++---- .../CmuxWebView+ScriptedDownloads.swift | 51 ++++++++++--------- Sources/Panels/CmuxWebView.swift | 12 ++--- 5 files changed, 91 insertions(+), 64 deletions(-) diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index ed7179917f1b..f66f925be5b3 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -2,12 +2,39 @@ import Foundation import ImageIO import CmuxSettings import UniformTypeIdentifiers +import WebKit nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { case allow case reject(statusCode: Int) } +extension CmuxWebView { + @discardableResult + func startSubframeResponseSessionDownload( + navigationResponse: WKNavigationResponse, + reason: String + ) -> Bool { + guard let url = navigationResponse.response.url, + ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + return false + } + let traceID = Self.makeContextDownloadTraceID(prefix: "subframe") +#if DEBUG + debugContextDownload("download.subframeSession trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") +#endif + downloadURLViaSession( + url, + suggestedFilename: navigationResponse.response.suggestedFilename, + sender: nil, + fallbackAction: nil, + fallbackTarget: nil, + traceID: traceID + ) + return true + } +} + nonisolated struct BrowserDownloadFilenameResolver: Sendable { private static let maxFilenameCollisionAttempts = 100 diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d82fef44be20..0e14e6267c36 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3362,9 +3362,7 @@ final class BrowserPanel: Panel, ObservableObject { isMainFrameProvisionalNavigationActive = false oldWebView.navigationDelegate = nil oldWebView.uiDelegate = nil - if let oldCmuxWebView = oldWebView as? CmuxWebView { - oldCmuxWebView.onContextMenuDownloadStateChanged = nil - } + if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } let replacement = Self.makeWebView( profileID: profileID, @@ -3741,8 +3739,8 @@ final class BrowserPanel: Panel, ObservableObject { webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } - webView.cmuxDownloadDelegate = downloadDelegate configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks() + webView.cmuxDownloadDelegate = downloadDelegate webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) @@ -3755,6 +3753,7 @@ final class BrowserPanel: Panel, ObservableObject { guard let navigationDelegate else { return } let boundWebViewInstanceID = webViewInstanceID let boundHistoryStore = historyStore + webView.onSubframeDownloadIntent = { [weak navigationDelegate] in navigationDelegate?.recordSubframeDownloadIntent($0) } navigationDelegate.didStartProvisionalNavigation = { [weak self] webView in MainActor.assumeIsolated { @@ -4431,9 +4430,7 @@ final class BrowserPanel: Panel, ObservableObject { isMainFrameProvisionalNavigationActive = false previousWebView.navigationDelegate = nil previousWebView.uiDelegate = nil - if let previousCmuxWebView = previousWebView as? CmuxWebView { - previousCmuxWebView.onContextMenuDownloadStateChanged = nil - } + if let previousCmuxWebView = previousWebView as? CmuxWebView { previousCmuxWebView.clearBrowserDownloadCallbacks() } profileID = resolvedProfileID historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) @@ -5012,9 +5009,7 @@ final class BrowserPanel: Panel, ObservableObject { isMainFrameProvisionalNavigationActive = false oldWebView.navigationDelegate = nil oldWebView.uiDelegate = nil - if let oldCmuxWebView = oldWebView as? CmuxWebView { - oldCmuxWebView.onContextMenuDownloadStateChanged = nil - } + if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } let replacement = Self.makeWebView( profileID: profileID, @@ -6023,9 +6018,7 @@ extension BrowserPanel { isMainFrameProvisionalNavigationActive = false oldWebView.navigationDelegate = nil oldWebView.uiDelegate = nil - if let oldCmuxWebView = oldWebView as? CmuxWebView { - oldCmuxWebView.onContextMenuDownloadStateChanged = nil - } + if let oldCmuxWebView = oldWebView as? CmuxWebView { oldCmuxWebView.clearBrowserDownloadCallbacks() } let replacement = Self.makeWebView( profileID: profileID, @@ -8338,6 +8331,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private func removeState(for download: WKDownload) -> DownloadState? { activeDownloadsLock.lock() let state = activeDownloads.removeValue(forKey: ObjectIdentifier(download)) + suggestedFilenameOverrides.removeValue(forKey: ObjectIdentifier(download)) activeDownloadsLock.unlock() return state } @@ -8424,6 +8418,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { // Save to a temp file — return synchronously so WebKit is never blocked. let filenameResolver = BrowserDownloadFilenameResolver() if case .reject = filenameResolver.httpStatusDecision(for: response) { + _ = removeState(for: download) completionHandler(nil) return } @@ -8438,7 +8433,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { self?.onDownloadStarted?(safeFilename) } #if DEBUG - cmuxDebugLog("download.decideDestination file=\(safeFilename)") + cmuxDebugLog("download.decideDestination file=") #endif completionHandler(destURL) } @@ -8451,7 +8446,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { return } #if DEBUG - cmuxDebugLog("download.finished file=\(info.suggestedFilename)") + cmuxDebugLog("download.finished file=") #endif let filenameResolver = BrowserDownloadFilenameResolver() Task { @MainActor in @@ -8482,7 +8477,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { case .success(let destinationURL): self.onDownloadSaved?(suggestedFilename, destinationURL) #if DEBUG - cmuxDebugLog("download.saved path=\(destinationURL.path)") + cmuxDebugLog("download.saved path=") #endif case .failure(let error): try? FileManager.default.removeItem(at: info.tempURL) @@ -9068,17 +9063,18 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime - pruneSubframeDownloadIntents(now: now) - guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { return } - let key = Self.downloadIntentKey(for: url) - recentSubframeDownloadIntentKeys.removeAll { $0.key == key } + let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) + guard navigationAction.navigationType == .linkActivated else { return } + recordSubframeDownloadIntent(url) + } + + func recordSubframeDownloadIntent(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) + let key = Self.downloadIntentKey(for: url); recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { - recentSubframeDownloadIntentKeys.removeFirst( - recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount - ) + recentSubframeDownloadIntentKeys.removeFirst(recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount) } } diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 84631c89d0b4..6f99e437c1b7 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -221,6 +221,7 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { panel } webView.cmuxDownloadDelegate = dlDel + webView.onSubframeDownloadIntent = { [weak navDel] in navDel?.recordSubframeDownloadIntent($0) } webView.uiDelegate = uiDel webView.navigationDelegate = navDel webAuthnCoordinator.install(on: webView) @@ -707,17 +708,17 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime - pruneSubframeDownloadIntents(now: now) - guard navigationAction.navigationType == .linkActivated - || browserNavigationHasSimpleUserActivation() else { return } - let key = Self.downloadIntentKey(for: url) - recentSubframeDownloadIntentKeys.removeAll { $0.key == key } + let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) + guard navigationAction.navigationType == .linkActivated else { return } + recordSubframeDownloadIntent(url) + } + func recordSubframeDownloadIntent(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) + let key = Self.downloadIntentKey(for: url); recentSubframeDownloadIntentKeys.removeAll { $0.key == key } recentSubframeDownloadIntentKeys.append((key, now)) if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { - recentSubframeDownloadIntentKeys.removeFirst( - recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount - ) + recentSubframeDownloadIntentKeys.removeFirst(recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount) } } diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 33e4f88070f3..c2d9de53a0fc 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -6,9 +6,20 @@ extension CmuxWebView { private static let scriptedDownloadMessageHandlerName = "cmuxScriptedDownload" private static var scriptedDownloadHandlerInstalledKey: UInt8 = 0 private static var scriptedDownloadTokenKey: UInt8 = 0 + private static var subframeDownloadIntentHandlerKey: UInt8 = 0 private static let maxScriptedDownloadPayloadBytes = 100 * 1024 * 1024 private static let maxScriptedDownloadDataURLCharacters = 140 * 1024 * 1024 + var onSubframeDownloadIntent: ((URL) -> Void)? { + get { objc_getAssociatedObject(self, &Self.subframeDownloadIntentHandlerKey) as? ((URL) -> Void) } + set { objc_setAssociatedObject(self, &Self.subframeDownloadIntentHandlerKey, newValue, .OBJC_ASSOCIATION_COPY_NONATOMIC) } + } + + func clearBrowserDownloadCallbacks() { + onContextMenuDownloadStateChanged = nil + onSubframeDownloadIntent = nil + } + private static func scriptedDownloadInterceptionBootstrapScriptSource(token: String) -> String { """ (() => { @@ -91,6 +102,16 @@ extension CmuxWebView { }); } catch (_) {} }; + const postSubframeDownloadIntent = (anchor, event) => { + try { + if (!hasUserActivation(event) || !anchor) return; + const href = String(anchor.href || anchor.getAttribute("href") || ""); + const scheme = href.split(":", 1)[0].toLowerCase(); + if (scheme === "http" || scheme === "https") { + postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: href }); + } + } catch (_) {} + }; const readBlobForDownload = (blob, suggestedFilename, fallbackURL) => { try { @@ -223,6 +244,7 @@ extension CmuxWebView { document.addEventListener("click", (event) => { const anchor = anchorForEvent(event); + postSubframeDownloadIntent(anchor, event); if (anchor && handledAnchors?.has(anchor)) { event.preventDefault(); event.stopPropagation(); @@ -309,30 +331,6 @@ extension CmuxWebView { ) } - @discardableResult - func startSubframeResponseSessionDownload( - navigationResponse: WKNavigationResponse, - reason: String - ) -> Bool { - guard let url = navigationResponse.response.url, - ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { - return false - } - let traceID = Self.makeContextDownloadTraceID(prefix: "subframe") -#if DEBUG - debugContextDownload("download.subframeSession trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") -#endif - downloadURLViaSession( - url, - suggestedFilename: navigationResponse.response.suggestedFilename, - sender: nil, - fallbackAction: nil, - fallbackTarget: nil, - traceID: traceID - ) - return true - } - fileprivate func handleScriptedDownloadMessage(_ body: [String: Any]) { let expectedToken = objc_getAssociatedObject( configuration.userContentController, @@ -350,6 +348,11 @@ extension CmuxWebView { let suggestedFilename = body["suggestedFilename"] as? String let urlString: String? switch kind { + case "subframeDownloadIntent": + guard let rawURL = body["url"] as? String, + let url = URL(string: rawURL.trimmingCharacters(in: .whitespacesAndNewlines)) else { return } + onSubframeDownloadIntent?(url) + return case "url": urlString = body["url"] as? String case "dataURL": diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 6ef801cc474d..597cdbb9b334 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1634,9 +1634,9 @@ final class CmuxWebView: WKWebView { guard let self else { return } self.notifyContextMenuDownloadState(false) switch result { - case .success(let destinationURL): + case .success: self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=\(destinationURL.path)" + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=" ) case .failure(let error): self.debugContextDownload( @@ -1660,7 +1660,7 @@ final class CmuxWebView: WKWebView { savePanel.canCreateDirectories = true savePanel.directoryURL = filenameResolver.downloadsDirectory() debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=\(saveName)" + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=" ) let completion: (NSApplication.ModalResponse) -> Void = { result in guard result == .OK, let destURL = savePanel.url else { @@ -1725,9 +1725,9 @@ final class CmuxWebView: WKWebView { filenameResolver: filenameResolver ) }.value - if case .success(let destinationURL) = result { + if case .success = result { self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=\(destinationURL.path)" + "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=" ) } completion(result) @@ -1868,7 +1868,7 @@ final class CmuxWebView: WKWebView { do { let data = try Data(contentsOf: url) self.debugContextDownload( - "browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=\(url.path)" + "browser.ctxdl.file trace=\(traceID) stage=readSuccess bytes=\(data.count) path=" ) let filename = suggestedFilename?.trimmingCharacters(in: .whitespacesAndNewlines) let saveName = (filename?.isEmpty == false ? filename! : url.lastPathComponent.isEmpty ? "download" : url.lastPathComponent) From 87b327aed73379c39ea8ea6b5d064ed2c39c359b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:13:27 -0700 Subject: [PATCH 13/59] Stream subframe fallback downloads with WebKit --- .../BrowserDownloadFilenameResolver.swift | 21 +++++++++---------- Sources/Panels/BrowserPanel.swift | 6 ++++-- .../Panels/BrowserPopupWindowController.swift | 6 +++--- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index f66f925be5b3..7fec9476b26e 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -11,26 +11,25 @@ nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { extension CmuxWebView { @discardableResult - func startSubframeResponseSessionDownload( + func startSubframeResponseWebKitDownload( navigationResponse: WKNavigationResponse, reason: String ) -> Bool { guard let url = navigationResponse.response.url, - ["http", "https"].contains(url.scheme?.lowercased() ?? "") else { + ["http", "https"].contains(url.scheme?.lowercased() ?? ""), + let downloadDelegate = cmuxDownloadDelegate else { return false } let traceID = Self.makeContextDownloadTraceID(prefix: "subframe") #if DEBUG - debugContextDownload("download.subframeSession trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") + debugContextDownload("download.subframeWebKit trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") #endif - downloadURLViaSession( - url, - suggestedFilename: navigationResponse.response.suggestedFilename, - sender: nil, - fallbackAction: nil, - fallbackTarget: nil, - traceID: traceID - ) + startDownload(using: URLRequest(url: url)) { download in + if let browserDownloadDelegate = downloadDelegate as? BrowserDownloadDelegate { + browserDownloadDelegate.setSuggestedFilenameOverride(navigationResponse.response.suggestedFilename, for: download) + } + download.delegate = downloadDelegate + } return true } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 0e14e6267c36..b07146bcd532 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4003,6 +4003,7 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadReadyToSave = { [weak self] filename in guard let self else { return } + self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -9049,7 +9050,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif if !navigationResponse.isForMainFrame, - (webView as? CmuxWebView)?.startSubframeResponseSessionDownload(navigationResponse: navigationResponse, reason: reason) == true { + (webView as? CmuxWebView)?.startSubframeResponseWebKitDownload(navigationResponse: navigationResponse, reason: reason) == true { decisionHandler(.cancel); return } decisionHandler(.download) @@ -9062,7 +9063,8 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, - Self.isHTTPDownloadIntentURL(url) else { return } + Self.isHTTPDownloadIntentURL(url), + (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) guard navigationAction.navigationType == .linkActivated else { return } recordSubframeDownloadIntent(url) diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 6f99e437c1b7..21ced5ef12f0 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -680,7 +680,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { allowsSubframeDownload: allowsSubframeDownload ) { if !navigationResponse.isForMainFrame, - (webView as? CmuxWebView)?.startSubframeResponseSessionDownload(navigationResponse: navigationResponse, reason: reason) == true { + (webView as? CmuxWebView)?.startSubframeResponseWebKitDownload(navigationResponse: navigationResponse, reason: reason) == true { decisionHandler(.cancel); return } decisionHandler(.download) @@ -703,11 +703,11 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { controller?.handleWebContentProcessTermination(for: webView) } - private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, - Self.isHTTPDownloadIntentURL(url) else { return } + Self.isHTTPDownloadIntentURL(url), + (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) guard navigationAction.navigationType == .linkActivated else { return } recordSubframeDownloadIntent(url) From 651fe5e03990e8590fb3f56b0e34e5bfff67c018 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:23:09 -0700 Subject: [PATCH 14/59] Handle redirected subframe download waits --- Sources/Panels/BrowserPanel.swift | 10 ++++++---- .../Panels/BrowserPopupWindowController.swift | 16 ++++++++-------- Sources/TerminalController.swift | 18 +++++++++--------- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b07146bcd532..79f43e602403 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -9066,7 +9066,11 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - guard navigationAction.navigationType == .linkActivated else { return } + if navigationAction.navigationType == .linkActivated { recordSubframeDownloadIntent(url); return } + guard let sourceURL = navigationAction.targetFrame?.request.url else { return } + let sourceKey = Self.downloadIntentKey(for: sourceURL) + guard sourceKey != Self.downloadIntentKey(for: url), + recentSubframeDownloadIntentKeys.contains(where: { $0.key == sourceKey }) else { return } recordSubframeDownloadIntent(url) } @@ -9094,9 +9098,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { } private func pruneSubframeDownloadIntents(now: TimeInterval) { - recentSubframeDownloadIntentKeys.removeAll { - now - $0.recordedAt > Self.subframeDownloadIntentLifetime - } + recentSubframeDownloadIntentKeys.removeAll { now - $0.recordedAt > Self.subframeDownloadIntentLifetime } } private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 21ced5ef12f0..07fcc04f9275 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -709,7 +709,11 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - guard navigationAction.navigationType == .linkActivated else { return } + if navigationAction.navigationType == .linkActivated { recordSubframeDownloadIntent(url); return } + guard let sourceURL = navigationAction.targetFrame?.request.url else { return } + let sourceKey = Self.downloadIntentKey(for: sourceURL) + guard sourceKey != Self.downloadIntentKey(for: url), + recentSubframeDownloadIntentKeys.contains(where: { $0.key == sourceKey }) else { return } recordSubframeDownloadIntent(url) } func recordSubframeDownloadIntent(_ url: URL) { @@ -723,10 +727,8 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { } private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { - guard let responseURL, - Self.isHTTPDownloadIntentURL(responseURL) else { return false } - let now = ProcessInfo.processInfo.systemUptime - pruneSubframeDownloadIntents(now: now) + guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) let key = Self.downloadIntentKey(for: responseURL) if let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) { recentSubframeDownloadIntentKeys.remove(at: index) @@ -736,9 +738,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { } private func pruneSubframeDownloadIntents(now: TimeInterval) { - recentSubframeDownloadIntentKeys.removeAll { - now - $0.recordedAt > Self.subframeDownloadIntentLifetime - } + recentSubframeDownloadIntentKeys.removeAll { now - $0.recordedAt > Self.subframeDownloadIntentLifetime } } private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 18cad89eae4c..6c7439b50505 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8608,13 +8608,13 @@ class TerminalController { } private func v2PopBrowserDownloadEvent(surfaceId: UUID) -> [String: Any]? { - guard let first = v2BrowserDownloadEventsBySurface[surfaceId]?.first else { - return nil - } var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - remaining.removeFirst() - v2BrowserDownloadEventsBySurface[surfaceId] = remaining - return first + while !remaining.isEmpty { + let first = remaining.removeFirst() + v2BrowserDownloadEventsBySurface[surfaceId] = remaining + if (first["type"] as? String) != "started" { return first } + } + return nil } private nonisolated func v2WaitForDownloadFile(path: String, timeout: TimeInterval) -> V2DownloadFileWaitResult { @@ -8702,9 +8702,9 @@ class TerminalController { object: nil, queue: nil ) { note in - guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, - candidateSurfaceId == surfaceId, - let event = note.userInfo?["event"] as? [String: Any] else { + guard let candidateSurfaceId = note.userInfo?["surfaceId"] as? UUID, candidateSurfaceId == surfaceId, + let event = note.userInfo?["event"] as? [String: Any], + (event["type"] as? String) != "started" else { return } finishOnce(event) From e64cd2db0c6b9c4972c54f6abf5b14be166be367 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:26:31 -0700 Subject: [PATCH 15/59] Share subframe download intent tracking --- .../BrowserDownloadFilenameResolver.swift | 59 ++++++++++++++++++ Sources/Panels/BrowserPanel.swift | 61 ++----------------- .../Panels/BrowserPopupWindowController.swift | 58 ++---------------- 3 files changed, 67 insertions(+), 111 deletions(-) diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 7fec9476b26e..35a7a54879f1 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -9,6 +9,65 @@ nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { case reject(statusCode: Int) } +final class BrowserSubframeDownloadIntentTracker { + private static let intentLifetime: TimeInterval = 10 + private static let maxIntentCount = 64 + + private var recentIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] + + func updateIfNeeded(_ navigationAction: WKNavigationAction) { + guard navigationAction.targetFrame?.isMainFrame == false, + let url = navigationAction.request.url, + Self.isHTTPDownloadIntentURL(url), + (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + if navigationAction.navigationType == .linkActivated { record(url); return } + guard let sourceURL = navigationAction.targetFrame?.request.url else { return } + let sourceKey = Self.downloadIntentKey(for: sourceURL) + guard sourceKey != Self.downloadIntentKey(for: url), + recentIntentKeys.contains(where: { $0.key == sourceKey }) else { return } + record(url) + } + + func record(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.downloadIntentKey(for: url); recentIntentKeys.removeAll { $0.key == key } + recentIntentKeys.append((key, now)) + if recentIntentKeys.count > Self.maxIntentCount { + recentIntentKeys.removeFirst(recentIntentKeys.count - Self.maxIntentCount) + } + } + + func consume(for responseURL: URL?) -> Bool { + guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.downloadIntentKey(for: responseURL) + if let index = recentIntentKeys.firstIndex(where: { $0.key == key }) { + recentIntentKeys.remove(at: index) + return true + } + return false + } + + private func prune(now: TimeInterval) { + recentIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } + } + + private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + private static func downloadIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.fragment = nil + return components.string ?? url.absoluteString + } +} + extension CmuxWebView { @discardableResult func startSubframeResponseWebKitDownload( diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 79f43e602403..ef8dd97686fd 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8710,10 +8710,7 @@ func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( } private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { - private static let subframeDownloadIntentLifetime: TimeInterval = 10 - private static let maxSubframeDownloadIntentCount = 64 - - private var recentSubframeDownloadIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] + private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() var didStartProvisionalNavigation: ((WKWebView) -> Void)? var didCommit: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? @@ -8911,7 +8908,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) - updateSubframeDownloadIntentIfNeeded(navigationAction) + subframeDownloadIntents.updateIfNeeded(navigationAction) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -9037,7 +9034,7 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame - || consumeRecentSubframeDownloadIntent(for: navigationResponse.response.url) + || subframeDownloadIntents.consume(for: navigationResponse.response.url) if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, @@ -9060,58 +9057,8 @@ private class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.allow) } - private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { - guard navigationAction.targetFrame?.isMainFrame == false, - let url = navigationAction.request.url, - Self.isHTTPDownloadIntentURL(url), - (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - if navigationAction.navigationType == .linkActivated { recordSubframeDownloadIntent(url); return } - guard let sourceURL = navigationAction.targetFrame?.request.url else { return } - let sourceKey = Self.downloadIntentKey(for: sourceURL) - guard sourceKey != Self.downloadIntentKey(for: url), - recentSubframeDownloadIntentKeys.contains(where: { $0.key == sourceKey }) else { return } - recordSubframeDownloadIntent(url) - } - func recordSubframeDownloadIntent(_ url: URL) { - guard Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - let key = Self.downloadIntentKey(for: url); recentSubframeDownloadIntentKeys.removeAll { $0.key == key } - recentSubframeDownloadIntentKeys.append((key, now)) - if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { - recentSubframeDownloadIntentKeys.removeFirst(recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount) - } - } - - private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { - guard let responseURL, - Self.isHTTPDownloadIntentURL(responseURL) else { return false } - let now = ProcessInfo.processInfo.systemUptime - pruneSubframeDownloadIntents(now: now) - let key = Self.downloadIntentKey(for: responseURL) - if let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) { - recentSubframeDownloadIntentKeys.remove(at: index) - return true - } - return false - } - - private func pruneSubframeDownloadIntents(now: TimeInterval) { - recentSubframeDownloadIntentKeys.removeAll { now - $0.recordedAt > Self.subframeDownloadIntentLifetime } - } - - private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { - let scheme = url.scheme?.lowercased() - return scheme == "http" || scheme == "https" - } - - private static func downloadIntentKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } - components.fragment = nil - return components.string ?? url.absoluteString + subframeDownloadIntents.record(url) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 07fcc04f9275..9885a6f63975 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -603,12 +603,9 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // MARK: - PopupNavigationDelegate private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { - private static let subframeDownloadIntentLifetime: TimeInterval = 10 - private static let maxSubframeDownloadIntentCount = 64 - weak var controller: BrowserPopupWindowController? var downloadDelegate: WKDownloadDelegate? - private var recentSubframeDownloadIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] + private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() func webView( _ webView: WKWebView, @@ -633,7 +630,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { decisionHandler(.cancel) return } - updateSubframeDownloadIntentIfNeeded(navigationAction) + subframeDownloadIntents.updateIfNeeded(navigationAction) // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { @@ -671,7 +668,7 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame - || consumeRecentSubframeDownloadIntent(for: navigationResponse.response.url) + || subframeDownloadIntents.consume(for: navigationResponse.response.url) if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, @@ -703,55 +700,8 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { controller?.handleWebContentProcessTermination(for: webView) } - private func updateSubframeDownloadIntentIfNeeded(_ navigationAction: WKNavigationAction) { - guard navigationAction.targetFrame?.isMainFrame == false, - let url = navigationAction.request.url, - Self.isHTTPDownloadIntentURL(url), - (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - if navigationAction.navigationType == .linkActivated { recordSubframeDownloadIntent(url); return } - guard let sourceURL = navigationAction.targetFrame?.request.url else { return } - let sourceKey = Self.downloadIntentKey(for: sourceURL) - guard sourceKey != Self.downloadIntentKey(for: url), - recentSubframeDownloadIntentKeys.contains(where: { $0.key == sourceKey }) else { return } - recordSubframeDownloadIntent(url) - } func recordSubframeDownloadIntent(_ url: URL) { - guard Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - let key = Self.downloadIntentKey(for: url); recentSubframeDownloadIntentKeys.removeAll { $0.key == key } - recentSubframeDownloadIntentKeys.append((key, now)) - if recentSubframeDownloadIntentKeys.count > Self.maxSubframeDownloadIntentCount { - recentSubframeDownloadIntentKeys.removeFirst(recentSubframeDownloadIntentKeys.count - Self.maxSubframeDownloadIntentCount) - } - } - - private func consumeRecentSubframeDownloadIntent(for responseURL: URL?) -> Bool { - guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } - let now = ProcessInfo.processInfo.systemUptime; pruneSubframeDownloadIntents(now: now) - let key = Self.downloadIntentKey(for: responseURL) - if let index = recentSubframeDownloadIntentKeys.firstIndex(where: { $0.key == key }) { - recentSubframeDownloadIntentKeys.remove(at: index) - return true - } - return false - } - - private func pruneSubframeDownloadIntents(now: TimeInterval) { - recentSubframeDownloadIntentKeys.removeAll { now - $0.recordedAt > Self.subframeDownloadIntentLifetime } - } - - private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { - let scheme = url.scheme?.lowercased() - return scheme == "http" || scheme == "https" - } - - private static func downloadIntentKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } - components.fragment = nil - return components.string ?? url.absoluteString + subframeDownloadIntents.record(url) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { From 8a7020bcf1108f2c6702c383622d999320db3417 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:29:12 -0700 Subject: [PATCH 16/59] Transfer redirected subframe download intents --- .../BrowserDownloadFilenameResolver.swift | 16 +++++++++--- ...BrowserDownloadFilenameResolverTests.swift | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 35a7a54879f1..fd2f946093f3 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -23,10 +23,7 @@ final class BrowserSubframeDownloadIntentTracker { let now = ProcessInfo.processInfo.systemUptime; prune(now: now) if navigationAction.navigationType == .linkActivated { record(url); return } guard let sourceURL = navigationAction.targetFrame?.request.url else { return } - let sourceKey = Self.downloadIntentKey(for: sourceURL) - guard sourceKey != Self.downloadIntentKey(for: url), - recentIntentKeys.contains(where: { $0.key == sourceKey }) else { return } - record(url) + recordRedirectIfNeeded(from: sourceURL, to: url) } func record(_ url: URL) { @@ -39,6 +36,17 @@ final class BrowserSubframeDownloadIntentTracker { } } + func recordRedirectIfNeeded(from sourceURL: URL, to url: URL) { + guard Self.isHTTPDownloadIntentURL(sourceURL), + Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let sourceKey = Self.downloadIntentKey(for: sourceURL) + guard sourceKey != Self.downloadIntentKey(for: url), + let sourceIndex = recentIntentKeys.firstIndex(where: { $0.key == sourceKey }) else { return } + recentIntentKeys.remove(at: sourceIndex) + record(url) + } + func consume(for responseURL: URL?) -> Bool { guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } let now = ProcessInfo.processInfo.systemUptime; prune(now: now) diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index e261a46faa90..d8aa32e01b39 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -86,6 +86,31 @@ import WebKit ) == "cannotShowMIME") } + @Test func subframeDownloadIntentTrackerTransfersRedirectIntent() throws { + let tracker = BrowserSubframeDownloadIntentTracker() + let source = try #require(URL(string: "https://mail.example.test/attachment?id=1#frag")) + let redirected = try #require(URL(string: "https://cdn.example.test/attachment?id=1")) + + tracker.record(source) + tracker.recordRedirectIfNeeded(from: source, to: redirected) + + #expect(tracker.consume(for: redirected)) + #expect(!tracker.consume(for: source)) + } + + @Test func subframeDownloadIntentTrackerFailsClosedForUnrelatedRedirect() throws { + let tracker = BrowserSubframeDownloadIntentTracker() + let source = try #require(URL(string: "https://mail.example.test/attachment?id=1")) + let unrelated = try #require(URL(string: "https://mail.example.test/attachment?id=2")) + let redirected = try #require(URL(string: "https://cdn.example.test/attachment?id=2")) + + tracker.record(source) + tracker.recordRedirectIfNeeded(from: unrelated, to: redirected) + + #expect(!tracker.consume(for: redirected)) + #expect(tracker.consume(for: source)) + } + @Test func uniqueDownloadDestinationDedupesExistingFiles() throws { let fileManager = FileManager.default let directory = fileManager.temporaryDirectory.appendingPathComponent( From b69a81c813c70f26f4d50cfa30f0f7a6abc3eb7d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:35:45 -0700 Subject: [PATCH 17/59] Fix subframe intent callback binding --- Sources/Panels/BrowserPanel.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ef8dd97686fd..20d00829856e 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3753,7 +3753,9 @@ final class BrowserPanel: Panel, ObservableObject { guard let navigationDelegate else { return } let boundWebViewInstanceID = webViewInstanceID let boundHistoryStore = historyStore - webView.onSubframeDownloadIntent = { [weak navigationDelegate] in navigationDelegate?.recordSubframeDownloadIntent($0) } + (webView as? CmuxWebView)?.onSubframeDownloadIntent = { [weak navigationDelegate] in + navigationDelegate?.recordSubframeDownloadIntent($0) + } navigationDelegate.didStartProvisionalNavigation = { [weak self] webView in MainActor.assumeIsolated { From 4a57d5edd712f4ad73ed4475dd7d33c6f58aeb27 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 24 Jun 2026 18:43:13 -0700 Subject: [PATCH 18/59] Fix prompted download activity accounting --- Sources/Panels/BrowserPanel.swift | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 20d00829856e..bca3dbae52fc 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4019,9 +4019,9 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } - dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL in + dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity in guard let self else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4036,9 +4036,9 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } - dlDelegate.onDownloadCancelled = { [weak self] filename in + dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity in guard let self else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4052,9 +4052,9 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } - dlDelegate.onDownloadFailed = { [weak self] error in + dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity in guard let self else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -8299,9 +8299,9 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private let activeDownloadsLock = NSLock() var onDownloadStarted: ((String) -> Void)? var onDownloadReadyToSave: ((String) -> Void)? - var onDownloadSaved: ((String, URL) -> Void)? - var onDownloadCancelled: ((String) -> Void)? - var onDownloadFailed: ((Error) -> Void)? + var onDownloadSaved: ((String, URL, Bool) -> Void)? + var onDownloadCancelled: ((String, Bool) -> Void)? + var onDownloadFailed: ((Error, Bool) -> Void)? var savePanelParentWindow: (() -> NSWindow?)? private static let tempDir: URL = { @@ -8390,7 +8390,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { guard let self else { return } guard result == .OK, let destURL = savePanel.url else { try? FileManager.default.removeItem(at: tempURL) - self.onDownloadCancelled?(suggestedFilename) + self.onDownloadCancelled?(suggestedFilename, false) return } do { @@ -8399,10 +8399,10 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } else { try FileManager.default.moveItem(at: tempURL, to: destURL) } - self.onDownloadSaved?(suggestedFilename, destURL) + self.onDownloadSaved?(suggestedFilename, destURL, false) } catch { try? FileManager.default.removeItem(at: tempURL) - self.onDownloadFailed?(error) + self.onDownloadFailed?(error, false) } } if let parentWindow = savePanelParentWindow?() { @@ -8478,13 +8478,13 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { }.value switch saveResult { case .success(let destinationURL): - self.onDownloadSaved?(suggestedFilename, destinationURL) + self.onDownloadSaved?(suggestedFilename, destinationURL, true) #if DEBUG cmuxDebugLog("download.saved path=") #endif case .failure(let error): try? FileManager.default.removeItem(at: info.tempURL) - self.onDownloadFailed?(error) + self.onDownloadFailed?(error, true) } } } @@ -8494,7 +8494,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { try? FileManager.default.removeItem(at: info.tempURL) } notifyOnMain { [weak self] in - self?.onDownloadFailed?(error) + self?.onDownloadFailed?(error, true) } #if DEBUG cmuxDebugLog("download.failed error=\(error.localizedDescription)") From 951bac4e9c31affeed3c69a62456070644c4caf7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 21:24:14 -0700 Subject: [PATCH 19/59] Fix download helper isolation warning --- Sources/Panels/BrowserPanel.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 3fffdc3f6aaf..16ccee23ef40 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8290,7 +8290,7 @@ private extension NSObject { /// during WebKit callbacks), then moving the finished file to the user's /// Downloads folder unless the browser save-panel setting is enabled. class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { - private static let maxDownloadDestinationCollisionRetries = 100 + private nonisolated static let maxDownloadDestinationCollisionRetries = 100 private struct DownloadState: Sendable { let tempURL: URL From 9e47a65e575f621668c96a1abc896ce7a628afab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 21:47:13 -0700 Subject: [PATCH 20/59] Harden browser download saves --- .github/swift-file-length-budget.tsv | 5 +- .../BrowserDownloadFilenameResolver.swift | 42 +++++++++++ Sources/Panels/BrowserPanel.swift | 6 ++ .../CmuxWebView+ScriptedDownloads.swift | 64 ++++++++++++++--- Sources/Panels/CmuxWebView.swift | 72 +++++++++++++++++-- ...BrowserDownloadFilenameResolverTests.swift | 42 +++++++++++ 6 files changed, 214 insertions(+), 17 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 78affa5d0a53..10f0bcdeac13 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12838 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11699 Sources/Panels/BrowserPanel.swift +11705 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -35,7 +35,7 @@ 3058 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift -2693 Sources/Panels/CmuxWebView.swift +2751 Sources/Panels/CmuxWebView.swift 2606 Sources/KeyboardShortcutSettings.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2524 cmuxTests/CommandPaletteSearchEngineTests.swift @@ -193,6 +193,7 @@ 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift +543 Sources/Panels/CmuxWebView+ScriptedDownloads.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index fd2f946093f3..120b5be902ce 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -1,4 +1,5 @@ import Foundation +import CoreServices import ImageIO import CmuxSettings import UniformTypeIdentifiers @@ -355,3 +356,44 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { return "img" } } + +extension URL { + func cmuxApplyWebDownloadQuarantine(sourceURL: URL?) throws { + guard let sourceURL, + !sourceURL.isFileURL else { + return + } + + var quarantineProperties: [String: Any] = [ + kLSQuarantineTypeKey as String: kLSQuarantineTypeWebDownload as String, + kLSQuarantineTimeStampKey as String: Date(), + kLSQuarantineAgentNameKey as String: Self.cmuxDownloadQuarantineAgentName(), + ] + if let bundleIdentifier = Bundle.main.bundleIdentifier, + !bundleIdentifier.isEmpty { + quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] = bundleIdentifier + } + if Self.cmuxCanStoreDownloadSourceURL(sourceURL) { + quarantineProperties[kLSQuarantineDataURLKey as String] = sourceURL + quarantineProperties[kLSQuarantineOriginURLKey as String] = sourceURL + } + + var resourceValues = URLResourceValues() + resourceValues.quarantineProperties = quarantineProperties + var fileURL = self + try fileURL.setResourceValues(resourceValues) + } + + private static func cmuxDownloadQuarantineAgentName() -> String { + let candidate = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? "cmux" + let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? "cmux" : trimmed + } + + private static func cmuxCanStoreDownloadSourceURL(_ sourceURL: URL) -> Bool { + let scheme = sourceURL.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 16ccee23ef40..b419be49a7c0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8355,11 +8355,13 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private nonisolated static func moveTemporaryDownloadToDownloads( tempURL: URL, suggestedFilename: String, + sourceURL: URL, filenameResolver: BrowserDownloadFilenameResolver, fileManager: FileManager = .default ) throws -> URL { let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) var lastCollisionError: Error? for _ in 0.. {}; const handledAnchors = typeof WeakSet === "function" ? new WeakSet() : null; try { @@ -59,15 +60,26 @@ extension CmuxWebView { const noteTrustedActivation = (event) => { try { - if (event && event.isTrusted) lastTrustedActivationMs = Date.now(); + if (event && event.isTrusted) { + lastTrustedActivationMs = Date.now(); + armSubframeDownloadObserver(); + } } catch (_) {} }; + const hasRecentTrustedActivation = () => { + try { + return Date.now() - lastTrustedActivationMs <= trustedActivationWindowMs; + } catch (_) { + return false; + } + }; + const hasUserActivation = (event) => { try { if (event && event.isTrusted) return true; if (navigator.userActivation && navigator.userActivation.isActive) return true; - return Date.now() - lastTrustedActivationMs <= trustedActivationWindowMs; + return hasRecentTrustedActivation(); } catch (_) { return false; } @@ -215,10 +227,6 @@ extension CmuxWebView { return ""; }; - ["pointerdown", "mousedown", "keydown", "click"].forEach((eventName) => { - document.addEventListener(eventName, noteTrustedActivation, true); - }); - const interceptAnchorDownload = (anchor, event) => { try { if (!hasUserActivation(event)) return false; @@ -256,8 +264,31 @@ extension CmuxWebView { }, true); if (!isMainFrame && typeof MutationObserver === "function") { + let observerDisconnectTimer = 0; + let observer = null; + const disconnectObserver = () => { + try { + if (observerDisconnectTimer) { + clearTimeout(observerDisconnectTimer); + observerDisconnectTimer = 0; + } + observer?.disconnect(); + } catch (_) {} + }; + const scheduleObserverDisconnect = () => { + try { + if (observerDisconnectTimer) clearTimeout(observerDisconnectTimer); + const remaining = trustedActivationWindowMs - (Date.now() - lastTrustedActivationMs); + if (remaining <= 0) { + disconnectObserver(); + return; + } + observerDisconnectTimer = setTimeout(disconnectObserver, remaining + 50); + } catch (_) {} + }; const inspectAddedNode = (node) => { try { + if (!hasRecentTrustedActivation()) return; if (!node || node.nodeType !== 1) return; const candidates = []; const tag = String(node.tagName || "").toUpperCase(); @@ -267,13 +298,18 @@ extension CmuxWebView { for (const anchor of candidates) { if (interceptAnchorDownload(anchor, null)) { handledAnchors?.add(anchor); + disconnectObserver(); return; } } } catch (_) {} }; - const observer = new MutationObserver((mutations) => { + observer = new MutationObserver((mutations) => { try { + if (!hasRecentTrustedActivation()) { + disconnectObserver(); + return; + } for (const mutation of mutations) { for (const node of mutation.addedNodes || []) { inspectAddedNode(node); @@ -281,9 +317,21 @@ extension CmuxWebView { } } catch (_) {} }); - observer.observe(document.documentElement || document, { childList: true, subtree: true }); + armSubframeDownloadObserver = () => { + try { + if (!hasRecentTrustedActivation()) return; + const root = document.documentElement || document; + if (!root) return; + observer.observe(root, { childList: true, subtree: true }); + scheduleObserverDisconnect(); + } catch (_) {} + }; } + ["pointerdown", "mousedown", "keydown", "click"].forEach((eventName) => { + document.addEventListener(eventName, noteTrustedActivation, true); + }); + const anchorPrototype = window.HTMLAnchorElement?.prototype ?? null; const originalAnchorClick = anchorPrototype?.click ?? null; if (isMainFrame && typeof originalAnchorClick === "function") { diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 9bd49b155d82..55b462de7f2a 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1622,6 +1622,7 @@ final class CmuxWebView: WKWebView { private func finishSessionDownload( data: Data, saveName: String, + sourceURL: URL?, traceID: String, logCategory: String, sender: Any?, @@ -1673,6 +1674,7 @@ final class CmuxWebView: WKWebView { self.writeSessionDownloadDataInBackground( data, destinationURL: destURL, + sourceURL: sourceURL, replaceExisting: true, completion: completeWrite ) @@ -1688,6 +1690,7 @@ final class CmuxWebView: WKWebView { autoSaveSessionDownloadDataInBackground( data, saveName: saveName, + sourceURL: sourceURL, filenameResolver: filenameResolver, traceID: traceID, logCategory: logCategory, @@ -1698,12 +1701,18 @@ final class CmuxWebView: WKWebView { private func writeSessionDownloadDataInBackground( _ data: Data, destinationURL: URL, + sourceURL: URL?, replaceExisting: Bool, completion: @escaping (Result) -> Void ) { Task { @MainActor in let result = await Task.detached(priority: .utility) { - Self.writeSessionDownloadData(data, to: destinationURL, replaceExisting: replaceExisting) + Self.writeSessionDownloadData( + data, + to: destinationURL, + sourceURL: sourceURL, + replaceExisting: replaceExisting + ) }.value completion(result) } @@ -1712,6 +1721,7 @@ final class CmuxWebView: WKWebView { private func autoSaveSessionDownloadDataInBackground( _ data: Data, saveName: String, + sourceURL: URL?, filenameResolver: BrowserDownloadFilenameResolver, traceID: String, logCategory: String, @@ -1722,6 +1732,7 @@ final class CmuxWebView: WKWebView { Self.autoSaveSessionDownloadData( data, saveName: saveName, + sourceURL: sourceURL, filenameResolver: filenameResolver ) }.value @@ -1737,6 +1748,7 @@ final class CmuxWebView: WKWebView { private nonisolated static func autoSaveSessionDownloadData( _ data: Data, saveName: String, + sourceURL: URL?, filenameResolver: BrowserDownloadFilenameResolver ) -> Result { Result { @@ -1751,7 +1763,12 @@ final class CmuxWebView: WKWebView { fileManager: fileManager ) do { - try writeSessionDownloadDataWithoutReplacing(data, to: destinationURL, fileManager: fileManager) + try writeSessionDownloadDataWithoutReplacing( + data, + to: destinationURL, + sourceURL: sourceURL, + fileManager: fileManager + ) return destinationURL } catch { guard fileManager.fileExists(atPath: destinationURL.path) else { @@ -1767,28 +1784,60 @@ final class CmuxWebView: WKWebView { private nonisolated static func writeSessionDownloadData( _ data: Data, to destinationURL: URL, + sourceURL: URL?, replaceExisting: Bool ) -> Result { Result { if replaceExisting { - try data.write(to: destinationURL, options: .atomic) + try writeSessionDownloadDataReplacing( + data, + to: destinationURL, + sourceURL: sourceURL, + fileManager: .default + ) } else { - try writeSessionDownloadDataWithoutReplacing(data, to: destinationURL, fileManager: .default) + try writeSessionDownloadDataWithoutReplacing( + data, + to: destinationURL, + sourceURL: sourceURL, + fileManager: .default + ) } return destinationURL } } + private nonisolated static func writeSessionDownloadDataReplacing( + _ data: Data, + to destinationURL: URL, + sourceURL: URL?, + fileManager: FileManager + ) throws { + let tempURL = temporarySessionDownloadURL(for: destinationURL) + do { + try data.write(to: tempURL, options: .atomic) + try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) + if fileManager.fileExists(atPath: destinationURL.path) { + _ = try fileManager.replaceItemAt(destinationURL, withItemAt: tempURL) + } else { + try fileManager.moveItem(at: tempURL, to: destinationURL) + } + } catch { + try? fileManager.removeItem(at: tempURL) + throw error + } + } + private nonisolated static func writeSessionDownloadDataWithoutReplacing( _ data: Data, to destinationURL: URL, + sourceURL: URL?, fileManager: FileManager ) throws { - let tempURL = destinationURL - .deletingLastPathComponent() - .appendingPathComponent(".cmux-\(UUID().uuidString).download", isDirectory: false) + let tempURL = temporarySessionDownloadURL(for: destinationURL) do { try data.write(to: tempURL, options: .atomic) + try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) try fileManager.moveItem(at: tempURL, to: destinationURL) } catch { try? fileManager.removeItem(at: tempURL) @@ -1796,6 +1845,12 @@ final class CmuxWebView: WKWebView { } } + private nonisolated static func temporarySessionDownloadURL(for destinationURL: URL) -> URL { + destinationURL + .deletingLastPathComponent() + .appendingPathComponent(".cmux-\(UUID().uuidString).download", isDirectory: false) + } + func downloadURLViaSession( _ url: URL, suggestedFilename: String?, @@ -1852,6 +1907,7 @@ final class CmuxWebView: WKWebView { self.finishSessionDownload( data: parsed.data, saveName: saveName, + sourceURL: url, traceID: traceID, logCategory: "data", sender: sender, @@ -1875,6 +1931,7 @@ final class CmuxWebView: WKWebView { self.finishSessionDownload( data: data, saveName: saveName, + sourceURL: url, traceID: traceID, logCategory: "file", sender: sender, @@ -1951,6 +2008,7 @@ final class CmuxWebView: WKWebView { self.finishSessionDownload( data: data, saveName: saveName, + sourceURL: url, traceID: traceID, logCategory: "response", sender: sender, diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index d8aa32e01b39..5565d811c101 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -1,4 +1,5 @@ import Foundation +import CoreServices import Testing import UniformTypeIdentifiers import WebKit @@ -160,6 +161,47 @@ import WebKit #expect(destination.pathExtension == "pdf") } + @Test func webDownloadQuarantineMetadataMarksRemoteDownloads() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-download-quarantine-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("report.csv", isDirectory: false) + try Data("download".utf8).write(to: fileURL) + let sourceURL = try #require(URL(string: "https://example.test/report.csv")) + + try fileURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) + + let properties = try #require( + fileURL.resourceValues(forKeys: [.quarantinePropertiesKey]).quarantineProperties + ) + #expect(properties[kLSQuarantineTypeKey as String] as? String == kLSQuarantineTypeWebDownload as String) + #expect(properties[kLSQuarantineAgentNameKey as String] as? String != nil) + #expect(properties[kLSQuarantineTimeStampKey as String] is Date) + } + + @Test func webDownloadQuarantineMetadataSkipsLocalFileSources() throws { + let fileManager = FileManager.default + let directory = fileManager.temporaryDirectory.appendingPathComponent( + "cmux-download-quarantine-local-\(UUID().uuidString)", + isDirectory: true + ) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + defer { try? fileManager.removeItem(at: directory) } + + let fileURL = directory.appendingPathComponent("local-copy.txt", isDirectory: false) + try Data("download".utf8).write(to: fileURL) + + try fileURL.cmuxApplyWebDownloadQuarantine(sourceURL: URL(fileURLWithPath: "/tmp/source.txt")) + + let properties = try fileURL.resourceValues(forKeys: [.quarantinePropertiesKey]).quarantineProperties + #expect(properties == nil) + } + @MainActor @Test func scriptedDownloadInterceptionRunsInSubframes() throws { let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) From bdea1212c9d0134fd7ecee718460e5ab4bbfde75 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 21:58:25 -0700 Subject: [PATCH 21/59] Address download autoreview findings --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/BrowserNavigationDelegate.swift | 10 +++++----- Sources/Panels/BrowserPanel.swift | 7 +++---- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 10f0bcdeac13..c10c6570f220 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12838 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11705 Sources/Panels/BrowserPanel.swift +11704 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 00e188e3aa6f..b6bd2e1f53a5 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -330,7 +330,6 @@ import WebKit ) { let mime = navigationResponse.response.mimeType ?? "unknown" let canShow = navigationResponse.canShowMIMEType - let responseURL = navigationResponse.response.url?.absoluteString ?? "nil" // Only classify HTTP(S) responses as downloads. Subframes are eligible // only for explicit attachment/force-download MIME decisions; the @@ -341,9 +340,11 @@ import WebKit return } - NSLog("BrowserPanel navigationResponse: url=%@ mime=%@ canShow=%d isMainFrame=%d", - responseURL, mime, canShow ? 1 : 0, - navigationResponse.isForMainFrame ? 1 : 0) + #if DEBUG + cmuxDebugLog( + "browser.nav.response mime=\(mime) canShow=\(canShow ? 1 : 0) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)" + ) + #endif let contentDisposition = (navigationResponse.response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Content-Disposition") @@ -356,7 +357,6 @@ import WebKit isForMainFrame: navigationResponse.isForMainFrame, allowsSubframeDownload: allowsSubframeDownload ) { - NSLog("BrowserPanel download: %@ mime=%@ url=%@", reason, mime, responseURL) #if DEBUG cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b419be49a7c0..dee93cd065b0 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8395,10 +8395,9 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { savePanel.canCreateDirectories = true savePanel.directoryURL = filenameResolver.downloadsDirectory() let completion: (NSApplication.ModalResponse) -> Void = { [weak self] result in - guard let self else { return } guard result == .OK, let destURL = savePanel.url else { try? FileManager.default.removeItem(at: tempURL) - self.onDownloadCancelled?(suggestedFilename, false) + self?.onDownloadCancelled?(suggestedFilename, false) return } do { @@ -8408,10 +8407,10 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } else { try FileManager.default.moveItem(at: tempURL, to: destURL) } - self.onDownloadSaved?(suggestedFilename, destURL, false) + self?.onDownloadSaved?(suggestedFilename, destURL, false) } catch { try? FileManager.default.removeItem(at: tempURL) - self.onDownloadFailed?(error, false) + self?.onDownloadFailed?(error, false) } } if let parentWindow = savePanelParentWindow?() { From 3f9a58de5d7f5e33d039822cc36c49a98e177e42 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 22:13:35 -0700 Subject: [PATCH 22/59] Fix context download save panel activity --- Sources/Panels/CmuxWebView.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 55b462de7f2a..cce426d71ad5 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1631,9 +1631,9 @@ final class CmuxWebView: WKWebView { failureFallbackReason: String? ) { let filenameResolver = BrowserDownloadFilenameResolver() - let completeWrite: (Result) -> Void = { [weak self] result in + let handleWriteResult: (Result, Bool) -> Void = { [weak self] result, shouldClearDownloadState in guard let self else { return } - self.notifyContextMenuDownloadState(false) + if shouldClearDownloadState { self.notifyContextMenuDownloadState(false) } switch result { case .success: self.debugContextDownload( @@ -1660,6 +1660,7 @@ final class CmuxWebView: WKWebView { savePanel.nameFieldStringValue = saveName savePanel.canCreateDirectories = true savePanel.directoryURL = filenameResolver.downloadsDirectory() + notifyContextMenuDownloadState(false) debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=" ) @@ -1668,7 +1669,6 @@ final class CmuxWebView: WKWebView { self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel" ) - self.notifyContextMenuDownloadState(false) return } self.writeSessionDownloadDataInBackground( @@ -1676,7 +1676,7 @@ final class CmuxWebView: WKWebView { destinationURL: destURL, sourceURL: sourceURL, replaceExisting: true, - completion: completeWrite + completion: { result in handleWriteResult(result, false) } ) } if let parentWindow = window { @@ -1694,7 +1694,7 @@ final class CmuxWebView: WKWebView { filenameResolver: filenameResolver, traceID: traceID, logCategory: logCategory, - completion: completeWrite + completion: { result in handleWriteResult(result, true) } ) } From 4f58615ad0790df5a01a3e1d0f9fdc765e1fafd2 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 22:30:17 -0700 Subject: [PATCH 23/59] Use response downloads for subframes --- .github/swift-file-length-budget.tsv | 4 +-- .../BrowserDownloadFilenameResolver.swift | 25 ------------------- .../Panels/BrowserNavigationDelegate.swift | 4 --- Sources/Panels/BrowserPanel.swift | 9 ++++--- .../Panels/BrowserPopupWindowController.swift | 4 --- 5 files changed, 8 insertions(+), 38 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index c10c6570f220..1498b833f5e5 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12838 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11704 Sources/Panels/BrowserPanel.swift +11707 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -125,9 +125,9 @@ 755 CLI/CMUXCLI+AgentHookDefinitions.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift -739 Sources/Panels/BrowserPopupWindowController.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +735 Sources/Panels/BrowserPopupWindowController.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 120b5be902ce..a8414674248c 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -77,31 +77,6 @@ final class BrowserSubframeDownloadIntentTracker { } } -extension CmuxWebView { - @discardableResult - func startSubframeResponseWebKitDownload( - navigationResponse: WKNavigationResponse, - reason: String - ) -> Bool { - guard let url = navigationResponse.response.url, - ["http", "https"].contains(url.scheme?.lowercased() ?? ""), - let downloadDelegate = cmuxDownloadDelegate else { - return false - } - let traceID = Self.makeContextDownloadTraceID(prefix: "subframe") -#if DEBUG - debugContextDownload("download.subframeWebKit trace=\(traceID) reason=\(reason) host=\(url.host ?? "nil")") -#endif - startDownload(using: URLRequest(url: url)) { download in - if let browserDownloadDelegate = downloadDelegate as? BrowserDownloadDelegate { - browserDownloadDelegate.setSuggestedFilenameOverride(navigationResponse.response.suggestedFilename, for: download) - } - download.delegate = downloadDelegate - } - return true - } -} - nonisolated struct BrowserDownloadFilenameResolver: Sendable { private static let maxFilenameCollisionAttempts = 100 diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index b6bd2e1f53a5..d05acd241aa6 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -360,10 +360,6 @@ import WebKit #if DEBUG cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif - if !navigationResponse.isForMainFrame, - (webView as? CmuxWebView)?.startSubframeResponseWebKitDownload(navigationResponse: navigationResponse, reason: reason) == true { - decisionHandler(.cancel); return - } decisionHandler(.download) return } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dee93cd065b0..795d79e7a2a6 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4030,7 +4030,8 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity in guard let self else { return } - if shouldEndActivity { self.endDownloadActivity() } + guard shouldEndActivity else { return } + self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4047,7 +4048,8 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity in guard let self else { return } - if shouldEndActivity { self.endDownloadActivity() } + guard shouldEndActivity else { return } + self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4063,7 +4065,8 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity in guard let self else { return } - if shouldEndActivity { self.endDownloadActivity() } + guard shouldEndActivity else { return } + self.endDownloadActivity() NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 35112a287cb7..630769018148 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -681,10 +681,6 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { isForMainFrame: navigationResponse.isForMainFrame, allowsSubframeDownload: allowsSubframeDownload ) { - if !navigationResponse.isForMainFrame, - (webView as? CmuxWebView)?.startSubframeResponseWebKitDownload(navigationResponse: navigationResponse, reason: reason) == true { - decisionHandler(.cancel); return - } decisionHandler(.download) return } From 3eb085653fab391e9aaa70a0abb7c670b7050640 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 22:50:42 -0700 Subject: [PATCH 24/59] Restrict scripted downloads to main frame --- Sources/Panels/CmuxWebView+ScriptedDownloads.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index c55ffaf16d86..df228a3b95a5 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -379,7 +379,7 @@ extension CmuxWebView { ) } - fileprivate func handleScriptedDownloadMessage(_ body: [String: Any]) { + fileprivate func handleScriptedDownloadMessage(_ body: [String: Any], isMainFrame: Bool) { let expectedToken = objc_getAssociatedObject( configuration.userContentController, &Self.scriptedDownloadTokenKey @@ -392,7 +392,7 @@ extension CmuxWebView { #endif return } - guard let kind = body["kind"] as? String else { return } + guard let kind = body["kind"] as? String, isMainFrame || kind == "subframeDownloadIntent" else { return } let suggestedFilename = body["suggestedFilename"] as? String let urlString: String? switch kind { @@ -537,7 +537,7 @@ private final class ScriptedDownloadMessageHandler: NSObject, WKScriptMessageHan return } MainActor.assumeIsolated { - webView.handleScriptedDownloadMessage(body) + webView.handleScriptedDownloadMessage(body, isMainFrame: message.frameInfo.isMainFrame) } } } From cd14b8c087bc775d29437d8df5bff7b3001e972d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 23:03:46 -0700 Subject: [PATCH 25/59] Validate scripted download frame origin --- Sources/Panels/BrowserPopupWindowController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 630769018148..d0ded5254b94 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -674,13 +674,13 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) - if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( + if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, contentDisposition: contentDisposition, isForMainFrame: navigationResponse.isForMainFrame, allowsSubframeDownload: allowsSubframeDownload - ) { + ) != nil { decisionHandler(.download) return } From de1fd4f1ce9dd2c12863ab1d08f4ea73e93511cd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Thu, 25 Jun 2026 23:23:58 -0700 Subject: [PATCH 26/59] Avoid intercepting subframe scripted downloads --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/CmuxWebView+ScriptedDownloads.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 1498b833f5e5..5e641ae2bd8d 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -193,7 +193,7 @@ 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift -543 Sources/Panels/CmuxWebView+ScriptedDownloads.swift +544 Sources/Panels/CmuxWebView+ScriptedDownloads.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index df228a3b95a5..2f87cfcfa6f9 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -236,6 +236,7 @@ extension CmuxWebView { const scheme = href.split(":", 1)[0].toLowerCase(); const suggestedFilename = suggestedFilenameForAnchor(anchor); + if (!isMainFrame && (scheme === "blob" || scheme === "data")) return false; if (scheme === "blob") { if (!reserveDownloadPost()) return false; return postBlobURLDownload(href, suggestedFilename); From 91835cb5f4068b885c501b9b1aede601ef022d68 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 00:03:54 -0700 Subject: [PATCH 27/59] Split browser download helper types --- .../BrowserDownloadFilenameResolver.swift | 73 ------------------- .../BrowserDownloadHTTPStatusDecision.swift | 4 + ...BrowserSubframeDownloadIntentTracker.swift | 69 ++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 8 ++ 4 files changed, 81 insertions(+), 73 deletions(-) create mode 100644 Sources/Panels/BrowserDownloadHTTPStatusDecision.swift create mode 100644 Sources/Panels/BrowserSubframeDownloadIntentTracker.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index a8414674248c..318623d8f8ba 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -3,79 +3,6 @@ import CoreServices import ImageIO import CmuxSettings import UniformTypeIdentifiers -import WebKit - -nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { - case allow - case reject(statusCode: Int) -} - -final class BrowserSubframeDownloadIntentTracker { - private static let intentLifetime: TimeInterval = 10 - private static let maxIntentCount = 64 - - private var recentIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] - - func updateIfNeeded(_ navigationAction: WKNavigationAction) { - guard navigationAction.targetFrame?.isMainFrame == false, - let url = navigationAction.request.url, - Self.isHTTPDownloadIntentURL(url), - (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - let now = ProcessInfo.processInfo.systemUptime; prune(now: now) - if navigationAction.navigationType == .linkActivated { record(url); return } - guard let sourceURL = navigationAction.targetFrame?.request.url else { return } - recordRedirectIfNeeded(from: sourceURL, to: url) - } - - func record(_ url: URL) { - guard Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime; prune(now: now) - let key = Self.downloadIntentKey(for: url); recentIntentKeys.removeAll { $0.key == key } - recentIntentKeys.append((key, now)) - if recentIntentKeys.count > Self.maxIntentCount { - recentIntentKeys.removeFirst(recentIntentKeys.count - Self.maxIntentCount) - } - } - - func recordRedirectIfNeeded(from sourceURL: URL, to url: URL) { - guard Self.isHTTPDownloadIntentURL(sourceURL), - Self.isHTTPDownloadIntentURL(url) else { return } - let now = ProcessInfo.processInfo.systemUptime; prune(now: now) - let sourceKey = Self.downloadIntentKey(for: sourceURL) - guard sourceKey != Self.downloadIntentKey(for: url), - let sourceIndex = recentIntentKeys.firstIndex(where: { $0.key == sourceKey }) else { return } - recentIntentKeys.remove(at: sourceIndex) - record(url) - } - - func consume(for responseURL: URL?) -> Bool { - guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } - let now = ProcessInfo.processInfo.systemUptime; prune(now: now) - let key = Self.downloadIntentKey(for: responseURL) - if let index = recentIntentKeys.firstIndex(where: { $0.key == key }) { - recentIntentKeys.remove(at: index) - return true - } - return false - } - - private func prune(now: TimeInterval) { - recentIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } - } - - private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { - let scheme = url.scheme?.lowercased() - return scheme == "http" || scheme == "https" - } - - private static func downloadIntentKey(for url: URL) -> String { - guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return url.absoluteString - } - components.fragment = nil - return components.string ?? url.absoluteString - } -} nonisolated struct BrowserDownloadFilenameResolver: Sendable { private static let maxFilenameCollisionAttempts = 100 diff --git a/Sources/Panels/BrowserDownloadHTTPStatusDecision.swift b/Sources/Panels/BrowserDownloadHTTPStatusDecision.swift new file mode 100644 index 000000000000..eba7238920d3 --- /dev/null +++ b/Sources/Panels/BrowserDownloadHTTPStatusDecision.swift @@ -0,0 +1,4 @@ +nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { + case allow + case reject(statusCode: Int) +} diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift new file mode 100644 index 000000000000..33c8a89e3f49 --- /dev/null +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -0,0 +1,69 @@ +import Foundation +import WebKit + +final class BrowserSubframeDownloadIntentTracker { + private static let intentLifetime: TimeInterval = 10 + private static let maxIntentCount = 64 + + private var recentIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] + + func updateIfNeeded(_ navigationAction: WKNavigationAction) { + guard navigationAction.targetFrame?.isMainFrame == false, + let url = navigationAction.request.url, + Self.isHTTPDownloadIntentURL(url), + (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + if navigationAction.navigationType == .linkActivated { record(url); return } + guard let sourceURL = navigationAction.targetFrame?.request.url else { return } + recordRedirectIfNeeded(from: sourceURL, to: url) + } + + func record(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.downloadIntentKey(for: url); recentIntentKeys.removeAll { $0.key == key } + recentIntentKeys.append((key, now)) + if recentIntentKeys.count > Self.maxIntentCount { + recentIntentKeys.removeFirst(recentIntentKeys.count - Self.maxIntentCount) + } + } + + func recordRedirectIfNeeded(from sourceURL: URL, to url: URL) { + guard Self.isHTTPDownloadIntentURL(sourceURL), + Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let sourceKey = Self.downloadIntentKey(for: sourceURL) + guard sourceKey != Self.downloadIntentKey(for: url), + let sourceIndex = recentIntentKeys.firstIndex(where: { $0.key == sourceKey }) else { return } + recentIntentKeys.remove(at: sourceIndex) + record(url) + } + + func consume(for responseURL: URL?) -> Bool { + guard let responseURL, Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.downloadIntentKey(for: responseURL) + if let index = recentIntentKeys.firstIndex(where: { $0.key == key }) { + recentIntentKeys.remove(at: index) + return true + } + return false + } + + private func prune(now: TimeInterval) { + recentIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } + } + + private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { + let scheme = url.scheme?.lowercased() + return scheme == "http" || scheme == "https" + } + + private static func downloadIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.fragment = nil + return components.string ?? url.absoluteString + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8da4291ed351..b92748eec7e1 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -106,6 +106,7 @@ 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 */; }; + C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */; }; A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; }; A5008373 /* BrowserFindWebViewEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindWebViewEvaluator.swift */; }; 7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */; }; @@ -149,6 +150,7 @@ 4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */; }; 4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */; }; A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; + C67540010000000000000001 /* BrowserSubframeDownloadIntentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */; }; 4BBF42E8A86EAAB94BC9EA16 /* BrowserSystemProxyMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048DACB3F8147BCBDD266297 /* BrowserSystemProxyMirror.swift */; }; C7B800062D4202A13C962D2D /* BrowserSystemProxyMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D828DA0070335773EBAE83F /* BrowserSystemProxyMirrorTests.swift */; }; 0796994CA7CCA25DB990BFEF /* BrowserSystemProxyWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 326E7A5E13C6DF650B9F8971 /* BrowserSystemProxyWatcher.swift */; }; @@ -1277,6 +1279,7 @@ 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 = ""; }; + C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadHTTPStatusDecision.swift; sourceTree = ""; }; A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = ""; }; A5008372 /* BrowserFindWebViewEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindWebViewEvaluator.swift; sourceTree = ""; }; 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFixtureInteractionUITests.swift; sourceTree = ""; }; @@ -1320,6 +1323,7 @@ 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotPipeline.swift; sourceTree = ""; }; 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotSnapshotter.swift; sourceTree = ""; }; A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = ""; }; + C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSubframeDownloadIntentTracker.swift; sourceTree = ""; }; 048DACB3F8147BCBDD266297 /* BrowserSystemProxyMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSystemProxyMirror.swift; sourceTree = ""; }; 8D828DA0070335773EBAE83F /* BrowserSystemProxyMirrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserSystemProxyMirrorTests.swift; sourceTree = ""; }; 326E7A5E13C6DF650B9F8971 /* BrowserSystemProxyWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSystemProxyWatcher.swift; sourceTree = ""; }; @@ -2829,6 +2833,8 @@ A5001412 /* BrowserPanel.swift */, C62530010000000000000002 /* BrowserPanelReloadMode.swift */, C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */, + C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */, + C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */, C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */, B3770BA00000000000000002 /* BrowserAutomation.swift */, 4472B0014472B0014472B001 /* BrowserScreenshot.swift */, @@ -3932,6 +3938,7 @@ B3770BA00000000000000001 /* BrowserAutomation.swift in Sources */, BCBC0A0E0000000000000C01 /* BrowserChromeMetrics.swift in Sources */, C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */, + C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */, A5008373 /* BrowserFindWebViewEvaluator.swift in Sources */, B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */, B42450010000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift in Sources */, @@ -3958,6 +3965,7 @@ 4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */, 4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */, A5008371 /* BrowserSearchOverlay.swift in Sources */, + C67540010000000000000001 /* BrowserSubframeDownloadIntentTracker.swift in Sources */, 4BBF42E8A86EAAB94BC9EA16 /* BrowserSystemProxyMirror.swift in Sources */, 0796994CA7CCA25DB990BFEF /* BrowserSystemProxyWatcher.swift in Sources */, A50100000000000000000002 /* BrowserWebAuthnAssertionExtensions.swift in Sources */, From eb22a3fb0889f7a2d08aed72999ec5f764ce70f8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 00:21:40 -0700 Subject: [PATCH 28/59] Limit subframe download intents to trusted clicks --- Sources/Panels/BrowserSubframeDownloadIntentTracker.swift | 3 +-- Sources/Panels/CmuxWebView+ScriptedDownloads.swift | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index 33c8a89e3f49..33e10a84116b 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -12,8 +12,7 @@ final class BrowserSubframeDownloadIntentTracker { let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - let now = ProcessInfo.processInfo.systemUptime; prune(now: now) - if navigationAction.navigationType == .linkActivated { record(url); return } + guard navigationAction.navigationType != .linkActivated else { return } guard let sourceURL = navigationAction.targetFrame?.request.url else { return } recordRedirectIfNeeded(from: sourceURL, to: url) } diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 2f87cfcfa6f9..e3c81a543227 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -116,10 +116,10 @@ extension CmuxWebView { }; const postSubframeDownloadIntent = (anchor, event) => { try { - if (!hasUserActivation(event) || !anchor) return; + if (isMainFrame || !anchor || !event || !event.isTrusted) return; const href = String(anchor.href || anchor.getAttribute("href") || ""); const scheme = href.split(":", 1)[0].toLowerCase(); - if (scheme === "http" || scheme === "https") { + if ((scheme === "http" || scheme === "https") && reserveDownloadPost()) { postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: href }); } } catch (_) {} From 209962a8ad6a66090b8ec92c038dc14427fba4fc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 02:42:11 -0700 Subject: [PATCH 29/59] Keep scripted download hook out of subframes --- .github/swift-file-length-budget.tsv | 2 +- .../CmuxWebView+ScriptedDownloads.swift | 17 +--- ...WebView+SubframeDownloadIntentScript.swift | 78 +++++++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + ...BrowserDownloadFilenameResolverTests.swift | 21 +++-- 5 files changed, 103 insertions(+), 19 deletions(-) create mode 100644 Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 5e641ae2bd8d..9debf01f4a82 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -193,13 +193,13 @@ 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift -544 Sources/Panels/CmuxWebView+ScriptedDownloads.swift 541 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 540 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift 539 CLI/CMUXCLI+Themes.swift 539 CLI/CodexTeamsApprovalBridge.swift 538 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/PTYBridge/RemotePTYBridgeSession.swift 536 cmuxTests/CmuxConfigContextMenuTests.swift +535 Sources/Panels/CmuxWebView+ScriptedDownloads.swift 534 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift 531 Sources/App/WorkspaceRuntimeSettings.swift 530 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/GhosttyRuntime.swift diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index e3c81a543227..7d4b44ebd5a7 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -114,17 +114,6 @@ extension CmuxWebView { }); } catch (_) {} }; - const postSubframeDownloadIntent = (anchor, event) => { - try { - if (isMainFrame || !anchor || !event || !event.isTrusted) return; - const href = String(anchor.href || anchor.getAttribute("href") || ""); - const scheme = href.split(":", 1)[0].toLowerCase(); - if ((scheme === "http" || scheme === "https") && reserveDownloadPost()) { - postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: href }); - } - } catch (_) {} - }; - const readBlobForDownload = (blob, suggestedFilename, fallbackURL) => { try { if (!blob) return false; @@ -253,7 +242,6 @@ extension CmuxWebView { document.addEventListener("click", (event) => { const anchor = anchorForEvent(event); - postSubframeDownloadIntent(anchor, event); if (anchor && handledAnchors?.has(anchor)) { event.preventDefault(); event.stopPropagation(); @@ -365,9 +353,12 @@ extension CmuxWebView { WKUserScript( source: Self.scriptedDownloadInterceptionBootstrapScriptSource(token: token), injectionTime: .atDocumentStart, - forMainFrameOnly: false + forMainFrameOnly: true ) ) + userContentController.addUserScript( + Self.subframeDownloadIntentScript(token: token, handlerName: Self.scriptedDownloadMessageHandlerName) + ) userContentController.add( Self.sharedScriptedDownloadMessageHandler, name: Self.scriptedDownloadMessageHandlerName diff --git a/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift b/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift new file mode 100644 index 000000000000..3e22c068e81a --- /dev/null +++ b/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift @@ -0,0 +1,78 @@ +import WebKit + +extension CmuxWebView { + static func subframeDownloadIntentScript(token: String, handlerName: String) -> WKUserScript { + WKUserScript( + source: subframeDownloadIntentScriptSource(token: token, handlerName: handlerName), + injectionTime: .atDocumentStart, + forMainFrameOnly: false + ) + } + + private static func subframeDownloadIntentScriptSource(token: String, handlerName: String) -> String { + """ + (() => { + try { + let isMainFrame = false; + try { + isMainFrame = window.top === window; + } catch (_) { + isMainFrame = false; + } + if (isMainFrame) return true; + + const bridgeToken = "\(token)"; + const handler = (() => { + try { + return window.webkit?.messageHandlers?.\(handlerName) ?? null; + } catch (_) { + return null; + } + })(); + if (!handler) return false; + const postMessage = handler.postMessage.bind(handler); + let lastIntentPostMs = 0; + + const reserveIntentPost = () => { + const now = Date.now(); + if (now - lastIntentPostMs < 500) return false; + lastIntentPostMs = now; + return true; + }; + + const anchorForEvent = (event) => { + try { + const path = typeof event.composedPath === "function" ? event.composedPath() : []; + for (const node of path) { + if (!node || node.nodeType !== 1) continue; + const tag = String(node.tagName || "").toUpperCase(); + if ((tag === "A" || tag === "AREA") && node.href) return node; + } + const target = event.target; + return target?.closest?.("a[href],area[href]") ?? null; + } catch (_) { + return null; + } + }; + + document.addEventListener("click", (event) => { + try { + if (!event || !event.isTrusted) return; + const anchor = anchorForEvent(event); + if (!anchor) return; + const href = String(anchor.href || anchor.getAttribute("href") || ""); + const scheme = href.split(":", 1)[0].toLowerCase(); + if ((scheme === "http" || scheme === "https") && reserveIntentPost()) { + postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: href }); + } + } catch (_) {} + }, true); + + return true; + } catch (_) { + return false; + } + })(); + """ + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b92748eec7e1..81c38c78bd08 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -359,6 +359,7 @@ D7C0DE00000000000000A101 /* CmuxWebView+ContextMenuLinkCapture.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C0DE00000000000000A102 /* CmuxWebView+ContextMenuLinkCapture.swift */; }; D7AB00000000000000000009 /* CmuxWebView+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */; }; C6255D010000000000000001 /* CmuxWebView+ScriptedDownloads.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */; }; + C67540030000000000000001 /* CmuxWebView+SubframeDownloadIntentScript.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540030000000000000002 /* CmuxWebView+SubframeDownloadIntentScript.swift */; }; A5001500 /* CmuxWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001510 /* CmuxWebView.swift */; }; D7C0DE00000000000000A103 /* CmuxWebViewContextMenuLinkCaptureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */; }; D0B1000CA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */; }; @@ -1484,6 +1485,7 @@ D7C0DE00000000000000A102 /* CmuxWebView+ContextMenuLinkCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/CmuxWebView+ContextMenuLinkCapture.swift"; sourceTree = ""; }; D7AB0000000000000000000A /* CmuxWebView+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/CmuxWebView+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/CmuxWebView+ScriptedDownloads.swift"; sourceTree = ""; }; + C67540030000000000000002 /* CmuxWebView+SubframeDownloadIntentScript.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/CmuxWebView+SubframeDownloadIntentScript.swift"; sourceTree = ""; }; A5001510 /* CmuxWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/CmuxWebView.swift; sourceTree = ""; }; D7C0DE00000000000000A104 /* CmuxWebViewContextMenuLinkCaptureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewContextMenuLinkCaptureTests.swift; sourceTree = ""; }; D0B1000DA1B2C3D4E5F60001 /* CmuxWebViewDragRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxWebViewDragRoutingTests.swift; sourceTree = ""; }; @@ -2836,6 +2838,7 @@ C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */, C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */, C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */, + C67540030000000000000002 /* CmuxWebView+SubframeDownloadIntentScript.swift */, B3770BA00000000000000002 /* BrowserAutomation.swift */, 4472B0014472B0014472B001 /* BrowserScreenshot.swift */, 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */, @@ -4041,6 +4044,7 @@ D7C0DE00000000000000A101 /* CmuxWebView+ContextMenuLinkCapture.swift in Sources */, D7AB00000000000000000009 /* CmuxWebView+MoveTabToNewWorkspace.swift in Sources */, C6255D010000000000000001 /* CmuxWebView+ScriptedDownloads.swift in Sources */, + C67540030000000000000001 /* CmuxWebView+SubframeDownloadIntentScript.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, E30750000000000000000004 /* CmuxWorkspaceDefinition.swift in Sources */, A9F200000000000000000016 /* CodexAppServerQueuedInput.swift in Sources */, diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 5565d811c101..a25f04228486 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -203,15 +203,26 @@ import WebKit } @MainActor - @Test func scriptedDownloadInterceptionRunsInSubframes() throws { + @Test func scriptedDownloadInterceptionKeepsFullHookOutOfSubframes() throws { let webView = CmuxWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let scripts = webView.configuration.userContentController.userScripts - let script = try #require( - webView.configuration.userContentController.userScripts.first { - $0.source.contains("cmuxScriptedDownload") + let mainFrameScript = try #require( + scripts.first { + $0.source.contains("__cmuxScriptedDownloadInstalled") } ) - #expect(script.isForMainFrameOnly == false) + #expect(mainFrameScript.isForMainFrameOnly) + + let subframeScript = try #require( + scripts.first { + $0.source.contains("subframeDownloadIntent") + && !$0.source.contains("__cmuxScriptedDownloadInstalled") + } + ) + #expect(!subframeScript.isForMainFrameOnly) + #expect(!subframeScript.source.contains("createObjectURL")) + #expect(!subframeScript.source.contains("revokeObjectURL")) } @Test func rejectsNonSuccessHTTPStatusBeforeSavePanelNaming() throws { From 9ba6ccf234ccc3b167cb795eeaf435fe16f59ebd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 14:06:50 -0700 Subject: [PATCH 30/59] Add prompted download completion regression --- Sources/Panels/BrowserPanel.swift | 2 +- ...BrowserDownloadFilenameResolverTests.swift | 52 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 795d79e7a2a6..ca9b596723b5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3032,7 +3032,7 @@ final class BrowserPanel: Panel, ObservableObject { private var webViewCancellables = Set() private var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? - private var downloadDelegate: BrowserDownloadDelegate? + var downloadDelegate: BrowserDownloadDelegate? private let webAuthnCoordinator = BrowserWebAuthnCoordinator() private var webViewObservers: [NSKeyValueObservation] = [] private var activeDownloadCount: Int = 0 diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index a25f04228486..bb3aab466b07 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -225,6 +225,39 @@ import WebKit #expect(!subframeScript.source.contains("revokeObjectURL")) } + @MainActor + @Test func promptedDownloadCompletionCallbacksPostFinalEvents() throws { + let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false) + let delegate = try #require(panel.downloadDelegate) + let capture = BrowserDownloadEventCapture() + let observer = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: panel, + queue: nil + ) { notification in + capture.append(notification) + } + defer { NotificationCenter.default.removeObserver(observer) } + + let savedURL = URL(fileURLWithPath: "/tmp/cmux-download-report.csv") + delegate.onDownloadSaved?("report.csv", savedURL, false) + delegate.onDownloadCancelled?("cancelled.txt", false) + delegate.onDownloadFailed?( + NSError(domain: "cmux.download.test", code: 7, userInfo: [ + NSLocalizedDescriptionKey: "disk full" + ]), + false + ) + + let events = capture.snapshot() + try #require(events.count == 3) + #expect(events.map { $0["type"] as? String } == ["saved", "cancelled", "failed"]) + #expect(events[0]["filename"] as? String == "report.csv") + #expect(events[0]["path"] as? String == savedURL.path) + #expect(events[1]["filename"] as? String == "cancelled.txt") + #expect(events[2]["error"] as? String == "disk full") + } + @Test func rejectsNonSuccessHTTPStatusBeforeSavePanelNaming() throws { let url = try #require(URL(string: "https://example.test/logo.jpg")) let response = try #require(HTTPURLResponse( @@ -357,6 +390,25 @@ import WebKit return try #require(HTTPCookie(properties: properties)) } + private final class BrowserDownloadEventCapture: @unchecked Sendable { + private let lock = NSLock() + private var events: [[String: Any]] = [] + + func append(_ notification: Notification) { + guard let event = notification.userInfo?["event"] as? [String: Any] else { return } + lock.lock() + events.append(event) + lock.unlock() + } + + func snapshot() -> [[String: Any]] { + lock.lock() + let result = events + lock.unlock() + return result + } + } + private static let onePixelPNG = Data([ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, From 99aed4a40c64b528d39bf33443e1d20674cb0826 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 14:29:17 -0700 Subject: [PATCH 31/59] Post prompted download completion events --- Sources/Panels/BrowserPanel.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index ca9b596723b5..42001ca7dec5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4030,8 +4030,7 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity in guard let self else { return } - guard shouldEndActivity else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4048,8 +4047,7 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity in guard let self else { return } - guard shouldEndActivity else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4065,8 +4063,7 @@ final class BrowserPanel: Panel, ObservableObject { } dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity in guard let self else { return } - guard shouldEndActivity else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, From 0ec2621b3a815f4220c97f7ba13646b643a0731f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 15:35:02 -0700 Subject: [PATCH 32/59] Deduplicate prompted download automation events --- .github/swift-file-length-budget.tsv | 6 +- Sources/Panels/BrowserPanel.swift | 62 ++++++++++++------- Sources/TerminalController.swift | 45 ++++++++++++-- ...BrowserDownloadFilenameResolverTests.swift | 8 ++- ...erminalControllerSocketSecurityTests.swift | 39 ++++++++++++ 5 files changed, 126 insertions(+), 34 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 7a28b6f7f2cc..49ffb7c48d03 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,11 +4,11 @@ 34629 CLI/cmux.swift 17838 Sources/AppDelegate.swift 16128 Sources/ContentView.swift -13913 Sources/TerminalController.swift +13948 Sources/TerminalController.swift 12878 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift -11707 Sources/Panels/BrowserPanel.swift +11720 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7986 Sources/Panels/BrowserPanelView.swift @@ -57,9 +57,9 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +1586 cmuxTests/TerminalControllerSocketSecurityTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift -1547 cmuxTests/TerminalControllerSocketSecurityTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift 1499 cmuxTests/OmnibarAndToolsTests.swift 1447 Sources/FileExplorerStore.swift diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 42001ca7dec5..311027b24bd9 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3996,7 +3996,7 @@ final class BrowserPanel: Panel, ObservableObject { dlDelegate.savePanelParentWindow = { [weak self] in self?.webView.window } - dlDelegate.onDownloadStarted = { [weak self] filename in + dlDelegate.onDownloadStarted = { [weak self] filename, downloadID in guard let self else { return } self.beginDownloadActivity() NotificationCenter.default.post( @@ -4007,12 +4007,13 @@ final class BrowserPanel: Panel, ObservableObject { "workspaceId": self.workspaceId, "event": [ "type": "started", + "download_id": downloadID, "filename": filename ] ] ) } - dlDelegate.onDownloadReadyToSave = { [weak self] filename in + dlDelegate.onDownloadReadyToSave = { [weak self] filename, downloadID in guard let self else { return } self.endDownloadActivity() NotificationCenter.default.post( @@ -4023,12 +4024,13 @@ final class BrowserPanel: Panel, ObservableObject { "workspaceId": self.workspaceId, "event": [ "type": "ready_to_save", + "download_id": downloadID, "filename": filename ] ] ) } - dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity in + dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity, downloadID in guard let self else { return } if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( @@ -4039,13 +4041,14 @@ final class BrowserPanel: Panel, ObservableObject { "workspaceId": self.workspaceId, "event": [ "type": "saved", + "download_id": downloadID, "filename": filename, "path": destinationURL.path ] ] ) } - dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity in + dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity, downloadID in guard let self else { return } if shouldEndActivity { self.endDownloadActivity() } NotificationCenter.default.post( @@ -4056,24 +4059,29 @@ final class BrowserPanel: Panel, ObservableObject { "workspaceId": self.workspaceId, "event": [ "type": "cancelled", + "download_id": downloadID, "filename": filename ] ] ) } - dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity in + dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity, downloadID in guard let self else { return } if shouldEndActivity { self.endDownloadActivity() } + var event: [String: Any] = [ + "type": "failed", + "error": error.localizedDescription + ] + if let downloadID { + event["download_id"] = downloadID + } NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, userInfo: [ "surfaceId": self.id, "workspaceId": self.workspaceId, - "event": [ - "type": "failed", - "error": error.localizedDescription - ] + "event": event ] ) } @@ -8293,6 +8301,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private nonisolated static let maxDownloadDestinationCollisionRetries = 100 private struct DownloadState: Sendable { + let downloadID: String let tempURL: URL let suggestedFilename: String let sourceURL: URL @@ -8302,11 +8311,11 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { private var activeDownloads: [ObjectIdentifier: DownloadState] = [:] private var suggestedFilenameOverrides: [ObjectIdentifier: String] = [:] private let activeDownloadsLock = NSLock() - var onDownloadStarted: ((String) -> Void)? - var onDownloadReadyToSave: ((String) -> Void)? - var onDownloadSaved: ((String, URL, Bool) -> Void)? - var onDownloadCancelled: ((String, Bool) -> Void)? - var onDownloadFailed: ((Error, Bool) -> Void)? + var onDownloadStarted: ((String, String) -> Void)? + var onDownloadReadyToSave: ((String, String) -> Void)? + var onDownloadSaved: ((String, URL, Bool, String) -> Void)? + var onDownloadCancelled: ((String, Bool, String) -> Void)? + var onDownloadFailed: ((Error, Bool, String?) -> Void)? var savePanelParentWindow: (() -> NSWindow?)? private static let tempDir: URL = { @@ -8384,12 +8393,13 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { @MainActor private func presentSavePanel( + downloadID: String, tempURL: URL, suggestedFilename: String, sourceURL: URL, filenameResolver: BrowserDownloadFilenameResolver ) { - onDownloadReadyToSave?(suggestedFilename) + onDownloadReadyToSave?(suggestedFilename, downloadID) let savePanel = NSSavePanel() savePanel.nameFieldStringValue = suggestedFilename savePanel.canCreateDirectories = true @@ -8397,7 +8407,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { let completion: (NSApplication.ModalResponse) -> Void = { [weak self] result in guard result == .OK, let destURL = savePanel.url else { try? FileManager.default.removeItem(at: tempURL) - self?.onDownloadCancelled?(suggestedFilename, false) + self?.onDownloadCancelled?(suggestedFilename, false, downloadID) return } do { @@ -8407,10 +8417,10 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } else { try FileManager.default.moveItem(at: tempURL, to: destURL) } - self?.onDownloadSaved?(suggestedFilename, destURL, false) + self?.onDownloadSaved?(suggestedFilename, destURL, false, downloadID) } catch { try? FileManager.default.removeItem(at: tempURL) - self?.onDownloadFailed?(error, false) + self?.onDownloadFailed?(error, false, downloadID) } } if let parentWindow = savePanelParentWindow?() { @@ -8438,10 +8448,11 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { let safeFilename = filenameResolver.suggestedFilename(suggestedFilename: preferredSuggestedFilename, response: response, sourceURL: sourceURL, imageType: nil) let tempFilename = "\(UUID().uuidString)-\(safeFilename)" let destURL = Self.tempDir.appendingPathComponent(tempFilename, isDirectory: false) + let downloadID = UUID().uuidString try? FileManager.default.removeItem(at: destURL) - storeState(DownloadState(tempURL: destURL, suggestedFilename: safeFilename, sourceURL: sourceURL), for: download) + storeState(DownloadState(downloadID: downloadID, tempURL: destURL, suggestedFilename: safeFilename, sourceURL: sourceURL), for: download) notifyOnMain { [weak self] in - self?.onDownloadStarted?(safeFilename) + self?.onDownloadStarted?(safeFilename, downloadID) } #if DEBUG cmuxDebugLog("download.decideDestination file=") @@ -8468,6 +8479,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { if filenameResolver.shouldAskWhereToSaveDownloads() { self.presentSavePanel( + downloadID: info.downloadID, tempURL: info.tempURL, suggestedFilename: suggestedFilename, sourceURL: info.sourceURL, @@ -8488,23 +8500,27 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { }.value switch saveResult { case .success(let destinationURL): - self.onDownloadSaved?(suggestedFilename, destinationURL, true) + self.onDownloadSaved?(suggestedFilename, destinationURL, true, info.downloadID) #if DEBUG cmuxDebugLog("download.saved path=") #endif case .failure(let error): try? FileManager.default.removeItem(at: info.tempURL) - self.onDownloadFailed?(error, true) + self.onDownloadFailed?(error, true, info.downloadID) } } } func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) { + let downloadID: String? if let info = removeState(for: download) { try? FileManager.default.removeItem(at: info.tempURL) + downloadID = info.downloadID + } else { + downloadID = nil } notifyOnMain { [weak self] in - self?.onDownloadFailed?(error, true) + self?.onDownloadFailed?(error, true, downloadID) } #if DEBUG cmuxDebugLog("download.failed error=\(error.localizedDescription)") diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index d9205c81386b..65e270a3e36b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -260,6 +260,7 @@ class TerminalController { private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:] private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:] private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] + private var v2ConsumedBrowserDownloadIDsBySurface: [UUID: Set] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private nonisolated let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() /// Stateless browser-control logic (JS builders, value normalization, @@ -282,6 +283,7 @@ class TerminalController { v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId) v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId) v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId) + v2ConsumedBrowserDownloadIDsBySurface.removeValue(forKey: surfaceId) v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId) v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId } @@ -338,9 +340,7 @@ class TerminalController { let event = note.userInfo?["event"] as? [String: Any] else { return } Task { @MainActor [weak self] in guard let self else { return } - var queue = self.v2BrowserDownloadEventsBySurface[surfaceId] ?? [] - queue.append(event) - self.v2BrowserDownloadEventsBySurface[surfaceId] = queue + self.v2RecordBrowserDownloadEvent(surfaceId: surfaceId, event: event) } } } @@ -8611,16 +8611,46 @@ class TerminalController { } } - private func v2PopBrowserDownloadEvent(surfaceId: UUID) -> [String: Any]? { + func v2RecordBrowserDownloadEvent(surfaceId: UUID, event: [String: Any]) { + guard v2ShouldStoreBrowserDownloadEvent(event, surfaceId: surfaceId) else { return } + var queue = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + queue.append(event) + v2BrowserDownloadEventsBySurface[surfaceId] = queue + } + + func v2PopBrowserDownloadEvent(surfaceId: UUID) -> [String: Any]? { var remaining = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] while !remaining.isEmpty { let first = remaining.removeFirst() v2BrowserDownloadEventsBySurface[surfaceId] = remaining - if (first["type"] as? String) != "started" { return first } + guard v2ShouldStoreBrowserDownloadEvent(first, surfaceId: surfaceId) else { continue } + if (first["type"] as? String) != "started" { + v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) + return first + } } return nil } + private func v2DownloadID(from event: [String: Any]) -> String? { + (event["download_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty + } + + private func v2ShouldStoreBrowserDownloadEvent(_ event: [String: Any], surfaceId: UUID) -> Bool { + guard let downloadID = v2DownloadID(from: event) else { return true } + return !(v2ConsumedBrowserDownloadIDsBySurface[surfaceId]?.contains(downloadID) ?? false) + } + + func v2MarkBrowserDownloadEventConsumed(_ event: [String: Any], surfaceId: UUID) { + guard let downloadID = v2DownloadID(from: event) else { return } + var consumed = v2ConsumedBrowserDownloadIDsBySurface[surfaceId] ?? [] + consumed.insert(downloadID) + v2ConsumedBrowserDownloadIDsBySurface[surfaceId] = consumed + v2BrowserDownloadEventsBySurface[surfaceId]?.removeAll { + v2DownloadID(from: $0) == downloadID + } + } + private nonisolated func v2WaitForDownloadFile(path: String, timeout: TimeInterval) -> V2DownloadFileWaitResult { let fm = FileManager.default let pathIsReady = { @@ -8723,6 +8753,11 @@ class TerminalController { if let observer { NotificationCenter.default.removeObserver(observer) } + if let event { + v2MainSync { + v2MarkBrowserDownloadEventConsumed(event, surfaceId: surfaceId) + } + } return event } diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index bb3aab466b07..12692a119d92 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -240,18 +240,20 @@ import WebKit defer { NotificationCenter.default.removeObserver(observer) } let savedURL = URL(fileURLWithPath: "/tmp/cmux-download-report.csv") - delegate.onDownloadSaved?("report.csv", savedURL, false) - delegate.onDownloadCancelled?("cancelled.txt", false) + delegate.onDownloadSaved?("report.csv", savedURL, false, "download-1") + delegate.onDownloadCancelled?("cancelled.txt", false, "download-1") delegate.onDownloadFailed?( NSError(domain: "cmux.download.test", code: 7, userInfo: [ NSLocalizedDescriptionKey: "disk full" ]), - false + false, + "download-1" ) let events = capture.snapshot() try #require(events.count == 3) #expect(events.map { $0["type"] as? String } == ["saved", "cancelled", "failed"]) + #expect(events.map { $0["download_id"] as? String } == ["download-1", "download-1", "download-1"]) #expect(events[0]["filename"] as? String == "report.csv") #expect(events[0]["path"] as? String == savedURL.path) #expect(events[1]["filename"] as? String == "cancelled.txt") diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 9cf18bfc6c75..511f56e3ada0 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -118,6 +118,45 @@ private func XCTFail( final class TerminalControllerSocketSecurityTests { private var teardownBlocks: [() -> Void] = [] + @Test func browserDownloadQueueDropsCompletionForConsumedPromptedDownload() { + let controller = TerminalController.shared + let surfaceId = UUID() + controller.cleanupSurfaceState(surfaceIds: [surfaceId]) + defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } + + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "started", + "download_id": "download-1", + "filename": "report.csv", + ] + ) + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "ready_to_save", + "download_id": "download-1", + "filename": "report.csv", + ] + ) + + let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) + XCTAssertEqual(returned?["type"] as? String, "ready_to_save") + + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "saved", + "download_id": "download-1", + "filename": "report.csv", + "path": "/tmp/report.csv", + ] + ) + + XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) + } + init() { TerminalController.shared.stop() } From 1bc840042e9530402ed5b984684e8867aad6ae56 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 15:46:31 -0700 Subject: [PATCH 33/59] Address prompted download autoreview findings --- .github/swift-file-length-budget.tsv | 4 +- .../BrowserDownloadFilenameResolver.swift | 18 +++-- Sources/Panels/BrowserPanel.swift | 2 +- Sources/TerminalController.swift | 23 +++++- ...BrowserDownloadFilenameResolverTests.swift | 21 ++++- ...erminalControllerSocketSecurityTests.swift | 78 +++++++++++++++++++ 6 files changed, 135 insertions(+), 11 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 49ffb7c48d03..2d0636c462bf 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 34629 CLI/cmux.swift 17838 Sources/AppDelegate.swift 16128 Sources/ContentView.swift -13948 Sources/TerminalController.swift +13967 Sources/TerminalController.swift 12878 Sources/Workspace.swift 12237 Sources/GhosttyTerminalView.swift 12144 cmuxTests/AppDelegateShortcutRoutingTests.swift @@ -57,7 +57,7 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift -1586 cmuxTests/TerminalControllerSocketSecurityTests.swift +1664 cmuxTests/TerminalControllerSocketSecurityTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index 318623d8f8ba..d0f2a03c86ff 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -275,9 +275,9 @@ extension URL { !bundleIdentifier.isEmpty { quarantineProperties[kLSQuarantineAgentBundleIdentifierKey as String] = bundleIdentifier } - if Self.cmuxCanStoreDownloadSourceURL(sourceURL) { - quarantineProperties[kLSQuarantineDataURLKey as String] = sourceURL - quarantineProperties[kLSQuarantineOriginURLKey as String] = sourceURL + if let sanitizedSourceURL = Self.cmuxSanitizedDownloadSourceURL(sourceURL) { + quarantineProperties[kLSQuarantineDataURLKey as String] = sanitizedSourceURL + quarantineProperties[kLSQuarantineOriginURLKey as String] = sanitizedSourceURL } var resourceValues = URLResourceValues() @@ -294,8 +294,16 @@ extension URL { return trimmed.isEmpty ? "cmux" : trimmed } - private static func cmuxCanStoreDownloadSourceURL(_ sourceURL: URL) -> Bool { + private static func cmuxSanitizedDownloadSourceURL(_ sourceURL: URL) -> URL? { let scheme = sourceURL.scheme?.lowercased() - return scheme == "http" || scheme == "https" + guard scheme == "http" || scheme == "https", + var components = URLComponents(url: sourceURL, resolvingAgainstBaseURL: false) else { + return nil + } + components.user = nil + components.password = nil + components.query = nil + components.fragment = nil + return components.url } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 311027b24bd9..a709467be84c 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3994,7 +3994,7 @@ final class BrowserPanel: Panel, ObservableObject { // callbacks), then auto-save to Downloads unless the prompt setting is enabled. let dlDelegate = BrowserDownloadDelegate() dlDelegate.savePanelParentWindow = { [weak self] in - self?.webView.window + self.flatMap { browserInteractiveModalHostWindow(for: $0.webView) } } dlDelegate.onDownloadStarted = { [weak self] filename, downloadID in guard let self else { return } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 65e270a3e36b..14ded0dbda2b 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -138,6 +138,7 @@ class TerminalController { private nonisolated static let socketListenerFailureCaptureCooldown: TimeInterval = 60 private nonisolated static let v2BrowserDownloadWaitDefaultTimeoutMs = 10_000 private nonisolated static let v2BrowserDownloadWaitMaxTimeoutMs = 120_000 + private nonisolated static let v2ConsumedBrowserDownloadIDLimit = 128 private nonisolated static let socketListenerFailureCaptureLock = NSLock() private nonisolated(unsafe) static var socketListenerFailureLastCapturedAt: [String: Date] = [:] private struct MobileViewportReport { @@ -260,7 +261,7 @@ class TerminalController { private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:] private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:] private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] - private var v2ConsumedBrowserDownloadIDsBySurface: [UUID: Set] = [:] + private var v2ConsumedBrowserDownloadIDsBySurface: [UUID: [String]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private nonisolated let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() /// Stateless browser-control logic (JS builders, value normalization, @@ -8614,6 +8615,10 @@ class TerminalController { func v2RecordBrowserDownloadEvent(surfaceId: UUID, event: [String: Any]) { guard v2ShouldStoreBrowserDownloadEvent(event, surfaceId: surfaceId) else { return } var queue = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] + if v2IsTerminalBrowserDownloadEvent(event), + let downloadID = v2DownloadID(from: event) { + queue.removeAll { v2DownloadID(from: $0) == downloadID } + } queue.append(event) v2BrowserDownloadEventsBySurface[surfaceId] = queue } @@ -8636,6 +8641,15 @@ class TerminalController { (event["download_id"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty } + private func v2IsTerminalBrowserDownloadEvent(_ event: [String: Any]) -> Bool { + switch event["type"] as? String { + case "saved", "cancelled", "failed": + return true + default: + return false + } + } + private func v2ShouldStoreBrowserDownloadEvent(_ event: [String: Any], surfaceId: UUID) -> Bool { guard let downloadID = v2DownloadID(from: event) else { return true } return !(v2ConsumedBrowserDownloadIDsBySurface[surfaceId]?.contains(downloadID) ?? false) @@ -8644,7 +8658,11 @@ class TerminalController { func v2MarkBrowserDownloadEventConsumed(_ event: [String: Any], surfaceId: UUID) { guard let downloadID = v2DownloadID(from: event) else { return } var consumed = v2ConsumedBrowserDownloadIDsBySurface[surfaceId] ?? [] - consumed.insert(downloadID) + consumed.removeAll { $0 == downloadID } + consumed.append(downloadID) + if consumed.count > Self.v2ConsumedBrowserDownloadIDLimit { + consumed.removeFirst(consumed.count - Self.v2ConsumedBrowserDownloadIDLimit) + } v2ConsumedBrowserDownloadIDsBySurface[surfaceId] = consumed v2BrowserDownloadEventsBySurface[surfaceId]?.removeAll { v2DownloadID(from: $0) == downloadID @@ -8741,6 +8759,7 @@ class TerminalController { (event["type"] as? String) != "started" else { return } + guard self.v2MainSync({ self.v2ShouldStoreBrowserDownloadEvent(event, surfaceId: surfaceId) }) else { return } finishOnce(event) } diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 12692a119d92..2248b8a9487b 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -172,7 +172,7 @@ import WebKit let fileURL = directory.appendingPathComponent("report.csv", isDirectory: false) try Data("download".utf8).write(to: fileURL) - let sourceURL = try #require(URL(string: "https://example.test/report.csv")) + let sourceURL = try #require(URL(string: "https://user:pass@example.test/report.csv?token=secret#section")) try fileURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) @@ -182,6 +182,8 @@ import WebKit #expect(properties[kLSQuarantineTypeKey as String] as? String == kLSQuarantineTypeWebDownload as String) #expect(properties[kLSQuarantineAgentNameKey as String] as? String != nil) #expect(properties[kLSQuarantineTimeStampKey as String] is Date) + #expect((properties[kLSQuarantineDataURLKey as String] as? URL)?.absoluteString == "https://example.test/report.csv") + #expect((properties[kLSQuarantineOriginURLKey as String] as? URL)?.absoluteString == "https://example.test/report.csv") } @Test func webDownloadQuarantineMetadataSkipsLocalFileSources() throws { @@ -260,6 +262,23 @@ import WebKit #expect(events[2]["error"] as? String == "disk full") } + @MainActor + @Test func promptedDownloadSavePanelSkipsHiddenPreloadWindow() throws { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: try #require(URL(string: "about:blank")), + preloadInitialNavigationInBackground: true, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(panel.hasBackgroundPreloadHost) + #expect(browserInteractiveModalHostWindow(for: panel.webView) == nil) + let delegate = try #require(panel.downloadDelegate) + + #expect(delegate.savePanelParentWindow?() == nil) + } + @Test func rejectsNonSuccessHTTPStatusBeforeSavePanelNaming() throws { let url = try #require(URL(string: "https://example.test/logo.jpg")) let response = try #require(HTTPURLResponse( diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 511f56e3ada0..238bc6916acc 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -157,6 +157,84 @@ final class TerminalControllerSocketSecurityTests { XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) } + @Test func browserDownloadQueuePrefersPromptedCompletionWhenAlreadyClosed() { + let controller = TerminalController.shared + let surfaceId = UUID() + controller.cleanupSurfaceState(surfaceIds: [surfaceId]) + defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } + + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "started", + "download_id": "download-closed", + "filename": "report.csv", + ] + ) + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "ready_to_save", + "download_id": "download-closed", + "filename": "report.csv", + ] + ) + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "saved", + "download_id": "download-closed", + "filename": "report.csv", + "path": "/tmp/report.csv", + ] + ) + + let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) + XCTAssertEqual(returned?["type"] as? String, "saved") + XCTAssertEqual(returned?["path"] as? String, "/tmp/report.csv") + XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) + } + + @Test func browserDownloadConsumedIDRegistryIsBounded() { + let controller = TerminalController.shared + let surfaceId = UUID() + controller.cleanupSurfaceState(surfaceIds: [surfaceId]) + defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } + + let oldestID = "download-0" + let newestID = "download-140" + for index in 0...140 { + controller.v2MarkBrowserDownloadEventConsumed( + [ + "type": "ready_to_save", + "download_id": "download-\(index)", + ], + surfaceId: surfaceId + ) + } + + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "saved", + "download_id": oldestID, + ] + ) + + let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) + XCTAssertEqual(returned?["download_id"] as? String, oldestID) + + controller.v2RecordBrowserDownloadEvent( + surfaceId: surfaceId, + event: [ + "type": "saved", + "download_id": newestID, + ] + ) + + XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) + } + init() { TerminalController.shared.stop() } From 23c638da3fbcdb33a4836fe383b94a5667cd467f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 16:39:49 -0700 Subject: [PATCH 34/59] Post session download automation events --- .github/swift-file-length-budget.tsv | 8 ++-- .../Panels/BrowserNavigationDelegate.swift | 10 +++++ Sources/Panels/BrowserPanel.swift | 12 ++++++ .../Panels/BrowserPopupWindowController.swift | 7 ++++ .../CmuxWebView+ScriptedDownloads.swift | 1 + Sources/Panels/CmuxWebView.swift | 41 ++++++++++++++++++- ...BrowserDownloadFilenameResolverTests.swift | 27 ++++++++++++ 7 files changed, 101 insertions(+), 5 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 134ca4e6b077..2a539d202f18 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -8,7 +8,7 @@ 12838 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12237 Sources/GhosttyTerminalView.swift -11720 Sources/Panels/BrowserPanel.swift +11732 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift 8016 CLI/cmux_open.swift 7952 Sources/Panels/BrowserPanelView.swift @@ -35,7 +35,7 @@ 3053 Sources/Update/UpdateTitlebarAccessory.swift 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift -2751 Sources/Panels/CmuxWebView.swift +2790 Sources/Panels/CmuxWebView.swift 2606 Sources/KeyboardShortcutSettings.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2524 cmuxTests/CommandPaletteSearchEngineTests.swift @@ -127,7 +127,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -735 Sources/Panels/BrowserPopupWindowController.swift +742 Sources/Panels/BrowserPopupWindowController.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift @@ -199,7 +199,7 @@ 539 CLI/CodexTeamsApprovalBridge.swift 538 Packages/macOS/CmuxRemoteWorkspace/Sources/CmuxRemoteWorkspace/PTYBridge/RemotePTYBridgeSession.swift 536 cmuxTests/CmuxConfigContextMenuTests.swift -535 Sources/Panels/CmuxWebView+ScriptedDownloads.swift +536 Sources/Panels/CmuxWebView+ScriptedDownloads.swift 534 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift 532 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Scene/SettingsWindowScene.swift 531 Sources/App/WorkspaceRuntimeSettings.swift diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index d05acd241aa6..e6b330f2ed15 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -350,6 +350,16 @@ import WebKit .value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) + if !navigationResponse.isForMainFrame, + allowsSubframeDownload, + let url = navigationResponse.response.url, + shouldBlockInsecureHTTPNavigation?(url) == true { + #if DEBUG + cmuxDebugLog("download.policy=cancel reason=insecureHTTPSubframe url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a709467be84c..d78aa05bf514 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3744,6 +3744,18 @@ final class BrowserPanel: Panel, ObservableObject { self?.endDownloadActivity() } } + webView.onSessionDownloadEvent = { [weak self] event in + guard let self else { return } + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": event + ] + ) + } webView.onContextMenuOpenLinkInNewTab = { [weak self] url in self?.openLinkInNewTab(url: url) } diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index d0ded5254b94..82d77e7d33ec 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -674,6 +674,13 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) + if !navigationResponse.isForMainFrame, + allowsSubframeDownload, + let url = navigationResponse.response.url, + browserShouldBlockInsecureHTTPURL(url) { + decisionHandler(.cancel) + return + } if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 7d4b44ebd5a7..630e8c934346 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -17,6 +17,7 @@ extension CmuxWebView { func clearBrowserDownloadCallbacks() { onContextMenuDownloadStateChanged = nil + onSessionDownloadEvent = nil onSubframeDownloadIntent = nil } diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index cce426d71ad5..e68c4df11b9b 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -380,6 +380,7 @@ final class CmuxWebView: WKWebView { private static var cmuxDownloadDelegateKey: UInt8 = 0 private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) var onContextMenuDownloadStateChanged: ((Bool) -> Void)? + var onSessionDownloadEvent: (([String: Any]) -> Void)? /// Called when "Open Link in New Tab" context menu is selected. /// Bypasses createWebViewWith so the link opens as a tab, not a popup. var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? @@ -1619,6 +1620,16 @@ final class CmuxWebView: WKWebView { } } + private func notifySessionDownloadEvent(_ event: [String: Any]) { + if Thread.isMainThread { + onSessionDownloadEvent?(event) + } else { + DispatchQueue.main.async { [weak self] in + self?.onSessionDownloadEvent?(event) + } + } + } + private func finishSessionDownload( data: Data, saveName: String, @@ -1631,18 +1642,36 @@ final class CmuxWebView: WKWebView { failureFallbackReason: String? ) { let filenameResolver = BrowserDownloadFilenameResolver() + let downloadID = UUID().uuidString + notifySessionDownloadEvent([ + "type": "started", + "download_id": downloadID, + "filename": saveName, + ]) let handleWriteResult: (Result, Bool) -> Void = { [weak self] result, shouldClearDownloadState in guard let self else { return } if shouldClearDownloadState { self.notifyContextMenuDownloadState(false) } switch result { - case .success: + case .success(let destinationURL): self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=" ) + self.notifySessionDownloadEvent([ + "type": "saved", + "download_id": downloadID, + "filename": saveName, + "path": destinationURL.path, + ]) case .failure(let error): self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" ) + self.notifySessionDownloadEvent([ + "type": "failed", + "download_id": downloadID, + "filename": saveName, + "error": error.localizedDescription, + ]) if let failureFallbackReason { self.runContextMenuFallback( action: fallbackAction, @@ -1661,6 +1690,11 @@ final class CmuxWebView: WKWebView { savePanel.canCreateDirectories = true savePanel.directoryURL = filenameResolver.downloadsDirectory() notifyContextMenuDownloadState(false) + notifySessionDownloadEvent([ + "type": "ready_to_save", + "download_id": downloadID, + "filename": saveName, + ]) debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=" ) @@ -1669,6 +1703,11 @@ final class CmuxWebView: WKWebView { self.debugContextDownload( "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel" ) + self.notifySessionDownloadEvent([ + "type": "cancelled", + "download_id": downloadID, + "filename": saveName, + ]) return } self.writeSessionDownloadDataInBackground( diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 2248b8a9487b..7a740640760b 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -262,6 +262,33 @@ import WebKit #expect(events[2]["error"] as? String == "disk full") } + @MainActor + @Test func sessionDownloadBridgePostsAutomationEvents() throws { + let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false) + let capture = BrowserDownloadEventCapture() + let observer = NotificationCenter.default.addObserver( + forName: .browserDownloadEventDidArrive, + object: panel, + queue: nil + ) { notification in + capture.append(notification) + } + defer { NotificationCenter.default.removeObserver(observer) } + + panel.webView.onSessionDownloadEvent?([ + "type": "saved", + "download_id": "session-download-1", + "filename": "report.csv", + "path": "/tmp/report.csv", + ]) + + let events = capture.snapshot() + try #require(events.count == 1) + #expect(events[0]["type"] as? String == "saved") + #expect(events[0]["download_id"] as? String == "session-download-1") + #expect(events[0]["path"] as? String == "/tmp/report.csv") + } + @MainActor @Test func promptedDownloadSavePanelSkipsHiddenPreloadWindow() throws { let panel = BrowserPanel( From 0cef08dd818b7ed772b5f25aa021b0e1bf671b9d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 16:52:55 -0700 Subject: [PATCH 35/59] Gate subframe download actions through HTTP policy --- Sources/Panels/BrowserNavigationDelegate.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index e6b330f2ed15..f5d7a7fa9ef1 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -278,6 +278,15 @@ import WebKit } if navigationAction.shouldPerformDownload { + if navigationAction.targetFrame?.isMainFrame == false, + let url = navigationAction.request.url, + shouldBlockInsecureHTTPNavigation?(url) == true { + #if DEBUG + cmuxDebugLog("browser.nav.decidePolicy.action kind=cancelDownload reason=insecureHTTPSubframe url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } decisionHandler(.download) return } From a1362ede8cb9fd74f5c391a6e1405a6e46b13fed Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 17:22:44 -0700 Subject: [PATCH 36/59] Preserve prompted download completions --- .github/swift-file-length-budget.tsv | 4 ++-- Sources/Panels/BrowserSubframeDownloadIntentTracker.swift | 5 ++++- Sources/TerminalController.swift | 4 +++- cmuxTests/TerminalControllerSocketSecurityTests.swift | 7 +++++-- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 2a539d202f18..11eb902708bd 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,7 +4,7 @@ 34629 CLI/cmux.swift 17854 Sources/AppDelegate.swift 16109 Sources/ContentView.swift -13967 Sources/TerminalController.swift +13969 Sources/TerminalController.swift 12838 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12237 Sources/GhosttyTerminalView.swift @@ -57,7 +57,7 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift -1664 cmuxTests/TerminalControllerSocketSecurityTests.swift +1667 cmuxTests/TerminalControllerSocketSecurityTests.swift 1581 Packages/iOS/CmuxMobileTerminal/Sources/CmuxMobileTerminal/TerminalInputTextView.swift 1560 cmuxTests/TextBoxMentionCompletionTests.swift 1500 cmuxUITests/MultiWindowNotificationsUITests.swift diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index 33e10a84116b..b6f16d18318d 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -12,7 +12,10 @@ final class BrowserSubframeDownloadIntentTracker { let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - guard navigationAction.navigationType != .linkActivated else { return } + if navigationAction.navigationType == .linkActivated { + record(url) + return + } guard let sourceURL = navigationAction.targetFrame?.request.url else { return } recordRedirectIfNeeded(from: sourceURL, to: url) } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 14ded0dbda2b..c297426365e6 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8630,7 +8630,9 @@ class TerminalController { v2BrowserDownloadEventsBySurface[surfaceId] = remaining guard v2ShouldStoreBrowserDownloadEvent(first, surfaceId: surfaceId) else { continue } if (first["type"] as? String) != "started" { - v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) + if v2IsTerminalBrowserDownloadEvent(first) { + v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) + } return first } } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 238bc6916acc..82f0d99aedb4 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -118,7 +118,7 @@ private func XCTFail( final class TerminalControllerSocketSecurityTests { private var teardownBlocks: [() -> Void] = [] - @Test func browserDownloadQueueDropsCompletionForConsumedPromptedDownload() { + @Test func browserDownloadQueueKeepsCompletionAfterPromptReadyEvent() { let controller = TerminalController.shared let surfaceId = UUID() controller.cleanupSurfaceState(surfaceIds: [surfaceId]) @@ -154,6 +154,9 @@ final class TerminalControllerSocketSecurityTests { ] ) + let saved = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) + XCTAssertEqual(saved?["type"] as? String, "saved") + XCTAssertEqual(saved?["path"] as? String, "/tmp/report.csv") XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) } @@ -206,7 +209,7 @@ final class TerminalControllerSocketSecurityTests { for index in 0...140 { controller.v2MarkBrowserDownloadEventConsumed( [ - "type": "ready_to_save", + "type": "saved", "download_id": "download-\(index)", ], surfaceId: surfaceId From 568e64d65213c21dcaf614feb9ff84ef2f6229cb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 18:53:09 -0700 Subject: [PATCH 37/59] Avoid consuming prompted download ready events --- Sources/TerminalController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c297426365e6..92448f099e18 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8774,7 +8774,7 @@ class TerminalController { if let observer { NotificationCenter.default.removeObserver(observer) } - if let event { + if let event, v2MainSync({ v2IsTerminalBrowserDownloadEvent(event) }) { v2MainSync { v2MarkBrowserDownloadEventConsumed(event, surfaceId: surfaceId) } From 7dd6bb603de3a9843affc517ef1fc10d0e16466e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 19:03:54 -0700 Subject: [PATCH 38/59] Handle popup subframe download actions --- .github/swift-file-length-budget.tsv | 2 +- Sources/Panels/BrowserPopupWindowController.swift | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 11eb902708bd..0a4a4df38594 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -127,7 +127,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift -742 Sources/Panels/BrowserPopupWindowController.swift +750 Sources/Panels/BrowserPopupWindowController.swift 722 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 82d77e7d33ec..1dea956ec6c1 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -639,6 +639,14 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { + if navigationAction.shouldPerformDownload { + if browserShouldBlockInsecureHTTPURL(url) { + decisionHandler(.cancel) + } else { + decisionHandler(.download) + } + return + } decisionHandler(.allow) return } From 92c98f1227cb61477c93c68cc76a2581b840bb67 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 19:17:56 -0700 Subject: [PATCH 39/59] Bound browser download event queues --- Sources/TerminalController.swift | 11 +- ...erminalControllerSocketSecurityTests.swift | 118 ++++++++---------- 2 files changed, 55 insertions(+), 74 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 92448f099e18..e2ef736a7d86 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8620,6 +8620,9 @@ class TerminalController { queue.removeAll { v2DownloadID(from: $0) == downloadID } } queue.append(event) + if queue.count > Self.v2ConsumedBrowserDownloadIDLimit { + queue.removeFirst(queue.count - Self.v2ConsumedBrowserDownloadIDLimit) + } v2BrowserDownloadEventsBySurface[surfaceId] = queue } @@ -8644,12 +8647,8 @@ class TerminalController { } private func v2IsTerminalBrowserDownloadEvent(_ event: [String: Any]) -> Bool { - switch event["type"] as? String { - case "saved", "cancelled", "failed": - return true - default: - return false - } + let type = event["type"] as? String + return type == "saved" || type == "cancelled" || type == "failed" } private func v2ShouldStoreBrowserDownloadEvent(_ event: [String: Any], surfaceId: UUID) -> Bool { diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index 82f0d99aedb4..b6aef5848cfe 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -124,35 +124,13 @@ final class TerminalControllerSocketSecurityTests { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "started", - "download_id": "download-1", - "filename": "report.csv", - ] - ) - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "ready_to_save", - "download_id": "download-1", - "filename": "report.csv", - ] - ) + recordDownloadEvent("started", id: "download-1", surfaceId: surfaceId) + recordDownloadEvent("ready_to_save", id: "download-1", surfaceId: surfaceId) let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(returned?["type"] as? String, "ready_to_save") - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "saved", - "download_id": "download-1", - "filename": "report.csv", - "path": "/tmp/report.csv", - ] - ) + recordDownloadEvent("saved", id: "download-1", surfaceId: surfaceId, path: "/tmp/report.csv") let saved = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(saved?["type"] as? String, "saved") @@ -166,31 +144,9 @@ final class TerminalControllerSocketSecurityTests { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "started", - "download_id": "download-closed", - "filename": "report.csv", - ] - ) - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "ready_to_save", - "download_id": "download-closed", - "filename": "report.csv", - ] - ) - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "saved", - "download_id": "download-closed", - "filename": "report.csv", - "path": "/tmp/report.csv", - ] - ) + recordDownloadEvent("started", id: "download-closed", surfaceId: surfaceId) + recordDownloadEvent("ready_to_save", id: "download-closed", surfaceId: surfaceId) + recordDownloadEvent("saved", id: "download-closed", surfaceId: surfaceId, path: "/tmp/report.csv") let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(returned?["type"] as? String, "saved") @@ -208,36 +164,62 @@ final class TerminalControllerSocketSecurityTests { let newestID = "download-140" for index in 0...140 { controller.v2MarkBrowserDownloadEventConsumed( - [ - "type": "saved", - "download_id": "download-\(index)", - ], + ["type": "saved", "download_id": "download-\(index)"], surfaceId: surfaceId ) } - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "saved", - "download_id": oldestID, - ] - ) + recordDownloadEvent("saved", id: oldestID, surfaceId: surfaceId) let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(returned?["download_id"] as? String, oldestID) - controller.v2RecordBrowserDownloadEvent( - surfaceId: surfaceId, - event: [ - "type": "saved", - "download_id": newestID, - ] - ) + recordDownloadEvent("saved", id: newestID, surfaceId: surfaceId) XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) } + @Test func browserDownloadEventQueueIsBounded() { + let controller = TerminalController.shared + let surfaceId = UUID() + controller.cleanupSurfaceState(surfaceIds: [surfaceId]) + defer { controller.cleanupSurfaceState(surfaceIds: [surfaceId]) } + + for index in 0...140 { + recordDownloadEvent( + "ready_to_save", + id: "download-\(index)", + surfaceId: surfaceId, + filename: "report-\(index).csv" + ) + } + + var returnedIDs: [String] = [] + while let event = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) { + if let downloadID = event["download_id"] as? String { + returnedIDs.append(downloadID) + } + } + + XCTAssertEqual(returnedIDs.count, 128) + XCTAssertEqual(returnedIDs.first, "download-13") + XCTAssertEqual(returnedIDs.last, "download-140") + } + + private func recordDownloadEvent( + _ type: String, + id: String, + surfaceId: UUID, + filename: String = "report.csv", + path: String? = nil + ) { + var event: [String: Any] = ["type": type, "download_id": id, "filename": filename] + if let path { + event["path"] = path + } + TerminalController.shared.v2RecordBrowserDownloadEvent(surfaceId: surfaceId, event: event) + } + init() { TerminalController.shared.stop() } From 57b2ab270cc219397b40edb83dd133ffb07fd4fd Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 19:38:29 -0700 Subject: [PATCH 40/59] Fix PDF viewer toolbar downloads --- .../BrowserDownloadFilenameResolver.swift | 23 ++++- .../Panels/BrowserNavigationDelegate.swift | 52 +++++++++- Sources/Panels/BrowserPopupPanel.swift | 58 +++++++++++ .../Panels/BrowserPopupWindowController.swift | 99 ++++++++----------- ...BrowserSubframeDownloadIntentTracker.swift | 61 +++++++++++- cmux.xcodeproj/project.pbxproj | 4 + ...BrowserDownloadFilenameResolverTests.swift | 22 +++++ 7 files changed, 254 insertions(+), 65 deletions(-) create mode 100644 Sources/Panels/BrowserPopupPanel.swift diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index d0f2a03c86ff..c0d6cb0bb84a 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -25,8 +25,14 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { canShowMIMEType: Bool, contentDisposition: String?, isForMainFrame: Bool = true, - allowsSubframeDownload: Bool = false + allowsSubframeDownload: Bool = false, + isUserActivatedPreviouslyRenderedSubframePDF: Bool = false ) -> String? { + if !isForMainFrame, + isUserActivatedPreviouslyRenderedSubframePDF, + isPDFMIMEType(mimeType) { + return "subframePDFUserAction" + } let canUseExplicitDownloadSignals = isForMainFrame || allowsSubframeDownload if canUseExplicitDownloadSignals, shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { @@ -40,6 +46,17 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { return canShowMIMEType ? nil : "cannotShowMIME" } + func shouldPrintPDFAfterLoad(mimeType: String?, responseURL: URL?, isForMainFrame: Bool) -> Bool { + guard isForMainFrame, isPDFMIMEType(mimeType) else { return false } + guard let components = responseURL.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) else { + return false + } + return components.queryItems?.contains { + $0.name.caseInsensitiveCompare("print") == .orderedSame && + (($0.value ?? "").caseInsensitiveCompare("true") == .orderedSame || $0.value == "1") + } == true + } + func httpStatusDecision(for response: URLResponse?) -> BrowserDownloadHTTPStatusDecision { guard let httpResponse = response as? HTTPURLResponse else { return .allow @@ -229,6 +246,10 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { return normalized.isEmpty ? nil : normalized } + private func isPDFMIMEType(_ mimeType: String?) -> Bool { + Self.normalizedMIMEType(mimeType) == "application/pdf" + } + private static func contentDispositionRequestsAttachment(_ contentDisposition: String?) -> Bool { guard let rawType = contentDisposition?.split(separator: ";", maxSplits: 1).first else { return false diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index f5d7a7fa9ef1..354a3f545e42 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -4,6 +4,7 @@ import WebKit @MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() + private var shouldPrintAfterCurrentNavigationFinishes = false var didStartProvisionalNavigation: ((WKWebView) -> Void)? var didCommit: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? @@ -28,6 +29,7 @@ import WebKit func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastAttemptedURL = lastAttemptedURL ?? webView.url + shouldPrintAfterCurrentNavigationFinishes = false didStartProvisionalNavigation?(webView) } @@ -37,6 +39,10 @@ import WebKit func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { didFinish?(webView) + if shouldPrintAfterCurrentNavigationFinishes { + shouldPrintAfterCurrentNavigationFinishes = false + webView.cmuxRunPrintOperation() + } } func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { @@ -222,7 +228,10 @@ import WebKit buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) - subframeDownloadIntents.updateIfNeeded(navigationAction) + subframeDownloadIntents.updateIfNeeded( + navigationAction, + hasUserActivation: browserNavigationHasSimpleUserActivation() + ) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -357,8 +366,23 @@ import WebKit let contentDisposition = (navigationResponse.response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Content-Disposition") + let filenameResolver = BrowserDownloadFilenameResolver() + if filenameResolver.shouldPrintPDFAfterLoad( + mimeType: mime, + responseURL: navigationResponse.response.url, + isForMainFrame: navigationResponse.isForMainFrame + ) { + shouldPrintAfterCurrentNavigationFinishes = true + } + let isUserActivatedPreviouslyRenderedSubframePDF = subframeDownloadIntents + .consumeUserActivatedPreviouslyRenderedSubframePDF( + responseURL: navigationResponse.response.url, + mimeType: mime, + isForMainFrame: navigationResponse.isForMainFrame + ) let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) + || isUserActivatedPreviouslyRenderedSubframePDF if !navigationResponse.isForMainFrame, allowsSubframeDownload, let url = navigationResponse.response.url, @@ -369,12 +393,13 @@ import WebKit decisionHandler(.cancel) return } - if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( + if let reason = filenameResolver.navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, contentDisposition: contentDisposition, isForMainFrame: navigationResponse.isForMainFrame, - allowsSubframeDownload: allowsSubframeDownload + allowsSubframeDownload: allowsSubframeDownload, + isUserActivatedPreviouslyRenderedSubframePDF: isUserActivatedPreviouslyRenderedSubframePDF ) { #if DEBUG cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") @@ -383,6 +408,11 @@ import WebKit return } + subframeDownloadIntents.markRenderedSubframePDFIfNeeded( + responseURL: navigationResponse.response.url, + mimeType: mime, + isForMainFrame: navigationResponse.isForMainFrame + ) decisionHandler(.allow) } @@ -406,3 +436,19 @@ import WebKit download.delegate = downloadDelegate } } + +extension WKWebView { + @MainActor + func cmuxRunPrintOperation() { + guard #available(macOS 11.0, *) else { return } + let printInfo = (NSPrintInfo.shared.copy() as? NSPrintInfo) ?? NSPrintInfo() + let operation = printOperation(with: printInfo) + operation.showsPrintPanel = true + operation.showsProgressPanel = true + if let window { + operation.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil) + } else { + operation.run() + } + } +} diff --git a/Sources/Panels/BrowserPopupPanel.swift b/Sources/Panels/BrowserPopupPanel.swift new file mode 100644 index 000000000000..d1e5cc937afc --- /dev/null +++ b/Sources/Panels/BrowserPopupPanel.swift @@ -0,0 +1,58 @@ +import AppKit + +func browserPopupContentRect( + requestedWidth: CGFloat?, + requestedHeight: CGFloat?, + requestedX: CGFloat?, + requestedTopY: CGFloat?, + visibleFrame: NSRect, + defaultWidth: CGFloat = 800, + defaultHeight: CGFloat = 600, + minWidth: CGFloat = 200, + minHeight: CGFloat = 150 +) -> NSRect { + let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width) + let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height) + + let x: CGFloat + let y: CGFloat + if let requestedX, let requestedTopY { + x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth)) + + // Web content expresses popup Y as distance from the screen's top edge, + // while AppKit window origins are bottom-up. + let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight + y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight)) + } else { + x = visibleFrame.midX - clampedWidth / 2 + y = visibleFrame.midY - clampedHeight / 2 + } + + return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) +} + +private func browserPopupPanelShouldSuppressStaleCloseTabShortcut(_ event: NSEvent) -> Bool { + let closeTabShortcut = KeyboardShortcutSettings.shortcut(for: .closeTab) + guard closeTabShortcut.isUnbound || closeTabShortcut != KeyboardShortcutSettings.Action.closeTab.defaultShortcut else { + return false + } + return KeyboardShortcutSettings.Action.closeTab.defaultShortcut.matches(event: event) +} + +/// NSPanel subclass that intercepts the configured Close Tab shortcut before the swizzled +/// `cmux_performKeyEquivalent` can dispatch it to the main menu's +/// "Close Tab" action (which would close the parent browser tab). +final class BrowserPopupPanel: NSPanel { + override func performKeyEquivalent(with event: NSEvent) -> Bool { + if AppDelegate.shared?.handleBrowserPopupCloseShortcutKeyEquivalent(event: event, popupWindow: self) == true { + return true + } + if browserPopupPanelShouldSuppressStaleCloseTabShortcut(event) { + #if DEBUG + cmuxDebugLog("popup.panel.closeShortcut suppressStaleDefault") + #endif + return true + } + return super.performKeyEquivalent(with: event) + } +} diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 1dea956ec6c1..cb6cd23fbe13 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -4,63 +4,6 @@ import CmuxFoundation import ObjectiveC import WebKit -func browserPopupContentRect( - requestedWidth: CGFloat?, - requestedHeight: CGFloat?, - requestedX: CGFloat?, - requestedTopY: CGFloat?, - visibleFrame: NSRect, - defaultWidth: CGFloat = 800, - defaultHeight: CGFloat = 600, - minWidth: CGFloat = 200, - minHeight: CGFloat = 150 -) -> NSRect { - let clampedWidth = min(max(requestedWidth ?? defaultWidth, minWidth), visibleFrame.width) - let clampedHeight = min(max(requestedHeight ?? defaultHeight, minHeight), visibleFrame.height) - - let x: CGFloat - let y: CGFloat - if let requestedX, let requestedTopY { - x = max(visibleFrame.minX, min(requestedX, visibleFrame.maxX - clampedWidth)) - - // Web content expresses popup Y as distance from the screen's top edge, - // while AppKit window origins are bottom-up. - let appKitY = visibleFrame.maxY - requestedTopY - clampedHeight - y = max(visibleFrame.minY, min(appKitY, visibleFrame.maxY - clampedHeight)) - } else { - x = visibleFrame.midX - clampedWidth / 2 - y = visibleFrame.midY - clampedHeight / 2 - } - - return NSRect(x: x, y: y, width: clampedWidth, height: clampedHeight) -} - -private func browserPopupPanelShouldSuppressStaleCloseTabShortcut(_ event: NSEvent) -> Bool { - let closeTabShortcut = KeyboardShortcutSettings.shortcut(for: .closeTab) - guard closeTabShortcut.isUnbound || closeTabShortcut != KeyboardShortcutSettings.Action.closeTab.defaultShortcut else { - return false - } - return KeyboardShortcutSettings.Action.closeTab.defaultShortcut.matches(event: event) -} - -/// NSPanel subclass that intercepts the configured Close Tab shortcut before the swizzled -/// `cmux_performKeyEquivalent` can dispatch it to the main menu's -/// "Close Tab" action (which would close the parent browser tab). -final class BrowserPopupPanel: NSPanel { - override func performKeyEquivalent(with event: NSEvent) -> Bool { - if AppDelegate.shared?.handleBrowserPopupCloseShortcutKeyEquivalent(event: event, popupWindow: self) == true { - return true - } - if browserPopupPanelShouldSuppressStaleCloseTabShortcut(event) { - #if DEBUG - cmuxDebugLog("popup.panel.closeShortcut suppressStaleDefault") - #endif - return true - } - return super.performKeyEquivalent(with: event) - } -} - /// Hosts a popup `CmuxWebView` in a standalone `NSPanel`, created when a page /// calls `window.open()` (scripted new-window requests). /// @@ -607,6 +550,7 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { var downloadDelegate: WKDownloadDelegate? private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator() + private var shouldPrintAfterCurrentNavigationFinishes = false func cancelPendingHTTPBasicAuthPrompts() { basicAuthPromptCoordinator.cancelAll() @@ -635,7 +579,10 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { decisionHandler(.cancel) return } - subframeDownloadIntents.updateIfNeeded(navigationAction) + subframeDownloadIntents.updateIfNeeded( + navigationAction, + hasUserActivation: browserNavigationHasSimpleUserActivation() + ) // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { @@ -680,8 +627,23 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { } let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") + let filenameResolver = BrowserDownloadFilenameResolver() + if filenameResolver.shouldPrintPDFAfterLoad( + mimeType: navigationResponse.response.mimeType, + responseURL: navigationResponse.response.url, + isForMainFrame: navigationResponse.isForMainFrame + ) { + shouldPrintAfterCurrentNavigationFinishes = true + } + let isUserActivatedPreviouslyRenderedSubframePDF = subframeDownloadIntents + .consumeUserActivatedPreviouslyRenderedSubframePDF( + responseURL: navigationResponse.response.url, + mimeType: navigationResponse.response.mimeType, + isForMainFrame: navigationResponse.isForMainFrame + ) let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) + || isUserActivatedPreviouslyRenderedSubframePDF if !navigationResponse.isForMainFrame, allowsSubframeDownload, let url = navigationResponse.response.url, @@ -689,20 +651,37 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { decisionHandler(.cancel) return } - if BrowserDownloadFilenameResolver().navigationResponseDownloadReason( + if filenameResolver.navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, contentDisposition: contentDisposition, isForMainFrame: navigationResponse.isForMainFrame, - allowsSubframeDownload: allowsSubframeDownload + allowsSubframeDownload: allowsSubframeDownload, + isUserActivatedPreviouslyRenderedSubframePDF: isUserActivatedPreviouslyRenderedSubframePDF ) != nil { decisionHandler(.download) return } + subframeDownloadIntents.markRenderedSubframePDFIfNeeded( + responseURL: navigationResponse.response.url, + mimeType: navigationResponse.response.mimeType, + isForMainFrame: navigationResponse.isForMainFrame + ) decisionHandler(.allow) } + func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + shouldPrintAfterCurrentNavigationFinishes = false + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if shouldPrintAfterCurrentNavigationFinishes { + shouldPrintAfterCurrentNavigationFinishes = false + webView.cmuxRunPrintOperation() + } + } + func webView( _ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index b6f16d18318d..f6b0fe3b0ad5 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -6,12 +6,17 @@ final class BrowserSubframeDownloadIntentTracker { private static let maxIntentCount = 64 private var recentIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] + private var recentUserActivatedSubframeNavigationKeys: [(key: String, recordedAt: TimeInterval)] = [] + private var renderedSubframePDFKeys: [String] = [] - func updateIfNeeded(_ navigationAction: WKNavigationAction) { + func updateIfNeeded(_ navigationAction: WKNavigationAction, hasUserActivation: Bool = false) { guard navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } + if hasUserActivation { + recordUserActivatedSubframeNavigation(url) + } if navigationAction.navigationType == .linkActivated { record(url) return @@ -52,8 +57,47 @@ final class BrowserSubframeDownloadIntentTracker { return false } + func recordUserActivatedSubframeNavigation(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.subframePDFIntentKey(for: url) + recentUserActivatedSubframeNavigationKeys.removeAll { $0.key == key } + recentUserActivatedSubframeNavigationKeys.append((key, now)) + if recentUserActivatedSubframeNavigationKeys.count > Self.maxIntentCount { + recentUserActivatedSubframeNavigationKeys.removeFirst(recentUserActivatedSubframeNavigationKeys.count - Self.maxIntentCount) + } + } + + func consumeUserActivatedPreviouslyRenderedSubframePDF(responseURL: URL?, mimeType: String?, isForMainFrame: Bool) -> Bool { + guard !isForMainFrame, + Self.isPDFMIMEType(mimeType), + let responseURL, + Self.isHTTPDownloadIntentURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.subframePDFIntentKey(for: responseURL) + guard renderedSubframePDFKeys.contains(key), + let index = recentUserActivatedSubframeNavigationKeys.firstIndex(where: { $0.key == key }) else { return false } + recentUserActivatedSubframeNavigationKeys.remove(at: index) + return true + } + + func markRenderedSubframePDFIfNeeded(responseURL: URL?, mimeType: String?, isForMainFrame: Bool) { + guard !isForMainFrame, + Self.isPDFMIMEType(mimeType), + let responseURL, + Self.isHTTPDownloadIntentURL(responseURL) else { return } + let key = Self.subframePDFIntentKey(for: responseURL) + recentUserActivatedSubframeNavigationKeys.removeAll { $0.key == key } + renderedSubframePDFKeys.removeAll { $0 == key } + renderedSubframePDFKeys.append(key) + if renderedSubframePDFKeys.count > Self.maxIntentCount { + renderedSubframePDFKeys.removeFirst(renderedSubframePDFKeys.count - Self.maxIntentCount) + } + } + private func prune(now: TimeInterval) { recentIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } + recentUserActivatedSubframeNavigationKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } } private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { @@ -61,6 +105,12 @@ final class BrowserSubframeDownloadIntentTracker { return scheme == "http" || scheme == "https" } + private static func isPDFMIMEType(_ mimeType: String?) -> Bool { + mimeType?.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("application/pdf") == .orderedSame + } + private static func downloadIntentKey(for url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString @@ -68,4 +118,13 @@ final class BrowserSubframeDownloadIntentTracker { components.fragment = nil return components.string ?? url.absoluteString } + + private static func subframePDFIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.query = nil + components.fragment = nil + return components.string ?? url.absoluteString + } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b50398fe226e..2230e7b18afb 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -143,6 +143,7 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; + C67540040000000000000001 /* BrowserPopupPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540040000000000000002 /* BrowserPopupPanel.swift */; }; A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; 7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */; }; BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; }; @@ -1339,6 +1340,7 @@ 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; + C67540040000000000000002 /* BrowserPopupPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupPanel.swift; sourceTree = ""; }; A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReliabilityRegressionUITests.swift; sourceTree = ""; }; 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = ""; }; @@ -2960,6 +2962,7 @@ B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */, B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */, C76D07ED88D244B1B02FAF3C /* BrowserOmnibarSubmitSupport.swift */, + C67540040000000000000002 /* BrowserPopupPanel.swift */, A5007421 /* BrowserPopupWindowController.swift */, A50100000000000000000001 /* BrowserWebAuthnAssertionExtensions.swift */, A50100000000000000000003 /* BrowserWebAuthnAssertionPublicKeyOptions.swift */, @@ -4028,6 +4031,7 @@ A5001402 /* BrowserPanel.swift in Sources */, C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + C67540040000000000000001 /* BrowserPopupPanel.swift in Sources */, A5007420 /* BrowserPopupWindowController.swift in Sources */, BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */, 4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */, diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 7a740640760b..3a213caa68d5 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -87,6 +87,28 @@ import WebKit ) == "cannotShowMIME") } + @Test func pdfViewerToolbarDownloadAndPrintPolicies() throws { + let renderedPDFURL = try #require(URL(string: "https://mail-attachment.example.test/report.pdf?token=1&disp=inline")) + let toolbarPDFURL = try #require(URL(string: "https://mail-attachment.example.test/report.pdf?token=2&disp=download")) + let printURL = try #require(URL(string: "https://docs-viewer.example.test/report.pdf?print=true")) + #expect(resolver.navigationResponseDownloadReason( + mimeType: "application/pdf", canShowMIMEType: true, contentDisposition: nil, + isForMainFrame: false, isUserActivatedPreviouslyRenderedSubframePDF: true + ) == "subframePDFUserAction") + #expect(!resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: false)) + #expect(resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: true)) + let tracker = BrowserSubframeDownloadIntentTracker() + tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) + #expect(!tracker.consumeUserActivatedPreviouslyRenderedSubframePDF( + responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false + )) + tracker.markRenderedSubframePDFIfNeeded(responseURL: renderedPDFURL, mimeType: "application/pdf", isForMainFrame: false) + tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) + #expect(tracker.consumeUserActivatedPreviouslyRenderedSubframePDF( + responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false + )) + } + @Test func subframeDownloadIntentTrackerTransfersRedirectIntent() throws { let tracker = BrowserSubframeDownloadIntentTracker() let source = try #require(URL(string: "https://mail.example.test/attachment?id=1#frag")) From c21c4411d1739d7337d0216ebd11baa1be2c4924 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 19:59:59 -0700 Subject: [PATCH 41/59] Gate PDF print toolbar intents --- .../BrowserDownloadFilenameResolver.swift | 15 +++++-- .../Panels/BrowserNavigationDelegate.swift | 17 ++++++- Sources/Panels/BrowserPanel.swift | 4 +- .../Panels/BrowserPopupWindowController.swift | 19 -------- ...BrowserSubframeDownloadIntentTracker.swift | 44 +++++++++++++++++++ ...BrowserDownloadFilenameResolverTests.swift | 18 ++++---- 6 files changed, 84 insertions(+), 33 deletions(-) diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index c0d6cb0bb84a..b5c8bcc6fdb1 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -46,9 +46,18 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { return canShowMIMEType ? nil : "cannotShowMIME" } - func shouldPrintPDFAfterLoad(mimeType: String?, responseURL: URL?, isForMainFrame: Bool) -> Bool { - guard isForMainFrame, isPDFMIMEType(mimeType) else { return false } - guard let components = responseURL.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) else { + func shouldPrintPDFAfterLoad( + mimeType: String?, + responseURL: URL?, + isForMainFrame: Bool, + hasTrustedPrintIntent: Bool + ) -> Bool { + guard hasTrustedPrintIntent, isForMainFrame, isPDFMIMEType(mimeType) else { return false } + return isPDFPrintRequestURL(responseURL) + } + + func isPDFPrintRequestURL(_ url: URL?) -> Bool { + guard let components = url.flatMap({ URLComponents(url: $0, resolvingAgainstBaseURL: false) }) else { return false } return components.queryItems?.contains { diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 354a3f545e42..d5c04c8964d0 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -367,10 +367,16 @@ import WebKit let contentDisposition = (navigationResponse.response as? HTTPURLResponse)? .value(forHTTPHeaderField: "Content-Disposition") let filenameResolver = BrowserDownloadFilenameResolver() + let hasTrustedPDFPrintIntent = subframeDownloadIntents.consumePDFPrintIntent( + responseURL: navigationResponse.response.url, + mimeType: mime, + isForMainFrame: navigationResponse.isForMainFrame + ) if filenameResolver.shouldPrintPDFAfterLoad( mimeType: mime, responseURL: navigationResponse.response.url, - isForMainFrame: navigationResponse.isForMainFrame + isForMainFrame: navigationResponse.isForMainFrame, + hasTrustedPrintIntent: hasTrustedPDFPrintIntent ) { shouldPrintAfterCurrentNavigationFinishes = true } @@ -420,6 +426,15 @@ import WebKit subframeDownloadIntents.record(url) } + func recordPDFPrintIntent(_ url: URL) { + subframeDownloadIntents.recordPDFPrintIntent(url) + } + + func recordPDFPrintIntentIfNeeded(_ request: URLRequest) { + guard browserNavigationHasSimpleUserActivation(), let url = request.url else { return } + recordPDFPrintIntent(url) + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG cmuxDebugLog("download.didBecome source=navigationAction") diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index d78aa05bf514..e6ec81cf9b18 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4107,8 +4107,8 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } - browserUIDelegate.requestNavigation = { [weak self] request, intent in - self?.requestNavigation(request, intent: intent) + browserUIDelegate.requestNavigation = { [weak self, weak navDelegate] request, intent in + navDelegate?.recordPDFPrintIntentIfNeeded(request); self?.requestNavigation(request, intent: intent) } browserUIDelegate.presentAlert = { [weak self] alert, webView, completion, cancel in guard let self else { diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index cb6cd23fbe13..b7c4200ca5b5 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -550,7 +550,6 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { var downloadDelegate: WKDownloadDelegate? private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator() - private var shouldPrintAfterCurrentNavigationFinishes = false func cancelPendingHTTPBasicAuthPrompts() { basicAuthPromptCoordinator.cancelAll() @@ -628,13 +627,6 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { let contentDisposition = (navigationResponse.response as? HTTPURLResponse)?.value(forHTTPHeaderField: "Content-Disposition") let filenameResolver = BrowserDownloadFilenameResolver() - if filenameResolver.shouldPrintPDFAfterLoad( - mimeType: navigationResponse.response.mimeType, - responseURL: navigationResponse.response.url, - isForMainFrame: navigationResponse.isForMainFrame - ) { - shouldPrintAfterCurrentNavigationFinishes = true - } let isUserActivatedPreviouslyRenderedSubframePDF = subframeDownloadIntents .consumeUserActivatedPreviouslyRenderedSubframePDF( responseURL: navigationResponse.response.url, @@ -671,17 +663,6 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { decisionHandler(.allow) } - func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { - shouldPrintAfterCurrentNavigationFinishes = false - } - - func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { - if shouldPrintAfterCurrentNavigationFinishes { - shouldPrintAfterCurrentNavigationFinishes = false - webView.cmuxRunPrintOperation() - } - } - func webView( _ webView: WKWebView, didReceive challenge: URLAuthenticationChallenge, diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index f6b0fe3b0ad5..68bc38f3c2e6 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -7,6 +7,7 @@ final class BrowserSubframeDownloadIntentTracker { private var recentIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] private var recentUserActivatedSubframeNavigationKeys: [(key: String, recordedAt: TimeInterval)] = [] + private var recentPDFPrintIntentKeys: [(key: String, recordedAt: TimeInterval)] = [] private var renderedSubframePDFKeys: [String] = [] func updateIfNeeded(_ navigationAction: WKNavigationAction, hasUserActivation: Bool = false) { @@ -95,9 +96,35 @@ final class BrowserSubframeDownloadIntentTracker { } } + func recordPDFPrintIntent(_ url: URL) { + guard Self.isHTTPDownloadIntentURL(url), + Self.isPDFPrintRequestURL(url) else { return } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.pdfPrintIntentKey(for: url) + recentPDFPrintIntentKeys.removeAll { $0.key == key } + recentPDFPrintIntentKeys.append((key, now)) + if recentPDFPrintIntentKeys.count > Self.maxIntentCount { + recentPDFPrintIntentKeys.removeFirst(recentPDFPrintIntentKeys.count - Self.maxIntentCount) + } + } + + func consumePDFPrintIntent(responseURL: URL?, mimeType: String?, isForMainFrame: Bool) -> Bool { + guard isForMainFrame, + Self.isPDFMIMEType(mimeType), + let responseURL, + Self.isHTTPDownloadIntentURL(responseURL), + Self.isPDFPrintRequestURL(responseURL) else { return false } + let now = ProcessInfo.processInfo.systemUptime; prune(now: now) + let key = Self.pdfPrintIntentKey(for: responseURL) + guard let index = recentPDFPrintIntentKeys.firstIndex(where: { $0.key == key }) else { return false } + recentPDFPrintIntentKeys.remove(at: index) + return true + } + private func prune(now: TimeInterval) { recentIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } recentUserActivatedSubframeNavigationKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } + recentPDFPrintIntentKeys.removeAll { now - $0.recordedAt > Self.intentLifetime } } private static func isHTTPDownloadIntentURL(_ url: URL) -> Bool { @@ -111,6 +138,14 @@ final class BrowserSubframeDownloadIntentTracker { .caseInsensitiveCompare("application/pdf") == .orderedSame } + private static func isPDFPrintRequestURL(_ url: URL) -> Bool { + guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } + return components.queryItems?.contains { + $0.name.caseInsensitiveCompare("print") == .orderedSame && + (($0.value ?? "").caseInsensitiveCompare("true") == .orderedSame || $0.value == "1") + } == true + } + private static func downloadIntentKey(for url: URL) -> String { guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url.absoluteString @@ -127,4 +162,13 @@ final class BrowserSubframeDownloadIntentTracker { components.fragment = nil return components.string ?? url.absoluteString } + + private static func pdfPrintIntentKey(for url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.query = nil + components.fragment = nil + return components.string ?? url.absoluteString + } } diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 3a213caa68d5..d01250cbb81e 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -91,22 +91,24 @@ import WebKit let renderedPDFURL = try #require(URL(string: "https://mail-attachment.example.test/report.pdf?token=1&disp=inline")) let toolbarPDFURL = try #require(URL(string: "https://mail-attachment.example.test/report.pdf?token=2&disp=download")) let printURL = try #require(URL(string: "https://docs-viewer.example.test/report.pdf?print=true")) + let redirectedPrintURL = try #require(URL(string: "https://docs-viewer.example.test/report.pdf?print=1&nonce=2")) #expect(resolver.navigationResponseDownloadReason( mimeType: "application/pdf", canShowMIMEType: true, contentDisposition: nil, isForMainFrame: false, isUserActivatedPreviouslyRenderedSubframePDF: true ) == "subframePDFUserAction") - #expect(!resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: false)) - #expect(resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: true)) + #expect(!resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: true, hasTrustedPrintIntent: false)) + #expect(!resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: false, hasTrustedPrintIntent: true)) + #expect(resolver.shouldPrintPDFAfterLoad(mimeType: "application/pdf", responseURL: printURL, isForMainFrame: true, hasTrustedPrintIntent: true)) let tracker = BrowserSubframeDownloadIntentTracker() + #expect(!tracker.consumePDFPrintIntent(responseURL: printURL, mimeType: "application/pdf", isForMainFrame: true)) + tracker.recordPDFPrintIntent(printURL) + #expect(tracker.consumePDFPrintIntent(responseURL: redirectedPrintURL, mimeType: "application/pdf", isForMainFrame: true)) + #expect(!tracker.consumePDFPrintIntent(responseURL: redirectedPrintURL, mimeType: "application/pdf", isForMainFrame: true)) tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) - #expect(!tracker.consumeUserActivatedPreviouslyRenderedSubframePDF( - responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false - )) + #expect(!tracker.consumeUserActivatedPreviouslyRenderedSubframePDF(responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false)) tracker.markRenderedSubframePDFIfNeeded(responseURL: renderedPDFURL, mimeType: "application/pdf", isForMainFrame: false) tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) - #expect(tracker.consumeUserActivatedPreviouslyRenderedSubframePDF( - responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false - )) + #expect(tracker.consumeUserActivatedPreviouslyRenderedSubframePDF(responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false)) } @Test func subframeDownloadIntentTrackerTransfersRedirectIntent() throws { From 588335467b95b29897b305cccd86d1d953f9e458 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 20:14:41 -0700 Subject: [PATCH 42/59] Use MainActor for download notifications --- Sources/Panels/CmuxWebView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index e68c4df11b9b..3aa001563d90 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -1614,7 +1614,7 @@ final class CmuxWebView: WKWebView { if Thread.isMainThread { onContextMenuDownloadStateChanged?(downloading) } else { - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in self?.onContextMenuDownloadStateChanged?(downloading) } } @@ -1624,7 +1624,7 @@ final class CmuxWebView: WKWebView { if Thread.isMainThread { onSessionDownloadEvent?(event) } else { - DispatchQueue.main.async { [weak self] in + Task { @MainActor [weak self] in self?.onSessionDownloadEvent?(event) } } From 10ee538aa404783b29964238cad21a3e39431029 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 20:35:13 -0700 Subject: [PATCH 43/59] Deduplicate browser download prompt events --- Sources/TerminalController.swift | 44 +++++++++---------- ...erminalControllerSocketSecurityTests.swift | 4 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index e2ef736a7d86..5b5bae255363 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -261,7 +261,7 @@ class TerminalController { private var v2BrowserInitStylesBySurface: [UUID: [String]] = [:] private var v2BrowserDialogQueueBySurface: [UUID: [V2BrowserPendingDialog]] = [:] private var v2BrowserDownloadEventsBySurface: [UUID: [[String: Any]]] = [:] - private var v2ConsumedBrowserDownloadIDsBySurface: [UUID: [String]] = [:] + private var v2ConsumedBrowserDownloadKeysBySurface: [UUID: [String]] = [:] private var v2BrowserUnsupportedNetworkRequestsBySurface: [UUID: [[String: Any]]] = [:] private nonisolated let v2BrowserUndefinedSentinel = V2BrowserUndefinedSentinel() /// Stateless browser-control logic (JS builders, value normalization, @@ -284,7 +284,7 @@ class TerminalController { v2BrowserInitStylesBySurface.removeValue(forKey: surfaceId) v2BrowserDialogQueueBySurface.removeValue(forKey: surfaceId) v2BrowserDownloadEventsBySurface.removeValue(forKey: surfaceId) - v2ConsumedBrowserDownloadIDsBySurface.removeValue(forKey: surfaceId) + v2ConsumedBrowserDownloadKeysBySurface.removeValue(forKey: surfaceId) v2BrowserUnsupportedNetworkRequestsBySurface.removeValue(forKey: surfaceId) v2BrowserElementRefs = v2BrowserElementRefs.filter { $0.value.surfaceId != surfaceId } @@ -8620,9 +8620,7 @@ class TerminalController { queue.removeAll { v2DownloadID(from: $0) == downloadID } } queue.append(event) - if queue.count > Self.v2ConsumedBrowserDownloadIDLimit { - queue.removeFirst(queue.count - Self.v2ConsumedBrowserDownloadIDLimit) - } + if queue.count > Self.v2ConsumedBrowserDownloadIDLimit { queue.removeFirst(queue.count - Self.v2ConsumedBrowserDownloadIDLimit) } v2BrowserDownloadEventsBySurface[surfaceId] = queue } @@ -8632,12 +8630,9 @@ class TerminalController { let first = remaining.removeFirst() v2BrowserDownloadEventsBySurface[surfaceId] = remaining guard v2ShouldStoreBrowserDownloadEvent(first, surfaceId: surfaceId) else { continue } - if (first["type"] as? String) != "started" { - if v2IsTerminalBrowserDownloadEvent(first) { - v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) - } - return first - } + guard (first["type"] as? String) != "started" else { continue } + v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) + return first } return nil } @@ -8653,20 +8648,25 @@ class TerminalController { private func v2ShouldStoreBrowserDownloadEvent(_ event: [String: Any], surfaceId: UUID) -> Bool { guard let downloadID = v2DownloadID(from: event) else { return true } - return !(v2ConsumedBrowserDownloadIDsBySurface[surfaceId]?.contains(downloadID) ?? false) + let consumed = v2ConsumedBrowserDownloadKeysBySurface[surfaceId] ?? [] + if consumed.contains(downloadID) { return false } + guard let type = (event["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { return true } + return !consumed.contains("\(type)\u{0}\(downloadID)") } func v2MarkBrowserDownloadEventConsumed(_ event: [String: Any], surfaceId: UUID) { - guard let downloadID = v2DownloadID(from: event) else { return } - var consumed = v2ConsumedBrowserDownloadIDsBySurface[surfaceId] ?? [] - consumed.removeAll { $0 == downloadID } - consumed.append(downloadID) - if consumed.count > Self.v2ConsumedBrowserDownloadIDLimit { - consumed.removeFirst(consumed.count - Self.v2ConsumedBrowserDownloadIDLimit) - } - v2ConsumedBrowserDownloadIDsBySurface[surfaceId] = consumed + guard let downloadID = v2DownloadID(from: event), let type = (event["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty else { return } + let isTerminal = v2IsTerminalBrowserDownloadEvent(event) + let eventKey = "\(type)\u{0}\(downloadID)" + let consumedKey = isTerminal ? downloadID : eventKey + var consumed = v2ConsumedBrowserDownloadKeysBySurface[surfaceId] ?? [] + consumed.removeAll { $0 == consumedKey } + consumed.append(consumedKey) + if consumed.count > Self.v2ConsumedBrowserDownloadIDLimit { consumed.removeFirst(consumed.count - Self.v2ConsumedBrowserDownloadIDLimit) } + v2ConsumedBrowserDownloadKeysBySurface[surfaceId] = consumed v2BrowserDownloadEventsBySurface[surfaceId]?.removeAll { - v2DownloadID(from: $0) == downloadID + if isTerminal { return v2DownloadID(from: $0) == downloadID } + return v2DownloadID(from: $0) == downloadID && (($0["type"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty == type) } } @@ -8773,7 +8773,7 @@ class TerminalController { if let observer { NotificationCenter.default.removeObserver(observer) } - if let event, v2MainSync({ v2IsTerminalBrowserDownloadEvent(event) }) { + if let event { v2MainSync { v2MarkBrowserDownloadEventConsumed(event, surfaceId: surfaceId) } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index b6aef5848cfe..beb526404bf1 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -129,9 +129,9 @@ final class TerminalControllerSocketSecurityTests { let returned = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(returned?["type"] as? String, "ready_to_save") - + recordDownloadEvent("ready_to_save", id: "download-1", surfaceId: surfaceId) + XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) recordDownloadEvent("saved", id: "download-1", surfaceId: surfaceId, path: "/tmp/report.csv") - let saved = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) XCTAssertEqual(saved?["type"] as? String, "saved") XCTAssertEqual(saved?["path"] as? String, "/tmp/report.csv") From e6aa389c61b47e00dfcc2ed2f7e8d4f91111f937 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 20:47:38 -0700 Subject: [PATCH 44/59] Avoid consuming HTTP bypass for subframe downloads --- Sources/Panels/BrowserNavigationDelegate.swift | 5 +++-- Sources/Panels/BrowserPanel.swift | 5 ++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index d5c04c8964d0..bb080713e072 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -15,6 +15,7 @@ import WebKit var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? var presentAlert: BrowserAlertPresenter = browserPresentAlert var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? + var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? /// Direct reference to the download delegate - must be set synchronously in didBecome callbacks. var downloadDelegate: WKDownloadDelegate? @@ -289,7 +290,7 @@ import WebKit if navigationAction.shouldPerformDownload { if navigationAction.targetFrame?.isMainFrame == false, let url = navigationAction.request.url, - shouldBlockInsecureHTTPNavigation?(url) == true { + shouldBlockInsecureHTTPSubframeDownload?(url) == true { #if DEBUG cmuxDebugLog("browser.nav.decidePolicy.action kind=cancelDownload reason=insecureHTTPSubframe url=\(url.absoluteString)") #endif @@ -392,7 +393,7 @@ import WebKit if !navigationResponse.isForMainFrame, allowsSubframeDownload, let url = navigationResponse.response.url, - shouldBlockInsecureHTTPNavigation?(url) == true { + shouldBlockInsecureHTTPSubframeDownload?(url) == true { #if DEBUG cmuxDebugLog("download.policy=cancel reason=insecureHTTPSubframe url=\(url.absoluteString)") #endif diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index e6ec81cf9b18..f5c1dae7ed78 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3992,9 +3992,8 @@ final class BrowserPanel: Panel, ObservableObject { } self.presentBrowserAlert(alert, in: webView, completion: completion, cancel: cancel) } - navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] url in - self?.shouldBlockInsecureHTTPNavigation(to: url) ?? false - } + navDelegate.shouldBlockInsecureHTTPNavigation = { [weak self] in self?.shouldBlockInsecureHTTPNavigation(to: $0) ?? false } + navDelegate.shouldBlockInsecureHTTPSubframeDownload = { browserShouldBlockInsecureHTTPURL($0) } navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in self?.presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false) } From 2c3ed067e8dbb10d96739bc2a9a759e0cb7dfc47 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 20:57:06 -0700 Subject: [PATCH 45/59] Require trusted subframe download intents --- .../Panels/BrowserSubframeDownloadIntentTracker.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index 68bc38f3c2e6..9ec6c6085c27 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -15,13 +15,8 @@ final class BrowserSubframeDownloadIntentTracker { let url = navigationAction.request.url, Self.isHTTPDownloadIntentURL(url), (navigationAction.request.httpMethod?.uppercased() ?? "GET") == "GET" else { return } - if hasUserActivation { - recordUserActivatedSubframeNavigation(url) - } - if navigationAction.navigationType == .linkActivated { - record(url) - return - } + if hasUserActivation { recordUserActivatedSubframeNavigation(url) } + if navigationAction.navigationType == .linkActivated { return } guard let sourceURL = navigationAction.targetFrame?.request.url else { return } recordRedirectIfNeeded(from: sourceURL, to: url) } From 72e03e1507e6e225c6c08b423d8b95bdf4033de6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 21:07:14 -0700 Subject: [PATCH 46/59] Gate subframe action downloads --- .../Panels/BrowserNavigationDelegate.swift | 28 +++++++++++-------- .../Panels/BrowserPopupWindowController.swift | 14 ++++------ 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index bb080713e072..d6fb4682d7ab 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -229,10 +229,8 @@ import WebKit buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) - subframeDownloadIntents.updateIfNeeded( - navigationAction, - hasUserActivation: browserNavigationHasSimpleUserActivation() - ) + let hasUserActivation = browserNavigationHasSimpleUserActivation() + subframeDownloadIntents.updateIfNeeded(navigationAction, hasUserActivation: hasUserActivation) #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -288,14 +286,20 @@ import WebKit } if navigationAction.shouldPerformDownload { - if navigationAction.targetFrame?.isMainFrame == false, - let url = navigationAction.request.url, - shouldBlockInsecureHTTPSubframeDownload?(url) == true { - #if DEBUG - cmuxDebugLog("browser.nav.decidePolicy.action kind=cancelDownload reason=insecureHTTPSubframe url=\(url.absoluteString)") - #endif - decisionHandler(.cancel) - return + if navigationAction.targetFrame?.isMainFrame == false { + guard let url = navigationAction.request.url else { + decisionHandler(.cancel) + return + } + let hasRecordedIntent = subframeDownloadIntents.consume(for: url) + guard hasUserActivation || hasRecordedIntent else { decisionHandler(.cancel); return } + if shouldBlockInsecureHTTPSubframeDownload?(url) == true { + #if DEBUG + cmuxDebugLog("browser.nav.decidePolicy.action kind=cancelDownload reason=insecureHTTPSubframe url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } } decisionHandler(.download) return diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index b7c4200ca5b5..1f558ca76315 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -578,19 +578,15 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { decisionHandler(.cancel) return } - subframeDownloadIntents.updateIfNeeded( - navigationAction, - hasUserActivation: browserNavigationHasSimpleUserActivation() - ) + let hasUserActivation = browserNavigationHasSimpleUserActivation() + subframeDownloadIntents.updateIfNeeded(navigationAction, hasUserActivation: hasUserActivation) // Only guard main-frame navigations guard navigationAction.targetFrame?.isMainFrame != false else { if navigationAction.shouldPerformDownload { - if browserShouldBlockInsecureHTTPURL(url) { - decisionHandler(.cancel) - } else { - decisionHandler(.download) - } + let hasRecordedIntent = subframeDownloadIntents.consume(for: url) + guard hasUserActivation || hasRecordedIntent else { decisionHandler(.cancel); return } + decisionHandler(browserShouldBlockInsecureHTTPURL(url) ? .cancel : .download) return } decisionHandler(.allow) From 2101441ef9e250d4bdc589f0d827c112d80862ea Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 21:18:11 -0700 Subject: [PATCH 47/59] Record dynamic subframe download intents --- .../CmuxWebView+ScriptedDownloads.swift | 67 ---------- ...WebView+SubframeDownloadIntentScript.swift | 122 +++++++++++++++++- 2 files changed, 117 insertions(+), 72 deletions(-) diff --git a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift index 630e8c934346..0daad062d3e0 100644 --- a/Sources/Panels/CmuxWebView+ScriptedDownloads.swift +++ b/Sources/Panels/CmuxWebView+ScriptedDownloads.swift @@ -35,7 +35,6 @@ extension CmuxWebView { let blobDownloadInFlight = false; let lastTrustedActivationMs = 0; let lastDownloadPostMs = 0; - let armSubframeDownloadObserver = () => {}; const handledAnchors = typeof WeakSet === "function" ? new WeakSet() : null; try { @@ -63,7 +62,6 @@ extension CmuxWebView { try { if (event && event.isTrusted) { lastTrustedActivationMs = Date.now(); - armSubframeDownloadObserver(); } } catch (_) {} }; @@ -253,71 +251,6 @@ extension CmuxWebView { event.stopPropagation(); }, true); - if (!isMainFrame && typeof MutationObserver === "function") { - let observerDisconnectTimer = 0; - let observer = null; - const disconnectObserver = () => { - try { - if (observerDisconnectTimer) { - clearTimeout(observerDisconnectTimer); - observerDisconnectTimer = 0; - } - observer?.disconnect(); - } catch (_) {} - }; - const scheduleObserverDisconnect = () => { - try { - if (observerDisconnectTimer) clearTimeout(observerDisconnectTimer); - const remaining = trustedActivationWindowMs - (Date.now() - lastTrustedActivationMs); - if (remaining <= 0) { - disconnectObserver(); - return; - } - observerDisconnectTimer = setTimeout(disconnectObserver, remaining + 50); - } catch (_) {} - }; - const inspectAddedNode = (node) => { - try { - if (!hasRecentTrustedActivation()) return; - if (!node || node.nodeType !== 1) return; - const candidates = []; - const tag = String(node.tagName || "").toUpperCase(); - if ((tag === "A" || tag === "AREA") && node.href) candidates.push(node); - const nested = node.querySelectorAll?.("a[href][download],area[href][download]") ?? []; - for (const anchor of nested) candidates.push(anchor); - for (const anchor of candidates) { - if (interceptAnchorDownload(anchor, null)) { - handledAnchors?.add(anchor); - disconnectObserver(); - return; - } - } - } catch (_) {} - }; - observer = new MutationObserver((mutations) => { - try { - if (!hasRecentTrustedActivation()) { - disconnectObserver(); - return; - } - for (const mutation of mutations) { - for (const node of mutation.addedNodes || []) { - inspectAddedNode(node); - } - } - } catch (_) {} - }); - armSubframeDownloadObserver = () => { - try { - if (!hasRecentTrustedActivation()) return; - const root = document.documentElement || document; - if (!root) return; - observer.observe(root, { childList: true, subtree: true }); - scheduleObserverDisconnect(); - } catch (_) {} - }; - } - ["pointerdown", "mousedown", "keydown", "click"].forEach((eventName) => { document.addEventListener(eventName, noteTrustedActivation, true); }); diff --git a/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift b/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift index 3e22c068e81a..5446d752d7cc 100644 --- a/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift +++ b/Sources/Panels/CmuxWebView+SubframeDownloadIntentScript.swift @@ -31,7 +31,11 @@ extension CmuxWebView { })(); if (!handler) return false; const postMessage = handler.postMessage.bind(handler); + const trustedActivationWindowMs = 2000; let lastIntentPostMs = 0; + let lastTrustedActivationMs = 0; + let observerDisconnectTimer = 0; + let observer = null; const reserveIntentPost = () => { const now = Date.now(); @@ -40,6 +44,26 @@ extension CmuxWebView { return true; }; + const hasRecentTrustedActivation = () => { + try { + return Date.now() - lastTrustedActivationMs <= trustedActivationWindowMs; + } catch (_) { + return false; + } + }; + + const postHTTPIntent = (href) => { + try { + const value = String(href || ""); + const scheme = value.split(":", 1)[0].toLowerCase(); + if ((scheme === "http" || scheme === "https") && reserveIntentPost()) { + postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: value }); + return true; + } + } catch (_) {} + return false; + }; + const anchorForEvent = (event) => { try { const path = typeof event.composedPath === "function" ? event.composedPath() : []; @@ -55,19 +79,107 @@ extension CmuxWebView { } }; + const postAnchorDownloadIntent = (anchor) => { + try { + if (!anchor || !anchor.hasAttribute("download")) return false; + return postHTTPIntent(anchor.href || anchor.getAttribute("href") || ""); + } catch (_) {} + return false; + }; + + const disconnectObserver = () => { + try { + if (observerDisconnectTimer) { + clearTimeout(observerDisconnectTimer); + observerDisconnectTimer = 0; + } + observer?.disconnect(); + } catch (_) {} + }; + + const scheduleObserverDisconnect = () => { + try { + if (observerDisconnectTimer) clearTimeout(observerDisconnectTimer); + const remaining = trustedActivationWindowMs - (Date.now() - lastTrustedActivationMs); + if (remaining <= 0) { + disconnectObserver(); + return; + } + observerDisconnectTimer = setTimeout(disconnectObserver, remaining + 50); + } catch (_) {} + }; + + const inspectAddedNode = (node) => { + try { + if (!hasRecentTrustedActivation() || !node || node.nodeType !== 1) return; + const candidates = []; + const tag = String(node.tagName || "").toUpperCase(); + if ((tag === "A" || tag === "AREA") && node.href) candidates.push(node); + const nested = node.querySelectorAll?.("a[href][download],area[href][download]") ?? []; + for (const anchor of nested) candidates.push(anchor); + for (const anchor of candidates) { + if (postAnchorDownloadIntent(anchor)) { + disconnectObserver(); + return; + } + } + } catch (_) {} + }; + + const armSubframeDownloadObserver = () => { + try { + if (!hasRecentTrustedActivation() || typeof MutationObserver !== "function") return; + if (!observer) { + observer = new MutationObserver((mutations) => { + try { + if (!hasRecentTrustedActivation()) { + disconnectObserver(); + return; + } + for (const mutation of mutations) { + for (const node of mutation.addedNodes || []) inspectAddedNode(node); + } + } catch (_) {} + }); + } + const root = document.documentElement || document; + if (!root) return; + observer.observe(root, { childList: true, subtree: true }); + scheduleObserverDisconnect(); + } catch (_) {} + }; + + const noteTrustedActivation = (event) => { + try { + if (!event || !event.isTrusted) return; + lastTrustedActivationMs = Date.now(); + armSubframeDownloadObserver(); + } catch (_) {} + }; + document.addEventListener("click", (event) => { try { if (!event || !event.isTrusted) return; + noteTrustedActivation(event); const anchor = anchorForEvent(event); if (!anchor) return; - const href = String(anchor.href || anchor.getAttribute("href") || ""); - const scheme = href.split(":", 1)[0].toLowerCase(); - if ((scheme === "http" || scheme === "https") && reserveIntentPost()) { - postMessage({ kind: "subframeDownloadIntent", token: bridgeToken, url: href }); - } + postHTTPIntent(anchor.href || anchor.getAttribute("href") || ""); } catch (_) {} }, true); + ["pointerdown", "mousedown", "keydown"].forEach((eventName) => { + document.addEventListener(eventName, noteTrustedActivation, true); + }); + + const anchorPrototype = window.HTMLAnchorElement?.prototype ?? null; + const originalAnchorClick = anchorPrototype?.click ?? null; + if (typeof originalAnchorClick === "function") { + anchorPrototype.click = function() { + if (hasRecentTrustedActivation()) postAnchorDownloadIntent(this); + return originalAnchorClick.apply(this, arguments); + }; + } + return true; } catch (_) { return false; From ca34d11edca1384ecfd75e3b32acd3f74bba189b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 21:35:06 -0700 Subject: [PATCH 48/59] Extract browser session download saver --- .../Panels/BrowserSessionDownloadSaver.swift | 242 +++++++++++++++++ Sources/Panels/CmuxWebView.swift | 257 +----------------- cmux.xcodeproj/project.pbxproj | 4 + 3 files changed, 261 insertions(+), 242 deletions(-) create mode 100644 Sources/Panels/BrowserSessionDownloadSaver.swift diff --git a/Sources/Panels/BrowserSessionDownloadSaver.swift b/Sources/Panels/BrowserSessionDownloadSaver.swift new file mode 100644 index 000000000000..927d82b2625a --- /dev/null +++ b/Sources/Panels/BrowserSessionDownloadSaver.swift @@ -0,0 +1,242 @@ +import AppKit +import Foundation + +@MainActor +final class BrowserSessionDownloadSaver { + typealias DownloadStateNotifier = (Bool) -> Void + typealias EventNotifier = ([String: Any]) -> Void + typealias DebugLogger = (String) -> Void + typealias FallbackRunner = (Selector?, AnyObject?, Any?, String, String) -> Void + + private let parentWindow: () -> NSWindow? + private let notifyDownloadState: DownloadStateNotifier + private let notifyEvent: EventNotifier + private let debugLog: DebugLogger + private let runFallback: FallbackRunner + + init( + parentWindow: @escaping () -> NSWindow?, + notifyDownloadState: @escaping DownloadStateNotifier, + notifyEvent: @escaping EventNotifier, + debugLog: @escaping DebugLogger, + runFallback: @escaping FallbackRunner + ) { + self.parentWindow = parentWindow + self.notifyDownloadState = notifyDownloadState + self.notifyEvent = notifyEvent + self.debugLog = debugLog + self.runFallback = runFallback + } + + func finish( + data: Data, + saveName: String, + sourceURL: URL?, + traceID: String, + logCategory: String, + sender: Any?, + fallbackAction: Selector?, + fallbackTarget: AnyObject?, + failureFallbackReason: String? + ) { + let filenameResolver = BrowserDownloadFilenameResolver() + let downloadID = UUID().uuidString + notifyEvent(["type": "started", "download_id": downloadID, "filename": saveName]) + let handleWriteResult: (Result, Bool) -> Void = { [weak self] result, shouldClearDownloadState in + guard let self else { return } + if shouldClearDownloadState { self.notifyDownloadState(false) } + switch result { + case .success(let destinationURL): + self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=") + self.notifyEvent(["type": "saved", "download_id": downloadID, "filename": saveName, "path": destinationURL.path]) + case .failure(let error): + self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)") + self.notifyEvent(["type": "failed", "download_id": downloadID, "filename": saveName, "error": error.localizedDescription]) + if let failureFallbackReason { + self.runFallback(fallbackAction, fallbackTarget, sender, traceID, failureFallbackReason) + } + } + } + + if filenameResolver.shouldAskWhereToSaveDownloads() { + promptForDestination( + data: data, + saveName: saveName, + sourceURL: sourceURL, + filenameResolver: filenameResolver, + downloadID: downloadID, + traceID: traceID, + logCategory: logCategory, + completion: { result in handleWriteResult(result, false) } + ) + return + } + + autoSaveInBackground( + data, + saveName: saveName, + sourceURL: sourceURL, + filenameResolver: filenameResolver, + traceID: traceID, + logCategory: logCategory, + completion: { result in handleWriteResult(result, true) } + ) + } + + private func promptForDestination( + data: Data, + saveName: String, + sourceURL: URL?, + filenameResolver: BrowserDownloadFilenameResolver, + downloadID: String, + traceID: String, + logCategory: String, + completion: @escaping (Result) -> Void + ) { + let savePanel = NSSavePanel() + savePanel.nameFieldStringValue = saveName + savePanel.canCreateDirectories = true + savePanel.directoryURL = filenameResolver.downloadsDirectory() + notifyDownloadState(false) + notifyEvent(["type": "ready_to_save", "download_id": downloadID, "filename": saveName]) + debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=") + let panelCompletion: (NSApplication.ModalResponse) -> Void = { [weak self] result in + guard let self else { return } + guard result == .OK, let destURL = savePanel.url else { + self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel") + self.notifyEvent(["type": "cancelled", "download_id": downloadID, "filename": saveName]) + return + } + self.writeInBackground(data, destinationURL: destURL, sourceURL: sourceURL, replaceExisting: true, completion: completion) + } + if let parentWindow = parentWindow() { + savePanel.beginSheetModal(for: parentWindow, completionHandler: panelCompletion) + } else { + savePanel.begin(completionHandler: panelCompletion) + } + } + + private func writeInBackground( + _ data: Data, + destinationURL: URL, + sourceURL: URL?, + replaceExisting: Bool, + completion: @escaping (Result) -> Void + ) { + Task { @MainActor in + let result = await Task.detached(priority: .utility) { + Self.write(data, to: destinationURL, sourceURL: sourceURL, replaceExisting: replaceExisting) + }.value + completion(result) + } + } + + private func autoSaveInBackground( + _ data: Data, + saveName: String, + sourceURL: URL?, + filenameResolver: BrowserDownloadFilenameResolver, + traceID: String, + logCategory: String, + completion: @escaping (Result) -> Void + ) { + Task { @MainActor in + let result = await Task.detached(priority: .utility) { + Self.autoSave(data, saveName: saveName, sourceURL: sourceURL, filenameResolver: filenameResolver) + }.value + if case .success = result { + self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=") + } + completion(result) + } + } + + private nonisolated static func autoSave( + _ data: Data, + saveName: String, + sourceURL: URL?, + filenameResolver: BrowserDownloadFilenameResolver + ) -> Result { + Result { + let fileManager = FileManager.default + let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + var lastCollisionError: Error? + for _ in 0..<100 { + let destinationURL = filenameResolver.uniqueDownloadDestination( + suggestedFilename: saveName, + in: directory, + fileManager: fileManager + ) + do { + try writeWithoutReplacing(data, to: destinationURL, sourceURL: sourceURL, fileManager: fileManager) + return destinationURL + } catch { + guard fileManager.fileExists(atPath: destinationURL.path) else { throw error } + lastCollisionError = error + } + } + throw lastCollisionError ?? CocoaError(.fileWriteUnknown) + } + } + + private nonisolated static func write( + _ data: Data, + to destinationURL: URL, + sourceURL: URL?, + replaceExisting: Bool + ) -> Result { + Result { + if replaceExisting { + try writeReplacing(data, to: destinationURL, sourceURL: sourceURL, fileManager: .default) + } else { + try writeWithoutReplacing(data, to: destinationURL, sourceURL: sourceURL, fileManager: .default) + } + return destinationURL + } + } + + private nonisolated static func writeReplacing( + _ data: Data, + to destinationURL: URL, + sourceURL: URL?, + fileManager: FileManager + ) throws { + let tempURL = temporaryURL(for: destinationURL) + do { + try data.write(to: tempURL, options: .atomic) + try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) + if fileManager.fileExists(atPath: destinationURL.path) { + _ = try fileManager.replaceItemAt(destinationURL, withItemAt: tempURL) + } else { + try fileManager.moveItem(at: tempURL, to: destinationURL) + } + } catch { + try? fileManager.removeItem(at: tempURL) + throw error + } + } + + private nonisolated static func writeWithoutReplacing( + _ data: Data, + to destinationURL: URL, + sourceURL: URL?, + fileManager: FileManager + ) throws { + let tempURL = temporaryURL(for: destinationURL) + do { + try data.write(to: tempURL, options: .atomic) + try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) + try fileManager.moveItem(at: tempURL, to: destinationURL) + } catch { + try? fileManager.removeItem(at: tempURL) + throw error + } + } + + private nonisolated static func temporaryURL(for destinationURL: URL) -> URL { + destinationURL + .deletingLastPathComponent() + .appendingPathComponent(".cmux-\(UUID().uuidString).download", isDirectory: false) + } +} diff --git a/Sources/Panels/CmuxWebView.swift b/Sources/Panels/CmuxWebView.swift index 3aa001563d90..cadb05cef85b 100644 --- a/Sources/Panels/CmuxWebView.swift +++ b/Sources/Panels/CmuxWebView.swift @@ -381,6 +381,15 @@ final class CmuxWebView: WKWebView { private static let pasteAsPlainTextKeyCode: UInt16 = 9 // V key (hardware position, layout-independent) var onContextMenuDownloadStateChanged: ((Bool) -> Void)? var onSessionDownloadEvent: (([String: Any]) -> Void)? + private lazy var sessionDownloadSaver = BrowserSessionDownloadSaver( + parentWindow: { [weak self] in self?.window }, + notifyDownloadState: { [weak self] in self?.notifyContextMenuDownloadState($0) }, + notifyEvent: { [weak self] in self?.notifySessionDownloadEvent($0) }, + debugLog: { [weak self] in self?.debugContextDownload($0) }, + runFallback: { [weak self] action, target, sender, traceID, reason in + self?.runContextMenuFallback(action: action, target: target, sender: sender, traceID: traceID, reason: reason) + } + ) /// Called when "Open Link in New Tab" context menu is selected. /// Bypasses createWebViewWith so the link opens as a tab, not a popup. var onContextMenuOpenLinkInNewTab: ((URL) -> Void)? @@ -1641,255 +1650,19 @@ final class CmuxWebView: WKWebView { fallbackTarget: AnyObject?, failureFallbackReason: String? ) { - let filenameResolver = BrowserDownloadFilenameResolver() - let downloadID = UUID().uuidString - notifySessionDownloadEvent([ - "type": "started", - "download_id": downloadID, - "filename": saveName, - ]) - let handleWriteResult: (Result, Bool) -> Void = { [weak self] result, shouldClearDownloadState in - guard let self else { return } - if shouldClearDownloadState { self.notifyContextMenuDownloadState(false) } - switch result { - case .success(let destinationURL): - self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=" - ) - self.notifySessionDownloadEvent([ - "type": "saved", - "download_id": downloadID, - "filename": saveName, - "path": destinationURL.path, - ]) - case .failure(let error): - self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)" - ) - self.notifySessionDownloadEvent([ - "type": "failed", - "download_id": downloadID, - "filename": saveName, - "error": error.localizedDescription, - ]) - if let failureFallbackReason { - self.runContextMenuFallback( - action: fallbackAction, - target: fallbackTarget, - sender: sender, - traceID: traceID, - reason: failureFallbackReason - ) - } - } - } - - if filenameResolver.shouldAskWhereToSaveDownloads() { - let savePanel = NSSavePanel() - savePanel.nameFieldStringValue = saveName - savePanel.canCreateDirectories = true - savePanel.directoryURL = filenameResolver.downloadsDirectory() - notifyContextMenuDownloadState(false) - notifySessionDownloadEvent([ - "type": "ready_to_save", - "download_id": downloadID, - "filename": saveName, - ]) - debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt shown=1 defaultName=" - ) - let completion: (NSApplication.ModalResponse) -> Void = { result in - guard result == .OK, let destURL = savePanel.url else { - self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=savePrompt result=cancel" - ) - self.notifySessionDownloadEvent([ - "type": "cancelled", - "download_id": downloadID, - "filename": saveName, - ]) - return - } - self.writeSessionDownloadDataInBackground( - data, - destinationURL: destURL, - sourceURL: sourceURL, - replaceExisting: true, - completion: { result in handleWriteResult(result, false) } - ) - } - if let parentWindow = window { - savePanel.beginSheetModal(for: parentWindow, completionHandler: completion) - } else { - savePanel.begin(completionHandler: completion) - } - return - } - - autoSaveSessionDownloadDataInBackground( - data, + sessionDownloadSaver.finish( + data: data, saveName: saveName, sourceURL: sourceURL, - filenameResolver: filenameResolver, traceID: traceID, logCategory: logCategory, - completion: { result in handleWriteResult(result, true) } + sender: sender, + fallbackAction: fallbackAction, + fallbackTarget: fallbackTarget, + failureFallbackReason: failureFallbackReason ) } - private func writeSessionDownloadDataInBackground( - _ data: Data, - destinationURL: URL, - sourceURL: URL?, - replaceExisting: Bool, - completion: @escaping (Result) -> Void - ) { - Task { @MainActor in - let result = await Task.detached(priority: .utility) { - Self.writeSessionDownloadData( - data, - to: destinationURL, - sourceURL: sourceURL, - replaceExisting: replaceExisting - ) - }.value - completion(result) - } - } - - private func autoSaveSessionDownloadDataInBackground( - _ data: Data, - saveName: String, - sourceURL: URL?, - filenameResolver: BrowserDownloadFilenameResolver, - traceID: String, - logCategory: String, - completion: @escaping (Result) -> Void - ) { - Task { @MainActor in - let result = await Task.detached(priority: .utility) { - Self.autoSaveSessionDownloadData( - data, - saveName: saveName, - sourceURL: sourceURL, - filenameResolver: filenameResolver - ) - }.value - if case .success = result { - self.debugContextDownload( - "browser.ctxdl.\(logCategory) trace=\(traceID) stage=autoSave path=" - ) - } - completion(result) - } - } - - private nonisolated static func autoSaveSessionDownloadData( - _ data: Data, - saveName: String, - sourceURL: URL?, - filenameResolver: BrowserDownloadFilenameResolver - ) -> Result { - Result { - let fileManager = FileManager.default - let directory = filenameResolver.downloadsDirectory(fileManager: fileManager) - try fileManager.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) - var lastCollisionError: Error? - for _ in 0..<100 { - let destinationURL = filenameResolver.uniqueDownloadDestination( - suggestedFilename: saveName, - in: directory, - fileManager: fileManager - ) - do { - try writeSessionDownloadDataWithoutReplacing( - data, - to: destinationURL, - sourceURL: sourceURL, - fileManager: fileManager - ) - return destinationURL - } catch { - guard fileManager.fileExists(atPath: destinationURL.path) else { - throw error - } - lastCollisionError = error - } - } - throw lastCollisionError ?? CocoaError(.fileWriteUnknown) - } - } - - private nonisolated static func writeSessionDownloadData( - _ data: Data, - to destinationURL: URL, - sourceURL: URL?, - replaceExisting: Bool - ) -> Result { - Result { - if replaceExisting { - try writeSessionDownloadDataReplacing( - data, - to: destinationURL, - sourceURL: sourceURL, - fileManager: .default - ) - } else { - try writeSessionDownloadDataWithoutReplacing( - data, - to: destinationURL, - sourceURL: sourceURL, - fileManager: .default - ) - } - return destinationURL - } - } - - private nonisolated static func writeSessionDownloadDataReplacing( - _ data: Data, - to destinationURL: URL, - sourceURL: URL?, - fileManager: FileManager - ) throws { - let tempURL = temporarySessionDownloadURL(for: destinationURL) - do { - try data.write(to: tempURL, options: .atomic) - try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) - if fileManager.fileExists(atPath: destinationURL.path) { - _ = try fileManager.replaceItemAt(destinationURL, withItemAt: tempURL) - } else { - try fileManager.moveItem(at: tempURL, to: destinationURL) - } - } catch { - try? fileManager.removeItem(at: tempURL) - throw error - } - } - - private nonisolated static func writeSessionDownloadDataWithoutReplacing( - _ data: Data, - to destinationURL: URL, - sourceURL: URL?, - fileManager: FileManager - ) throws { - let tempURL = temporarySessionDownloadURL(for: destinationURL) - do { - try data.write(to: tempURL, options: .atomic) - try tempURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) - try fileManager.moveItem(at: tempURL, to: destinationURL) - } catch { - try? fileManager.removeItem(at: tempURL) - throw error - } - } - - private nonisolated static func temporarySessionDownloadURL(for destinationURL: URL) -> URL { - destinationURL - .deletingLastPathComponent() - .appendingPathComponent(".cmux-\(UUID().uuidString).download", isDirectory: false) - } - func downloadURLViaSession( _ url: URL, suggestedFilename: String?, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 096d00a99cf7..d612d0482753 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */; }; 4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */; }; A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; + C67540050000000000000001 /* BrowserSessionDownloadSaver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540050000000000000002 /* BrowserSessionDownloadSaver.swift */; }; C67540010000000000000001 /* BrowserSubframeDownloadIntentTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */; }; 4BBF42E8A86EAAB94BC9EA16 /* BrowserSystemProxyMirror.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048DACB3F8147BCBDD266297 /* BrowserSystemProxyMirror.swift */; }; C7B800062D4202A13C962D2D /* BrowserSystemProxyMirrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D828DA0070335773EBAE83F /* BrowserSystemProxyMirrorTests.swift */; }; @@ -1355,6 +1356,7 @@ 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotPipeline.swift; sourceTree = ""; }; 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotSnapshotter.swift; sourceTree = ""; }; A5008370 /* BrowserSearchOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserSearchOverlay.swift; sourceTree = ""; }; + C67540050000000000000002 /* BrowserSessionDownloadSaver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSessionDownloadSaver.swift; sourceTree = ""; }; C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSubframeDownloadIntentTracker.swift; sourceTree = ""; }; 048DACB3F8147BCBDD266297 /* BrowserSystemProxyMirror.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSystemProxyMirror.swift; sourceTree = ""; }; 8D828DA0070335773EBAE83F /* BrowserSystemProxyMirrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserSystemProxyMirrorTests.swift; sourceTree = ""; }; @@ -2923,6 +2925,7 @@ C62530010000000000000002 /* BrowserPanelReloadMode.swift */, C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */, C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */, + C67540050000000000000002 /* BrowserSessionDownloadSaver.swift */, C67540010000000000000002 /* BrowserSubframeDownloadIntentTracker.swift */, C6255D010000000000000002 /* CmuxWebView+ScriptedDownloads.swift */, C67540030000000000000002 /* CmuxWebView+SubframeDownloadIntentScript.swift */, @@ -4059,6 +4062,7 @@ 4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */, 4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */, A5008371 /* BrowserSearchOverlay.swift in Sources */, + C67540050000000000000001 /* BrowserSessionDownloadSaver.swift in Sources */, C67540010000000000000001 /* BrowserSubframeDownloadIntentTracker.swift in Sources */, 4BBF42E8A86EAAB94BC9EA16 /* BrowserSystemProxyMirror.swift in Sources */, 0796994CA7CCA25DB990BFEF /* BrowserSystemProxyWatcher.swift in Sources */, From feebd877875ce4f289a1d0ece61ba4fdbc3a69a0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 21:53:14 -0700 Subject: [PATCH 49/59] Scope PDF print intents to rendered subframes --- Sources/Panels/BrowserNavigationDelegate.swift | 10 +++++++--- Sources/Panels/BrowserPanel.swift | 9 +++++---- .../Panels/BrowserSubframeDownloadIntentTracker.swift | 7 +++++++ cmuxTests/BrowserDownloadFilenameResolverTests.swift | 3 +++ 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index d6fb4682d7ab..0881dd08a142 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -435,9 +435,13 @@ import WebKit subframeDownloadIntents.recordPDFPrintIntent(url) } - func recordPDFPrintIntentIfNeeded(_ request: URLRequest) { - guard browserNavigationHasSimpleUserActivation(), let url = request.url else { return } - recordPDFPrintIntent(url) + func recordPDFPrintIntentIfNeeded(_ request: URLRequest, sourceFrame: WKFrameInfo?) { + guard let url = request.url else { return } + subframeDownloadIntents.recordPDFPrintIntent( + url, + sourceFrameURL: sourceFrame?.request.url, + sourceIsMainFrame: sourceFrame?.isMainFrame ?? true + ) } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f5c1dae7ed78..326d4431791d 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4106,9 +4106,8 @@ final class BrowserPanel: Panel, ObservableObject { guard let self else { return } self.openLinkInNewTab(url: url) } - browserUIDelegate.requestNavigation = { [weak self, weak navDelegate] request, intent in - navDelegate?.recordPDFPrintIntentIfNeeded(request); self?.requestNavigation(request, intent: intent) - } + browserUIDelegate.requestNavigation = { [weak self] in self?.requestNavigation($0, intent: $1) } + browserUIDelegate.recordPDFPrintIntent = { [weak navDelegate] in navDelegate?.recordPDFPrintIntentIfNeeded($0, sourceFrame: $1) } browserUIDelegate.presentAlert = { [weak self] alert, webView, completion, cancel in guard let self else { cancel() @@ -8752,7 +8751,7 @@ func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( private class BrowserUIDelegate: NSObject, WKUIDelegate { var openInNewTab: ((URL) -> Void)? - var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?; var recordPDFPrintIntent: ((URLRequest, WKFrameInfo?) -> Void)? var presentAlert: BrowserAlertPresenter = browserPresentAlert var openPopup: ((WKWebViewConfiguration, WKWindowFeatures) -> WKWebView?)? var closeRequested: ((WKWebView) -> Void)? @@ -8850,6 +8849,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { ) #endif if let requestNavigation { + recordPDFPrintIntent?(navigationAction.request, navigationAction.sourceFrame) requestNavigation(navigationAction.request, .currentTab) } else { browserLoadRequest(navigationAction.request, in: webView) @@ -8885,6 +8885,7 @@ private class BrowserUIDelegate: NSObject, WKUIDelegate { "url=\(browserNavigationDebugURL(url))" ) #endif + recordPDFPrintIntent?(navigationAction.request, navigationAction.sourceFrame) requestNavigation(navigationAction.request, intent) } else { #if DEBUG diff --git a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift index 9ec6c6085c27..949cc8517e7d 100644 --- a/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift +++ b/Sources/Panels/BrowserSubframeDownloadIntentTracker.swift @@ -103,6 +103,13 @@ final class BrowserSubframeDownloadIntentTracker { } } + func recordPDFPrintIntent(_ url: URL, sourceFrameURL: URL?, sourceIsMainFrame: Bool) { + guard !sourceIsMainFrame, + let sourceFrameURL, + renderedSubframePDFKeys.contains(Self.subframePDFIntentKey(for: sourceFrameURL)) else { return } + recordPDFPrintIntent(url) + } + func consumePDFPrintIntent(responseURL: URL?, mimeType: String?, isForMainFrame: Bool) -> Bool { guard isForMainFrame, Self.isPDFMIMEType(mimeType), diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index d01250cbb81e..f6e8077d766e 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -104,9 +104,12 @@ import WebKit tracker.recordPDFPrintIntent(printURL) #expect(tracker.consumePDFPrintIntent(responseURL: redirectedPrintURL, mimeType: "application/pdf", isForMainFrame: true)) #expect(!tracker.consumePDFPrintIntent(responseURL: redirectedPrintURL, mimeType: "application/pdf", isForMainFrame: true)) + tracker.recordPDFPrintIntent(printURL, sourceFrameURL: toolbarPDFURL, sourceIsMainFrame: true); #expect(!tracker.consumePDFPrintIntent(responseURL: printURL, mimeType: "application/pdf", isForMainFrame: true)) + tracker.recordPDFPrintIntent(printURL, sourceFrameURL: toolbarPDFURL, sourceIsMainFrame: false); #expect(!tracker.consumePDFPrintIntent(responseURL: printURL, mimeType: "application/pdf", isForMainFrame: true)) tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) #expect(!tracker.consumeUserActivatedPreviouslyRenderedSubframePDF(responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false)) tracker.markRenderedSubframePDFIfNeeded(responseURL: renderedPDFURL, mimeType: "application/pdf", isForMainFrame: false) + tracker.recordPDFPrintIntent(printURL, sourceFrameURL: toolbarPDFURL, sourceIsMainFrame: false); #expect(tracker.consumePDFPrintIntent(responseURL: printURL, mimeType: "application/pdf", isForMainFrame: true)) tracker.recordUserActivatedSubframeNavigation(toolbarPDFURL) #expect(tracker.consumeUserActivatedPreviouslyRenderedSubframePDF(responseURL: toolbarPDFURL, mimeType: "application/pdf", isForMainFrame: false)) } From 949d203d2d34ef6aa9bc43abd625060b3042ec47 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 22:06:54 -0700 Subject: [PATCH 50/59] Preserve download quarantine and completion events --- Sources/Panels/BrowserPanel.swift | 2 +- Sources/Panels/BrowserSessionDownloadSaver.swift | 1 + Sources/TerminalController.swift | 5 ++--- cmuxTests/TerminalControllerSocketSecurityTests.swift | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 326d4431791d..211108772e47 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8427,7 +8427,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } else { try FileManager.default.moveItem(at: tempURL, to: destURL) } - self?.onDownloadSaved?(suggestedFilename, destURL, false, downloadID) + try destURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL); self?.onDownloadSaved?(suggestedFilename, destURL, false, downloadID) } catch { try? FileManager.default.removeItem(at: tempURL) self?.onDownloadFailed?(error, false, downloadID) diff --git a/Sources/Panels/BrowserSessionDownloadSaver.swift b/Sources/Panels/BrowserSessionDownloadSaver.swift index 927d82b2625a..2b02991ad44a 100644 --- a/Sources/Panels/BrowserSessionDownloadSaver.swift +++ b/Sources/Panels/BrowserSessionDownloadSaver.swift @@ -211,6 +211,7 @@ final class BrowserSessionDownloadSaver { } else { try fileManager.moveItem(at: tempURL, to: destinationURL) } + try destinationURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) } catch { try? fileManager.removeItem(at: tempURL) throw error diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 5b5bae255363..7fe2ccf7b129 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -8613,7 +8613,7 @@ class TerminalController { } func v2RecordBrowserDownloadEvent(surfaceId: UUID, event: [String: Any]) { - guard v2ShouldStoreBrowserDownloadEvent(event, surfaceId: surfaceId) else { return } + guard v2ShouldStoreBrowserDownloadEvent(event, surfaceId: surfaceId), (event["type"] as? String) != "started" else { return } var queue = v2BrowserDownloadEventsBySurface[surfaceId] ?? [] if v2IsTerminalBrowserDownloadEvent(event), let downloadID = v2DownloadID(from: event) { @@ -8629,8 +8629,7 @@ class TerminalController { while !remaining.isEmpty { let first = remaining.removeFirst() v2BrowserDownloadEventsBySurface[surfaceId] = remaining - guard v2ShouldStoreBrowserDownloadEvent(first, surfaceId: surfaceId) else { continue } - guard (first["type"] as? String) != "started" else { continue } + guard v2ShouldStoreBrowserDownloadEvent(first, surfaceId: surfaceId), (first["type"] as? String) != "started" else { continue } v2MarkBrowserDownloadEventConsumed(first, surfaceId: surfaceId) return first } diff --git a/cmuxTests/TerminalControllerSocketSecurityTests.swift b/cmuxTests/TerminalControllerSocketSecurityTests.swift index beb526404bf1..9dcc82d7c48d 100644 --- a/cmuxTests/TerminalControllerSocketSecurityTests.swift +++ b/cmuxTests/TerminalControllerSocketSecurityTests.swift @@ -132,8 +132,8 @@ final class TerminalControllerSocketSecurityTests { recordDownloadEvent("ready_to_save", id: "download-1", surfaceId: surfaceId) XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) recordDownloadEvent("saved", id: "download-1", surfaceId: surfaceId, path: "/tmp/report.csv") - let saved = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId) - XCTAssertEqual(saved?["type"] as? String, "saved") + for index in 0...140 { recordDownloadEvent("started", id: "started-\(index)", surfaceId: surfaceId) } + let saved = controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId); XCTAssertEqual(saved?["type"] as? String, "saved") XCTAssertEqual(saved?["path"] as? String, "/tmp/report.csv") XCTAssertNil(controller.v2PopBrowserDownloadEvent(surfaceId: surfaceId)) } From 89d51dab54664823b857c5d6095e12f51c7c3ea0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Fri, 26 Jun 2026 22:18:05 -0700 Subject: [PATCH 51/59] Treat final quarantine as best effort --- Sources/Panels/BrowserPanel.swift | 2 +- Sources/Panels/BrowserSessionDownloadSaver.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 211108772e47..b4fa93950193 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -8427,7 +8427,7 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } else { try FileManager.default.moveItem(at: tempURL, to: destURL) } - try destURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL); self?.onDownloadSaved?(suggestedFilename, destURL, false, downloadID) + try? destURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL); self?.onDownloadSaved?(suggestedFilename, destURL, false, downloadID) } catch { try? FileManager.default.removeItem(at: tempURL) self?.onDownloadFailed?(error, false, downloadID) diff --git a/Sources/Panels/BrowserSessionDownloadSaver.swift b/Sources/Panels/BrowserSessionDownloadSaver.swift index 2b02991ad44a..725a494894bd 100644 --- a/Sources/Panels/BrowserSessionDownloadSaver.swift +++ b/Sources/Panels/BrowserSessionDownloadSaver.swift @@ -211,7 +211,7 @@ final class BrowserSessionDownloadSaver { } else { try fileManager.moveItem(at: tempURL, to: destinationURL) } - try destinationURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) + try? destinationURL.cmuxApplyWebDownloadQuarantine(sourceURL: sourceURL) } catch { try? fileManager.removeItem(at: tempURL) throw error From 146f4befcccb9d64916ac665baab1fbe6e9aa4e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 27 Jun 2026 01:35:49 -0700 Subject: [PATCH 52/59] Sanitize browser download failure events --- Resources/Localizable.xcstrings | 17 +++++++++++++++++ Sources/Panels/BrowserPanel.swift | 4 ++-- .../Panels/BrowserSessionDownloadSaver.swift | 11 ++++++++--- .../BrowserDownloadFilenameResolverTests.swift | 6 +++--- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 91068fa28357..03ea75c141c9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -39228,6 +39228,23 @@ } } }, + "browser.download.error.generic": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロードに失敗しました" + } + } + } + }, "browser.downloadInProgress": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index b4fa93950193..dd7432b4f0eb 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4076,12 +4076,12 @@ final class BrowserPanel: Panel, ObservableObject { ] ) } - dlDelegate.onDownloadFailed = { [weak self] error, shouldEndActivity, downloadID in + dlDelegate.onDownloadFailed = { [weak self] _, shouldEndActivity, downloadID in guard let self else { return } if shouldEndActivity { self.endDownloadActivity() } var event: [String: Any] = [ "type": "failed", - "error": error.localizedDescription + "error": String(localized: "browser.download.error.generic", defaultValue: "Download failed") ] if let downloadID { event["download_id"] = downloadID diff --git a/Sources/Panels/BrowserSessionDownloadSaver.swift b/Sources/Panels/BrowserSessionDownloadSaver.swift index 725a494894bd..2383b1c501a3 100644 --- a/Sources/Panels/BrowserSessionDownloadSaver.swift +++ b/Sources/Panels/BrowserSessionDownloadSaver.swift @@ -49,9 +49,14 @@ final class BrowserSessionDownloadSaver { case .success(let destinationURL): self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveSuccess path=") self.notifyEvent(["type": "saved", "download_id": downloadID, "filename": saveName, "path": destinationURL.path]) - case .failure(let error): - self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=\(error.localizedDescription)") - self.notifyEvent(["type": "failed", "download_id": downloadID, "filename": saveName, "error": error.localizedDescription]) + case .failure: + self.debugLog("browser.ctxdl.\(logCategory) trace=\(traceID) stage=saveFailure error=") + self.notifyEvent([ + "type": "failed", + "download_id": downloadID, + "filename": saveName, + "error": String(localized: "browser.download.error.generic", defaultValue: "Download failed"), + ]) if let failureFallbackReason { self.runFallback(fallbackAction, fallbackTarget, sender, traceID, failureFallbackReason) } diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index f6e8077d766e..ba10ac5ab0be 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -256,7 +256,7 @@ import WebKit @MainActor @Test func promptedDownloadCompletionCallbacksPostFinalEvents() throws { - let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false) + let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false); defer { panel.close() } let delegate = try #require(panel.downloadDelegate) let capture = BrowserDownloadEventCapture() let observer = NotificationCenter.default.addObserver( @@ -286,12 +286,12 @@ import WebKit #expect(events[0]["filename"] as? String == "report.csv") #expect(events[0]["path"] as? String == savedURL.path) #expect(events[1]["filename"] as? String == "cancelled.txt") - #expect(events[2]["error"] as? String == "disk full") + #expect(events[2]["error"] as? String == String(localized: "browser.download.error.generic", defaultValue: "Download failed")) } @MainActor @Test func sessionDownloadBridgePostsAutomationEvents() throws { - let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false) + let panel = BrowserPanel(workspaceId: UUID(), renderInitialNavigation: false); defer { panel.close() } let capture = BrowserDownloadEventCapture() let observer = NotificationCenter.default.addObserver( forName: .browserDownloadEventDidArrive, From fec6a27990d38f50a77ad806126195fedfac235b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sat, 27 Jun 2026 02:50:06 -0700 Subject: [PATCH 53/59] Fix browser download event test compile --- cmuxTests/BrowserDownloadFilenameResolverTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index ba10ac5ab0be..75d7103b01ad 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -302,7 +302,7 @@ import WebKit } defer { NotificationCenter.default.removeObserver(observer) } - panel.webView.onSessionDownloadEvent?([ + (try #require(panel.webView as? CmuxWebView)).onSessionDownloadEvent?([ "type": "saved", "download_id": "session-download-1", "filename": "report.csv", From 0e6b6ba7e4fb88a59342579efe5e113d641101c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 28 Jun 2026 20:04:00 -0700 Subject: [PATCH 54/59] Add browser PDF toolbar and force-download subframe attachments - Surface Download/Print buttons in the omnibar when a PDF document is rendered (main frame or subframe), tracked via BrowserPanel .renderedPDFDocumentURL and navigation-delegate render callbacks. - Force-download subframe navigation responses that carry explicit download signals (Content-Disposition: attachment / force-download MIME), and apply the insecure-HTTP subframe block only once a download is actually chosen. Update resolver tests for the new classification. - Extract navigation popup policy, debug-URL helper, and the omnibar address button style out of BrowserPanel/BrowserPanelView into their own files; wire the new files into the Xcode project. - Localize the new PDF download/print strings (en + ja). Co-Authored-By: Claude Opus 4.8 --- Resources/Localizable.xcstrings | 34 +++ .../BrowserDownloadFilenameResolver.swift | 15 +- .../Panels/BrowserNavigationDebugURL.swift | 11 + .../Panels/BrowserNavigationDelegate.swift | 33 ++- .../Panels/BrowserNavigationPopupPolicy.swift | 200 +++++++++++++++ .../BrowserPDFDocumentToolbarButtons.swift | 33 +++ .../BrowserPanel+PDFDocumentActions.swift | 32 +++ Sources/Panels/BrowserPanel.swift | 231 ++---------------- Sources/Panels/BrowserPanelView.swift | 39 +-- .../Panels/BrowserPopupWindowController.swift | 13 +- .../Panels/OmnibarAddressButtonStyle.swift | 34 +++ cmux.xcodeproj/project.pbxproj | 20 ++ ...BrowserDownloadFilenameResolverTests.swift | 8 +- 13 files changed, 432 insertions(+), 271 deletions(-) create mode 100644 Sources/Panels/BrowserNavigationDebugURL.swift create mode 100644 Sources/Panels/BrowserNavigationPopupPolicy.swift create mode 100644 Sources/Panels/BrowserPDFDocumentToolbarButtons.swift create mode 100644 Sources/Panels/BrowserPanel+PDFDocumentActions.swift create mode 100644 Sources/Panels/OmnibarAddressButtonStyle.swift diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 03ea75c141c9..a565aaeb4ee2 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -39245,6 +39245,40 @@ } } }, + "browser.pdf.download": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Download PDF" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PDFをダウンロード" + } + } + } + }, + "browser.pdf.print": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Print PDF" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "PDFをプリント" + } + } + } + }, "browser.downloadInProgress": { "extractionState": "manual", "localizations": { diff --git a/Sources/Panels/BrowserDownloadFilenameResolver.swift b/Sources/Panels/BrowserDownloadFilenameResolver.swift index b5c8bcc6fdb1..087bdbdacf97 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -28,20 +28,17 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { allowsSubframeDownload: Bool = false, isUserActivatedPreviouslyRenderedSubframePDF: Bool = false ) -> String? { + if shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { + return "content-disposition" + } + if shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { + return "forceDownloadMIME" + } if !isForMainFrame, isUserActivatedPreviouslyRenderedSubframePDF, isPDFMIMEType(mimeType) { return "subframePDFUserAction" } - let canUseExplicitDownloadSignals = isForMainFrame || allowsSubframeDownload - if canUseExplicitDownloadSignals, - shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { - return "content-disposition" - } - if canUseExplicitDownloadSignals, - shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { - return "forceDownloadMIME" - } guard isForMainFrame else { return nil } return canShowMIMEType ? nil : "cannotShowMIME" } diff --git a/Sources/Panels/BrowserNavigationDebugURL.swift b/Sources/Panels/BrowserNavigationDebugURL.swift new file mode 100644 index 000000000000..70e00d6fb7cf --- /dev/null +++ b/Sources/Panels/BrowserNavigationDebugURL.swift @@ -0,0 +1,11 @@ +import Foundation + +func browserNavigationDebugURL(_ url: URL?) -> String { + guard let url, + var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return "nil" + } + components.query = nil + components.fragment = nil + return components.string ?? "\(url.scheme ?? "unknown")://\(url.host ?? "")" +} diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 0881dd08a142..f03e4d207629 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -17,6 +17,8 @@ import WebKit var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)? var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? + var didRenderPDFDocument: ((URL, Bool) -> Void)? + var didClearPDFDocument: (() -> 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 @@ -31,6 +33,7 @@ import WebKit func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastAttemptedURL = lastAttemptedURL ?? webView.url shouldPrintAfterCurrentNavigationFinishes = false + didClearPDFDocument?() didStartProvisionalNavigation?(webView) } @@ -394,16 +397,6 @@ import WebKit let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) || isUserActivatedPreviouslyRenderedSubframePDF - if !navigationResponse.isForMainFrame, - allowsSubframeDownload, - let url = navigationResponse.response.url, - shouldBlockInsecureHTTPSubframeDownload?(url) == true { - #if DEBUG - cmuxDebugLog("download.policy=cancel reason=insecureHTTPSubframe url=\(url.absoluteString)") - #endif - decisionHandler(.cancel) - return - } if let reason = filenameResolver.navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, @@ -412,6 +405,15 @@ import WebKit allowsSubframeDownload: allowsSubframeDownload, isUserActivatedPreviouslyRenderedSubframePDF: isUserActivatedPreviouslyRenderedSubframePDF ) { + if !navigationResponse.isForMainFrame, + let url = navigationResponse.response.url, + shouldBlockInsecureHTTPSubframeDownload?(url) == true { + #if DEBUG + cmuxDebugLog("download.policy=cancel reason=insecureHTTPSubframe url=\(url.absoluteString)") + #endif + decisionHandler(.cancel) + return + } #if DEBUG cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif @@ -424,6 +426,11 @@ import WebKit mimeType: mime, isForMainFrame: navigationResponse.isForMainFrame ) + if isPDFMIMEType(mime), let url = navigationResponse.response.url { + didRenderPDFDocument?(url, navigationResponse.isForMainFrame) + } else if navigationResponse.isForMainFrame { + didClearPDFDocument?() + } decisionHandler(.allow) } @@ -444,6 +451,12 @@ import WebKit ) } + private func isPDFMIMEType(_ mimeType: String?) -> Bool { + mimeType?.split(separator: ";", maxSplits: 1).first? + .trimmingCharacters(in: .whitespacesAndNewlines) + .caseInsensitiveCompare("application/pdf") == .orderedSame + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG cmuxDebugLog("download.didBecome source=navigationAction") diff --git a/Sources/Panels/BrowserNavigationPopupPolicy.swift b/Sources/Panels/BrowserNavigationPopupPolicy.swift new file mode 100644 index 000000000000..e5d491a475c0 --- /dev/null +++ b/Sources/Panels/BrowserNavigationPopupPolicy.swift @@ -0,0 +1,200 @@ +import AppKit +import Foundation +import WebKit + +func browserNavigationShouldOpenInNewTab( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + guard navigationType == .linkActivated || navigationType == .other else { + return false + } + + if modifierFlags.contains(.command) { + return true + } + if buttonNumber == 2 { + return true + } + // In some WebKit paths, middle-click arrives as buttonNumber=4. + // Recover intent when we just observed a local middle-click. + if buttonNumber == 4, hasRecentMiddleClickIntent { + return true + } + + // WebKit can omit buttonNumber for middle-click link activations. + if let currentEventType, + (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), + currentEventButtonNumber == 2 { + return true + } + return false +} + +func browserNavigationShouldCreatePopup( + navigationType: WKNavigationType, + modifierFlags: NSEvent.ModifierFlags, + buttonNumber: Int, + popupFeaturesWereSpecified: Bool = false, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber +) -> Bool { + let isUserNewTab = browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) + return navigationType == .other && popupFeaturesWereSpecified && !isUserNewTab +} + +func browserNavigationShouldFallbackNilTargetToNewTab( + navigationType: WKNavigationType +) -> Bool { + // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live + // web view so window.opener/postMessage remain intact across OAuth flows. + navigationType != .other +} + +func browserNavigationHasSimpleUserActivation( + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type +) -> Bool { + switch currentEventType { + case .keyDown, .keyUp, .leftMouseDown, .leftMouseUp: + return true + default: + return false + } +} + +func browserNavigationPopupFeaturesWereSpecified( + x: NSNumber?, + y: NSNumber?, + width: NSNumber?, + height: NSNumber?, + menuBarVisibility: NSNumber?, + statusBarVisibility: NSNumber?, + toolbarsVisibility: NSNumber?, + allowsResizing: NSNumber? +) -> Bool { + x != nil || + y != nil || + width != nil || + height != nil || + menuBarVisibility != nil || + statusBarVisibility != nil || + toolbarsVisibility != nil || + allowsResizing != nil +} + +func browserNavigationPopupFeaturesWereSpecified(windowFeatures: WKWindowFeatures) -> Bool { + browserNavigationPopupFeaturesWereSpecified( + x: windowFeatures.x, + y: windowFeatures.y, + width: windowFeatures.width, + height: windowFeatures.height, + menuBarVisibility: windowFeatures.menuBarVisibility, + statusBarVisibility: windowFeatures.statusBarVisibility, + toolbarsVisibility: windowFeatures.toolbarsVisibility, + allowsResizing: windowFeatures.allowsResizing + ) +} + +// Keep popup retargeting intentionally narrow. Explicit cross-host alias groups +// preserve known first-party search flows without guessing at the public suffix +// list for arbitrary hosted tenants, while same-host scripted popups stay on +// the popup path so opener-dependent browser flows keep working. +private let browserNavigationSimpleUserGesturePopupRetargetHostAliases: [Set] = [ + [ + "bilibili.com", + "search.bilibili.com", + "www.bilibili.com", + ], +] + +private func browserNavigationDefaultPort(for scheme: String) -> Int? { + switch scheme { + case "http": + return 80 + case "https": + return 443 + default: + return nil + } +} + +private func browserNavigationShouldRetargetSimpleUserGesturePopup( + requestURL: URL?, + openerURL: URL? +) -> Bool { + guard let requestURL, + let openerURL, + let requestScheme = requestURL.scheme?.lowercased(), !requestScheme.isEmpty, + let openerScheme = openerURL.scheme?.lowercased(), !openerScheme.isEmpty, + requestScheme == openerScheme, + (requestURL.port ?? browserNavigationDefaultPort(for: requestScheme)) + == (openerURL.port ?? browserNavigationDefaultPort(for: openerScheme)), + let requestHost = BrowserInsecureHTTPSettings.normalizeHost(requestURL.host ?? ""), + let openerHost = BrowserInsecureHTTPSettings.normalizeHost(openerURL.host ?? "") else { + return false + } + for aliases in browserNavigationSimpleUserGesturePopupRetargetHostAliases { + if requestHost != openerHost, + aliases.contains(requestHost), + aliases.contains(openerHost) { + return true + } + } + return false +} + +func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( + navigationType: WKNavigationType, + requestMethod: String?, + requestURL: URL?, + openerURL: URL?, + modifierFlags: NSEvent.ModifierFlags = [], + buttonNumber: Int = 0, + hasRecentMiddleClickIntent: Bool = false, + currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, + currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber, + popupFeaturesWereSpecified: Bool +) -> Bool { + guard navigationType == .other else { + return false + } + // Some sites use `window.open()` for plain same-site searches triggered by a + // direct keyboard submit or left-click, without requesting popup chrome or + // opener-style geometry. Route those to a normal tab while keeping + // cross-site/OAuth-style popups on the popup path. + guard browserNavigationHasSimpleUserActivation(currentEventType: currentEventType) else { + return false + } + guard !browserNavigationShouldOpenInNewTab( + navigationType: navigationType, + modifierFlags: modifierFlags, + buttonNumber: buttonNumber, + hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, + currentEventType: currentEventType, + currentEventButtonNumber: currentEventButtonNumber + ) else { + return false + } + guard (requestMethod ?? "GET").uppercased() == "GET" else { + return false + } + guard !popupFeaturesWereSpecified else { + return false + } + return browserNavigationShouldRetargetSimpleUserGesturePopup( + requestURL: requestURL, + openerURL: openerURL + ) +} diff --git a/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift b/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift new file mode 100644 index 000000000000..1d88df4eb43a --- /dev/null +++ b/Sources/Panels/BrowserPDFDocumentToolbarButtons.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct BrowserPDFDocumentToolbarButtons: View { + let panel: BrowserPanel + let iconPointSize: CGFloat + let hitSize: CGFloat + + var body: some View { + if panel.renderedPDFDocumentURL != nil { + Button(action: { + panel.downloadRenderedPDFDocument() + }) { + CmuxSystemSymbolImage(systemName: "square.and.arrow.down", pointSize: iconPointSize, weight: .medium) + .frame(width: hitSize, height: hitSize, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .safeHelp(String(localized: "browser.pdf.download", defaultValue: "Download PDF")) + .accessibilityLabel(String(localized: "browser.pdf.download", defaultValue: "Download PDF")) + + Button(action: { + panel.printRenderedPDFDocument() + }) { + CmuxSystemSymbolImage(systemName: "printer", pointSize: iconPointSize, weight: .medium) + .frame(width: hitSize, height: hitSize, alignment: .center) + .contentShape(Rectangle()) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .safeHelp(String(localized: "browser.pdf.print", defaultValue: "Print PDF")) + .accessibilityLabel(String(localized: "browser.pdf.print", defaultValue: "Print PDF")) + } + } +} diff --git a/Sources/Panels/BrowserPanel+PDFDocumentActions.swift b/Sources/Panels/BrowserPanel+PDFDocumentActions.swift new file mode 100644 index 000000000000..9e343ed3f516 --- /dev/null +++ b/Sources/Panels/BrowserPanel+PDFDocumentActions.swift @@ -0,0 +1,32 @@ +import AppKit +import Foundation + +extension BrowserPanel { + func downloadRenderedPDFDocument() { + guard let url = renderedPDFDocumentURL else { + NSSound.beep() + return + } + guard let webView = webView as? CmuxWebView else { + NSSound.beep() + return + } + let traceID = CmuxWebView.makeContextDownloadTraceID(prefix: "pdfdl") + webView.downloadURLViaSession( + url, + suggestedFilename: nil, + sender: nil, + fallbackAction: nil, + fallbackTarget: nil, + traceID: traceID + ) + } + + func printRenderedPDFDocument() { + guard renderedPDFDocumentURL != nil else { + NSSound.beep() + return + } + webView.cmuxRunPrintOperation() + } +} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dd7432b4f0eb..f03fa249726f 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2898,6 +2898,8 @@ final class BrowserPanel: Panel, ObservableObject { /// Published download state for browser downloads (navigation + context menu). @Published private(set) var isDownloading: Bool = false + @Published private(set) var renderedPDFDocumentURL: URL? + /// Per-pane browser audio mute intent. BrowserPanel owns this so the state /// survives WKWebView replacement and can be applied to each new page. @Published private(set) var isMuted: Bool = false @@ -3777,6 +3779,12 @@ final class BrowserPanel: Panel, ObservableObject { (webView as? CmuxWebView)?.onSubframeDownloadIntent = { [weak navigationDelegate] in navigationDelegate?.recordSubframeDownloadIntent($0) } + navigationDelegate.didRenderPDFDocument = { [weak self] url, isMainFrame in + MainActor.assumeIsolated { self?.noteRenderedPDFDocument(url, isMainFrame: isMainFrame) } + } + navigationDelegate.didClearPDFDocument = { [weak self] in + MainActor.assumeIsolated { self?.clearRenderedPDFDocument() } + } navigationDelegate.didStartProvisionalNavigation = { [weak self] webView in MainActor.assumeIsolated { @@ -4391,6 +4399,20 @@ final class BrowserPanel: Panel, ObservableObject { } } + func noteRenderedPDFDocument(_ url: URL, isMainFrame: Bool) { + renderedPDFDocumentURL = url + #if DEBUG + cmuxDebugLog( + "browser.pdf.rendered panel=\(id.uuidString.prefix(5)) " + + "mainFrame=\(isMainFrame ? 1 : 0) url=\(browserNavigationDebugURL(url))" + ) + #endif + } + + func clearRenderedPDFDocument() { + renderedPDFDocumentURL = nil + } + func updateWorkspaceId(_ newWorkspaceId: UUID) { workspaceId = newWorkspaceId } @@ -6027,6 +6049,7 @@ extension BrowserPanel { pageTitle = "" currentURL = nil + renderedPDFDocumentURL = nil hiddenWebViewDiscardManager.updateRestoredSessionRenderIntent(nil) faviconPNGData = nil lastFaviconURLString = nil @@ -8539,214 +8562,6 @@ class BrowserDownloadDelegate: NSObject, WKDownloadDelegate { } } -// MARK: - Navigation Delegate - -func browserNavigationShouldOpenInNewTab( - navigationType: WKNavigationType, - modifierFlags: NSEvent.ModifierFlags, - buttonNumber: Int, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber -) -> Bool { - guard navigationType == .linkActivated || navigationType == .other else { - return false - } - - if modifierFlags.contains(.command) { - return true - } - if buttonNumber == 2 { - return true - } - // In some WebKit paths, middle-click arrives as buttonNumber=4. - // Recover intent when we just observed a local middle-click. - if buttonNumber == 4, hasRecentMiddleClickIntent { - return true - } - - // WebKit can omit buttonNumber for middle-click link activations. - if let currentEventType, - (currentEventType == .otherMouseDown || currentEventType == .otherMouseUp), - currentEventButtonNumber == 2 { - return true - } - return false -} - -func browserNavigationShouldCreatePopup( - navigationType: WKNavigationType, - modifierFlags: NSEvent.ModifierFlags, - buttonNumber: Int, - popupFeaturesWereSpecified: Bool = false, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber -) -> Bool { - let isUserNewTab = browserNavigationShouldOpenInNewTab( - navigationType: navigationType, - modifierFlags: modifierFlags, - buttonNumber: buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, - currentEventType: currentEventType, - currentEventButtonNumber: currentEventButtonNumber - ) - return navigationType == .other && popupFeaturesWereSpecified && !isUserNewTab -} - -func browserNavigationShouldFallbackNilTargetToNewTab( - navigationType: WKNavigationType -) -> Bool { - // Scripted popups rely on WKUIDelegate.createWebViewWith returning a live - // web view so window.opener/postMessage remain intact across OAuth flows. - navigationType != .other -} - -func browserNavigationHasSimpleUserActivation( - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type -) -> Bool { - switch currentEventType { - case .keyDown, .keyUp, .leftMouseDown, .leftMouseUp: - return true - default: - return false - } -} - -func browserNavigationPopupFeaturesWereSpecified( - x: NSNumber?, - y: NSNumber?, - width: NSNumber?, - height: NSNumber?, - menuBarVisibility: NSNumber?, - statusBarVisibility: NSNumber?, - toolbarsVisibility: NSNumber?, - allowsResizing: NSNumber? -) -> Bool { - x != nil || - y != nil || - width != nil || - height != nil || - menuBarVisibility != nil || - statusBarVisibility != nil || - toolbarsVisibility != nil || - allowsResizing != nil -} - -func browserNavigationPopupFeaturesWereSpecified(windowFeatures: WKWindowFeatures) -> Bool { - browserNavigationPopupFeaturesWereSpecified( - x: windowFeatures.x, - y: windowFeatures.y, - width: windowFeatures.width, - height: windowFeatures.height, - menuBarVisibility: windowFeatures.menuBarVisibility, - statusBarVisibility: windowFeatures.statusBarVisibility, - toolbarsVisibility: windowFeatures.toolbarsVisibility, - allowsResizing: windowFeatures.allowsResizing - ) -} -// Keep popup retargeting intentionally narrow. Explicit cross-host alias groups -// preserve known first-party search flows without guessing at the public suffix -// list for arbitrary hosted tenants, while same-host scripted popups stay on -// the popup path so opener-dependent browser flows keep working. -private let browserNavigationSimpleUserGesturePopupRetargetHostAliases: [Set] = [ - [ - "bilibili.com", - "search.bilibili.com", - "www.bilibili.com", - ], -] - -private func browserNavigationDefaultPort(for scheme: String) -> Int? { - switch scheme { - case "http": - return 80 - case "https": - return 443 - default: - return nil - } -} - -private func browserNavigationShouldRetargetSimpleUserGesturePopup( - requestURL: URL?, - openerURL: URL? -) -> Bool { - guard let requestURL, - let openerURL, - let requestScheme = requestURL.scheme?.lowercased(), !requestScheme.isEmpty, - let openerScheme = openerURL.scheme?.lowercased(), !openerScheme.isEmpty, - requestScheme == openerScheme, - (requestURL.port ?? browserNavigationDefaultPort(for: requestScheme)) - == (openerURL.port ?? browserNavigationDefaultPort(for: openerScheme)), - let requestHost = BrowserInsecureHTTPSettings.normalizeHost(requestURL.host ?? ""), - let openerHost = BrowserInsecureHTTPSettings.normalizeHost(openerURL.host ?? "") else { - return false - } - for aliases in browserNavigationSimpleUserGesturePopupRetargetHostAliases { - if requestHost != openerHost, - aliases.contains(requestHost), - aliases.contains(openerHost) { - return true - } - } - return false -} - -func browserNavigationDebugURL(_ url: URL?) -> String { - guard let url, - var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { - return "nil" - } - components.query = nil - components.fragment = nil - return components.string ?? "\(url.scheme ?? "unknown")://\(url.host ?? "")" -} - -func browserNavigationShouldOpenSimpleUserGesturePopupInCurrentTab( - navigationType: WKNavigationType, - requestMethod: String?, - requestURL: URL?, - openerURL: URL?, - modifierFlags: NSEvent.ModifierFlags = [], - buttonNumber: Int = 0, - hasRecentMiddleClickIntent: Bool = false, - currentEventType: NSEvent.EventType? = NSApp.currentEvent?.type, - currentEventButtonNumber: Int? = NSApp.currentEvent?.buttonNumber, - popupFeaturesWereSpecified: Bool -) -> Bool { - guard navigationType == .other else { - return false - } - // Some sites use `window.open()` for plain same-site searches triggered by a - // direct keyboard submit or left-click, without requesting popup chrome or - // opener-style geometry. Route those to a normal tab while keeping - // cross-site/OAuth-style popups on the popup path. - guard browserNavigationHasSimpleUserActivation(currentEventType: currentEventType) else { - return false - } - guard !browserNavigationShouldOpenInNewTab( - navigationType: navigationType, - modifierFlags: modifierFlags, - buttonNumber: buttonNumber, - hasRecentMiddleClickIntent: hasRecentMiddleClickIntent, - currentEventType: currentEventType, - currentEventButtonNumber: currentEventButtonNumber - ) else { - return false - } - guard (requestMethod ?? "GET").uppercased() == "GET" else { - return false - } - guard !popupFeaturesWereSpecified else { - return false - } - return browserNavigationShouldRetargetSimpleUserGesturePopup( - requestURL: requestURL, - openerURL: openerURL - ) -} - // MARK: - UI Delegate private class BrowserUIDelegate: NSObject, WKUIDelegate { diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index db5def2604d0..5f6367bb50e3 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -296,39 +296,6 @@ struct OmnibarInlineCompletion: Equatable { } } -private struct OmnibarAddressButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - OmnibarAddressButtonStyleBody(configuration: configuration) - } -} - -private struct OmnibarAddressButtonStyleBody: View { - let configuration: OmnibarAddressButtonStyle.Configuration - - @Environment(\.isEnabled) private var isEnabled - @State private var isHovered = false - - private var backgroundOpacity: Double { - guard isEnabled else { return 0.0 } - if configuration.isPressed { return 0.16 } - if isHovered { return 0.08 } - return 0.0 - } - - var body: some View { - configuration.label - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(Color.primary.opacity(backgroundOpacity)) - ) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeOut(duration: 0.12), value: isHovered) - .animation(.easeOut(duration: 0.08), value: configuration.isPressed) - } -} - func resolvedBrowserChromeBackgroundColor( for colorScheme: ColorScheme, themeBackgroundColor: NSColor, @@ -1361,6 +1328,12 @@ struct BrowserPanelView: View { } .safeHelp(panel.isLoading ? String(localized: "browser.stop", defaultValue: "Stop") : String(localized: "browser.reload", defaultValue: "Reload")) + BrowserPDFDocumentToolbarButtons( + panel: panel, + iconPointSize: chromeMetrics.navigationIconFontSize, + hitSize: addressBarButtonHitSize + ) + if panel.isDownloading { HStack(spacing: 4) { ProgressView() diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 1f558ca76315..0e303aa3c90a 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -632,13 +632,6 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { let allowsSubframeDownload = navigationResponse.isForMainFrame || subframeDownloadIntents.consume(for: navigationResponse.response.url) || isUserActivatedPreviouslyRenderedSubframePDF - if !navigationResponse.isForMainFrame, - allowsSubframeDownload, - let url = navigationResponse.response.url, - browserShouldBlockInsecureHTTPURL(url) { - decisionHandler(.cancel) - return - } if filenameResolver.navigationResponseDownloadReason( mimeType: navigationResponse.response.mimeType, canShowMIMEType: navigationResponse.canShowMIMEType, @@ -647,6 +640,12 @@ private class PopupUIDelegate: NSObject, WKUIDelegate { allowsSubframeDownload: allowsSubframeDownload, isUserActivatedPreviouslyRenderedSubframePDF: isUserActivatedPreviouslyRenderedSubframePDF ) != nil { + if !navigationResponse.isForMainFrame, + let url = navigationResponse.response.url, + browserShouldBlockInsecureHTTPURL(url) { + decisionHandler(.cancel) + return + } decisionHandler(.download) return } diff --git a/Sources/Panels/OmnibarAddressButtonStyle.swift b/Sources/Panels/OmnibarAddressButtonStyle.swift new file mode 100644 index 000000000000..f1eb462a640d --- /dev/null +++ b/Sources/Panels/OmnibarAddressButtonStyle.swift @@ -0,0 +1,34 @@ +import SwiftUI + +struct OmnibarAddressButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + OmnibarAddressButtonStyleBody(configuration: configuration) + } +} + +private struct OmnibarAddressButtonStyleBody: View { + let configuration: OmnibarAddressButtonStyle.Configuration + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var backgroundOpacity: Double { + guard isEnabled else { return 0.0 } + if configuration.isPressed { return 0.16 } + if isHovered { return 0.08 } + return 0.0 + } + + var body: some View { + configuration.label + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.primary.opacity(backgroundOpacity)) + ) + .onHover { hovering in + isHovered = hovering + } + .animation(.easeOut(duration: 0.12), value: isHovered) + .animation(.easeOut(duration: 0.08), value: configuration.isPressed) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index d612d0482753..d3e31b186350 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -127,7 +127,9 @@ BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */; }; A500MH01 /* BrowserMediaPlaybackMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MH00 /* BrowserMediaPlaybackMessageHandler.swift */; }; A500MR01 /* BrowserMediaPlaybackReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MR00 /* BrowserMediaPlaybackReport.swift */; }; + C67540080000000000000001 /* BrowserNavigationDebugURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540080000000000000002 /* BrowserNavigationDebugURL.swift */; }; BABA25000000000000000009 /* BrowserNavigationDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA2500000000000000000A /* BrowserNavigationDelegate.swift */; }; + C67540100000000000000001 /* BrowserNavigationPopupPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540100000000000000002 /* BrowserNavigationPopupPolicy.swift */; }; B0A501000000000000000001 /* BrowserOmnibarAppKitBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */; }; B0A500000000000000000001 /* BrowserOmnibarPerformanceSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */; }; C2B6A97D1F2E4C71A8B9D001 /* BrowserOmnibarPerformanceSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */; }; @@ -137,12 +139,14 @@ D0B1001EA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */; }; A500MX01 /* BrowserPanel+MediaPlayback.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500MX00 /* BrowserPanel+MediaPlayback.swift */; }; D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */; }; + C67540060000000000000001 /* BrowserPanel+PDFDocumentActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540060000000000000002 /* BrowserPanel+PDFDocumentActions.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62530010000000000000002 /* BrowserPanelReloadMode.swift */; }; B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */; }; 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */; }; A5001404 /* BrowserPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001414 /* BrowserPanelView.swift */; }; D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */; }; + C67540070000000000000001 /* BrowserPDFDocumentToolbarButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */; }; C67540040000000000000001 /* BrowserPopupPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540040000000000000002 /* BrowserPopupPanel.swift */; }; A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; 7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */; }; @@ -642,6 +646,7 @@ A5001094 /* NotificationsPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001091 /* NotificationsPage.swift */; }; D36090020000000000000001 /* NSWindow+CmuxPeerWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = D36090020000000000000002 /* NSWindow+CmuxPeerWindow.swift */; }; 645645000000000000000002 /* NumberedShortcutSwapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645645000000000000000001 /* NumberedShortcutSwapTests.swift */; }; + C67540090000000000000001 /* OmnibarAddressButtonStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540090000000000000002 /* OmnibarAddressButtonStyle.swift */; }; 4378399A7C0245EF8186F306 /* OmnibarAndToolsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */; }; 9637C6D3170F4BE1A7EF6243 /* OmnibarSubmitDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 48D6AE11906A4DF3BEB8CDFE /* OmnibarSubmitDecisionTests.swift */; }; 0A0F00550000000000000001 /* OmpSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00550000000000000002 /* OmpSupportTests.swift */; }; @@ -1332,7 +1337,9 @@ BCBC0A0E0000000000000E22 /* BrowserMediaPlaybackAudioActivityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaPlaybackAudioActivityTests.swift; sourceTree = ""; }; A500MH00 /* BrowserMediaPlaybackMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserMediaPlaybackMessageHandler.swift; sourceTree = ""; }; A500MR00 /* BrowserMediaPlaybackReport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserMediaPlaybackReport.swift; sourceTree = ""; }; + C67540080000000000000002 /* BrowserNavigationDebugURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserNavigationDebugURL.swift; sourceTree = ""; }; BABA2500000000000000000A /* BrowserNavigationDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserNavigationDelegate.swift; sourceTree = ""; }; + C67540100000000000000002 /* BrowserNavigationPopupPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserNavigationPopupPolicy.swift; sourceTree = ""; }; B0A501000000000000000002 /* BrowserOmnibarAppKitBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserOmnibarAppKitBridge.swift; sourceTree = ""; }; B0A500000000000000000002 /* BrowserOmnibarPerformanceSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserOmnibarPerformanceSupport.swift; sourceTree = ""; }; C2B6A97D1F2E4C71A8B9D002 /* BrowserOmnibarPerformanceSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserOmnibarPerformanceSupportTests.swift; sourceTree = ""; }; @@ -1342,12 +1349,14 @@ D0B1001FA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneDropTargetView.swift; sourceTree = ""; }; A500MX00 /* BrowserPanel+MediaPlayback.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MediaPlayback.swift"; sourceTree = ""; }; D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+MoveTabToNewWorkspace.swift"; sourceTree = ""; }; + C67540060000000000000002 /* BrowserPanel+PDFDocumentActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+PDFDocumentActions.swift"; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; C62530010000000000000002 /* BrowserPanelReloadMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelReloadMode.swift; sourceTree = ""; }; B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelSessionRestoreTests.swift; sourceTree = ""; }; 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelTests.swift; sourceTree = ""; }; A5001414 /* BrowserPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelView.swift; sourceTree = ""; }; D0E0F0B1A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPaneNavigationKeybindUITests.swift; sourceTree = ""; }; + C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPDFDocumentToolbarButtons.swift; sourceTree = ""; }; C67540040000000000000002 /* BrowserPopupPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupPanel.swift; sourceTree = ""; }; A5007421 /* BrowserPopupWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPopupWindowController.swift; sourceTree = ""; }; 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReliabilityRegressionUITests.swift; sourceTree = ""; }; @@ -1794,6 +1803,7 @@ A5001091 /* NotificationsPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsPage.swift; sourceTree = ""; }; D36090020000000000000002 /* NSWindow+CmuxPeerWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "App/NSWindow+CmuxPeerWindow.swift"; sourceTree = ""; }; 645645000000000000000001 /* NumberedShortcutSwapTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NumberedShortcutSwapTests.swift; sourceTree = ""; }; + C67540090000000000000002 /* OmnibarAddressButtonStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/OmnibarAddressButtonStyle.swift; sourceTree = ""; }; B09C007F42697761B5F1A2AB /* OmnibarAndToolsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarAndToolsTests.swift; sourceTree = ""; }; 48D6AE11906A4DF3BEB8CDFE /* OmnibarSubmitDecisionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmnibarSubmitDecisionTests.swift; sourceTree = ""; }; 0A0F00550000000000000002 /* OmpSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmpSupportTests.swift; sourceTree = ""; }; @@ -2921,6 +2931,8 @@ BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */, BABA2500000000000000000E /* BrowserHTTPBasicAuthPromptRequest.swift */, BABA2500000000000000000A /* BrowserNavigationDelegate.swift */, + C67540080000000000000002 /* BrowserNavigationDebugURL.swift */, + C67540100000000000000002 /* BrowserNavigationPopupPolicy.swift */, A5001412 /* BrowserPanel.swift */, C62530010000000000000002 /* BrowserPanelReloadMode.swift */, C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */, @@ -2934,6 +2946,7 @@ 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */, 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */, D7AB00000000000000000008 /* BrowserPanel+MoveTabToNewWorkspace.swift */, + C67540060000000000000002 /* BrowserPanel+PDFDocumentActions.swift */, A500MR00 /* BrowserMediaPlaybackReport.swift */, A500MH00 /* BrowserMediaPlaybackMessageHandler.swift */, A500MX00 /* BrowserPanel+MediaPlayback.swift */, @@ -2944,6 +2957,8 @@ 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */, 326E7A5E13C6DF650B9F8971 /* BrowserSystemProxyWatcher.swift */, A5001414 /* BrowserPanelView.swift */, + C67540070000000000000002 /* BrowserPDFDocumentToolbarButtons.swift */, + C67540090000000000000002 /* OmnibarAddressButtonStyle.swift */, A9E010000000000000000001 /* AgentSessionProvider.swift */, A9F100000000000000000001 /* AgentExecutableResolver.swift */, A9F100000000000000000002 /* AgentExecutableResolverError.swift */, @@ -4045,16 +4060,20 @@ BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */, A500MH01 /* BrowserMediaPlaybackMessageHandler.swift in Sources */, A500MR01 /* BrowserMediaPlaybackReport.swift in Sources */, + C67540080000000000000001 /* BrowserNavigationDebugURL.swift in Sources */, BABA25000000000000000009 /* BrowserNavigationDelegate.swift in Sources */, + C67540100000000000000001 /* BrowserNavigationPopupPolicy.swift in Sources */, B0A501000000000000000001 /* BrowserOmnibarAppKitBridge.swift in Sources */, B0A500000000000000000001 /* BrowserOmnibarPerformanceSupport.swift in Sources */, 27DA3BE42CBF4AAFB6DE69C5 /* BrowserOmnibarSubmitSupport.swift in Sources */, D0B1001EA1B2C3D4E5F60001 /* BrowserPaneDropTargetView.swift in Sources */, A500MX01 /* BrowserPanel+MediaPlayback.swift in Sources */, D7AB00000000000000000007 /* BrowserPanel+MoveTabToNewWorkspace.swift in Sources */, + C67540060000000000000001 /* BrowserPanel+PDFDocumentActions.swift in Sources */, A5001402 /* BrowserPanel.swift in Sources */, C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */, A5001404 /* BrowserPanelView.swift in Sources */, + C67540070000000000000001 /* BrowserPDFDocumentToolbarButtons.swift in Sources */, C67540040000000000000001 /* BrowserPopupPanel.swift in Sources */, A5007420 /* BrowserPopupWindowController.swift in Sources */, BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */, @@ -4315,6 +4334,7 @@ B7F00002 /* NotificationSoundSettings.swift in Sources */, A5001094 /* NotificationsPage.swift in Sources */, D36090020000000000000001 /* NSWindow+CmuxPeerWindow.swift in Sources */, + C67540090000000000000001 /* OmnibarAddressButtonStyle.swift in Sources */, A9F200000000000000000017 /* OpenCodeEventStreamParser.swift in Sources */, A9F200000000000000000018 /* OpenCodeEventTextAccumulator.swift in Sources */, A9F200000000000000000019 /* OpenCodeProcessOutputDisposition.swift in Sources */, diff --git a/cmuxTests/BrowserDownloadFilenameResolverTests.swift b/cmuxTests/BrowserDownloadFilenameResolverTests.swift index 75d7103b01ad..ab5f41eaa0ec 100644 --- a/cmuxTests/BrowserDownloadFilenameResolverTests.swift +++ b/cmuxTests/BrowserDownloadFilenameResolverTests.swift @@ -40,7 +40,7 @@ import WebKit )) } - @Test func navigationResponseClassifiesExplicitSubframeDownloads() { + @Test func navigationResponseClassifiesExplicitSubframeDownloadsWithRecordedIntent() { #expect(resolver.navigationResponseDownloadReason( mimeType: "text/html", canShowMIMEType: true, @@ -57,19 +57,19 @@ import WebKit ) == "forceDownloadMIME") } - @Test func navigationResponseRejectsUnactivatedSubframeDownloads() { + @Test func navigationResponseClassifiesExplicitSubframeDownloadsWithoutRecordedIntent() { #expect(resolver.navigationResponseDownloadReason( mimeType: "text/html", canShowMIMEType: true, contentDisposition: "attachment; filename=index.html", isForMainFrame: false - ) == nil) + ) == "content-disposition") #expect(resolver.navigationResponseDownloadReason( mimeType: "text/csv", canShowMIMEType: true, contentDisposition: nil, isForMainFrame: false - ) == nil) + ) == "forceDownloadMIME") } @Test func navigationResponseKeepsUnshowableSubframesInlineWithoutExplicitDownloadSignal() { From 09db6479e15ffaf04e795e7af9b14de44941e02a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 29 Jun 2026 13:43:48 -0700 Subject: [PATCH 55/59] Scope PDF document toolbar to main-frame PDFs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-review caught that noteRenderedPDFDocument stored renderedPDFDocumentURL for subframe PDFs too, so an embedded