Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
145 commits
Select commit Hold shift + click to select a range
2f96677
Add failing regression test for discarded browser pane page state
austinywang Sep 27, 2026
fe4064e
Restore discarded browser panes from WebKit session state
austinywang Sep 28, 2026
95b7e5b
Add failing regression test for hidden WebContent termination restore
austinywang Sep 28, 2026
61ae4fb
Restore hidden panes whose WebContent process died from session state
austinywang Sep 28, 2026
c5f2569
Add failing regression test for timer-free hidden web view discard de…
austinywang Sep 28, 2026
8886feb
Discard hidden web views oldest-first under a memory budget
austinywang Sep 28, 2026
80994db
Add failing regression test for hidden pane discard blockers
austinywang Sep 28, 2026
c5ff174
Keep hidden panes whose state a restore would lose
austinywang Sep 28, 2026
a429e12
Keep the hidden discard mode enum on one line in the cmux.json schema
austinywang Sep 28, 2026
809ba56
Add a per-pane pin that keeps a hidden browser page active
austinywang Sep 28, 2026
51aa4e0
Add failing regression tests for manual restore of unloaded pages
austinywang Sep 28, 2026
88e384d
Add a setting to keep unloaded browser pages until the user restores …
austinywang Sep 28, 2026
53e3041
Add failing regression tests for discard restore gaps from review
austinywang Sep 28, 2026
710fe52
Restore form submission results by URL and report input after a cache…
austinywang Sep 28, 2026
6322b80
Restore a page whose process died while hidden even after Stop
austinywang Sep 28, 2026
a09a046
Count Dock browser panes in the hidden page budget and pressure sweep
austinywang Sep 28, 2026
cbdb19d
Create the Dock budget test's workspace through addWorkspaceIfActive
austinywang Sep 28, 2026
714ed5c
Add a failing test for a new-window request clearing a form submissio…
austinywang Sep 28, 2026
c5afe25
Ignore new-window requests when tracking form submissions
austinywang Sep 28, 2026
d07cd03
Merge main (d0cf4f1fe2ad) into 15069-browser-discard-state
austinywang Sep 28, 2026
fafa32a
Splice Memory Saver search entries with a call instead of +
austinywang Sep 28, 2026
9aa473b
Move the form-state scripts onto the WebKit types that run them
austinywang Sep 28, 2026
a920690
Add failing tests for a browser view outside a window marking its pan…
austinywang Sep 28, 2026
05d8064
Report a browser pane visible only while its view is in a window
austinywang Sep 28, 2026
ea214fe
Merge main (0e1ab9647bc9) into 15069-browser-discard-state
austinywang Sep 28, 2026
5933e1b
Let the window visibility tests run main-actor tasks
austinywang Sep 28, 2026
4a1d372
Merge main (b0d5083b51a7) into 15069-browser-discard-state
austinywang Sep 28, 2026
cabba95
Cover popup page-state reports and a remote pane's queued restore
austinywang Sep 28, 2026
e7ce24c
Bind page-state reports to their web view and note queued restores
austinywang Sep 28, 2026
4be39c2
Wait on causes, not intervals, in the discard and visibility tests
austinywang Sep 28, 2026
9a60133
Wait for WebKit to save the scroll before discarding in the restore t…
austinywang Sep 28, 2026
d192505
Merge main (871416019500) into 15069-browser-discard-state
austinywang Sep 28, 2026
f04b778
Keep the Import Choose… button's accessibility identifier
austinywang Sep 28, 2026
ba47551
Merge main (31a59abb80d6) into 15069-browser-discard-state
austinywang Sep 28, 2026
e1dc477
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 28, 2026
d9101d5
test: cover agent commands on hidden panes that need a restore
austinywang Sep 28, 2026
405cdf5
fix: restore hidden browser panes for agent commands
austinywang Sep 28, 2026
5c6d833
test: record the backdated hide in the agent budget test
austinywang Sep 28, 2026
387fb4a
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 28, 2026
865080e
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 28, 2026
3195547
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 28, 2026
d7eb043
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 28, 2026
9566944
Test that agent-driven restore cycles free dropped web views
austinywang Sep 28, 2026
08bd3be
Merge remote-tracking branch 'origin/main' into 15069-browser-discard…
austinywang Sep 28, 2026
9e8e080
Merge remote-tracking branch 'origin/main' into 15069-browser-discard…
austinywang Sep 28, 2026
3aa8e9b
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
cb9e8b4
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
bd8cee9
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
c0897eb
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
3253ddb
fix: tear down replaced browser webviews
austinywang Sep 29, 2026
38fab8e
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
3ebd8fc
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
b05f885
test: assert browser teardown attachments
austinywang Sep 29, 2026
3a513d0
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
ffc9d1b
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
d6f27e3
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
9041db6
test: align flaky host assertions with current behavior
austinywang Sep 29, 2026
b03dce6
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
e524962
test: drain async browser teardown before leak checks
austinywang Sep 29, 2026
03c3925
fix: let replaced browser views drain observer tasks
austinywang Sep 29, 2026
ae9bbb7
test: name browser lifetime checks precisely
austinywang Sep 29, 2026
5a70262
fix: hoist async readiness before XCTest assertions
austinywang Sep 29, 2026
45b91f9
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
f561d56
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
9eeb511
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 29, 2026
e94c414
test: provide remote browser proxy credential
austinywang Sep 29, 2026
f0679f8
test: align restored browser and SSH fixtures with main
austinywang Sep 29, 2026
7c11752
test: avoid sleep in browser restore wait
austinywang Sep 29, 2026
4e0a32a
fix: await browser automation fixture setup
austinywang Sep 29, 2026
dd5cfe4
test: assert resolved SSH route settings
austinywang Sep 30, 2026
d6bf99b
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 30, 2026
9add630
Test immediate cleanup of pane drag previews
austinywang Sep 30, 2026
18170cf
Merge main (8265e7893424) into 15069-browser-discard-state
austinywang Sep 30, 2026
283b684
Stabilize unrelated app-host fixture waits
austinywang Sep 30, 2026
b2be7db
Merge main (31014dcd4867) into 15069-browser-discard-state
austinywang Sep 30, 2026
bcd87a4
Keep font fixture assertions within test budget
austinywang Sep 30, 2026
fd99449
Capture dock fixture after window setup
austinywang Sep 30, 2026
bafd762
Use the loader signal in the correct fork fixture
austinywang Sep 30, 2026
8499a57
Merge main (64a1765fcc7a) into 15069-browser-discard-state
austinywang Sep 30, 2026
f0a6f99
Merge green main into browser discard state
austinywang Sep 30, 2026
66ae317
Keep settings merge within source budgets
austinywang Sep 30, 2026
2bfde91
Fit accessibility fix within settings budget
austinywang Sep 30, 2026
33cd3db
Restore ghostty and bonsplit pointers dropped by a main merge
austinywang Sep 30, 2026
36efe7a
Restore Memory Saver settings rows dropped by a main merge
austinywang Sep 30, 2026
bbcee38
Merge main (296537c5ddf1) into 15069-browser-discard-state
austinywang Sep 30, 2026
ff1c27b
Test that every Browser Memory Saver row is searchable
austinywang Sep 30, 2026
1eaf9ab
Merge main (5fbbc0c84409) into 15069-browser-discard-state
austinywang Sep 30, 2026
8f8dcbf
Merge commit '96914e73277ba3fa962b65adbd5cbc3c5176ab28' into 15069-br…
austinywang Sep 30, 2026
f2c0979
Test that imported sessions drop WebKit page state
austinywang Sep 30, 2026
4cb08ce
Drop WebKit page state from imported sessions
austinywang Sep 30, 2026
22ee2e8
Merge main (b3ca4185f55f) into 15069-browser-discard-state
austinywang Sep 30, 2026
2008624
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Sep 30, 2026
ba97209
Merge latest main and preserve browser regression coverage
austinywang Sep 30, 2026
32650bd
Harden browser import and web view teardown
austinywang Sep 30, 2026
015ba4b
Fix temporary Codex config provider forwarding
austinywang Sep 30, 2026
2021f6d
Keep CLI OpenCode config path self contained
austinywang Sep 30, 2026
a7bad15
Align close-tab test helper with latest main
austinywang Sep 30, 2026
b19731a
Repair latest main test target wiring
austinywang Sep 30, 2026
6897f94
Merge latest main compile fixes into browser discard state
austinywang Sep 30, 2026
d671df1
Fix billing seat nudge web assertion
austinywang Oct 1, 2026
c6260ab
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Oct 1, 2026
4412417
test(web): pin the seats-follow-membership billing copy
teamleaderleo Sep 30, 2026
4b4ec9c
fix(web): restore the seats-follow-membership copy the dashboard port…
teamleaderleo Sep 30, 2026
9463fd2
test(web): pin the new-team seat copy too
teamleaderleo Sep 30, 2026
4320e50
fix(dev): apply concurrent-index migrations outside transactions
austinywang Sep 30, 2026
b8b72d6
fix(ci): use transaction-safe migrations and restore queue timeout he…
austinywang Sep 30, 2026
aa40003
Fix pinned request uploads on Bun 1.3
austinywang Oct 1, 2026
8f1e014
fix(ci): route every local migration lane through safe runner
austinywang Sep 30, 2026
0d1f971
Cancel pinned uploads when requests close
austinywang Oct 1, 2026
ae74ed5
fix(web): insert a real JSON null in the malformed cleanup-row test
austinywang Sep 30, 2026
650e60f
cloud: pin the team date wire shapes in a test
teamleaderleo Sep 30, 2026
67dc44b
cloud: accept the team API's millisecond timestamps
teamleaderleo Sep 30, 2026
2b793b8
test(cloud): pin sub-second precision and a dated invite link
teamleaderleo Sep 30, 2026
61cb908
Keep queue helper within test file budget
austinywang Oct 1, 2026
1e7412f
Avoid duplicate SessionEntry test target source
austinywang Oct 1, 2026
9c7d2a5
fix(tests): call the mutating reconcile budget outside #expect
lawrencecchen Sep 30, 2026
d2c7e64
test: check the vm ready poll interval in cmuxCLITests
austinywang Sep 30, 2026
f97bd61
test: cover vm poll interval boundaries
teamleaderleo Oct 1, 2026
5cf412f
test: compile the vm ready poll policy into cmuxCLITests
lawrencecchen Oct 1, 2026
1ed3a5a
test: drive hook state recovery through the bundled CLI
lawrencecchen Oct 1, 2026
1819582
Keep reconcile test within file budget
austinywang Oct 1, 2026
54e399f
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Oct 1, 2026
15c2d10
Give every app-host test process its own preferences domain
lawrencecchen Sep 30, 2026
7e9ded4
Stabilize app-host CI regressions
austinywang Oct 1, 2026
f3dc6d8
Keep app-host defaults test within budget
austinywang Oct 1, 2026
f074e1a
Repair remaining app-host CI regressions
austinywang Oct 1, 2026
e8979e8
Test locale preference writes during background navigation
lawrencecchen Sep 16, 2026
2f051b5
Keep background locale responses from changing language preferences
lawrencecchen Sep 16, 2026
b397e97
Cover normalized fetch metadata and cross-tab locale races
lawrencecchen Sep 16, 2026
85ab5f8
Use browser fetch metadata after Next.js request normalization
lawrencecchen Sep 16, 2026
50bf5b1
Exercise background cookie writes with a real HTML fetch
lawrencecchen Sep 16, 2026
ae67b9a
Correct locale navigation test metadata
austinywang Oct 1, 2026
92f5715
Address browser restoration review findings
austinywang Oct 1, 2026
f79afad
Harden browser discard edge cases
austinywang Oct 1, 2026
fbc47fb
Document browser form state caps
austinywang Oct 1, 2026
4c03bfb
Merge branch 'main' of https://github.com/manaflow-ai/cmux into 15069…
austinywang Oct 1, 2026
729b04a
Fix migration script trailing whitespace
austinywang Oct 1, 2026
2b9885f
Address browser review cleanup findings
austinywang Oct 1, 2026
441f693
Correct hidden memory budget planner fixture
austinywang Oct 1, 2026
6dec9cb
Merge main (70c83fd8423f) into 15069-browser-discard-state
austinywang Oct 1, 2026
7a10e85
Repair accent color access after main catch-up
austinywang Oct 1, 2026
a83e1ea
Fix browser window presence callback capture
austinywang Oct 1, 2026
796d6f7
Merge main (2152cd75036a) into 15069-browser-discard-state
austinywang Oct 1, 2026
4be656b
Merge remote-tracking branch 'origin/main' into 15069-browser-discard…
austinywang Oct 1, 2026
6cd6507
Merge remote-tracking branch 'origin/main' into 15069-browser-discard…
austinywang Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
/// Media playback across a page's frames, folded from the per-frame reports
/// of the injected media-playback hook.
public struct BrowserMediaPlaybackFrames: Equatable, Sendable {
private var playingFrameIDs: Set<String> = []
private var audibleFrameIDs: Set<String> = []
private var pictureInPictureFrameIDs: Set<String> = []

public init() {}

/// Whether any frame has actively playing media.
public var isPlaying: Bool { !playingFrameIDs.isEmpty }
/// Whether any frame is playing media that is audible.
public var isAudible: Bool { !audibleFrameIDs.isEmpty }
/// Whether any frame has a video in Picture in Picture, playing or paused.
public var isPictureInPictureActive: Bool { !pictureInPictureFrameIDs.isEmpty }

/// Replaces the reporting frame's state with the report.
public mutating func apply(_ report: BrowserMediaPlaybackReport) {
playingFrameIDs.update(report.frameID, isMember: report.isPlaying)
audibleFrameIDs.update(report.frameID, isMember: report.isPlaying && report.isAudible)
pictureInPictureFrameIDs.update(report.frameID, isMember: report.isPictureInPicture)
}
}

