From 2f966776bce6ab3662e11c399c695602feb8531b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 16:45:24 -0700 Subject: [PATCH 001/102] Add failing regression test for discarded browser pane page state A hidden browser pane discarded for memory comes back through a fresh URL navigation, so it loses native back/forward history, scroll position and typed form input (#15069). This test discards a scrolled page with typed input and asserts all three survive the restore. Co-Authored-By: Claude Opus 5.5 --- cmux.xcodeproj/project.pbxproj | 4 + .../BrowserDiscardPageStateRestoreTests.swift | 176 ++++++++++++++++++ 2 files changed, 180 insertions(+) create mode 100644 cmuxTests/BrowserDiscardPageStateRestoreTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 80f2ec884e18..15dae65319db 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -405,6 +405,7 @@ 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 */; }; + B871FCD53CB8D64CF0872CE5 /* BrowserDiscardPageStateRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5904AAC8086DB4BAE438A7F4 /* BrowserDiscardPageStateRestoreTests.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 */; }; @@ -4560,6 +4561,7 @@ 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 = ""; }; + 5904AAC8086DB4BAE438A7F4 /* BrowserDiscardPageStateRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserDiscardPageStateRestoreTests.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 = ""; }; @@ -12235,6 +12237,7 @@ AFD49B60E1A7F637C0ADFEF4 /* TerminalPasteFailureNoticeTests.swift */, 8E924EBE2A4B7D8798B993C1 /* SidebarSelectedRowScrollTests.swift */, E1440E4C8B82D1DACD9AE13A /* CloudTreeHeaderActionsTests.swift */, + 5904AAC8086DB4BAE438A7F4 /* BrowserDiscardPageStateRestoreTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -15839,6 +15842,7 @@ B1F0C0060000000000000001 /* BrowserDeveloperToolsDockControlNormalizerTests.swift in Sources */, B1F0C0070000000000000001 /* BrowserDeveloperToolsLifecycleTests.swift in Sources */, B75040020000000000000001 /* BrowserDiscardedWebViewRestoreRetryTests.swift in Sources */, + B871FCD53CB8D64CF0872CE5 /* BrowserDiscardPageStateRestoreTests.swift in Sources */, B75040030000000000000001 /* BrowserDiscardRestoreHealPredicateTests.swift in Sources */, C59240010000000000000003 /* BrowserDownloadFilenameResolverTests.swift in Sources */, A12451000000000000000005 /* BrowserDownloadHistoryTests.swift in Sources */, diff --git a/cmuxTests/BrowserDiscardPageStateRestoreTests.swift b/cmuxTests/BrowserDiscardPageStateRestoreTests.swift new file mode 100644 index 000000000000..f305b68c8b4d --- /dev/null +++ b/cmuxTests/BrowserDiscardPageStateRestoreTests.swift @@ -0,0 +1,176 @@ +import AppKit +import CmuxBrowser +import WebKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// Regression coverage for https://github.com/manaflow-ai/cmux/issues/15069: +/// a hidden pane that is discarded for memory must come back with the page +/// state the user left, like a Chrome tab discard: native back/forward +/// history, scroll position, and typed form input. Restoring by replaying the +/// URL loses all three. +@MainActor +final class BrowserDiscardPageStateRestoreTests: XCTestCase { + private var fixtureDirectory: URL! + private var hostWindow: NSWindow! + private var previousDiscardEnabled: Any? + + override func setUp() { + super.setUp() + let defaults = UserDefaults.standard + previousDiscardEnabled = defaults.object(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + defaults.set(true, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + fixtureDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-discard-state-\(UUID().uuidString)", isDirectory: true) + try? FileManager.default.createDirectory(at: fixtureDirectory, withIntermediateDirectories: true) + hostWindow = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 800, height: 600), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + hostWindow.isReleasedWhenClosed = false + } + + override func tearDown() { + hostWindow.orderOut(nil) + hostWindow = nil + if let fixtureDirectory { + try? FileManager.default.removeItem(at: fixtureDirectory) + } + let defaults = UserDefaults.standard + if let previousDiscardEnabled { + defaults.set(previousDiscardEnabled, forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + } else { + defaults.removeObject(forKey: BrowserHiddenWebViewDiscardPolicy.enabledKey) + } + super.tearDown() + } + + func testDiscardedPaneRestoresHistoryScrollAndTypedInput() throws { + let pageA = fixtureDirectory.appendingPathComponent("a.html") + let pageB = fixtureDirectory.appendingPathComponent("b.html") + try "AA" + .write(to: pageA, atomically: true, encoding: .utf8) + try """ + B + +
+
+ + """.write(to: pageB, atomically: true, encoding: .utf8) + + let panel = BrowserPanel(workspaceId: UUID(), initialURL: pageA, isRemoteWorkspace: false) + defer { panel.close() } + host(panel.webView) + waitForPage(panel, url: pageA) + + browserLoadRequest(URLRequest(url: pageB), in: panel.webView) + waitForPage(panel, url: pageB) + + _ = evaluate( + """ + (() => { + for (const [id, value] of [["name", "typed name"], ["notes", "typed notes"]]) { + const field = document.getElementById(id); + field.focus(); + field.value = value; + field.dispatchEvent(new InputEvent("input", { bubbles: true, inputType: "insertText", data: value })); + } + document.activeElement.blur(); + window.scrollTo(0, 1500); + return window.scrollY; + })() + """, + in: panel.webView + ) + waitUntil("page scrolled before hide") { + (self.evaluate("window.scrollY", in: panel.webView) as? Double) == 1500 + } + // Let passive page-state observers deliver their script messages. + RunLoop.current.run(until: Date().addingTimeInterval(0.5)) + + panel.noteWebViewVisibility(false, reason: "test.hidden") + let discardedWebView = panel.webView + XCTAssertTrue(panel.discardHiddenWebViewForSystemMemoryPressure()) + XCTAssertFalse(panel.webView === discardedWebView) + XCTAssertEqual(panel.webViewLifecycleState, .discarded) + discardedWebView.removeFromSuperview() + + host(panel.webView) + panel.noteWebViewVisibility(true, reason: "test.visible") + waitForPage(panel, url: pageB, timeout: 10) + + XCTAssertEqual( + panel.webView.backForwardList.backItem?.url.standardizedFileURL, + pageA.standardizedFileURL, + "Restore must bring back the native WebKit back/forward list" + ) + XCTAssertTrue(panel.webView.canGoBack) + waitUntil("scroll position restored", timeout: 10) { + (self.evaluate("window.scrollY", in: panel.webView) as? Double) == 1500 + } + waitUntil("typed input restored", timeout: 10) { + (self.evaluate( + "document.getElementById('name').value + '|' + document.getElementById('notes').value", + in: panel.webView + ) as? String) == "typed name|typed notes" + } + } + + private func host(_ webView: WKWebView) { + webView.frame = hostWindow.contentView?.bounds ?? .zero + webView.autoresizingMask = [.width, .height] + hostWindow.contentView?.addSubview(webView) + } + + private func evaluate(_ script: String, in webView: WKWebView) -> Any? { + var result: Any? + var finished = false + webView.evaluateJavaScript(script) { value, _ in + result = value + finished = true + } + let deadline = Date().addingTimeInterval(5) + while !finished, Date() < deadline { + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.01)) + } + return result + } + + private func waitForPage( + _ panel: BrowserPanel, + url: URL, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line + ) { + waitUntil("load of \(url.lastPathComponent)", timeout: timeout, file: file, line: line) { + panel.webView.url?.standardizedFileURL == url.standardizedFileURL + && !panel.webView.isLoading + && panel.webView.backForwardList.currentItem?.url.standardizedFileURL == url.standardizedFileURL + && !panel.isLoading + } + } + + private func waitUntil( + _ description: String, + timeout: TimeInterval = 5, + file: StaticString = #filePath, + line: UInt = #line, + predicate: () -> Bool + ) { + let deadline = Date().addingTimeInterval(timeout) + while Date() < deadline { + if predicate() { return } + RunLoop.current.run(mode: .default, before: Date().addingTimeInterval(0.02)) + } + continueAfterFailure = false + XCTFail("Timed out waiting for \(description)", file: file, line: line) + } +} From fe4064eed03fa7525c79f7570d137bad89e9d42c Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Sun, 27 Sep 2026 18:39:55 -0700 Subject: [PATCH 002/102] Restore discarded browser panes from WebKit session state Discarding a hidden browser pane kept only its URL, history URL list and zoom, so returning to it replayed a fresh navigation: scroll position, typed input and SPA route were lost (#15069). Discard now captures the page's WebKit interactionState, a snapshot image and the typed form values reported by an isolated-world user script. Restore assigns the interaction state to the replacement web view, paints the snapshot with a "Restoring" label until the first paint, and replays form values once the document loads. URL replay stays as the fallback when no state was captured, the state belongs to another document, or WebKit does not start a load from it. Interaction state is persisted in session snapshots so relaunch restores the same way, except for private profiles, form submissions and state over the size limit. Co-Authored-By: Claude Opus 5.5 --- .../BrowserDiscardRestoreStrategy.swift | 86 +++++ .../BrowserFormStateMessageHandler.swift | 29 ++ .../BrowserFormStateScript.swift | 220 +++++++++++++ .../BrowserFormStateSnapshot.swift | 123 +++++++ .../BrowserPageRestorationState.swift | 228 +++++++++++++ .../BrowserPageSnapshotOverlayView.swift | 100 ++++++ .../BrowserPageStateCapture.swift | 96 ++++++ .../BrowserHiddenWebViewDiscardManager.swift | 6 + .../BrowserDiscardRestoreStrategyTests.swift | 94 ++++++ .../BrowserPageRestorationStateTests.swift | 227 +++++++++++++ .../BrowserPageStateCaptureTests.swift | 128 ++++++++ Resources/Localizable.xcstrings | 59 ++++ Sources/DockSplitStore+SessionSnapshot.swift | 2 +- .../Panels/BrowserDiscardRestoreHeal.swift | 10 +- .../Panels/BrowserNavigationDelegate.swift | 2 +- .../BrowserPanel+FormStateTracking.swift | 69 ++++ .../Panels/BrowserPanel+MediaPlayback.swift | 19 ++ .../Panels/BrowserPanel+PageRestoration.swift | 299 ++++++++++++++++++ .../BrowserPanel+WebContentTermination.swift | 2 +- Sources/Panels/BrowserPanel.swift | 48 ++- Sources/SessionBrowserPanelSnapshot.swift | 10 +- Sources/Workspace.swift | 2 +- cmux.xcodeproj/project.pbxproj | 8 + 23 files changed, 1828 insertions(+), 39 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserDiscardRestoreStrategy.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateMessageHandler.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateScript.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateSnapshot.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageRestorationState.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageSnapshotOverlayView.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageStateCapture.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserDiscardRestoreStrategyTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageRestorationStateTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageStateCaptureTests.swift create mode 100644 Sources/Panels/BrowserPanel+FormStateTracking.swift create mode 100644 Sources/Panels/BrowserPanel+PageRestoration.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserDiscardRestoreStrategy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserDiscardRestoreStrategy.swift new file mode 100644 index 000000000000..62b26c70b3d2 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserDiscardRestoreStrategy.swift @@ -0,0 +1,86 @@ +public import Foundation + +/// How a pane that was discarded for memory brings its page back when it is +/// shown again. +/// +/// WebKit's `interactionState` carries the native back/forward list together +/// with each entry's scroll and view state, so assigning it to the replacement +/// web view returns the page the user left, like a Chrome tab discard. Loading +/// the URL from scratch is the fallback for panes whose captured state cannot +/// be replayed safely. +public enum BrowserDiscardRestoreStrategy: Equatable, Sendable { + /// Assign the captured WebKit session state to the replacement web view. + case restoreInteractionState(Data) + /// Load the restore URL from scratch. + case replayURL(URL) + + /// Pane conditions that force the URL fallback. + public struct Conditions: Equatable, Sendable { + /// The user asked for a reload, which must fetch a fresh document. + public var isExplicitReload: Bool + /// A remote workspace pane. Its pages load through a loopback proxy + /// whose endpoint can change across reconnects, so session state would + /// replay stale proxy URLs. The URL path also queues until the + /// endpoint is up. + public var usesRemoteWorkspaceProxy: Bool + /// Cloud browser routing owns its own connection flow. + public var usesCloudAccessRouting: Bool + /// The web content process died and recovery replaces the web view. + public var hasRecoverableWebContentTermination: Bool + /// An http page the insecure-HTTP gate would stop. Session state + /// replays as a back/forward load, which asks again; the URL path + /// reopens a page the user already chose to open without asking. + public var requiresInsecureHTTPConsent: Bool + + public init( + isExplicitReload: Bool = false, + usesRemoteWorkspaceProxy: Bool = false, + usesCloudAccessRouting: Bool = false, + hasRecoverableWebContentTermination: Bool = false, + requiresInsecureHTTPConsent: Bool = false + ) { + self.isExplicitReload = isExplicitReload + self.usesRemoteWorkspaceProxy = usesRemoteWorkspaceProxy + self.usesCloudAccessRouting = usesCloudAccessRouting + self.hasRecoverableWebContentTermination = hasRecoverableWebContentTermination + self.requiresInsecureHTTPConsent = requiresInsecureHTTPConsent + } + + var forcesURLReplay: Bool { + isExplicitReload + || usesRemoteWorkspaceProxy + || usesCloudAccessRouting + || hasRecoverableWebContentTermination + || requiresInsecureHTTPConsent + } + } + + /// Whether session state may be captured, persisted or restored for a + /// document at `url`. Web and local file documents qualify; the caller + /// re-grants a local file's trust before assigning the state. App-internal + /// documents such as the diff viewer resolve through their own handlers, + /// which a back/forward replay would bypass. + public static func canRestoreSessionState(for url: URL?) -> Bool { + guard let scheme = url?.scheme?.lowercased() else { return false } + return scheme == "http" || scheme == "https" || scheme == "file" + } + + /// Picks the restore path for `restoreURL`. Captured state is used only + /// when it was taken for the same page the pane is about to restore, so a + /// navigation issued while the pane was discarded always wins. + public static func resolve( + restoreURL: URL, + capture: BrowserPageStateCapture?, + conditions: Conditions = Conditions() + ) -> BrowserDiscardRestoreStrategy { + guard !conditions.forcesURLReplay, + canRestoreSessionState(for: restoreURL), + let capture, + capture.anchorURL == restoreURL, + let interactionState = capture.interactionState, + !interactionState.isEmpty else { + return .replayURL(restoreURL) + } + return .restoreInteractionState(interactionState) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateMessageHandler.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateMessageHandler.swift new file mode 100644 index 000000000000..4198e12d6068 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateMessageHandler.swift @@ -0,0 +1,29 @@ +public import Foundation +public import WebKit + +/// Receives unsaved form input from the injected ``BrowserFormStateScript`` +/// observer and forwards it to the owning panel on the main actor. +/// +/// Mirrors ``BrowserMediaPlaybackMessageHandler``: a thin `NSObject` adapter +/// so the panel never conforms to `WKScriptMessageHandler` itself. +public final class BrowserFormStateMessageHandler: NSObject, WKScriptMessageHandler { + private let onReport: @MainActor (BrowserFormStateSnapshot) -> Void + + public init(onReport: @escaping @MainActor (BrowserFormStateSnapshot) -> Void) { + self.onReport = onReport + } + + public func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + guard message.frameInfo.isMainFrame, + let snapshot = BrowserFormStateSnapshot(messageBody: message.body) else { return } + // WebKit delivers script messages on the main thread, in order with + // navigation callbacks, so a report sent by a document before it + // navigates away lands before the next document's commit resets it. + MainActor.assumeIsolated { + onReport(snapshot) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateScript.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateScript.swift new file mode 100644 index 000000000000..a4c178513811 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateScript.swift @@ -0,0 +1,220 @@ +/// Scripts that keep a discarded pane's unsaved form input. +/// +/// Both run in an isolated content world: they share the DOM with the page +/// but not its JavaScript globals, so page script can neither read the +/// reported values nor post fake reports. The observer is passive (capture +/// phase listeners, no prototype or global changes) and main frame only. +public enum BrowserFormStateScript { + /// Name shared by the content world and the script message handler. + public static let messageHandlerName = "cmuxFormState" + + /// How long the restore script waits for late-rendered controls, such as + /// a single-page app that builds its form after the document loads. + public static let restoreTimeoutMilliseconds = 5_000 + + /// Document-start observer. After input settles, and when the page is + /// hidden, it reports every control whose value differs from its default, + /// keyed by a locator the restore script can resolve again. + public static let observerSource = #""" + (() => { + try { + const MAX_FIELDS = 200; + const MAX_VALUE = 65536; + const EXCLUDED_TYPES = new Set(["password", "hidden", "file", "button", "submit", "reset", "image"]); + const isSensitiveAutocomplete = (raw) => String(raw || "").toLowerCase().split(/\s+/).some((token) => + token === "off" || token === "one-time-code" || token.startsWith("cc-") || token.endsWith("-password")); + const isEligible = (el) => { + if (el.disabled) return false; + if (el instanceof HTMLInputElement && EXCLUDED_TYPES.has(el.type)) return false; + if (isSensitiveAutocomplete(el.getAttribute("autocomplete"))) return false; + if (el.form && isSensitiveAutocomplete(el.form.getAttribute("autocomplete"))) return false; + return true; + }; + const keyFor = (el) => { + if (el.id) return "id:" + el.id; + if (el.name) { + const form = el.form; + const formIndex = form ? Array.prototype.indexOf.call(document.forms, form) : -1; + const scope = form ? form.elements : document.getElementsByName(el.name); + let index = 0; + for (const other of scope) { + if (other === el) break; + if (other.name === el.name) index += 1; + } + return "name:" + formIndex + ":" + el.name + ":" + index; + } + const parts = []; + let node = el; + while (node && node !== document.documentElement && node.parentElement) { + parts.push(node.tagName.toLowerCase() + ":" + Array.prototype.indexOf.call(node.parentElement.children, node)); + node = node.parentElement; + } + return "path:" + parts.reverse().join("/"); + }; + const selectState = (el) => { + const options = Array.from(el.options); + const selected = []; + let defaults = []; + options.forEach((option, index) => { + if (option.selected) selected.push(index); + if (option.defaultSelected) defaults.push(index); + }); + if (!el.multiple) { + if (defaults.length > 1) defaults = [defaults[defaults.length - 1]]; + if (defaults.length === 0 && el.size <= 1) { + const first = options.findIndex((option) => !option.disabled); + if (first >= 0) defaults = [first]; + } + } + return selected.join(",") === defaults.join(",") ? null : { s: selected }; + }; + const fieldState = (el) => { + if (el instanceof HTMLSelectElement) return selectState(el); + if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) { + return el.checked === el.defaultChecked ? null : { c: el.checked }; + } + if (el.value === el.defaultValue || el.value.length > MAX_VALUE) return null; + return { v: el.value }; + }; + const collect = () => { + const fields = []; + const seen = new Set(); + for (const el of document.querySelectorAll("input, textarea, select")) { + if (fields.length >= MAX_FIELDS) break; + if (!isEligible(el)) continue; + const state = fieldState(el); + if (!state) continue; + const key = keyFor(el); + if (seen.has(key)) continue; + seen.add(key); + state.k = key; + fields.push(state); + } + return fields; + }; + let lastReported = "[]"; + let timer = null; + let unloading = false; + const flush = () => { + if (timer !== null) { + clearTimeout(timer); + timer = null; + } + if (unloading) return; + let fields = []; + try { fields = collect(); } catch (_) {} + const serialized = JSON.stringify(fields); + if (serialized === lastReported) return; + lastReported = serialized; + try { + window.webkit.messageHandlers["\#(messageHandlerName)"].postMessage({ url: String(location.href), fields }); + } catch (_) {} + }; + const schedule = () => { + if (timer !== null) clearTimeout(timer); + timer = setTimeout(flush, 250); + }; + document.addEventListener("input", schedule, true); + document.addEventListener("change", schedule, true); + document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flush(); + }, true); + // A report sent while the document unloads can arrive after the next + // document commits and be taken for its input. WebKit keeps form + // values of pages navigated away from in their history items. + window.addEventListener("pagehide", () => { + unloading = true; + if (timer !== null) clearTimeout(timer); + timer = null; + }, true); + window.addEventListener("pageshow", () => { unloading = false; }, true); + } catch (_) {} + return true; + })(); + """# + + /// Body for `callAsyncJavaScript` with arguments `fields` (the snapshot's + /// ``BrowserFormStateSnapshot/restorePayload``) and `timeoutMs`. Fills + /// controls the page has not changed itself, dispatches `input` and + /// `change` so frameworks see the values, waits up to `timeoutMs` for + /// controls rendered later, and resolves to the number restored. + public static let restoreFunctionBody = #""" + const pending = new Map(); + for (const field of fields) pending.set(field.k, field); + let restored = 0; + const findByKey = (key) => { + if (key.startsWith("id:")) return document.getElementById(key.slice(3)); + if (key.startsWith("name:")) { + const rest = key.slice(5); + const first = rest.indexOf(":"); + const last = rest.lastIndexOf(":"); + if (first < 0 || last <= first) return null; + const formIndex = Number(rest.slice(0, first)); + const name = rest.slice(first + 1, last); + const wanted = Number(rest.slice(last + 1)); + const form = formIndex >= 0 ? document.forms[formIndex] : null; + if (formIndex >= 0 && !form) return null; + const scope = form ? form.elements : document.getElementsByName(name); + let index = 0; + for (const el of scope) { + if (el.name !== name) continue; + if (index === wanted) return el; + index += 1; + } + return null; + } + if (key.startsWith("path:")) { + let node = document.documentElement; + for (const part of key.slice(5).split("/")) { + if (!part) continue; + const separator = part.lastIndexOf(":"); + const child = node ? node.children[Number(part.slice(separator + 1))] : null; + if (!child || child.tagName.toLowerCase() !== part.slice(0, separator)) return null; + node = child; + } + return node; + } + return null; + }; + const apply = (el, field) => { + if (el instanceof HTMLSelectElement) { + if (!Array.isArray(field.s)) return true; + const wanted = new Set(field.s); + Array.from(el.options).forEach((option, index) => { option.selected = wanted.has(index); }); + } else if (el instanceof HTMLInputElement && (el.type === "checkbox" || el.type === "radio")) { + if (typeof field.c !== "boolean" || el.checked === field.c || el.checked !== el.defaultChecked) return true; + el.checked = field.c; + } else if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) { + if (el instanceof HTMLInputElement && (el.type === "password" || el.type === "file" || el.type === "hidden")) return true; + if (typeof field.v !== "string" || el.value === field.v || el.value !== el.defaultValue) return true; + el.value = field.v; + } else { + return false; + } + el.dispatchEvent(new Event("input", { bubbles: true })); + el.dispatchEvent(new Event("change", { bubbles: true })); + restored += 1; + return true; + }; + const applyPending = () => { + for (const [key, field] of pending) { + const el = findByKey(key); + if (el && apply(el, field)) pending.delete(key); + } + return pending.size === 0; + }; + if (applyPending()) return restored; + return await new Promise((resolve) => { + let timer = null; + let observer = null; + const finish = () => { + if (observer) observer.disconnect(); + if (timer !== null) clearTimeout(timer); + resolve(restored); + }; + observer = new MutationObserver(() => { if (applyPending()) finish(); }); + observer.observe(document.documentElement, { childList: true, subtree: true }); + timer = setTimeout(finish, timeoutMs); + }); + """# +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateSnapshot.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateSnapshot.swift new file mode 100644 index 000000000000..78159333424f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserFormStateSnapshot.swift @@ -0,0 +1,123 @@ +public import Foundation + +/// Unsaved input in the main frame's form controls, reported by the injected +/// form-state observer. +/// +/// WebKit's `interactionState` only carries form values for history entries +/// the user navigated away from, so the current page's typed input would be +/// lost when a discarded pane restores. This snapshot fills that gap. It is +/// kept in memory only and never written to the session file. Password, +/// payment, one-time-code and `autocomplete="off"` fields are never reported. +public struct BrowserFormStateSnapshot: Equatable, Sendable { + /// Largest number of fields kept per document. + public static let maxFieldCount = 200 + /// Largest value, in UTF-16 code units, kept per field. + public static let maxValueLength = 64 * 1024 + + public struct Field: Equatable, Sendable { + /// Stable locator for the control: `id:`, `name:` or `path:` prefixed. + public var key: String + /// Text value for text-like inputs and text areas. + public var value: String? + /// Checked state for checkboxes and radio buttons. + public var isChecked: Bool? + /// Selected option indexes for select elements. + public var selectedOptionIndexes: [Int]? + + public init(key: String, value: String? = nil, isChecked: Bool? = nil, selectedOptionIndexes: [Int]? = nil) { + self.key = key + self.value = value + self.isChecked = isChecked + self.selectedOptionIndexes = selectedOptionIndexes + } + } + + /// URL of the document the fields belong to. + public var documentURL: URL + public var fields: [Field] + + public init(documentURL: URL, fields: [Field]) { + self.documentURL = documentURL + self.fields = fields + } + + /// Parses `{ url, fields: [{ k, v?, c?, s? }] }` from the observer. Returns + /// nil for a malformed body. Oversized values and fields past + /// ``maxFieldCount`` are dropped. + public init?(messageBody: Any) { + guard let body = messageBody as? [String: Any], + let urlString = body["url"] as? String, + let documentURL = URL(string: urlString), + let rawFields = body["fields"] as? [Any] else { + return nil + } + var fields: [Field] = [] + for rawField in rawFields { + guard fields.count < Self.maxFieldCount else { break } + guard let entry = rawField as? [String: Any], + let key = entry["k"] as? String, + !key.isEmpty else { continue } + let field: Field + if let value = entry["v"] as? String { + guard value.utf16.count <= Self.maxValueLength else { continue } + field = Field(key: key, value: value) + } else if let checked = entry["c"] as? Bool { + field = Field(key: key, isChecked: checked) + } else if let selected = entry["s"] as? [Any] { + field = Field(key: key, selectedOptionIndexes: selected.compactMap { ($0 as? NSNumber)?.intValue }) + } else { + continue + } + fields.append(field) + } + self.init(documentURL: documentURL, fields: fields) + } + + public var isEmpty: Bool { fields.isEmpty } + + /// Whether the fields were typed on the same origin as `url`. Values are + /// never carried to another site. The report URL can trail the document + /// URL after a same-document route change, so paths are not compared, + /// except for file URLs, whose origin is the file itself. + public func sharesOrigin(with url: URL?) -> Bool { + guard let url else { return false } + if documentURL.isFileURL || url.isFileURL { + return Self.isSameDocument(documentURL, url) + } + guard let scheme = documentURL.scheme?.lowercased(), let host = documentURL.host?.lowercased() else { + return false + } + return scheme == url.scheme?.lowercased() + && host == url.host?.lowercased() + && documentURL.port == url.port + } + + /// Whether two URLs load the same document. Fragment changes keep the + /// same document, so they are ignored. + public static func isSameDocument(_ lhs: URL, _ rhs: URL) -> Bool { + documentIdentity(lhs) == documentIdentity(rhs) + } + + /// Fields in the shape the restore script expects as its `fields` argument. + public var restorePayload: [[String: Any]] { + fields.map { field in + var entry: [String: Any] = ["k": field.key] + if let value = field.value { + entry["v"] = value + } else if let isChecked = field.isChecked { + entry["c"] = isChecked + } else if let selectedOptionIndexes = field.selectedOptionIndexes { + entry["s"] = selectedOptionIndexes + } + return entry + } + } + + static func documentIdentity(_ url: URL) -> String { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + return url.absoluteString + } + components.fragment = nil + return components.string ?? url.absoluteString + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageRestorationState.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageRestorationState.swift new file mode 100644 index 000000000000..86c1403527b6 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageRestorationState.swift @@ -0,0 +1,228 @@ +public import Foundation + +/// A browser pane's page state across a web view discard. +/// +/// While the page is live it collects what WebKit's session state leaves +/// out: unsaved form input, whether any history entry was a form submission, +/// and a snapshot taken when the pane is hidden. A discard folds these into +/// one ``BrowserPageStateCapture``. The capture lives until the replacement +/// web view commits a document. If that commit is the restore, the capture's +/// form input waits for the load to finish; any other commit drops it. +@MainActor +public final class BrowserPageRestorationState { + /// How a discarded page was brought back. + public enum RestoreMethod: Equatable, Sendable { + /// WebKit session state was assigned to the replacement web view. + case interactionState + /// The restore URL was loaded again. + case urlReplay + } + + /// A commit that completed the restore of a discarded page. + public struct RestoredCommit: Equatable, Sendable { + public var method: RestoreMethod + /// Whether the restored back/forward list is the pane's whole history. + public var coversNavigationHistory: Bool + + public init(method: RestoreMethod, coversNavigationHistory: Bool) { + self.method = method + self.coversNavigationHistory = coversNavigationHistory + } + } + + /// Unsaved input the current document last reported, or nil when none. + public private(set) var liveFormState: BrowserFormStateSnapshot? + /// Whether the current web view's back/forward list holds a form + /// submission. Sticky until the list is replaced. + public private(set) var liveContainsFormSubmission = false + /// State captured at the last discard, until a document commits. + public private(set) var discardedCapture: BrowserPageStateCapture? + /// The restore issued for ``discardedCapture`` that has not committed. + public private(set) var inFlightRestore: RestoreMethod? + /// Form input to apply once the restored document finishes loading. + public private(set) var pendingFormRestore: BrowserFormStateSnapshot? + + private var hiddenSnapshotToken: UUID? + private var hiddenSnapshot: BrowserPageSnapshotImage? + + /// Script message handler registered for the current web view. + public var formStateMessageHandler: BrowserFormStateMessageHandler? + /// Snapshot painted over the replacement web view while it restores. + public var overlayView: BrowserPageSnapshotOverlayView? + + public init() {} + + // MARK: Live page + + public func recordLiveFormState(_ snapshot: BrowserFormStateSnapshot) { + liveFormState = snapshot.isEmpty ? nil : snapshot + } + + /// Call when a main-frame navigation submits a form. WebKit keeps the + /// request body in the entry's session state. + public func noteMainFrameFormSubmission() { + liveContainsFormSubmission = true + } + + // MARK: Hidden snapshot + + /// Starts a snapshot for a pane that was just hidden and returns the + /// token its completion must present. + public func beginHiddenSnapshot() -> UUID { + let token = UUID() + hiddenSnapshotToken = token + hiddenSnapshot = nil + return token + } + + /// Stores a finished snapshot. A snapshot that completes after the pane + /// was discarded still attaches to that discard's capture. + public func completeHiddenSnapshot(token: UUID, image: BrowserPageSnapshotImage?) { + guard let image else { return } + if hiddenSnapshotToken == token { + hiddenSnapshot = image + } else if var capture = discardedCapture, capture.snapshotToken == token, capture.snapshot == nil { + capture.snapshot = image + discardedCapture = capture + } + } + + /// Forgets the hidden snapshot, for a pane shown again before a discard. + public func cancelHiddenSnapshot() { + hiddenSnapshotToken = nil + hiddenSnapshot = nil + } + + // MARK: Discard and restore + + /// Folds the live state into a capture as the web view is dropped. + /// + /// - Parameters: + /// - interactionState: The dropped web view's session state, when it + /// showed a replayable page. + /// - documentURL: URL of that web view's current history entry. + /// - anchorURL: The URL the pane will restore to. + /// - coversNavigationHistory: Whether the native back/forward list was + /// the pane's history. + public func recordDiscard( + interactionState: Data?, + documentURL: URL?, + anchorURL: URL?, + coversNavigationHistory: Bool + ) { + defer { + liveFormState = nil + liveContainsFormSubmission = false + cancelHiddenSnapshot() + inFlightRestore = nil + pendingFormRestore = nil + } + // A web view dropped before its restore committed holds nothing newer + // than the capture it was restoring. + guard discardedCapture == nil else { return } + let formState = liveFormState.flatMap { $0.sharesOrigin(with: documentURL) ? $0 : nil } + discardedCapture = BrowserPageStateCapture( + interactionState: interactionState, + documentURL: documentURL, + anchorURL: anchorURL, + formState: formState, + snapshot: hiddenSnapshot, + snapshotToken: hiddenSnapshotToken, + coversNavigationHistory: coversNavigationHistory, + containsFormSubmission: liveContainsFormSubmission + ) + } + + /// Seeds the capture from session state saved by a previous launch. Such + /// state was only saved when it covered the pane's history and held no + /// form submission. + public func seedFromSessionState(_ interactionState: Data, documentURL: URL?, anchorURL: URL?) { + guard !interactionState.isEmpty else { return } + discardedCapture = BrowserPageStateCapture( + interactionState: interactionState, + documentURL: documentURL, + anchorURL: anchorURL, + coversNavigationHistory: true, + containsFormSubmission: false + ) + } + + /// Call when any main-frame navigation starts. The restore recorded after + /// this call is the one in flight; any later navigation supersedes it, so + /// its commit does not bring back the capture's input. + public func noteNavigationStarted() { + inFlightRestore = nil + } + + /// Records the restore issued for the discarded capture. + public func noteRestoreStarted(_ method: RestoreMethod) { + guard discardedCapture != nil else { return } + inFlightRestore = method + } + + /// Drops the capture so the next restore reloads from the network, for + /// an explicit reload. + public func dropCapture() { + discardedCapture = nil + inFlightRestore = nil + pendingFormRestore = nil + } + + /// Drops the capture for a pane brought back without a restore, such as + /// by a history navigation. + /// + /// - Returns: Whether the web view's back/forward list already holds the + /// pane's whole history because session state was assigned to it. + @discardableResult + public func noteReactivatedWithoutRestore() -> Bool { + let restoredHistory = inFlightRestore == .interactionState + && discardedCapture?.coversNavigationHistory == true + dropCapture() + return restoredHistory + } + + /// Call for every main-frame document commit except the browser's own + /// error page, which keeps the capture for the next restore attempt. + /// + /// - Parameter isDiscardRestoreCommit: Whether the commit is the discarded + /// page coming back rather than a navigation somewhere else. + /// - Returns: The restore the commit completed, or nil when it completed + /// none. + @discardableResult + public func noteDocumentCommitted(isDiscardRestoreCommit: Bool) -> RestoredCommit? { + liveFormState = nil + pendingFormRestore = nil + let method = inFlightRestore + inFlightRestore = nil + guard let capture = discardedCapture else { return nil } + discardedCapture = nil + guard isDiscardRestoreCommit, let method else { return nil } + if method == .interactionState { + liveContainsFormSubmission = capture.containsFormSubmission + } + pendingFormRestore = capture.formState + return RestoredCommit(method: method, coversNavigationHistory: capture.coversNavigationHistory) + } + + /// Returns and clears form input waiting for the document at `url`. + /// Input is never applied to another origin. + public func takePendingFormRestore(for url: URL?) -> BrowserFormStateSnapshot? { + defer { pendingFormRestore = nil } + guard let pendingFormRestore, !pendingFormRestore.isEmpty, pendingFormRestore.sharesOrigin(with: url) else { + return nil + } + return pendingFormRestore + } + + /// Session state that may be written to the session file for a pane whose + /// web view is discarded, or nil when the capture may not be persisted. + public func persistableDiscardedInteractionState() -> Data? { + discardedCapture?.persistableInteractionState() + } + + /// Removes the restore overlay, if one is showing. + public func dismissOverlay() { + overlayView?.dismiss() + overlayView = nil + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageSnapshotOverlayView.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageSnapshotOverlayView.swift new file mode 100644 index 000000000000..7f48a23c95e7 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageSnapshotOverlayView.swift @@ -0,0 +1,100 @@ +public import AppKit + +/// Paints the last snapshot of a discarded page over its replacement web view +/// until the restored document paints, with a small "Restoring" badge so the +/// user can tell the page is not live yet. +/// +/// The overlay never takes mouse events: clicks, scrolls and drags reach the +/// web view underneath, which is already loading the restored page. +@MainActor +public final class BrowserPageSnapshotOverlayView: NSView { + private let imageView = NSImageView() + private let badge = NSView() + private let spinner = NSProgressIndicator() + private let label = NSTextField(labelWithString: "") + + public override var isFlipped: Bool { true } + public override var isOpaque: Bool { false } + + /// - Parameters: + /// - snapshot: The page as it looked before the discard, or nil to show + /// only the badge. + /// - restoringLabel: Localized badge text supplied by the app. + public init(snapshot: BrowserPageSnapshotImage?, restoringLabel: String) { + super.init(frame: .zero) + wantsLayer = true + autoresizingMask = [.width, .height] + setAccessibilityElement(true) + setAccessibilityRole(.progressIndicator) + setAccessibilityLabel(restoringLabel) + + if let snapshot, let image = NSImage(data: snapshot.jpegData) { + image.size = snapshot.pointSize + imageView.image = image + imageView.imageScaling = .scaleNone + imageView.imageAlignment = .alignTopLeft + imageView.imageFrameStyle = .none + imageView.translatesAutoresizingMaskIntoConstraints = false + addSubview(imageView) + NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: leadingAnchor), + imageView.topAnchor.constraint(equalTo: topAnchor), + imageView.widthAnchor.constraint(equalToConstant: snapshot.pointSize.width), + imageView.heightAnchor.constraint(equalToConstant: snapshot.pointSize.height) + ]) + } + + badge.wantsLayer = true + badge.layer?.cornerRadius = 11 + badge.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.65).cgColor + badge.translatesAutoresizingMaskIntoConstraints = false + spinner.style = .spinning + spinner.controlSize = .small + spinner.appearance = NSAppearance(named: .darkAqua) + spinner.isDisplayedWhenStopped = false + spinner.translatesAutoresizingMaskIntoConstraints = false + label.stringValue = restoringLabel + label.font = .systemFont(ofSize: NSFont.smallSystemFontSize, weight: .medium) + label.textColor = .white + label.translatesAutoresizingMaskIntoConstraints = false + badge.addSubview(spinner) + badge.addSubview(label) + addSubview(badge) + NSLayoutConstraint.activate([ + badge.centerXAnchor.constraint(equalTo: centerXAnchor), + badge.topAnchor.constraint(equalTo: topAnchor, constant: 12), + badge.heightAnchor.constraint(equalToConstant: 22), + spinner.leadingAnchor.constraint(equalTo: badge.leadingAnchor, constant: 8), + spinner.centerYAnchor.constraint(equalTo: badge.centerYAnchor), + spinner.widthAnchor.constraint(equalToConstant: 12), + spinner.heightAnchor.constraint(equalToConstant: 12), + label.leadingAnchor.constraint(equalTo: spinner.trailingAnchor, constant: 6), + label.trailingAnchor.constraint(equalTo: badge.trailingAnchor, constant: -10), + label.centerYAnchor.constraint(equalTo: badge.centerYAnchor) + ]) + spinner.startAnimation(nil) + } + + @available(*, unavailable) + public required init?(coder: NSCoder) { + nil + } + + /// Whether the overlay carries a page snapshot, not just the badge. + public var showsSnapshot: Bool { imageView.image != nil } + + public override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + /// Covers `webView`'s bounds and follows its size. + public func install(over webView: NSView) { + frame = webView.bounds + webView.addSubview(self, positioned: .above, relativeTo: nil) + } + + public func dismiss() { + spinner.stopAnimation(nil) + removeFromSuperview() + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageStateCapture.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageStateCapture.swift new file mode 100644 index 000000000000..d3d4fe4641f7 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/PageRestoration/BrowserPageStateCapture.swift @@ -0,0 +1,96 @@ +public import Foundation + +/// The page state a browser pane keeps when its web view is dropped, so the +/// replacement can come back where the user left it. +public struct BrowserPageStateCapture: Equatable, Sendable { + /// Upper bound for session state written to the session file. Typical + /// pages serialize to a few kilobytes; very long histories are dropped + /// rather than bloating every autosave. + public static let defaultPersistableInteractionStateByteLimit = 256 * 1024 + + /// WebKit `interactionState`: the back/forward list plus each entry's + /// scroll and view state. Nil when no replayable page was showing. + public var interactionState: Data? + /// URL of the document WebKit had committed when the state was captured. + /// This is the URL WebKit loads on restore, which can differ from the + /// display URL (remote proxy loopback aliases). + public var documentURL: URL? + /// The restore URL the pane resolves right after the discard. Restore uses + /// the captured state only while the pane still points at this URL. + public var anchorURL: URL? + /// Unsaved form input from the main frame, kept in memory only. + public var formState: BrowserFormStateSnapshot? + /// JPEG of the page painted while the restore loads. + public var snapshot: BrowserPageSnapshotImage? + /// Token of the snapshot request whose result belongs to this capture. + public var snapshotToken: UUID? + /// Whether the native back/forward list is the pane's history. False while + /// the pane replays URL history restored from a previous launch, which the + /// restore must then keep. + public var coversNavigationHistory: Bool + /// Whether any entry came from a form submission. WebKit serializes POST + /// bodies into session state, so such state never reaches disk. + public var containsFormSubmission: Bool + + public init( + interactionState: Data?, + documentURL: URL?, + anchorURL: URL? = nil, + formState: BrowserFormStateSnapshot? = nil, + snapshot: BrowserPageSnapshotImage? = nil, + snapshotToken: UUID? = nil, + coversNavigationHistory: Bool, + containsFormSubmission: Bool + ) { + self.interactionState = interactionState + self.documentURL = documentURL + self.anchorURL = anchorURL + self.formState = formState + self.snapshot = snapshot + self.snapshotToken = snapshotToken + self.coversNavigationHistory = coversNavigationHistory + self.containsFormSubmission = containsFormSubmission + } + + /// Session state that may be written to the session file, or nil when it + /// is missing, too large, replays restored URL history, or holds a form + /// submission. + public func persistableInteractionState( + maxBytes: Int = defaultPersistableInteractionStateByteLimit + ) -> Data? { + Self.persistableInteractionState( + interactionState, + coversNavigationHistory: coversNavigationHistory, + containsFormSubmission: containsFormSubmission, + maxBytes: maxBytes + ) + } + + /// Shared persistence gate for captured and live session state. + public static func persistableInteractionState( + _ interactionState: Data?, + coversNavigationHistory: Bool, + containsFormSubmission: Bool, + maxBytes: Int = defaultPersistableInteractionStateByteLimit + ) -> Data? { + guard let interactionState, + !interactionState.isEmpty, + interactionState.count <= maxBytes, + coversNavigationHistory, + !containsFormSubmission else { + return nil + } + return interactionState + } +} + +/// A compressed bitmap of a page and the size, in points, it was taken at. +public struct BrowserPageSnapshotImage: Equatable, Sendable { + public var jpegData: Data + public var pointSize: CGSize + + public init(jpegData: Data, pointSize: CGSize) { + self.jpegData = jpegData + self.pointSize = pointSize + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/BrowserHiddenWebViewDiscardManager.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/BrowserHiddenWebViewDiscardManager.swift index be01df1fb945..701d3bd29f15 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/BrowserHiddenWebViewDiscardManager.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/WebView/BrowserHiddenWebViewDiscardManager.swift @@ -125,6 +125,12 @@ public final class BrowserHiddenWebViewDiscardManager { discardTimer != nil } + /// Whether hidden web views may be discarded at all under the current + /// settings. Panes skip discard-only preparation when it is off. + public var isPolicyEnabled: Bool { + BrowserHiddenWebViewDiscardPolicy.isEnabled(defaults: policyDefaults) + } + public func blockers( for snapshot: BlockerSnapshot, now: Date = Date(), diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserDiscardRestoreStrategyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserDiscardRestoreStrategyTests.swift new file mode 100644 index 000000000000..8e08448f091b --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserDiscardRestoreStrategyTests.swift @@ -0,0 +1,94 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +struct BrowserDiscardRestoreStrategyTests { + private let pageURL = URL(string: "https://example.com/app#inbox")! + private let state = Data([0x01, 0x02, 0x03]) + + private func capture(anchor: URL?, state: Data?) -> BrowserPageStateCapture { + BrowserPageStateCapture( + interactionState: state, + documentURL: anchor, + anchorURL: anchor, + coversNavigationHistory: true, + containsFormSubmission: false + ) + } + + @Test("A capture anchored at the restore URL restores its session state") + func restoresInteractionStateAtAnchor() { + let strategy = BrowserDiscardRestoreStrategy.resolve( + restoreURL: pageURL, + capture: capture(anchor: pageURL, state: state) + ) + #expect(strategy == .restoreInteractionState(state)) + } + + @Test("Without a capture the restore URL is replayed") + func replaysWithoutCapture() { + #expect(BrowserDiscardRestoreStrategy.resolve(restoreURL: pageURL, capture: nil) == .replayURL(pageURL)) + } + + @Test("A capture without session state replays the URL") + func replaysEmptyState() { + #expect( + BrowserDiscardRestoreStrategy.resolve(restoreURL: pageURL, capture: capture(anchor: pageURL, state: nil)) + == .replayURL(pageURL) + ) + #expect( + BrowserDiscardRestoreStrategy.resolve(restoreURL: pageURL, capture: capture(anchor: pageURL, state: Data())) + == .replayURL(pageURL) + ) + } + + @Test("A pane that moved to another URL since the discard replays that URL") + func replaysWhenAnchorDiffers() { + let otherURL = URL(string: "https://example.com/other")! + #expect( + BrowserDiscardRestoreStrategy.resolve(restoreURL: otherURL, capture: capture(anchor: pageURL, state: state)) + == .replayURL(otherURL) + ) + } + + @Test( + "Reload, remote proxy, cloud routing, crash recovery and insecure HTTP replay the URL", + arguments: [ + BrowserDiscardRestoreStrategy.Conditions(isExplicitReload: true), + BrowserDiscardRestoreStrategy.Conditions(usesRemoteWorkspaceProxy: true), + BrowserDiscardRestoreStrategy.Conditions(usesCloudAccessRouting: true), + BrowserDiscardRestoreStrategy.Conditions(hasRecoverableWebContentTermination: true), + BrowserDiscardRestoreStrategy.Conditions(requiresInsecureHTTPConsent: true) + ] + ) + func forcedReplay(conditions: BrowserDiscardRestoreStrategy.Conditions) { + #expect( + BrowserDiscardRestoreStrategy.resolve( + restoreURL: pageURL, + capture: capture(anchor: pageURL, state: state), + conditions: conditions + ) == .replayURL(pageURL) + ) + } + + @Test("Web and local file documents restore session state; internal documents do not") + func restorableSchemes() { + #expect(BrowserDiscardRestoreStrategy.canRestoreSessionState(for: URL(string: "http://example.com/"))) + #expect(BrowserDiscardRestoreStrategy.canRestoreSessionState(for: URL(string: "HTTPS://example.com/"))) + #expect(BrowserDiscardRestoreStrategy.canRestoreSessionState(for: URL(fileURLWithPath: "/tmp/a.html"))) + #expect(!BrowserDiscardRestoreStrategy.canRestoreSessionState(for: URL(string: "cmux-diff-viewer://t/index"))) + #expect(!BrowserDiscardRestoreStrategy.canRestoreSessionState(for: URL(string: "about:blank"))) + #expect(!BrowserDiscardRestoreStrategy.canRestoreSessionState(for: nil)) + + let fileURL = URL(fileURLWithPath: "/tmp/a.html") + #expect( + BrowserDiscardRestoreStrategy.resolve(restoreURL: fileURL, capture: capture(anchor: fileURL, state: state)) + == .restoreInteractionState(state) + ) + let internalURL = URL(string: "cmux-diff-viewer://t/index")! + #expect( + BrowserDiscardRestoreStrategy.resolve(restoreURL: internalURL, capture: capture(anchor: internalURL, state: state)) + == .replayURL(internalURL) + ) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageRestorationStateTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageRestorationStateTests.swift new file mode 100644 index 000000000000..98d975706cc8 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageRestorationStateTests.swift @@ -0,0 +1,227 @@ +import CoreGraphics +import Foundation +import Testing +@testable import CmuxBrowser + +@MainActor +struct BrowserPageRestorationStateTests { + private let pageURL = URL(string: "https://example.com/compose")! + private let sessionState = Data([0x0A, 0x0B]) + + private func form(_ url: URL, value: String = "draft") -> BrowserFormStateSnapshot { + BrowserFormStateSnapshot(documentURL: url, fields: [.init(key: "id:body", value: value)]) + } + + private func image(_ byte: UInt8) -> BrowserPageSnapshotImage { + BrowserPageSnapshotImage(jpegData: Data([byte]), pointSize: CGSize(width: 10, height: 20)) + } + + private func discard(_ restoration: BrowserPageRestorationState, covers: Bool = true) { + restoration.recordDiscard( + interactionState: sessionState, + documentURL: pageURL, + anchorURL: pageURL, + coversNavigationHistory: covers + ) + } + + @Test("A discard captures form input, snapshot and taint, and clears live state") + func discardCapturesLiveState() throws { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + restoration.noteMainFrameFormSubmission() + let token = restoration.beginHiddenSnapshot() + restoration.completeHiddenSnapshot(token: token, image: image(1)) + + discard(restoration) + + let capture = try #require(restoration.discardedCapture) + #expect(capture.interactionState == sessionState) + #expect(capture.anchorURL == pageURL) + #expect(capture.formState == form(pageURL)) + #expect(capture.snapshot == image(1)) + #expect(capture.containsFormSubmission) + #expect(restoration.liveFormState == nil) + #expect(!restoration.liveContainsFormSubmission) + } + + @Test("Input reported for another origin is not captured") + func discardDropsForeignInput() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(URL(string: "https://other.example/")!)) + discard(restoration) + #expect(restoration.discardedCapture?.formState == nil) + } + + @Test("An empty report clears earlier input") + func emptyReportClearsInput() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + restoration.recordLiveFormState(BrowserFormStateSnapshot(documentURL: pageURL, fields: [])) + #expect(restoration.liveFormState == nil) + } + + @Test("A snapshot finishing after the discard attaches to its capture") + func lateSnapshotAttaches() { + let restoration = BrowserPageRestorationState() + let token = restoration.beginHiddenSnapshot() + discard(restoration) + #expect(restoration.discardedCapture?.snapshot == nil) + + restoration.completeHiddenSnapshot(token: UUID(), image: image(9)) + #expect(restoration.discardedCapture?.snapshot == nil) + restoration.completeHiddenSnapshot(token: token, image: image(2)) + #expect(restoration.discardedCapture?.snapshot == image(2)) + } + + @Test("A snapshot for a pane shown again is dropped") + func cancelledSnapshotIsDropped() { + let restoration = BrowserPageRestorationState() + let token = restoration.beginHiddenSnapshot() + restoration.cancelHiddenSnapshot() + restoration.completeHiddenSnapshot(token: token, image: image(3)) + discard(restoration) + #expect(restoration.discardedCapture?.snapshot == nil) + } + + @Test("Discarding a web view whose restore never committed keeps the first capture") + func secondDiscardKeepsFirstCapture() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + discard(restoration) + restoration.noteRestoreStarted(.interactionState) + + restoration.recordDiscard( + interactionState: nil, + documentURL: nil, + anchorURL: pageURL, + coversNavigationHistory: false + ) + + #expect(restoration.discardedCapture?.interactionState == sessionState) + #expect(restoration.discardedCapture?.formState == form(pageURL)) + #expect(restoration.inFlightRestore == nil) + } + + @Test("A session-state restore commit brings back input and the form-submission taint") + func interactionStateCommit() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + restoration.noteMainFrameFormSubmission() + discard(restoration) + restoration.noteRestoreStarted(.interactionState) + + let commit = restoration.noteDocumentCommitted(isDiscardRestoreCommit: true) + + #expect(commit == .init(method: .interactionState, coversNavigationHistory: true)) + #expect(restoration.discardedCapture == nil) + #expect(restoration.liveContainsFormSubmission) + #expect(restoration.takePendingFormRestore(for: pageURL) == form(pageURL)) + #expect(restoration.takePendingFormRestore(for: pageURL) == nil) + } + + @Test("A URL replay commit starts a fresh history without the taint") + func urlReplayCommit() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + restoration.noteMainFrameFormSubmission() + discard(restoration, covers: false) + restoration.noteRestoreStarted(.urlReplay) + + let commit = restoration.noteDocumentCommitted(isDiscardRestoreCommit: true) + + #expect(commit == .init(method: .urlReplay, coversNavigationHistory: false)) + #expect(!restoration.liveContainsFormSubmission) + #expect(restoration.takePendingFormRestore(for: pageURL) == form(pageURL)) + } + + @Test("A commit elsewhere drops the capture and its input") + func unrelatedCommitDropsCapture() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + discard(restoration) + restoration.noteRestoreStarted(.interactionState) + + #expect(restoration.noteDocumentCommitted(isDiscardRestoreCommit: false) == nil) + #expect(restoration.discardedCapture == nil) + #expect(restoration.takePendingFormRestore(for: pageURL) == nil) + } + + @Test("Pending input is never applied to another origin") + func pendingInputStaysOnOrigin() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + discard(restoration) + restoration.noteRestoreStarted(.urlReplay) + restoration.noteDocumentCommitted(isDiscardRestoreCommit: true) + + #expect(restoration.takePendingFormRestore(for: URL(string: "https://login.example/")) == nil) + #expect(restoration.takePendingFormRestore(for: pageURL) == nil) + } + + @Test("An explicit reload drops the capture") + func dropCapture() { + let restoration = BrowserPageRestorationState() + discard(restoration) + restoration.noteRestoreStarted(.interactionState) + restoration.dropCapture() + #expect(restoration.discardedCapture == nil) + #expect(restoration.inFlightRestore == nil) + restoration.noteRestoreStarted(.urlReplay) + #expect(restoration.inFlightRestore == nil) + } + + @Test("Saved session state seeds a persistable capture") + func seedFromSessionState() { + let restoration = BrowserPageRestorationState() + restoration.seedFromSessionState(sessionState, documentURL: pageURL, anchorURL: pageURL) + #expect(restoration.discardedCapture?.anchorURL == pageURL) + #expect(restoration.persistableDiscardedInteractionState() == sessionState) + + let empty = BrowserPageRestorationState() + empty.seedFromSessionState(Data(), documentURL: pageURL, anchorURL: pageURL) + #expect(empty.discardedCapture == nil) + } + + @Test("A tainted capture is not persisted") + func taintedCaptureIsNotPersisted() { + let restoration = BrowserPageRestorationState() + restoration.noteMainFrameFormSubmission() + discard(restoration) + #expect(restoration.persistableDiscardedInteractionState() == nil) + } + + @Test("A navigation started after the restore is not treated as the restore") + func laterNavigationSupersedesRestore() { + let restoration = BrowserPageRestorationState() + restoration.recordLiveFormState(form(pageURL)) + discard(restoration) + restoration.noteNavigationStarted() + restoration.noteRestoreStarted(.interactionState) + #expect(restoration.inFlightRestore == .interactionState) + + restoration.noteNavigationStarted() + + #expect(restoration.noteDocumentCommitted(isDiscardRestoreCommit: true) == nil) + #expect(restoration.takePendingFormRestore(for: pageURL) == nil) + } + + @Test("Reactivating reports whether assigned session state holds the history") + func reactivationReportsRestoredHistory() { + let restoring = BrowserPageRestorationState() + discard(restoring) + restoring.noteRestoreStarted(.interactionState) + #expect(restoring.noteReactivatedWithoutRestore()) + #expect(restoring.discardedCapture == nil) + + let partial = BrowserPageRestorationState() + discard(partial, covers: false) + partial.noteRestoreStarted(.interactionState) + #expect(!partial.noteReactivatedWithoutRestore()) + + let idle = BrowserPageRestorationState() + discard(idle) + #expect(!idle.noteReactivatedWithoutRestore()) + #expect(idle.discardedCapture == nil) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageStateCaptureTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageStateCaptureTests.swift new file mode 100644 index 000000000000..c359cedc435f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/PageRestoration/BrowserPageStateCaptureTests.swift @@ -0,0 +1,128 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +struct BrowserPageStateCaptureTests { + private let state = Data(repeating: 0xAB, count: 64) + + private func capture( + state: Data?, + coversNavigationHistory: Bool = true, + containsFormSubmission: Bool = false + ) -> BrowserPageStateCapture { + BrowserPageStateCapture( + interactionState: state, + documentURL: URL(string: "https://example.com/"), + coversNavigationHistory: coversNavigationHistory, + containsFormSubmission: containsFormSubmission + ) + } + + @Test("State covering the pane's history without form submissions persists") + func persistsCleanState() { + #expect(capture(state: state).persistableInteractionState() == state) + } + + @Test("State holding a form submission never persists") + func dropsFormSubmission() { + #expect(capture(state: state, containsFormSubmission: true).persistableInteractionState() == nil) + } + + @Test("State that replays restored URL history does not persist") + func dropsPartialHistory() { + #expect(capture(state: state, coversNavigationHistory: false).persistableInteractionState() == nil) + } + + @Test("Missing, empty and oversized state does not persist") + func dropsEmptyAndOversizedState() { + #expect(capture(state: nil).persistableInteractionState() == nil) + #expect(capture(state: Data()).persistableInteractionState() == nil) + #expect(capture(state: state).persistableInteractionState(maxBytes: 63) == nil) + #expect(capture(state: state).persistableInteractionState(maxBytes: 64) == state) + } +} + +struct BrowserFormStateSnapshotTests { + @Test("Parses text, checkbox and select fields from an observer report") + func parsesReport() throws { + let body: [String: Any] = [ + "url": "https://example.com/compose", + "fields": [ + ["k": "id:subject", "v": "Hello"], + ["k": "name:0:urgent:0", "c": true], + ["k": "path:body:0/select:2", "s": [NSNumber(value: 1), NSNumber(value: 3)]] + ] as [[String: Any]] + ] + let snapshot = try #require(BrowserFormStateSnapshot(messageBody: body)) + #expect(snapshot.documentURL == URL(string: "https://example.com/compose")) + #expect(snapshot.fields == [ + .init(key: "id:subject", value: "Hello"), + .init(key: "name:0:urgent:0", isChecked: true), + .init(key: "path:body:0/select:2", selectedOptionIndexes: [1, 3]) + ]) + } + + @Test("Rejects malformed reports and skips malformed fields") + func rejectsMalformed() throws { + #expect(BrowserFormStateSnapshot(messageBody: "nope") == nil) + #expect(BrowserFormStateSnapshot(messageBody: ["fields": [] as [Any]] as [String: Any]) == nil) + let body: [String: Any] = [ + "url": "https://example.com/", + "fields": [["k": "", "v": "x"], ["v": "no key"], ["k": "id:a"], "junk", ["k": "id:b", "v": "kept"]] as [Any] + ] + let snapshot = try #require(BrowserFormStateSnapshot(messageBody: body)) + #expect(snapshot.fields == [.init(key: "id:b", value: "kept")]) + } + + @Test("Caps field count and value length") + func capsFields() throws { + let oversized = String(repeating: "x", count: BrowserFormStateSnapshot.maxValueLength + 1) + var fields: [[String: Any]] = [["k": "id:big", "v": oversized]] + for index in 0..<(BrowserFormStateSnapshot.maxFieldCount + 10) { + fields.append(["k": "id:f\(index)", "v": "\(index)"]) + } + let snapshot = try #require( + BrowserFormStateSnapshot(messageBody: ["url": "https://example.com/", "fields": fields] as [String: Any]) + ) + #expect(snapshot.fields.count == BrowserFormStateSnapshot.maxFieldCount) + #expect(snapshot.fields.first?.key == "id:f0") + } + + @Test("Input stays on its origin; file URLs must be the same file") + func originMatching() { + let snapshot = BrowserFormStateSnapshot( + documentURL: URL(string: "https://example.com/a?x=1#top")!, + fields: [.init(key: "id:a", value: "v")] + ) + #expect(snapshot.sharesOrigin(with: URL(string: "https://EXAMPLE.com/b"))) + #expect(!snapshot.sharesOrigin(with: URL(string: "https://example.com:8443/a"))) + #expect(!snapshot.sharesOrigin(with: URL(string: "http://example.com/a"))) + #expect(!snapshot.sharesOrigin(with: URL(string: "https://evil.example/a"))) + #expect(!snapshot.sharesOrigin(with: nil)) + + let fileSnapshot = BrowserFormStateSnapshot( + documentURL: URL(fileURLWithPath: "/tmp/a.html"), + fields: [.init(key: "id:a", value: "v")] + ) + #expect(fileSnapshot.sharesOrigin(with: URL(string: "file:///tmp/a.html#section"))) + #expect(!fileSnapshot.sharesOrigin(with: URL(fileURLWithPath: "/tmp/b.html"))) + } + + @Test("Restore payload mirrors the report shape") + func restorePayload() { + let snapshot = BrowserFormStateSnapshot( + documentURL: URL(string: "https://example.com/")!, + fields: [ + .init(key: "id:a", value: "text"), + .init(key: "id:b", isChecked: false), + .init(key: "id:c", selectedOptionIndexes: [2]) + ] + ) + let payload = snapshot.restorePayload + #expect(payload.count == 3) + #expect(payload[0]["k"] as? String == "id:a") + #expect(payload[0]["v"] as? String == "text") + #expect(payload[1]["c"] as? Bool == false) + #expect(payload[2]["s"] as? [Int] == [2]) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 139a9292e4a8..9cf965a22737 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -563008,6 +563008,65 @@ } } } + }, + "browser.discard.restoring": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Restoring…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ الاستعادة…" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wird wiederhergestellt…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restaurando…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Restauration…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "復元中…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "복원 중…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "正在恢复…" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "正在還原…" + } + } + } } }, "version": "1.0" diff --git a/Sources/DockSplitStore+SessionSnapshot.swift b/Sources/DockSplitStore+SessionSnapshot.swift index 469d8302fb51..83703be375da 100644 --- a/Sources/DockSplitStore+SessionSnapshot.swift +++ b/Sources/DockSplitStore+SessionSnapshot.swift @@ -382,7 +382,7 @@ extension DockSplitStore { forwardHistoryURLStrings: history.forwardHistoryURLStrings, transparentBackground: browser.sessionSnapshotTransparentBackground, diffViewerToken: diffViewer?.token, - diffViewerRequestPath: diffViewer?.requestPath, cloudResource: browser.cloudResourceForSession + diffViewerRequestPath: diffViewer?.requestPath, cloudResource: browser.cloudResourceForSession, interactionState: browser.persistableInteractionStateForSessionSnapshot() ) } else if let deferred = panel as? DeferredBrowserPanel { browserSnapshot = deferred.sessionPanelSnapshot.browser diff --git a/Sources/Panels/BrowserDiscardRestoreHeal.swift b/Sources/Panels/BrowserDiscardRestoreHeal.swift index e83254c1e0b3..a225c51924cb 100644 --- a/Sources/Panels/BrowserDiscardRestoreHeal.swift +++ b/Sources/Panels/BrowserDiscardRestoreHeal.swift @@ -9,6 +9,7 @@ extension BrowserPanel { } func noteDiscardedWebViewRestoreNavigationStarted() { + pageRestoration.noteNavigationStarted() if hiddenWebViewDiscardManager.isDiscardedForMemory { // Each restore attempt tracks its own commit. Without this reset, a // previous attempt's error-page commit would satisfy the stall @@ -32,6 +33,7 @@ extension BrowserPanel { func noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: String) { hiddenWebViewDiscardManager.noteRestoreNavigationDidNotCommit(reason: reason) pendingDiscardRestoreNavigation = nil + pageRestoration.dismissOverlay() refreshWebViewLifecycleState() } @@ -88,13 +90,7 @@ extension BrowserPanel { } if hiddenWebViewDiscardManager.restoreIfNeeded(reason: reason, force: forceRestartPendingRestore, performRestore: { - shouldRenderWebView = true - navigateWithoutInsecureHTTPPrompt( - to: restoreURL, - recordTypedNavigation: false, - preserveRestoredSessionHistory: true, - cachePolicy: cachePolicy - ) + performDiscardRestore(to: restoreURL, cachePolicy: cachePolicy, isExplicitReload: forceRestartPendingRestore) }) { return true } diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 69af82714ac4..cd3cd15d5aee 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -625,7 +625,7 @@ import WebKit } else if let url = navigationAction.request.url, let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" { - recordAttemptedRequest(navigationAction.request) + recordMainFrameWebRequest(navigationAction.request) } else { clearAttemptedRequest() } diff --git a/Sources/Panels/BrowserPanel+FormStateTracking.swift b/Sources/Panels/BrowserPanel+FormStateTracking.swift new file mode 100644 index 000000000000..d20d1df55b90 --- /dev/null +++ b/Sources/Panels/BrowserPanel+FormStateTracking.swift @@ -0,0 +1,69 @@ +import CmuxBrowser +import Foundation +import WebKit + +extension BrowserPanel { + /// Isolated content world shared by the form-state observer, its message + /// handler and the restore call, so page JavaScript can neither read the + /// reported input nor post fake reports. + static let formStateContentWorld = WKContentWorld.world(name: BrowserFormStateScript.messageHandlerName) + + /// Main-frame observer that reports unsaved form input, which a discarded + /// pane restores after its page comes back. + static func installFormStateUserScript(into configuration: WKWebViewConfiguration) { + configuration.userContentController.addUserScript( + WKUserScript( + source: BrowserFormStateScript.observerSource, + injectionTime: .atDocumentStart, + forMainFrameOnly: true, + in: formStateContentWorld + ) + ) + } + + func setupFormStateMessageHandler(for webView: WKWebView) { + // The handler outlives this web view generation on the old content + // controller, so reports from a replaced web view are ignored. + let boundWebViewInstanceID = webViewInstanceID + let handler = BrowserFormStateMessageHandler { [weak self] snapshot in + guard let self, boundWebViewInstanceID == self.webViewInstanceID else { return } + self.pageRestoration.recordLiveFormState(snapshot) + } + pageRestoration.formStateMessageHandler = handler + webView.configuration.userContentController.add( + handler, + contentWorld: Self.formStateContentWorld, + name: BrowserFormStateScript.messageHandlerName + ) + } + + func tearDownFormStateMessageHandler(for webView: WKWebView) { + webView.configuration.userContentController.removeScriptMessageHandler( + forName: BrowserFormStateScript.messageHandlerName, + contentWorld: Self.formStateContentWorld + ) + pageRestoration.formStateMessageHandler = nil + } + + /// Refills the restored document's unsaved input once it has loaded. + func applyPendingFormRestore(to webView: WKWebView) { + guard let formState = pageRestoration.takePendingFormRestore(for: webView.url) else { return } + webView.callAsyncJavaScript( + BrowserFormStateScript.restoreFunctionBody, + arguments: [ + "fields": formState.restorePayload, + "timeoutMs": BrowserFormStateScript.restoreTimeoutMilliseconds + ], + in: nil, + in: Self.formStateContentWorld + ) { result in +#if DEBUG + if case .failure(let error) = result { + cmuxDebugLog("browser.discard.formRestore failed error=\(error.localizedDescription)") + } +#else + _ = result +#endif + } + } +} diff --git a/Sources/Panels/BrowserPanel+MediaPlayback.swift b/Sources/Panels/BrowserPanel+MediaPlayback.swift index 00c2a0684140..0c197b759180 100644 --- a/Sources/Panels/BrowserPanel+MediaPlayback.swift +++ b/Sources/Panels/BrowserPanel+MediaPlayback.swift @@ -230,6 +230,25 @@ extension BrowserPanel { })(); """ + /// Reports `