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:
co-authored by
cmux reload-cloud
parent
950e60bc97
commit
f43c6ccbec
+9
-5
@@ -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
|
||||
}
|
||||
|
||||
+301
@@ -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)
|
||||
}
|
||||
}
|
||||
+23
@@ -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
|
||||
}
|
||||
+30
@@ -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)
|
||||
}
|
||||
}
|
||||
+37
@@ -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
|
||||
}
|
||||
}
|
||||
+90
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+462
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user