private extension Set<String> {
mutating func update(_ frameID: String, isMember: Bool) {
if isMember { insert(frameID) } else { remove(frameID) }
}
}
Original file line number Diff line number Diff line change
@@ -1,35 +1,47 @@
public import Foundation
public import WebKit

/// Receives `{ frameID, playing, audible }` from the injected media-playback hook and
/// Receives `{ frameID, playing, audible, pip }` from the injected media-playback hook and
/// forwards it to the owning ``BrowserPanel`` on the main actor.
///
/// Mirrors ``ReactGrabMessageHandler``: a thin `NSObject` adapter so the panel
/// itself never has to conform to `WKScriptMessageHandler`.
/// itself never has to conform to `WKScriptMessageHandler`. It is bound to one
/// web view: a popup the page opens is built from the opener's configuration
/// and shares its content controller, so the popup's reports reach this handler
/// too and are dropped.
@MainActor
public final class BrowserMediaPlaybackMessageHandler: NSObject, WKScriptMessageHandler {
private weak var webView: WKWebView?
private let onReport: @MainActor (BrowserMediaPlaybackReport) -> Void

public init(onReport: @escaping @MainActor (BrowserMediaPlaybackReport) -> Void) {
public init(
webView: WKWebView,
onReport: @escaping @MainActor (BrowserMediaPlaybackReport) -> Void
) {
self.webView = webView
self.onReport = onReport
}

public func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard let body = message.body as? [String: Any],
guard message.webView === webView,
let body = message.body as? [String: Any],
let frameID = body["frameID"] as? String,
let playing = body["playing"] as? Bool else { return }
let audible = body["audible"] as? Bool ?? false
let report = BrowserMediaPlaybackReport(frameID: frameID, isPlaying: playing, isAudible: audible)
let report = BrowserMediaPlaybackReport(
frameID: frameID,
isPlaying: playing,
isAudible: body["audible"] as? Bool ?? false,
isPictureInPicture: body["pip"] as? Bool ?? false
)
// WebKit delivers script messages on the main thread. Apply the report
// synchronously instead of hopping through a `Task` so it lands in
// WebKit's delivery order relative to navigation callbacks: a report
// emitted by a document before it navigates away is applied before the
// matching `didCommit` reset, so a stale `playing: true` cannot re-add a
// dead frame id after the reset and pin the pane against discard.
MainActor.assumeIsolated {
onReport(report)
}
onReport(report)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,18 @@ public struct BrowserMediaPlaybackReport: Sendable {
public let isPlaying: Bool
/// Whether that frame currently has an unmuted, non-zero-volume audio source.
public let isAudible: Bool
/// Whether that frame has a video in Picture in Picture, playing or paused.
public let isPictureInPicture: Bool

public init(
frameID: String,
isPlaying: Bool,
isAudible: Bool
isAudible: Bool,
isPictureInPicture: Bool = false
) {
self.frameID = frameID
self.isPlaying = isPlaying
self.isAudible = isAudible
self.isPictureInPicture = isPictureInPicture
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
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. A page
/// shown as a form submission result loads by URL, because assigning its
/// state would send the form again.
public static func resolve(
restoreURL: URL,
capture: BrowserPageStateCapture?,
conditions: Conditions = Conditions()
) -> BrowserDiscardRestoreStrategy {
guard !conditions.forcesURLReplay,
canRestoreSessionState(for: restoreURL),
let capture,
capture.anchorURL == restoreURL,
!capture.documentHasFormSubmission,
let interactionState = capture.interactionState,
!interactionState.isEmpty else {
return .replayURL(restoreURL)
}
return .restoreInteractionState(interactionState)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
public import Foundation
public import WebKit

/// Receives unsaved form input from the injected
/// ``WKUserScript/browserFormStateObserver()`` and forwards it to the
/// owning panel on the main actor.
///
/// A thin `NSObject` adapter so the panel never conforms to
/// `WKScriptMessageHandler` itself. It is bound to one web view: a popup the
/// page opens is built from the opener's configuration and shares its content
/// controller, so the popup's reports reach this handler too and are dropped.
@MainActor
public final class BrowserFormStateMessageHandler: NSObject, WKScriptMessageHandler {
private weak var webView: WKWebView?
private let onReport: @MainActor (BrowserFormStateSnapshot) -> Void

public init(
webView: WKWebView,
onReport: @escaping @MainActor (BrowserFormStateSnapshot) -> Void
) {
self.webView = webView
self.onReport = onReport
}

public func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.frameInfo.isMainFrame,
message.webView === webView,
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.
onReport(snapshot)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
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;
/// a change to one only sets ``hasUnrestorableInput``.
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]
/// Whether the document also holds typed input a restore cannot replay,
/// such as a password, a file selection or a rich-text edit.
public var hasUnrestorableInput: Bool

public init(documentURL: URL, fields: [Field], hasUnrestorableInput: Bool = false) {
self.documentURL = documentURL
self.fields = fields
self.hasUnrestorableInput = hasUnrestorableInput
}

/// Parses `{ url, fields: [{ k, v?, c?, s? }], unrestorable? }` from the
/// observer. Returns nil for a malformed body. Oversized values and fields
/// past ``maxFieldCount`` are dropped and count as unrestorable input.
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] = []
var hasUnrestorableInput = body["unrestorable"] as? Bool ?? false
for rawField in rawFields {
guard fields.count < Self.maxFieldCount else {
hasUnrestorableInput = true
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 {
hasUnrestorableInput = true
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] {
let indexes = selected.compactMap { ($0 as? NSNumber)?.intValue }
guard indexes.count == selected.count else {
hasUnrestorableInput = true
continue
}
field = Field(key: key, selectedOptionIndexes: indexes)
} else {
continue
}
fields.append(field)
}
self.init(documentURL: documentURL, fields: fields, hasUnrestorableInput: hasUnrestorableInput)
}

/// Whether there are no fields to restore.
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 documentURL.isFileURL && url.isFileURL && Self.isSameDocument(documentURL, url)
}
guard let scheme = documentURL.scheme?.lowercased(), let host = documentURL.host?.lowercased() else {
return false
}
guard scheme == url.scheme?.lowercased(), host == url.host?.lowercased() else { return false }
let defaultPort = scheme == "https" ? 443 : scheme == "http" ? 80 : nil
return (documentURL.port ?? defaultPort) == (url.port ?? defaultPort)
}

/// 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 {
if url.isFileURL {
let standardized = url.standardizedFileURL
let host = url.host?.lowercased() ?? ""
let port = url.port.map(String.init) ?? ""
return "\(host):\(port):\(standardized.path)"
}
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url.absoluteString
}
components.fragment = nil
return components.string ?? url.absoluteString
}
}
Loading
Loading