diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index cc9904cb21dd..a0145f8f6c27 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -4,13 +4,13 @@ 34427 CLI/cmux.swift 17908 Sources/AppDelegate.swift 16491 Sources/ContentView.swift -14170 Sources/TerminalController.swift +14224 Sources/TerminalController.swift 12917 Sources/Workspace.swift 12348 cmuxTests/AppDelegateShortcutRoutingTests.swift 12214 Sources/GhosttyTerminalView.swift -11592 Sources/Panels/BrowserPanel.swift +11671 Sources/Panels/BrowserPanel.swift 9497 cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift -8059 Sources/Panels/BrowserPanelView.swift +8032 Sources/Panels/BrowserPanelView.swift 8016 CLI/cmux_open.swift 7366 cmuxTests/WorkspaceUnitTests.swift 7218 cmuxTests/WorkspaceRemoteConnectionTests.swift @@ -36,7 +36,7 @@ 2876 cmuxTests/CMUXOpenCommandTests.swift 2875 Sources/SessionIndexView.swift 2606 Sources/KeyboardShortcutSettings.swift -2565 Sources/Panels/CmuxWebView.swift +2563 Sources/Panels/CmuxWebView.swift 2546 cmuxTests/WorkspaceManualUnreadTests.swift 2524 cmuxTests/CommandPaletteSearchEngineTests.swift 2395 Sources/Mobile/MobileHostService.swift @@ -57,9 +57,9 @@ 1680 cmuxUITests/BrowserPaneNavigationKeybindUITests.swift 1656 Sources/FileExplorerView.swift 1652 cmuxTests/CMUXCLIErrorOutputRegressionTests.swift +1649 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 @@ -90,17 +90,16 @@ 1009 cmuxTests/CmuxTopSnapshotScopeTests.swift 1006 cmuxTests/CmuxSSHURLRequestTests.swift 1002 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/PairedMacBackupTests.swift +982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift 953 cmuxTests/DockSocketLifecycleTests.swift 951 Sources/App/TerminalDirectoryOpenSupport.swift -982 Packages/Shared/CmuxAgentChat/Tests/CmuxAgentChatTests/ChatConversationStoreTests.swift 948 Sources/App/ShortcutRoutingSupport.swift 947 Sources/TerminalNotificationPolicy.swift 945 Sources/SessionIndexRegisteredAgents.swift -937 Sources/Panels/BrowserPopupWindowController.swift +944 Sources/CommandPalette/CommandPaletteSettingsToggle.swift 937 Sources/TextBoxMentionIndexStore.swift 928 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AppSection.swift -920 Sources/CommandPalette/CommandPaletteSettingsToggle.swift -755 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +918 Sources/Panels/BrowserPopupWindowController.swift 905 Sources/CmuxSSHURLRequest.swift 899 Sources/Panels/MarkdownWebRenderer.swift 885 Sources/Panels/TerminalPanel.swift @@ -108,6 +107,7 @@ 881 Sources/DockSplitStore.swift 871 cmuxTests/ClaudeHookSurfaceResolutionSwiftTests.swift 868 Sources/Panels/BrowserScreenshotSnapshotter.swift +864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 859 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Workspace/ControlCommandCoordinator+Workspace.swift 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift @@ -124,13 +124,13 @@ 773 Sources/App/MenuBarExtraController.swift 769 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Input.swift 768 cmuxUITests/BrowserFixtureInteractionUITests.swift -864 Packages/Shared/CmuxAgentChat/Sources/CmuxAgentChat/Store/ChatConversationStore.swift 762 Packages/iOS/CmuxMobileTransport/Sources/CmuxMobileTransport/CmxNetworkByteTransport.swift 761 Sources/Mobile/AgentChat/AgentChatSessionRegistry.swift 760 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 756 Sources/Panels/AgentSessionWebRendererCoordinator.swift 754 Sources/TerminalController+ControlWorkspaceContext.swift 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift +743 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift 739 cmuxTests/CLICodexHookTimeoutRegressionTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift 718 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift @@ -167,7 +167,7 @@ 627 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/KeyboardShortcutsSection.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 620 cmuxTests/TerminalNotificationQueueTests.swift -613 Sources/SettingsNavigation.swift +615 Sources/SettingsNavigation.swift 608 cmuxUITests/FeedSidebarUITests.swift 607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift 607 Sources/SessionIndexModels.swift @@ -179,6 +179,7 @@ 596 cmuxTests/CmuxEventBusTests.swift 594 cmuxTests/PortalTabDragRoutingTests.swift 591 Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/DefaultsValueModelLifecycleTests.swift +590 Sources/Panels/BrowserNavigationDelegate.swift 589 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 588 cmuxTests/CommandPaletteShortcutCustomizationTests.swift 586 Packages/macOS/CmuxRemoteSession/Sources/CmuxRemoteSession/Session/RemoteSessionCoordinator+PortScan.swift @@ -191,6 +192,7 @@ 574 Packages/macOS/CmuxWorkspaces/Tests/CmuxWorkspacesTests/WorkspaceCoordinatorTests.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 563 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Pane/ControlCommandCoordinator+Pane.swift 562 cmuxTests/AgentExecutableResolverTests.swift @@ -198,7 +200,6 @@ 560 cmuxTests/CLISSHPTYResizeInputTests.swift 558 Packages/macOS/CmuxGit/Sources/CmuxGit/Parsing/GitMetadataService+Config.swift 553 Sources/RightSidebarPanelView.swift -552 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift 549 Sources/Panels/BrowserAutomation.swift 547 Packages/iOS/CmuxMobileShell/Sources/CmuxMobileShell/BackingUpPairedMacStore.swift 546 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/AutomationSection.swift 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 7f1c089d3a7c..989450f20d63 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -21871,6 +21871,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": { @@ -22109,6 +22126,108 @@ } } }, + "browser.downloads.clear": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "消去" + } + } + } + }, + "browser.downloads.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "No recent downloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近のダウンロードはありません" + } + } + } + }, + "browser.downloads.failed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "失敗しました" + } + } + } + }, + "browser.downloads.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "開く" + } + } + } + }, + "browser.downloads.showInFinder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show in Finder" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Finderに表示" + } + } + } + }, + "browser.downloads.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Downloads" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ダウンロード" + } + } + } + }, "browser.error.bypass": { "extractionState": "manual", "localizations": { @@ -27908,6 +28027,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.popup.loadingTitle": { "extractionState": "manual", "localizations": { @@ -142861,6 +143014,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.customSearchEngineName": { "extractionState": "manual", "localizations": { @@ -161398,6 +161585,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.enable-browser": { "extractionState": "manual", "localizations": { diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 3759783f2278..bb15f5b1b7de 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -287,6 +287,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 @@ -425,6 +429,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..087bdbdacf97 100644 --- a/Sources/Panels/BrowserDownloadFilenameResolver.swift +++ b/Sources/Panels/BrowserDownloadFilenameResolver.swift @@ -1,13 +1,12 @@ import Foundation +import CoreServices import ImageIO +import CmuxSettings import UniformTypeIdentifiers -nonisolated enum BrowserDownloadHTTPStatusDecision: Equatable, Sendable { - case allow - case reject(statusCode: Int) -} - nonisolated struct BrowserDownloadFilenameResolver: Sendable { + private static let maxFilenameCollisionAttempts = 100 + func shouldForceDownload( mimeType: String?, contentDisposition: String? @@ -24,7 +23,10 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { func navigationResponseDownloadReason( mimeType: String?, canShowMIMEType: Bool, - contentDisposition: String? + contentDisposition: String?, + isForMainFrame: Bool = true, + allowsSubframeDownload: Bool = false, + isUserActivatedPreviouslyRenderedSubframePDF: Bool = false ) -> String? { if shouldForceDownload(mimeType: nil, contentDisposition: contentDisposition) { return "content-disposition" @@ -32,9 +34,35 @@ nonisolated struct BrowserDownloadFilenameResolver: Sendable { if shouldForceDownload(mimeType: mimeType, contentDisposition: nil) { return "forceDownloadMIME" } + if !isForMainFrame, + isUserActivatedPreviouslyRenderedSubframePDF, + isPDFMIMEType(mimeType) { + return "subframePDFUserAction" + } + guard isForMainFrame else { return nil } return canShowMIMEType ? nil : "cannotShowMIME" } + 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 { + $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 @@ -114,6 +142,51 @@ 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 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) { + return url + } + index += 1 + } + + let uuid = UUID().uuidString + let fallbackName = ext.isEmpty ? "\(base)-\(uuid)" : "\(base)-\(uuid).\(ext)" + return directory.appendingPathComponent(fallbackName, isDirectory: false) + } + private func imageFilename( candidate: String, imageType: UTType @@ -179,6 +252,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 @@ -208,3 +285,52 @@ 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 let sanitizedSourceURL = Self.cmuxSanitizedDownloadSourceURL(sourceURL) { + quarantineProperties[kLSQuarantineDataURLKey as String] = sanitizedSourceURL + quarantineProperties[kLSQuarantineOriginURLKey as String] = sanitizedSourceURL + } + + 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 cmuxSanitizedDownloadSourceURL(_ sourceURL: URL) -> URL? { + let scheme = sourceURL.scheme?.lowercased() + 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/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/BrowserDownloadRecord.swift b/Sources/Panels/BrowserDownloadRecord.swift new file mode 100644 index 000000000000..e7e937640635 --- /dev/null +++ b/Sources/Panels/BrowserDownloadRecord.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Immutable snapshot of a single browser download, surfaced in the +/// Safari/Chrome-style downloads popover. Value type so it can be passed below +/// the popover's `ForEach` boundary without dragging the `BrowserPanel` store +/// along (see the snapshot-boundary rule in CLAUDE.md). +struct BrowserDownloadRecord: Identifiable, Equatable { + enum State: Equatable { + case downloading + case saved + case failed + } + + /// Stable id — the download's `download_id` from the event stream. + let id: String + var filename: String + /// Final on-disk location once `state == .saved`. + var fileURL: URL? + var state: State + /// File size in bytes once known (saved downloads only). + var byteCount: Int? + + var isComplete: Bool { state != .downloading } +} diff --git a/Sources/Panels/BrowserDownloadsToolbarButton.swift b/Sources/Panels/BrowserDownloadsToolbarButton.swift new file mode 100644 index 000000000000..abe89b46d1fd --- /dev/null +++ b/Sources/Panels/BrowserDownloadsToolbarButton.swift @@ -0,0 +1,218 @@ +import SwiftUI + +/// Safari/Chrome-style downloads button for the browser omnibar. Shows a +/// popover listing recent downloads with Open / Show in Finder actions. +/// +/// Everything below this view receives immutable value snapshots +/// (`BrowserDownloadRecord`) plus action closures — no `BrowserPanel` store +/// crosses the popover's `ForEach` boundary (CLAUDE.md snapshot-boundary rule). +struct BrowserDownloadsToolbarButton: View { + let downloads: [BrowserDownloadRecord] + let isDownloading: Bool + let iconPointSize: CGFloat + let hitSize: CGFloat + let onOpen: (BrowserDownloadRecord) -> Void + let onReveal: (BrowserDownloadRecord) -> Void + let onClear: () -> Void + + @State private var isPresented = false + @State private var seenIDs: Set = [] + + private var completedCount: Int { + downloads.reduce(0) { $0 + ($1.state == .saved ? 1 : 0) } + } + + /// Downloads not yet viewed in the popover — drives the notification bubble. + private var unseenCount: Int { + downloads.reduce(0) { $0 + (seenIDs.contains($1.id) ? 0 : 1) } + } + + var body: some View { + Button { + isPresented.toggle() + } label: { + ZStack(alignment: .topTrailing) { + // Monochrome to match the rest of the omnibar — motion carries + // the state instead of a persistent accent tint: a spinner while + // a download is in flight, and a bounce each time one lands. + // (A repeating `.bounce` would need macOS 15; plain SF Symbol so + // the discrete `.bounce` applies — CmuxSystemSymbolImage is + // NSImage-backed and ignores `.symbolEffect`.) + Group { + if isDownloading { + ProgressView() + .controlSize(.small) + } else { + Image(systemName: "arrow.down.circle") + .font(.system(size: iconPointSize, weight: .medium)) + .foregroundStyle(Color.primary) + .symbolEffect(.bounce, value: completedCount) + } + } + .frame(width: hitSize, height: hitSize, alignment: .center) + + // Notification bubble: count of downloads not yet viewed. Clears + // when the popover is opened (see onChange below). + if unseenCount > 0 { + Text(unseenCount > 99 ? "99+" : "\(unseenCount)") + .font(.system(size: 9, weight: .bold)) + .monospacedDigit() + .foregroundStyle(.white) + .padding(.horizontal, 3) + .frame(minWidth: 14, minHeight: 14) + .background(Capsule().fill(Color.red)) + .overlay(Capsule().stroke(Color(nsColor: .windowBackgroundColor), lineWidth: 1.5)) + .offset(x: 6, y: -4) + .transition(.scale.combined(with: .opacity)) + } + } + .frame(width: hitSize, height: hitSize, alignment: .center) + .contentShape(Rectangle()) + .animation(.spring(response: 0.32, dampingFraction: 0.55), value: unseenCount) + } + .buttonStyle(OmnibarAddressButtonStyle()) + .safeHelp(String(localized: "browser.downloads.title", defaultValue: "Downloads")) + .accessibilityLabel(String(localized: "browser.downloads.title", defaultValue: "Downloads")) + .onChange(of: isPresented) { _, presented in + if presented { + seenIDs = Set(downloads.map(\.id)) + } + } + .popover(isPresented: $isPresented, arrowEdge: .bottom) { + BrowserDownloadsPopoverContent( + downloads: downloads, + onOpen: onOpen, + onReveal: onReveal, + onClear: onClear + ) + } + } +} + +private struct BrowserDownloadsPopoverContent: View { + let downloads: [BrowserDownloadRecord] + let onOpen: (BrowserDownloadRecord) -> Void + let onReveal: (BrowserDownloadRecord) -> Void + let onClear: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + Text(String(localized: "browser.downloads.title", defaultValue: "Downloads")) + .font(.headline) + Spacer() + if !downloads.isEmpty { + Button(String(localized: "browser.downloads.clear", defaultValue: "Clear")) { + onClear() + } + .buttonStyle(.borderless) + .font(.callout) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + + Divider() + + if downloads.isEmpty { + Text(String(localized: "browser.downloads.empty", defaultValue: "No recent downloads")) + .font(.callout) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 28) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(downloads) { record in + BrowserDownloadRow(record: record, onOpen: onOpen, onReveal: onReveal) + if record.id != downloads.last?.id { + Divider().padding(.leading, 44) + } + } + } + } + .frame(maxHeight: 320) + } + } + .frame(width: 340) + } +} + +private struct BrowserDownloadRow: View { + let record: BrowserDownloadRecord + let onOpen: (BrowserDownloadRecord) -> Void + let onReveal: (BrowserDownloadRecord) -> Void + + var body: some View { + HStack(spacing: 10) { + leadingIcon + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + Text(record.filename) + .lineLimit(1) + .truncationMode(.middle) + Text(subtitle) + .font(.caption) + .foregroundStyle(record.state == .failed ? Color.red : Color.secondary) + } + + Spacer(minLength: 8) + + if record.state == .saved { + Button(String(localized: "browser.downloads.open", defaultValue: "Open")) { + onOpen(record) + } + .buttonStyle(.borderless) + .font(.callout) + + Button { + onReveal(record) + } label: { + Image(systemName: "magnifyingglass") + } + .buttonStyle(.borderless) + .help(String(localized: "browser.downloads.showInFinder", defaultValue: "Show in Finder")) + .accessibilityLabel(String(localized: "browser.downloads.showInFinder", defaultValue: "Show in Finder")) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .contentShape(Rectangle()) + .onTapGesture { + if record.state == .saved { + onOpen(record) + } + } + } + + @ViewBuilder + private var leadingIcon: some View { + switch record.state { + case .downloading: + ProgressView().controlSize(.small) + case .saved: + Image(systemName: "doc.fill") + .foregroundStyle(.secondary) + .imageScale(.large) + case .failed: + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + .imageScale(.large) + } + } + + private var subtitle: String { + switch record.state { + case .downloading: + return String(localized: "browser.downloading", defaultValue: "Downloading...") + case .failed: + return String(localized: "browser.downloads.failed", defaultValue: "Failed") + case .saved: + if let bytes = record.byteCount, bytes > 0 { + return ByteCountFormatter.string(fromByteCount: Int64(bytes), countStyle: .file) + } + return record.fileURL?.deletingLastPathComponent().lastPathComponent ?? "" + } + } +} 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 3aa2b3b94cd3..2476caefbabb 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -3,6 +3,8 @@ import Foundation 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)? @@ -13,7 +15,10 @@ import WebKit var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? var presentAlert: BrowserAlertPresenter = browserPresentAlert 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 @@ -80,6 +85,8 @@ import WebKit func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { lastAttemptedURL = lastAttemptedURL ?? webView.url ?? lastAttemptedRequest?.url + shouldPrintAfterCurrentNavigationFinishes = false + didClearPDFDocument?() didStartProvisionalNavigation?(webView) } @@ -92,6 +99,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) { @@ -239,6 +250,8 @@ import WebKit buttonNumber: navigationAction.buttonNumber, hasRecentMiddleClickIntent: hasRecentMiddleClickIntent ) + 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" @@ -295,6 +308,21 @@ import WebKit } if navigationAction.shouldPerformDownload { + 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 + } + } clearAttemptedRequest(discardPendingBypasses: true) decisionHandler(.download) return @@ -425,44 +453,109 @@ import WebKit 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) 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") - if let reason = BrowserDownloadFilenameResolver().navigationResponseDownloadReason( + 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, + hasTrustedPrintIntent: hasTrustedPDFPrintIntent + ) { + 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 let reason = filenameResolver.navigationResponseDownloadReason( mimeType: mime, canShowMIMEType: canShow, - contentDisposition: contentDisposition + contentDisposition: contentDisposition, + isForMainFrame: navigationResponse.isForMainFrame, + allowsSubframeDownload: allowsSubframeDownload, + isUserActivatedPreviouslyRenderedSubframePDF: isUserActivatedPreviouslyRenderedSubframePDF ) { - NSLog("BrowserPanel download: %@ mime=%@ url=%@", reason, mime, responseURL) + 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)") + cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)") #endif decisionHandler(.download) return } + subframeDownloadIntents.markRenderedSubframePDFIfNeeded( + responseURL: navigationResponse.response.url, + mimeType: mime, + isForMainFrame: navigationResponse.isForMainFrame + ) + if isPDFMIMEType(mime), let url = navigationResponse.response.url { + didRenderPDFDocument?(url, navigationResponse.isForMainFrame) + } else if navigationResponse.isForMainFrame { + didClearPDFDocument?() + } decisionHandler(.allow) } + func recordSubframeDownloadIntent(_ url: URL) { + subframeDownloadIntents.record(url) + } + + func recordPDFPrintIntent(_ url: URL) { + subframeDownloadIntents.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 + ) + } + + 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") @@ -479,3 +572,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/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 b95a4d8ff7bc..4800481e74f8 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2898,6 +2898,14 @@ final class BrowserPanel: Panel, ObservableObject { /// Published download state for browser downloads (navigation + context menu). @Published private(set) var isDownloading: Bool = false + /// Recent downloads for this pane, newest first, surfaced in the downloads + /// toolbar popover (Safari/Chrome-style). Capped at `maxRecentDownloads`. + @Published private(set) var recentDownloads: [BrowserDownloadRecord] = [] + + private static let maxRecentDownloads = 25 + + @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 @@ -3032,7 +3040,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 @@ -3363,9 +3371,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, @@ -3747,10 +3753,29 @@ final class BrowserPanel: Panel, ObservableObject { self?.endDownloadActivity() } } + webView.onSessionDownloadEvent = { [weak self] event in + guard let self else { return } + self.applyBrowserDownloadEvent( + type: event["type"] as? String ?? "", + downloadID: event["download_id"] as? String, + filename: event["filename"] as? String, + path: event["path"] as? String + ) + 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) } configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks() + webView.cmuxDownloadDelegate = downloadDelegate webView.navigationDelegate = navigationDelegate webView.uiDelegate = uiDelegate setupObservers(for: webView) @@ -3781,6 +3806,15 @@ final class BrowserPanel: Panel, ObservableObject { guard let navigationDelegate else { return } let boundWebViewInstanceID = webViewInstanceID let boundHistoryStore = historyStore + (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 { @@ -4004,9 +4038,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) } @@ -4014,12 +4047,16 @@ 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() - dlDelegate.onDownloadStarted = { [weak self] filename in + dlDelegate.savePanelParentWindow = { [weak self] in + self.flatMap { browserInteractiveModalHostWindow(for: $0.webView) } + } + dlDelegate.onDownloadStarted = { [weak self] filename, downloadID in guard let self else { return } self.beginDownloadActivity() + self.applyBrowserDownloadEvent(type: "started", downloadID: downloadID, filename: filename, path: nil) NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4028,12 +4065,13 @@ final class BrowserPanel: Panel, ObservableObject { "workspaceId": self.workspaceId, "event": [ "type": "started", + "download_id": downloadID, "filename": filename ] ] ) } - dlDelegate.onDownloadReadyToSave = { [weak self] in + dlDelegate.onDownloadReadyToSave = { [weak self] filename, downloadID in guard let self else { return } self.endDownloadActivity() NotificationCenter.default.post( @@ -4043,14 +4081,36 @@ final class BrowserPanel: Panel, ObservableObject { "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ - "type": "ready_to_save" + "type": "ready_to_save", + "download_id": downloadID, + "filename": filename ] ] ) } - dlDelegate.onDownloadFailed = { [weak self] error in + dlDelegate.onDownloadSaved = { [weak self] filename, destinationURL, shouldEndActivity, downloadID in guard let self else { return } - self.endDownloadActivity() + if shouldEndActivity { self.endDownloadActivity() } + self.applyBrowserDownloadEvent(type: "saved", downloadID: downloadID, filename: filename, path: destinationURL.path) + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": [ + "type": "saved", + "download_id": downloadID, + "filename": filename, + "path": destinationURL.path + ] + ] + ) + } + dlDelegate.onDownloadCancelled = { [weak self] filename, shouldEndActivity, downloadID in + guard let self else { return } + if shouldEndActivity { self.endDownloadActivity() } + self.applyBrowserDownloadEvent(type: "cancelled", downloadID: downloadID, filename: filename, path: nil) NotificationCenter.default.post( name: .browserDownloadEventDidArrive, object: self, @@ -4058,12 +4118,34 @@ final class BrowserPanel: Panel, ObservableObject { "surfaceId": self.id, "workspaceId": self.workspaceId, "event": [ - "type": "failed", - "error": error.localizedDescription + "type": "cancelled", + "download_id": downloadID, + "filename": filename ] ] ) } + dlDelegate.onDownloadFailed = { [weak self] _, shouldEndActivity, downloadID in + guard let self else { return } + if shouldEndActivity { self.endDownloadActivity() } + self.applyBrowserDownloadEvent(type: "failed", downloadID: downloadID, filename: nil, path: nil) + var event: [String: Any] = [ + "type": "failed", + "error": String(localized: "browser.download.error.generic", defaultValue: "Download failed") + ] + if let downloadID { + event["download_id"] = downloadID + } + NotificationCenter.default.post( + name: .browserDownloadEventDidArrive, + object: self, + userInfo: [ + "surfaceId": self.id, + "workspaceId": self.workspaceId, + "event": event + ] + ) + } navDelegate.downloadDelegate = dlDelegate self.downloadDelegate = dlDelegate self.navigationDelegate = navDelegate @@ -4074,9 +4156,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] 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() @@ -4360,6 +4441,103 @@ final class BrowserPanel: Panel, ObservableObject { } } + /// Fold a browser download event (from either the WKDownload path or the + /// session/context-menu path) into `recentDownloads` for the toolbar popover. + /// Mirrors the event vocabulary posted on `.browserDownloadEventDidArrive`. + /// + /// Always invoked on the main thread: the WKDownload callbacks fire inside a + /// `@MainActor` Task / `notifyOnMain`, and the session path hops to main + /// before delivering. It therefore mutates `recentDownloads` synchronously. + func applyBrowserDownloadEvent(type: String, downloadID: String?, filename: String?, path: String?) { + assert(Thread.isMainThread, "applyBrowserDownloadEvent must run on the main thread") + guard let downloadID else { return } + switch type { + case "started": + guard let filename, !filename.isEmpty else { return } + upsertRecentDownload( + BrowserDownloadRecord(id: downloadID, filename: filename, fileURL: nil, state: .downloading, byteCount: nil) + ) + case "saved": + let url = path.map { URL(fileURLWithPath: $0) } + let resolvedName = (filename?.isEmpty == false ? filename : nil) ?? url?.lastPathComponent + guard let resolvedName else { return } + let size = url.flatMap { u in + ((try? FileManager.default.attributesOfItem(atPath: u.path))?[.size] as? NSNumber)?.intValue + } + upsertRecentDownload( + BrowserDownloadRecord(id: downloadID, filename: resolvedName, fileURL: url, state: .saved, byteCount: size) + ) + case "failed": + markRecentDownloadFailed(id: downloadID, filename: filename) + case "cancelled": + recentDownloads.removeAll { $0.id == downloadID } + default: + break + } + } + + private func upsertRecentDownload(_ record: BrowserDownloadRecord) { + if let idx = recentDownloads.firstIndex(where: { $0.id == record.id }) { + recentDownloads.remove(at: idx) + } + recentDownloads.insert(record, at: 0) + if recentDownloads.count > Self.maxRecentDownloads { + recentDownloads.removeLast(recentDownloads.count - Self.maxRecentDownloads) + } + } + + private func markRecentDownloadFailed(id: String, filename: String?) { + if let idx = recentDownloads.firstIndex(where: { $0.id == id }) { + recentDownloads[idx].state = .failed + } else if let filename, !filename.isEmpty { + recentDownloads.insert( + BrowserDownloadRecord(id: id, filename: filename, fileURL: nil, state: .failed, byteCount: nil), + at: 0 + ) + } + } + + /// Open a completed download with the default app (Finder/Launch Services). + func openDownload(_ record: BrowserDownloadRecord) { + guard let url = record.fileURL, FileManager.default.fileExists(atPath: url.path) else { + NSSound.beep() + return + } + NSWorkspace.shared.open(url) + } + + /// Reveal a completed download in Finder (Safari/Chrome "Show in Finder"). + func revealDownloadInFinder(_ record: BrowserDownloadRecord) { + guard let url = record.fileURL, FileManager.default.fileExists(atPath: url.path) else { + NSSound.beep() + return + } + NSWorkspace.shared.activateFileViewerSelecting([url]) + } + + func clearRecentDownloads() { + recentDownloads.removeAll() + } + + func noteRenderedPDFDocument(_ url: URL, isMainFrame: Bool) { + // The PDF toolbar's Download/Print buttons act on the main web view, so + // only a top-level (main-frame) PDF document should drive the toolbar. + // A subframe PDF (e.g. an embedded