diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 1c6864426411..82e752ec5c32 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -180,7 +180,7 @@ 630 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/ShortcutWhenClause.swift 624 Sources/SettingsNavigation.swift 623 Packages/macOS/CmuxControlSocket/Sources/CmuxControlSocket/Coordinator/Surface/ControlCommandCoordinator+Surface.swift -620 Sources/Panels/BrowserNavigationDelegate.swift +633 Sources/Panels/BrowserNavigationDelegate.swift 620 cmuxTests/FinderFileDropRegressionTests.swift 608 cmuxUITests/FeedSidebarUITests.swift 607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift new file mode 100644 index 000000000000..e83254c1e0b3 --- /dev/null +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -0,0 +1,318 @@ +import Foundation +import WebKit + +extension BrowserPanel { + func shouldTreatCommitAsDiscardedRestoreCommit(from webView: WKWebView) -> Bool { + guard navigationDelegate?.activeErrorPageDisplayURL == nil else { return false } + guard let committedURL = webView.url else { return false } + return !Self.isAboutBlankURL(committedURL) + } + + func noteDiscardedWebViewRestoreNavigationStarted() { + if hiddenWebViewDiscardManager.isDiscardedForMemory { + // Each restore attempt tracks its own commit. Without this reset, a + // previous attempt's error-page commit would satisfy the stall + // detector forever and a silently stalled retry could never re-arm. + hasCommittedDocumentSinceWebViewReplacement = false + currentDiscardRestoreAttemptID = UUID() + } + hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "navigation") + refreshWebViewLifecycleState() + } + + func noteDiscardedWebViewRestoreNavigationCommitted(reason: String = "navigation_commit") { + guard hiddenWebViewDiscardManager.noteRestoreNavigationCommitted(reason: reason) else { + return + } + pendingDiscardRestoreNavigation = nil + currentDiscardRestoreAttemptID = nil + refreshWebViewLifecycleState() + } + + func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { + hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) + pendingDiscardRestoreNavigation = nil + refreshWebViewLifecycleState() + } + + /// Whether a WebKit failure/cancel callback belongs to the navigation the + /// discard-restore bookkeeping is tracking. WebKit can deliver an older + /// provisional load's cancellation after a newer attempt has already + /// started; clearing the pending state for that stale callback would let a + /// visibility touch hijack the in-flight navigation with a restore reload. + /// A nil callback navigation or no tracked navigation matches conservatively. + func isDiscardRestoreBookkeepingNavigation(_ navigation: WKNavigation?) -> Bool { + guard let tracked = pendingDiscardRestoreNavigation else { return true } + guard let navigation else { return true } + return navigation === tracked + } + + /// Restore touch for a possibly-discarded pane: detects stalled restore + /// attempts, honors an explicit user Stop, restores through the discard + /// manager, and falls back to blank-shell healing. + @discardableResult + func restoreDiscardedWebViewIfNeeded( + reason: String, + cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy, + allowBlankShellHeal: Bool = true, + forceRestartPendingRestore: Bool = false + ) -> Bool { + if Self.isRestoreStalled( + isRestoreNavigationPending: hiddenWebViewDiscardManager.isRestoreNavigationPending, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement + ) { + noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "\(reason).stalled") + } + + if forceRestartPendingRestore { + userStoppedLoadSinceWebViewReplacement = false + } + // Stop is sticky for discarded restores too: routine visibility touches + // must not restart a stopped load; explicit reload is the override. + guard !userStoppedLoadSinceWebViewReplacement else { return false } + + if Self.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: hiddenWebViewDiscardManager.isDiscardedForMemory, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + forceRestartPendingRestore: forceRestartPendingRestore + ) { + return true + } + + let restoreURL = restoredHistoryCurrentURL ?? currentURL + guard let restoreURL, !Self.isAboutBlankURL(restoreURL) else { + return reactivateDiscardedPaneWithoutRestorableURL(reason: reason) + } + + if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, force: forceRestartPendingRestore, performRestore: { + shouldRenderWebView = true + navigateWithoutInsecureHTTPPrompt( + to: restoreURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true, + cachePolicy: cachePolicy + ) + }) { + return true + } + + guard allowBlankShellHeal else { return false } + return healBlankRestoredWebViewIfNeeded(reason: reason, cachePolicy: cachePolicy) + } + + /// Re-navigates a rendered-but-empty web view (for example after a failed + /// discard restore whose state was already consumed) back to its intent URL. + @discardableResult + private func healBlankRestoredWebViewIfNeeded( + reason _: String, + cachePolicy: URLRequest.CachePolicy + ) -> Bool { + let intentURL = restoredHistoryCurrentURL ?? currentURL + let isNavigationBlockedPendingConsent = intentURL.map { browserShouldBlockInsecureHTTPURL($0) } ?? false + guard Self.shouldHealBlankShell( + shouldRenderWebView: shouldRenderWebView, + isClosing: isClosingWebViewLifecycle, + hasPendingRemoteNavigation: hasPendingRemoteNavigation, + isWebViewLoading: webView.isLoading, + isMainFrameProvisionalNavigationActive: isMainFrameProvisionalNavigationActive, + hasCommittedDocument: hasCommittedDocumentSinceWebViewReplacement, + isNavigationBlockedPendingConsent: isNavigationBlockedPendingConsent, + hasRecoverableWebContentTermination: hasRecoverableWebContentTermination, + userStoppedLoad: userStoppedLoadSinceWebViewReplacement, + isShowingErrorPage: navigationDelegate?.activeErrorPageDisplayURL != nil, + intentURL: intentURL + ) else { + return false + } + guard let intentURL else { return false } + navigateWithoutInsecureHTTPPrompt( + to: intentURL, + recordTypedNavigation: false, + preserveRestoredSessionHistory: true, + cachePolicy: cachePolicy + ) + return true + } + + /// Restore fallback for a discarded pane with no restorable document (nil + /// or about:blank restore URL): navigating would wait on a commit that + /// ``shouldTreatCommitAsDiscardedRestoreCommit(from:)`` ignores, leaving the + /// manager pending forever, so reactivate in place instead. + func reactivateDiscardedPaneWithoutRestorableURL(reason: String) -> Bool { + guard reactivateDiscardedWebViewWithoutNavigation(reason: "\(reason).no_restore_url") else { + return false + } + refreshNavigationAvailability() + refreshWebViewLifecycleState() + return true + } + + /// ISO8601DateFormatter is documented thread-safe; cached so the polled + /// lifecycle-payload path stays allocation-free. + private nonisolated(unsafe) static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + nonisolated static func webViewLifecycleTimestamp(_ date: Date?) -> Any { + guard let date else { return NSNull() } + return webViewLifecycleTimestampFormatter.string(from: date) + } + + nonisolated static func webViewHiddenDurationMilliseconds( + hiddenAt: Date?, + visible: Bool, + now: Date + ) -> Any { + guard !visible, let hiddenAt else { return NSNull() } + return max(0, Int((now.timeIntervalSince(hiddenAt) * 1000.0).rounded())) + } + + nonisolated static func isAboutBlankURL(_ url: URL?) -> Bool { + guard let url else { return false } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + return value.caseInsensitiveCompare("about:blank") == .orderedSame + } + + nonisolated static func shouldHealBlankShell( + shouldRenderWebView: Bool, + isClosing: Bool, + hasPendingRemoteNavigation: Bool, + isWebViewLoading: Bool, + isMainFrameProvisionalNavigationActive: Bool, + hasCommittedDocument: Bool, + isNavigationBlockedPendingConsent: Bool, + hasRecoverableWebContentTermination: Bool, + userStoppedLoad: Bool, + isShowingErrorPage: Bool, + intentURL: URL? + ) -> Bool { + guard shouldRenderWebView else { return false } + guard !isClosing else { return false } + guard !hasPendingRemoteNavigation else { return false } + guard !isWebViewLoading else { return false } + guard !isMainFrameProvisionalNavigationActive else { return false } + guard !hasCommittedDocument else { return false } + guard !isNavigationBlockedPendingConsent else { return false } + // A crashed WebContent process waits for the user's explicit Reload; + // auto-healing here would bypass that gate and can re-enter the crash. + guard !hasRecoverableWebContentTermination else { return false } + // A load the user explicitly stopped before first commit must stay + // stopped; healing on reveal would silently undo the Stop. + guard !userStoppedLoad else { return false } + // The browser's own error page commits as about:blank; it is content + // awaiting the user's Reload, not a blank shell to heal over. + guard !isShowingErrorPage else { return false } + guard let intentURL else { return false } + return !isAboutBlankURL(intentURL) + } + + /// Whether a discarded pane's restore is already queued waiting for the + /// remote workspace proxy endpoint. A queued remote restore never enters + /// performNavigation, so isRestoreNavigationPending stays false; without + /// this check every later restore touch would re-run the restore closure + /// and re-queue the navigation instead of treating the queue as in-flight. + /// An explicit reload (force) still restarts the queued restore. + nonisolated static func isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: Bool, + hasPendingRemoteNavigation: Bool, + forceRestartPendingRestore: Bool + ) -> Bool { + guard isDiscardedForMemory, hasPendingRemoteNavigation else { return false } + return !forceRestartPendingRestore + } + + nonisolated static func isRestoreStalled( + isRestoreNavigationPending: Bool, + isWebViewLoading: Bool, + isMainFrameProvisionalNavigationActive: Bool, + hasPendingRemoteNavigation: Bool, + hasCommittedDocument: Bool + ) -> Bool { + guard isRestoreNavigationPending else { return false } + guard !isWebViewLoading else { return false } + guard !isMainFrameProvisionalNavigationActive else { return false } + guard !hasPendingRemoteNavigation else { return false } + return !hasCommittedDocument + } +} + +extension BrowserPanel { + /// Whether browser native/SwiftUI fills should draw over the window root + /// backdrop. Mirrors terminal/markdown panel background decisions. + static func drawsConfiguredWebViewBackground( + isBlankPage: Bool, + usesTransparentBackground: Bool = false + ) -> Bool { + drawsWebViewBackground( + isBlankPage: isBlankPage, + usesTransparentBackground: usesTransparentBackground, + opacity: GhosttyApp.shared.defaultBackgroundOpacity, + usesGhosttyGlassStyle: GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle, + usesTransparentWindow: WindowBackgroundComposition.policy + .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) + ) + } + + nonisolated static func isBlankBrowserPageURL(_ url: URL?) -> Bool { + guard let url else { return true } + let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) + return value.caseInsensitiveCompare("about:blank") == .orderedSame + } + + nonisolated static func isBlankBrowserPage( + liveURL: URL?, + currentURL: URL?, + pendingNavigationURL: URL?, + isMainFrameProvisionalNavigationActive: Bool + ) -> Bool { + if isMainFrameProvisionalNavigationActive, + !isBlankBrowserPageURL(pendingNavigationURL) { + return false + } + if !isBlankBrowserPageURL(pendingNavigationURL), + isBlankBrowserPageURL(liveURL), + isBlankBrowserPageURL(currentURL) { + return false + } + return isBlankBrowserPageURL(liveURL) && isBlankBrowserPageURL(currentURL) + } + + nonisolated static func drawsWebViewBackground( + isBlankPage: Bool, + usesTransparentBackground: Bool = false, + opacity: Double, + usesGhosttyGlassStyle: Bool, + usesTransparentWindow: Bool + ) -> Bool { + if usesTransparentBackground { + return drawsWebViewBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } + guard isBlankPage else { return true } + return drawsWebViewBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } + + nonisolated static func drawsWebViewBackground( + opacity: Double, + usesGhosttyGlassStyle: Bool, + usesTransparentWindow: Bool + ) -> Bool { + !PanelAppearance.shouldUseClearContentBackground( + opacity: opacity, + usesGhosttyGlassStyle: usesGhosttyGlassStyle, + usesTransparentWindow: usesTransparentWindow + ) + } +} diff --git a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift index b447b5e51559..2d547ad125ce 100644 --- a/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift +++ b/Sources/Panels/BrowserHiddenWebViewDiscardManager.swift @@ -66,6 +66,7 @@ final class BrowserHiddenWebViewDiscardManager { private(set) var lastDiscardReason: String? private(set) var lastRestoreReason: String? private(set) var restoredSessionShouldRenderWebView: Bool? + private(set) var isRestoreNavigationPending: Bool = false var hasScheduledDiscard: Bool { discardTimer != nil @@ -239,29 +240,56 @@ final class BrowserHiddenWebViewDiscardManager { func markDiscarded(reason: String, now: Date) { isDiscardedForMemory = true + isRestoreNavigationPending = false discardedAt = now lastDiscardReason = reason updateRestoredSessionRenderIntent(true) } @discardableResult - func restoreIfNeeded(reason: String, performRestore: () -> Void) -> Bool { + func restoreIfNeeded(reason: String, force: Bool = false, performRestore: () -> Void) -> Bool { guard isDiscardedForMemory else { return false } cancel() - guard clearDiscardState(reason: reason) else { return false } + if isRestoreNavigationPending { + // An explicit user reload restarts an in-flight restore instead of + // being swallowed by the pending dedup. + guard force else { return true } + isRestoreNavigationPending = false + } + lastRestoreReason = reason updateRestoredSessionRenderIntent(nil) performRestore() return true } + func noteRestoreNavigationStarted(reason: String) { + guard isDiscardedForMemory else { return } + isRestoreNavigationPending = true +#if DEBUG + cmuxDebugLog("browser.discard.restoreNavigation.start reason=\(reason)") +#endif + } + + @discardableResult + func noteRestoreNavigationCommitted(reason: String) -> Bool { + isRestoreNavigationPending = false + return clearDiscardState(reason: reason) + } + + func noteRestoreNavigationDidNotCommit(reason: String) { + guard isDiscardedForMemory else { return } + isRestoreNavigationPending = false +#if DEBUG + cmuxDebugLog("browser.discard.restoreNavigation.didNotCommit reason=\(reason)") +#endif + } + @discardableResult func reactivateWithoutNavigation(reason: String, performReactivate: () -> Void) -> Bool { guard isDiscardedForMemory else { return false } cancel() - guard clearDiscardState(reason: reason) else { return false } - updateRestoredSessionRenderIntent(nil) performReactivate() - return true + return clearDiscardState(reason: reason) } func updateRestoredSessionRenderIntent(_ shouldRenderWebView: Bool?) { @@ -272,14 +300,17 @@ final class BrowserHiddenWebViewDiscardManager { func clearDiscardState(reason: String) -> Bool { guard isDiscardedForMemory else { return false } isDiscardedForMemory = false + isRestoreNavigationPending = false discardedAt = nil lastRestoreReason = reason + updateRestoredSessionRenderIntent(nil) return true } func resetMetadata() { cancel() isDiscardedForMemory = false + isRestoreNavigationPending = false discardedAt = nil lastDiscardReason = nil lastRestoreReason = nil diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 79586a7eadf2..86c10b9c8a99 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -3,13 +3,16 @@ import Foundation import WebKit @MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate { + enum PolicyCancellationKind { case terminal(restoreAttemptID: UUID?) } private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker() private var shouldPrintAfterCurrentNavigationFinishes = false var didStartProvisionalNavigation: ((WKWebView) -> Void)? var didCommit: ((WKWebView) -> Void)? var didFinish: ((WKWebView) -> Void)? - var didFailNavigation: ((WKWebView, String) -> Void)? - var didCancelProvisionalNavigation: ((WKWebView) -> Void)? + var didFailNavigation: ((WKWebView, String, WKNavigation?) -> Void)? + var didCancelProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)? + var didCancelNavigationPolicy: ((WKWebView, PolicyCancellationKind) -> Void)? + var didBecomeDownload: ((WKWebView, Bool, UUID?) -> Void)? var didTerminateWebContentProcess: ((WKWebView) -> Void)? var openInNewTab: ((URL) -> Void)? var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? @@ -18,12 +21,13 @@ import WebKit var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)? var handleBlockedInsecureHTTPNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)? var handleDroppedFileNavigation: (([URL]) -> Bool)? + var currentRestoreAttemptID: (() -> UUID?)? + var terminalPolicyCancellationReporter: ((WKNavigationAction, WKWebView) -> () -> 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 - /// when a provisional navigation fails (e.g. connection refused on localhost:3000). + /// Last attempted navigation URL, used to preserve the omnibar URL after provisional failures. var lastAttemptedURL: URL? private(set) var activeErrorPageDisplayURL: URL? private let basicAuthPromptCoordinator = BrowserHTTPBasicAuthPromptCoordinator() @@ -35,6 +39,7 @@ import WebKit private var activeSSLTrustBypassErrorPageFailedURL: String? private var activeSSLTrustBypassReplayRequest: URLRequest? private var activeSSLTrustBypassErrorPageRetryRequest: URLRequest? + private var pendingMainFrameDownloadRestoreAttemptID: UUID? func cancelPendingAuthenticationPrompts(allowFuturePrompts: Bool = false) { basicAuthPromptCoordinator.cancelAll(allowFuturePrompts: allowFuturePrompts) @@ -111,7 +116,7 @@ import WebKit // Treat committed-navigation failures the same as provisional ones so // stale favicon/title state from the prior page gets cleared. let failedURL = webView.url?.absoluteString ?? "" - didFailNavigation?(webView, failedURL) + didFailNavigation?(webView, failedURL, navigation) } func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { @@ -120,7 +125,7 @@ import WebKit // Cancelled navigations (e.g. rapid typing) are not real errors. if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled { - didCancelProvisionalNavigation?(webView) + didCancelProvisionalNavigation?(webView, navigation) return } @@ -128,14 +133,14 @@ import WebKit // navigation response is converted into a download via .download policy. // This is expected and should not show an error page. if nsError.domain == "WebKitErrorDomain", nsError.code == 102 { - didCancelProvisionalNavigation?(webView) + didCancelProvisionalNavigation?(webView, navigation) return } let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String ?? lastAttemptedURL?.absoluteString ?? "" - didFailNavigation?(webView, failedURL) + didFailNavigation?(webView, failedURL, navigation) loadErrorPage( in: webView, failedURL: failedURL, @@ -268,6 +273,9 @@ import WebKit ) let hasUserActivation = browserNavigationHasSimpleUserActivation() subframeDownloadIntents.updateIfNeeded(navigationAction, hasUserActivation: hasUserActivation) + if navigationAction.targetFrame?.isMainFrame == true { + pendingMainFrameDownloadRestoreAttemptID = currentRestoreAttemptID?() + } #if DEBUG let currentEventType = NSApp.currentEvent.map { String(describing: $0.type) } ?? "nil" let currentEventButton = NSApp.currentEvent.map { String($0.buttonNumber) } ?? "nil" @@ -288,6 +296,7 @@ import WebKit if let url = navigationAction.request.url, shouldOpenCheckoutInSystemBrowser(navigationAction, url: url) { clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} let opened = NSWorkspace.shared.open(url) #if DEBUG cmuxDebugLog( @@ -295,6 +304,7 @@ import WebKit "url=\(browserNavigationDebugURL(url))" ) #endif + if opened { reportTerminalCancellation() } decisionHandler(opened ? .cancel : .allow) return } @@ -319,11 +329,10 @@ import WebKit return } - // WebKit cannot open app-specific deeplinks (discord://, slack://, zoommtg://, etc.). - // Hand these off to macOS so the owning app can handle them. if let url = navigationAction.request.url, browserShouldRouteExternalNavigation(url) { clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} browserHandleExternalNavigation( url, source: "navDelegate", @@ -331,7 +340,8 @@ import WebKit loadFallbackRequest: { [requestNavigation] request in requestNavigation?(request, .currentTab) }, - presentAlert: presentAlert + presentAlert: presentAlert, + onTerminalExternalNavigation: reportTerminalCancellation ) decisionHandler(.cancel) return @@ -367,14 +377,13 @@ import WebKit ) #endif clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} openRequestInNewTab(navigationAction.request) + reportTerminalCancellation() decisionHandler(.cancel) return } - // target=_blank link navigations should open in a new tab. - // Scripted popups (navigationType == .other) are handled in - // WKUIDelegate.createWebViewWith so OAuth opener linkage survives. if navigationAction.targetFrame == nil, browserNavigationShouldFallbackNilTargetToNewTab( navigationType: navigationAction.navigationType @@ -386,7 +395,9 @@ import WebKit ) #endif clearAttemptedRequest(discardPendingBypasses: true) + let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} openRequestInNewTab(navigationAction.request) + reportTerminalCancellation() decisionHandler(.cancel) return } @@ -502,9 +513,6 @@ import WebKit let mime = navigationResponse.response.mimeType ?? "unknown" let canShow = navigationResponse.canShowMIMEType - // 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) @@ -603,18 +611,25 @@ import WebKit } func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true + let restoreAttemptID = isMainFrame ? pendingMainFrameDownloadRestoreAttemptID : nil #if DEBUG cmuxDebugLog("download.didBecome source=navigationAction") #endif NSLog("BrowserPanel download didBecome from navigationAction") + didBecomeDownload?(webView, isMainFrame, restoreAttemptID) + if isMainFrame { pendingMainFrameDownloadRestoreAttemptID = nil } download.delegate = downloadDelegate } func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + let restoreAttemptID = navigationResponse.isForMainFrame ? pendingMainFrameDownloadRestoreAttemptID : nil #if DEBUG cmuxDebugLog("download.didBecome source=navigationResponse") #endif NSLog("BrowserPanel download didBecome from navigationResponse") + didBecomeDownload?(webView, navigationResponse.isForMainFrame, restoreAttemptID) + if navigationResponse.isForMainFrame { pendingMainFrameDownloadRestoreAttemptID = nil } download.delegate = downloadDelegate } } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 226ddc53e6dd..82686c11c543 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -1005,6 +1005,12 @@ enum BrowserExternalNavigationAction: Equatable { case promptToOpenApp(URL) } +enum BrowserExternalNavigationHandlingResult: Equatable { + case notHandled + case browserFallback + case externalPrompt +} + func browserShouldRouteExternalNavigation(_ url: URL) -> Bool { return browserExternalNavigationAction(for: url) != nil } @@ -1158,9 +1164,10 @@ func browserHandleExternalNavigation( source: String, webView: WKWebView, loadFallbackRequest: (URLRequest) -> Void, - presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert -) -> Bool { - guard let action = browserExternalNavigationAction(for: url) else { return false } + presentAlert: @escaping BrowserAlertPresenter = browserPresentAlert, + onTerminalExternalNavigation: @escaping () -> Void = {} +) -> BrowserExternalNavigationHandlingResult { + guard let action = browserExternalNavigationAction(for: url) else { return .notHandled } switch action { case let .browserFallback(fallbackURL): @@ -1172,7 +1179,7 @@ func browserHandleExternalNavigation( "fallbackURL=\(browserNavigationDebugURL(fallbackURL)) url=\(browserNavigationDebugURL(url))" ) #endif - return true + return .browserFallback case let .promptToOpenApp(externalURL): browserPresentExternalNavigationPrompt( @@ -1188,16 +1195,17 @@ func browserHandleExternalNavigation( #endif return } - browserOpenExternalNavigationURL( + let opened = browserOpenExternalNavigationURL( externalURL, source: source, webView: webView, presentAlert: presentAlert ) + if opened { onTerminalExternalNavigation() } }, presentAlert: presentAlert ) - return true + return .externalPrompt } } @@ -1823,6 +1831,22 @@ enum BrowserInsecureHTTPNavigationIntent { case newTab } +enum BrowserInsecureHTTPNavigationResolution { + case openedExternally + case proceededInCurrentTab + case proceededInNewTab + case cancelled + + var isTerminalPolicyCancellation: Bool { + switch self { + case .openedExternally, .proceededInNewTab: + true + case .proceededInCurrentTab, .cancelled: + false + } + } +} + nonisolated enum BrowserWebViewLifecycleState: String { case newTab = "new_tab" case deferredURL = "deferred_url" @@ -2766,7 +2790,7 @@ final class BrowserPanel: Panel, ObservableObject { /// Whether the browser panel should render its WKWebView in the content area. /// New browser tabs stay in an empty "new tab" state until first navigation. - @Published private(set) var shouldRenderWebView: Bool = false { + @Published var shouldRenderWebView: Bool = false { didSet { if oldValue != shouldRenderWebView { refreshWebViewLifecycleState() @@ -2775,7 +2799,11 @@ final class BrowserPanel: Panel, ObservableObject { } } @Published private(set) var backgroundAppearanceRevision: UInt64 = 0 - private let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() + let hiddenWebViewDiscardManager = BrowserHiddenWebViewDiscardManager() + var hasCommittedDocumentSinceWebViewReplacement = false + var userStoppedLoadSinceWebViewReplacement = false + var pendingDiscardRestoreNavigation: WKNavigation? + var currentDiscardRestoreAttemptID: UUID? @Published private(set) var webViewLifecycleState: BrowserWebViewLifecycleState = .newTab private(set) var webViewLastVisibleAt: Date? @@ -2796,7 +2824,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingInteractiveBrowserPrompts: [PendingInteractiveBrowserPrompt] = [] private var isPresentingPendingInteractiveBrowserPrompt = false private var isWebViewVisibleInUI: Bool = false - private var isClosingWebViewLifecycle: Bool = false + var isClosingWebViewLifecycle: Bool = false /// True while a canvas pane hosts this browser's webview inline (in the /// pane's own hierarchy). Portal-side reconcilers must not rebind or @@ -2864,10 +2892,10 @@ final class BrowserPanel: Panel, ObservableObject { private var usesRestoredSessionHistory: Bool { restoredSessionHistory.usesRestoredSessionHistory } - private var restoredHistoryCurrentURL: URL? { + var restoredHistoryCurrentURL: URL? { restoredSessionHistory.current } - private var isMainFrameProvisionalNavigationActive: Bool = false + var isMainFrameProvisionalNavigationActive: Bool = false /// Published estimated progress (0.0 - 1.0) @Published private(set) var estimatedProgress: Double = 0.0 @@ -2969,7 +2997,7 @@ final class BrowserPanel: Panel, ObservableObject { private var pendingDistinctPortalHostReplacementPaneId: UUID? private var lockedPortalHost: PortalHostLock? private var webViewCancellables = Set() - private var navigationDelegate: BrowserNavigationDelegate? + private(set) var navigationDelegate: BrowserNavigationDelegate? private var uiDelegate: BrowserUIDelegate? var downloadDelegate: BrowserDownloadDelegate? private let webAuthnCoordinator = BrowserWebAuthnCoordinator() @@ -2988,8 +3016,8 @@ final class BrowserPanel: Panel, ObservableObject { private let maxPageZoom: CGFloat = 5.0 private let pageZoomStep: CGFloat = 0.1 private var insecureHTTPBypassHostOnce: String? - private var insecureHTTPAlertFactory: () -> NSAlert - private var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } + var insecureHTTPAlertFactory: () -> NSAlert + var insecureHTTPAlertWindowProvider: () -> NSWindow? = { NSApp.keyWindow ?? NSApp.mainWindow } // Persist user intent across WebKit detach/reattach churn (split/layout updates). @Published private(set) var preferredDeveloperToolsVisible: Bool = false @Published var isReactGrabActive: Bool = false { @@ -3167,7 +3195,10 @@ final class BrowserPanel: Panel, ObservableObject { if visible { cancelHiddenWebViewDiscard() - restoreDiscardedWebViewIfNeeded(reason: "visible.\(reason)") + restoreDiscardedWebViewIfNeeded( + reason: "visible.\(reason)", + allowBlankShellHeal: changed || isFirstVisibilityRecord + ) drainPendingInteractiveBrowserPromptsIfPossible(reason: "visible.\(reason)") } else if changed || isFirstVisibilityRecord || !hiddenWebViewDiscardManager.hasScheduledDiscard { scheduleHiddenWebViewDiscardIfNeeded(reason: reason, now: now) @@ -3182,6 +3213,8 @@ final class BrowserPanel: Panel, ObservableObject { "should_render": shouldRenderWebView, "discard_eligible": discardBlockers.isEmpty, "discard_blockers": discardBlockers, + "restore_pending": hiddenWebViewDiscardManager.isRestoreNavigationPending, + "has_committed_document": hasCommittedDocumentSinceWebViewReplacement, "discarded_at": Self.webViewLifecycleTimestamp(hiddenWebViewDiscardManager.discardedAt), "last_discard_reason": hiddenWebViewDiscardManager.lastDiscardReason.map { $0 as Any } ?? NSNull(), "last_restore_reason": hiddenWebViewDiscardManager.lastRestoreReason.map { $0 as Any } ?? NSNull(), @@ -3197,11 +3230,11 @@ final class BrowserPanel: Panel, ObservableObject { ] } - private func refreshWebViewLifecycleState() { + func refreshWebViewLifecycleState() { let nextState: BrowserWebViewLifecycleState if isClosingWebViewLifecycle { nextState = .closing - } else if hiddenWebViewDiscardManager.isDiscardedForMemory { + } else if hiddenWebViewDiscardManager.isDiscardedForMemory && !shouldRenderWebView { nextState = .discarded } else if !shouldRenderWebView { nextState = preferredURLStringForOmnibar() == nil ? .newTab : .deferredURL @@ -3214,29 +3247,9 @@ final class BrowserPanel: Panel, ObservableObject { webViewLifecycleState = nextState } - private static let webViewLifecycleTimestampFormatter: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - private static func webViewLifecycleTimestamp(_ date: Date?) -> Any { - guard let date else { return NSNull() } - return webViewLifecycleTimestampFormatter.string(from: date) - } - - private static func webViewHiddenDurationMilliseconds( - hiddenAt: Date?, - visible: Bool, - now: Date - ) -> Any { - guard !visible, let hiddenAt else { return NSNull() } - return max(0, Int((now.timeIntervalSince(hiddenAt) * 1000.0).rounded())) - } - private func resetWebViewLifecycleMetadata(resetVisibility: Bool = true) { cancelHiddenWebViewDiscard() - webViewLifecycleState = .newTab + webViewLifecycleState = .newTab; pendingDiscardRestoreNavigation = nil; currentDiscardRestoreAttemptID = nil if resetVisibility { webViewLastVisibleAt = nil webViewLastHiddenAt = nil @@ -3312,6 +3325,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement hiddenWebViewDiscardManager.markDiscarded(reason: reason, now: now) currentURL = restoreURL @@ -3342,36 +3356,15 @@ final class BrowserPanel: Panel, ObservableObject { hiddenWebViewDiscardManager.requestImmediateDiscardIfSafe(reason: "system_memory_pressure", now: now) } - @discardableResult - func restoreDiscardedWebViewIfNeeded( - reason: String, - cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy - ) -> Bool { - return hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason) { - shouldRenderWebView = true - guard let restoreURL = restoredHistoryCurrentURL ?? currentURL else { - refreshNavigationAvailability() - return - } - navigateWithoutInsecureHTTPPrompt( - to: restoreURL, - recordTypedNavigation: false, - preserveRestoredSessionHistory: true, - cachePolicy: cachePolicy - ) - } - } - - private func clearWebViewDiscardState(reason: String) { - guard hiddenWebViewDiscardManager.clearDiscardState(reason: reason) else { return } - refreshWebViewLifecycleState() - } + var hasPendingRemoteNavigation: Bool { pendingRemoteNavigation != nil } @discardableResult - private func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { - return hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { + func reactivateDiscardedWebViewWithoutNavigation(reason: String) -> Bool { + let reactivated = hiddenWebViewDiscardManager.reactivateWithoutNavigation(reason: reason) { shouldRenderWebView = true } + if reactivated { pendingDiscardRestoreNavigation = nil; currentDiscardRestoreAttemptID = nil } + return reactivated } /// Popups inherit this panel's exact WebKit storage context. @@ -3750,6 +3743,12 @@ final class BrowserPanel: Panel, ObservableObject { MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false + // An about:blank commit is WebKit's placeholder document, not + // content; leaving the flag false keeps the restore-stall + // detector armed so a restore that dead-ends there retries. + if !Self.isAboutBlankURL(webView.url) { + self.hasCommittedDocumentSinceWebViewReplacement = true + } // Reset playback tracking only once the new top-level document has // actually replaced the old one. Resetting earlier (on provisional // start) would drop a still-playing page's frames if the @@ -3759,6 +3758,9 @@ final class BrowserPanel: Panel, ObservableObject { self.resetMediaPlaybackTracking() self.publishCommittedURL(from: webView) self.applyMuteState(to: webView, reason: "navigationCommit") + if self.shouldTreatCommitAsDiscardedRestoreCommit(from: webView) { + self.noteDiscardedWebViewRestoreNavigationCommitted() + } } } navigationDelegate.didFinish = { [weak self] webView in @@ -3776,7 +3778,7 @@ final class BrowserPanel: Panel, ObservableObject { self.restoreFindStateAfterNavigation(replaySearch: true) } } - navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL in + navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL, failedNavigation in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return } self.isMainFrameProvisionalNavigationActive = false @@ -3789,16 +3791,42 @@ final class BrowserPanel: Panel, ObservableObject { self.faviconPNGData = nil self.lastFaviconURLString = nil self.applyMuteState(to: failedWebView, reason: "navigationFail") + if self.isDiscardRestoreBookkeepingNavigation(failedNavigation) { + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_failed") + } // Keep find-in-page open and clear stale counters on failed loads. self.restoreFindStateAfterNavigation(replaySearch: false) } } - navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView in + navigationDelegate.didCancelNavigationPolicy = { [weak self] webView, cancellationKind in MainActor.assumeIsolated { guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + switch cancellationKind { + case let .terminal(restoreAttemptID): self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) + } + } + } + navigationDelegate.didCancelProvisionalNavigation = { [weak self] webView, cancelledNavigation in + MainActor.assumeIsolated { + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return } + let isRestoreBookkeepingNavigation = self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation) self.isMainFrameProvisionalNavigationActive = false - self.navigationDelegate?.clearAttemptedRequest() + if isRestoreBookkeepingNavigation { + self.navigationDelegate?.clearAttemptedRequest() + } self.refreshBackgroundAppearance() + if isRestoreBookkeepingNavigation { + self.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_cancelled") + } + } + } + navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame, restoreAttemptID in + MainActor.assumeIsolated { + guard isMainFrame, let restoreAttemptID else { return } + guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID), restoreAttemptID == self.currentDiscardRestoreAttemptID else { return } + // A main-frame download is a terminal outcome with no document commit; never restart it on the next reveal. + self.hasCommittedDocumentSinceWebViewReplacement = true + self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download") } } } @@ -3816,6 +3844,12 @@ final class BrowserPanel: Panel, ObservableObject { GlobalSearchCoordinator.shared.captureBrowserPanel(self) } + private func noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: UUID?) { + guard let restoreAttemptID, restoreAttemptID == currentDiscardRestoreAttemptID else { return } + hasCommittedDocumentSinceWebViewReplacement = true + noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_policy_cancelled") + } + private func isCurrentWebView(_ candidate: WKWebView, instanceID: UUID? = nil) -> Bool { guard candidate === webView else { return false } guard let instanceID else { return true } @@ -3975,7 +4009,25 @@ final class BrowserPanel: Panel, ObservableObject { 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) + guard let self else { return } + let restoreAttemptID = self.currentDiscardRestoreAttemptID + self.presentInsecureHTTPAlert( + for: request, + intent: intent, + recordTypedNavigation: false, + onResolution: { [weak self] resolution in + guard resolution.isTerminalPolicyCancellation else { return } + self?.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) + } + ) + } + navDelegate.currentRestoreAttemptID = { [weak self] in self?.currentDiscardRestoreAttemptID } + navDelegate.terminalPolicyCancellationReporter = { [weak self] navigationAction, webView in + let restoreAttemptID = self?.currentDiscardRestoreAttemptID + return { [weak self, weak webView] in + guard let self, let webView, navigationAction.targetFrame?.isMainFrame == true, self.isCurrentWebView(webView) else { return } + self.noteDiscardedWebViewRestoreNavigationTerminallyCancelled(restoreAttemptID: restoreAttemptID) + } } navDelegate.handleDroppedFileNavigation = { [weak self] urls in guard let self, let workspace = AppDelegate.shared?.workspaceFor(tabId: self.workspaceId), @@ -4156,6 +4208,9 @@ final class BrowserPanel: Panel, ObservableObject { if adoptedPrewarmedWebView { // Already navigated while hidden; record for recovery paths. navigationDelegate?.recordAttemptedRequest(URLRequest(url: url), displayURL: url) + // The pool only vends finished loads; seed the committed flag so + // blank-shell healing never reloads the adopted page on reveal. + hasCommittedDocumentSinceWebViewReplacement = true refreshBackgroundAppearance() } else { navigate(to: url) @@ -4571,6 +4626,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement currentURL = restoreURL @@ -4998,28 +5054,6 @@ final class BrowserPanel: Panel, ObservableObject { ) } - /// Whether browser native/SwiftUI fills should draw over the window root - /// backdrop. Mirrors terminal/markdown panel background decisions. - static func drawsConfiguredWebViewBackground( - isBlankPage: Bool, - usesTransparentBackground: Bool = false - ) -> Bool { - drawsWebViewBackground( - isBlankPage: isBlankPage, - usesTransparentBackground: usesTransparentBackground, - opacity: GhosttyApp.shared.defaultBackgroundOpacity, - usesGhosttyGlassStyle: GhosttyApp.shared.defaultBackgroundBlur.isMacOSGlassStyle, - usesTransparentWindow: WindowBackgroundComposition.policy - .shouldUseTransparentBackgroundWindow(glassEffectAvailable: false) - ) - } - - nonisolated static func isBlankBrowserPageURL(_ url: URL?) -> Bool { - guard let url else { return true } - let value = url.absoluteString.trimmingCharacters(in: .whitespacesAndNewlines) - return value.caseInsensitiveCompare("about:blank") == .orderedSame - } - private func restorableDisplayURLForCurrentErrorPage(liveURL: URL?) -> URL? { Self.restorableDisplayURL( liveURL: liveURL, @@ -5028,58 +5062,6 @@ final class BrowserPanel: Panel, ObservableObject { ) } - nonisolated static func isBlankBrowserPage( - liveURL: URL?, - currentURL: URL?, - pendingNavigationURL: URL?, - isMainFrameProvisionalNavigationActive: Bool - ) -> Bool { - if isMainFrameProvisionalNavigationActive, - !isBlankBrowserPageURL(pendingNavigationURL) { - return false - } - if !isBlankBrowserPageURL(pendingNavigationURL), - isBlankBrowserPageURL(liveURL), - isBlankBrowserPageURL(currentURL) { - return false - } - return isBlankBrowserPageURL(liveURL) && isBlankBrowserPageURL(currentURL) - } - - nonisolated static func drawsWebViewBackground( - isBlankPage: Bool, - usesTransparentBackground: Bool = false, - opacity: Double, - usesGhosttyGlassStyle: Bool, - usesTransparentWindow: Bool - ) -> Bool { - if usesTransparentBackground { - return drawsWebViewBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - guard isBlankPage else { return true } - return drawsWebViewBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - - nonisolated static func drawsWebViewBackground( - opacity: Double, - usesGhosttyGlassStyle: Bool, - usesTransparentWindow: Bool - ) -> Bool { - !PanelAppearance.shouldUseClearContentBackground( - opacity: opacity, - usesGhosttyGlassStyle: usesGhosttyGlassStyle, - usesTransparentWindow: usesTransparentWindow - ) - } - private func replaceWebViewAfterContentProcessTermination(for terminatedWebView: WKWebView) { replaceWebViewPreservingState( from: terminatedWebView, @@ -5153,6 +5135,7 @@ final class BrowserPanel: Panel, ObservableObject { ) replacement.pageZoom = desiredZoom webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement shouldRenderWebView = wasRenderable @@ -5706,7 +5689,7 @@ final class BrowserPanel: Panel, ObservableObject { navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) } - private func navigateWithoutInsecureHTTPPrompt( + func navigateWithoutInsecureHTTPPrompt( to url: URL, recordTypedNavigation: Bool, preserveRestoredSessionHistory: Bool = false, @@ -5727,7 +5710,6 @@ final class BrowserPanel: Panel, ObservableObject { ) { guard let url = request.url else { return } cancelHiddenWebViewDiscard() - clearWebViewDiscardState(reason: "navigation") if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil { pendingRemoteNavigation = PendingRemoteNavigation( request: request, @@ -5795,7 +5777,14 @@ final class BrowserPanel: Panel, ObservableObject { if recordTypedNavigation { historyStore.recordTypedNavigation(url: originalURL) } - browserLoadRequest(effectiveRequest, in: webView) + noteDiscardedWebViewRestoreNavigationStarted() + userStoppedLoadSinceWebViewReplacement = false + let startedNavigation = browserLoadRequest(effectiveRequest, in: webView) + if startedNavigation == nil { + noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_not_started") + } else if hiddenWebViewDiscardManager.isDiscardedForMemory { + pendingDiscardRestoreNavigation = startedNavigation + } } private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { @@ -5893,10 +5882,11 @@ final class BrowserPanel: Panel, ObservableObject { } } - private func presentInsecureHTTPAlert( + func presentInsecureHTTPAlert( for request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent, - recordTypedNavigation: Bool + recordTypedNavigation: Bool, + onResolution: @escaping (BrowserInsecureHTTPNavigationResolution) -> Void = { _ in } ) { guard let url = request.url else { return } guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return } @@ -5919,7 +5909,8 @@ final class BrowserPanel: Panel, ObservableObject { request: request, url: url, intent: intent, - recordTypedNavigation: recordTypedNavigation + recordTypedNavigation: recordTypedNavigation, + onResolution: onResolution ) } @@ -5936,14 +5927,15 @@ final class BrowserPanel: Panel, ObservableObject { handleResponse(alert.runModal()) } - private func handleInsecureHTTPAlertResponse( + func handleInsecureHTTPAlertResponse( _ response: NSApplication.ModalResponse, alert: NSAlert?, host: String, request: URLRequest, url: URL, intent: BrowserInsecureHTTPNavigationIntent, - recordTypedNavigation: Bool + recordTypedNavigation: Bool, openExternalURL: (URL) -> Bool = { NSWorkspace.shared.open($0) }, + onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void ) { if browserShouldPersistInsecureHTTPAllowlistSelection( response: response, @@ -5953,16 +5945,20 @@ final class BrowserPanel: Panel, ObservableObject { } switch response { case .alertFirstButtonReturn: - NSWorkspace.shared.open(url) + if !openExternalURL(url) { return } + onResolution(.openedExternally) case .alertSecondButtonReturn: switch intent { case .currentTab: + onResolution(.proceededInCurrentTab) insecureHTTPBypassHostOnce = host navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation) case .newTab: + onResolution(.proceededInNewTab) openLinkInNewTab(request: request, bypassInsecureHTTPHostOnce: host) } default: + onResolution(.cancelled) return } } @@ -6149,6 +6145,7 @@ extension BrowserPanel { websiteDataStore: websiteDataStore ) webViewInstanceID = UUID() + hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement shouldRenderWebView = false refreshWebViewLifecycleState() @@ -6398,7 +6395,7 @@ extension BrowserPanel { if recoverTerminatedWebContent(reason: reason, cachePolicy: mode.recoveryCachePolicy) { return true } - if restoreDiscardedWebViewIfNeeded(reason: reason, cachePolicy: mode.recoveryCachePolicy) { + if restoreDiscardedWebViewIfNeeded(reason: reason, cachePolicy: mode.recoveryCachePolicy, forceRestartPendingRestore: true) { return true } webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent @@ -6438,6 +6435,8 @@ extension BrowserPanel { /// Stop loading func stopLoading() { + // Fail closed: a reveal must never blank-shell-heal over an explicit Stop. + userStoppedLoadSinceWebViewReplacement = true webView.stopLoading() isMainFrameProvisionalNavigationActive = false } @@ -7816,7 +7815,7 @@ extension BrowserPanel { return restoredHistoryCurrentURL } - private func refreshNavigationAvailability() { + func refreshNavigationAvailability() { let availability = restoredSessionHistory.availability( nativeCanGoBack: nativeCanGoBack, nativeCanGoForward: nativeCanGoForward @@ -7888,35 +7887,6 @@ extension BrowserPanel { #if DEBUG extension BrowserPanel { - func configureInsecureHTTPAlertHooksForTesting( - alertFactory: @escaping () -> NSAlert, - windowProvider: @escaping () -> NSWindow? - ) { - insecureHTTPAlertFactory = alertFactory - insecureHTTPAlertWindowProvider = windowProvider - } - - func resetInsecureHTTPAlertHooksForTesting() { - insecureHTTPAlertFactory = { NSAlert() } - insecureHTTPAlertWindowProvider = { [weak self] in - if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { - return window - } - return browserFallbackInteractiveModalHostWindow() - } - } - - func presentInsecureHTTPAlertForTesting( - url: URL, - recordTypedNavigation: Bool = false - ) { - presentInsecureHTTPAlert( - for: URLRequest(url: url), - intent: .currentTab, - recordTypedNavigation: recordTypedNavigation - ) - } - private static func debugRectDescription(_ rect: NSRect) -> String { String( format: "%.1f,%.1f %.1fx%.1f", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 269ef1041eba..3de1c0f430e9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -154,6 +154,9 @@ B1F0C0050000000000000001 /* BrowserDeveloperToolsDockControlNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0050000000000000002 /* BrowserDeveloperToolsDockControlNormalizer.swift */; }; B1F0C0060000000000000001 /* BrowserDeveloperToolsDockControlNormalizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0060000000000000002 /* BrowserDeveloperToolsDockControlNormalizerTests.swift */; }; B1F0C0070000000000000001 /* BrowserDeveloperToolsLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0070000000000000002 /* BrowserDeveloperToolsLifecycleTests.swift */; }; + B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */; }; + B75040010000000000000001 /* BrowserDiscardRestoreHeal.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */; }; + B75040030000000000000001 /* BrowserDiscardRestoreHealPredicateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */; }; C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */; }; C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000002 /* BrowserDownloadFilenameResolver.swift */; }; C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C59240010000000000000004 /* BrowserDownloadFilenameResolverTests.swift */; }; @@ -180,6 +183,7 @@ BABA2500000000000000000B /* BrowserHTTPBasicAuthProtectionSpaceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */; }; FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */; }; FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */; }; + B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */; }; B1F0C0010000000000000001 /* BrowserInspectorFocusHandoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */; }; B1F0C0020000000000000001 /* BrowserInspectorFocusHandoffTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */; }; BCBC0A0E0000000000000F01 /* BrowserMediaActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */; }; @@ -1791,6 +1795,9 @@ B1F0C0050000000000000002 /* BrowserDeveloperToolsDockControlNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDeveloperToolsDockControlNormalizer.swift; sourceTree = ""; }; B1F0C0060000000000000002 /* BrowserDeveloperToolsDockControlNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDeveloperToolsDockControlNormalizerTests.swift; sourceTree = ""; }; B1F0C0070000000000000002 /* BrowserDeveloperToolsLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDeveloperToolsLifecycleTests.swift; sourceTree = ""; }; + B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardedWebViewRestoreRetryTests.swift; sourceTree = ""; }; + B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDiscardRestoreHeal.swift; sourceTree = ""; }; + B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserDiscardRestoreHealPredicateTests.swift; sourceTree = ""; }; C42660030000000000000002 /* BrowserDownloadDelegate+PDFPreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserDownloadDelegate+PDFPreviewData.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 = ""; }; @@ -1817,6 +1824,7 @@ BABA2500000000000000000C /* BrowserHTTPBasicAuthProtectionSpaceKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHTTPBasicAuthProtectionSpaceKey.swift; sourceTree = ""; }; FA100001A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportMappingTests.swift; sourceTree = ""; }; FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserImportProfilesUITests.swift; sourceTree = ""; }; + B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInsecureHTTPAlertTestSupport.swift; sourceTree = ""; }; B1F0C0010000000000000002 /* BrowserInspectorFocusHandoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/BrowserInspectorFocusHandoff.swift; sourceTree = ""; }; B1F0C0020000000000000002 /* BrowserInspectorFocusHandoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserInspectorFocusHandoffTests.swift; sourceTree = ""; }; BCBC0A0E0000000000000F02 /* BrowserMediaActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserMediaActivity.swift; sourceTree = ""; }; @@ -3973,6 +3981,7 @@ C0DE6B420000000000000002 /* TerminalPanelTextBoxState.swift */, B42450020000000000000001 /* BrowserHiddenWebViewDiscardPolicy.swift */, B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */, + B75040010000000000000002 /* BrowserDiscardRestoreHeal.swift */, B4245004000000000000PW01 /* BrowserPrewarmedWebViewPool.swift */, B424PWAD000000000000PW02 /* BrowserPanel+PrewarmedWebViewAdoption.swift */, B424PWNP000000000000PW02 /* BrowserNavigationPolicy.swift */, @@ -4570,6 +4579,9 @@ D3622101A1B2C3D4E5F60718 /* EditableTextViewArrowKeyForwardingTests.swift */, B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */, B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */, + B75040020000000000000002 /* BrowserDiscardedWebViewRestoreRetryTests.swift */, + B75040030000000000000002 /* BrowserDiscardRestoreHealPredicateTests.swift */, + B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, @@ -5357,6 +5369,7 @@ D7032A020000000000000001 /* BrowserClientCertificateAuthenticationController.swift in Sources */, D7032A050000000000000001 /* BrowserClientCertificateCredentialPicker.swift in Sources */, B1F0C0050000000000000001 /* BrowserDeveloperToolsDockControlNormalizer.swift in Sources */, + B75040010000000000000001 /* BrowserDiscardRestoreHeal.swift in Sources */, C42660030000000000000001 /* BrowserDownloadDelegate+PDFPreviewData.swift in Sources */, C59240010000000000000001 /* BrowserDownloadFilenameResolver.swift in Sources */, C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */, @@ -6408,6 +6421,8 @@ E12E88F82733EC42F32C36A3 /* BrowserConfigTests.swift in Sources */, B1F0C0060000000000000001 /* BrowserDeveloperToolsDockControlNormalizerTests.swift in Sources */, B1F0C0070000000000000001 /* BrowserDeveloperToolsLifecycleTests.swift in Sources */, + B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */, + B75040030000000000000001 /* BrowserDiscardRestoreHealPredicateTests.swift in Sources */, C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */, @@ -6415,6 +6430,7 @@ BABA25000000000000000005 /* BrowserHTTPBasicAuthPromptCoordinatorTests.swift in Sources */, BABA25000000000000000003 /* BrowserHTTPBasicAuthPromptTests.swift in Sources */, FA100000A1B2C3D4E5F60718 /* BrowserImportMappingTests.swift in Sources */, + B75040040000000000000001 /* BrowserInsecureHTTPAlertTestSupport.swift in Sources */, B1F0C0020000000000000001 /* BrowserInspectorFocusHandoffTests.swift in Sources */, BCBC0A0E0000000000000E11 /* BrowserMediaActivityAggregationTests.swift in Sources */, BCBC0A0E0000000000000E21 /* BrowserMediaPlaybackAudioActivityTests.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift new file mode 100644 index 000000000000..6a997483efa8 --- /dev/null +++ b/cmuxTests/BrowserDiscardRestoreHealPredicateTests.swift @@ -0,0 +1,482 @@ +import AppKit +import Foundation +import Testing +import WebKit + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Pure-predicate coverage for the discard-restore heal decision helpers in +/// BrowserDiscardRestoreHeal (blank-shell healing gates and restore-stall +/// detection). Broader panel-level restore-retry behavior lives in +/// BrowserDiscardedWebViewRestoreRetryTests. +@MainActor +struct BrowserDiscardRestoreHealPredicateTests { + @Test func pureRestoreHealPredicatesCoverBlankShellAndStalledCases() throws { + let intentURL = try #require(URL(string: "http://127.0.0.1:7777/app")) + let aboutBlankURL = try #require(URL(string: "about:blank")) + let mixedCaseAboutBlankURL = try #require(URL(string: "ABOUT:BLANK")) + + #expect(BrowserPanel.isAboutBlankURL(aboutBlankURL)) + #expect(BrowserPanel.isAboutBlankURL(mixedCaseAboutBlankURL)) + #expect(!BrowserPanel.isAboutBlankURL(intentURL)) + #expect(!BrowserPanel.isAboutBlankURL(nil)) + + #expect(BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: true, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: aboutBlankURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: nil + )) + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: true, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + // A crashed WebContent process must wait for the user's explicit + // Reload; blank-shell healing never auto-navigates over that gate. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: true, + userStoppedLoad: false, + isShowingErrorPage: false, + intentURL: intentURL + )) + // A load the user explicitly stopped before first commit must stay + // stopped; a reveal never heals over the Stop. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: true, + isShowingErrorPage: false, + intentURL: intentURL + )) + // The browser's own error page is content awaiting the user's Reload; + // a reveal never heals over it into re-requesting the failed URL. + #expect(!BrowserPanel.shouldHealBlankShell( + shouldRenderWebView: true, + isClosing: false, + hasPendingRemoteNavigation: false, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasCommittedDocument: false, + isNavigationBlockedPendingConsent: false, + hasRecoverableWebContentTermination: false, + userStoppedLoad: false, + isShowingErrorPage: true, + intentURL: intentURL + )) + + #expect(BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: false + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: true + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: false, + hasPendingRemoteNavigation: true, + forceRestartPendingRestore: false + )) + #expect(!BrowserPanel.isQueuedRemoteRestoreInFlight( + isDiscardedForMemory: true, + hasPendingRemoteNavigation: false, + forceRestartPendingRestore: false + )) + + #expect(BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: true, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: true, + hasCommittedDocument: false + )) + #expect(!BrowserPanel.isRestoreStalled( + isRestoreNavigationPending: true, + isWebViewLoading: false, + isMainFrameProvisionalNavigationActive: false, + hasPendingRemoteNavigation: false, + hasCommittedDocument: true + )) + } + +} + +private final class BrowserDiscardRestorePolicyCancelAlert: NSAlert { + var response: NSApplication.ModalResponse = .alertThirdButtonReturn + + override func runModal() -> NSApplication.ModalResponse { + response + } +} + +private final class BrowserDiscardRestoreDeferredPolicyAlert: NSAlert { + var completionHandler: ((NSApplication.ModalResponse) -> Void)? + + override func beginSheetModal( + for sheetWindow: NSWindow, + completionHandler handler: ((NSApplication.ModalResponse) -> Void)? + ) { + completionHandler = handler + } + + override func runModal() -> NSApplication.ModalResponse { + .alertThirdButtonReturn + } +} + +@MainActor +struct BrowserDiscardRestorePolicyCancelTests { + @Test func stalledRestoreClearsTrackedNavigationBeforeReactivation() { + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 100) + ) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.pendingDiscardRestoreNavigation = WKNavigation() + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + + #expect(panel.pendingDiscardRestoreNavigation == nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webViewLifecycleTopPayload()["state"] as? String != "discarded") + } + + @Test func cancelledExternalAppPromptDoesNotReportTerminalRestore() throws { + let url = try #require(URL(string: "cmux-issue-7504-external://open")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + var terminalCancellationCount = 0 + let result = browserHandleExternalNavigation( + url, + source: "test", + webView: panel.webView, + loadFallbackRequest: { _ in Issue.record("custom scheme should not use a browser fallback") }, + presentAlert: { _, _, completion, _ in completion(.alertSecondButtonReturn) }, + onTerminalExternalNavigation: { terminalCancellationCount += 1 } + ) + + #expect(result == .externalPrompt) + #expect(terminalCancellationCount == 0) + } + + @Test func staleRestoreCancelDoesNotClearCurrentAttemptedRequest() throws { + let staleURL = try #require(URL(string: "https://example.com/cmux-issue-7504-stale")) + let currentURL = try #require(URL(string: "https://example.com/cmux-issue-7504-current")) + let staleNavigation = WKNavigation() + let currentNavigation = WKNavigation() + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: staleURL.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.current") + panel.pendingDiscardRestoreNavigation = currentNavigation + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: currentURL)) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, staleNavigation) + + #expect(panel.navigationDelegate?.lastAttemptedURL == currentURL) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == true) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, currentNavigation) + + #expect(panel.navigationDelegate?.lastAttemptedURL == nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + } + + @Test func cancelledInsecureHTTPPromptKeepsDiscardRestoreRetryable() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-insecure-prompt")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { + panel.resetInsecureHTTPAlertHooksForTesting() + panel.close() + } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 200) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { + let alert = BrowserDiscardRestorePolicyCancelAlert() + alert.response = .alertThirdButtonReturn + return alert + }, + windowProvider: { nil } + ) + + panel.navigationDelegate?.handleBlockedInsecureHTTPNavigation?(URLRequest(url: url), .currentTab) + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String == "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func failedInsecureHTTPExternalOpenDoesNotReportTerminalRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-open-failure")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + var resolutions: [BrowserInsecureHTTPNavigationResolution] = [] + var openedURL: URL? + panel.handleInsecureHTTPAlertResponse( + .alertFirstButtonReturn, + alert: nil, + host: "example.com", + request: URLRequest(url: url), + url: url, + intent: .currentTab, + recordTypedNavigation: false, + openExternalURL: { url in + openedURL = url + return false + }, + onResolution: { resolutions.append($0) } + ) + + #expect(openedURL == url) + #expect(resolutions.isEmpty) + } + + @Test func terminalPolicyCompletionAfterProvisionalCancelCompletesDiscardRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-delayed-policy-terminal")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 250) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() + let restoreAttemptID = try #require(panel.currentDiscardRestoreAttemptID) + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webViewLifecycleTopPayload()["state"] as? String == "discarded") + + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: restoreAttemptID)) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func staleInsecureHTTPPromptDoesNotCompleteNewerRestore() throws { + let url = try #require(URL(string: "http://example.com/cmux-issue-7504-stale-prompt")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 480, height: 320), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + defer { + panel.resetInsecureHTTPAlertHooksForTesting() + window.close() + panel.close() + } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded( + reason: "test.discard", + now: Date(timeIntervalSince1970: 300) + ) + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.pendingDiscardRestoreNavigation = WKNavigation() + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + let alert = BrowserDiscardRestoreDeferredPolicyAlert() + panel.configureInsecureHTTPAlertHooksForTesting( + alertFactory: { alert }, + windowProvider: { window } + ) + panel.navigationDelegate?.handleBlockedInsecureHTTPNavigation?(URLRequest(url: url), .currentTab) + let staleCompletion = try #require(alert.completionHandler) + + panel.noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "test.old_cancel") + let currentNavigation = WKNavigation() + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.pendingDiscardRestoreNavigation = currentNavigation + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + staleCompletion(.alertThirdButtonReturn) + + let payload = panel.webViewLifecycleTopPayload() + #expect(panel.pendingDiscardRestoreNavigation === currentNavigation) + #expect(payload["restore_pending"] as? Bool == true) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + } +} diff --git a/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift new file mode 100644 index 000000000000..078dd6e3070a --- /dev/null +++ b/cmuxTests/BrowserDiscardedWebViewRestoreRetryTests.swift @@ -0,0 +1,498 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +private func withBrowserDiscardRestoreRetryPolicyEnabled(_ body: (UserDefaults) -> Void) { + let suiteName = "com.cmux.BrowserDiscardedWebViewRestoreRetryTests.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defaults.set(true, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + defaults.set( + BrowserHiddenWebViewDiscardPolicy.defaultHiddenDelay, + forKey: BrowserHiddenWebViewDiscardPolicy.hiddenDelayKey + ) + defer { + defaults.removePersistentDomain(forName: suiteName) + } + body(defaults) +} + +@MainActor +private func makeDiscardRestoreRetryBlockerSnapshot() -> BrowserHiddenWebViewDiscardManager.BlockerSnapshot { + BrowserHiddenWebViewDiscardManager.BlockerSnapshot( + isClosing: false, + isVisibleInUI: false, + shouldRenderWebView: true, + hasPendingRemoteNavigation: false, + hasCurrentURL: true, + isLoading: false, + webViewIsLoading: false, + hasActiveMainFrameProvisionalNavigation: false, + isDownloading: false, + activeDownloadCount: 0, + preferredDeveloperToolsVisible: false, + isDeveloperToolsVisible: false, + isElementFullscreenActive: false, + isReactGrabActive: false, + isVisualAutomationCaptureActive: false, + hasPopups: false, + isCapturingMedia: false, + isPlayingMedia: false + ) +} + +@MainActor +@discardableResult +private func waitForDiscardRestoreRetryWebViewToSettle( + _ panel: BrowserPanel, + timeout: TimeInterval = 30.0 +) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while panel.webView.isLoading || panel.isLoading, + Date() < deadline { + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + return !panel.webView.isLoading && !panel.isLoading +} + +@MainActor +@discardableResult +private func waitForDiscardRestoreRetryWebViewToBecomeRetryable( + _ panel: BrowserPanel, + timeout: TimeInterval = 20.0 +) -> Bool { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + let restorePending = panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool ?? false + if !restorePending, !panel.webView.isLoading, !panel.isLoading { + return true + } + _ = RunLoop.main.run(mode: .default, before: Date().addingTimeInterval(0.05)) + } + return false +} + +@MainActor +@Suite(.serialized) +struct BrowserDiscardedWebViewRestoreRetryTests { + @Test func discardedManagerRetriesWhenRestoreNeverStartsOrCommits() { + // RED(#7504): a restore closure that never starts navigation must not consume discard state. + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 100)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + + #expect(manager.isDiscardedForMemory) + #expect(restoreCount == 1) + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + #expect(restoreCount == 2) + } + } + + @Test func browserPanelRetriesDiscardedRestoreAfterConnectionRefused() throws { + // RED(#7504): connection-refused restore must leave the pane retryable on the next restore touch. + let url = try #require(URL(string: "http://127.0.0.1:1/cmux-issue-7504")) + let discardedAt = Date(timeIntervalSince1970: 200) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + let originalWebView = panel.webView + + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + #expect(panel.webView !== originalWebView) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore1")) + #expect(waitForDiscardRestoreRetryWebViewToBecomeRetryable(panel)) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore2")) + } + + @Test func remoteSessionRestoreQueuedForProxyEndpointDoesNotMarkNavigationPending() throws { + let url = try #require(URL(string: "http://localhost:3000/cmux-issue-7504")) + let workspaceId = UUID() + let panel = BrowserPanel( + workspaceId: workspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: workspaceId + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + + #expect(panel.webViewLifecycleState == .discarded) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote")) + + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.webViewLifecycleState == .liveHidden) + #expect(panel.webViewLifecycleTopPayload()["restore_pending"] as? Bool == false) + #expect(panel.webView.url == nil) + } +} + +// MARK: - GREEN(#7504) new-API coverage (added with the fix) + +@MainActor +@Suite(.serialized) +struct BrowserDiscardedWebViewRestoreRetryGreenTests { + @Test func managerKeepsDiscardStateUntilRestoreNavigationCommits() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 300)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation1") + manager.noteRestoreNavigationDidNotCommit(reason: "test.failed") + + #expect(manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + #expect(manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation2") + #expect(manager.noteRestoreNavigationCommitted(reason: "test.commit")) + + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + let didRestoreAfterCommit = manager.restoreIfNeeded(reason: "test.restore3") { + restoreCount += 1 + } + #expect(!didRestoreAfterCommit) + #expect(restoreCount == 2) + } + } + + @Test func managerDeduplicatesRestoreWhileNavigationIsPending() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 400)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore1") { + restoreCount += 1 + }) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + + #expect(manager.restoreIfNeeded(reason: "test.restore2") { + restoreCount += 1 + }) + #expect(restoreCount == 1) + #expect(manager.isRestoreNavigationPending) + } + } + + @Test func managerClearsDiscardStateWhenRestoreBecomesDownload() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 450)) + + #expect(manager.restoreIfNeeded(reason: "test.restore") {}) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + #expect(manager.noteRestoreNavigationCommitted(reason: "test.download")) + + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + } + } + + @Test func explicitReloadForcesRestartOfPendingRestore() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 900)) + + var restoreCount = 0 + #expect(manager.restoreIfNeeded(reason: "test.restore") { restoreCount += 1 }) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + + // A plain restore touch is deduplicated while a restore is pending… + #expect(manager.restoreIfNeeded(reason: "test.touch") { restoreCount += 1 }) + #expect(restoreCount == 1) + + // …but an explicit reload restarts the pending restore. + #expect(manager.restoreIfNeeded(reason: "test.reload", force: true) { restoreCount += 1 }) + #expect(restoreCount == 2) + #expect(manager.isDiscardedForMemory) + } + } + + @Test func queuedRemoteRestoreDeduplicatesUntilExplicitReload() throws { + let url = try #require(URL(string: "http://localhost:3000/cmux-issue-7504-dedupe")) + let workspaceId = UUID() + let panel = BrowserPanel( + workspaceId: workspaceId, + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: workspaceId + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote1")) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote1") + + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore.remote2")) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote1") + + #expect(panel.restoreDiscardedWebViewIfNeeded( + reason: "test.restore.remote3", + forceRestartPendingRestore: true + )) + #expect(panel.hiddenWebViewDiscardSnapshot.hasPendingRemoteNavigation) + #expect(panel.hiddenWebViewDiscardManager.lastRestoreReason == "test.restore.remote3") + } + + @Test func policyCancelledRestoreClearsDiscardStateInsteadOfReplaying() throws { + let url = try #require(URL(string: "https://example.com/cmux-issue-7504-policy-cancel")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 400)) + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: url)) + + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: panel.currentDiscardRestoreAttemptID)) + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func intentBrowserFallbackPolicyCancelStaysRetryableUntilFallbackCommits() throws { + let intentURLString = [ + "intent://join/abc#Intent", + "scheme=zoommtg", + "package=us.zoom.videomeetings", + "S.browser_fallback_url=https%3A%2F%2Fzoom.us%2Fjoin%2Fabc", + "end", + ].joined(separator: ";") + let intentURL = try #require(URL(string: intentURLString)) + let fallbackURL = try #require(URL(string: "https://zoom.us/join/abc")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: intentURL.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 500)) + panel.noteDiscardedWebViewRestoreNavigationStarted() + panel.navigationDelegate?.recordAttemptedRequest(URLRequest(url: intentURL)) + panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + + var fallbackRequest: URLRequest? + var terminalCancellationCount = 0 + let handlingResult = browserHandleExternalNavigation( + intentURL, + source: "test", + webView: panel.webView, + loadFallbackRequest: { fallbackRequest = $0 }, + presentAlert: { _, _, _, cancel in cancel() }, + onTerminalExternalNavigation: { terminalCancellationCount += 1 } + ) + #expect(handlingResult == .browserFallback) + #expect(terminalCancellationCount == 0) + #expect(fallbackRequest?.url == fallbackURL) + + if terminalCancellationCount > 0 { + panel.navigationDelegate?.didCancelNavigationPolicy?(panel.webView, .terminal(restoreAttemptID: panel.currentDiscardRestoreAttemptID)) + } + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func unknownCancellationAfterClearedAttemptedURLKeepsRestoreRetryable() throws { + let url = try #require(URL(string: "file:///tmp/cmux-issue-7504-policy-cancel.html")) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: nil, + renderInitialNavigation: false + ) + defer { panel.close() } + + panel.restoreSessionSnapshot(SessionBrowserPanelSnapshot( + urlString: url.absoluteString, + profileID: nil, + shouldRenderWebView: true, + pageZoom: 1.0, + developerToolsVisible: false, + backHistoryURLStrings: [], + forwardHistoryURLStrings: [] + )) + panel.hiddenWebViewDiscardManager.noteRestoreNavigationStarted(reason: "test.restore") + panel.navigationDelegate?.clearAttemptedRequest(discardPendingBypasses: true) + + panel.navigationDelegate?.didCancelProvisionalNavigation?(panel.webView, nil) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == true) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func markDiscardedResetsStalePendingRestoreNavigation() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard1", now: Date(timeIntervalSince1970: 500)) + #expect(manager.restoreIfNeeded(reason: "test.restore") {}) + manager.noteRestoreNavigationStarted(reason: "test.navigation") + #expect(manager.isRestoreNavigationPending) + + manager.markDiscarded(reason: "test.discard2", now: Date(timeIntervalSince1970: 501)) + + #expect(manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + } + } + + @Test func reactivationWithoutNavigationClearsDiscardState() { + withBrowserDiscardRestoreRetryPolicyEnabled { defaults in + let manager = BrowserHiddenWebViewDiscardManager(policyDefaults: defaults) + manager.markDiscarded(reason: "test.discard", now: Date(timeIntervalSince1970: 600)) + + var reactivationCount = 0 + #expect(manager.reactivateWithoutNavigation(reason: "test.reactivate") { + reactivationCount += 1 + }) + + #expect(reactivationCount == 1) + #expect(!manager.isDiscardedForMemory) + #expect(!manager.isRestoreNavigationPending) + #expect(!manager.blockers(for: makeDiscardRestoreRetryBlockerSnapshot()).contains("already_discarded")) + + var restoreCount = 0 + #expect(!manager.restoreIfNeeded(reason: "test.restore") { + restoreCount += 1 + }) + #expect(restoreCount == 0) + } + } + + @Test func mainFrameDownloadCompletesRestoreAndSuppressesBlankShellHeal() throws { + let url = try #require(URL(string: "http://127.0.0.1:1/cmux-issue-7504-download")) + let discardedAt = Date(timeIntervalSince1970: 700) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) + + // Simulate WebKit converting the pending restore navigation into a + // main-frame download before any document commits. + panel.navigationDelegate?.didBecomeDownload?(panel.webView, true, panel.currentDiscardRestoreAttemptID) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["restore_pending"] as? Bool == false) + #expect(payload["has_committed_document"] as? Bool == true) + #expect(payload["state"] as? String != "discarded") + + // A later reveal touch must not blank-shell-heal into re-triggering the + // download navigation. + #expect(!panel.restoreDiscardedWebViewIfNeeded(reason: "test.reveal")) + } + + @Test func aboutBlankDiscardedPaneReactivatesWithoutRestoreNavigation() throws { + let url = try #require(URL(string: "about:blank")) + let discardedAt = Date(timeIntervalSince1970: 800) + let panel = BrowserPanel( + workspaceId: UUID(), + initialURL: url, + isRemoteWorkspace: false + ) + defer { panel.close() } + + #expect(waitForDiscardRestoreRetryWebViewToSettle(panel)) + + panel.noteWebViewVisibility(false, reason: "test.hidden", now: discardedAt) + #expect(panel.discardHiddenWebViewForMemory(reason: "test.discard", now: discardedAt)) + + // Restoring a pane whose only URL is about:blank must reactivate in + // place (no navigation) and fully clear discard bookkeeping instead of + // waiting on a restore commit that shouldTreatCommitAsDiscardedRestoreCommit ignores. + #expect(panel.restoreDiscardedWebViewIfNeeded(reason: "test.restore")) + + let payload = panel.webViewLifecycleTopPayload() + #expect(payload["state"] as? String != "discarded") + #expect(payload["restore_pending"] as? Bool == false) + #expect(payload["discard_blockers"] is [String]) + #expect((payload["discard_blockers"] as? [String])?.contains("already_discarded") == false) + } +} diff --git a/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift b/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift new file mode 100644 index 000000000000..2ee99eea3ef6 --- /dev/null +++ b/cmuxTests/BrowserInsecureHTTPAlertTestSupport.swift @@ -0,0 +1,40 @@ +import AppKit +import Foundation + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +extension BrowserPanel { + func configureInsecureHTTPAlertHooksForTesting( + alertFactory: @escaping () -> NSAlert, + windowProvider: @escaping () -> NSWindow? + ) { + insecureHTTPAlertFactory = alertFactory + insecureHTTPAlertWindowProvider = windowProvider + } + + func resetInsecureHTTPAlertHooksForTesting() { + insecureHTTPAlertFactory = { NSAlert() } + insecureHTTPAlertWindowProvider = { [weak self] in + if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { + return window + } + return browserFallbackInteractiveModalHostWindow() + } + } + + func presentInsecureHTTPAlertForTesting( + url: URL, + recordTypedNavigation: Bool = false + ) { + presentInsecureHTTPAlert( + for: URLRequest(url: url), + intent: .currentTab, + recordTypedNavigation: recordTypedNavigation + ) + } +}