diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 7c6141a0d2..dd4f9892e5 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -32081,6 +32081,12 @@ "value": "再読み込み" } }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ផ្ទុកឡើងវិញ" + } + }, "zh-Hans": { "stringUnit": { "state": "translated", diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 2ae5442f27..cf048b5abd 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2502,7 +2502,7 @@ actor BrowserSearchSuggestionService { } /// BrowserPanel provides a WKWebView-based browser panel. -/// All browser panels share a WKProcessPool for cookie sharing. +/// Each browser panel owns a WKProcessPool so WebContent crashes stay isolated. private enum BrowserInsecureHTTPNavigationIntent { case currentTab case newTab @@ -2967,9 +2967,6 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { - /// Shared process pool for cookie sharing across all browser panels - private static let sharedProcessPool = WKProcessPool() - /// Popup windows owned by this panel (for lifecycle cleanup) private var popupControllers: [BrowserPopupWindowController] = [] @@ -3118,11 +3115,21 @@ final class BrowserPanel: Panel, ObservableObject { /// The underlying web view private(set) var webView: WKWebView private var websiteDataStore: WKWebsiteDataStore + /// Isolate WebKit crashes to this browser panel while keeping popups in the same browsing context. + private let processPool: WKProcessPool var webViewDidRequestClose: (() -> Void)? /// Monotonic identity for the current WKWebView instance. /// Incremented whenever we replace the underlying WKWebView after a process crash. @Published private(set) var webViewInstanceID: UUID = UUID() + private(set) var hasRecoverableWebContentTermination = false { + willSet { + if newValue != hasRecoverableWebContentTermination { + objectWillChange.send() + } + } + } + private var pendingWebContentRecoveryURL: URL? /// Prevent the omnibar from auto-focusing for a short window after explicit programmatic focus. /// This avoids races where SwiftUI focus state steals first responder back from WebKit. @@ -3796,7 +3803,8 @@ final class BrowserPanel: Panel, ObservableObject { let replacement = Self.makeWebView( profileID: profileID, - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + processPool: processPool ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() @@ -4034,12 +4042,14 @@ final class BrowserPanel: Panel, ObservableObject { private static func makeWebView( profileID: UUID, - websiteDataStore: WKWebsiteDataStore? = nil + websiteDataStore: WKWebsiteDataStore? = nil, + processPool: WKProcessPool ) -> CmuxWebView { let config = WKWebViewConfiguration() configureWebViewConfiguration( config, - websiteDataStore: websiteDataStore ?? BrowserProfileStore.shared.websiteDataStore(for: profileID) + websiteDataStore: websiteDataStore ?? BrowserProfileStore.shared.websiteDataStore(for: profileID), + processPool: processPool ) let webView = CmuxWebView(frame: .zero, configuration: config) @@ -4059,9 +4069,11 @@ final class BrowserPanel: Panel, ObservableObject { static func configureWebViewConfiguration( _ configuration: WKWebViewConfiguration, websiteDataStore: WKWebsiteDataStore, - processPool: WKProcessPool = BrowserPanel.sharedProcessPool + processPool: WKProcessPool? = nil ) { - configuration.processPool = processPool + if let processPool { + configuration.processPool = processPool + } configuration.mediaTypesRequiringUserActionForPlayback = [] // Ensure browser cookies/storage persist across navigations and launches. // This reduces repeated consent/bot-challenge flows on sites like Google. @@ -4253,10 +4265,13 @@ final class BrowserPanel: Panel, ObservableObject { self.websiteDataStore = isRemoteWorkspace ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? workspaceId) : BrowserProfileStore.shared.websiteDataStore(for: resolvedProfileID) + let processPool = WKProcessPool() + self.processPool = processPool let webView = Self.makeWebView( profileID: resolvedProfileID, - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + processPool: processPool ) self.webView = webView self.insecureHTTPAlertFactory = { NSAlert() } @@ -4677,6 +4692,8 @@ final class BrowserPanel: Panel, ObservableObject { webViewObservers.removeAll() webViewCancellables.removeAll() + clearWebContentTerminationRecovery() + clearBrowserFocusMode(reason: "profileSwitch") faviconTask?.cancel() faviconTask = nil faviconRefreshGeneration &+= 1 @@ -4701,7 +4718,8 @@ final class BrowserPanel: Panel, ObservableObject { let replacement = Self.makeWebView( profileID: resolvedProfileID, - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + processPool: processPool ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() @@ -5156,21 +5174,32 @@ final class BrowserPanel: Panel, ObservableObject { replaceWebViewPreservingState( from: terminatedWebView, websiteDataStore: websiteDataStore, - reason: "webcontent_process_terminated" + reason: "webcontent_process_terminated", + waitForManualRecovery: true ) } private func replaceWebViewPreservingState( from oldWebView: WKWebView, websiteDataStore: WKWebsiteDataStore, - reason: String + reason: String, + waitForManualRecovery: Bool = false ) { guard oldWebView === webView else { return } let wasRenderable = shouldRenderWebView - let restoreURL = Self.remoteProxyDisplayURL(for: oldWebView.url) ?? currentURL + let attemptedURL = Self.remoteProxyDisplayURL(for: navigationDelegate?.lastAttemptedURL) + ?? navigationDelegate?.lastAttemptedURL + let liveURL = Self.remoteProxyDisplayURL(for: oldWebView.url) + ?? currentURL + let restoreURL = (isMainFrameProvisionalNavigationActive ? attemptedURL : nil) + ?? liveURL + ?? attemptedURL + ?? resolvedCurrentSessionHistoryURL() let restoreURLString = restoreURL?.absoluteString - let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString + let hasRecoveryTarget = restoreURLString != nil && restoreURLString != blankURLString + let shouldRestoreURL = wasRenderable && hasRecoveryTarget + let shouldShowManualRecovery = waitForManualRecovery && wasRenderable && hasRecoveryTarget let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() let desiredZoom = max(minPageZoom, min(maxPageZoom, oldWebView.pageZoom)) @@ -5188,9 +5217,15 @@ final class BrowserPanel: Panel, ObservableObject { webViewObservers.removeAll() webViewCancellables.removeAll() + clearBrowserFocusMode(reason: reason) faviconTask?.cancel() faviconTask = nil faviconRefreshGeneration &+= 1 + loadingGeneration &+= 1 + loadingEndWorkItem?.cancel() + loadingEndWorkItem = nil + isLoading = false + estimatedProgress = 0 cancelPendingInteractiveBrowserPrompts(reason: reason) closeBackgroundPreloadHost(reason: reason) BrowserWindowPortalRegistry.detach(webView: oldWebView) @@ -5204,7 +5239,8 @@ final class BrowserPanel: Panel, ObservableObject { let replacement = Self.makeWebView( profileID: profileID, - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + processPool: processPool ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() @@ -5224,14 +5260,21 @@ final class BrowserPanel: Panel, ObservableObject { ) } - if shouldRestoreURL, let restoreURL { - navigateWithoutInsecureHTTPPrompt( - to: restoreURL, - recordTypedNavigation: false, - preserveRestoredSessionHistory: true - ) - } else { + if shouldShowManualRecovery, let restoreURL { + pendingWebContentRecoveryURL = restoreURL + hasRecoverableWebContentTermination = true refreshNavigationAvailability() + } else { + clearWebContentTerminationRecovery() + if shouldRestoreURL, let restoreURL { + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + } else { + refreshNavigationAvailability() + } } if restoreDevTools { @@ -5248,6 +5291,34 @@ final class BrowserPanel: Panel, ObservableObject { #endif } + @discardableResult + func recoverTerminatedWebContent(reason: String = "manual") -> Bool { + guard hasRecoverableWebContentTermination else { return false } + let recoveryURL = pendingWebContentRecoveryURL + clearWebContentTerminationRecovery() +#if DEBUG + cmuxDebugLog( + "browser.webcontent.recover panel=\(id.uuidString.prefix(5)) " + + "reason=\(reason) url=\(recoveryURL?.absoluteString ?? "nil")" + ) +#endif + guard let recoveryURL else { + refreshNavigationAvailability() + return true + } + navigateWithoutInsecureHTTPPrompt( + to: recoveryURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true + ) + return true + } + + private func clearWebContentTerminationRecovery() { + pendingWebContentRecoveryURL = nil + hasRecoverableWebContentTermination = false + } + #if DEBUG func debugSimulateWebContentProcessTermination() { replaceWebViewAfterContentProcessTermination(for: webView) @@ -5795,6 +5866,7 @@ final class BrowserPanel: Panel, ObservableObject { preserveRestoredSessionHistory: Bool ) { cancelHiddenWebViewDiscard() + clearWebContentTerminationRecovery() if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } @@ -6079,6 +6151,8 @@ extension BrowserPanel { isDownloading || activeDownloadCount != 0 || preferredDeveloperToolsVisible || + hasRecoverableWebContentTermination || + pendingWebContentRecoveryURL != nil || webView.superview != nil } @@ -6112,6 +6186,7 @@ extension BrowserPanel { developerToolsRestoreRetryAttempt = 0 preferredAttachedDeveloperToolsWidth = nil preferredAttachedDeveloperToolsWidthFraction = nil + clearWebContentTerminationRecovery() loadingEndWorkItem?.cancel() loadingEndWorkItem = nil @@ -6163,7 +6238,8 @@ extension BrowserPanel { let replacement = Self.makeWebView( profileID: profileID, - websiteDataStore: websiteDataStore + websiteDataStore: websiteDataStore, + processPool: processPool ) webViewInstanceID = UUID() webView = replacement @@ -6399,6 +6475,9 @@ extension BrowserPanel { /// Reload the current page func reload() { + if recoverTerminatedWebContent(reason: "reload") { + return + } if restoreDiscardedWebViewIfNeeded(reason: "reload") { return } diff --git a/Sources/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 4399cfe6c9..ebf42b4c28 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -657,6 +657,10 @@ struct BrowserPanelView: View { return } + if panel.recoverTerminatedWebContent(reason: "toolbarReload") { + return + } + if currentEventIsCommandPointerActivation { #if DEBUG cmuxDebugLog("browser.reload.commandClickDuplicate panel=\(panel.id.uuidString.prefix(5))") @@ -1749,10 +1753,37 @@ struct BrowserPanelView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .overlay { + if panel.hasRecoverableWebContentTermination { + webContentRecoveryOverlay + } + } .layoutPriority(1) .zIndex(0) } + private var webContentRecoveryOverlay: some View { + ZStack { + Color(nsColor: browserChromeBackgroundColor) + .opacity(0.92) + Button(action: { + panel.recoverTerminatedWebContent(reason: "overlayButton") + }) { + Label( + String(localized: "browser.error.reload", defaultValue: "Reload"), + systemImage: "arrow.clockwise" + ) + .font(.system(size: 13, weight: .medium)) + .padding(.horizontal, 6) + } + .buttonStyle(.borderedProminent) + .controlSize(.regular) + .safeHelp(String(localized: "browser.reload", defaultValue: "Reload")) + .accessibilityIdentifier("BrowserWebContentRecoveryButton") + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + private func triggerFocusFlashAnimation() { focusFlashAnimationGeneration &+= 1 let generation = focusFlashAnimationGeneration diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 998550c5e4..8c5bfe4571 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -353,6 +353,14 @@ final class BrowserPopupWindowController: NSObject, NSWindowDelegate { } } + fileprivate func handleWebContentProcessTermination(for terminatedWebView: WKWebView) { + guard terminatedWebView === webView else { return } +#if DEBUG + cmuxDebugLog("popup.webcontent.terminated depth=\(nestingDepth)") +#endif + closePopup() + } + fileprivate func requestNavigation(_ request: URLRequest, in webView: WKWebView) { guard let url = request.url else { return } @@ -666,6 +674,10 @@ private class PopupNavigationDelegate: NSObject, WKNavigationDelegate { completionHandler(.performDefaultHandling, nil) } + func webViewWebContentProcessDidTerminate(_ webView: WKWebView) { + controller?.handleWebContentProcessTermination(for: webView) + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { #if DEBUG cmuxDebugLog("popup.download.didBecome source=navigationAction") diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 5accfc93b1..5dcf011e63 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -70,6 +70,7 @@ 4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */; }; A5008371 /* BrowserSearchOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008370 /* BrowserSearchOverlay.swift */; }; A5007422 /* BrowserWebAuthnSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007423 /* BrowserWebAuthnSupport.swift */; }; + C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35530000000000000102 /* BundledCLILinkageTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; @@ -712,6 +713,7 @@ 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 = ""; }; A5007423 /* BrowserWebAuthnSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserWebAuthnSupport.swift; sourceTree = ""; }; + C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebContentProcessTests.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; C0DE35530000000000000102 /* BundledCLILinkageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledCLILinkageTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; @@ -1790,6 +1792,7 @@ A50019B3 /* SettingsSearchIndexTests.swift */, D36090010000000000000004 /* SettingsWindowPresenterTests.swift */, 970226F3C99D0D937CD00539 /* BrowserConfigTests.swift */, + C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */, 43F90FAF3FD44F11BF547BE9 /* CmuxWebViewMouseNavigationButtonTests.swift */, D3622001A1B2C3D4E5F60718 /* BrowserArrowKeyForwardingTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, @@ -2653,6 +2656,7 @@ C2B6A97D1F2E4C71A8B9D001 /* BrowserOmnibarPerformanceSupportTests.swift in Sources */, D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, + C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */, C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, diff --git a/cmuxTests/BrowserWebContentProcessTests.swift b/cmuxTests/BrowserWebContentProcessTests.swift new file mode 100644 index 0000000000..1a07a5d871 --- /dev/null +++ b/cmuxTests/BrowserWebContentProcessTests.swift @@ -0,0 +1,158 @@ +import Foundation +import Testing +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct BrowserWebContentProcessTests { + private let recoveryURL = URL(string: "data:text/html,cmux-recovery")! + + @Test + func browserPanelsUseSeparateWebContentProcessPools() { + let first = BrowserPanel(workspaceId: UUID()) + let second = BrowserPanel(workspaceId: UUID()) + defer { + first.close() + second.close() + } + + #expect(!(first.webView.configuration.processPool === second.webView.configuration.processPool)) + #expect(first.webView.configuration.websiteDataStore === second.webView.configuration.websiteDataStore) + } + + @Test + func configureWebViewConfigurationPreservesCopiedProcessPoolWhenOmitted() { + let configuration = WKWebViewConfiguration() + let originalProcessPool = configuration.processPool + let suppliedProcessPool = WKProcessPool() + + BrowserPanel.configureWebViewConfiguration( + configuration, + websiteDataStore: .nonPersistent() + ) + #expect(configuration.processPool === originalProcessPool) + + BrowserPanel.configureWebViewConfiguration( + configuration, + websiteDataStore: .nonPersistent(), + processPool: suppliedProcessPool + ) + #expect(configuration.processPool === suppliedProcessPool) + } + + @Test + func webViewReplacementAfterProcessTerminationUpdatesInstanceIdentity() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: recoveryURL + ) + defer { panel.close() } + let oldWebView = panel.webView + let oldInstanceID = panel.webViewInstanceID + let oldProcessPool = oldWebView.configuration.processPool + + panel.debugSimulateWebContentProcessTermination() + + #expect(!(panel.webView === oldWebView)) + #expect(panel.webViewInstanceID != oldInstanceID) + #expect(panel.webView.configuration.processPool === oldProcessPool) + #expect(panel.hasRecoverableWebContentTermination) + #expect(panel.webView.navigationDelegate != nil) + #expect(panel.webView.uiDelegate != nil) + } + + @Test + func reloadRecoversTerminatedWebView() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: recoveryURL + ) + defer { panel.close() } + + panel.debugSimulateWebContentProcessTermination() + #expect(panel.hasRecoverableWebContentTermination) + + panel.reload() + + #expect(!panel.hasRecoverableWebContentTermination) + #expect(panel.shouldRenderWebView) + } + + @Test + func workspaceContextResetClearsTerminatedWebViewRecovery() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: recoveryURL + ) + defer { panel.close() } + + panel.debugSimulateWebContentProcessTermination() + #expect(panel.hasRecoverableWebContentTermination) + + panel.resetForWorkspaceContextChange(reason: "test") + + #expect(!panel.hasRecoverableWebContentTermination) + #expect(!panel.shouldRenderWebView) + #expect(panel.preferredURLStringForOmnibar() == nil) + } + + @Test + func profileSwitchClearsTerminatedWebViewRecovery() throws { + let profile = try #require( + BrowserProfileStore.shared.createProfile( + named: "WebContent Recovery \(UUID().uuidString)" + ) + ) + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: BrowserProfileStore.shared.builtInDefaultProfileID, + initialURL: recoveryURL + ) + defer { panel.close() } + + panel.debugSimulateWebContentProcessTermination() + #expect(panel.hasRecoverableWebContentTermination) + + #expect(panel.switchToProfile(profile.id)) + + #expect(!panel.hasRecoverableWebContentTermination) + } + + @Test + func webViewReplacementPreservesEmptyNewTabRenderState() { + let panel = BrowserPanel(workspaceId: UUID()) + defer { panel.close() } + #expect(!panel.shouldRenderWebView) + + panel.debugSimulateWebContentProcessTermination() + + #expect(!panel.shouldRenderWebView) + #expect(!panel.hasRecoverableWebContentTermination) + } + + @Test + func floatingPopupClosesWhenWebContentProcessTerminates() throws { + let panel = BrowserPanel(workspaceId: UUID(), isRemoteWorkspace: false) + defer { panel.close() } + let popupWebView = try #require( + panel.createFloatingPopup( + configuration: WKWebViewConfiguration(), + windowFeatures: WKWindowFeatures() + ) + ) + let popupWindow = try #require(popupWebView.window) + + popupWebView.navigationDelegate?.webViewWebContentProcessDidTerminate?(popupWebView) + + #expect(popupWebView.navigationDelegate == nil) + #expect(popupWebView.uiDelegate == nil) + #expect(popupWebView.window == nil) + #expect(!popupWindow.isVisible) + } +}