Fix browser automation recovery after load failures (#8548)

* test: cover browser recovery navigation commit

* test: require browser navigation commit barrier

* fix: await browser navigation commits

* fix: bound browser navigation transaction state

* fix: preserve browser navigation semantics

* fix: hand off browser navigation transactions

* fix: cover browser navigation handoff outcomes

* fix: validate deferred browser navigation targets

* fix: complete browser download navigations

* fix: preserve browser navigation results

* fix: correlate browser navigation terminal paths

* fix: preserve browser policy navigation identity

* fix: distinguish browser navigation policy signals

* fix: sanitize browser navigation failures

* fix: correlate deferred browser navigation handoffs

* fix: bound browser navigation outcome ownership

* fix: separate same-document navigation signals

* fix: correlate browser policy outcomes exactly

* fix: normalize browser navigation targets

---------

Co-authored-by: cmux reload-cloud <[email protected]>
This commit is contained in:
Austin Wang
2026-07-21 19:18:43 -07:00
committed by GitHub
co-authored by cmux reload-cloud
parent 950e60bc97
commit f43c6ccbec
18 changed files with 1970 additions and 72 deletions
+9 -5
View File
@@ -12975,11 +12975,12 @@ struct CMUXCLI {
let subArgs = Array(args.dropFirst())
let browserValueTextFormatter = BrowserValueTextFormatter()
// A post-action snapshot can spend 3s in document readiness, 10s in the
// requested action, 10s in snapshot JavaScript, and 2.5s in recovery.
// Keep transport headroom beyond that 25.5s app-side maximum.
// A committed navigation can spend up to 15s waiting for its delegate callback.
// A post-action snapshot can then spend another 10s in JavaScript and 2.5s in
// recovery. Keep transport headroom beyond that 27.5s app-side maximum when
// --snapshot-after is requested.
func sendBrowserAutomationRequest(method: String, params: [String: Any]) throws -> [String: Any] {
let responseTimeout: TimeInterval = (params["snapshot_after"] as? Bool) == true ? 30 : 20
let responseTimeout: TimeInterval = (params["snapshot_after"] as? Bool) == true ? 35 : 20
return try client.sendV2(method: method, params: params, responseTimeout: responseTimeout)
}
@@ -13399,7 +13400,10 @@ struct CMUXCLI {
guard !url.isEmpty else {
throw CLIError(message: "browser <surface> open requires a URL")
}
let payload = try client.sendV2(method: "browser.navigate", params: ["surface_id": sid, "url": url])
let payload = try sendBrowserAutomationRequest(
method: "browser.navigate",
params: ["surface_id": sid, "url": url]
)
output(payload, fallback: "OK")
return
}
@@ -0,0 +1,301 @@
public import Foundation
/// Owns the lifecycle of browser-automation navigations for one browser panel.
///
/// A transaction is associated with the exact navigation identity returned by the load call.
/// Document loads complete only for a delegate callback carrying that identity. Same-document
/// loads complete only for a trusted main-frame event that reaches the exact target URL.
@MainActor
public final class BrowserAutomationNavigationCoordinator {
/// Cancellable timing source used for the terminal-navigation deadline.
public typealias Sleep = @Sendable (_ duration: Duration) async throws -> Void
private let navigationTimeout: Duration
private let sleep: Sleep
private var observedInstanceID: UUID?
private var activeTicket: BrowserAutomationNavigationTicket?
private var activeNavigationID: ObjectIdentifier?
private var activeTargetURL: URL?
private var allowsSameDocumentCompletion = false
private var downloadPolicyNavigationID: ObjectIdentifier?
/// Creates a coordinator with a bounded continuous-clock navigation deadline.
public init(navigationTimeout: Duration = .seconds(15)) {
self.navigationTimeout = navigationTimeout
let clock = ContinuousClock()
self.sleep = { duration in
try await clock.sleep(for: duration)
}
}
/// Creates a coordinator with an injected timing source for deterministic tests.
public init(
navigationTimeout: Duration = .seconds(15),
sleep: @escaping Sleep
) {
self.navigationTimeout = navigationTimeout
self.sleep = sleep
}
/// Starts observing a WebView instance and supersedes a transaction from an older instance.
public func bind(to instanceID: UUID) {
guard observedInstanceID != instanceID else { return }
if let activeTicket {
finish(activeTicket, with: .superseded)
}
observedInstanceID = instanceID
}
/// Begins a transaction for the currently bound WebView instance.
///
/// - Parameters:
/// - instanceID: Identity of the WebView instance that will perform the navigation.
/// - targetURL: Display URL the navigation must reach.
/// - allowsSameDocumentCompletion: Whether a trusted same-document event may finish
/// the transaction. Pass `false` for reloads and app-owned error documents.
/// - Returns: A ticket that observes the transaction's one terminal outcome.
public func begin(
instanceID: UUID,
targetURL: URL? = nil,
allowsSameDocumentCompletion: Bool = false
) -> BrowserAutomationNavigationTicket {
if let activeTicket {
finish(activeTicket, with: .superseded)
}
let ticket = BrowserAutomationNavigationTicket(instanceID: instanceID)
guard observedInstanceID == instanceID else {
ticket.transaction.finish(with: .superseded)
return ticket
}
activeTicket = ticket
activeNavigationID = nil
activeTargetURL = targetURL
self.allowsSameDocumentCompletion = allowsSameDocumentCompletion
downloadPolicyNavigationID = nil
return ticket
}
/// Associates the load call's returned navigation identity with its transaction.
public func didStart(
_ ticket: BrowserAutomationNavigationTicket,
navigationID: ObjectIdentifier?
) {
guard activeTicket == ticket else { return }
guard let navigationID else {
finish(ticket, with: .notStarted)
return
}
activeNavigationID = navigationID
}
/// Associates a deferred or replacement load's returned navigation identity.
public func didAssociate(
instanceID: UUID,
navigationID: ObjectIdentifier?,
targetURL: URL? = nil
) {
guard let navigationID,
let activeTicket,
activeTicket.instanceID == instanceID else {
return
}
if activeNavigationID == nil {
if let activeTargetURL, targetURL != activeTargetURL {
finish(activeTicket, with: .superseded)
return
}
activeNavigationID = navigationID
}
}
/// Records the provisional delegate start for an associated navigation.
public func didStart(
instanceID: UUID,
navigationID: ObjectIdentifier?,
targetURL: URL? = nil
) {
didAssociate(instanceID: instanceID, navigationID: navigationID, targetURL: targetURL)
}
/// Resolves a reload after WebKit returns no navigation identity.
///
/// A document-less new tab is already in its requested state. Active recovery/deferred
/// signals keep the transaction open for the delegate callback that binds its real load;
/// every other nil return means WebKit did not start the requested reload.
public func didReturnNoNavigation(
_ ticket: BrowserAutomationNavigationTicket,
hasCurrentHistoryItem: Bool,
isShowingNewTabPage: Bool,
waitsForDeferredNavigation: Bool
) {
guard activeTicket == ticket, activeNavigationID == nil else { return }
guard !waitsForDeferredNavigation else { return }
let outcome: BrowserAutomationNavigationOutcome =
!hasCurrentHistoryItem && isShowingNewTabPage ? .committed : .notStarted
finish(ticket, with: outcome)
}
/// Completes the active transaction after WebKit reports a same-document navigation.
///
/// The owning WebView must call this only from a trusted main-frame same-document event.
/// Presentation URL observation is not a navigation lifecycle signal and must not call this API.
public func didFinishSameDocumentNavigation(instanceID: UUID, url: URL?) {
guard let url,
let activeTicket,
activeTicket.instanceID == instanceID,
activeNavigationID != nil,
allowsSameDocumentCompletion,
let activeTargetURL,
let observedNavigationURL = BrowserAutomationNavigationURL(url),
let targetNavigationURL = BrowserAutomationNavigationURL(activeTargetURL),
observedNavigationURL == targetNavigationURL else {
return
}
finish(activeTicket, with: .committed)
}
/// Authorizes a download outcome for the exact provisional navigation whose response policy changed.
///
/// - Parameters:
/// - instanceID: Identity of the WebView instance receiving the response.
/// - navigationID: Identity of the provisional navigation whose response became a download.
public func didChooseDownloadPolicy(instanceID: UUID, navigationID: ObjectIdentifier?) {
guard let navigationID,
let activeTicket,
activeTicket.instanceID == instanceID,
activeNavigationID == navigationID else {
return
}
downloadPolicyNavigationID = navigationID
}
/// Completes an exact policy-interrupted navigation and reports whether it was an authorized download.
///
/// WebKit error 102 covers every policy interruption, so only a preceding response-download decision
/// for the same navigation identity is a successful download. All other matching interruptions
/// are cancellations.
///
/// - Parameters:
/// - instanceID: Identity of the WebView instance reporting the interruption.
/// - navigationID: Identity of the provisional navigation interrupted by policy.
/// - Returns: `true` only when the exact navigation had an authorized response-download decision.
@discardableResult
public func didInterruptByPolicyChange(
instanceID: UUID,
navigationID: ObjectIdentifier?
) -> Bool {
guard let navigationID,
let activeTicket,
activeTicket.instanceID == instanceID,
activeNavigationID == navigationID else {
return false
}
let isDownload = downloadPolicyNavigationID == navigationID
finish(activeTicket, with: isDownload ? .downloaded : .cancelled)
return isDownload
}
/// Records a commit only when it belongs to the exact active navigation.
public func didCommit(instanceID: UUID, navigationID: ObjectIdentifier?) {
finishMatching(instanceID: instanceID, navigationID: navigationID, with: .committed)
}
/// Records a failure only when it belongs to the exact active navigation.
public func didFail(instanceID: UUID, navigationID: ObjectIdentifier?, message: String) {
finishMatching(instanceID: instanceID, navigationID: navigationID, with: .failed(message))
}
/// Records a cancellation only when it belongs to the exact active navigation.
public func didCancel(instanceID: UUID, navigationID: ObjectIdentifier?) {
finishMatching(instanceID: instanceID, navigationID: navigationID, with: .cancelled)
}
/// Cancels a transaction that no longer has a caller waiting for it.
public func cancel(_ ticket: BrowserAutomationNavigationTicket) {
guard activeTicket == ticket else { return }
finish(ticket, with: .cancelled)
}
/// Cancels the active transaction and stops observing the current WebView instance.
public func invalidate() {
if let activeTicket {
finish(activeTicket, with: .cancelled)
}
observedInstanceID = nil
}
/// Waits for the exact navigation to commit or reach another terminal delegate outcome.
public func wait(
for ticket: BrowserAutomationNavigationTicket
) async -> BrowserAutomationNavigationOutcome {
guard !Task.isCancelled else {
cancel(ticket)
ticket.transaction.discardTerminalOutcome()
return .cancelled
}
if let completed = ticket.transaction.takeTerminalOutcome() {
return completed
}
guard activeTicket == ticket else { return .superseded }
let events = ticket.transaction.makeEventStream()
let outcome = await withTaskGroup(
of: BrowserAutomationNavigationOutcome.self,
returning: BrowserAutomationNavigationOutcome.self
) { group in
group.addTask {
var iterator = events.makeAsyncIterator()
return await iterator.next() ?? .cancelled
}
group.addTask { [navigationTimeout, sleep] in
do {
try await sleep(navigationTimeout)
} catch {
return .cancelled
}
return Task.isCancelled ? .cancelled : .timedOut
}
let first = await group.next() ?? .cancelled
group.cancelAll()
ticket.transaction.cancelWaiter()
await group.waitForAll()
return first
}
ticket.transaction.discardTerminalOutcome()
if activeTicket == ticket {
finish(ticket, with: Task.isCancelled ? .cancelled : outcome)
ticket.transaction.discardTerminalOutcome()
}
return Task.isCancelled ? .cancelled : outcome
}
private func finishMatching(
instanceID: UUID,
navigationID: ObjectIdentifier?,
with outcome: BrowserAutomationNavigationOutcome
) {
guard let navigationID,
let activeTicket,
activeTicket.instanceID == instanceID,
activeNavigationID == navigationID else {
return
}
finish(activeTicket, with: outcome)
}
private func finish(
_ ticket: BrowserAutomationNavigationTicket,
with outcome: BrowserAutomationNavigationOutcome
) {
guard activeTicket == ticket else { return }
activeTicket = nil
activeNavigationID = nil
activeTargetURL = nil
allowsSameDocumentCompletion = false
downloadPolicyNavigationID = nil
ticket.transaction.finish(with: outcome)
}
}
@@ -0,0 +1,23 @@
/// The terminal result of one browser-automation navigation transaction.
public enum BrowserAutomationNavigationOutcome: Sendable, Equatable {
/// The exact navigation started for the transaction committed a document.
case committed
/// The exact main-frame navigation became a download instead of a document.
case downloaded
/// WebKit reported a terminal navigation failure.
case failed(String)
/// WebKit cancelled the provisional navigation before it committed.
case cancelled
/// A newer automation navigation or WebView instance replaced this transaction.
case superseded
/// WebKit declined to create a navigation for the requested load.
case notStarted
/// No terminal delegate callback arrived before the bounded navigation deadline.
case timedOut
}
@@ -0,0 +1,30 @@
public import Foundation
/// Stable identity for one browser-automation navigation transaction.
public struct BrowserAutomationNavigationTicket: Sendable, Hashable {
/// Identity of the WebView instance that owns the transaction.
public let instanceID: UUID
let transactionID: UUID
let transaction: BrowserAutomationNavigationTransaction
@MainActor
init(instanceID: UUID, transactionID: UUID = UUID()) {
self.instanceID = instanceID
self.transactionID = transactionID
self.transaction = BrowserAutomationNavigationTransaction()
}
/// Returns whether two tickets identify the same navigation transaction.
public static func == (
lhs: BrowserAutomationNavigationTicket,
rhs: BrowserAutomationNavigationTicket
) -> Bool {
lhs.transactionID == rhs.transactionID
}
/// Hashes the stable identity of this navigation transaction.
public func hash(into hasher: inout Hasher) {
hasher.combine(transactionID)
}
}
@@ -0,0 +1,37 @@
/// One-shot result storage owned by the navigation ticket returned to a caller.
@MainActor
final class BrowserAutomationNavigationTransaction {
private var terminalOutcome: BrowserAutomationNavigationOutcome?
private var waiter: AsyncStream<BrowserAutomationNavigationOutcome>.Continuation?
func takeTerminalOutcome() -> BrowserAutomationNavigationOutcome? {
defer { terminalOutcome = nil }
return terminalOutcome
}
func makeEventStream() -> AsyncStream<BrowserAutomationNavigationOutcome> {
let (events, continuation) = AsyncStream.makeStream(
of: BrowserAutomationNavigationOutcome.self,
bufferingPolicy: .bufferingNewest(1)
)
waiter?.finish()
waiter = continuation
return events
}
func finish(with outcome: BrowserAutomationNavigationOutcome) {
terminalOutcome = outcome
waiter?.yield(outcome)
waiter?.finish()
waiter = nil
}
func cancelWaiter() {
waiter?.finish()
waiter = nil
}
func discardTerminalOutcome() {
terminalOutcome = nil
}
}
@@ -0,0 +1,90 @@
import Foundation
/// A deterministic URL value for matching WebKit same-document navigation reports.
struct BrowserAutomationNavigationURL: Equatable {
private static let uppercaseHexadecimal = Array("0123456789ABCDEF".utf8)
private let scheme: String?
private let user: String?
private let password: String?
private let host: String?
private let port: Int?
private let path: String
private let query: String?
private let fragment: String?
init?(_ url: URL) {
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return nil
}
let normalizedScheme = components.scheme?.lowercased()
scheme = normalizedScheme
user = components.percentEncodedUser.map(Self.normalizePercentEncoding)
password = components.percentEncodedPassword.map(Self.normalizePercentEncoding)
host = components.host?.lowercased()
switch (normalizedScheme, components.port) {
case ("http", 80), ("https", 443):
port = nil
default:
port = components.port
}
let normalizedPath = Self.normalizePercentEncoding(components.percentEncodedPath)
if (normalizedScheme == "http" || normalizedScheme == "https"),
components.host != nil,
normalizedPath.isEmpty {
path = "/"
} else {
path = normalizedPath
}
query = components.percentEncodedQuery.map(Self.normalizePercentEncoding)
fragment = components.percentEncodedFragment.map(Self.normalizePercentEncoding)
}
private static func normalizePercentEncoding(_ value: String) -> String {
let bytes = Array(value.utf8)
var normalized: [UInt8] = []
normalized.reserveCapacity(bytes.count)
var index = 0
while index < bytes.count {
if bytes[index] == 0x25,
index + 2 < bytes.count,
let high = hexadecimalValue(bytes[index + 1]),
let low = hexadecimalValue(bytes[index + 2]) {
let decoded = high << 4 | low
if isUnreserved(decoded) {
normalized.append(decoded)
} else {
normalized.append(0x25)
normalized.append(uppercaseHexadecimal[Int(decoded >> 4)])
normalized.append(uppercaseHexadecimal[Int(decoded & 0x0F)])
}
index += 3
continue
}
normalized.append(bytes[index])
index += 1
}
return String(decoding: normalized, as: UTF8.self)
}
private static func hexadecimalValue(_ byte: UInt8) -> UInt8? {
switch byte {
case 0x30...0x39: byte - 0x30
case 0x41...0x46: byte - 0x41 + 10
case 0x61...0x66: byte - 0x61 + 10
default: nil
}
}
private static func isUnreserved(_ byte: UInt8) -> Bool {
switch byte {
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2D, 0x2E, 0x5F, 0x7E: true
default: false
}
}
}
@@ -0,0 +1,462 @@
import Foundation
import Testing
@testable import CmuxBrowser
@MainActor
@Suite("Browser automation navigation coordinator")
struct BrowserAutomationNavigationCoordinatorTests {
@Test("The exact started navigation commit completes the transaction")
func exactNavigationCommitCompletesTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didCommit(instanceID: instanceID, navigationID: ObjectIdentifier(navigation))
#expect(await coordinator.wait(for: ticket) == .committed)
}
@Test("A delegate commit releases an already waiting transaction")
func commitReleasesWaitingTransaction() async {
let (registrations, registrationContinuation) = AsyncStream.makeStream(of: Void.self)
var registrationIterator = registrations.makeAsyncIterator()
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
let wait = Task { @MainActor in
registrationContinuation.yield()
return await coordinator.wait(for: ticket)
}
let registered: Void? = await registrationIterator.next()
#expect(registered != nil)
coordinator.didCommit(instanceID: instanceID, navigationID: ObjectIdentifier(navigation))
#expect(await wait.value == .committed)
registrationContinuation.finish()
}
@Test("Cancelling a wait cancels its active transaction")
func cancellingWaitCancelsTransaction() async {
let (registrations, registrationContinuation) = AsyncStream.makeStream(of: Void.self)
var registrationIterator = registrations.makeAsyncIterator()
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
let wait = Task { @MainActor in
registrationContinuation.yield()
return await coordinator.wait(for: ticket)
}
let registered: Void? = await registrationIterator.next()
#expect(registered != nil)
wait.cancel()
#expect(await wait.value == .cancelled)
registrationContinuation.finish()
}
@Test("A different navigation cannot satisfy the active transaction")
func unrelatedCommitIsIgnored() async {
let coordinator = BrowserAutomationNavigationCoordinator(
sleep: { _ in }
)
let instanceID = UUID()
let requestedNavigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(requestedNavigation))
coordinator.didCommit(instanceID: instanceID, navigationID: ObjectIdentifier(NSObject()))
#expect(await coordinator.wait(for: ticket) == .timedOut)
}
@Test("A failure delivered before waiting remains observable")
func earlyFailureRemainsObservable() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFail(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation),
message: "connection refused"
)
#expect(await coordinator.wait(for: ticket) == .failed("connection refused"))
}
@Test("A completed outcome survives a newer transaction beginning")
func completedOutcomeSurvivesNewerTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let firstNavigation = NSObject()
coordinator.bind(to: instanceID)
let firstTicket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(firstTicket, navigationID: ObjectIdentifier(firstNavigation))
coordinator.didCommit(
instanceID: instanceID,
navigationID: ObjectIdentifier(firstNavigation)
)
_ = coordinator.begin(instanceID: instanceID)
#expect(await coordinator.wait(for: firstTicket) == .committed)
}
@Test("An abandoned terminal outcome is released with its ticket")
func abandonedTerminalOutcomeIsReleased() {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
coordinator.bind(to: instanceID)
weak var transaction: BrowserAutomationNavigationTransaction?
do {
let navigation = NSObject()
let ticket = coordinator.begin(instanceID: instanceID)
transaction = ticket.transaction
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFail(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation),
message: "connection refused"
)
}
#expect(transaction == nil)
}
@Test("A deferred load can bind when its real navigation starts")
func deferredLoadBindsOnStart() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didCommit(instanceID: instanceID, navigationID: ObjectIdentifier(navigation))
#expect(await coordinator.wait(for: ticket) == .committed)
}
@Test("An unrelated deferred navigation supersedes the transaction")
func unrelatedDeferredNavigationSupersedes() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let expectedURL = URL(string: "https://example.com/expected")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: expectedURL)
coordinator.didStart(
instanceID: instanceID,
navigationID: ObjectIdentifier(NSObject()),
targetURL: URL(string: "https://example.com/unrelated")!
)
#expect(await coordinator.wait(for: ticket) == .superseded)
}
@Test("An uncorrelated policy replacement cannot seize the active transaction")
func policyReplacementCannotSeizeTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let originalNavigation = NSObject()
let replacementNavigation = NSObject()
let originalURL = URL(string: "https://example.com/launch")!
let fallbackURL = URL(string: "https://example.com/fallback")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: originalURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(originalNavigation))
coordinator.didStart(
instanceID: instanceID,
navigationID: ObjectIdentifier(replacementNavigation),
targetURL: fallbackURL
)
coordinator.didCommit(
instanceID: instanceID,
navigationID: ObjectIdentifier(replacementNavigation)
)
coordinator.didCancel(
instanceID: instanceID,
navigationID: ObjectIdentifier(originalNavigation)
)
#expect(await coordinator.wait(for: ticket) == .cancelled)
}
@Test("An authoritative same-document navigation event completes the transaction")
func sameDocumentNavigationEventCompletes() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/page#section")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(
instanceID: instanceID,
targetURL: targetURL,
allowsSameDocumentCompletion: true
)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFinishSameDocumentNavigation(instanceID: instanceID, url: targetURL)
#expect(await coordinator.wait(for: ticket) == .committed)
}
@Test("WebKit-canonical same-document URLs complete the transaction")
func canonicalSameDocumentURLsComplete() async {
let equivalents = [
("https://example.com#verified", "https://example.com/#verified"),
(
"HTTPS://EXAMPLE.COM:443/%7euser?q=%7e#part%2fvalue",
"https://example.com/~user?q=~#part%2Fvalue"
),
("http://EXAMPLE.COM:80#verified", "http://example.com/#verified"),
]
for (target, observed) in equivalents {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: target)!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(
instanceID: instanceID,
targetURL: targetURL,
allowsSameDocumentCompletion: true
)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFinishSameDocumentNavigation(
instanceID: instanceID,
url: URL(string: observed)
)
#expect(await coordinator.wait(for: ticket) == .committed)
}
}
@Test("Reserved escapes are not collapsed when matching same-document URLs")
func reservedEscapeRemainsDistinct() async {
let coordinator = BrowserAutomationNavigationCoordinator(sleep: { _ in })
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/a%2Fb#verified")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(
instanceID: instanceID,
targetURL: targetURL,
allowsSameDocumentCompletion: true
)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFinishSameDocumentNavigation(
instanceID: instanceID,
url: URL(string: "https://example.com/a/b#verified")
)
#expect(await coordinator.wait(for: ticket) == .timedOut)
}
@Test("An error document cannot satisfy a navigation with a fragment event")
func errorDocumentSameDocumentEventIsIgnored() async {
let coordinator = BrowserAutomationNavigationCoordinator(sleep: { _ in })
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/page#section")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: targetURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didFinishSameDocumentNavigation(instanceID: instanceID, url: targetURL)
#expect(await coordinator.wait(for: ticket) == .timedOut)
}
@Test("Associating a matching load is not itself a navigation completion")
func navigationAssociationDoesNotCompleteTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator(sleep: { _ in })
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/page")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: targetURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
#expect(await coordinator.wait(for: ticket) == .timedOut)
}
@Test("A main-frame download completes the transaction without a document commit")
func mainFrameDownloadCompletes() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/archive.zip")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: targetURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didChooseDownloadPolicy(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
)
#expect(coordinator.didInterruptByPolicyChange(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
))
#expect(await coordinator.wait(for: ticket) == .downloaded)
}
@Test("An unrelated download policy cannot authorize the active transaction")
func unrelatedDownloadPolicyIsIgnored() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/page")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: targetURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
coordinator.didChooseDownloadPolicy(
instanceID: instanceID,
navigationID: ObjectIdentifier(NSObject())
)
#expect(!coordinator.didInterruptByPolicyChange(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
))
#expect(await coordinator.wait(for: ticket) == .cancelled)
}
@Test("A matching URL without exact download policy identity is a cancellation")
func urlMatchCannotAuthorizeDownload() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
let targetURL = URL(string: "https://example.com/archive.zip")!
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID, targetURL: targetURL)
coordinator.didStart(ticket, navigationID: ObjectIdentifier(navigation))
#expect(!coordinator.didInterruptByPolicyChange(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
))
#expect(await coordinator.wait(for: ticket) == .cancelled)
}
@Test("A document-less new-tab reload can complete without WebKit navigation")
func documentlessNewTabReloadCompletes() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didReturnNoNavigation(
ticket,
hasCurrentHistoryItem: false,
isShowingNewTabPage: true,
waitsForDeferredNavigation: false
)
#expect(await coordinator.wait(for: ticket) == .committed)
}
@Test("A nil reload for an existing document is not reported as committed")
func existingDocumentNilReloadIsNotStarted() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didReturnNoNavigation(
ticket,
hasCurrentHistoryItem: true,
isShowingNewTabPage: false,
waitsForDeferredNavigation: false
)
#expect(await coordinator.wait(for: ticket) == .notStarted)
}
@Test("A deferred nil reload remains pending for its real navigation")
func deferredNilReloadBindsRealNavigation() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
let navigation = NSObject()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didReturnNoNavigation(
ticket,
hasCurrentHistoryItem: true,
isShowingNewTabPage: false,
waitsForDeferredNavigation: true
)
coordinator.didStart(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
)
coordinator.didCommit(
instanceID: instanceID,
navigationID: ObjectIdentifier(navigation)
)
#expect(await coordinator.wait(for: ticket) == .committed)
}
@Test("A load that returns no navigation terminates as not started")
func missingNavigationIsNotStarted() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
coordinator.bind(to: instanceID)
let ticket = coordinator.begin(instanceID: instanceID)
coordinator.didStart(ticket, navigationID: nil)
#expect(await coordinator.wait(for: ticket) == .notStarted)
}
@Test("A newer transaction supersedes the previous transaction")
func newerTransactionSupersedesPreviousTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let instanceID = UUID()
coordinator.bind(to: instanceID)
let firstTicket = coordinator.begin(instanceID: instanceID)
_ = coordinator.begin(instanceID: instanceID)
#expect(await coordinator.wait(for: firstTicket) == .superseded)
}
@Test("Binding a replacement instance supersedes the old transaction")
func replacementSupersedesOldTransaction() async {
let coordinator = BrowserAutomationNavigationCoordinator()
let firstInstanceID = UUID()
coordinator.bind(to: firstInstanceID)
let ticket = coordinator.begin(instanceID: firstInstanceID)
coordinator.bind(to: UUID())
#expect(await coordinator.wait(for: ticket) == .superseded)
}
}
+42 -10
View File
@@ -9,13 +9,15 @@ import WebKit
var didStartProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)?
var didCommit: ((WKWebView, WKNavigation?) -> Void)?
var didFinish: ((WKWebView) -> Void)?
var didFailNavigation: ((WKWebView, String, WKNavigation?) -> Void)?
var didFailNavigation: ((WKWebView, String, String, WKNavigation?) -> Void)?
var didCancelProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)?
var didChooseMainFrameDownloadPolicy: ((WKWebView, WKNavigation?) -> Void)?
var didInterruptProvisionalNavigationByPolicy: ((WKWebView, WKNavigation?) -> Bool)?
var didCancelNavigationPolicy: ((WKWebView, PolicyCancellationKind) -> Void)?
var didBecomeDownload: ((WKWebView, Bool, UUID?) -> Void)?
var didTerminateWebContentProcess: ((WKWebView) -> Void)?
var openInNewTab: ((URL) -> Void)?
var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent) -> Void)?
var requestNavigation: ((URLRequest, BrowserInsecureHTTPNavigationIntent, ((WKNavigation?) -> Void)?) -> Void)?
var presentAlert: BrowserAlertPresenter = browserPresentAlert
var shouldBlockInsecureHTTPNavigation: ((URL) -> Bool)?
var shouldBlockInsecureHTTPSubframeDownload: ((URL) -> Bool)?
@@ -40,6 +42,8 @@ import WebKit
private var activeSSLTrustBypassReplayRequest: URLRequest?
private var activeSSLTrustBypassErrorPageRetryRequest: URLRequest?
private var pendingMainFrameDownloadRestoreAttemptID: UUID?
// WKNavigation is WebKit's only public identity linking a load to its lifecycle callbacks.
private var activeMainFrameNavigation: WKNavigation?
func cancelPendingAuthenticationPrompts(allowFuturePrompts: Bool = false) {
basicAuthPromptCoordinator.cancelAll(allowFuturePrompts: allowFuturePrompts)
@@ -90,6 +94,7 @@ import WebKit
}
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
activeMainFrameNavigation = navigation
lastAttemptedURL = lastAttemptedURL ?? webView.url ?? lastAttemptedRequest?.url
shouldPrintAfterCurrentNavigationFinishes = false
didClearPDFDocument?()
@@ -101,10 +106,12 @@ import WebKit
clearAttemptedRequest(discardPendingBypasses: true)
}
didCommit?(webView, navigation)
clearActiveMainFrameNavigation(ifMatching: navigation)
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
didFinish?(webView)
clearActiveMainFrameNavigation(ifMatching: navigation)
if shouldPrintAfterCurrentNavigationFinishes {
shouldPrintAfterCurrentNavigationFinishes = false
webView.cmuxRunPrintOperation()
@@ -116,7 +123,8 @@ import WebKit
// Treat committed-navigation failures the same as provisional ones so
// stale favicon/title state from the prior page gets cleared.
let failedURL = webView.url?.absoluteString ?? ""
didFailNavigation?(webView, failedURL, navigation)
didFailNavigation?(webView, failedURL, error.localizedDescription, navigation)
clearActiveMainFrameNavigation(ifMatching: navigation)
}
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
@@ -126,21 +134,26 @@ import WebKit
// Cancelled navigations (e.g. rapid typing) are not real errors.
if nsError.domain == NSURLErrorDomain, nsError.code == NSURLErrorCancelled {
didCancelProvisionalNavigation?(webView, navigation)
clearActiveMainFrameNavigation(ifMatching: navigation)
return
}
// "Frame load interrupted" (WebKitErrorDomain code 102) fires when a
// navigation response is converted into a download via .download policy.
// This is expected and should not show an error page.
// "Frame load interrupted" (WebKitErrorDomain code 102) can result from
// several policy transfers. Only an explicit .download decision is success.
if nsError.domain == "WebKitErrorDomain", nsError.code == 102 {
didCancelProvisionalNavigation?(webView, navigation)
let isDownload = didInterruptProvisionalNavigationByPolicy?(webView, navigation) == true
if !isDownload {
didCancelProvisionalNavigation?(webView, navigation)
}
clearActiveMainFrameNavigation(ifMatching: navigation)
return
}
let failedURL = nsError.userInfo[NSURLErrorFailingURLStringErrorKey] as? String
?? lastAttemptedURL?.absoluteString
?? ""
didFailNavigation?(webView, failedURL, navigation)
didFailNavigation?(webView, failedURL, error.localizedDescription, navigation)
clearActiveMainFrameNavigation(ifMatching: navigation)
loadErrorPage(
in: webView,
failedURL: failedURL,
@@ -213,6 +226,11 @@ import WebKit
return .urlOnly
}
func activeErrorPageRetryForAutomation() -> BrowserErrorPageRetry? {
guard let failedURL = activeErrorPageDisplayURL?.absoluteString else { return nil }
return retryForFailedNavigation(failedURL: failedURL)
}
private func loadErrorPage(in webView: WKWebView, failedURL: String, retry: BrowserErrorPageRetry, error: NSError) {
activeSSLTrustBypassReplayRequest = nil
activeSSLTrustBypassErrorPageRetryRequest = nil
@@ -257,7 +275,7 @@ import WebKit
let openRequestInNewTab: (URLRequest) -> Void = { [requestNavigation, openInNewTab] request in
if let requestNavigation {
requestNavigation(request, .newTab)
requestNavigation(request, .newTab, nil)
return
}
if let url = request.url {
@@ -333,12 +351,14 @@ import WebKit
browserShouldRouteExternalNavigation(url) {
clearAttemptedRequest(discardPendingBypasses: true)
let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {}
// WKNavigationAction has no public WKNavigation identity. Keep the replacement
// unbound so the exact original policy cancellation terminates automation.
browserHandleExternalNavigation(
url,
source: "navDelegate",
webView: webView,
loadFallbackRequest: { [requestNavigation] request in
requestNavigation?(request, .currentTab)
requestNavigation?(request, .currentTab, nil)
},
presentAlert: presentAlert,
onTerminalExternalNavigation: reportTerminalCancellation
@@ -348,6 +368,8 @@ import WebKit
}
if navigationAction.shouldPerformDownload {
// Action-policy downloads expose no WKNavigation identity. Only a response-policy
// conversion can authorize automation success for an exact provisional navigation.
if navigationAction.targetFrame?.isMainFrame == false {
guard let url = navigationAction.request.url else {
decisionHandler(.cancel)
@@ -570,6 +592,11 @@ import WebKit
#if DEBUG
cmuxDebugLog("download.policy=download reason=\(reason) mime=\(mime) mainFrame=\(navigationResponse.isForMainFrame ? 1 : 0)")
#endif
if navigationResponse.isForMainFrame {
// A main-frame response follows didStartProvisionalNavigation, so this is the
// exact WKNavigation whose response WebKit is converting into a download.
didChooseMainFrameDownloadPolicy?(webView, activeMainFrameNavigation)
}
decisionHandler(.download)
return
}
@@ -610,6 +637,11 @@ import WebKit
.caseInsensitiveCompare("application/pdf") == .orderedSame
}
private func clearActiveMainFrameNavigation(ifMatching navigation: WKNavigation?) {
guard activeMainFrameNavigation === navigation else { return }
activeMainFrameNavigation = nil
}
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
let isMainFrame = navigationAction.targetFrame?.isMainFrame ?? true
let restoreAttemptID = isMainFrame ? pendingMainFrameDownloadRestoreAttemptID : nil
@@ -3,6 +3,112 @@ import CmuxBrowser
import WebKit
extension BrowserPanel {
func setupSameDocumentNavigationMessageHandler(for webView: WKWebView) {
let observedWebViewInstanceID = webViewInstanceID
let handler = BrowserSameDocumentNavigationMessageHandler(
webView: webView,
onNavigation: { [weak self, weak webView] url in
guard let self, let webView,
self.webView === webView,
self.webViewInstanceID == observedWebViewInstanceID else {
return
}
let displayURL = Self.remoteProxyDisplayURL(for: url) ?? url
self.automationNavigationCoordinator.didFinishSameDocumentNavigation(
instanceID: observedWebViewInstanceID,
url: displayURL
)
}
)
sameDocumentNavigationMessageHandler = handler
let userContentController = webView.configuration.userContentController
userContentController.removeScriptMessageHandler(
forName: BrowserSameDocumentNavigationMessageHandler.name,
contentWorld: BrowserSameDocumentNavigationMessageHandler.contentWorld
)
userContentController.add(
handler,
contentWorld: BrowserSameDocumentNavigationMessageHandler.contentWorld,
name: BrowserSameDocumentNavigationMessageHandler.name
)
}
func beginAutomationNavigation(
to targetURL: URL,
recordTypedNavigation: Bool
) -> BrowserAutomationNavigationTicket {
let ticket = automationNavigationCoordinator.begin(
instanceID: webViewInstanceID,
targetURL: targetURL,
allowsSameDocumentCompletion: navigationDelegate?.activeErrorPageDisplayURL == nil
)
navigate(
to: targetURL,
recordTypedNavigation: recordTypedNavigation,
onNavigationStarted: { [weak self] navigation in
self?.automationNavigationCoordinator.didStart(
ticket,
navigationID: navigation.map { ObjectIdentifier($0) }
)
}
)
return ticket
}
func beginAutomationReloadFromCLI() -> (
ticket: BrowserAutomationNavigationTicket,
targetURL: URL
)? {
guard let targetURL = automationReloadTargetURL() else { return nil }
let ticket = automationNavigationCoordinator.begin(
instanceID: webViewInstanceID,
targetURL: targetURL
)
let navigationStarted: (WKNavigation?) -> Void = { [weak self] navigation in
self?.automationNavigationCoordinator.didStart(
ticket,
navigationID: navigation.map { ObjectIdentifier($0) }
)
}
switch navigationDelegate?.activeErrorPageRetryForAutomation() {
case .request(let request):
navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: false,
onNavigationStarted: navigationStarted
)
case .urlOnly:
navigate(
to: targetURL,
recordTypedNavigation: false,
onNavigationStarted: navigationStarted
)
case .disabled:
navigationStarted(nil)
case nil:
if let navigation = reload() {
navigationStarted(navigation)
} else {
automationNavigationCoordinator.didReturnNoNavigation(
ticket,
hasCurrentHistoryItem: webView.backForwardList.currentItem != nil,
isShowingNewTabPage: isShowingNewTabPage,
waitsForDeferredNavigation: webView.isLoading ||
isMainFrameProvisionalNavigationActive ||
hasPendingRemoteNavigation
)
}
}
return (ticket, targetURL)
}
func finishAutomationNavigation(
_ ticket: BrowserAutomationNavigationTicket
) async -> BrowserAutomationNavigationOutcome {
await automationNavigationCoordinator.wait(for: ticket)
}
func registerBrowserAutomationInitScript(_ userScript: WKUserScript) -> Int {
browserAutomationUserScripts.append(userScript)
browserAutomationInitScriptCount += 1
+213 -46
View File
@@ -2804,6 +2804,7 @@ final class BrowserPanel: Panel, ObservableObject {
private let visualAutomationCaptureGate = BrowserScreenshotCaptureGate()
let automationWatchdog = BrowserAutomationWatchdog()
let automationDocumentReadiness = BrowserAutomationDocumentReadiness()
let automationNavigationCoordinator = BrowserAutomationNavigationCoordinator()
var activeVisualAutomationCaptureCount: Int = 0
private struct PendingInteractiveBrowserPrompt {
let present: (NSWindow, @escaping () -> Void) -> Void
@@ -3045,6 +3046,7 @@ final class BrowserPanel: Panel, ObservableObject {
}
var reactGrabMessageHandler: ReactGrabMessageHandler?
var sslTrustBypassMessageHandler: BrowserSSLTrustBypassMessageHandler?
var sameDocumentNavigationMessageHandler: BrowserSameDocumentNavigationMessageHandler?
/// Whether the live page currently has any actively-playing `<video>` or
/// `<audio>` element, in the main frame or any iframe, reported by the
/// injected media-playback hook. Keeps an actively-playing pane alive in the
@@ -3115,6 +3117,7 @@ final class BrowserPanel: Panel, ObservableObject {
let request: URLRequest
let recordTypedNavigation: Bool
let preserveRestoredSessionHistory: Bool
let onNavigationStarted: ((WKNavigation?) -> Void)?
}
private var pendingRemoteNavigation: PendingRemoteNavigation?
private let bypassesRemoteWorkspaceProxy: Bool
@@ -3611,6 +3614,9 @@ final class BrowserPanel: Panel, ObservableObject {
forMainFrameOnly: true
)
)
configuration.userContentController.addUserScript(
BrowserSameDocumentNavigationMessageHandler.userScript
)
// Keep browser console/error/dialog telemetry active from document start on every navigation.
// Main frame only injecting into cross-origin iframes causes CAPTCHA providers
// (reCAPTCHA, hCaptcha, Cloudflare Turnstile) to detect the overridden console.*
@@ -3725,10 +3731,12 @@ final class BrowserPanel: Panel, ObservableObject {
}
configureMoveTabToNewWorkspaceContextMenu(for: webView); configureNavigationDelegateCallbacks()
automationDocumentReadiness.bind(to: webViewInstanceID, hasCommittedDocument: webView.backForwardList.currentItem != nil)
automationNavigationCoordinator.bind(to: webViewInstanceID)
webView.cmuxDownloadDelegate = downloadDelegate
webView.navigationDelegate = navigationDelegate
webView.uiDelegate = uiDelegate
setupObservers(for: webView)
setupSameDocumentNavigationMessageHandler(for: webView)
setupReactGrabMessageHandler(for: webView)
designModeController.install(on: webView)
setupSSLTrustBypassMessageHandler(for: webView)
@@ -3751,6 +3759,7 @@ final class BrowserPanel: Panel, ObservableObject {
userContentController.removeScriptMessageHandler(forName: BrowserSSLTrustBypassMessageHandler.name)
userContentController.add(handler, name: BrowserSSLTrustBypassMessageHandler.name)
}
private func configureNavigationDelegateCallbacks() {
guard let navigationDelegate else { return }
let boundWebViewInstanceID = webViewInstanceID
@@ -3768,6 +3777,12 @@ final class BrowserPanel: Panel, ObservableObject {
MainActor.assumeIsolated {
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return }
(webView as? CmuxWebView)?.diffViewerNavigationDidStart(navigation)
self.automationNavigationCoordinator.didStart(
instanceID: boundWebViewInstanceID,
navigationID: navigation.map { ObjectIdentifier($0) },
targetURL: Self.remoteProxyDisplayURL(for: self.navigationDelegate?.lastAttemptedURL)
?? self.navigationDelegate?.lastAttemptedURL
)
self.isMainFrameProvisionalNavigationActive = true
self.refreshBackgroundAppearance()
self.applyMuteState(to: webView, reason: "navigationStart")
@@ -3780,6 +3795,10 @@ final class BrowserPanel: Panel, ObservableObject {
(webView as? CmuxWebView)?.diffViewerNavigationDidCommit(navigation)
self.isMainFrameProvisionalNavigationActive = false
self.automationDocumentReadiness.didCommit(instanceID: boundWebViewInstanceID)
self.automationNavigationCoordinator.didCommit(
instanceID: boundWebViewInstanceID,
navigationID: navigation.map { ObjectIdentifier($0) }
)
// An about:blank placeholder leaves the restore-stall detector armed.
if !Self.isAboutBlankURL(webView.url) {
self.hasCommittedDocumentSinceWebViewReplacement = true
@@ -3813,9 +3832,14 @@ final class BrowserPanel: Panel, ObservableObject {
self.restoreFindStateAfterNavigation(replaySearch: true)
}
}
navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL, failedNavigation in
navigationDelegate.didFailNavigation = { [weak self] failedWebView, failedURL, failureMessage, failedNavigation in
MainActor.assumeIsolated {
guard let self, self.isCurrentWebView(failedWebView, instanceID: boundWebViewInstanceID) else { return }
self.automationNavigationCoordinator.didFail(
instanceID: boundWebViewInstanceID,
navigationID: failedNavigation.map { ObjectIdentifier($0) },
message: failureMessage
)
self.isMainFrameProvisionalNavigationActive = false
if let url = URL(string: failedURL) {
self.currentURL = Self.remoteProxyDisplayURL(for: url) ?? url
@@ -3845,6 +3869,10 @@ final class BrowserPanel: Panel, ObservableObject {
MainActor.assumeIsolated {
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return }
(webView as? CmuxWebView)?.diffViewerNavigationDidCancel(cancelledNavigation)
self.automationNavigationCoordinator.didCancel(
instanceID: boundWebViewInstanceID,
navigationID: cancelledNavigation.map { ObjectIdentifier($0) }
)
let isRestoreBookkeepingNavigation = self.isDiscardRestoreBookkeepingNavigation(cancelledNavigation)
self.isMainFrameProvisionalNavigationActive = false
if isRestoreBookkeepingNavigation {
@@ -3856,10 +3884,41 @@ final class BrowserPanel: Panel, ObservableObject {
}
}
}
navigationDelegate.didChooseMainFrameDownloadPolicy = { [weak self] webView, navigation in
MainActor.assumeIsolated {
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else { return }
self.automationNavigationCoordinator.didChooseDownloadPolicy(
instanceID: boundWebViewInstanceID,
navigationID: navigation.map { ObjectIdentifier($0) }
)
}
}
navigationDelegate.didInterruptProvisionalNavigationByPolicy = { [weak self] webView, navigation in
MainActor.assumeIsolated {
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else {
return false
}
let isDownload = self.automationNavigationCoordinator.didInterruptByPolicyChange(
instanceID: boundWebViewInstanceID,
navigationID: navigation.map { ObjectIdentifier($0) }
)
guard isDownload else { return false }
self.isMainFrameProvisionalNavigationActive = false
self.refreshBackgroundAppearance()
return true
}
}
navigationDelegate.didBecomeDownload = { [weak self] webView, isMainFrame, restoreAttemptID in
MainActor.assumeIsolated {
guard isMainFrame, let restoreAttemptID else { return }
guard let self, self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID), restoreAttemptID == self.currentDiscardRestoreAttemptID else { return }
guard isMainFrame,
let self,
self.isCurrentWebView(webView, instanceID: boundWebViewInstanceID) else {
return
}
guard let restoreAttemptID,
restoreAttemptID == self.currentDiscardRestoreAttemptID else {
return
}
// A main-frame download is a terminal outcome with no document commit; never restart it on the next reveal.
self.hasCommittedDocumentSinceWebViewReplacement = true
self.noteDiscardedWebViewRestoreNavigationCommitted(reason: "navigation_download")
@@ -4031,8 +4090,12 @@ final class BrowserPanel: Panel, ObservableObject {
navDelegate.openInNewTab = { [weak self] url in
self?.openLinkInNewTab(url: url)
}
navDelegate.requestNavigation = { [weak self] request, intent in
self?.requestNavigation(request, intent: intent)
navDelegate.requestNavigation = { [weak self] request, intent, onNavigationStarted in
self?.requestNavigation(
request,
intent: intent,
onNavigationStarted: onNavigationStarted
)
}
navDelegate.presentAlert = { [weak self] alert, webView, completion, cancel in
guard let self else {
@@ -4046,6 +4109,8 @@ final class BrowserPanel: Panel, ObservableObject {
navDelegate.handleBlockedInsecureHTTPNavigation = { [weak self] request, intent in
guard let self else { return }
let restoreAttemptID = self.currentDiscardRestoreAttemptID
// This is an action-policy replacement, which exposes no WKNavigation identity.
// Do not transfer an automation ticket based on WebView or URL coincidence.
self.presentInsecureHTTPAlert(
for: request,
intent: intent,
@@ -4893,6 +4958,11 @@ final class BrowserPanel: Panel, ObservableObject {
/// from the fresh web view.
private func detachWebViewObservers() {
webViewObservers.removeAll()
webView.configuration.userContentController.removeScriptMessageHandler(
forName: BrowserSameDocumentNavigationMessageHandler.name,
contentWorld: BrowserSameDocumentNavigationMessageHandler.contentWorld
)
sameDocumentNavigationMessageHandler = nil
resetMediaPlaybackTracking()
setMediaActivity(isUsingMicrophone: false, isUsingCamera: false, reason: "media_capture_changed")
webViewCancellables.removeAll()
@@ -4908,7 +4978,7 @@ final class BrowserPanel: Panel, ObservableObject {
guard let self, self.isCurrentWebView(webView, instanceID: observedWebViewInstanceID) else { return }
guard !self.isMainFrameProvisionalNavigationActive else { return }
self.designModeController.webViewURLDidChange(to: observedURL)
self.currentURL = Self.remoteProxyDisplayURL(for: observedURL)
self.currentURL = Self.remoteProxyDisplayURL(for: observedURL) ?? observedURL
self.refreshBackgroundAppearance()
GlobalSearchCoordinator.shared.captureBrowserPanel(self)
}
@@ -5352,6 +5422,7 @@ final class BrowserPanel: Panel, ObservableObject {
func close() {
cancelHiddenWebViewDiscard()
isClosingWebViewLifecycle = true
automationNavigationCoordinator.invalidate()
automationDocumentReadiness.invalidate()
automationWatchdog.invalidate()
refreshWebViewLifecycleState()
@@ -5739,54 +5810,79 @@ final class BrowserPanel: Panel, ObservableObject {
// MARK: - Navigation
/// Navigate to a URL
func navigate(to url: URL, recordTypedNavigation: Bool = false) {
@discardableResult
func navigate(
to url: URL,
recordTypedNavigation: Bool = false,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) -> WKNavigation? {
let request = URLRequest(url: url)
if shouldBlockInsecureHTTPNavigation(to: url) {
presentInsecureHTTPAlert(for: request, intent: .currentTab, recordTypedNavigation: recordTypedNavigation)
return
presentInsecureHTTPAlert(
for: request,
intent: .currentTab,
recordTypedNavigation: recordTypedNavigation,
onNavigationStarted: onNavigationStarted
)
return nil
}
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
return navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
onNavigationStarted: onNavigationStarted
)
}
@discardableResult
func navigateWithoutInsecureHTTPPrompt(
to url: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false,
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy
) {
cachePolicy: URLRequest.CachePolicy = .useProtocolCachePolicy,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) -> WKNavigation? {
let request = URLRequest(url: url, cachePolicy: cachePolicy)
navigateWithoutInsecureHTTPPrompt(
return navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
preserveRestoredSessionHistory: preserveRestoredSessionHistory,
onNavigationStarted: onNavigationStarted
)
}
private func navigateWithoutInsecureHTTPPrompt(
@discardableResult
func navigateWithoutInsecureHTTPPrompt(
request: URLRequest,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool = false
) {
guard let url = request.url else { return }
preserveRestoredSessionHistory: Bool = false,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) -> WKNavigation? {
guard let url = request.url else {
onNavigationStarted?(nil)
return nil
}
cancelHiddenWebViewDiscard()
if usesRemoteWorkspaceProxy, remoteProxyEndpoint == nil {
pendingRemoteNavigation?.onNavigationStarted?(nil)
pendingRemoteNavigation = PendingRemoteNavigation(
request: request,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
preserveRestoredSessionHistory: preserveRestoredSessionHistory,
onNavigationStarted: onNavigationStarted
)
hiddenWebViewDiscardManager.updateRestoredSessionRenderIntent(nil)
currentURL = Self.remoteProxyDisplayURL(for: url) ?? url
navigationDelegate?.recordAttemptedRequest(request)
refreshBackgroundAppearance()
shouldRenderWebView = true
return
return nil
}
performNavigation(
return performNavigation(
request: request,
originalURL: url,
recordTypedNavigation: recordTypedNavigation,
preserveRestoredSessionHistory: preserveRestoredSessionHistory
preserveRestoredSessionHistory: preserveRestoredSessionHistory,
onNavigationStarted: onNavigationStarted
)
}
@@ -5798,6 +5894,7 @@ final class BrowserPanel: Panel, ObservableObject {
return
}
guard let originalURL = navigation.request.url else {
navigation.onNavigationStarted?(nil)
pendingRemoteNavigation = nil
reevaluateHiddenWebViewDiscardScheduling(reason: "pending_remote_navigation_cleared")
return
@@ -5806,17 +5903,20 @@ final class BrowserPanel: Panel, ObservableObject {
request: navigation.request,
originalURL: originalURL,
recordTypedNavigation: navigation.recordTypedNavigation,
preserveRestoredSessionHistory: navigation.preserveRestoredSessionHistory
preserveRestoredSessionHistory: navigation.preserveRestoredSessionHistory,
onNavigationStarted: navigation.onNavigationStarted
)
pendingRemoteNavigation = nil
}
@discardableResult
private func performNavigation(
request: URLRequest,
originalURL: URL,
recordTypedNavigation: Bool,
preserveRestoredSessionHistory: Bool
) {
preserveRestoredSessionHistory: Bool,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) -> WKNavigation? {
cancelHiddenWebViewDiscard()
clearWebContentTerminationRecovery()
if !preserveRestoredSessionHistory {
@@ -5844,6 +5944,8 @@ final class BrowserPanel: Panel, ObservableObject {
} else if hiddenWebViewDiscardManager.isDiscardedForMemory {
pendingDiscardRestoreNavigation = startedNavigation
}
onNavigationStarted?(startedNavigation)
return startedNavigation
}
private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest {
@@ -5897,18 +5999,28 @@ final class BrowserPanel: Panel, ObservableObject {
/// Navigate with smart URL/search detection
/// - If input looks like a URL, navigate to it
/// - Otherwise, perform a web search
func navigateSmart(_ input: String) {
@discardableResult
func navigateSmart(_ input: String) -> WKNavigation? {
guard let navigation = resolveSmartNavigation(from: input) else { return nil }
return navigate(
to: navigation.url,
recordTypedNavigation: navigation.recordTypedNavigation
)
}
func resolveSmartNavigation(
from input: String
) -> (url: URL, recordTypedNavigation: Bool)? {
let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
guard !trimmed.isEmpty else { return nil }
if let url = resolveNavigableURL(from: trimmed) {
navigate(to: url, recordTypedNavigation: true)
return
return (url, true)
}
let searchConfiguration = BrowserSearchSettingsStore().currentConfiguration
guard let searchURL = searchConfiguration.searchURL(query: trimmed) else { return }
navigate(to: searchURL)
guard let searchURL = searchConfiguration.searchURL(query: trimmed) else { return nil }
return (searchURL, false)
}
func resolveNavigableURL(from input: String) -> URL? {
@@ -5927,16 +6039,33 @@ final class BrowserPanel: Panel, ObservableObject {
browserShouldConsumeOneTimeInsecureHTTPBypass(url, bypassHostOnce: &insecureHTTPBypassHostOnce)
}
private func requestNavigation(_ request: URLRequest, intent: BrowserInsecureHTTPNavigationIntent) {
guard let url = request.url else { return }
private func requestNavigation(
_ request: URLRequest,
intent: BrowserInsecureHTTPNavigationIntent,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) {
guard let url = request.url else {
onNavigationStarted?(nil)
return
}
if shouldBlockInsecureHTTPNavigation(to: url) {
presentInsecureHTTPAlert(for: request, intent: intent, recordTypedNavigation: false)
presentInsecureHTTPAlert(
for: request,
intent: intent,
recordTypedNavigation: false,
onNavigationStarted: onNavigationStarted
)
return
}
switch intent {
case .currentTab:
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: false)
navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: false,
onNavigationStarted: onNavigationStarted
)
case .newTab:
onNavigationStarted?(nil)
openLinkInNewTab(request: request)
}
}
@@ -5945,10 +6074,17 @@ final class BrowserPanel: Panel, ObservableObject {
for request: URLRequest,
intent: BrowserInsecureHTTPNavigationIntent,
recordTypedNavigation: Bool,
onResolution: @escaping (BrowserInsecureHTTPNavigationResolution) -> Void = { _ in }
onResolution: @escaping (BrowserInsecureHTTPNavigationResolution) -> Void = { _ in },
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) {
guard let url = request.url else { return }
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else { return }
guard let url = request.url else {
onNavigationStarted?(nil)
return
}
guard let host = BrowserInsecureHTTPSettings.normalizeHost(url.host ?? "") else {
onNavigationStarted?(nil)
return
}
let alert = insecureHTTPAlertFactory()
alert.alertStyle = .warning
alert.messageText = String(localized: "browser.error.insecure.title", defaultValue: "Connection isn\u{2019}t secure")
@@ -5968,11 +6104,21 @@ final class BrowserPanel: Panel, ObservableObject {
url: url,
intent: intent,
recordTypedNavigation: recordTypedNavigation,
onResolution: onResolution
onResolution: onResolution,
onNavigationStarted: onNavigationStarted
)
}
presentBrowserAlert(alert, in: webView, windowProvider: insecureHTTPAlertWindowProvider, completion: handleResponse, cancel: { onResolution(.cancelled) })
presentBrowserAlert(
alert,
in: webView,
windowProvider: insecureHTTPAlertWindowProvider,
completion: handleResponse,
cancel: {
onNavigationStarted?(nil)
onResolution(.cancelled)
}
)
}
func handleInsecureHTTPAlertResponse(
@@ -5983,7 +6129,8 @@ final class BrowserPanel: Panel, ObservableObject {
url: URL,
intent: BrowserInsecureHTTPNavigationIntent,
recordTypedNavigation: Bool, openExternalURL: (URL) -> Bool = { NSWorkspace.shared.open($0) },
onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void
onResolution: (BrowserInsecureHTTPNavigationResolution) -> Void,
onNavigationStarted: ((WKNavigation?) -> Void)? = nil
) {
if browserShouldPersistInsecureHTTPAllowlistSelection(
response: response,
@@ -5993,19 +6140,29 @@ final class BrowserPanel: Panel, ObservableObject {
}
switch response {
case .alertFirstButtonReturn:
if !openExternalURL(url) { return }
if !openExternalURL(url) {
onNavigationStarted?(nil)
return
}
onNavigationStarted?(nil)
onResolution(.openedExternally)
case .alertSecondButtonReturn:
switch intent {
case .currentTab:
onResolution(.proceededInCurrentTab)
insecureHTTPBypassHostOnce = host
navigateWithoutInsecureHTTPPrompt(request: request, recordTypedNavigation: recordTypedNavigation)
navigateWithoutInsecureHTTPPrompt(
request: request,
recordTypedNavigation: recordTypedNavigation,
onNavigationStarted: onNavigationStarted
)
case .newTab:
onNavigationStarted?(nil)
onResolution(.proceededInNewTab)
openLinkInNewTab(request: request, bypassInsecureHTTPHostOnce: host)
}
default:
onNavigationStarted?(nil)
onResolution(.cancelled)
return
}
@@ -6441,6 +6598,15 @@ extension BrowserPanel {
bypassesRemoteWorkspaceProxy
}
func automationReloadTargetURL() -> URL? {
restorableDisplayURLForCurrentErrorPage(liveURL: webView.url)
?? Self.remoteProxyDisplayURL(for: navigationDelegate?.lastAttemptedURL)
?? navigationDelegate?.lastAttemptedURL
?? resolvedCurrentSessionHistoryURL()
?? currentURL
?? URL(string: "about:blank")
}
private func prepareForReload(reason: String, mode: BrowserPanelReloadMode) -> Bool {
if recoverTerminatedWebContent(reason: reason, cachePolicy: mode.recoveryCachePolicy) {
return true
@@ -6468,11 +6634,12 @@ extension BrowserPanel {
}
/// Reload the current page
func reload() {
@discardableResult
func reload() -> WKNavigation? {
if prepareForReload(reason: "reload", mode: .soft) {
return
return nil
}
webView.reload()
return webView.reload()
}
/// Reload the current page, bypassing WebKit's cache.
@@ -0,0 +1,52 @@
import Foundation
import WebKit
/// Bridges authoritative same-document events from the active main-frame document.
@MainActor
final class BrowserSameDocumentNavigationMessageHandler: NSObject, WKScriptMessageHandler {
static let name = "cmuxSameDocumentNavigation"
static let contentWorld = WKContentWorld.world(name: "cmux.browser.same-document-navigation")
static let userScript = WKUserScript(
source: """
(() => {
const reportNavigation = (event) => {
// The isolated content world hides the handler from page script;
// isTrusted also rejects synthetic events dispatched by the page.
if (!event.isTrusted) return;
window.webkit.messageHandlers['\(name)'].postMessage(window.location.href);
};
window.addEventListener('hashchange', reportNavigation, true);
window.addEventListener('popstate', reportNavigation, true);
})();
""",
injectionTime: .atDocumentStart,
forMainFrameOnly: true,
in: contentWorld
)
private weak var webView: WKWebView?
private let onNavigation: @MainActor (URL) -> Void
init(
webView: WKWebView,
onNavigation: @escaping @MainActor (URL) -> Void
) {
self.webView = webView
self.onNavigation = onNavigation
}
func userContentController(
_ userContentController: WKUserContentController,
didReceive message: WKScriptMessage
) {
guard message.name == Self.name,
message.frameInfo.isMainFrame,
message.webView === webView,
let value = message.body as? String,
let url = URL(string: value) else {
return
}
onNavigation(url)
}
}
+19 -7
View File
@@ -1,4 +1,5 @@
import AppKit
import CmuxBrowser
import Foundation
import WebKit
@@ -275,17 +276,28 @@ extension BrowserPanel {
(webView.url ?? currentURL)?.absoluteString == expectedURL
}
@discardableResult
func navigateFromCLI(_ url: String, expectedURL: String? = nil) -> Bool {
guard expectedURL.map(hasCurrentURL) != false else { return false }
func beginAutomationNavigationFromCLI(
_ url: String,
expectedURL: String? = nil
) -> (ticket: BrowserAutomationNavigationTicket, targetURL: URL)? {
guard expectedURL.map(hasCurrentURL) != false else { return nil }
let targetURL: URL
if let internalURL = URL(string: url),
internalURL.scheme == CmuxDiffViewerURLSchemeHandler.scheme {
guard CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: internalURL) else { return false }
navigate(to: internalURL)
guard CmuxDiffViewerURLSchemeHandler.shared.allowsNavigation(to: internalURL) else { return nil }
targetURL = internalURL
} else {
navigateSmart(url)
guard let resolvedNavigation = resolveSmartNavigation(from: url) else { return nil }
targetURL = resolvedNavigation.url
return (
beginAutomationNavigation(
to: targetURL,
recordTypedNavigation: resolvedNavigation.recordTypedNavigation
),
targetURL
)
}
return true
return (beginAutomationNavigation(to: targetURL, recordTypedNavigation: false), targetURL)
}
}
@@ -3,6 +3,78 @@ import Foundation
import WebKit
extension TerminalController {
nonisolated func v2AwaitBrowserAutomationNavigation(
_ ticket: BrowserAutomationNavigationTicket,
browserPanel: BrowserPanel
) -> BrowserAutomationNavigationOutcome? {
var navigationTask: Task<Void, Never>?
let outcome: BrowserAutomationNavigationOutcome? = socketAwaitCallback(timeout: 17.5) { finish in
navigationTask = Task { @MainActor in
finish(await browserPanel.finishAutomationNavigation(ticket))
}
}
if outcome == nil {
navigationTask?.cancel()
}
return outcome
}
nonisolated func v2BrowserNavigationFailureResult(
_ outcome: BrowserAutomationNavigationOutcome?,
targetURL: URL
) -> V2CallResult? {
let data: [String: Any] = ["url": targetURL.absoluteString]
switch outcome {
case .committed, .downloaded:
return nil
case .failed:
return .err(
code: "navigation_failed",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: data
)
case .cancelled:
return .err(
code: "navigation_cancelled",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: data
)
case .superseded:
return .err(
code: "stale_state",
message: String(
localized: "browser.automation.error.superseded",
defaultValue: "The browser surface was already recovered. Retry the command."
),
data: data
)
case .notStarted:
return .err(
code: "navigation_failed",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: data
)
case .timedOut, nil:
return .err(
code: "navigation_timeout",
message: String(
localized: "browser.automation.error.documentReadinessTimedOut",
defaultValue: "Timed out waiting for the browser document to become ready"
),
data: data
)
}
}
nonisolated func v2CaptureBrowserAutomationSnapshot(
_ browserPanel: BrowserPanel,
timeout: TimeInterval
+139 -4
View File
@@ -6626,16 +6626,33 @@ class TerminalController {
private nonisolated func v2BrowserNavigate(params: [String: Any]) -> V2CallResult {
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(code: "unavailable", message: "TabManager not available", data: nil)
return .err(
code: "unavailable",
message: String(
localized: "cli.browser.error.tabManagerUnavailable",
defaultValue: "Browser controls are unavailable"
),
data: nil
)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(code: "invalid_params", message: "Missing or invalid surface_id", data: nil)
return .err(
code: "invalid_params",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: nil
)
}
guard let url = v2String(params, "url") else {
return .err(code: "invalid_params", message: "Missing url", data: nil)
}
var basePayload: [String: Any]?
var resolutionError: V2CallResult?
var navigationPanel: BrowserPanel?
var navigationTicket: BrowserAutomationNavigationTicket?
var navigationTargetURL: URL?
v2MainSync {
let resolvedContext = v2ResolveBrowserPanelContext(params: params, tabManager: tabManager)
if let error = resolvedContext.error {
@@ -6644,7 +6661,20 @@ class TerminalController {
}
guard let context = resolvedContext.context,
context.surfaceId == surfaceId else { return }
if !context.browserPanel.navigateFromCLI(url, expectedURL: v2String(params, "expected_url")) { resolutionError = .err(code: "stale_state", message: "Browser URL changed before navigation", data: nil); return }
guard let navigation = context.browserPanel.beginAutomationNavigationFromCLI(
url,
expectedURL: v2String(params, "expected_url")
) else {
resolutionError = .err(
code: "stale_state",
message: "Browser URL changed before navigation",
data: nil
)
return
}
navigationPanel = context.browserPanel
navigationTicket = navigation.ticket
navigationTargetURL = navigation.targetURL
if AppDelegate.shared?.tabManagerForWindowDockOwner(context.workspaceId) != nil {
basePayload = v2WindowDockBrowserActionPayload(context)
} else {
@@ -6663,6 +6693,26 @@ class TerminalController {
guard var payload = basePayload else {
return .err(code: "not_found", message: "Surface not found or not a browser", data: ["surface_id": surfaceId.uuidString])
}
guard let navigationPanel, let navigationTicket, let navigationTargetURL else {
return .err(
code: "internal_error",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: nil
)
}
let navigationOutcome = v2AwaitBrowserAutomationNavigation(
navigationTicket,
browserPanel: navigationPanel
)
if let failure = v2BrowserNavigationFailureResult(
navigationOutcome,
targetURL: navigationTargetURL
) {
return failure
}
// Run the optional --snapshot-after walk on the worker thread (not inside
// v2MainSync) so a slow accessibility-tree snapshot on a fresh surface
// can't block SwiftUI and recreate mount deadlocks. Standalone
@@ -6680,7 +6730,92 @@ class TerminalController {
}
private nonisolated func v2BrowserReload(params: [String: Any]) -> V2CallResult {
return v2BrowserNavSimple(params: params, action: "reload")
guard let tabManager = v2ResolveTabManager(params: params) else {
return .err(
code: "unavailable",
message: String(
localized: "cli.browser.error.tabManagerUnavailable",
defaultValue: "Browser controls are unavailable"
),
data: nil
)
}
guard let surfaceId = v2UUID(params, "surface_id") else {
return .err(
code: "invalid_params",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: nil
)
}
var setupError: V2CallResult?
var basePayload: [String: Any]?
var navigationPanel: BrowserPanel?
var navigationTicket: BrowserAutomationNavigationTicket?
var navigationTargetURL: URL?
v2MainSync {
let resolvedContext = v2ResolveBrowserPanelContext(params: params, tabManager: tabManager)
if let error = resolvedContext.error {
setupError = error
return
}
guard let context = resolvedContext.context,
context.surfaceId == surfaceId else { return }
guard let navigation = context.browserPanel.beginAutomationReloadFromCLI() else {
setupError = .err(
code: "internal_error",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: nil
)
return
}
navigationPanel = context.browserPanel
navigationTicket = navigation.ticket
navigationTargetURL = navigation.targetURL
if AppDelegate.shared?.tabManagerForWindowDockOwner(context.workspaceId) != nil {
basePayload = v2WindowDockBrowserActionPayload(context)
} else {
basePayload = v2BrowserActionPayload(
workspaceId: context.workspaceId,
surfaceId: context.surfaceId,
tabManager: tabManager
)
}
}
if let setupError {
return setupError
}
guard var payload = basePayload,
let navigationPanel,
let navigationTicket,
let navigationTargetURL else {
return .err(
code: "not_found",
message: String(
localized: "cli.browser.error.operationFailed",
defaultValue: "Browser operation failed"
),
data: ["surface_id": surfaceId.uuidString]
)
}
let navigationOutcome = v2AwaitBrowserAutomationNavigation(
navigationTicket,
browserPanel: navigationPanel
)
if let failure = v2BrowserNavigationFailureResult(
navigationOutcome,
targetURL: navigationTargetURL
) {
return failure
}
v2BrowserAppendPostSnapshot(params: params, surfaceId: surfaceId, payload: &payload)
return .ok(payload)
}
private nonisolated func v2BrowserNotFoundDiagnostics(
+12
View File
@@ -258,6 +258,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
A5008381 /* BrowserFindJavaScriptTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008380 /* BrowserFindJavaScriptTests.swift */; };
A5008373 /* BrowserFindWebViewEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindWebViewEvaluator.swift */; };
7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */; };
8478B0000000000000000002 /* BrowserFixtureSocketTestCase+PendingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8478B0000000000000000001 /* BrowserFixtureSocketTestCase+PendingRequest.swift */; };
A28B087F0000000000000013 /* BrowserFocusModeKeyDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000012 /* BrowserFocusModeKeyDecision.swift */; };
B42450030000000000000001 /* BrowserHiddenWebViewDiscardManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */; };
B6585001B6585001B6585001 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */; };
@@ -336,8 +337,10 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources
B424PWRS000000000000PW01 /* BrowserPortalWebViewRenderingState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424PWRS000000000000PW02 /* BrowserPortalWebViewRenderingState.swift */; };
B4245003000000000000PW01 /* BrowserPrewarmedWebViewPool.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4245004000000000000PW01 /* BrowserPrewarmedWebViewPool.swift */; };
B6585001B6585001B658PW01 /* BrowserPrewarmedWebViewPoolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */; };
8478A0000000000000000002 /* BrowserRecoveryHTTPServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8478A0000000000000000001 /* BrowserRecoveryHTTPServer.swift */; };
7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */; };
BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */; };
8478A0010000000000000001 /* BrowserSameDocumentNavigationMessageHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8478A0010000000000000002 /* BrowserSameDocumentNavigationMessageHandler.swift */; };
4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0014472B0014472B001 /* BrowserScreenshot.swift */; };
4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */; };
4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */; };
@@ -2448,6 +2451,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
A5008380 /* BrowserFindJavaScriptTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFindJavaScriptTests.swift; sourceTree = "<group>"; };
A5008372 /* BrowserFindWebViewEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindWebViewEvaluator.swift; sourceTree = "<group>"; };
7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFixtureInteractionUITests.swift; sourceTree = "<group>"; };
8478B0000000000000000001 /* BrowserFixtureSocketTestCase+PendingRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserFixtureSocketTestCase+PendingRequest.swift"; sourceTree = "<group>"; };
A28B087F0000000000000012 /* BrowserFocusModeKeyDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserFocusModeKeyDecision.swift; sourceTree = "<group>"; };
B42450040000000000000001 /* BrowserHiddenWebViewDiscardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserHiddenWebViewDiscardManager.swift; sourceTree = "<group>"; };
B6585002B6585002B6585002 /* BrowserHiddenWebViewDiscardMemoryPressureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserHiddenWebViewDiscardMemoryPressureTests.swift; sourceTree = "<group>"; };
@@ -2526,8 +2530,10 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
B424PWRS000000000000PW02 /* BrowserPortalWebViewRenderingState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPortalWebViewRenderingState.swift; sourceTree = "<group>"; };
B4245004000000000000PW01 /* BrowserPrewarmedWebViewPool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPrewarmedWebViewPool.swift; sourceTree = "<group>"; };
B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPrewarmedWebViewPoolTests.swift; sourceTree = "<group>"; };
8478A0000000000000000001 /* BrowserRecoveryHTTPServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserRecoveryHTTPServer.swift; sourceTree = "<group>"; };
7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserReliabilityRegressionUITests.swift; sourceTree = "<group>"; };
109D0E38E29A8779FA0BAE82 /* BrowserRemoteWorkspaceStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserRemoteWorkspaceStatus.swift; sourceTree = "<group>"; };
8478A0010000000000000002 /* BrowserSameDocumentNavigationMessageHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserSameDocumentNavigationMessageHandler.swift; sourceTree = "<group>"; };
4472B0014472B0014472B001 /* BrowserScreenshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshot.swift; sourceTree = "<group>"; };
4472B0024472B0024472B002 /* BrowserScreenshotPipeline.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotPipeline.swift; sourceTree = "<group>"; };
4472B0034472B0034472B003 /* BrowserScreenshotSnapshotter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserScreenshotSnapshotter.swift; sourceTree = "<group>"; };
@@ -4409,7 +4415,9 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
D0E0F0B3A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift */,
FB100001A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift */,
7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */,
8478B0000000000000000001 /* BrowserFixtureSocketTestCase+PendingRequest.swift */,
7B5F1A2E9C0D4B6A8E217303 /* BrowserReliabilityRegressionUITests.swift */,
8478A0000000000000000001 /* BrowserRecoveryHTTPServer.swift */,
C0B4D9B1A1B2C3D4E5F60718 /* UpdatePillUITests.swift */,
D10000000000000000A00011 /* SettingsUITestSupport.swift */,
D10000000000000000B00001 /* ControlSocketReadinessUITestSupport.swift */,
@@ -5393,6 +5401,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
C2035A0C000000000000002 /* BrowserErrorPageRetry.swift */,
C2035A030000000000000002 /* BrowserSSLTrustBypassState.swift */,
C2035A0B000000000000002 /* BrowserSSLTrustBypassMessageHandler.swift */,
8478A0010000000000000002 /* BrowserSameDocumentNavigationMessageHandler.swift */,
C2035A080000000000000002 /* BrowserSSLTrustGrant.swift */,
C2035A040000000000000002 /* BrowserSSLTrustScope.swift */,
C2035A050000000000000002 /* BrowserServerTrustFingerprint.swift */,
@@ -7186,6 +7195,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
B424PWRS000000000000PW01 /* BrowserPortalWebViewRenderingState.swift in Sources */,
B4245003000000000000PW01 /* BrowserPrewarmedWebViewPool.swift in Sources */,
BFBAC1A77CEE7A2DF91DA02C /* BrowserRemoteWorkspaceStatus.swift in Sources */,
8478A0010000000000000001 /* BrowserSameDocumentNavigationMessageHandler.swift in Sources */,
4472A0014472A0014472A001 /* BrowserScreenshot.swift in Sources */,
4472A0024472A0024472A002 /* BrowserScreenshotPipeline.swift in Sources */,
4472A0034472A0034472A003 /* BrowserScreenshotSnapshotter.swift in Sources */,
@@ -8393,9 +8403,11 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa =
B9000012A1B2C3D4E5F60719 /* AutomationSocketUITests.swift in Sources */,
AA1B2C3D4E5F60718 /* BonsplitTabDragUITests.swift in Sources */,
7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */,
8478B0000000000000000002 /* BrowserFixtureSocketTestCase+PendingRequest.swift in Sources */,
FB100000A1B2C3D4E5F60718 /* BrowserImportProfilesUITests.swift in Sources */,
D0E0F0B2A1B2C3D4E5F60718 /* BrowserOmnibarSuggestionsUITests.swift in Sources */,
D0E0F0B0A1B2C3D4E5F60718 /* BrowserPaneNavigationKeybindUITests.swift in Sources */,
8478A0000000000000000002 /* BrowserRecoveryHTTPServer.swift in Sources */,
7B5F1A2E9C0D4B6A8E217304 /* BrowserReliabilityRegressionUITests.swift in Sources */,
B9000025A1B2C3D4E5F60719 /* CloseWindowConfirmDialogUITests.swift in Sources */,
B9000023A1B2C3D4E5F60719 /* CloseWorkspaceCmdDUITests.swift in Sources */,
@@ -0,0 +1,120 @@
import Darwin
import Foundation
extension BrowserFixtureSocketTestCase {
func beginPendingSocketRequest(
method: String,
params: [String: Any],
responseTimeout: TimeInterval = 15
) throws -> Int32 {
let request: [String: Any] = [
"id": UUID().uuidString,
"method": method,
"params": params,
]
guard JSONSerialization.isValidJSONObject(request) else {
throw POSIXError(.EINVAL)
}
let requestData = try JSONSerialization.data(withJSONObject: request) + Data([0x0A])
let descriptor = socket(AF_UNIX, SOCK_STREAM, 0)
guard descriptor >= 0 else { throw POSIXError(.EIO) }
do {
try configurePendingSocket(descriptor, responseTimeout: responseTimeout)
try connectPendingSocket(descriptor)
try writePendingSocketRequest(requestData, to: descriptor)
return descriptor
} catch {
Darwin.close(descriptor)
throw error
}
}
func pendingSocketResponseIsReady(_ descriptor: Int32) -> Bool {
var readiness = pollfd(fd: descriptor, events: Int16(POLLIN), revents: 0)
return Darwin.poll(&readiness, 1, 0) > 0
}
func finishPendingSocketRequest(_ descriptor: Int32) -> [String: Any]? {
var bytes = [UInt8](repeating: 0, count: 4096)
var response = Data()
while true {
let count = Darwin.read(descriptor, &bytes, bytes.count)
guard count > 0 else { return nil }
response.append(contentsOf: bytes[..<count])
guard let newline = response.firstIndex(of: 0x0A) else { continue }
let line = response[..<newline]
return (try? JSONSerialization.jsonObject(with: line)) as? [String: Any]
}
}
func closePendingSocketRequest(_ descriptor: Int32) {
Darwin.close(descriptor)
}
private func configurePendingSocket(
_ descriptor: Int32,
responseTimeout: TimeInterval
) throws {
var timeout = timeval(
tv_sec: Int(responseTimeout),
tv_usec: Int32((responseTimeout - floor(responseTimeout)) * 1_000_000)
)
let result = withUnsafePointer(to: &timeout) { pointer in
setsockopt(
descriptor,
SOL_SOCKET,
SO_RCVTIMEO,
pointer,
socklen_t(MemoryLayout<timeval>.size)
)
}
guard result == 0 else { throw posixError() }
}
private func connectPendingSocket(_ descriptor: Int32) throws {
var address = sockaddr_un()
memset(&address, 0, MemoryLayout<sockaddr_un>.size)
address.sun_family = sa_family_t(AF_UNIX)
let pathBytes = Array(socketPath.utf8CString)
guard pathBytes.count <= MemoryLayout.size(ofValue: address.sun_path) else {
throw POSIXError(.ENAMETOOLONG)
}
withUnsafeMutablePointer(to: &address.sun_path) { pointer in
let raw = UnsafeMutableRawPointer(pointer).assumingMemoryBound(to: CChar.self)
for index in pathBytes.indices {
raw[index] = pathBytes[index]
}
}
let pathOffset = MemoryLayout<sockaddr_un>.offset(of: \.sun_path) ?? 0
let addressLength = socklen_t(pathOffset + pathBytes.count)
let result = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
Darwin.connect(descriptor, socketAddress, addressLength)
}
}
guard result == 0 else { throw posixError() }
}
private func writePendingSocketRequest(_ data: Data, to descriptor: Int32) throws {
try data.withUnsafeBytes { rawBuffer in
guard let baseAddress = rawBuffer.baseAddress else { return }
var written = 0
while written < rawBuffer.count {
let count = Darwin.write(
descriptor,
baseAddress.advanced(by: written),
rawBuffer.count - written
)
guard count > 0 else { throw posixError() }
written += count
}
}
}
private func posixError() -> POSIXError {
POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
}
+153
View File
@@ -0,0 +1,153 @@
import Darwin
import Foundation
final class BrowserRecoveryHTTPServer {
let port: UInt16
private let inputPipe = Pipe()
private let outputPipe = Pipe()
private var outputBuffer = Data()
private var process: Process?
private var hasHeldRequest = false
init() throws {
self.port = try Self.availablePort()
}
deinit {
stop()
}
func start() throws {
guard process == nil else { return }
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/python3")
process.arguments = [
"-u",
"-c",
Self.serverScript,
String(port),
]
process.standardInput = inputPipe
process.standardOutput = outputPipe
process.standardError = Pipe()
try process.run()
self.process = process
guard try nextSignal(timeoutMilliseconds: 5_000) == "READY" else {
throw ServerError.unexpectedSignal
}
}
func waitForRequest() throws {
guard try nextSignal(timeoutMilliseconds: 15_000) == "REQUEST" else {
throw ServerError.unexpectedSignal
}
hasHeldRequest = true
}
func releaseResponse() throws {
guard hasHeldRequest else { return }
hasHeldRequest = false
try inputPipe.fileHandleForWriting.write(contentsOf: Data("RELEASE\n".utf8))
}
func stop() {
guard let process else { return }
self.process = nil
try? releaseResponse()
if process.isRunning {
process.terminate()
}
}
private func nextSignal(timeoutMilliseconds: Int32) throws -> String {
while true {
if let newline = outputBuffer.firstIndex(of: 0x0A) {
let line = outputBuffer[..<newline]
outputBuffer.removeSubrange(...newline)
return String(decoding: line, as: UTF8.self)
}
var readiness = pollfd(
fd: outputPipe.fileHandleForReading.fileDescriptor,
events: Int16(POLLIN),
revents: 0
)
guard Darwin.poll(&readiness, 1, timeoutMilliseconds) > 0 else {
throw ServerError.signalTimedOut
}
var bytes = [UInt8](repeating: 0, count: 128)
let count = Darwin.read(readiness.fd, &bytes, bytes.count)
guard count > 0 else {
throw ServerError.signalStreamClosed
}
outputBuffer.append(contentsOf: bytes[..<count])
}
}
private static func availablePort() throws -> UInt16 {
let descriptor = socket(AF_INET, SOCK_STREAM, 0)
guard descriptor >= 0 else { throw ServerError.couldNotReservePort }
defer { close(descriptor) }
var address = sockaddr_in()
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
address.sin_family = sa_family_t(AF_INET)
address.sin_port = 0
address.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let didBind = withUnsafePointer(to: &address) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
Darwin.bind(descriptor, socketAddress, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
guard didBind == 0 else { throw ServerError.couldNotReservePort }
var resolvedAddress = sockaddr_in()
var resolvedLength = socklen_t(MemoryLayout<sockaddr_in>.size)
let didResolve = withUnsafeMutablePointer(to: &resolvedAddress) { pointer in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { socketAddress in
getsockname(descriptor, socketAddress, &resolvedLength)
}
}
guard didResolve == 0 else { throw ServerError.couldNotReservePort }
return UInt16(bigEndian: resolvedAddress.sin_port)
}
private enum ServerError: Error {
case couldNotReservePort
case signalStreamClosed
case signalTimedOut
case unexpectedSignal
}
private static let serverScript = #"""
import sys
from http.server import BaseHTTPRequestHandler, HTTPServer
port = int(sys.argv[1])
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
print('REQUEST', flush=True)
if sys.stdin.readline().strip() != 'RELEASE':
self.send_error(500)
return
body = b'<!doctype html><body data-cmux-recovered="true">recovered</body>'
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass
server = HTTPServer(('127.0.0.1', port), Handler)
print('READY', flush=True)
server.serve_forever()
"""#
}
@@ -7,6 +7,96 @@ import Foundation
/// (defined in BrowserFixtureInteractionUITests.swift).
final class BrowserReliabilityRegressionUITests: BrowserFixtureSocketTestCase {
/// Regression: browser.navigate used to acknowledge only that WKWebView.load
/// was called. After a connection-refused error page, a slow recovered origin
/// therefore returned `ok` while the old error-page DOM was still active.
/// Success must mean that the requested document actually committed.
func testGotoWaitsForRecoveredDocumentCommitAfterConnectionRefusal() throws {
try launchApp()
let sid = try openBrowserSurface()
let server = try BrowserRecoveryHTTPServer()
let failedURL = "http://127.0.0.1:\(server.port)/unavailable"
let recoveredURL = "http://127.0.0.1:\(server.port)/recovered"
XCTAssertNotNil(
socketEnvelope(
method: "browser.navigate",
params: ["surface_id": sid, "url": failedURL],
responseTimeout: 15
),
"Expected the refused navigation to reach a terminal response"
)
try socketResult(
method: "browser.wait",
params: [
"surface_id": sid,
"text": "refused to connect",
"timeout_ms": 10_000,
],
responseTimeout: 15
)
try server.start()
defer { server.stop() }
let pendingNavigation = try beginPendingSocketRequest(
method: "browser.navigate",
params: ["surface_id": sid, "url": recoveredURL],
responseTimeout: 15
)
defer { closePendingSocketRequest(pendingNavigation) }
try server.waitForRequest()
let returnedBeforeResponseRelease = pendingSocketResponseIsReady(pendingNavigation)
try server.releaseResponse()
let navigationEnvelope = try XCTUnwrap(
finishPendingSocketRequest(pendingNavigation),
"Expected browser.navigate to return after the recovered response was released"
)
XCTAssertEqual(
navigationEnvelope["ok"] as? Bool,
true,
"browser.navigate failed after the recovered response was released: \(navigationEnvelope)"
)
XCTAssertFalse(
returnedBeforeResponseRelease,
"browser.navigate returned before the recovered response could commit"
)
XCTAssertEqual(
try evalString(
"document.body.dataset.cmuxRecovered || ''",
surfaceID: sid
),
"true",
"browser.navigate returned success before the recovered document committed"
)
let sameDocumentURL = recoveredURL + "#verified"
let sameDocumentEnvelope = try XCTUnwrap(
socketEnvelope(
method: "browser.navigate",
params: ["surface_id": sid, "url": sameDocumentURL],
responseTimeout: 15
),
"Expected a terminal response for the same-document navigation"
)
XCTAssertEqual(
sameDocumentEnvelope["ok"] as? Bool,
true,
"same-document browser.navigate failed: \(sameDocumentEnvelope)"
)
XCTAssertEqual(
try evalString("window.location.hash", surfaceID: sid),
"#verified",
"same-document browser.navigate returned before the trusted document event"
)
XCTAssertEqual(
try evalString("document.body.dataset.cmuxRecovered || ''", surfaceID: sid),
"true",
"the fragment navigation unexpectedly replaced the recovered document"
)
}
/// Regression: a WKWebView that has never committed a navigation has no
/// JavaScript context, so browser.wait used to hang for its full timeout
/// (or fail) on a URL-less browser.open_split surface. The surface must