From fc602cdaabf1997d35aa05e44e19e195d098ca1a Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 14 Jul 2026 23:20:39 -0700 Subject: [PATCH 01/39] Add configurable browser engine selection --- Packages/macOS/CmuxBrowser/Package.swift | 2 + .../Engine/BrowserApplication.swift | 25 + .../Engine/BrowserApplicationProviding.swift | 9 + .../Engine/BrowserEngineResolver.swift | 67 + .../Engine/BrowserEngineSelection.swift | 23 + .../BrowserEngineSelectionService.swift | 51 + .../Engine/BrowserEngineSession.swift | 77 + .../Engine/BrowserEngineSessionError.swift | 50 + .../Engine/BrowserEngineState.swift | 47 + .../Engine/BrowserJavaScriptValue.swift | 72 + .../Engine/BrowserJavaScriptWorld.swift | 8 + .../BrowserChromiumProfileDirectory.swift | 28 + .../Engine/Chromium/CDPConnection.swift | 121 ++ .../Engine/Chromium/CDPEvent.swift | 8 + .../Engine/Chromium/CDPJSONValue.swift | 100 + ...hromiumBrowserEngineSession+Viewport.swift | 83 + .../ChromiumBrowserEngineSession.swift | 482 +++++ .../Chromium/ChromiumProcessController.swift | 117 ++ .../Chromium/ChromiumViewportDocument.swift | 110 ++ .../ChromiumViewportDocumentJSONLiteral.swift | 12 + .../ChromiumViewportMessageHandler.swift | 13 + ...chServicesBrowserApplicationProvider.swift | 55 + .../Engine/WebKitBrowserEngineSession.swift | 151 ++ .../ClosedBrowserPanelRestoreSnapshot.swift | 6 + .../BrowserApplicationProviderFake.swift | 15 + .../Engine/BrowserEngineResolverTests.swift | 57 + .../BrowserEngineSelectionServiceTests.swift | 47 + .../Engine/BrowserJavaScriptValueTests.swift | 30 + .../CmuxCore/Browser/BrowserEngineKind.swift | 10 + .../Browser/BrowserEnginePreference.swift | 13 + Packages/macOS/CmuxSettings/Package.swift | 2 + ...owserEnginePreference+SettingCodable.swift | 3 + .../Keys/BrowserCatalogSection.swift | 8 + Packages/macOS/CmuxSettingsUI/Package.swift | 2 + .../Sections/BrowserEngineSettingRow.swift | 45 + .../Sections/BrowserSection.swift | 8 +- Resources/Localizable.xcstrings | 1750 +++++++++++++++++ Sources/CmuxSettingsJSONPathSupport.swift | 2 + Sources/DockSplitStore.swift | 4 + .../Find/BrowserFindWebViewEvaluator.swift | 4 +- ...rdShortcutSettingsFileStore+Template.swift | 2 + .../KeyboardShortcutSettingsFileStore.swift | 8 + .../BrowserEngineSelection+Current.swift | 21 + .../BrowserPanel+AutomationRecovery.swift | 75 +- Sources/Panels/BrowserPanel.swift | 334 +++- Sources/SessionPersistence.swift | 5 + Sources/TabManager.swift | 9 +- ...Controller+BrowserAutomationRecovery.swift | 17 +- ...lController+WindowDockBrowserRouting.swift | 3 +- Sources/TerminalController.swift | 243 ++- Sources/Workspace+DockBrowserLookup.swift | 6 +- Sources/Workspace.swift | 21 +- cmux.xcodeproj/project.pbxproj | 4 + skills/cmux-settings/SKILL.md | 2 +- skills/cmux-settings/references/all-keys.md | 1 + web/data/cmux.schema.json | 7 + web/messages/ar.json | 3 +- web/messages/bs.json | 3 +- web/messages/da.json | 3 +- web/messages/de.json | 3 +- web/messages/en.json | 1 + web/messages/es.json | 3 +- web/messages/fr.json | 3 +- web/messages/it.json | 3 +- web/messages/ja.json | 1 + web/messages/km.json | 3 +- web/messages/ko.json | 3 +- web/messages/no.json | 3 +- web/messages/pl.json | 3 +- web/messages/pt-BR.json | 3 +- web/messages/ru.json | 3 +- web/messages/th.json | 3 +- web/messages/tr.json | 3 +- web/messages/uk.json | 3 +- web/messages/zh-CN.json | 3 +- web/messages/zh-TW.json | 3 +- 76 files changed, 4317 insertions(+), 214 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplication.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplicationProviding.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelection.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelectionService.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptWorld.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEvent.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPJSONValue.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocumentJSONLiteral.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportMessageHandler.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/LaunchServicesBrowserApplicationProvider.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserApplicationProviderFake.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineSelectionServiceTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserJavaScriptValueTests.swift create mode 100644 Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEngineKind.swift create mode 100644 Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEnginePreference.swift create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/BrowserEnginePreference+SettingCodable.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserEngineSettingRow.swift create mode 100644 Sources/Panels/BrowserEngineSelection+Current.swift diff --git a/Packages/macOS/CmuxBrowser/Package.swift b/Packages/macOS/CmuxBrowser/Package.swift index 93d9fe45987..2ffe789d8c2 100644 --- a/Packages/macOS/CmuxBrowser/Package.swift +++ b/Packages/macOS/CmuxBrowser/Package.swift @@ -14,12 +14,14 @@ let package = Package( ), ], dependencies: [ + .package(path: "../CmuxCore"), .package(path: "../../../vendor/bonsplit"), ], targets: [ .target( name: "CmuxBrowser", dependencies: [ + .product(name: "CmuxCore", package: "CmuxCore"), .product(name: "Bonsplit", package: "bonsplit"), ], swiftSettings: [ diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplication.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplication.swift new file mode 100644 index 00000000000..40e70831b6c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplication.swift @@ -0,0 +1,25 @@ +public import Foundation + +/// An installed browser application that can host a Chromium engine process. +public struct BrowserApplication: Equatable, Sendable { + /// The application's bundle identifier. + public let bundleIdentifier: String + + /// The URL of the application bundle. + public let bundleURL: URL + + /// The executable launched for Chrome DevTools Protocol control. + public let executableURL: URL + + /// Creates an installed-browser description. + /// + /// - Parameters: + /// - bundleIdentifier: The application's bundle identifier. + /// - bundleURL: The application bundle URL. + /// - executableURL: The executable inside the application bundle. + public init(bundleIdentifier: String, bundleURL: URL, executableURL: URL) { + self.bundleIdentifier = bundleIdentifier + self.bundleURL = bundleURL + self.executableURL = executableURL + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplicationProviding.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplicationProviding.swift new file mode 100644 index 00000000000..2cb9bba3046 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserApplicationProviding.swift @@ -0,0 +1,9 @@ +/// Supplies LaunchServices browser-handler and installed-application information. +@MainActor +public protocol BrowserApplicationProviding: AnyObject { + /// Returns the applications LaunchServices selects for representative HTTPS and HTTP URLs. + func defaultBrowserApplications() -> [BrowserApplication] + + /// Returns installed Chromium-family applications that cmux knows how to launch. + func installedChromiumApplications() -> [BrowserApplication] +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift new file mode 100644 index 00000000000..9e5c020eca2 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift @@ -0,0 +1,67 @@ +public import CmuxCore +import Foundation + +/// Resolves a browser-engine preference against LaunchServices handler identities. +public struct BrowserEngineResolver: Sendable { + /// Creates an engine resolver. + public init() {} + + /// Resolves the effective engine. + /// + /// Handler identifiers should be ordered by preference, normally HTTPS then + /// HTTP. Explicit preferences do not inspect the handlers. Automatic mode + /// selects Chromium only for a known Chromium-family bundle identifier; + /// Safari, Firefox-family handlers, and unknown applications use WebKit. + /// + /// - Parameters: + /// - preference: The stored user preference. + /// - defaultHandlerBundleIdentifiers: Bundle identifiers returned by + /// LaunchServices for representative HTTPS and HTTP URLs. + /// - Returns: The engine to use for a new browser surface. + public func resolve( + preference: BrowserEnginePreference, + defaultHandlerBundleIdentifiers: [String] + ) -> BrowserEngineKind { + switch preference { + case .webKit: + return .webKit + case .chromium: + return .chromium + case .automatic: + return defaultHandlerBundleIdentifiers.contains(where: isChromiumFamilyBundleIdentifier) + ? .chromium + : .webKit + } + } + + /// Returns whether a LaunchServices handler identity belongs to a supported + /// Chromium-family application. + /// + /// - Parameter bundleIdentifier: An application bundle identifier. + /// - Returns: `true` for Chromium-family applications cmux can drive. + public func isChromiumFamilyBundleIdentifier(_ bundleIdentifier: String) -> Bool { + let normalized = bundleIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !normalized.isEmpty else { return false } + + if BrowserImportBrowserDescriptor.allBrowserDescriptors.contains(where: { descriptor in + descriptor.family == .chromium && descriptor.bundleIdentifiers.contains { + $0.caseInsensitiveCompare(normalized) == .orderedSame + } + }) { + return true + } + + return Self.chromiumBundleIdentifierPrefixes.contains { normalized.hasPrefix($0) } + } + + private static let chromiumBundleIdentifierPrefixes = [ + "com.google.chrome", + "com.brave.browser", + "com.microsoft.edge", + "com.microsoft.edgemac", + "com.operasoftware.", + "com.vivaldi.vivaldi", + "company.thebrowser.", + "org.chromium.", + ] +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelection.swift new file mode 100644 index 00000000000..e1a34a84542 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelection.swift @@ -0,0 +1,23 @@ +public import CmuxCore + +/// The resolved engine and, when needed, the Chromium application that implements it. +public struct BrowserEngineSelection: Equatable, Sendable { + /// The effective engine for the browser surface. + public let kind: BrowserEngineKind + + /// The application cmux launches for a Chromium surface. + public let chromiumApplication: BrowserApplication? + + /// Creates a resolved browser-engine selection. + /// + /// - Parameters: + /// - kind: The effective engine. + /// - chromiumApplication: The Chromium application, required for a usable Chromium surface. + public init(kind: BrowserEngineKind, chromiumApplication: BrowserApplication? = nil) { + self.kind = kind + self.chromiumApplication = chromiumApplication + } + + /// A deterministic WebKit selection for direct construction and tests. + public static let webKit = BrowserEngineSelection(kind: .webKit) +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelectionService.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelectionService.swift new file mode 100644 index 00000000000..edb42361d36 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSelectionService.swift @@ -0,0 +1,51 @@ +public import CmuxCore + +/// Resolves stored policy and restored-session intent into a launchable browser engine. +@MainActor +public struct BrowserEngineSelectionService { + private let applicationProvider: any BrowserApplicationProviding + private let resolver: BrowserEngineResolver + + /// Creates a browser-engine selection service with injected system boundaries. + /// + /// - Parameters: + /// - applicationProvider: Supplies LaunchServices handlers and installed browsers. + /// - resolver: Maps handler identities to an engine family. + public init( + applicationProvider: any BrowserApplicationProviding, + resolver: BrowserEngineResolver = BrowserEngineResolver() + ) { + self.applicationProvider = applicationProvider + self.resolver = resolver + } + + /// Selects the effective engine for a newly-created or restored surface. + /// + /// A restored engine is authoritative. Otherwise the stored preference is + /// resolved against the actual LaunchServices HTTP/HTTPS handlers. Explicit + /// Chromium selection remains Chromium even when no compatible application + /// is installed, allowing the pane to present an actionable error rather + /// than silently changing engines. + /// + /// - Parameters: + /// - preference: The user's stored engine preference. + /// - restoredKind: The engine persisted for a restored surface, if any. + /// - Returns: The effective selection and optional Chromium application. + public func select( + preference: BrowserEnginePreference, + restoredKind: BrowserEngineKind? = nil + ) -> BrowserEngineSelection { + let defaultApplications = applicationProvider.defaultBrowserApplications() + let kind = restoredKind ?? resolver.resolve( + preference: preference, + defaultHandlerBundleIdentifiers: defaultApplications.map(\.bundleIdentifier) + ) + guard kind == .chromium else { return .webKit } + + let defaultChromium = defaultApplications.first { + resolver.isChromiumFamilyBundleIdentifier($0.bundleIdentifier) + } + let application = defaultChromium ?? applicationProvider.installedChromiumApplications().first + return BrowserEngineSelection(kind: .chromium, chromiumApplication: application) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift new file mode 100644 index 00000000000..f1d3d13f7e6 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -0,0 +1,77 @@ +public import AppKit +public import CmuxCore +public import Foundation + +/// A live, engine-neutral browser surface used by pane chrome and automation. +@MainActor +public protocol BrowserEngineSession: AnyObject { + /// The engine family implementing this session. + var kind: BrowserEngineKind { get } + + /// The native view hosted by the browser pane portal. + var contentView: NSView { get } + + /// The latest browser state snapshot. + var state: BrowserEngineState { get } + + /// State snapshots emitted when navigation or engine lifecycle changes. + var stateUpdates: AsyncStream { get } + + /// Loads a request in the current browser surface. + /// + /// - Parameter request: The top-level request to load. + func load(_ request: URLRequest) + + /// Traverses backward in engine-native history. + func goBack() + + /// Traverses forward in engine-native history. + func goForward() + + /// Reloads the current page. + func reload() + + /// Reloads the current page while bypassing cached content when supported. + func reloadFromOrigin() + + /// Stops the current navigation. + func stopLoading() + + /// Evaluates JavaScript in an engine-defined content world. + /// + /// - Parameters: + /// - script: The JavaScript expression or program to evaluate. + /// - world: The page or isolated world that should execute the script. + /// - Returns: A Sendable value copied from the page. + /// - Throws: An engine or JavaScript evaluation error. + func evaluateJavaScript( + _ script: String, + in world: BrowserJavaScriptWorld + ) async throws -> BrowserJavaScriptValue + + /// Installs JavaScript that runs at document start on future navigations. + /// + /// - Parameter script: The script source to install. + /// - Throws: An engine error when the script cannot be installed. + func addInitializationScript(_ script: String) async throws + + /// Captures the current page viewport as PNG data. + /// + /// - Returns: PNG-encoded viewport pixels. + /// - Throws: An engine or image-conversion error. + func captureScreenshot() async throws -> Data + + /// Releases page, process, and transport resources owned by the session. + func close() +} + +public extension BrowserEngineSession { + /// Evaluates JavaScript in the page's main world. + /// + /// - Parameter script: The JavaScript expression or program to evaluate. + /// - Returns: A Sendable value copied from the page. + /// - Throws: An engine or JavaScript evaluation error. + func evaluateJavaScript(_ script: String) async throws -> BrowserJavaScriptValue { + try await evaluateJavaScript(script, in: .page) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift new file mode 100644 index 00000000000..465139cde30 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift @@ -0,0 +1,50 @@ +public import Foundation + +/// Errors shared by browser-engine session implementations. +public enum BrowserEngineSessionError: LocalizedError, Sendable { + /// The engine returned no screenshot pixels. + case emptyScreenshot + + /// A Chromium application could not be found. + case chromiumUnavailable + + /// A Chrome DevTools Protocol operation failed. + case chromiumProtocol(String) + + /// A Chromium process ended before exposing its DevTools endpoint. + case chromiumLaunch(String) + + /// A browser returned a JavaScript value that cannot cross the engine boundary. + case unsupportedJavaScriptValue + + /// A localized, user-facing description that omits protocol internals. + public var errorDescription: String? { + switch self { + case .emptyScreenshot: + return String( + localized: "browser.engine.error.emptyScreenshot", + defaultValue: "The browser returned an empty screenshot." + ) + case .chromiumUnavailable: + return String( + localized: "browser.chromium.error.notInstalled", + defaultValue: "Chromium is selected, but no supported Chromium browser is installed." + ) + case .chromiumProtocol: + return String( + localized: "browser.chromium.error.operationFailed", + defaultValue: "Chromium stopped responding. Reload the page or switch to WebKit." + ) + case .chromiumLaunch: + return String( + localized: "browser.chromium.error.launchFailed", + defaultValue: "Chromium could not start. Try another installed Chromium browser or switch to WebKit." + ) + case .unsupportedJavaScriptValue: + return String( + localized: "browser.engine.error.unsupportedJavaScriptValue", + defaultValue: "The browser returned an unsupported JavaScript value." + ) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift new file mode 100644 index 00000000000..056c4b9dd62 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift @@ -0,0 +1,47 @@ +public import Foundation + +/// Engine-neutral browser state projected into browser chrome and persistence. +public struct BrowserEngineState: Equatable, Sendable { + /// The current top-level URL. + public var url: URL? + + /// The current document title. + public var title: String + + /// Whether a top-level navigation is in progress. + public var isLoading: Bool + + /// Whether the engine can traverse backward in native history. + public var canGoBack: Bool + + /// Whether the engine can traverse forward in native history. + public var canGoForward: Bool + + /// A visible engine-startup or transport error, when present. + public var errorMessage: String? + + /// Creates an engine state snapshot. + /// + /// - Parameters: + /// - url: The current top-level URL. + /// - title: The current document title. + /// - isLoading: Whether a top-level navigation is active. + /// - canGoBack: Whether native backward history is available. + /// - canGoForward: Whether native forward history is available. + /// - errorMessage: A user-facing engine error, if present. + public init( + url: URL? = nil, + title: String = "", + isLoading: Bool = false, + canGoBack: Bool = false, + canGoForward: Bool = false, + errorMessage: String? = nil + ) { + self.url = url + self.title = title + self.isLoading = isLoading + self.canGoBack = canGoBack + self.canGoForward = canGoForward + self.errorMessage = errorMessage + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift new file mode 100644 index 00000000000..1793a778119 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift @@ -0,0 +1,72 @@ +import CoreFoundation +import Foundation + +/// A Sendable copy of a JavaScript evaluation result. +public enum BrowserJavaScriptValue: Equatable, Sendable { + /// JavaScript `undefined` or a missing remote value. + case undefined + + /// JavaScript `null`. + case null + + /// A JavaScript boolean. + case bool(Bool) + + /// A JavaScript number. + case number(Double) + + /// A JavaScript string. + case string(String) + + /// A JavaScript array containing transferable values. + case array([BrowserJavaScriptValue]) + + /// A JavaScript object with string keys and transferable values. + case object([String: BrowserJavaScriptValue]) + + /// Copies a Foundation value returned by WebKit into a Sendable representation. + /// + /// - Parameter foundationValue: A WebKit JavaScript result. + /// - Throws: ``BrowserEngineSessionError/unsupportedJavaScriptValue`` when the result is not transferable. + public init(foundationValue: Any?) throws { + switch foundationValue { + case nil: + self = .undefined + case is NSNull: + self = .null + case let value as NSNumber where CFGetTypeID(value) == CFBooleanGetTypeID(): + self = .bool(value.boolValue) + case let value as NSNumber: + self = .number(value.doubleValue) + case let value as String: + self = .string(value) + case let values as [Any]: + self = try .array(values.map { try BrowserJavaScriptValue(foundationValue: $0) }) + case let values as [String: Any]: + self = try .object(values.mapValues { try BrowserJavaScriptValue(foundationValue: $0) }) + default: + throw BrowserEngineSessionError.unsupportedJavaScriptValue + } + } + + /// The Foundation representation expected by existing browser automation callers. + @MainActor + public var foundationValue: Any? { + switch self { + case .undefined: + return nil + case .null: + return NSNull() + case .bool(let value): + return value + case .number(let value): + return value + case .string(let value): + return value + case .array(let values): + return values.map(\.foundationValue) + case .object(let values): + return values.mapValues(\.foundationValue) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptWorld.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptWorld.swift new file mode 100644 index 00000000000..247cd0e39c3 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptWorld.swift @@ -0,0 +1,8 @@ +/// A JavaScript execution world exposed by a browser engine. +public enum BrowserJavaScriptWorld: Sendable { + /// The page's own JavaScript world, including its global variables. + case page + + /// An engine-isolated world that shares the page DOM but not page globals. + case isolated +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift new file mode 100644 index 00000000000..62423c4c6d2 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift @@ -0,0 +1,28 @@ +public import Foundation + +/// Builds isolated profile directories for Chromium engine process sessions. +public struct BrowserChromiumProfileDirectory { + private let fileManager: FileManager + + /// Creates a profile-directory builder with an injectable filesystem boundary. + /// + /// - Parameter fileManager: The file manager that supplies the temporary directory. + public init(fileManager: FileManager = .default) { + self.fileManager = fileManager + } + + /// Returns the directory for one engine process session. + /// + /// - Parameters: + /// - profileID: The cmux browser profile identifier. + /// - surfaceID: The browser surface identifier. + /// - sessionID: A unique process-session identifier that prevents overlapping processes from sharing a lock file. + /// - Returns: A temporary directory URL namespaced by profile, surface, and process session. + public func url(profileID: UUID, surfaceID: UUID, sessionID: UUID) -> URL { + fileManager.temporaryDirectory + .appendingPathComponent("cmux-chromium", isDirectory: true) + .appendingPathComponent(profileID.uuidString, isDirectory: true) + .appendingPathComponent(surfaceID.uuidString, isDirectory: true) + .appendingPathComponent(sessionID.uuidString, isDirectory: true) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift new file mode 100644 index 00000000000..ab624bab1bd --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -0,0 +1,121 @@ +import Foundation + +/// Owns one browser-level Chrome DevTools Protocol WebSocket connection. +actor CDPConnection { + private let webSocketTask: URLSessionWebSocketTask + private let urlSession: URLSession + private let eventContinuation: AsyncStream.Continuation + private let eventStream: AsyncStream + private var receiveTask: Task? + private var nextRequestID = 1 + private var pendingRequests: [Int: CheckedContinuation] = [:] + private var isClosed = false + + init(url: URL) { + let configuration = URLSessionConfiguration.ephemeral + let session = URLSession(configuration: configuration) + self.urlSession = session + self.webSocketTask = session.webSocketTask(with: url) + (eventStream, eventContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + } + + func connect() { + guard receiveTask == nil else { return } + webSocketTask.resume() + receiveTask = Task { [weak self] in + await self?.receiveMessages() + } + } + + func events() -> AsyncStream { eventStream } + + func send( + method: String, + parameters: [String: CDPJSONValue] = [:], + sessionID: String? = nil + ) async throws -> CDPJSONValue { + guard !isClosed else { throw BrowserEngineSessionError.chromiumProtocol("DevTools connection is closed.") } + let requestID = nextRequestID + nextRequestID += 1 + var message: [String: CDPJSONValue] = [ + "id": .number(Double(requestID)), + "method": .string(method), + "params": .object(parameters), + ] + if let sessionID { message["sessionId"] = .string(sessionID) } + let data = try JSONEncoder().encode(message) + return try await withCheckedThrowingContinuation { continuation in + pendingRequests[requestID] = continuation + Task { [weak self, webSocketTask] in + do { + try await webSocketTask.send(.data(data)) + } catch { + await self?.failPendingRequest(requestID, error: error) + } + } + } + } + + func close() { + guard !isClosed else { return } + isClosed = true + receiveTask?.cancel() + receiveTask = nil + webSocketTask.cancel(with: .goingAway, reason: nil) + urlSession.invalidateAndCancel() + let error = BrowserEngineSessionError.chromiumProtocol("DevTools connection closed.") + pendingRequests.values.forEach { $0.resume(throwing: error) } + pendingRequests.removeAll() + eventContinuation.finish() + } + + private func failPendingRequest(_ requestID: Int, error: any Error) { + pendingRequests.removeValue(forKey: requestID)?.resume(throwing: error) + } + + private func receiveMessages() async { + do { + while !Task.isCancelled { + let message = try await webSocketTask.receive() + let data: Data + switch message { + case .data(let value): + data = value + case .string(let value): + data = Data(value.utf8) + @unknown default: + continue + } + try handleMessage(data) + } + } catch { + guard !isClosed else { return } + isClosed = true + pendingRequests.values.forEach { $0.resume(throwing: error) } + pendingRequests.removeAll() + eventContinuation.finish() + } + } + + private func handleMessage(_ data: Data) throws { + let payload = try JSONDecoder().decode([String: CDPJSONValue].self, from: data) + if let requestID = payload["id"]?.intValue, + let continuation = pendingRequests.removeValue(forKey: requestID) { + if let remoteError = payload["error"]?.objectValue { + let message = remoteError["message"]?.stringValue ?? "Unknown DevTools error" + continuation.resume(throwing: BrowserEngineSessionError.chromiumProtocol(message)) + } else { + continuation.resume(returning: payload["result"] ?? .null) + } + return + } + guard let method = payload["method"]?.stringValue else { return } + eventContinuation.yield(CDPEvent( + method: method, + parameters: payload["params"]?.objectValue ?? [:], + sessionID: payload["sessionId"]?.stringValue + )) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEvent.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEvent.swift new file mode 100644 index 00000000000..3ab84d6c17a --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEvent.swift @@ -0,0 +1,8 @@ +import Foundation + +/// An unsolicited event received from a Chrome DevTools Protocol session. +struct CDPEvent: Sendable { + let method: String + let parameters: [String: CDPJSONValue] + let sessionID: String? +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPJSONValue.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPJSONValue.swift new file mode 100644 index 00000000000..7777819f099 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPJSONValue.swift @@ -0,0 +1,100 @@ +import Foundation + +/// A Sendable JSON value used at the Chrome DevTools Protocol actor boundary. +enum CDPJSONValue: Codable, Equatable, Sendable { + case null + case bool(Bool) + case number(Double) + case string(String) + case array([CDPJSONValue]) + case object([String: CDPJSONValue]) + + init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([CDPJSONValue].self) { + self = .array(value) + } else { + self = .object(try container.decode([String: CDPJSONValue].self)) + } + } + + func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: + try container.encodeNil() + case .bool(let value): + try container.encode(value) + case .number(let value): + try container.encode(value) + case .string(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + } + } + + var objectValue: [String: CDPJSONValue]? { + guard case .object(let value) = self else { return nil } + return value + } + + var arrayValue: [CDPJSONValue]? { + guard case .array(let value) = self else { return nil } + return value + } + + var stringValue: String? { + guard case .string(let value) = self else { return nil } + return value + } + + var intValue: Int? { + guard case .number(let value) = self else { return nil } + return Int(value) + } + + var foundationValue: Any { + switch self { + case .null: + return NSNull() + case .bool(let value): + return value + case .number(let value): + return value + case .string(let value): + return value + case .array(let values): + return values.map(\.foundationValue) + case .object(let values): + return values.mapValues(\.foundationValue) + } + } + + var browserJavaScriptValue: BrowserJavaScriptValue { + switch self { + case .null: + return .null + case .bool(let value): + return .bool(value) + case .number(let value): + return .number(value) + case .string(let value): + return .string(value) + case .array(let values): + return .array(values.map(\.browserJavaScriptValue)) + case .object(let values): + return .object(values.mapValues(\.browserJavaScriptValue)) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift new file mode 100644 index 00000000000..98b7deaab0b --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -0,0 +1,83 @@ +import Foundation + +extension ChromiumBrowserEngineSession { + func handleViewportMessage(_ payload: [String: Any]) { + guard let type = payload["type"] as? String else { return } + switch type { + case "resize": + viewportWidth = max(1, payload["width"] as? Int ?? viewportWidth) + viewportHeight = max(1, payload["height"] as? Int ?? viewportHeight) + deviceScaleFactor = max(1, payload["scale"] as? Double ?? deviceScaleFactor) + updateDeviceMetrics() + case "mouse": + dispatchMouse(payload) + case "key": + dispatchKey(payload) + default: + break + } + } + + func sendDeviceMetrics(connection: CDPConnection, sessionID: String) async throws { + _ = try await connection.send( + method: "Emulation.setDeviceMetricsOverride", + parameters: [ + "width": .number(Double(viewportWidth)), + "height": .number(Double(viewportHeight)), + "deviceScaleFactor": .number(deviceScaleFactor), + "mobile": .bool(false), + ], + sessionID: sessionID + ) + } + + private func updateDeviceMetrics() { + guard let connection, let cdpSessionID else { return } + Task { try? await sendDeviceMetrics(connection: connection, sessionID: cdpSessionID) } + } + + private func dispatchMouse(_ payload: [String: Any]) { + guard let connection, let cdpSessionID, + let event = payload["event"] as? String else { return } + let buttonIndex = payload["button"] as? Int ?? -1 + let button = [0: "left", 1: "middle", 2: "right"][buttonIndex] ?? "none" + var parameters: [String: CDPJSONValue] = [ + "type": .string(event), + "x": .number(payload["x"] as? Double ?? 0), + "y": .number(payload["y"] as? Double ?? 0), + "button": .string(button), + "modifiers": .number(Double(payload["modifiers"] as? Int ?? 0)), + "clickCount": .number(Double(payload["clickCount"] as? Int ?? 0)), + ] + if event == "mouseWheel" { + parameters["deltaX"] = .number(payload["deltaX"] as? Double ?? 0) + parameters["deltaY"] = .number(payload["deltaY"] as? Double ?? 0) + } + Task { + try? await connection.send( + method: "Input.dispatchMouseEvent", + parameters: parameters, + sessionID: cdpSessionID + ) + } + } + + private func dispatchKey(_ payload: [String: Any]) { + guard let connection, let cdpSessionID, + let event = payload["event"] as? String else { return } + let parameters: [String: CDPJSONValue] = [ + "type": .string(event), + "key": .string(payload["key"] as? String ?? ""), + "code": .string(payload["code"] as? String ?? ""), + "text": .string(payload["text"] as? String ?? ""), + "modifiers": .number(Double(payload["modifiers"] as? Int ?? 0)), + ] + Task { + try? await connection.send( + method: "Input.dispatchKeyEvent", + parameters: parameters, + sessionID: cdpSessionID + ) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift new file mode 100644 index 00000000000..f052bc9b7b0 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -0,0 +1,482 @@ +public import AppKit +public import CmuxCore +public import Foundation +public import WebKit + +/// A Chromium engine rendered through CDP screencast frames in a cmux browser pane. +@MainActor +public final class ChromiumBrowserEngineSession: BrowserEngineSession { + /// The engine family implementing this session. + public let kind = BrowserEngineKind.chromium + + /// The local viewport view hosted by the browser pane portal. + public var contentView: NSView { viewportWebView } + + /// The latest Chromium state snapshot. + public private(set) var state = BrowserEngineState() + + /// State snapshots emitted from CDP lifecycle events. + public let stateUpdates: AsyncStream + + private let stateContinuation: AsyncStream.Continuation + private let viewportWebView: WKWebView + private let application: BrowserApplication? + private let userDataDirectory: URL + private let processController = ChromiumProcessController() + private let viewportHandler = ChromiumViewportMessageHandler() + var connection: CDPConnection? + var cdpSessionID: String? + private var startupTask: Task? + private var eventTask: Task? + private var pendingRequest: URLRequest? + private var initializationScripts: [String] + private var initializationScriptInstallations: [Int: Task] = [:] + var viewportWidth = 1280 + var viewportHeight = 720 + var deviceScaleFactor = 1.0 + private var isClosed = false + + /// Creates and starts a Chromium engine session. + /// + /// - Parameters: + /// - viewportWebView: The local WebKit transport view used only for canvas presentation and input capture. + /// - application: The installed Chromium-family browser to launch, or `nil` to show an unavailable error. + /// - userDataDirectory: An isolated Chrome profile directory for this engine session. + /// - initializationScripts: Scripts to install before the first requested page loads. + public init( + viewportWebView: WKWebView, + application: BrowserApplication?, + userDataDirectory: URL, + initializationScripts: [String] = [] + ) { + self.viewportWebView = viewportWebView + self.application = application + self.userDataDirectory = userDataDirectory + self.initializationScripts = initializationScripts + (stateUpdates, stateContinuation) = AsyncStream.makeStream() + viewportHandler.session = self + let controller = viewportWebView.configuration.userContentController + controller.removeScriptMessageHandler(forName: "cmuxChromiumViewport") + controller.add(viewportHandler, name: "cmuxChromiumViewport") + let loadingText = String(localized: "browser.chromium.starting", defaultValue: "Starting Chromium…") + let accessibilityLabel = String( + localized: "browser.chromium.accessibility.viewport", + defaultValue: "Chromium page" + ) + viewportWebView.loadHTMLString( + ChromiumViewportDocument().html( + loadingText: loadingText, + accessibilityLabel: accessibilityLabel + ), + baseURL: nil + ) + start() + } + + /// Queues or performs a top-level CDP navigation. + public func load(_ request: URLRequest) { + guard let url = request.url else { return } + guard application != nil else { + updateState { state in + state.url = url + state.isLoading = false + } + return + } + pendingRequest = request + updateState { state in + state.url = url + state.isLoading = true + state.errorMessage = nil + } + guard let connection, let cdpSessionID else { return } + pendingRequest = nil + Task { [weak self] in + do { + _ = try await connection.send( + method: "Page.navigate", + parameters: ["url": .string(url.absoluteString)], + sessionID: cdpSessionID + ) + } catch { + self?.presentOperationFailure() + } + } + } + + /// Traverses backward in Chromium history. + public func goBack() { traverseHistory(offset: -1) } + + /// Traverses forward in Chromium history. + public func goForward() { traverseHistory(offset: 1) } + + /// Reloads the current Chromium page. + public func reload() { reload(ignoreCache: false) } + + /// Reloads while bypassing Chromium's cache. + public func reloadFromOrigin() { reload(ignoreCache: true) } + + /// Stops the current Chromium navigation. + public func stopLoading() { + guard let connection, let cdpSessionID else { return } + Task { try? await connection.send(method: "Page.stopLoading", sessionID: cdpSessionID) } + updateState { $0.isLoading = false } + } + + /// Evaluates JavaScript through `Runtime.evaluate` and returns its by-value result. + public func evaluateJavaScript( + _ script: String, + in world: BrowserJavaScriptWorld + ) async throws -> BrowserJavaScriptValue { + if connection == nil { + await startupTask?.value + } + guard let connection, let cdpSessionID else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + var parameters: [String: CDPJSONValue] = [ + "expression": .string(script), + "awaitPromise": .bool(true), + "returnByValue": .bool(true), + "userGesture": .bool(true), + ] + if world == .isolated { + parameters["contextId"] = .number(Double(try await isolatedExecutionContextID( + connection: connection, + sessionID: cdpSessionID + ))) + } + let response = try await connection.send( + method: "Runtime.evaluate", + parameters: parameters, + sessionID: cdpSessionID + ) + let object = response.objectValue ?? [:] + if let exception = object["exceptionDetails"]?.objectValue { + let text = exception["text"]?.stringValue ?? "JavaScript evaluation failed." + throw BrowserEngineSessionError.chromiumProtocol(text) + } + guard let remoteObject = object["result"]?.objectValue else { return .undefined } + if remoteObject["type"]?.stringValue == "undefined" { return .undefined } + return remoteObject["value"]?.browserJavaScriptValue ?? .undefined + } + + /// Installs a CDP document-start script for future navigations. + public func addInitializationScript(_ script: String) async throws { + let scriptIndex = initializationScripts.count + initializationScripts.append(script) + if connection == nil { + await startupTask?.value + } + guard let connection, let cdpSessionID else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + try await ensureInitializationScriptInstalled( + at: scriptIndex, + connection: connection, + sessionID: cdpSessionID + ) + } + + private func isolatedExecutionContextID( + connection: CDPConnection, + sessionID: String + ) async throws -> Int { + let frameTree = try await connection.send(method: "Page.getFrameTree", sessionID: sessionID) + guard let frameID = frameTree.objectValue?["frameTree"]?.objectValue?["frame"]?.objectValue?["id"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not expose its main frame.") + } + let isolatedWorld = try await connection.send( + method: "Page.createIsolatedWorld", + parameters: [ + "frameId": .string(frameID), + "worldName": .string("cmux.browser.automation"), + "grantUniveralAccess": .bool(true), + ], + sessionID: sessionID + ) + guard let contextID = isolatedWorld.objectValue?["executionContextId"]?.intValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not create an isolated JavaScript world.") + } + return contextID + } + + /// Captures the current Chromium viewport through CDP. + public func captureScreenshot() async throws -> Data { + if connection == nil { + await startupTask?.value + } + guard let connection, let cdpSessionID else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + let response = try await connection.send( + method: "Page.captureScreenshot", + parameters: ["format": .string("png"), "fromSurface": .bool(true)], + sessionID: cdpSessionID + ) + guard let base64 = response.objectValue?["data"]?.stringValue, + let data = Data(base64Encoded: base64) else { + throw BrowserEngineSessionError.emptyScreenshot + } + return data + } + + /// Closes the CDP target, connection, process, and local viewport bridge. + public func close() { + guard !isClosed else { return } + isClosed = true + startupTask?.cancel() + eventTask?.cancel() + initializationScriptInstallations.values.forEach { $0.cancel() } + initializationScriptInstallations.removeAll() + startupTask = nil + eventTask = nil + viewportWebView.configuration.userContentController.removeScriptMessageHandler( + forName: "cmuxChromiumViewport" + ) + stateContinuation.finish() + let connection = connection + Task { [processController] in + await processController.close() + await connection?.close() + } + self.connection = nil + cdpSessionID = nil + } + + private func start() { + guard let application else { + let message = String( + localized: "browser.chromium.error.notInstalled", + defaultValue: "Chromium is selected, but no supported Chromium browser is installed." + ) + presentMessage(message) + return + } + updateState { $0.isLoading = true } + startupTask = Task { [weak self] in + guard let self else { return } + do { + let endpoint = try await processController.start( + application: application, + userDataDirectory: userDataDirectory + ) + try await connect(to: endpoint) + } catch is CancellationError { + return + } catch { + presentLaunchFailure() + } + } + } + + private func connect(to endpoint: URL) async throws { + let connection = CDPConnection(url: endpoint) + await connection.connect() + self.connection = connection + let created = try await connection.send( + method: "Target.createTarget", + parameters: [ + "url": .string("about:blank"), + "width": .number(Double(viewportWidth)), + "height": .number(Double(viewportHeight)), + ] + ) + guard let targetID = created.objectValue?["targetId"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not create a page target.") + } + let attached = try await connection.send( + method: "Target.attachToTarget", + parameters: ["targetId": .string(targetID), "flatten": .bool(true)] + ) + guard let sessionID = attached.objectValue?["sessionId"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not attach to the page target.") + } + cdpSessionID = sessionID + beginEvents(connection: connection, sessionID: sessionID) + _ = try await connection.send(method: "Page.enable", sessionID: sessionID) + _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) + try await sendDeviceMetrics(connection: connection, sessionID: sessionID) + _ = try await connection.send( + method: "Page.startScreencast", + parameters: [ + "format": .string("jpeg"), + "quality": .number(75), + "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), + "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), + "everyNthFrame": .number(1), + ], + sessionID: sessionID + ) + try await installAllInitializationScripts(connection: connection, sessionID: sessionID) + if let request = pendingRequest { + load(request) + } else { + updateState { $0.isLoading = false } + } + } + + private func beginEvents(connection: CDPConnection, sessionID: String) { + eventTask = Task { [weak self] in + let events = await connection.events() + for await event in events where event.sessionID == nil || event.sessionID == sessionID { + guard let self, !Task.isCancelled else { return } + await self.handle(event, connection: connection, sessionID: sessionID) + } + guard let self, !Task.isCancelled, !self.isClosed else { return } + self.presentOperationFailure() + } + } + + private func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { + switch event.method { + case "Page.screencastFrame": + guard let data = event.parameters["data"]?.stringValue, + let frameSessionID = event.parameters["sessionId"]?.intValue else { return } + let literal = ChromiumViewportDocumentJSONLiteral().encode("data:image/jpeg;base64,\(data)") + _ = try? await viewportWebView.evaluateJavaScript("window.cmuxChromiumFrame(\(literal))") + _ = try? await connection.send( + method: "Page.screencastFrameAck", + parameters: ["sessionId": .number(Double(frameSessionID))], + sessionID: sessionID + ) + case "Page.frameStartedLoading": + updateState { $0.isLoading = true } + case "Page.frameStoppedLoading", "Page.loadEventFired": + updateState { $0.isLoading = false } + await refreshDocumentState(connection: connection, sessionID: sessionID) + case "Page.frameNavigated": + if let frame = event.parameters["frame"]?.objectValue, + frame["parentId"] == nil, + let rawURL = frame["url"]?.stringValue { + updateState { $0.url = URL(string: rawURL) } + } + default: + break + } + } + + private func refreshDocumentState(connection: CDPConnection, sessionID: String) async { + if case .string(let title) = try? await evaluateJavaScript("document.title") { + updateState { $0.title = title } + } + guard let history = try? await connection.send(method: "Page.getNavigationHistory", sessionID: sessionID), + let object = history.objectValue, + let currentIndex = object["currentIndex"]?.intValue, + let entries = object["entries"]?.arrayValue else { return } + updateState { state in + state.canGoBack = currentIndex > 0 + state.canGoForward = currentIndex + 1 < entries.count + } + } + + private func traverseHistory(offset: Int) { + guard let connection, let cdpSessionID else { return } + Task { [weak self] in + do { + let history = try await connection.send(method: "Page.getNavigationHistory", sessionID: cdpSessionID) + guard let object = history.objectValue, + let currentIndex = object["currentIndex"]?.intValue, + let entries = object["entries"]?.arrayValue else { return } + let targetIndex = currentIndex + offset + guard entries.indices.contains(targetIndex), + let entryID = entries[targetIndex].objectValue?["id"] else { return } + _ = try await connection.send( + method: "Page.navigateToHistoryEntry", + parameters: ["entryId": entryID], + sessionID: cdpSessionID + ) + } catch { + self?.presentOperationFailure() + } + } + } + + private func reload(ignoreCache: Bool) { + guard let connection, let cdpSessionID else { return } + Task { [weak self] in + do { + _ = try await connection.send( + method: "Page.reload", + parameters: ["ignoreCache": .bool(ignoreCache)], + sessionID: cdpSessionID + ) + } catch { + self?.presentOperationFailure() + } + } + } + + private func installAllInitializationScripts( + connection: CDPConnection, + sessionID: String + ) async throws { + while true { + let scriptCount = initializationScripts.count + for scriptIndex in 0.. Void) { + mutation(&state) + stateContinuation.yield(state) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift new file mode 100644 index 00000000000..13317536155 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -0,0 +1,117 @@ +import Foundation + +/// Launches and owns one headless Chromium process for a browser-engine session. +actor ChromiumProcessController { + private let launchTimeout: Duration + private var process: Process? + private var stderrTask: Task? + private var timeoutTask: Task? + private var endpointContinuation: CheckedContinuation? + private var didResolveEndpoint = false + + init(launchTimeout: Duration = .seconds(10)) { + self.launchTimeout = launchTimeout + } + + func start(application: BrowserApplication, userDataDirectory: URL) async throws -> URL { + guard process == nil else { + throw BrowserEngineSessionError.chromiumLaunch("Chromium is already running for this session.") + } + try FileManager.default.createDirectory( + at: userDataDirectory, + withIntermediateDirectories: true + ) + + let process = Process() + let stderrPipe = Pipe() + process.executableURL = application.executableURL + process.arguments = [ + "--headless=new", + "--remote-debugging-port=0", + "--remote-allow-origins=*", + "--user-data-dir=\(userDataDirectory.path)", + "--no-first-run", + "--no-default-browser-check", + "--disable-component-update", + "--disable-background-networking", + "about:blank", + ] + process.standardInput = FileHandle.nullDevice + process.standardOutput = FileHandle.nullDevice + process.standardError = stderrPipe + try process.run() + self.process = process + + return try await withCheckedThrowingContinuation { continuation in + endpointContinuation = continuation + stderrTask = Task { [weak self] in + await self?.consumeStandardError(stderrPipe.fileHandleForReading) + } + timeoutTask = Task { [weak self, launchTimeout] in + // A bounded launch deadline is intentional behavior; never leave a pane waiting forever for DevTools. + try? await ContinuousClock().sleep(for: launchTimeout) + guard !Task.isCancelled else { return } + await self?.failUnresolvedEndpoint( + BrowserEngineSessionError.chromiumLaunch("Chromium timed out before opening its DevTools endpoint.") + ) + } + } + } + + func close() { + stderrTask?.cancel() + stderrTask = nil + timeoutTask?.cancel() + timeoutTask = nil + if !didResolveEndpoint { + failUnresolvedEndpoint(BrowserEngineSessionError.chromiumLaunch( + "Chromium stopped before opening its DevTools endpoint." + )) + } else if let process, process.isRunning { + process.terminate() + } + process = nil + endpointContinuation = nil + } + + private func failUnresolvedEndpoint(_ error: any Error) { + guard !didResolveEndpoint else { return } + didResolveEndpoint = true + timeoutTask?.cancel() + timeoutTask = nil + endpointContinuation?.resume(throwing: error) + endpointContinuation = nil + if let process, process.isRunning { + process.terminate() + } + } + + private func consumeStandardError(_ handle: FileHandle) async { + do { + for try await line in handle.bytes.lines { + resolveEndpointIfPresent(in: line) + } + if !didResolveEndpoint { + failUnresolvedEndpoint(BrowserEngineSessionError.chromiumLaunch( + "Chromium stopped before opening its DevTools endpoint." + )) + } + } catch { + failUnresolvedEndpoint(error) + } + } + + private func resolveEndpointIfPresent(in line: String) { + guard !didResolveEndpoint else { return } + let marker = "DevTools listening on " + guard let range = line.range(of: marker), + let url = URL(string: String(line[range.upperBound...]).trimmingCharacters(in: .whitespacesAndNewlines)) else { + return + } + didResolveEndpoint = true + timeoutTask?.cancel() + timeoutTask = nil + endpointContinuation?.resume(returning: url) + endpointContinuation = nil + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift new file mode 100644 index 00000000000..368f551c769 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift @@ -0,0 +1,110 @@ +import Foundation + +/// Builds the local canvas document used to display and interact with CDP screencast frames. +struct ChromiumViewportDocument { + func html(loadingText: String, accessibilityLabel: String) -> String { + let encodedLoadingText = jsonLiteral(loadingText) + let encodedAccessibilityLabel = jsonLiteral(accessibilityLabel) + return """ + + + + + + + +
+ + + + + """ + } + + private func jsonLiteral(_ value: String) -> String { + guard let data = try? JSONEncoder().encode(value), + let result = String(data: data, encoding: .utf8) else { + return "\"\"" + } + return result + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocumentJSONLiteral.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocumentJSONLiteral.swift new file mode 100644 index 00000000000..9182636d647 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocumentJSONLiteral.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Encodes native text for safe interpolation into the local viewport document. +struct ChromiumViewportDocumentJSONLiteral { + func encode(_ value: String) -> String { + guard let data = try? JSONEncoder().encode(value), + let result = String(data: data, encoding: .utf8) else { + return "\"\"" + } + return result + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportMessageHandler.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportMessageHandler.swift new file mode 100644 index 00000000000..0621eea91e8 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportMessageHandler.swift @@ -0,0 +1,13 @@ +import Foundation +import WebKit + +/// Forwards local viewport input messages to its Chromium engine session. +@MainActor +final class ChromiumViewportMessageHandler: NSObject, WKScriptMessageHandler { + weak var session: ChromiumBrowserEngineSession? + + func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + guard let payload = message.body as? [String: Any] else { return } + session?.handleViewportMessage(payload) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/LaunchServicesBrowserApplicationProvider.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/LaunchServicesBrowserApplicationProvider.swift new file mode 100644 index 00000000000..ca3a26529f1 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/LaunchServicesBrowserApplicationProvider.swift @@ -0,0 +1,55 @@ +public import AppKit + +/// Reads default URL handlers and installed Chromium applications through LaunchServices. +@MainActor +public final class LaunchServicesBrowserApplicationProvider: BrowserApplicationProviding { + private let workspace: NSWorkspace + + /// Creates a LaunchServices-backed provider. + /// + /// - Parameter workspace: The workspace used for URL-handler and application lookup. + public init(workspace: NSWorkspace = .shared) { + self.workspace = workspace + } + + /// Returns the applications LaunchServices selects for HTTPS and HTTP. + public func defaultBrowserApplications() -> [BrowserApplication] { + let representativeURLs = [ + URL(string: "https://cmux.invalid/default-browser")!, + URL(string: "http://cmux.invalid/default-browser")!, + ] + return deduplicatedApplications(representativeURLs.compactMap { url in + workspace.urlForApplication(toOpen: url).flatMap(application(at:)) + }) + } + + /// Returns installed Chromium-family applications in stable preference order. + public func installedChromiumApplications() -> [BrowserApplication] { + let bundleIdentifiers = BrowserImportBrowserDescriptor.allBrowserDescriptors + .filter { $0.family == .chromium } + .flatMap(\.bundleIdentifiers) + return deduplicatedApplications(bundleIdentifiers.compactMap { bundleIdentifier in + workspace.urlForApplication(withBundleIdentifier: bundleIdentifier).flatMap(application(at:)) + }) + } + + private func application(at bundleURL: URL) -> BrowserApplication? { + guard let bundle = Bundle(url: bundleURL), + let bundleIdentifier = bundle.bundleIdentifier, + let executableURL = bundle.executableURL else { + return nil + } + return BrowserApplication( + bundleIdentifier: bundleIdentifier, + bundleURL: bundleURL, + executableURL: executableURL + ) + } + + private func deduplicatedApplications(_ applications: [BrowserApplication]) -> [BrowserApplication] { + var seen = Set() + return applications.filter { application in + seen.insert(application.bundleIdentifier.lowercased()).inserted + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift new file mode 100644 index 00000000000..2e0c3888795 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -0,0 +1,151 @@ +public import AppKit +public import CmuxCore +public import Foundation +public import WebKit + +/// A ``BrowserEngineSession`` backed by an in-process `WKWebView`. +@MainActor +public final class WebKitBrowserEngineSession: BrowserEngineSession { + /// The engine family implementing this session. + public let kind = BrowserEngineKind.webKit + + /// The wrapped WebKit view. + public let webView: WKWebView + + /// The native view hosted by the browser pane portal. + public var contentView: NSView { webView } + + /// The latest WebKit state snapshot. + public private(set) var state: BrowserEngineState + + /// State snapshots emitted from WebKit KVO signals. + public let stateUpdates: AsyncStream + + private let stateContinuation: AsyncStream.Continuation + private var observations: [NSKeyValueObservation] = [] + + /// Creates a WebKit engine session around an app-configured web view. + /// + /// - Parameter webView: The view whose configuration and delegates are owned by the app. + public init(webView: WKWebView) { + self.webView = webView + self.state = BrowserEngineState( + url: webView.url, + title: webView.title ?? "", + isLoading: webView.isLoading, + canGoBack: webView.canGoBack, + canGoForward: webView.canGoForward + ) + (stateUpdates, stateContinuation) = AsyncStream.makeStream() + installObservations() + } + + /// Loads a request in WebKit. + public func load(_ request: URLRequest) { webView.load(request) } + + /// Traverses backward in WebKit history. + public func goBack() { webView.goBack() } + + /// Traverses forward in WebKit history. + public func goForward() { webView.goForward() } + + /// Reloads the current page. + public func reload() { webView.reload() } + + /// Reloads from origin, bypassing WebKit caches. + public func reloadFromOrigin() { webView.reloadFromOrigin() } + + /// Stops the active WebKit load. + public func stopLoading() { webView.stopLoading() } + + /// Evaluates JavaScript in a WebKit content world. + public func evaluateJavaScript( + _ script: String, + in world: BrowserJavaScriptWorld + ) async throws -> BrowserJavaScriptValue { + let contentWorld: WKContentWorld = switch world { + case .page: .page + case .isolated: .defaultClient + } + return try await withCheckedThrowingContinuation { continuation in + webView.evaluateJavaScript(script, in: nil, in: contentWorld) { result in + switch result { + case .success(let value): + do { + continuation.resume(returning: try BrowserJavaScriptValue(foundationValue: value)) + } catch { + continuation.resume(throwing: error) + } + case .failure(let error): + continuation.resume(throwing: error) + } + } + } + } + + /// Installs a WebKit user script for future documents. + public func addInitializationScript(_ script: String) async throws { + webView.configuration.userContentController.addUserScript( + WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + } + + /// Captures a PNG snapshot of the current WebKit viewport. + public func captureScreenshot() async throws -> Data { + let image = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + webView.takeSnapshot(with: nil) { image, error in + if let error { + continuation.resume(throwing: error) + } else if let image { + continuation.resume(returning: image) + } else { + continuation.resume(throwing: BrowserEngineSessionError.emptyScreenshot) + } + } + } + guard let tiff = image.tiffRepresentation, + let bitmap = NSBitmapImageRep(data: tiff), + let png = bitmap.representation(using: NSBitmapImageRep.FileType.png, properties: [:]) else { + throw BrowserEngineSessionError.emptyScreenshot + } + return png + } + + /// Stops loading and finishes the state stream. + public func close() { + observations.removeAll() + webView.stopLoading() + stateContinuation.finish() + } + + private func installObservations() { + observations = [ + webView.observe(\.url, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.publishState() } + }, + webView.observe(\.title, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.publishState() } + }, + webView.observe(\.isLoading, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.publishState() } + }, + webView.observe(\.canGoBack, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.publishState() } + }, + webView.observe(\.canGoForward, options: [.initial, .new]) { [weak self] _, _ in + MainActor.assumeIsolated { self?.publishState() } + }, + ] + } + + private func publishState() { + state = BrowserEngineState( + url: webView.url, + title: webView.title ?? "", + isLoading: webView.isLoading, + canGoBack: webView.canGoBack, + canGoForward: webView.canGoForward + ) + stateContinuation.yield(state) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/RecentlyClosed/ClosedBrowserPanelRestoreSnapshot.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/RecentlyClosed/ClosedBrowserPanelRestoreSnapshot.swift index 38463ed398c..3612b12d1a8 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/RecentlyClosed/ClosedBrowserPanelRestoreSnapshot.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/RecentlyClosed/ClosedBrowserPanelRestoreSnapshot.swift @@ -1,5 +1,6 @@ public import Foundation public import Bonsplit +public import CmuxCore /// The full restore payload a recently-closed browser panel needs to be /// reopened: its origin workspace, the page it showed, and where it sat in the @@ -11,6 +12,8 @@ public struct ClosedBrowserPanelRestoreSnapshot: BrowserPanelRestoreSnapshot { public let url: URL? /// The browser profile the panel used, if any. public let profileID: UUID? + /// The browser engine the panel used. + public let engineKind: BrowserEngineKind /// The pane that originally hosted the panel. public let originalPaneId: UUID /// The tab index the panel occupied within its pane. @@ -29,6 +32,7 @@ public struct ClosedBrowserPanelRestoreSnapshot: BrowserPanelRestoreSnapshot { /// - workspaceId: The workspace that owned the closed browser panel. /// - url: The page the panel was showing, if any. /// - profileID: The browser profile the panel used, if any. + /// - engineKind: The browser engine the panel used. /// - originalPaneId: The pane that originally hosted the panel. /// - originalTabIndex: The tab index the panel occupied within its pane. /// - fallbackSplitOrientation: The split orientation to recreate when the @@ -42,6 +46,7 @@ public struct ClosedBrowserPanelRestoreSnapshot: BrowserPanelRestoreSnapshot { workspaceId: UUID, url: URL?, profileID: UUID?, + engineKind: BrowserEngineKind = .webKit, originalPaneId: UUID, originalTabIndex: Int, fallbackSplitOrientation: SplitOrientation?, @@ -52,6 +57,7 @@ public struct ClosedBrowserPanelRestoreSnapshot: BrowserPanelRestoreSnapshot { self.workspaceId = workspaceId self.url = url self.profileID = profileID + self.engineKind = engineKind self.originalPaneId = originalPaneId self.originalTabIndex = originalTabIndex self.fallbackSplitOrientation = fallbackSplitOrientation diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserApplicationProviderFake.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserApplicationProviderFake.swift new file mode 100644 index 00000000000..3d3db881f5d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserApplicationProviderFake.swift @@ -0,0 +1,15 @@ +@testable import CmuxBrowser + +@MainActor +final class BrowserApplicationProviderFake: BrowserApplicationProviding { + var defaultApplications: [BrowserApplication] + var chromiumApplications: [BrowserApplication] + + init(defaultApplications: [BrowserApplication], chromiumApplications: [BrowserApplication] = []) { + self.defaultApplications = defaultApplications + self.chromiumApplications = chromiumApplications + } + + func defaultBrowserApplications() -> [BrowserApplication] { defaultApplications } + func installedChromiumApplications() -> [BrowserApplication] { chromiumApplications } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift new file mode 100644 index 00000000000..e0cc38b6538 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift @@ -0,0 +1,57 @@ +import CmuxCore +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite struct BrowserEngineResolverTests { + private let resolver = BrowserEngineResolver() + + @Test(arguments: [ + "com.google.Chrome", + "com.google.Chrome.canary", + "org.chromium.Chromium", + "com.microsoft.edgemac", + "com.brave.Browser", + "company.thebrowser.Browser", + "com.vivaldi.Vivaldi", + "com.operasoftware.OperaGX", + ]) + func automaticUsesChromiumForChromiumHandlers(_ bundleIdentifier: String) { + #expect(resolver.resolve( + preference: .automatic, + defaultHandlerBundleIdentifiers: [bundleIdentifier] + ) == .chromium) + } + + @Test(arguments: [ + "com.apple.Safari", + "org.mozilla.firefox", + "app.zen-browser.zen", + "com.example.UnknownBrowser", + "", + ]) + func automaticUsesWebKitForSafariAndUnknownHandlers(_ bundleIdentifier: String) { + #expect(resolver.resolve( + preference: .automatic, + defaultHandlerBundleIdentifiers: [bundleIdentifier] + ) == .webKit) + } + + @Test func explicitPreferenceOverridesDefaultHandler() { + #expect(resolver.resolve( + preference: .webKit, + defaultHandlerBundleIdentifiers: ["com.google.Chrome"] + ) == .webKit) + #expect(resolver.resolve( + preference: .chromium, + defaultHandlerBundleIdentifiers: ["com.apple.Safari"] + ) == .chromium) + } + + @Test func automaticConsidersBothHTTPSAndHTTPHandlers() { + #expect(resolver.resolve( + preference: .automatic, + defaultHandlerBundleIdentifiers: ["com.example.UnknownBrowser", "com.brave.Browser"] + ) == .chromium) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineSelectionServiceTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineSelectionServiceTests.swift new file mode 100644 index 00000000000..74b8c41ad40 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineSelectionServiceTests.swift @@ -0,0 +1,47 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite @MainActor struct BrowserEngineSelectionServiceTests { + private static func application(_ bundleIdentifier: String) -> BrowserApplication { + BrowserApplication( + bundleIdentifier: bundleIdentifier, + bundleURL: URL(fileURLWithPath: "/Applications/Test.app"), + executableURL: URL(fileURLWithPath: "/Applications/Test.app/Contents/MacOS/Test") + ) + } + + @Test func automaticUsesLaunchServicesDefaultApplication() { + let chromium = Self.application("com.google.Chrome") + let service = BrowserEngineSelectionService( + applicationProvider: BrowserApplicationProviderFake(defaultApplications: [chromium]) + ) + + #expect(service.select(preference: .automatic) == BrowserEngineSelection( + kind: .chromium, + chromiumApplication: chromium + )) + } + + @Test func explicitChromiumUsesAnInstalledFallback() { + let safari = Self.application("com.apple.Safari") + let brave = Self.application("com.brave.Browser") + let service = BrowserEngineSelectionService( + applicationProvider: BrowserApplicationProviderFake( + defaultApplications: [safari], + chromiumApplications: [brave] + ) + ) + + #expect(service.select(preference: .chromium).chromiumApplication == brave) + } + + @Test func restoredEngineOverridesCurrentPreference() { + let service = BrowserEngineSelectionService( + applicationProvider: BrowserApplicationProviderFake(defaultApplications: []) + ) + + #expect(service.select(preference: .chromium, restoredKind: .webKit).kind == .webKit) + #expect(service.select(preference: .webKit, restoredKind: .chromium).kind == .chromium) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserJavaScriptValueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserJavaScriptValueTests.swift new file mode 100644 index 00000000000..d6c9dc201bc --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserJavaScriptValueTests.swift @@ -0,0 +1,30 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite struct BrowserJavaScriptValueTests { + @Test func copiesNestedFoundationValues() throws { + let value = try BrowserJavaScriptValue(foundationValue: [ + "enabled": true, + "count": 3, + "items": ["first", NSNull()], + ]) + + #expect(value == .object([ + "enabled": .bool(true), + "count": .number(3), + "items": .array([.string("first"), .null]), + ])) + } + + @Test func distinguishesUndefinedFromNull() throws { + #expect(try BrowserJavaScriptValue(foundationValue: nil) == .undefined) + #expect(try BrowserJavaScriptValue(foundationValue: NSNull()) == .null) + } + + @Test func rejectsNonTransferableValues() { + #expect(throws: BrowserEngineSessionError.self) { + try BrowserJavaScriptValue(foundationValue: Date()) + } + } +} diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEngineKind.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEngineKind.swift new file mode 100644 index 00000000000..cbe1c0cb6f7 --- /dev/null +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEngineKind.swift @@ -0,0 +1,10 @@ +import Foundation + +/// The web-content engine that renders a cmux browser surface. +public enum BrowserEngineKind: String, Codable, CaseIterable, Sendable { + /// Apple's WebKit engine hosted by `WKWebView`. + case webKit = "webkit" + + /// A Chromium engine controlled through the Chrome DevTools Protocol. + case chromium +} diff --git a/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEnginePreference.swift b/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEnginePreference.swift new file mode 100644 index 00000000000..330d7f84f27 --- /dev/null +++ b/Packages/macOS/CmuxCore/Sources/CmuxCore/Browser/BrowserEnginePreference.swift @@ -0,0 +1,13 @@ +import Foundation + +/// A user's browser-engine selection policy. +public enum BrowserEnginePreference: String, Codable, CaseIterable, Sendable { + /// Follow the engine family of the macOS default HTTP/HTTPS handler. + case automatic = "auto" + + /// Always use Apple's WebKit engine. + case webKit = "webkit" + + /// Always use a Chromium engine. + case chromium +} diff --git a/Packages/macOS/CmuxSettings/Package.swift b/Packages/macOS/CmuxSettings/Package.swift index 805bff95944..36cc645e25a 100644 --- a/Packages/macOS/CmuxSettings/Package.swift +++ b/Packages/macOS/CmuxSettings/Package.swift @@ -14,12 +14,14 @@ let package = Package( ), ], dependencies: [ + .package(path: "../CmuxCore"), .package(path: "../CmuxFoundation"), ], targets: [ .target( name: "CmuxSettings", dependencies: [ + .product(name: "CmuxCore", package: "CmuxCore"), .product(name: "CmuxFoundation", package: "CmuxFoundation"), ] ), diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/BrowserEnginePreference+SettingCodable.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/BrowserEnginePreference+SettingCodable.swift new file mode 100644 index 00000000000..eb606dc34c0 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Codable/BrowserEnginePreference+SettingCodable.swift @@ -0,0 +1,3 @@ +import CmuxCore + +extension BrowserEnginePreference: SettingCodable {} diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift index 353dbfb0e08..1f034d91fbf 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/BrowserCatalogSection.swift @@ -1,7 +1,15 @@ +import CmuxCore import Foundation /// Settings under the dotted-id prefix `browser.*`. public struct BrowserCatalogSection: SettingCatalogSection { + /// Chooses the engine used by newly-created browser surfaces. + public let engine = DefaultsKey( + id: "browser.engine", + defaultValue: .automatic, + userDefaultsKey: "browserEnginePreference" + ) + public let defaultSearchEngine = DefaultsKey( id: "browser.defaultSearchEngine", defaultValue: BrowserSearchSettingsStore.defaultSearchEngine, diff --git a/Packages/macOS/CmuxSettingsUI/Package.swift b/Packages/macOS/CmuxSettingsUI/Package.swift index 701b7f470db..e3e1e735450 100644 --- a/Packages/macOS/CmuxSettingsUI/Package.swift +++ b/Packages/macOS/CmuxSettingsUI/Package.swift @@ -15,6 +15,7 @@ let package = Package( ], dependencies: [ .package(path: "../../Shared/CMUXMobileCore"), + .package(path: "../CmuxCore"), .package(path: "../CmuxFoundation"), .package(path: "../CmuxSettings"), ], @@ -23,6 +24,7 @@ let package = Package( name: "CmuxSettingsUI", dependencies: [ "CMUXMobileCore", + .product(name: "CmuxCore", package: "CmuxCore"), .product(name: "CmuxFoundation", package: "CmuxFoundation"), .product(name: "CmuxSettings", package: "CmuxSettings"), ] diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserEngineSettingRow.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserEngineSettingRow.swift new file mode 100644 index 00000000000..982e845bb5e --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserEngineSettingRow.swift @@ -0,0 +1,45 @@ +import CmuxCore +import SwiftUI + +/// The browser-engine policy row in Browser settings. +@MainActor +struct BrowserEngineSettingRow: View { + let model: DefaultsValueModel + let controlWidth: CGFloat + + var body: some View { + SettingsCardRow( + configurationReview: .json("browser.engine"), + String(localized: "settings.browser.engine", defaultValue: "Browser Engine"), + subtitle: String( + localized: "settings.browser.engine.subtitle", + defaultValue: "Auto follows your macOS default browser for new tabs. Remote-proxied panes and cmux pages use WebKit." + ), + controlWidth: controlWidth + ) { + Picker("", selection: selection) { + ForEach(BrowserEnginePreference.allCases, id: \.self) { preference in + Text(label(for: preference)).tag(preference) + } + } + .labelsHidden() + .pickerStyle(.menu) + .accessibilityIdentifier("SettingsBrowserEnginePicker") + } + } + + private var selection: Binding { + Binding(get: { model.current }, set: { model.set($0) }) + } + + private func label(for preference: BrowserEnginePreference) -> String { + switch preference { + case .automatic: + return String(localized: "settings.browser.engine.auto", defaultValue: "Auto") + case .webKit: + return String(localized: "settings.browser.engine.webKit", defaultValue: "Safari (WebKit)") + case .chromium: + return String(localized: "settings.browser.engine.chromium", defaultValue: "Chromium") + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift index 9c38b72bbe7..bbc869c5de0 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/BrowserSection.swift @@ -1,5 +1,6 @@ import CmuxFoundation import AppKit +import CmuxCore import CmuxSettings import SwiftUI @@ -18,6 +19,7 @@ public struct BrowserSection: View { private let importAnchorID: String? @State private var disabled: DefaultsValueModel + @State private var browserEngine: DefaultsValueModel @State private var engine: DefaultsValueModel @State private var customName: DefaultsValueModel @State private var customURL: DefaultsValueModel @@ -49,6 +51,7 @@ public struct BrowserSection: View { self.hostActions = hostActions self.importAnchorID = importAnchorID _disabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.disabled)) + _browserEngine = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.engine)) _engine = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.defaultSearchEngine)) _customName = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.customSearchEngineName)) _customURL = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.browser.customSearchEngineURLTemplate)) @@ -85,7 +88,7 @@ public struct BrowserSection: View { Button(String(localized: "settings.browser.history.clearDialog.cancel", defaultValue: "Cancel"), role: .cancel) {} } message: { Text(String(localized: "settings.browser.history.clearDialog.message", defaultValue: "This removes visited-page suggestions from the browser omnibar.")) - }.task { startSettingsObservation([disabled, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } + }.task { startSettingsObservation([disabled, browserEngine, engine, customName, customURL, suggestions, theme, discardEnabled, discardDelay, askWhereToSaveDownloads, openTermLinks, interceptOpen, hosts, external, httpAllowlist, importHint, reactGrab]) } } @ViewBuilder @@ -107,6 +110,9 @@ public struct BrowserSection: View { } SettingsCardDivider() + BrowserEngineSettingRow(model: browserEngine, controlWidth: Self.columnWidth) + SettingsCardDivider() + // Default Search Engine SettingsCardRow( configurationReview: .json("browser.defaultSearchEngine"), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 87bca74b1e5..04fa1286c77 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -237756,6 +237756,1756 @@ } } } + }, + "browser.automation.initScript.timeout": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Timed out installing the browser initialization script." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザの初期化スクリプトのインストールがタイムアウトしました。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "انتهت مهلة تثبيت البرنامج النصي لتهيئة المتصفح." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isteklo je vreme za instaliranje skripte za inicijalizaciju pretraživača." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Timeout for installation af browserinitialiseringsscriptet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeitüberschreitung bei der Installation des Browser-Initialisierungsskripts." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se agotó el tiempo de instalación del script de inicialización del navegador." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le délai d'installation du script d'initialisation du navigateur a expiré." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Timeout durante l'installazione dello script di inizializzazione del browser." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អស់ពេលដំឡើងស្គ្រីបចាប់ផ្ដើមកម្មវិធីរុករកហើយ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 초기화 스크립트 설치 시간이 초과되었습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Tidsavbrudd for installasjon av nettleserinitialiseringsskriptet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Upłynął limit czasu instalacji skryptu inicjującego przeglądarkę." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tempo limite de instalação do script de inicialização do navegador." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Превышено время установки сценария инициализации браузера." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หมดเวลาการติดตั้งสคริปต์การเริ่มต้นเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı başlatma komut dosyasının yüklenmesi zaman aşımına uğradı." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Минув час очікування встановлення сценарію ініціалізації браузера." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "安装浏览器初始化脚本超时。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "安裝瀏覽器初始化腳本逾時。" + } + } + } + }, + "browser.automation.webkitOnly": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is available only for WebKit browser panes." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ はWebKitブラウザペインでのみ利用できます。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ متاح فقط لأجزاء متصفح WebKit." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ je dostupan samo za okna pretraživača WebKit." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ er kun tilgængelig for WebKit-browserruder." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ ist nur für WebKit-Browserbereiche verfügbar." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ está disponible solo para los paneles del navegador WebKit." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ est disponible uniquement pour les volets du navigateur WebKit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ è disponibile solo per i riquadri del browser WebKit." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@ មានសម្រាប់តែផ្ទាំងកម្មវិធីរុករកតាមអ៊ីនធឺណិត WebKit ប៉ុណ្ណោះ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@은 WebKit 브라우저 창에서만 사용할 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ er kun tilgjengelig for WebKit nettleserruter." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ jest dostępne tylko dla paneli przeglądarki WebKit." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ está disponível apenas para painéis do navegador WebKit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ доступен только для панелей браузера WebKit." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ ใช้ได้เฉพาะกับบานหน้าต่างเบราว์เซอร์ WebKit เท่านั้น" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ yalnızca WebKit tarayıcı bölmeleri için kullanılabilir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ доступний лише для панелей веб-переглядача WebKit." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 仅适用于 WebKit 浏览器窗格。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 僅適用於 WebKit 瀏覽器窗格。" + } + } + } + }, + "browser.chromium.accessibility.viewport": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium page" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumページ" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "صفحة كروميوم" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chromium stranica" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chromium-side" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium-Seite" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Página de cromo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Page Chrome" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Pagina Chromium" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ទំព័រ Chromium" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium 페이지" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chromium-side" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Strona Chroma" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Página do Chromium" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Страница Chromium" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "หน้าโครเมียม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Krom sayfası" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сторінка Chromium" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Chromium 页面" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Chromium 頁面" + } + } + } + }, + "browser.chromium.error.launchFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium could not start. Try another installed Chromium browser or switch to WebKit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumを起動できませんでした。別のインストール済みChromiumブラウザを試すか、WebKitに切り替えてください。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعذر بدء تشغيل Chromium. جرب متصفح Chromium آخر مثبتًا أو قم بالتبديل إلى WebKit." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chromium se nije mogao pokrenuti. Pokušajte s drugim instaliranim Chromium preglednikom ili se prebacite na WebKit." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chromium kunne ikke starte. Prøv en anden installeret Chromium-browser, eller skift til WebKit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium konnte nicht gestartet werden. Versuchen Sie es mit einem anderen installierten Chromium-Browser oder wechseln Sie zu WebKit." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Chromium no pudo iniciarse. Pruebe con otro navegador Chromium instalado o cambie a WebKit." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chromium n'a pas pu démarrer. Essayez un autre navigateur Chromium installé ou passez à WebKit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile avviare Chromium. Prova un altro browser Chromium installato o passa a WebKit." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Chromium មិនអាចចាប់ផ្តើមបានទេ។ សាកល្បងកម្មវិធីរុករកតាមអ៊ីនធឺណិត Chromium ដែលបានដំឡើងមួយផ្សេងទៀត ឬប្តូរទៅ WebKit ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium을 시작할 수 없습니다. 설치된 다른 Chromium 브라우저를 사용하거나 WebKit으로 전환하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chromium kunne ikke starte. Prøv en annen installert Chromium-nettleser eller bytt til WebKit." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie można uruchomić Chromium. Wypróbuj inną zainstalowaną przeglądarkę Chromium lub przełącz się na WebKit." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Não foi possível iniciar o Chromium. Experimente outro navegador Chromium instalado ou mude para o WebKit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось запустить Chromium. Попробуйте другой установленный браузер Chromium или переключитесь на WebKit." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Chromium ไม่สามารถเริ่มทำงานได้ ลองใช้เบราว์เซอร์ Chromium ตัวอื่นที่ติดตั้งไว้หรือเปลี่ยนไปใช้ WebKit" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium başlatılamadı. Yüklü başka bir Chromium tarayıcısını deneyin veya WebKit'e geçin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося запустити Chromium. Спробуйте інший встановлений браузер Chromium або перейдіть на WebKit." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Chromium 无法启动。尝试另一个已安装的 Chromium 浏览器或切换到 WebKit。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Chromium 無法啟動。嘗試另一個已安裝的 Chromium 瀏覽器或切換到 WebKit。" + } + } + } + }, + "browser.chromium.error.notInstalled": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium is selected, but no supported Chromium browser is installed." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumが選択されていますが、対応するChromiumブラウザがインストールされていません。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تحديد Chromium، ولكن لم يتم تثبيت متصفح Chromium المدعوم." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chromium je odabran, ali nije instaliran podržani Chromium pretraživač." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chromium er valgt, men ingen understøttet Chromium-browser er installeret." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium ist ausgewählt, aber kein unterstützter Chromium-Browser ist installiert." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Chromium está seleccionado, pero no hay ningún navegador Chromium compatible instalado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chromium est sélectionné, mais aucun navigateur Chromium pris en charge n'est installé." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chromium è selezionato, ma non è installato alcun browser Chromium supportato." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Chromium ត្រូវបានជ្រើសរើស ប៉ុន្តែមិនមានកម្មវិធីរុករក Chromium ដែលគាំទ្រត្រូវបានដំឡើងទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium이 선택되었지만 지원되는 Chromium 브라우저가 설치되어 있지 않습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chromium er valgt, men ingen støttet Chromium-nettleser er installert." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wybrano Chromium, ale nie zainstalowano obsługiwanej przeglądarki Chromium." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Chromium está selecionado, mas nenhum navegador Chromium compatível está instalado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбран Chromium, но поддерживаемый браузер Chromium не установлен." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เลือก Chromium ไว้ แต่ไม่มีการติดตั้งเบราว์เซอร์ Chromium ที่รองรับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium seçili ancak desteklenen bir Chromium tarayıcısı yüklü değil." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вибрано Chromium, але підтримуваний браузер Chromium не встановлено." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "选择了 Chromium,但未安装受支持的 Chromium 浏览器。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "選擇了 Chromium,但未安裝受支援的 Chromium 瀏覽器。" + } + } + } + }, + "browser.chromium.error.operationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium stopped responding. Reload the page or switch to WebKit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumが応答しなくなりました。ページを再読み込みするか、WebKitに切り替えてください。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "توقف Chromium عن الاستجابة. أعد تحميل الصفحة أو قم بالتبديل إلى WebKit." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chromium je prestao da odgovara. Ponovo učitajte stranicu ili pređite na WebKit." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chromium holdt op med at svare. Genindlæs siden eller skift til WebKit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium reagiert nicht mehr. Laden Sie die Seite neu oder wechseln Sie zu WebKit." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Chromium dejó de responder. Vuelva a cargar la página o cambie a WebKit." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chromium ne répond plus. Rechargez la page ou passez à WebKit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chromium ha smesso di rispondere. Ricarica la pagina o passa a WebKit." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Chromium ឈប់ឆ្លើយតប។ ផ្ទុកទំព័រឡើងវិញ ឬប្តូរទៅ WebKit ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium이 응답을 중지했습니다. 페이지를 다시 로드하거나 WebKit으로 전환하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chromium sluttet å svare. Last inn siden på nytt eller bytt til WebKit." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Chromium przestał odpowiadać. Załaduj ponownie stronę lub przełącz się na WebKit." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O Chromium parou de responder. Recarregue a página ou mude para o WebKit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Chromium перестал отвечать на запросы. Перезагрузите страницу или переключитесь на WebKit." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Chromium หยุดตอบสนอง โหลดหน้าซ้ำหรือเปลี่ยนไปใช้ WebKit" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium yanıt vermeyi durdurdu. Sayfayı yeniden yükleyin veya WebKit'e geçin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Chromium перестав відповідати. Перезавантажте сторінку або перейдіть на WebKit." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Chromium 停止响应。重新加载页面或切换到 WebKit。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Chromium 停止反應。重新載入頁面或切換到 WebKit。" + } + } + } + }, + "browser.chromium.starting": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Starting Chromium…" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumを起動中…" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "جارٍ بدء تشغيل Chromium..." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pokretanje Chromiuma…" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Starter Chromium..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium wird gestartet…" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Iniciando Chromium…" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Démarrage de Chrome…" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Avvio di Chromium…" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កំពុងចាប់ផ្តើម Chromium…" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium을 시작하는 중…" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Starter Chromium …" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Uruchamiam Chromium…" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Iniciando o Chromium…" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Запускаем Chromium…" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กำลังเริ่ม Chromium..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium başlatılıyor…" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Запуск Chromium…" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "启动 Chromium..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "啟動 Chromium..." + } + } + } + }, + "browser.engine.error.emptyScreenshot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The browser returned an empty screenshot." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから空のスクリーンショットが返されました。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض المتصفح لقطة شاشة فارغة." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraživač je vratio prazan snimak ekrana." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browseren returnerede et tomt skærmbillede." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Browser hat einen leeren Screenshot zurückgegeben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El navegador devolvió una captura de pantalla vacía." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le navigateur a renvoyé une capture d'écran vide." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il browser ha restituito uno screenshot vuoto." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កម្មវិធីរុករកតាមអ៊ីនធឺណិតបានត្រឡប់រូបថតអេក្រង់ទទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저가 빈 스크린샷을 반환했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleseren returnerte et tomt skjermbilde." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka zwróciła pusty zrzut ekranu." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O navegador retornou uma captura de tela vazia." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер вернул пустой снимок экрана." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์ส่งคืนภาพหน้าจอที่ว่างเปล่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı boş bir ekran görüntüsü döndürdü." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Браузер повернув порожній знімок екрана." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器返回空屏幕截图。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器返回空螢幕截圖。" + } + } + } + }, + "browser.engine.error.unsupportedJavaScriptValue": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "The browser returned an unsupported JavaScript value." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザから対応していないJavaScript値が返されました。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "قام المتصفح بإرجاع قيمة JavaScript غير مدعومة." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Pretraživač je vratio nepodržanu JavaScript vrijednost." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browseren returnerede en ikke-understøttet JavaScript-værdi." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Der Browser hat einen nicht unterstützten JavaScript-Wert zurückgegeben." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El navegador devolvió un valor de JavaScript no compatible." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Le navigateur a renvoyé une valeur JavaScript non prise en charge." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il browser ha restituito un valore JavaScript non supportato." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "កម្មវិធីរុករកតាមអ៊ីនធឺណិតបានត្រឡប់តម្លៃ JavaScript ដែលមិនគាំទ្រ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저가 지원되지 않는 JavaScript 값을 반환했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettleseren returnerte en JavaScript-verdi som ikke støttes." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeglądarka zwróciła nieobsługiwaną wartość JavaScript." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "O navegador retornou um valor JavaScript não suportado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузер вернул неподдерживаемое значение JavaScript." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบราว์เซอร์ส่งคืนค่า JavaScript ที่ไม่รองรับ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı desteklenmeyen bir JavaScript değeri döndürdü." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Браузер повернув непідтримуване значення JavaScript." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器返回了不受支持的 JavaScript 值。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器傳回了不支援的 JavaScript 值。" + } + } + } + }, + "settings.browser.engine": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Browser Engine" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ブラウザエンジン" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "محرك المتصفح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Motor pretraživača" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Browsermotor" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Browser-Engine" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Motor de navegador" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Moteur de navigateur" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Motore del browser" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ម៉ាស៊ីនកម្មវិធីរុករក" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "브라우저 엔진" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nettlesermotor" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Silnik przeglądarki" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Mecanismo do navegador" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Браузерный движок" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เอนจินเบราว์เซอร์" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tarayıcı Motoru" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Механізм браузера" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浏览器引擎" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "瀏覽器引擎" + } + } + } + }, + "settings.browser.engine.auto": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تلقائي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Automatski" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Automatisk" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Automatisch" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Automatique" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Automatico" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្វ័យប្រវត្តិ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Automatisk" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Automatycznie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Automático" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Авто" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อัตโนมัติ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Авто" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動" + } + } + } + }, + "settings.browser.engine.chromium": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Chromium" + } + } + } + }, + "settings.browser.engine.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Auto follows your macOS default browser for new tabs. Remote-proxied panes and cmux pages use WebKit." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "「自動」では、新しいタブにmacOSのデフォルトブラウザと同じエンジン系統を使用します。リモートプロキシ経由のペインとcmuxページではWebKitを使用します。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتبع تلقائيًا متصفح macOS الافتراضي الخاص بك لعلامات التبويب الجديدة. تستخدم الأجزاء الوكيلة عن بعد وصفحات cmux WebKit." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Automatski prati vaš macOS zadani pretraživač za nove kartice. Udaljeno proksi okna i cmux stranice koriste WebKit." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Auto følger din macOS-standardbrowser til nye faner. Fjernbetjente ruder og cmux-sider bruger WebKit." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Folgt bei neuen Tabs automatisch Ihrem macOS-Standardbrowser. Remote-Proxy-Bereiche und cmux-Seiten verwenden WebKit." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Auto sigue tu navegador predeterminado de macOS para nuevas pestañas. Los paneles con proxy remoto y las páginas cmux utilizan WebKit." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Auto suit votre navigateur par défaut macOS pour les nouveaux onglets. Les volets proxy distants et les pages cmux utilisent WebKit." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Segue automaticamente il browser predefinito di macOS per le nuove schede. I riquadri con proxy remoto e le pagine cmux utilizzano WebKit." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "ស្វ័យប្រវត្តិធ្វើតាមកម្មវិធីរុករកលំនាំដើម macOS របស់អ្នកសម្រាប់ផ្ទាំងថ្មី។ បន្ទះប្រូកស៊ីពីចម្ងាយ និងទំព័រ cmux ប្រើ WebKit ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자동은 새 탭에 대해 macOS 기본 브라우저를 따릅니다. 원격 프록시 창과 cmux 페이지는 WebKit을 사용합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Auto følger standardnettleseren din for macOS for nye faner. Ekstern proxy-ruter og cmux-sider bruker WebKit." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Automatycznie podąża za domyślną przeglądarką macOS w przypadku nowych kart. Zdalne okienka proxy i strony cmux korzystają z WebKit." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Auto segue o navegador padrão do macOS para novas guias. Painéis com proxy remoto e páginas cmux usam WebKit." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автоматически следует за браузером по умолчанию в macOS для новых вкладок. Панели с удаленным проксированием и страницы cmux используют WebKit." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตามเบราว์เซอร์เริ่มต้น macOS ของคุณโดยอัตโนมัติสำหรับแท็บใหม่ บานหน้าต่างพร็อกซีระยะไกลและหน้า cmux ใช้ WebKit" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Otomatik, yeni sekmeler için macOS varsayılan tarayıcınızı takip eder. Uzaktan proxy'li bölmeler ve cmux sayfaları WebKit'i kullanır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Автоматичний режим використовує для нових вкладок рушій браузера macOS за замовчуванням. Панелі з віддаленим проксі та сторінки cmux використовують WebKit." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自动遵循 macOS 默认浏览器打开新选项卡。远程代理窗格和 cmux 页面使用 WebKit。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自動遵循 macOS 預設瀏覽器開啟新分頁。遠端代理窗格和 cmux 頁面使用 WebKit。" + } + } + } + }, + "settings.browser.engine.webKit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Safari(WebKit)" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Safari (WebKit)" + } + } + } } }, "version": "1.0" diff --git a/Sources/CmuxSettingsJSONPathSupport.swift b/Sources/CmuxSettingsJSONPathSupport.swift index 86a5346f29e..68e7650253d 100644 --- a/Sources/CmuxSettingsJSONPathSupport.swift +++ b/Sources/CmuxSettingsJSONPathSupport.swift @@ -1,3 +1,4 @@ +import CmuxCore import CmuxSettings import Foundation @@ -451,6 +452,7 @@ extension CmuxSettingsFileStore { "automation.kiroNotificationLevel", "automation.portBase", "automation.portRange", + "browser.engine", "browser.defaultSearchEngine", "browser.customSearchEngineName", "browser.customSearchEngineURLTemplate", diff --git a/Sources/DockSplitStore.swift b/Sources/DockSplitStore.swift index 9fd1c38c4db..eae44876afc 100644 --- a/Sources/DockSplitStore.swift +++ b/Sources/DockSplitStore.swift @@ -256,6 +256,7 @@ final class DockSplitStore: BonsplitDelegate { tmuxStartCommand: String? = nil, focus: Bool = true, preferredProfileID: UUID? = nil, + browserEngineKind: BrowserEngineKind? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> UUID? { ensureLoaded() @@ -268,6 +269,7 @@ final class DockSplitStore: BonsplitDelegate { workingDirectory: workingDirectory ?? currentBaseDirectory(), tmuxStartCommand: tmuxStartCommand, preferredProfileID: preferredProfileID, + browserEngineKind: browserEngineKind, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) else { return nil } let previousFocus = focus ? nil : focusedDockPaneSelection() @@ -438,6 +440,7 @@ final class DockSplitStore: BonsplitDelegate { workingDirectory: String, tmuxStartCommand: String? = nil, preferredProfileID: UUID? = nil, + browserEngineKind: BrowserEngineKind? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> (any Panel)? { switch kind { @@ -460,6 +463,7 @@ final class DockSplitStore: BonsplitDelegate { url: url, initialRequest: initialRequest, preferredProfileID: preferredProfileID, + browserEngineKind: browserEngineKind, bypassInsecureHTTPHostOnce: bypassInsecureHTTPHostOnce ) } diff --git a/Sources/Find/BrowserFindWebViewEvaluator.swift b/Sources/Find/BrowserFindWebViewEvaluator.swift index 57c58ef80d7..8ab7291f7b5 100644 --- a/Sources/Find/BrowserFindWebViewEvaluator.swift +++ b/Sources/Find/BrowserFindWebViewEvaluator.swift @@ -1,7 +1,7 @@ import CmuxBrowser import WebKit -/// Evaluates find-in-page scripts against the current `WKWebView` of a `BrowserPanel`. +/// Evaluates find-in-page scripts through the current engine of a `BrowserPanel`. /// /// `BrowserPanel` owns its `BrowserFindService`, which owns this adapter, which holds a `weak` /// reference back to the panel. The weak back-reference breaks what would otherwise be a retain @@ -19,6 +19,6 @@ final class BrowserFindWebViewEvaluator: BrowserFindScriptEvaluating { func evaluate(_ script: BrowserFindScript) async throws -> Any? { guard let panel else { return nil } - return try await panel.webView.evaluateJavaScript(script.source) + return try await panel.evaluateJavaScript(script.source) } } diff --git a/Sources/KeyboardShortcutSettingsFileStore+Template.swift b/Sources/KeyboardShortcutSettingsFileStore+Template.swift index fc6d61cd376..126de48a5e8 100644 --- a/Sources/KeyboardShortcutSettingsFileStore+Template.swift +++ b/Sources/KeyboardShortcutSettingsFileStore+Template.swift @@ -1,3 +1,4 @@ +import CmuxCore import CmuxSettings import Foundation @@ -191,6 +192,7 @@ extension CmuxSettingsFileStore { ], [ "browser": [ + "engine": BrowserEnginePreference.automatic.rawValue, "defaultSearchEngine": BrowserSearchSettingsStore.defaultSearchEngine.rawValue, "customSearchEngineName": BrowserSearchSettingsStore.defaultCustomSearchEngineName, "customSearchEngineURLTemplate": BrowserSearchSettingsStore.defaultCustomSearchEngineURLTemplate, diff --git a/Sources/KeyboardShortcutSettingsFileStore.swift b/Sources/KeyboardShortcutSettingsFileStore.swift index 730e7bb99f2..9173d1eb377 100644 --- a/Sources/KeyboardShortcutSettingsFileStore.swift +++ b/Sources/KeyboardShortcutSettingsFileStore.swift @@ -1,4 +1,5 @@ import Combine +import CmuxCore import CmuxFoundation import CmuxSettings import Foundation @@ -899,6 +900,13 @@ final class CmuxSettingsFileStore { ) { let browserSearchSettings = BrowserSearchSettingsStore() + if let raw = jsonString(section["engine"]) { + guard let preference = BrowserEnginePreference(rawValue: raw) else { + logInvalid("browser.engine", sourcePath: sourcePath) + return + } + snapshot.managedUserDefaults[SettingCatalog().browser.engine.userDefaultsKey] = .string(preference.rawValue) + } if let raw = jsonString(section["defaultSearchEngine"]) { guard let engine = BrowserSearchEngine(rawValue: raw) else { logInvalid("browser.defaultSearchEngine", sourcePath: sourcePath) diff --git a/Sources/Panels/BrowserEngineSelection+Current.swift b/Sources/Panels/BrowserEngineSelection+Current.swift new file mode 100644 index 00000000000..eea3b71ad40 --- /dev/null +++ b/Sources/Panels/BrowserEngineSelection+Current.swift @@ -0,0 +1,21 @@ +import CmuxBrowser +import CmuxCore +import CmuxSettings +import Foundation + +extension BrowserEngineSelection { + /// Resolves a production browser-engine selection from the typed settings key and LaunchServices. + @MainActor + static func current( + restoring restoredKind: BrowserEngineKind? = nil, + defaults: UserDefaults = .standard + ) -> BrowserEngineSelection { + let catalog = SettingCatalog() + let store = UserDefaultsSettingsStore(defaults: defaults) + let preference = store.initialValue(for: catalog.browser.engine) + let service = BrowserEngineSelectionService( + applicationProvider: LaunchServicesBrowserApplicationProvider() + ) + return service.select(preference: preference, restoredKind: restoredKind) + } +} diff --git a/Sources/Panels/BrowserPanel+AutomationRecovery.swift b/Sources/Panels/BrowserPanel+AutomationRecovery.swift index 3eaf1179866..f0c2980daa2 100644 --- a/Sources/Panels/BrowserPanel+AutomationRecovery.swift +++ b/Sources/Panels/BrowserPanel+AutomationRecovery.swift @@ -3,38 +3,19 @@ import CmuxBrowser import WebKit extension BrowserPanel { - func registerBrowserAutomationInitScript(_ userScript: WKUserScript) -> Int { - browserAutomationUserScripts.append(userScript) - browserAutomationInitScriptCount += 1 - webView.configuration.userContentController.addUserScript(userScript) - return browserAutomationInitScriptCount - } - - func registerBrowserAutomationStyleScript(_ userScript: WKUserScript) -> Int { - browserAutomationUserScripts.append(userScript) - browserAutomationStyleScriptCount += 1 - webView.configuration.userContentController.addUserScript(userScript) - return browserAutomationStyleScriptCount - } - func clearBrowserAutomationUserScripts() { - browserAutomationUserScripts.removeAll() - browserAutomationInitScriptCount = 0 - browserAutomationStyleScriptCount = 0 + engineInitializationScripts.removeAll() + engineInitializationScriptCounts.removeAll() } func makeReplacementWebView( profileID: UUID, websiteDataStore: WKWebsiteDataStore ) -> CmuxWebView { - let replacement = Self.makeWebView( + Self.makeWebView( profileID: profileID, websiteDataStore: websiteDataStore ) - for userScript in browserAutomationUserScripts { - replacement.configuration.userContentController.addUserScript(userScript) - } - return replacement } var canRecoverFromAutomationTimeout: Bool { @@ -58,28 +39,41 @@ extension BrowserPanel { guard canRecoverFromAutomationTimeout else { return .responsive } let observedWebViewInstanceID = webViewInstanceID - let asyncJavaScriptProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in + let pageJavaScriptProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in guard let self, ObjectIdentifier(webView) == expectedWebViewIdentifier, webViewInstanceID == observedWebViewInstanceID else { finish() return } - webView.callAsyncJavaScript( - "return true", - arguments: [:], - in: nil, - in: .page - ) { _ in finish() } + Task { @MainActor [weak self] in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + _ = try? await engineSession.evaluateJavaScript("true", in: .page) + finish() + } } - let evaluationProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in + let isolatedJavaScriptProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in guard let self, ObjectIdentifier(webView) == expectedWebViewIdentifier, webViewInstanceID == observedWebViewInstanceID else { finish() return } - webView.evaluateJavaScript("void 0") { _, _ in finish() } + Task { @MainActor [weak self] in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + _ = try? await engineSession.evaluateJavaScript("void 0", in: .isolated) + finish() + } } let snapshotProbe: BrowserAutomationWatchdog.Probe = { [weak self] finish in guard let self, @@ -88,15 +82,22 @@ extension BrowserPanel { finish() return } - let configuration = WKSnapshotConfiguration() - configuration.rect = NSRect(x: 0, y: 0, width: 1, height: 1) - webView.takeSnapshot(with: configuration) { _, _ in finish() } + Task { @MainActor [weak self] in + guard let self, + ObjectIdentifier(webView) == expectedWebViewIdentifier, + webViewInstanceID == observedWebViewInstanceID else { + finish() + return + } + _ = try? await engineSession.captureScreenshot() + finish() + } } let outcome = await automationWatchdog.recoverIfUnresponsive( observedInstanceID: observedWebViewInstanceID, - // One WebContent process services every automation API. Probing all callback channels - // lets JavaScript and screenshot callers safely share this single in-flight check. - probes: [asyncJavaScriptProbe, evaluationProbe, snapshotProbe], + // Probe every engine-neutral automation channel so JavaScript and screenshot callers + // can safely share this single in-flight responsiveness check. + probes: [pageJavaScriptProbe, isolatedJavaScriptProbe, snapshotProbe], recover: { [weak self] in self?.replaceWebViewAfterAutomationTimeout( expectedWebViewIdentifier: expectedWebViewIdentifier, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 98666c06889..f2b96b9b24a 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2619,6 +2619,11 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { + enum AutomationInitializationScriptKind { + case script + case style + } + /// Popup windows owned by this panel (for lifecycle cleanup) private var popupControllers: [BrowserPopupWindowController] = [] @@ -2727,12 +2732,27 @@ final class BrowserPanel: Panel, ObservableObject { @Published private(set) var profileID: UUID @Published private(set) var historyStore: BrowserHistoryStore - /// The underlying web view + /// The engine family selected when this surface was created. + private(set) var engineKind: BrowserEngineKind + + /// The engine-neutral owner of navigation, automation, and page capture. + private let chromiumApplication: BrowserApplication? + private var engineSessionStorage: (any BrowserEngineSession)? + private var engineStateTask: Task? + var engineInitializationScripts: [String] = [] + var engineInitializationScriptCounts: [AutomationInitializationScriptKind: Int] = [:] + + var engineSession: any BrowserEngineSession { + guard let engineSessionStorage else { + preconditionFailure("Browser engine session accessed before initialization") + } + return engineSessionStorage + } + + /// The portal-hosted view. For Chromium this is a private canvas transport, + /// not the page engine consumed by browser behavior or automation. private(set) var webView: WKWebView var websiteDataStore: WKWebsiteDataStore - var browserAutomationUserScripts: [WKUserScript] = [] - var browserAutomationInitScriptCount = 0 - var browserAutomationStyleScriptCount = 0 var webViewDidRequestClose: (() -> Void)? /// Monotonic identity for the current WKWebView instance. @@ -3278,6 +3298,9 @@ final class BrowserPanel: Panel, ObservableObject { @discardableResult func discardHiddenWebViewForMemory(reason: String, now: Date = Date()) -> Bool { + // Chromium owns a separate process and navigation history. Replacing only + // its local viewport WKWebView would orphan that session. + guard engineKind == .webKit else { return false } let blockers = hiddenWebViewDiscardBlockers() guard blockers.isEmpty else { return false } @@ -3317,6 +3340,7 @@ final class BrowserPanel: Panel, ObservableObject { webViewInstanceID = UUID() hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement + replaceEngineSession(for: replacement) hiddenWebViewDiscardManager.markDiscarded(reason: reason, now: now) currentURL = restoreURL shouldRenderWebView = false @@ -3543,7 +3567,10 @@ final class BrowserPanel: Panel, ObservableObject { // white before content loads. Do not force page appearance or inject color-scheme CSS; // websites must keep control of their own theme. webView.underPageBackgroundColor = GhosttyBackgroundTheme.currentColor() - // Always present as Safari. + // This WKWebView is the page engine for WebKit panes and only the local + // viewport transport for Chromium panes. Keeping the existing Safari + // override is harmless for the transport document and preserves the + // WebKit behavior. webView.customUserAgent = BrowserUserAgentSettings.safariUserAgent return webView } @@ -3934,16 +3961,26 @@ final class BrowserPanel: Panel, ObservableObject { proxyEndpoint: BrowserProxyEndpoint? = nil, bypassRemoteProxy: Bool = false, isRemoteWorkspace: Bool = false, - remoteWebsiteDataStoreIdentifier: UUID? = nil + remoteWebsiteDataStoreIdentifier: UUID? = nil, + engineSelection: BrowserEngineSelection = .webKit ) { // Register fallback defaults and normalize legacy/out-of-range settings once // per process, before any setting is read below or by the SwiftUI view. Self.bootstrapBrowserDefaultsIfNeeded() - self.id = UUID() + let requestedInitialURL = initialRequest?.url ?? initialURL + let effectiveEngineSelection = Self.requiresWebKit( + for: requestedInitialURL, + isRemoteWorkspace: isRemoteWorkspace, + bypassRemoteProxy: bypassRemoteProxy + ) ? .webKit : engineSelection + let panelID = UUID() + self.id = panelID self.workspaceId = workspaceId let resolvedProfileID = Self.resolvedProfileID(requested: profileID) self.profileID = resolvedProfileID self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) + self.engineKind = effectiveEngineSelection.kind + self.chromiumApplication = effectiveEngineSelection.chromiumApplication self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.bypassesRemoteWorkspaceProxy = bypassRemoteProxy self.remoteProxyEndpoint = bypassRemoteProxy ? nil : proxyEndpoint @@ -3975,9 +4012,19 @@ final class BrowserPanel: Panel, ObservableObject { ) } self.webView = webView + self.engineSessionStorage = Self.makeEngineSession( + kind: effectiveEngineSelection.kind, + chromiumApplication: effectiveEngineSelection.chromiumApplication, + webView: webView, + profileID: resolvedProfileID, + surfaceID: panelID, + initializationScripts: [] + ) self.insecureHTTPAlertFactory = { NSAlert() } hiddenWebViewDiscardManager.delegate = self - applyProxyConfigurationIfAvailable() + if engineKind == .webKit { + applyProxyConfigurationIfAvailable() + } BrowserProfileStore.shared.noteUsed(resolvedProfileID) // Set up navigation delegate @@ -4158,10 +4205,15 @@ final class BrowserPanel: Panel, ObservableObject { } self.uiDelegate = browserUIDelegate - bindWebView(webView) - installDetachedDeveloperToolsWindowCloseObserver() - installHiddenWebViewDiscardPolicyObserver() - applyBrowserThemeModeIfNeeded() + if engineKind == .webKit { + bindWebView(webView) + installDetachedDeveloperToolsWindowCloseObserver() + installHiddenWebViewDiscardPolicyObserver() + applyBrowserThemeModeIfNeeded() + } else { + bindChromiumViewport(webView) + observeEngineState() + } ReactGrabScriptLoader.prefetch() insecureHTTPAlertWindowProvider = { [weak self] in if let self, let window = browserInteractiveModalHostWindow(for: self.webView) { @@ -4207,6 +4259,97 @@ final class BrowserPanel: Panel, ObservableObject { } } + private static func requiresWebKit( + for initialURL: URL?, + isRemoteWorkspace: Bool, + bypassRemoteProxy: Bool + ) -> Bool { + if isRemoteWorkspace && !bypassRemoteProxy { + return true + } + guard let scheme = initialURL?.scheme?.lowercased() else { return false } + return !["http", "https", "about", "data", "file"].contains(scheme) + } + + private func bindChromiumViewport(_ webView: CmuxWebView) { + webView.onMouseBackButton = { [weak self] in self?.goBack() } + webView.onMouseForwardButton = { [weak self] in self?.goForward() } + } + + private static func makeEngineSession( + kind: BrowserEngineKind, + chromiumApplication: BrowserApplication?, + webView: WKWebView, + profileID: UUID, + surfaceID: UUID, + initializationScripts: [String] + ) -> any BrowserEngineSession { + switch kind { + case .webKit: + for script in initializationScripts { + webView.configuration.userContentController.addUserScript( + WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false) + ) + } + return WebKitBrowserEngineSession(webView: webView) + case .chromium: + return ChromiumBrowserEngineSession( + viewportWebView: webView, + application: chromiumApplication, + userDataDirectory: BrowserChromiumProfileDirectory().url( + profileID: profileID, + surfaceID: surfaceID, + sessionID: UUID() + ), + initializationScripts: initializationScripts + ) + } + } + + private func replaceEngineSession(for webView: WKWebView) { + engineStateTask?.cancel() + engineStateTask = nil + engineSessionStorage?.close() + engineSessionStorage = Self.makeEngineSession( + kind: engineKind, + chromiumApplication: chromiumApplication, + webView: webView, + profileID: profileID, + surfaceID: id, + initializationScripts: engineInitializationScripts + ) + if engineKind == .chromium, let webView = webView as? CmuxWebView { + bindChromiumViewport(webView) + observeEngineState() + } + } + + private func observeEngineState() { + guard engineKind == .chromium else { return } + applyChromiumEngineState(engineSession.state) + let updates = engineSession.stateUpdates + engineStateTask = Task { @MainActor [weak self] in + for await state in updates { + guard let self else { return } + self.applyChromiumEngineState(state) + } + } + } + + private func applyChromiumEngineState(_ state: BrowserEngineState) { + currentURL = state.url ?? currentURL + let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) + pageTitle = title + isLoading = state.isLoading + estimatedProgress = state.isLoading ? 0.2 : 1 + nativeCanGoBack = state.canGoBack + nativeCanGoForward = state.canGoForward + refreshNavigationAvailability() + if !state.isLoading, state.errorMessage == nil, let url = state.url { + historyStore.recordVisit(url: url, title: title.isEmpty ? nil : title) + } + } + @discardableResult private func ensureBackgroundPreloadHostIfNeeded(reason: String) -> Bool { if let preloadWindow = backgroundPreloadWindow { @@ -4388,6 +4531,7 @@ final class BrowserPanel: Panel, ObservableObject { } private func applyProxyConfigurationIfAvailable() { + guard engineKind == .webKit else { return } guard #available(macOS 14.0, *) else { return } let store = webView.configuration.websiteDataStore @@ -4558,10 +4702,14 @@ final class BrowserPanel: Panel, ObservableObject { ? WKWebsiteDataStore(forIdentifier: remoteWebsiteDataStoreIdentifier ?? newWorkspaceId) : BrowserProfileStore.shared.websiteDataStore(for: profileID) let needsStoreSwap = webView.configuration.websiteDataStore !== targetStore + let needsEngineDowngrade = engineKind == .chromium && isRemoteWorkspace && !bypassesRemoteWorkspaceProxy + if needsEngineDowngrade { + engineKind = .webKit + } websiteDataStore = targetStore remoteProxyEndpoint = bypassesRemoteWorkspaceProxy ? nil : proxyEndpoint remoteWorkspaceStatus = remoteStatus - if needsStoreSwap { + if needsStoreSwap || needsEngineDowngrade { clearBrowserAutomationUserScripts() replaceWebViewPreservingState( from: webView, @@ -4569,6 +4717,10 @@ final class BrowserPanel: Panel, ObservableObject { reason: "workspace_reattach" ) } + if needsEngineDowngrade { + installDetachedDeveloperToolsWindowCloseObserver() + installHiddenWebViewDiscardPolicyObserver() + } applyProxyConfigurationIfAvailable() resumePendingRemoteNavigationIfNeeded() } @@ -4633,13 +4785,16 @@ final class BrowserPanel: Panel, ObservableObject { hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement + replaceEngineSession(for: replacement) currentURL = restoreURL shouldRenderWebView = wasRenderable refreshWebViewLifecycleState() - bindWebView(replacement) - applyProxyConfigurationIfAvailable() - applyBrowserThemeModeIfNeeded() + if engineKind == .webKit { + bindWebView(replacement) + applyProxyConfigurationIfAvailable() + applyBrowserThemeModeIfNeeded() + } if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { restoreSessionNavigationHistory( @@ -4807,6 +4962,9 @@ final class BrowserPanel: Panel, ObservableObject { requestPath: components.requestPath ) } + if engineKind == .chromium { + return !Self.isTemporarySessionHistoryURL(currentURL) + } guard !Self.isTemporarySessionHistoryURL(webView.url), !Self.isTemporarySessionHistoryURL(currentURL), !Self.isTemporarySessionHistoryURL(restoredHistoryCurrentURL) else { @@ -5141,11 +5299,14 @@ final class BrowserPanel: Panel, ObservableObject { hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement + replaceEngineSession(for: replacement) shouldRenderWebView = wasRenderable refreshWebViewLifecycleState() - bindWebView(replacement) - applyBrowserThemeModeIfNeeded() + if engineKind == .webKit { + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + } if !history.backHistoryURLStrings.isEmpty || !history.forwardHistoryURLStrings.isEmpty { restoreSessionNavigationHistory( @@ -5324,6 +5485,10 @@ final class BrowserPanel: Panel, ObservableObject { webViewDidRequestClose = nil detachWebViewObservers() faviconTask?.cancel(); faviconTask = nil + engineStateTask?.cancel() + engineStateTask = nil + engineSessionStorage?.close() + engineSessionStorage = nil } // MARK: - Popup window management @@ -5765,6 +5930,7 @@ final class BrowserPanel: Panel, ObservableObject { ) { cancelHiddenWebViewDiscard() clearWebContentTerminationRecovery() + downgradeToWebKitIfRequired(for: originalURL) if !preserveRestoredSessionHistory { abandonRestoredSessionHistoryIfNeeded() } @@ -5784,6 +5950,10 @@ final class BrowserPanel: Panel, ObservableObject { } noteDiscardedWebViewRestoreNavigationStarted() userStoppedLoadSinceWebViewReplacement = false + if engineKind == .chromium { + engineSession.load(effectiveRequest) + return + } let startedNavigation = browserLoadRequest(effectiveRequest, in: webView) if startedNavigation == nil { noteDiscardedWebViewRestoreNavigationDidNotCommit(reason: "navigation_not_started") @@ -5792,6 +5962,26 @@ final class BrowserPanel: Panel, ObservableObject { } } + private func downgradeToWebKitIfRequired(for url: URL) { + guard engineKind == .chromium, + Self.requiresWebKit( + for: url, + isRemoteWorkspace: usesRemoteWorkspaceProxy, + bypassRemoteProxy: bypassesRemoteWorkspaceProxy + ) else { + return + } + engineKind = .webKit + replaceEngineSession(for: webView) + if let webView = webView as? CmuxWebView { + bindWebView(webView) + } + installDetachedDeveloperToolsWindowCloseObserver() + installHiddenWebViewDiscardPolicyObserver() + applyProxyConfigurationIfAvailable() + applyBrowserThemeModeIfNeeded() + } + private func remoteProxyPreparedRequest(from request: URLRequest, logScope: String) -> URLRequest { guard remoteProxyEndpoint != nil else { return request } guard let url = request.url else { return request } @@ -6142,10 +6332,13 @@ extension BrowserPanel { webViewInstanceID = UUID() hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement + replaceEngineSession(for: replacement) shouldRenderWebView = false refreshWebViewLifecycleState() - bindWebView(replacement) - applyBrowserThemeModeIfNeeded() + if engineKind == .webKit { + bindWebView(replacement) + applyBrowserThemeModeIfNeeded() + } refreshNavigationAvailability() #if DEBUG @@ -6221,8 +6414,8 @@ private func browserDottedHostWithPortCandidate(_ input: String, schemeCandidate extension BrowserPanel { private func cancelInFlightNavigationBeforeHistoryTraversal() { - guard webView.isLoading || isMainFrameProvisionalNavigationActive else { return } - webView.stopLoading() + guard isLoading || webView.isLoading || isMainFrameProvisionalNavigationActive else { return } + engineSession.stopLoading() isMainFrameProvisionalNavigationActive = false } @@ -6263,14 +6456,14 @@ extension BrowserPanel { preserveRestoredSessionHistory: true ) case .nativeGoBack: - webView.goBack() + engineSession.goBack() case .nativeGoForward, .refreshOnly: refreshNavigationAvailability() } return } - webView.goBack() + engineSession.goBack() } /// Go forward in history @@ -6287,7 +6480,7 @@ extension BrowserPanel { ) switch decision { case .nativeGoForward: - webView.goForward() + engineSession.goForward() case .navigate(let targetURL): refreshNavigationAvailability() navigateWithoutInsecureHTTPPrompt( @@ -6301,7 +6494,7 @@ extension BrowserPanel { return } - webView.goForward() + engineSession.goForward() } /// Open a link in a new browser surface in the same pane @@ -6361,6 +6554,7 @@ extension BrowserPanel { initialRequest: seed.initialRequest, focus: true, preferredProfileID: profileID, + browserEngineKind: engineKind, bypassInsecureHTTPHostOnce: seed.bypassInsecureHTTPHostOnce ) else { #if DEBUG @@ -6414,25 +6608,50 @@ extension BrowserPanel { /// Reload the current page func reload() { + if engineKind == .chromium { + reloadChromium(ignoreCache: false) + return + } if prepareForReload(reason: "reload", mode: .soft) { return } - webView.reload() + engineSession.reload() } /// Reload the current page, bypassing WebKit's cache. func hardReload() { + if engineKind == .chromium { + reloadChromium(ignoreCache: true) + return + } if prepareForReload(reason: "hardReload", mode: .hard) { return } - webView.reloadFromOrigin() + engineSession.reloadFromOrigin() + } + + private func reloadChromium(ignoreCache: Bool) { + guard engineSession.state.errorMessage != nil else { + if ignoreCache { + engineSession.reloadFromOrigin() + } else { + engineSession.reload() + } + return + } + + let request = currentURL.map { URLRequest(url: $0) } + replaceEngineSession(for: webView) + if let request { + engineSession.load(request) + } } /// Stop loading func stopLoading() { // Fail closed: a reveal must never blank-shell-heal over an explicit Stop. userStoppedLoadSinceWebViewReplacement = true - webView.stopLoading() + engineSession.stopLoading() isMainFrameProvisionalNavigationActive = false } @@ -7013,7 +7232,14 @@ extension BrowserPanel { } func captureAutomationVisibleViewportSnapshot() async throws -> NSImage { - try await withCheckedThrowingContinuation { continuation in + if engineKind == .chromium { + let data = try await engineSession.captureScreenshot() + guard let image = NSImage(data: data) else { + throw BrowserEngineSessionError.emptyScreenshot + } + return image + } + return try await withCheckedThrowingContinuation { continuation in captureAutomationVisibleViewportSnapshot { result in continuation.resume(with: result) } @@ -7023,6 +7249,21 @@ extension BrowserPanel { func captureAutomationVisibleViewportSnapshot( completion: @escaping (Result) -> Void ) { + if engineKind == .chromium { + Task { @MainActor [weak self] in + guard let self else { return } + do { + let data = try await self.engineSession.captureScreenshot() + guard let image = NSImage(data: data) else { + throw BrowserEngineSessionError.emptyScreenshot + } + completion(.success(image)) + } catch { + completion(.failure(error)) + } + } + return + } guard visualAutomationCaptureGate.begin() else { completion(.failure(BrowserScreenshotError.emptySnapshot)) return @@ -7146,7 +7387,24 @@ extension BrowserPanel { /// Execute JavaScript func evaluateJavaScript(_ script: String) async throws -> Any? { - try await webView.evaluateJavaScript(script) + try await engineSession.evaluateJavaScript(script).foundationValue + } + + /// Executes JavaScript in the requested engine content world. + func evaluateJavaScript(_ script: String, in world: BrowserJavaScriptWorld) async throws -> Any? { + try await engineSession.evaluateJavaScript(script, in: world).foundationValue + } + + /// Installs JavaScript at document start through the active browser engine. + func addInitializationScript( + _ script: String, + kind: AutomationInitializationScriptKind + ) async throws -> Int { + try await engineSession.addInitializationScript(script) + engineInitializationScripts.append(script) + let count = (engineInitializationScriptCounts[kind] ?? 0) + 1 + engineInitializationScriptCounts[kind] = count + return count } // MARK: - Find in Page @@ -11309,12 +11567,10 @@ extension BrowserPanel { } } -/// Bridges `BrowserOmnibarPageFocusRepository` to a panel's live `WKWebView`. +/// Bridges `BrowserOmnibarPageFocusRepository` to a panel's live engine. /// /// Holds the panel weakly so the panel (which owns the repository, which owns -/// this adapter) does not form a retain cycle. Always reads `panel.webView` at -/// call time because the panel reassigns its web view across navigations and -/// profile switches. +/// this adapter) does not form a retain cycle. @MainActor private final class BrowserOmnibarPageFocusAdapter: BrowserOmnibarScriptEvaluating { private weak var panel: BrowserPanel? @@ -11331,9 +11587,11 @@ private final class BrowserOmnibarPageFocusAdapter: BrowserOmnibarScriptEvaluati completion(nil, nil) return } - panel.webView.evaluateJavaScript(script) { result, error in - MainActor.assumeIsolated { - completion(result, error) + Task { @MainActor in + do { + completion(try await panel.evaluateJavaScript(script), nil) + } catch { + completion(nil, error) } } } diff --git a/Sources/SessionPersistence.swift b/Sources/SessionPersistence.swift index f719b55e224..9d3bff539e0 100644 --- a/Sources/SessionPersistence.swift +++ b/Sources/SessionPersistence.swift @@ -1509,6 +1509,7 @@ struct SessionTextBoxInputAttachmentSnapshot: Codable, Equatable, Sendable { struct SessionBrowserPanelSnapshot: Codable, Sendable { var urlString: String? var profileID: UUID? + var engineKind: BrowserEngineKind? var shouldRenderWebView: Bool var pageZoom: Double var developerToolsVisible: Bool @@ -1528,6 +1529,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { init( urlString: String?, profileID: UUID?, + engineKind: BrowserEngineKind? = nil, shouldRenderWebView: Bool, pageZoom: Double, developerToolsVisible: Bool, @@ -1541,6 +1543,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { ) { self.urlString = urlString self.profileID = profileID + self.engineKind = engineKind self.shouldRenderWebView = shouldRenderWebView self.pageZoom = pageZoom self.developerToolsVisible = developerToolsVisible @@ -1556,6 +1559,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { private enum CodingKeys: String, CodingKey { case urlString case profileID + case engineKind case shouldRenderWebView case pageZoom case developerToolsVisible @@ -1572,6 +1576,7 @@ struct SessionBrowserPanelSnapshot: Codable, Sendable { let container = try decoder.container(keyedBy: CodingKeys.self) urlString = try container.decodeIfPresent(String.self, forKey: .urlString) profileID = try container.decodeIfPresent(UUID.self, forKey: .profileID) + engineKind = try container.decodeIfPresent(BrowserEngineKind.self, forKey: .engineKind) shouldRenderWebView = try container.decode(Bool.self, forKey: .shouldRenderWebView) pageZoom = try container.decode(Double.self, forKey: .pageZoom) developerToolsVisible = try container.decode(Bool.self, forKey: .developerToolsVisible) diff --git a/Sources/TabManager.swift b/Sources/TabManager.swift index 1144bc80fac..a202ddfcb63 100644 --- a/Sources/TabManager.swift +++ b/Sources/TabManager.swift @@ -4258,7 +4258,8 @@ class TabManager: ObservableObject { inPane: originalPane, url: snapshot.url, focus: true, - preferredProfileID: snapshot.profileID + preferredProfileID: snapshot.profileID, + browserEngineKind: snapshot.engineKind ) { let tabCount = workspace.bonsplitController.tabs(inPane: originalPane).count let maxIndex = max(0, tabCount - 1) @@ -4277,7 +4278,8 @@ class TabManager: ObservableObject { orientation: orientation, insertFirst: snapshot.fallbackSplitInsertFirst, url: snapshot.url, - preferredProfileID: snapshot.profileID + preferredProfileID: snapshot.profileID, + browserEngineKind: snapshot.engineKind )?.id { return browserPanelId } @@ -4289,7 +4291,8 @@ class TabManager: ObservableObject { inPane: focusedPane, url: snapshot.url, focus: true, - preferredProfileID: snapshot.profileID + preferredProfileID: snapshot.profileID, + browserEngineKind: snapshot.engineKind )?.id } diff --git a/Sources/TerminalController+BrowserAutomationRecovery.swift b/Sources/TerminalController+BrowserAutomationRecovery.swift index 7864cb778ae..472c690ed81 100644 --- a/Sources/TerminalController+BrowserAutomationRecovery.swift +++ b/Sources/TerminalController+BrowserAutomationRecovery.swift @@ -6,30 +6,31 @@ extension TerminalController { nonisolated func v2CaptureBrowserAutomationSnapshot( _ browserPanel: BrowserPanel, timeout: TimeInterval - ) -> (webViewIdentifier: ObjectIdentifier, result: BrowserAutomationSnapshotResult)? { - socketAwaitCallback(timeout: timeout) { finish in + ) -> (webViewIdentifier: ObjectIdentifier, result: BrowserAutomationSnapshotResult) { + let webViewIdentifier = v2MainSync { ObjectIdentifier(browserPanel.webView) } + let result: BrowserAutomationSnapshotResult? = socketAwaitCallback(timeout: timeout) { finish in v2MainSync { - let webViewIdentifier = ObjectIdentifier(browserPanel.webView) browserPanel.captureAutomationVisibleViewportSnapshot { result in switch result { case .success(let image): guard let data = self.v2PNGData(from: image) else { - finish((webViewIdentifier, .failure(BrowserScreenshotError.invalidImageRepresentation.localizedDescription))) + finish(.failure(BrowserScreenshotError.invalidImageRepresentation.localizedDescription)) return } - finish((webViewIdentifier, .success(data))) + finish(.success(data)) case .failure(let error as BrowserScreenshotError): if case .automationTimedOut = error { - finish((webViewIdentifier, .timedOut)) + finish(.timedOut) } else { - finish((webViewIdentifier, .failure(error.localizedDescription))) + finish(.failure(error.localizedDescription)) } case .failure(let error): - finish((webViewIdentifier, .failure(error.localizedDescription))) + finish(.failure(error.localizedDescription)) } } } } + return (webViewIdentifier, result ?? .timedOut) } nonisolated func v2RecoverTimedOutBrowserJavaScript( diff --git a/Sources/TerminalController+WindowDockBrowserRouting.swift b/Sources/TerminalController+WindowDockBrowserRouting.swift index 078b0393030..9015664c947 100644 --- a/Sources/TerminalController+WindowDockBrowserRouting.swift +++ b/Sources/TerminalController+WindowDockBrowserRouting.swift @@ -78,7 +78,8 @@ extension TerminalController { workspaceId: dock.workspaceId, surfaceId: surfaceId, browserPanel: browserPanel, - webView: browserPanel.webView + webView: browserPanel.webView, + engineKind: browserPanel.engineKind ), nil ) diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index c0171e9f0a8..67bbf41a494 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3126,6 +3126,7 @@ class TerminalController { guard let browserPanel = workspace.controlSurfaceTarget(for: surface.surfaceID)?.panel as? BrowserPanel else { item["url"] = surface.isBrowser ? (surface.url ?? "") : NSNull() item["browser_web_content_pid"] = NSNull() + item["browser_engine"] = NSNull() return item } @@ -3133,6 +3134,7 @@ class TerminalController { let url = browserPanel.currentURL?.absoluteString ?? "" item["url"] = url item["browser_web_content_pid"] = v2OrNull(webContentPID) + item["browser_engine"] = browserPanel.engineKind.rawValue item["browser_webview_lifecycle_state"] = browserPanel.webViewLifecycleState.rawValue item["webviews"] = [[ "kind": "webview", @@ -3143,6 +3145,7 @@ class TerminalController { "surface_ref": v2Ref(kind: .surface, uuid: surface.surfaceID), "title": browserPanel.displayTitle, "url": url, + "engine": browserPanel.engineKind.rawValue, "pid": v2OrNull(webContentPID), "lifecycle": browserPanel.webViewLifecycleTopPayload() ] as [String: Any]] @@ -5800,6 +5803,7 @@ class TerminalController { let surfaceId: UUID let browserPanel: BrowserPanel let webView: WKWebView + let engineKind: BrowserEngineKind } private func v2ResolveBrowserPanelContext( @@ -5829,7 +5833,8 @@ class TerminalController { workspaceId: ws.id, surfaceId: surfaceId, browserPanel: browserPanel, - webView: browserPanel.webView + webView: browserPanel.webView, + engineKind: browserPanel.engineKind ), nil ) @@ -5850,6 +5855,7 @@ class TerminalController { "index": index, "title": panel.displayTitle, "url": panel.currentURL?.absoluteString ?? "", + "engine": panel.engineKind.rawValue, "focused": panel.id == focusedPanelId, "pane_id": v2OrNull(paneId?.id.uuidString), "pane_ref": v2Ref(kind: .pane, uuid: paneId?.id) @@ -5867,7 +5873,8 @@ class TerminalController { private nonisolated func v2BrowserPanelFields(_ ctx: V2BrowserPanelContext, adding fields: [String: Any] = [:]) -> [String: Any] { var result: [String: Any] = [ "workspace_id": ctx.workspaceId.uuidString, "workspace_ref": v2Ref(kind: .workspace, uuid: ctx.workspaceId), - "surface_id": ctx.surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: ctx.surfaceId) + "surface_id": ctx.surfaceId.uuidString, "surface_ref": v2Ref(kind: .surface, uuid: ctx.surfaceId), + "engine": ctx.engineKind.rawValue ] fields.forEach { result[$0.key] = $0.value } return result @@ -5942,6 +5949,55 @@ class TerminalController { case failure(String) } + private enum V2InitializationScriptResult { + case success(Int) + case failure(String) + } + + private nonisolated func v2InstallBrowserInitializationScript( + _ browserPanel: BrowserPanel, + script: String, + kind: BrowserPanel.AutomationInitializationScriptKind, + timeout: TimeInterval = 10.0 + ) -> V2InitializationScriptResult { + let outcome: V2InitializationScriptResult? = v2AwaitCallback(timeout: timeout) { finish in + Task { @MainActor in + do { + let count = try await browserPanel.addInitializationScript(script, kind: kind) + finish(.success(count)) + } catch { + finish(.failure(error.localizedDescription)) + } + } + } + return outcome ?? .failure(String( + localized: "browser.automation.initScript.timeout", + defaultValue: "Timed out installing the browser initialization script." + )) + } + + private nonisolated func v2WebKitView(_ ctx: V2BrowserPanelContext) -> WKWebView? { + v2MainSync { + guard ctx.engineKind == .webKit else { return nil } + return ctx.browserPanel.webView + } + } + + private nonisolated func v2WebKitOnlyError( + method: String, + engineKind: BrowserEngineKind + ) -> V2CallResult { + let format = String( + localized: "browser.automation.webkitOnly", + defaultValue: "%@ is available only for WebKit browser panes." + ) + return .err( + code: "not_supported", + message: String(format: format, method), + data: ["engine": engineKind.rawValue] + ) + } + /// True when a page-world JS failure looks like a CSP block of eval/function /// construction (script-src without 'unsafe-eval'). That is the only failure /// the isolated-world retry is meant to recover from; gating on it keeps the @@ -5953,62 +6009,20 @@ class TerminalController { v2BrowserControl.failureLooksLikeCSPEvalBlock(message) } - /// Sendable stand-in for `WKContentWorld` so nonisolated callers can pick a world without - /// touching the main-actor-isolated `WKContentWorld.page`/`.defaultClient` statics. The real - /// world is resolved on the main actor inside `v2RunJavaScript`. - private enum V2JSContentWorld: Sendable { case page, isolated } - - private nonisolated func v2RunJavaScript( - _ webView: WKWebView, + /// Bridges an engine-neutral async evaluation into the synchronous socket reply. + private nonisolated func v2RunEngineJavaScript( + _ browserPanel: BrowserPanel, script: String, timeout: TimeInterval = 5.0, - preferAsync: Bool = false, - world: V2JSContentWorld + world: BrowserJavaScriptWorld ) -> BrowserJavaScriptEvaluationResult { let timeoutSeconds = max(0.01, timeout) - // Capture the held browser-control service (a Sendable value) rather than - // `self`, reusing the already-initialized instance for error description. - let browserControl = v2BrowserControl - // The evaluator only ever runs on the main actor (Thread.isMainThread branch or - // DispatchQueue.main.async below), so assumeIsolated is safe and lets us touch the - // main-actor WKWebView APIs and WKContentWorld statics without spurious isolation warnings. - let evaluator: (@escaping (Any?, String?) -> Void) -> Void = { finish in - MainActor.assumeIsolated { - let contentWorld: WKContentWorld = (world == .page) ? .page : .defaultClient - if preferAsync, #available(macOS 11.0, *) { - webView.callAsyncJavaScript(script, arguments: [:], in: nil, in: contentWorld) { result in - switch result { - case .success(let value): - finish(value, nil) - case .failure(let error): - finish(nil, browserControl.describeJavaScriptError(error)) - } - } - } else { - webView.evaluateJavaScript(script) { value, error in - if let error { - finish(nil, browserControl.describeJavaScriptError(error)) - } else { - finish(value, nil) - } - } - } - } - } - - let outcome: (Any?, String?)? - if Thread.isMainThread { - outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in - evaluator { value, error in - finish((value, error)) - } - } - } else { - outcome = v2AwaitCallback(timeout: timeoutSeconds) { finish in - DispatchQueue.main.async { - evaluator { value, error in - finish((value, error)) - } + let outcome: (Any?, String?)? = v2AwaitCallback(timeout: timeoutSeconds) { finish in + Task { @MainActor in + do { + finish((try await browserPanel.evaluateJavaScript(script, in: world), nil)) + } catch { + finish((nil, error.localizedDescription)) } } } @@ -6016,8 +6030,8 @@ class TerminalController { guard let outcome else { #if DEBUG cmuxDebugLog( - "browser.jsRun.timeout preferAsync=\(preferAsync) " + - "world=\(world == .page ? "page" : "isolated") timeout=\(timeoutSeconds)" + "browser.jsRun.timeout world=\(world == .page ? "page" : "isolated") " + + "timeout=\(timeoutSeconds)" ) #endif return .timedOut @@ -6181,6 +6195,11 @@ class TerminalController { surfaceId: UUID, timeout: TimeInterval = 3.0 ) -> Bool { + let engineKind = v2MainSync { browserPanel.engineKind } + if engineKind == .chromium { + return v2MainSync { ObjectIdentifier(browserPanel.webView) == ObjectIdentifier(webView) } + } + let expectedWebViewIdentifier = ObjectIdentifier(webView) var readinessTask: Task? let outcome: BrowserAutomationDocumentReadinessOutcome? = v2AwaitCallback(timeout: timeout) { finish in @@ -6275,7 +6294,8 @@ class TerminalController { executionBlock = "const __r = \(script);" } - let asyncFunctionBody = """ + let evaluationExpression = """ + (async () => { \(framePrelude) const __cmuxMaybeAwait = async (__r) => { @@ -6296,25 +6316,15 @@ class TerminalController { }; return await __cmuxEvalInFrame(); + })() """ - var rawResult: BrowserJavaScriptEvaluationResult - if #available(macOS 11.0, *) { - rawResult = v2RunJavaScript( - webView, - script: asyncFunctionBody, - timeout: timeout, - preferAsync: true, - world: .page - ) - } else { - let evaluateFallback = """ - (async () => { - \(asyncFunctionBody) - })() - """ - rawResult = v2RunJavaScript(webView, script: evaluateFallback, timeout: timeout, world: .page) - } + var rawResult: BrowserJavaScriptEvaluationResult = v2RunEngineJavaScript( + browserPanel, + script: evaluationExpression, + timeout: timeout, + world: .page + ) // Retry in the isolated world only when page CSP blocked eval/function construction // (script-src without 'unsafe-eval'). That block applies to callAsyncJavaScript and page @@ -6332,13 +6342,11 @@ class TerminalController { // user-supplied browser.eval, onIsolatedWorldFallback annotates the result's content world. if !requiresPageWorld, case .failure(let pageMessage) = rawResult, - v2BrowserFailureLooksLikeCSPEvalBlock(pageMessage), - #available(macOS 11.0, *) { - let isolatedResult = v2RunJavaScript( - webView, - script: asyncFunctionBody, + v2BrowserFailureLooksLikeCSPEvalBlock(pageMessage) { + let isolatedResult = v2RunEngineJavaScript( + browserPanel, + script: evaluationExpression, timeout: timeout, - preferAsync: true, world: .isolated ) switch isolatedResult { @@ -7353,7 +7361,7 @@ class TerminalController { message: "Wait condition could not be evaluated: \(message)", data: [ "timeout_ms": timeoutMs, - "url": v2MainSync { webView.url?.absoluteString ?? "about:blank" }, + "url": v2MainSync { browserPanel.currentURL?.absoluteString ?? "about:blank" }, "hint": "Verify the page loaded with 'cmux browser get url' before waiting" ] ) @@ -7754,9 +7762,7 @@ class TerminalController { return .err(code: "internal_error", message: "Browser operation failed", data: nil) } - guard let snapshotAttempt = v2CaptureBrowserAutomationSnapshot(browserPanel, timeout: 17.0) else { - return .err(code: "timeout", message: BrowserScreenshotError.automationTimedOut.localizedDescription, data: nil) - } + let snapshotAttempt = v2CaptureBrowserAutomationSnapshot(browserPanel, timeout: 17.0) let imageData: Data switch snapshotAttempt.result { case .success(let data): @@ -9403,9 +9409,10 @@ class TerminalController { private nonisolated func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - let store = v2MainSync { - ctx.webView.configuration.websiteDataStore.httpCookieStore + guard let webView = v2WebKitView(ctx) else { + return v2WebKitOnlyError(method: "browser.cookies.get", engineKind: ctx.engineKind) } + let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } guard var cookies = v2BrowserCookieStoreAll(store) else { return .err(code: "timeout", message: "Timed out reading cookies", data: nil) } @@ -9426,9 +9433,12 @@ class TerminalController { private nonisolated func v2BrowserCookiesSet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in + guard let webView = v2WebKitView(ctx) else { + return v2WebKitOnlyError(method: "browser.cookies.set", engineKind: ctx.engineKind) + } let cookieContext = v2MainSync { ( - store: ctx.webView.configuration.websiteDataStore.httpCookieStore, + store: webView.configuration.websiteDataStore.httpCookieStore, fallbackURL: ctx.browserPanel.currentURL ) } @@ -9472,9 +9482,10 @@ class TerminalController { private nonisolated func v2BrowserCookiesClear(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - let store = v2MainSync { - ctx.webView.configuration.websiteDataStore.httpCookieStore + guard let webView = v2WebKitView(ctx) else { + return v2WebKitOnlyError(method: "browser.cookies.clear", engineKind: ctx.engineKind) } + let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } guard let cookies = v2BrowserCookieStoreAll(store) else { return .err(code: "timeout", message: "Timed out reading cookies", data: nil) } @@ -9984,6 +9995,9 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in + guard let webView = v2WebKitView(ctx) else { + return v2WebKitOnlyError(method: "browser.state.save", engineKind: ctx.engineKind) + } let storageScript = """ (() => { const readStorage = (st) => { @@ -10010,9 +10024,7 @@ class TerminalController { storageValue = v2NormalizeJSValue(value) } - let store = v2MainSync { - ctx.webView.configuration.websiteDataStore.httpCookieStore - } + let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } let cookies = (v2BrowserCookieStoreAll(store) ?? []).map(v2BrowserCookieDict) let stateSnapshot = v2MainSync { ( @@ -10060,6 +10072,9 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in + guard let webView = v2WebKitView(ctx) else { + return v2WebKitOnlyError(method: "browser.state.load", engineKind: ctx.engineKind) + } let cookieContext = v2MainSync { if let frameSelector = raw["frame_selector"] as? String, !frameSelector.isEmpty { v2BrowserFrameSelectorBySurface[ctx.surfaceId] = frameSelector @@ -10074,7 +10089,7 @@ class TerminalController { } return ( - store: ctx.webView.configuration.websiteDataStore.httpCookieStore, + store: webView.configuration.websiteDataStore.httpCookieStore, fallbackURL: ctx.browserPanel.currentURL ) } @@ -10118,11 +10133,24 @@ class TerminalController { return .err(code: "invalid_params", message: "Missing script", data: nil) } return v2BrowserWithPanelContext(params: params) { ctx in - let scriptsCount = v2MainSync { - let userScript = WKUserScript(source: script, injectionTime: .atDocumentStart, forMainFrameOnly: false) - return ctx.browserPanel.registerBrowserAutomationInitScript(userScript) + let scriptsCount: Int + switch v2InstallBrowserInitializationScript( + ctx.browserPanel, + script: script, + kind: .script + ) { + case .success(let count): + scriptsCount = count + case .failure(let message): + return .err(code: "browser_error", message: message, data: ["engine": ctx.engineKind.rawValue]) } - _ = v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: script, timeout: 10.0) + _ = v2RunBrowserJavaScript( + ctx.webView, + browserPanel: ctx.browserPanel, + surfaceId: ctx.surfaceId, + script: script, + timeout: 10.0 + ) return .ok(v2BrowserPanelFields(ctx, adding: ["scripts": scriptsCount])) } @@ -10157,11 +10185,24 @@ class TerminalController { })() """ - let stylesCount = v2MainSync { - let userScript = WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: false) - return ctx.browserPanel.registerBrowserAutomationStyleScript(userScript) + let stylesCount: Int + switch v2InstallBrowserInitializationScript( + ctx.browserPanel, + script: source, + kind: .style + ) { + case .success(let count): + stylesCount = count + case .failure(let message): + return .err(code: "browser_error", message: message, data: ["engine": ctx.engineKind.rawValue]) } - _ = v2RunBrowserJavaScript(ctx.webView, browserPanel: ctx.browserPanel, surfaceId: ctx.surfaceId, script: source, timeout: 10.0) + _ = v2RunBrowserJavaScript( + ctx.webView, + browserPanel: ctx.browserPanel, + surfaceId: ctx.surfaceId, + script: source, + timeout: 10.0 + ) return .ok(v2BrowserPanelFields(ctx, adding: ["styles": stylesCount])) } diff --git a/Sources/Workspace+DockBrowserLookup.swift b/Sources/Workspace+DockBrowserLookup.swift index 17beb6e87d7..01e745190ee 100644 --- a/Sources/Workspace+DockBrowserLookup.swift +++ b/Sources/Workspace+DockBrowserLookup.swift @@ -47,6 +47,7 @@ extension Workspace { initialRequest: seed.initialRequest, focus: true, preferredProfileID: panel.profileID, + browserEngineKind: panel.engineKind, bypassInsecureHTTPHostOnce: seed.bypassInsecureHTTPHostOnce ) != nil } @@ -63,6 +64,7 @@ extension Workspace { initialRequest: seed.initialRequest, focus: true, preferredProfileID: panel.profileID, + browserEngineKind: panel.engineKind, bypassInsecureHTTPHostOnce: seed.bypassInsecureHTTPHostOnce ) != nil } @@ -97,6 +99,7 @@ extension DockSplitStore { url: URL?, initialRequest: URLRequest? = nil, preferredProfileID: UUID? = nil, + browserEngineKind: BrowserEngineKind? = nil, bypassInsecureHTTPHostOnce: String? = nil ) -> BrowserPanel { let settings = currentRemoteBrowserSettings() @@ -109,7 +112,8 @@ extension DockSplitStore { proxyEndpoint: settings.proxyEndpoint, bypassRemoteProxy: settings.bypassRemoteProxy, isRemoteWorkspace: settings.isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: settings.remoteWebsiteDataStoreIdentifier + remoteWebsiteDataStoreIdentifier: settings.remoteWebsiteDataStoreIdentifier, + engineSelection: .current(restoring: browserEngineKind) ) panel.setRemoteWorkspaceStatus(settings.remoteStatus) panel.webViewDidRequestClose = { [weak self, weak panel] in diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 845bd7c5e57..8c94ad34fa9 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -595,6 +595,7 @@ extension Workspace { browserSnapshot = SessionBrowserPanelSnapshot( urlString: browserPanel.preferredURLStringForSessionSnapshot(), profileID: browserPanel.profileID, + engineKind: browserPanel.engineKind, shouldRenderWebView: browserPanel.shouldRenderWebViewForSessionSnapshot(), pageZoom: Double(browserPanel.currentPageZoomFactor()), developerToolsVisible: browserPanel.isDeveloperToolsVisible(), @@ -1593,11 +1594,15 @@ extension Workspace { applySessionPanelMetadata(snapshot, toPanelId: terminalPanel.id) return terminalPanel.id case .browser: + let restoredEngineKind: BrowserEngineKind? = snapshot.browser?.diffViewerToken == nil + ? snapshot.browser?.engineKind + : .webKit guard let browserPanel = newBrowserSurface( inPane: paneId, url: nil, focus: false, preferredProfileID: snapshot.browser?.profileID, + browserEngineKind: restoredEngineKind, creationPolicy: .restoration, transparentBackground: snapshot.browser?.transparentBackground ?? false ) else { @@ -2970,7 +2975,8 @@ final class Workspace: Identifiable, ObservableObject { profileID: resolvedNewBrowserProfileID(), initialURL: initialBrowserURL, omnibarVisible: initialBrowserOmnibarVisible, - transparentBackground: initialBrowserTransparentBackground + transparentBackground: initialBrowserTransparentBackground, + engineSelection: .current() ) configureBrowserPanel(browserPanel) panels[browserPanel.id] = browserPanel @@ -7880,6 +7886,7 @@ final class Workspace: Identifiable, ObservableObject { insertFirst: Bool = false, url: URL? = nil, preferredProfileID: UUID? = nil, + browserEngineKind: BrowserEngineKind? = nil, focus: Bool = true, creationPolicy: BrowserPanelCreationPolicy = .userInitiated, omnibarVisible: Bool = true, @@ -7912,6 +7919,7 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel + let inheritedEngineKind = browserEngineKind ?? (panels[panelId] as? BrowserPanel)?.engineKind let browserPanel = BrowserPanel( workspaceId: id, profileID: resolvedNewBrowserProfileID( @@ -7926,7 +7934,8 @@ final class Workspace: Identifiable, ObservableObject { proxyEndpoint: remoteProxyEndpoint, bypassRemoteProxy: bypassRemoteProxy, isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil, + engineSelection: .current(restoring: inheritedEngineKind) ) configureBrowserPanel(browserPanel) panels[browserPanel.id] = browserPanel @@ -7995,6 +8004,7 @@ final class Workspace: Identifiable, ObservableObject { selectWhenNotFocused: Bool = false, insertAtEnd: Bool = false, preferredProfileID: UUID? = nil, + browserEngineKind: BrowserEngineKind? = nil, bypassInsecureHTTPHostOnce: String? = nil, creationPolicy: BrowserPanelCreationPolicy = .userInitiated, omnibarVisible: Bool = true, @@ -8019,6 +8029,9 @@ final class Workspace: Identifiable, ObservableObject { let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView + let inheritedEngineKind = browserEngineKind ?? sourcePanelId.flatMap { + (panels[$0] as? BrowserPanel)?.engineKind + } let browserPanel = BrowserPanel( workspaceId: id, profileID: resolvedNewBrowserProfileID( @@ -8035,7 +8048,8 @@ final class Workspace: Identifiable, ObservableObject { proxyEndpoint: remoteProxyEndpoint, bypassRemoteProxy: bypassRemoteProxy, isRemoteWorkspace: isRemoteWorkspace, - remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil + remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil, + engineSelection: .current(restoring: inheritedEngineKind) ) configureBrowserPanel(browserPanel) panels[browserPanel.id] = browserPanel @@ -9014,6 +9028,7 @@ final class Workspace: Identifiable, ObservableObject { workspaceId: id, url: resolvedURL, profileID: browserPanel.profileID, + engineKind: browserPanel.engineKind, originalPaneId: pane.id, originalTabIndex: tabIndex, fallbackSplitOrientation: fallbackPlan?.orientation, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index f4d15803c60..4c3ae6c081b 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -223,6 +223,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */; }; C67540110000000000000001 /* BrowserDownloadRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540110000000000000002 /* BrowserDownloadRecord.swift */; }; C67540120000000000000001 /* BrowserDownloadsToolbarButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540120000000000000002 /* BrowserDownloadsToolbarButton.swift */; }; + B147E0010000000000000001 /* BrowserEngineSelection+Current.swift in Sources */ = {isa = PBXBuildFile; fileRef = B147E0010000000000000002 /* BrowserEngineSelection+Current.swift */; }; C2035A010000000000000001 /* BrowserErrorPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2035A010000000000000002 /* BrowserErrorPage.swift */; }; C2035A020000000000000001 /* BrowserErrorPageContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2035A020000000000000002 /* BrowserErrorPageContent.swift */; }; C2035A0C000000000000001 /* BrowserErrorPageRetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2035A0C000000000000002 /* BrowserErrorPageRetry.swift */; }; @@ -2200,6 +2201,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C67540020000000000000002 /* BrowserDownloadHTTPStatusDecision.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadHTTPStatusDecision.swift; sourceTree = ""; }; C67540110000000000000002 /* BrowserDownloadRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadRecord.swift; sourceTree = ""; }; C67540120000000000000002 /* BrowserDownloadsToolbarButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserDownloadsToolbarButton.swift; sourceTree = ""; }; + B147E0010000000000000002 /* BrowserEngineSelection+Current.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserEngineSelection+Current.swift"; sourceTree = ""; }; C2035A010000000000000002 /* BrowserErrorPage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserErrorPage.swift; sourceTree = ""; }; C2035A020000000000000002 /* BrowserErrorPageContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserErrorPageContent.swift; sourceTree = ""; }; C2035A0C000000000000002 /* BrowserErrorPageRetry.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserErrorPageRetry.swift; sourceTree = ""; }; @@ -4484,6 +4486,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C7268001000000000000002 /* Workspace+CustomSidebarSnapshot.swift */, C7268002000000000000002 /* Workspace+SidebarDirectories.swift */, DCDC1000000000000000B014 /* Workspace+DockBrowserLookup.swift */, + B147E0010000000000000002 /* BrowserEngineSelection+Current.swift */, E30780000000000000000011 /* SSHPTYAttachStartupCommandBuilder.swift */, EE30D5000000000000000001 /* RemoteDaemonStrings+App.swift */, EE30D5000000000000000003 /* WorkspaceRemoteRelayCommandRewriter.swift */, @@ -6429,6 +6432,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C67540020000000000000001 /* BrowserDownloadHTTPStatusDecision.swift in Sources */, C67540110000000000000001 /* BrowserDownloadRecord.swift in Sources */, C67540120000000000000001 /* BrowserDownloadsToolbarButton.swift in Sources */, + B147E0010000000000000001 /* BrowserEngineSelection+Current.swift in Sources */, C2035A010000000000000001 /* BrowserErrorPage.swift in Sources */, C2035A020000000000000001 /* BrowserErrorPageContent.swift in Sources */, C2035A0C000000000000001 /* BrowserErrorPageRetry.swift in Sources */, diff --git a/skills/cmux-settings/SKILL.md b/skills/cmux-settings/SKILL.md index 8a912d360da..4ce3d3bda4a 100644 --- a/skills/cmux-settings/SKILL.md +++ b/skills/cmux-settings/SKILL.md @@ -66,7 +66,7 @@ Subcommands: - Sidebar tint: `sidebarAppearance.matchTerminalBackground`, `sidebarAppearance.tintColor`, `sidebarAppearance.tintOpacity` (0..1). - Sidebar details: `sidebar.hideAllDetails`, `sidebar.showBranchDirectory`, `sidebar.showPullRequests`, `sidebar.showPorts`, `sidebar.showLog`. - Notifications: `notifications.dockBadge`, `notifications.sound` (enum incl. `"none"`, `"custom_file"`), `notifications.customSoundFilePath`, `notifications.hooks` (array). -- Browser: `browser.defaultSearchEngine`, `browser.theme`, `browser.openTerminalLinksInCmuxBrowser`, `browser.hostsToOpenInEmbeddedBrowser`. +- Browser: `browser.engine` (`auto | webkit | chromium`), `browser.defaultSearchEngine`, `browser.theme`, `browser.openTerminalLinksInCmuxBrowser`, `browser.hostsToOpenInEmbeddedBrowser`. - Automation: `automation.socketControlMode` (`off | cmuxOnly | automation | password | allowAll`), `automation.portBase`, `automation.portRange`. - Shortcuts: `shortcuts.bindings.` = `"cmd+b"`, `["ctrl+b","c"]`, `null`, or `""` to unbind. See `references/shortcut-actions.md`. diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index 48b62891135..9ccfa255b34 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -120,6 +120,7 @@ Embedded browser settings from Settings > Browser. | Key | Type | Default | Description | |---|---|---|---| +| `browser.engine` | `"auto"` or `"webkit"` or `"chromium"` | `"auto"` | Browser engine for new panes. Auto follows the engine family of the macOS default browser; remote-proxied panes and cmux internal pages use WebKit. | | `browser.defaultSearchEngine` | `"google"` or `"duckduckgo"` or `"bing"` or `"kagi"` or `"startpage"` | `"google"` | Default search engine for non-URL queries. | | `browser.showSearchSuggestions` | boolean | `true` | Show omnibar search suggestions. | | `browser.theme` | `"system"` or `"light"` or `"dark"` | `"system"` | Embedded browser theme. | diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index 32d8441a31c..1584ca6d98e 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1324,6 +1324,13 @@ "type": "object", "additionalProperties": false, "properties": { + "engine": { + "type": "string", + "enum": ["auto", "webkit", "chromium"], + "default": "auto", + "descriptionKey": "schemaDescriptions.browser.engine", + "description": "Browser engine for newly created browser panes. Auto follows the engine family of the macOS default browser. Remote-proxied panes and cmux internal pages use WebKit." + }, "defaultSearchEngine": { "type": "string", "enum": ["google", "duckduckgo", "bing", "kagi", "startpage", "brave", "perplexity", "exa", "yahoo", "ecosia", "qwant", "mojeek", "wikipedia", "github", "baidu", "yandex", "custom"], diff --git a/web/messages/ar.json b/web/messages/ar.json index 26cd8dd2a38..960ccaec6d0 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "محرك البحث الافتراضي لاستعلامات شريط عنوان المتصفح التي ليست عناوين URL. استخدم custom مع customSearchEngineURLTemplate لموفري بحث آخرين.", "customSearchEngineName": "اسم العرض المستخدم عندما تكون defaultSearchEngine هي custom.", - "customSearchEngineURLTemplate": "عنوان URL للبحث عند استخدام custom. أدرج موضع الاستعلام أو %s لعبارة البحث المرمزة. عند عدم وجود موضع، يضيف cmux q= إلى العنوان." + "customSearchEngineURLTemplate": "عنوان URL للبحث عند استخدام custom. أدرج موضع الاستعلام أو %s لعبارة البحث المرمزة. عند عدم وجود موضع، يضيف cmux q= إلى العنوان.", + "engine": "محرك المتصفح لأجزاء المتصفح التي تم إنشاؤها حديثًا. يتبع Auto عائلة المحركات الخاصة بمتصفح macOS الافتراضي. تستخدم الأجزاء الوكيلة عن بعد والصفحات الداخلية cmux WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "الإعداد الافتراضي العام لمكان وضع مساحة العمل الجديدة عند استخدام Cmd-N داخل مجموعة أو زر + في رأس المجموعة أو إجراءات المجموعة المضبوطة: `afterCurrent` (بعد مساحة العمل النشطة داخل المجموعة، مع الرجوع إلى top)، أو `top` (الموضع الثاني بعد المرساة مباشرة)، أو `end` (بعد آخر عضو).", diff --git a/web/messages/bs.json b/web/messages/bs.json index a69f1272444..9584958aa9a 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Zadani pretraživač za upite u adresnoj traci preglednika koji nisu URL-ovi. Koristite custom uz customSearchEngineURLTemplate za druge pružatelje.", "customSearchEngineName": "Prikazani naziv koji se koristi kada je defaultSearchEngine custom.", - "customSearchEngineURLTemplate": "URL pretrage koji se koristi kada je defaultSearchEngine custom. Uključite rezervirano mjesto za upit ili %s za kodirani upit. Ako nema rezerviranog mjesta, cmux dodaje q= u URL." + "customSearchEngineURLTemplate": "URL pretrage koji se koristi kada je defaultSearchEngine custom. Uključite rezervirano mjesto za upit ili %s za kodirani upit. Ako nema rezerviranog mjesta, cmux dodaje q= u URL.", + "engine": "Motor pretraživača za novokreirana okna pretraživača. Auto prati porodicu motora zadanog pretraživača za macOS. Udaljeno proksi okna i cmux interne stranice koriste WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Globalna zadana vrijednost za mjesto gdje Cmd-N unutar grupe, + dugme u zaglavlju grupe i podešene grupne akcije stavljaju novi workspace: `afterCurrent` (poslije aktivnog workspacea u grupi, uz povratak na top), `top` (druga pozicija, odmah poslije sidra) ili `end` (poslije zadnjeg člana).", diff --git a/web/messages/da.json b/web/messages/da.json index 8239d879f79..470c3c66450 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Standardsøgemaskine for forespørgsler i browserens adresselinje, der ikke er URLer. Brug custom med customSearchEngineURLTemplate til andre udbydere.", "customSearchEngineName": "Visningsnavn brugt når defaultSearchEngine er custom.", - "customSearchEngineURLTemplate": "Søge-URL brugt når defaultSearchEngine er custom. Medtag forespørgselspladsholderen eller %s for den kodede søgning. Uden en pladsholder tilføjer cmux q= til URLen." + "customSearchEngineURLTemplate": "Søge-URL brugt når defaultSearchEngine er custom. Medtag forespørgselspladsholderen eller %s for den kodede søgning. Uden en pladsholder tilføjer cmux q= til URLen.", + "engine": "Browsermotor til nyoprettede browserruder. Auto følger motorfamilien i macOS-standardbrowseren. Fjernbetjente ruder og interne cmux-sider bruger WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Global standard for hvor Cmd-N i en gruppe, gruppehovedets + knap og konfigurerede gruppehandlinger placerer det nye workspace: `afterCurrent` (efter det aktive workspace i gruppen, med top som fallback), `top` (anden plads, lige efter ankeret) eller `end` (efter det sidste medlem).", diff --git a/web/messages/de.json b/web/messages/de.json index 948e18031d0..9d3fdd72a58 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Standardsuchmaschine für Nicht-URL-Eingaben in der Browser-Adressleiste. Verwenden Sie custom mit customSearchEngineURLTemplate für beliebige Anbieter.", "customSearchEngineName": "Anzeigename, der verwendet wird, wenn defaultSearchEngine custom ist.", - "customSearchEngineURLTemplate": "Such-URL, die verwendet wird, wenn defaultSearchEngine custom ist. Verwenden Sie den Abfrageplatzhalter oder %s für den kodierten Suchbegriff. Ohne Platzhalter fügt cmux q= zur URL hinzu." + "customSearchEngineURLTemplate": "Such-URL, die verwendet wird, wenn defaultSearchEngine custom ist. Verwenden Sie den Abfrageplatzhalter oder %s für den kodierten Suchbegriff. Ohne Platzhalter fügt cmux q= zur URL hinzu.", + "engine": "Browser-Engine für neu erstellte Browserfenster. Auto folgt der Engine-Familie des macOS-Standardbrowsers. Remote-Proxy-Bereiche und cmux-interne Seiten verwenden WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Globaler Standard dafür, wo Cmd-N innerhalb einer Gruppe, die + Schaltfläche im Gruppenkopf und konfigurierte Gruppenaktionen den neuen Workspace platzieren: `afterCurrent` (nach dem aktiven Workspace in der Gruppe, mit Rückfall auf top), `top` (zweite Position direkt nach dem Anker) oder `end` (nach dem letzten Mitglied).", diff --git a/web/messages/en.json b/web/messages/en.json index 484dc86b389..7c2000cb340 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -1719,6 +1719,7 @@ "textBoxSubmitActionBackgroundColorHex": "Action color metadata as RGB or RGBA hex. The submit button fill stays white and only changes opacity between enabled and disabled states." }, "browser": { + "engine": "Browser engine for newly created browser panes. Auto follows the engine family of the macOS default browser. Remote-proxied panes and cmux internal pages use WebKit.", "defaultSearchEngine": "Default search engine for non-URL browser address bar queries. Use custom with customSearchEngineURLTemplate for arbitrary providers.", "customSearchEngineName": "Display name used when defaultSearchEngine is custom.", "customSearchEngineURLTemplate": "Search URL used when defaultSearchEngine is custom. Include the query placeholder or %s for the encoded query. If omitted, cmux appends q= to the URL.", diff --git a/web/messages/es.json b/web/messages/es.json index 7d63e22a811..40b72ebb71a 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Motor de búsqueda predeterminado para consultas de la barra de direcciones del navegador que no son URL. Usa custom con customSearchEngineURLTemplate para otros proveedores.", "customSearchEngineName": "Nombre visible usado cuando defaultSearchEngine es custom.", - "customSearchEngineURLTemplate": "URL de búsqueda usada cuando defaultSearchEngine es custom. Incluye el marcador de consulta o %s para la consulta codificada. Si no hay marcador, cmux añade q= a la URL." + "customSearchEngineURLTemplate": "URL de búsqueda usada cuando defaultSearchEngine es custom. Incluye el marcador de consulta o %s para la consulta codificada. Si no hay marcador, cmux añade q= a la URL.", + "engine": "Motor de navegador para paneles de navegador recién creados. Auto sigue la familia de motores del navegador predeterminado de macOS. Los paneles con proxy remoto y las páginas internas de cmux utilizan WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Valor predeterminado global para dónde Cmd-N dentro de un grupo, el botón + del encabezado del grupo y las acciones de grupo configuradas colocan el nuevo workspace: `afterCurrent` (después del workspace activo dentro del grupo, con top como reserva), `top` (segunda posición, justo después del ancla) o `end` (después del último miembro).", diff --git a/web/messages/fr.json b/web/messages/fr.json index cd8095982ee..37dbc6ba6f2 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Moteur de recherche par défaut pour les saisies de la barre d’adresse du navigateur qui ne sont pas des URL. Utilisez custom avec customSearchEngineURLTemplate pour d’autres fournisseurs.", "customSearchEngineName": "Nom affiché lorsque defaultSearchEngine vaut custom.", - "customSearchEngineURLTemplate": "URL de recherche utilisée lorsque defaultSearchEngine vaut custom. Incluez l’espace réservé de requête ou %s pour la requête encodée. Sans espace réservé, cmux ajoute q= à l’URL." + "customSearchEngineURLTemplate": "URL de recherche utilisée lorsque defaultSearchEngine vaut custom. Incluez l’espace réservé de requête ou %s pour la requête encodée. Sans espace réservé, cmux ajoute q= à l’URL.", + "engine": "Moteur de navigateur pour les volets de navigateur nouvellement créés. Auto suit la famille de moteurs du navigateur par défaut de macOS. Les volets proxy distants et les pages internes cmux utilisent WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Valeur globale par défaut indiquant où Cmd-N dans un groupe, le bouton + de l’en-tête du groupe et les actions de groupe configurées placent le nouveau workspace : `afterCurrent` (après le workspace actif du groupe, avec repli sur top), `top` (deuxième position, juste après l’ancre) ou `end` (après le dernier membre).", diff --git a/web/messages/it.json b/web/messages/it.json index a08aa09e262..ad2be423029 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Motore di ricerca predefinito per le query nella barra degli indirizzi del browser che non sono URL. Usa custom con customSearchEngineURLTemplate per altri provider.", "customSearchEngineName": "Nome visualizzato quando defaultSearchEngine è custom.", - "customSearchEngineURLTemplate": "URL di ricerca usato quando defaultSearchEngine è custom. Includi il segnaposto della query o %s per la query codificata. Senza segnaposto, cmux aggiunge q= all’URL." + "customSearchEngineURLTemplate": "URL di ricerca usato quando defaultSearchEngine è custom. Includi il segnaposto della query o %s per la query codificata. Senza segnaposto, cmux aggiunge q= all’URL.", + "engine": "Motore del browser per i riquadri del browser appena creati. Auto segue la famiglia di motori del browser predefinito di macOS. I riquadri con proxy remoto e le pagine interne cmux utilizzano WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Valore predefinito globale per dove Cmd-N dentro un gruppo, il pulsante + nell’intestazione del gruppo e le azioni di gruppo configurate inseriscono il nuovo workspace: `afterCurrent` (dopo il workspace attivo nel gruppo, con fallback a top), `top` (seconda posizione, subito dopo l’ancora) o `end` (dopo l’ultimo membro).", diff --git a/web/messages/ja.json b/web/messages/ja.json index 42f0aed0004..228c8f683ce 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -1642,6 +1642,7 @@ "textBoxSubmitActionBackgroundColorHex": "アクション色のメタデータを RGB または RGBA の 16 進数で指定します。送信ボタンの塗りは白のままで、有効状態と無効状態では不透明度だけが変わります。" }, "browser": { + "engine": "新しく作成するブラウザペインで使うブラウザエンジンです。Auto は macOS のデフォルトブラウザと同じエンジン系統を使います。リモートプロキシ経由のペインと cmux の内部ページでは WebKit を使います。", "defaultSearchEngine": "ブラウザのアドレスバーでURLではない入力に使う既定の検索エンジンです。任意のプロバイダには custom と customSearchEngineURLTemplate を使います。", "customSearchEngineName": "defaultSearchEngine が custom のときに使う表示名です。", "customSearchEngineURLTemplate": "defaultSearchEngine が custom のときに使う検索URLです。エンコード済み検索語にはクエリ用プレースホルダーまたは %s を含めます。省略すると cmux が URL に q= を追加します。", diff --git a/web/messages/km.json b/web/messages/km.json index 733b681f407..82111b4c817 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "ម៉ាស៊ីនស្វែងរកលំនាំដើមសម្រាប់សំណួរនៅក្នុងរបារអាសយដ្ឋានដែលមិនមែនជា URL។ ប្រើ custom ជាមួយ customSearchEngineURLTemplate សម្រាប់អ្នកផ្តល់ផ្សេងៗ។", "customSearchEngineName": "ឈ្មោះបង្ហាញដែលប្រើនៅពេល defaultSearchEngine ជា custom។", - "customSearchEngineURLTemplate": "URL ស្វែងរកដែលប្រើនៅពេល defaultSearchEngine ជា custom។ ដាក់ទីតាំងជំនួសសំណួរ ឬ %s សម្រាប់ពាក្យស្វែងរកដែលបានអ៊ិនកូដ។ ប្រសិនបើគ្មានទីតាំងជំនួស cmux បន្ថែម q= ទៅ URL។" + "customSearchEngineURLTemplate": "URL ស្វែងរកដែលប្រើនៅពេល defaultSearchEngine ជា custom។ ដាក់ទីតាំងជំនួសសំណួរ ឬ %s សម្រាប់ពាក្យស្វែងរកដែលបានអ៊ិនកូដ។ ប្រសិនបើគ្មានទីតាំងជំនួស cmux បន្ថែម q= ទៅ URL។", + "engine": "ម៉ាស៊ីនកម្មវិធីរុករកសម្រាប់ផ្ទាំងកម្មវិធីរុករកដែលបានបង្កើតថ្មី។ ស្វ័យប្រវត្តិធ្វើតាមគ្រួសារម៉ាស៊ីននៃកម្មវិធីរុករកលំនាំដើម macOS ។ ផ្ទាំងប្រូកស៊ីពីចម្ងាយ និងទំព័រខាងក្នុង cmux ប្រើ WebKit ។" }, "workspaceGroups": { "newWorkspacePlacement": "តម្លៃលំនាំដើមសកលសម្រាប់ទីតាំងដែល Cmd-N នៅក្នុងក្រុម ប៊ូតុង + ក្នុងក្បាលក្រុម និងសកម្មភាពក្រុមដែលបានកំណត់ ដាក់ workspace ថ្មី: `afterCurrent` (បន្ទាប់ពី workspace សកម្មក្នុងក្រុម ហើយត្រឡប់ទៅ top ប្រសិនបើគ្មាន), `top` (ទីតាំងទីពីរ បន្ទាប់ពី anchor ភ្លាមៗ) ឬ `end` (បន្ទាប់ពីសមាជិកចុងក្រោយ).", diff --git a/web/messages/ko.json b/web/messages/ko.json index 77e4697d0bf..3285ce8cbf3 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "브라우저 주소 표시줄에서 URL이 아닌 입력에 사용할 기본 검색 엔진입니다. 다른 제공자는 custom과 customSearchEngineURLTemplate을 사용하세요.", "customSearchEngineName": "defaultSearchEngine이 custom일 때 사용할 표시 이름입니다.", - "customSearchEngineURLTemplate": "defaultSearchEngine이 custom일 때 사용할 검색 URL입니다. 인코딩된 검색어에는 쿼리 자리표시자 또는 %s를 포함하세요. 자리표시자가 없으면 cmux가 URL에 q=를 추가합니다." + "customSearchEngineURLTemplate": "defaultSearchEngine이 custom일 때 사용할 검색 URL입니다. 인코딩된 검색어에는 쿼리 자리표시자 또는 %s를 포함하세요. 자리표시자가 없으면 cmux가 URL에 q=를 추가합니다.", + "engine": "새로 생성된 브라우저 창을 위한 브라우저 엔진입니다. Auto는 macOS 기본 브라우저의 엔진 제품군을 따릅니다. 원격 프록시 창과 cmux 내부 페이지는 WebKit을 사용합니다." }, "workspaceGroups": { "newWorkspacePlacement": "그룹 안에서 Cmd-N, 그룹 헤더의 + 버튼, 구성된 그룹 액션이 새 workspace를 배치할 위치의 전역 기본값입니다: `afterCurrent` (활성 그룹 내 workspace 바로 뒤, 없으면 top), `top` (앵커 바로 뒤의 두 번째 위치), `end` (마지막 멤버 뒤).", diff --git a/web/messages/no.json b/web/messages/no.json index 1b0b589df12..05842617420 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Standard søkemotor for søk i nettleserens adresselinje som ikke er URLer. Bruk custom med customSearchEngineURLTemplate for andre leverandører.", "customSearchEngineName": "Visningsnavn som brukes når defaultSearchEngine er custom.", - "customSearchEngineURLTemplate": "Søke-URL som brukes når defaultSearchEngine er custom. Inkluder søkeplassholderen eller %s for det kodede søket. Uten plassholder legger cmux til q= i URLen." + "customSearchEngineURLTemplate": "Søke-URL som brukes når defaultSearchEngine er custom. Inkluder søkeplassholderen eller %s for det kodede søket. Uten plassholder legger cmux til q= i URLen.", + "engine": "Nettlesermotor for nyopprettede nettleserruter. Auto følger motorfamilien til standardnettleseren macOS. Ekstern proxy-ruter og interne cmux-sider bruker WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Global standard for hvor Cmd-N i en gruppe, + knappen i gruppehodet og konfigurerte gruppehandlinger plasserer det nye workspace: `afterCurrent` (etter aktivt workspace i gruppen, med top som fallback), `top` (andre plass, rett etter ankeret) eller `end` (etter siste medlem).", diff --git a/web/messages/pl.json b/web/messages/pl.json index f2adf6e3499..1d5dabe501e 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -926,7 +926,8 @@ "browser": { "defaultSearchEngine": "Domyślna wyszukiwarka dla wpisów w pasku adresu przeglądarki, które nie są URL-ami. Użyj custom z customSearchEngineURLTemplate dla innych dostawców.", "customSearchEngineName": "Nazwa wyświetlana, gdy defaultSearchEngine ma wartość custom.", - "customSearchEngineURLTemplate": "URL wyszukiwania używany, gdy defaultSearchEngine ma wartość custom. Dodaj symbol zastępczy zapytania lub %s dla zakodowanego zapytania. Bez symbolu zastępczego cmux doda q= do URL." + "customSearchEngineURLTemplate": "URL wyszukiwania używany, gdy defaultSearchEngine ma wartość custom. Dodaj symbol zastępczy zapytania lub %s dla zakodowanego zapytania. Bez symbolu zastępczego cmux doda q= do URL.", + "engine": "Silnik przeglądarki dla nowo tworzonych paneli przeglądarki. Auto podąża za rodziną silników domyślnej przeglądarki macOS. Zdalne okienka proxy i wewnętrzne strony cmux korzystają z WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Globalna wartość domyślna określająca, gdzie Cmd-N w grupie, przycisk + w nagłówku grupy i skonfigurowane akcje grupy umieszczają nowy workspace: `afterCurrent` (po aktywnym workspace w grupie, z fallbackiem do top), `top` (druga pozycja, tuż za kotwicą) albo `end` (po ostatnim członku).", diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index 657caa86411..926a58785de 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Mecanismo de busca padrão para consultas na barra de endereço do navegador que não são URLs. Use custom com customSearchEngineURLTemplate para outros provedores.", "customSearchEngineName": "Nome exibido quando defaultSearchEngine é custom.", - "customSearchEngineURLTemplate": "URL de busca usada quando defaultSearchEngine é custom. Inclua o marcador da consulta ou %s para a consulta codificada. Sem marcador, o cmux adiciona q= à URL." + "customSearchEngineURLTemplate": "URL de busca usada quando defaultSearchEngine é custom. Inclua o marcador da consulta ou %s para a consulta codificada. Sem marcador, o cmux adiciona q= à URL.", + "engine": "Mecanismo de navegador para painéis de navegador recém-criados. Auto segue a família de mecanismos do navegador padrão do macOS. Painéis com proxy remoto e páginas internas do cmux usam WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Padrão global para onde Cmd-N dentro de um grupo, o botão + do cabeçalho do grupo e ações de grupo configuradas colocam o novo workspace: `afterCurrent` (depois do workspace ativo no grupo, voltando para top), `top` (segunda posição, logo após a âncora) ou `end` (após o último membro).", diff --git a/web/messages/ru.json b/web/messages/ru.json index f790ee7c2da..8dcec23c03c 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Поисковая система по умолчанию для запросов в адресной строке браузера, которые не являются URL. Используйте custom с customSearchEngineURLTemplate для других провайдеров.", "customSearchEngineName": "Отображаемое имя, когда defaultSearchEngine имеет значение custom.", - "customSearchEngineURLTemplate": "URL поиска, используемый когда defaultSearchEngine имеет значение custom. Укажите заполнитель запроса или %s для закодированного запроса. Без заполнителя cmux добавит q= к URL." + "customSearchEngineURLTemplate": "URL поиска, используемый когда defaultSearchEngine имеет значение custom. Укажите заполнитель запроса или %s для закодированного запроса. Без заполнителя cmux добавит q= к URL.", + "engine": "Браузерный движок для вновь создаваемых панелей браузера. Auto соответствует семейству движков браузера по умолчанию в macOS. Панели с удаленным проксированием и внутренние страницы cmux используют WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Глобальное значение по умолчанию для того, куда Cmd-N внутри группы, кнопка + в заголовке группы и настроенные действия группы помещают новый workspace: `afterCurrent` (после активного workspace в группе, с откатом к top), `top` (вторая позиция сразу после якоря) или `end` (после последнего участника).", diff --git a/web/messages/th.json b/web/messages/th.json index a23e9eeb66b..9fed0e472a5 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -926,7 +926,8 @@ "browser": { "defaultSearchEngine": "เครื่องมือค้นหาเริ่มต้นสำหรับคำค้นในแถบที่อยู่ของเบราว์เซอร์ที่ไม่ใช่ URL ใช้ custom กับ customSearchEngineURLTemplate สำหรับผู้ให้บริการอื่น", "customSearchEngineName": "ชื่อที่แสดงเมื่อ defaultSearchEngine เป็น custom", - "customSearchEngineURLTemplate": "URL ค้นหาที่ใช้เมื่อ defaultSearchEngine เป็น custom ใส่ตัวแทนคำค้นหรือ %s สำหรับคำค้นที่เข้ารหัส หากไม่มีตัวแทน cmux จะเพิ่ม q= ให้กับ URL" + "customSearchEngineURLTemplate": "URL ค้นหาที่ใช้เมื่อ defaultSearchEngine เป็น custom ใส่ตัวแทนคำค้นหรือ %s สำหรับคำค้นที่เข้ารหัส หากไม่มีตัวแทน cmux จะเพิ่ม q= ให้กับ URL", + "engine": "กลไกเบราว์เซอร์สำหรับบานหน้าต่างเบราว์เซอร์ที่สร้างขึ้นใหม่ Auto ติดตามตระกูลเอ็นจิ้นของเบราว์เซอร์เริ่มต้น macOS บานหน้าต่างพร็อกซีระยะไกลและหน้าภายใน cmux ใช้ WebKit" }, "workspaceGroups": { "newWorkspacePlacement": "ค่าเริ่มต้นส่วนกลางสำหรับตำแหน่งที่ Cmd-N ภายในกลุ่ม ปุ่ม + ในหัวกลุ่ม และการกระทำของกลุ่มที่ตั้งค่าไว้จะวาง workspace ใหม่: `afterCurrent` (หลัง workspace ที่ใช้งานอยู่ในกลุ่ม และ fallback เป็น top), `top` (ตำแหน่งที่สอง ถัดจาก anchor ทันที) หรือ `end` (หลังสมาชิกสุดท้าย).", diff --git a/web/messages/tr.json b/web/messages/tr.json index 9e771c6e604..d6e9b59774f 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Tarayıcı adres çubuğundaki URL olmayan sorgular için varsayılan arama motoru. Diğer sağlayıcılar için custom ile customSearchEngineURLTemplate kullanın.", "customSearchEngineName": "defaultSearchEngine custom olduğunda kullanılan görünen ad.", - "customSearchEngineURLTemplate": "defaultSearchEngine custom olduğunda kullanılan arama URL’si. Kodlanmış sorgu için sorgu yer tutucusunu veya %s ekleyin. Yer tutucu yoksa cmux URL’ye q= ekler." + "customSearchEngineURLTemplate": "defaultSearchEngine custom olduğunda kullanılan arama URL’si. Kodlanmış sorgu için sorgu yer tutucusunu veya %s ekleyin. Yer tutucu yoksa cmux URL’ye q= ekler.", + "engine": "Yeni oluşturulan tarayıcı bölmeleri için tarayıcı motoru. Otomatik, macOS varsayılan tarayıcısının motor ailesini takip eder. Uzaktan proxy'li bölmeler ve cmux dahili sayfaları WebKit'i kullanır." }, "workspaceGroups": { "newWorkspacePlacement": "Bir grup içindeki Cmd-N, grup başlığındaki + düğmesi ve yapılandırılmış grup eylemlerinin yeni workspace’i nereye yerleştireceğine dair genel varsayılan: `afterCurrent` (gruptaki aktif workspace’ten sonra, yoksa top), `top` (ankordan hemen sonraki ikinci konum) veya `end` (son üyeden sonra).", diff --git a/web/messages/uk.json b/web/messages/uk.json index cdafca92185..c04c2152f0c 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "Пошукова система за замовчуванням для запитів в адресному рядку браузера, які не є URL. Для інших провайдерів використовуйте custom з customSearchEngineURLTemplate.", "customSearchEngineName": "Назва для показу, коли defaultSearchEngine має значення custom.", - "customSearchEngineURLTemplate": "URL пошуку, який використовується коли defaultSearchEngine має значення custom. Додайте заповнювач запиту або %s для закодованого запиту. Без заповнювача cmux додасть q= до URL." + "customSearchEngineURLTemplate": "URL пошуку, який використовується коли defaultSearchEngine має значення custom. Додайте заповнювач запиту або %s для закодованого запиту. Без заповнювача cmux додасть q= до URL.", + "engine": "Механізм браузера для щойно створених панелей браузера. Auto відповідає сімейству механізмів браузера macOS за замовчуванням. Панелі з віддаленим проксі та внутрішні сторінки cmux використовують WebKit." }, "workspaceGroups": { "newWorkspacePlacement": "Глобальне значення за замовчуванням для того, куди Cmd-N у групі, кнопка + у заголовку групи та налаштовані дії групи розміщують новий workspace: `afterCurrent` (після активного workspace у групі, з fallback до top), `top` (друга позиція одразу після якоря) або `end` (після останнього учасника).", diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 85060e5c863..55aaaa13300 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "浏览器地址栏输入不是 URL 时使用的默认搜索引擎。对于任意提供商,请使用 custom 和 customSearchEngineURLTemplate。", "customSearchEngineName": "defaultSearchEngine 为 custom 时使用的显示名称。", - "customSearchEngineURLTemplate": "defaultSearchEngine 为 custom 时使用的搜索 URL。使用查询占位符或 %s 表示已编码的查询。如果没有占位符,cmux 会向 URL 添加 q=。" + "customSearchEngineURLTemplate": "defaultSearchEngine 为 custom 时使用的搜索 URL。使用查询占位符或 %s 表示已编码的查询。如果没有占位符,cmux 会向 URL 添加 q=。", + "engine": "新创建的浏览器窗格的浏览器引擎。 Auto 遵循 macOS 默认浏览器的引擎系列。远程代理窗格和 cmux 内部页面使用 WebKit。" }, "workspaceGroups": { "newWorkspacePlacement": "组内 Cmd-N、组标题中的 + 按钮以及已配置的组操作放置新 workspace 的全局默认位置:`afterCurrent`(在当前活动的组内 workspace 后面,没有时回退到 top)、`top`(第二个位置,紧跟锚点之后)或 `end`(最后一个成员之后)。", diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index f17f0a1ec07..fcaefca3409 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -925,7 +925,8 @@ "browser": { "defaultSearchEngine": "瀏覽器位址列輸入不是 URL 時使用的預設搜尋引擎。任意提供者請使用 custom 與 customSearchEngineURLTemplate。", "customSearchEngineName": "defaultSearchEngine 為 custom 時使用的顯示名稱。", - "customSearchEngineURLTemplate": "defaultSearchEngine 為 custom 時使用的搜尋 URL。使用查詢佔位符或 %s 表示已編碼的查詢。若沒有佔位符,cmux 會將 q= 加到 URL。" + "customSearchEngineURLTemplate": "defaultSearchEngine 為 custom 時使用的搜尋 URL。使用查詢佔位符或 %s 表示已編碼的查詢。若沒有佔位符,cmux 會將 q= 加到 URL。", + "engine": "新建的瀏覽器窗格的瀏覽器引擎。 Auto 遵循 macOS 預設瀏覽器的引擎系列。遠端代理窗格和 cmux 內部頁面使用 WebKit。" }, "workspaceGroups": { "newWorkspacePlacement": "群組內 Cmd-N、群組標題中的 + 按鈕,以及已設定的群組動作放置新 workspace 的全域預設位置:`afterCurrent`(在目前作用中的群組內 workspace 之後,沒有時回退到 top)、`top`(第二個位置,緊接錨點之後)或 `end`(最後一個成員之後)。", From 799dcfc7e3d7c1ba08cc86b36d975e89e6b96bac Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:03:54 -0700 Subject: [PATCH 02/39] Bound Chromium engine resource lifecycles --- .../BrowserChromiumProfileDirectory.swift | 21 +++ .../Engine/Chromium/CDPConnection.swift | 159 ++++++++++++++---- ...hromiumBrowserEngineSession+Viewport.swift | 159 +++++++++++++++--- .../ChromiumBrowserEngineSession.swift | 10 ++ .../Chromium/ChromiumProcessController.swift | 10 +- ...BrowserChromiumProfileDirectoryTests.swift | 52 ++++++ .../Engine/CDPConnectionTests.swift | 48 ++++++ .../ChromiumViewportInputQueueTests.swift | 57 +++++++ 8 files changed, 460 insertions(+), 56 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift index 62423c4c6d2..87fae820ba1 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift @@ -25,4 +25,25 @@ public struct BrowserChromiumProfileDirectory { .appendingPathComponent(surfaceID.uuidString, isDirectory: true) .appendingPathComponent(sessionID.uuidString, isDirectory: true) } + + /// Removes a session directory only when it has the exact cmux-owned UUID path shape. + func removeSessionDirectoryIfOwned(_ sessionDirectory: URL) throws { + let root = fileManager.temporaryDirectory + .appendingPathComponent("cmux-chromium", isDirectory: true) + .standardizedFileURL + .resolvingSymlinksInPath() + let candidate = sessionDirectory + .standardizedFileURL + .resolvingSymlinksInPath() + let rootComponents = root.pathComponents + let candidateComponents = candidate.pathComponents + guard candidateComponents.count == rootComponents.count + 3, + candidateComponents.starts(with: rootComponents), + candidateComponents.suffix(3).allSatisfy({ UUID(uuidString: $0) != nil }) else { + return + } + guard fileManager.fileExists(atPath: candidate.path) else { return } + + try fileManager.removeItem(at: candidate) + } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index ab624bab1bd..2becc2dfac7 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -1,21 +1,79 @@ import Foundation +/// Sendable transport boundary for the browser-level DevTools WebSocket. +protocol CDPWebSocketTransport: Sendable { + func resume() + func send(_ data: Data) async throws + func receive() async throws -> Data + func cancel() +} + +private final class URLSessionCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { + private let session: URLSession + private let task: URLSessionWebSocketTask + + init(url: URL) { + let configuration = URLSessionConfiguration.ephemeral + let session = URLSession(configuration: configuration) + self.session = session + self.task = session.webSocketTask(with: url) + } + + func resume() { + task.resume() + } + + func send(_ data: Data) async throws { + try await task.send(.data(data)) + } + + func receive() async throws -> Data { + switch try await task.receive() { + case .data(let data): + return data + case .string(let value): + return Data(value.utf8) + @unknown default: + return Data() + } + } + + func cancel() { + task.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + } +} + /// Owns one browser-level Chrome DevTools Protocol WebSocket connection. actor CDPConnection { - private let webSocketTask: URLSessionWebSocketTask - private let urlSession: URLSession + private struct PendingRequest { + let continuation: CheckedContinuation + let timeoutTask: Task + } + + private let transport: any CDPWebSocketTransport + private let requestTimeout: Duration private let eventContinuation: AsyncStream.Continuation private let eventStream: AsyncStream private var receiveTask: Task? private var nextRequestID = 1 - private var pendingRequests: [Int: CheckedContinuation] = [:] + private var pendingRequests: [Int: PendingRequest] = [:] private var isClosed = false - init(url: URL) { - let configuration = URLSessionConfiguration.ephemeral - let session = URLSession(configuration: configuration) - self.urlSession = session - self.webSocketTask = session.webSocketTask(with: url) + init(url: URL, requestTimeout: Duration = .seconds(10)) { + self.transport = URLSessionCDPWebSocketTransport(url: url) + self.requestTimeout = requestTimeout + (eventStream, eventContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(256) + ) + } + + init( + transport: any CDPWebSocketTransport, + requestTimeout: Duration = .seconds(10) + ) { + self.transport = transport + self.requestTimeout = requestTimeout (eventStream, eventContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(256) ) @@ -23,7 +81,7 @@ actor CDPConnection { func connect() { guard receiveTask == nil else { return } - webSocketTask.resume() + transport.resume() receiveTask = Task { [weak self] in await self?.receiveMessages() } @@ -46,14 +104,40 @@ actor CDPConnection { ] if let sessionID { message["sessionId"] = .string(sessionID) } let data = try JSONEncoder().encode(message) - return try await withCheckedThrowingContinuation { continuation in - pendingRequests[requestID] = continuation - Task { [weak self, webSocketTask] in - do { - try await webSocketTask.send(.data(data)) - } catch { - await self?.failPendingRequest(requestID, error: error) + return try await withTaskCancellationHandler { + try await withCheckedThrowingContinuation { continuation in + guard !Task.isCancelled else { + continuation.resume(throwing: CancellationError()) + return } + let timeoutTask = Task { [weak self, requestTimeout] in + do { + try await ContinuousClock().sleep(for: requestTimeout) + } catch { + return + } + await self?.failPendingRequest( + requestID, + error: BrowserEngineSessionError.chromiumProtocol( + "DevTools request \(method) timed out." + ) + ) + } + pendingRequests[requestID] = PendingRequest( + continuation: continuation, + timeoutTask: timeoutTask + ) + Task { [weak self, transport] in + do { + try await transport.send(data) + } catch { + await self?.failPendingRequest(requestID, error: error) + } + } + } + } onCancel: { [weak self] in + Task { + await self?.failPendingRequest(requestID, error: CancellationError()) } } } @@ -63,38 +147,40 @@ actor CDPConnection { isClosed = true receiveTask?.cancel() receiveTask = nil - webSocketTask.cancel(with: .goingAway, reason: nil) - urlSession.invalidateAndCancel() + transport.cancel() let error = BrowserEngineSessionError.chromiumProtocol("DevTools connection closed.") - pendingRequests.values.forEach { $0.resume(throwing: error) } - pendingRequests.removeAll() + failAllPendingRequests(error: error) eventContinuation.finish() } private func failPendingRequest(_ requestID: Int, error: any Error) { - pendingRequests.removeValue(forKey: requestID)?.resume(throwing: error) + guard let request = pendingRequests.removeValue(forKey: requestID) else { return } + request.timeoutTask.cancel() + request.continuation.resume(throwing: error) + } + + private func failAllPendingRequests(error: any Error) { + let requests = Array(pendingRequests.values) + pendingRequests.removeAll() + for request in requests { + request.timeoutTask.cancel() + request.continuation.resume(throwing: error) + } } private func receiveMessages() async { do { while !Task.isCancelled { - let message = try await webSocketTask.receive() - let data: Data - switch message { - case .data(let value): - data = value - case .string(let value): - data = Data(value.utf8) - @unknown default: - continue - } + let data = try await transport.receive() + guard !data.isEmpty else { continue } try handleMessage(data) } } catch { guard !isClosed else { return } isClosed = true - pendingRequests.values.forEach { $0.resume(throwing: error) } - pendingRequests.removeAll() + transport.cancel() + receiveTask = nil + failAllPendingRequests(error: error) eventContinuation.finish() } } @@ -102,12 +188,13 @@ actor CDPConnection { private func handleMessage(_ data: Data) throws { let payload = try JSONDecoder().decode([String: CDPJSONValue].self, from: data) if let requestID = payload["id"]?.intValue, - let continuation = pendingRequests.removeValue(forKey: requestID) { + let request = pendingRequests.removeValue(forKey: requestID) { + request.timeoutTask.cancel() if let remoteError = payload["error"]?.objectValue { let message = remoteError["message"]?.stringValue ?? "Unknown DevTools error" - continuation.resume(throwing: BrowserEngineSessionError.chromiumProtocol(message)) + request.continuation.resume(throwing: BrowserEngineSessionError.chromiumProtocol(message)) } else { - continuation.resume(returning: payload["result"] ?? .null) + request.continuation.resume(returning: payload["result"] ?? .null) } return } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index 98b7deaab0b..2cc8bbb7042 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -1,5 +1,100 @@ import Foundation +struct ChromiumViewportInputCommand: Equatable { + enum CoalescingKind: Equatable { + case mouseMove + case mouseWheel + } + + let method: String + var parameters: [String: CDPJSONValue] + let coalescingKind: CoalescingKind? + + static func mouse(parameters: [String: CDPJSONValue]) -> Self { + let coalescingKind: CoalescingKind? = switch parameters["type"] { + case .string("mouseMoved"): + .mouseMove + case .string("mouseWheel"): + .mouseWheel + default: + nil + } + return Self( + method: "Input.dispatchMouseEvent", + parameters: parameters, + coalescingKind: coalescingKind + ) + } + + static func key(parameters: [String: CDPJSONValue]) -> Self { + Self( + method: "Input.dispatchKeyEvent", + parameters: parameters, + coalescingKind: nil + ) + } + + func coalescing(with newer: Self) -> Self { + guard coalescingKind == .mouseWheel, newer.coalescingKind == .mouseWheel else { + return newer + } + var parameters = newer.parameters + parameters["deltaX"] = .number(number(for: "deltaX") + newer.number(for: "deltaX")) + parameters["deltaY"] = .number(number(for: "deltaY") + newer.number(for: "deltaY")) + return Self( + method: newer.method, + parameters: parameters, + coalescingKind: newer.coalescingKind + ) + } + + private func number(for key: String) -> Double { + guard case .number(let value) = parameters[key] else { return 0 } + return value + } +} + +struct ChromiumViewportInputQueue { + static let maximumPendingCommands = 64 + + private(set) var commands: [ChromiumViewportInputCommand] = [] + + var count: Int { commands.count } + + mutating func enqueue(_ command: ChromiumViewportInputCommand) { + if let coalescingKind = command.coalescingKind { + let currentOrderingSegmentStart = commands.lastIndex(where: { + $0.coalescingKind == nil + }).map { $0 + 1 } ?? commands.startIndex + if let existingIndex = commands[currentOrderingSegmentStart...].firstIndex(where: { + $0.coalescingKind == coalescingKind + }) { + commands[existingIndex] = commands[existingIndex].coalescing(with: command) + return + } + } + + if commands.count == Self.maximumPendingCommands { + guard let coalescibleIndex = commands.firstIndex(where: { + $0.coalescingKind != nil + }) else { + return + } + commands.remove(at: coalescibleIndex) + } + commands.append(command) + } + + mutating func popFirst() -> ChromiumViewportInputCommand? { + guard !commands.isEmpty else { return nil } + return commands.removeFirst() + } + + mutating func removeAll() { + commands.removeAll(keepingCapacity: true) + } +} + extension ChromiumBrowserEngineSession { func handleViewportMessage(_ payload: [String: Any]) { guard let type = payload["type"] as? String else { return } @@ -32,13 +127,24 @@ extension ChromiumBrowserEngineSession { } private func updateDeviceMetrics() { - guard let connection, let cdpSessionID else { return } - Task { try? await sendDeviceMetrics(connection: connection, sessionID: cdpSessionID) } + deviceMetricsPending = true + guard deviceMetricsTask == nil else { return } + deviceMetricsTask = Task { [weak self] in + await self?.drainDeviceMetrics() + } + } + + private func drainDeviceMetrics() async { + defer { deviceMetricsTask = nil } + while deviceMetricsPending, !Task.isCancelled { + deviceMetricsPending = false + guard let connection, let cdpSessionID else { return } + _ = try? await sendDeviceMetrics(connection: connection, sessionID: cdpSessionID) + } } private func dispatchMouse(_ payload: [String: Any]) { - guard let connection, let cdpSessionID, - let event = payload["event"] as? String else { return } + guard let event = payload["event"] as? String else { return } let buttonIndex = payload["button"] as? Int ?? -1 let button = [0: "left", 1: "middle", 2: "right"][buttonIndex] ?? "none" var parameters: [String: CDPJSONValue] = [ @@ -53,18 +159,11 @@ extension ChromiumBrowserEngineSession { parameters["deltaX"] = .number(payload["deltaX"] as? Double ?? 0) parameters["deltaY"] = .number(payload["deltaY"] as? Double ?? 0) } - Task { - try? await connection.send( - method: "Input.dispatchMouseEvent", - parameters: parameters, - sessionID: cdpSessionID - ) - } + enqueueViewportInput(.mouse(parameters: parameters)) } private func dispatchKey(_ payload: [String: Any]) { - guard let connection, let cdpSessionID, - let event = payload["event"] as? String else { return } + guard let event = payload["event"] as? String else { return } let parameters: [String: CDPJSONValue] = [ "type": .string(event), "key": .string(payload["key"] as? String ?? ""), @@ -72,12 +171,34 @@ extension ChromiumBrowserEngineSession { "text": .string(payload["text"] as? String ?? ""), "modifiers": .number(Double(payload["modifiers"] as? Int ?? 0)), ] - Task { - try? await connection.send( - method: "Input.dispatchKeyEvent", - parameters: parameters, - sessionID: cdpSessionID - ) + enqueueViewportInput(.key(parameters: parameters)) + } + + private func enqueueViewportInput(_ command: ChromiumViewportInputCommand) { + viewportInputQueue.enqueue(command) + guard viewportInputTask == nil else { return } + viewportInputTask = Task { [weak self] in + await self?.drainViewportInput() + } + } + + private func drainViewportInput() async { + defer { viewportInputTask = nil } + while !Task.isCancelled, let command = viewportInputQueue.popFirst() { + guard let connection, let cdpSessionID else { + viewportInputQueue.removeAll() + return + } + do { + _ = try await connection.send( + method: command.method, + parameters: command.parameters, + sessionID: cdpSessionID + ) + } catch { + viewportInputQueue.removeAll() + return + } } } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index f052bc9b7b0..0a74ab8f004 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -31,6 +31,10 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private var pendingRequest: URLRequest? private var initializationScripts: [String] private var initializationScriptInstallations: [Int: Task] = [:] + var viewportInputQueue = ChromiumViewportInputQueue() + var viewportInputTask: Task? + var deviceMetricsPending = false + var deviceMetricsTask: Task? var viewportWidth = 1280 var viewportHeight = 720 var deviceScaleFactor = 1.0 @@ -229,6 +233,12 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { eventTask?.cancel() initializationScriptInstallations.values.forEach { $0.cancel() } initializationScriptInstallations.removeAll() + viewportInputTask?.cancel() + viewportInputTask = nil + viewportInputQueue.removeAll() + deviceMetricsTask?.cancel() + deviceMetricsTask = nil + deviceMetricsPending = false startupTask = nil eventTask = nil viewportWebView.configuration.userContentController.removeScriptMessageHandler( diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift index 13317536155..bf545f20ab6 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -39,7 +39,15 @@ actor ChromiumProcessController { process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice process.standardError = stderrPipe - try process.run() + process.terminationHandler = { _ in + try? BrowserChromiumProfileDirectory().removeSessionDirectoryIfOwned(userDataDirectory) + } + do { + try process.run() + } catch { + try? BrowserChromiumProfileDirectory().removeSessionDirectoryIfOwned(userDataDirectory) + throw error + } self.process = process return try await withCheckedThrowingContinuation { continuation in diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift new file mode 100644 index 00000000000..8a87f5ac335 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift @@ -0,0 +1,52 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite struct BrowserChromiumProfileDirectoryTests { + @Test func removesOnlyTheOwnedSession() throws { + let builder = BrowserChromiumProfileDirectory() + let profileID = UUID() + let surfaceID = UUID() + let firstSession = builder.url( + profileID: profileID, + surfaceID: surfaceID, + sessionID: UUID() + ) + let secondSession = builder.url( + profileID: profileID, + surfaceID: surfaceID, + sessionID: UUID() + ) + let profileDirectory = firstSession + .deletingLastPathComponent() + .deletingLastPathComponent() + try FileManager.default.createDirectory(at: firstSession, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: secondSession, withIntermediateDirectories: true) + defer { + try? builder.removeSessionDirectoryIfOwned(firstSession) + try? builder.removeSessionDirectoryIfOwned(secondSession) + try? FileManager.default.removeItem(at: profileDirectory) + } + + try builder.removeSessionDirectoryIfOwned(firstSession) + + #expect(!FileManager.default.fileExists(atPath: firstSession.path)) + #expect(FileManager.default.fileExists(atPath: secondSession.path)) + + try builder.removeSessionDirectoryIfOwned(secondSession) + + #expect(!FileManager.default.fileExists(atPath: secondSession.path)) + } + + @Test func refusesToRemoveDirectoryOutsideOwnedUUIDHierarchy() throws { + let builder = BrowserChromiumProfileDirectory() + let outsideDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-chromium-cleanup-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: outsideDirectory) } + + try builder.removeSessionDirectoryIfOwned(outsideDirectory) + + #expect(FileManager.default.fileExists(atPath: outsideDirectory.path)) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift new file mode 100644 index 00000000000..9c61efc7c86 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -0,0 +1,48 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite struct CDPConnectionTests { + @Test func requestFailsAtItsDeadlineWhenChromiumNeverReplies() async { + let connection = CDPConnection( + transport: NoResponseCDPWebSocketTransport(), + requestTimeout: .milliseconds(20) + ) + + await #expect(throws: BrowserEngineSessionError.self) { + _ = try await connection.send(method: "Runtime.evaluate") + } + await connection.close() + } + + @Test func cancellingCallerCancelsItsPendingRequest() async { + let connection = CDPConnection( + transport: NoResponseCDPWebSocketTransport(), + requestTimeout: .seconds(5) + ) + let request = Task { + try await connection.send(method: "Page.captureScreenshot") + } + await Task.yield() + + request.cancel() + + await #expect(throws: CancellationError.self) { + _ = try await request.value + } + await connection.close() + } +} + +private final class NoResponseCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { + func resume() {} + + func send(_: Data) async throws {} + + func receive() async throws -> Data { + try await ContinuousClock().sleep(for: .seconds(60)) + return Data() + } + + func cancel() {} +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift new file mode 100644 index 00000000000..9bf687575a2 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -0,0 +1,57 @@ +import Testing +@testable import CmuxBrowser + +@Suite struct ChromiumViewportInputQueueTests { + @Test func pointerUpdatesCoalesceWithoutCrossingOrderedInput() { + var queue = ChromiumViewportInputQueue() + + queue.enqueue(mouse(type: "mouseMoved", x: 1)) + queue.enqueue(mouse(type: "mouseMoved", x: 2)) + queue.enqueue(.key(parameters: ["type": .string("keyDown")])) + queue.enqueue(mouse(type: "mouseMoved", x: 3)) + queue.enqueue(mouse(type: "mouseMoved", x: 4)) + + #expect(queue.count == 3) + #expect(queue.commands[0].parameters["x"] == .number(2)) + #expect(queue.commands[1].method == "Input.dispatchKeyEvent") + #expect(queue.commands[2].parameters["x"] == .number(4)) + } + + @Test func wheelUpdatesAccumulateTheirDeltas() { + var queue = ChromiumViewportInputQueue() + + queue.enqueue(mouse(type: "mouseWheel", deltaX: 2, deltaY: 3)) + queue.enqueue(mouse(type: "mouseWheel", deltaX: -1, deltaY: 5)) + + #expect(queue.count == 1) + #expect(queue.commands[0].parameters["deltaX"] == .number(1)) + #expect(queue.commands[0].parameters["deltaY"] == .number(8)) + } + + @Test func pendingInputHasAFixedUpperBound() { + var queue = ChromiumViewportInputQueue() + + for index in 0..<(ChromiumViewportInputQueue.maximumPendingCommands * 2) { + queue.enqueue(.key(parameters: [ + "type": .string("keyDown"), + "key": .string(String(index)), + ])) + } + + #expect(queue.count == ChromiumViewportInputQueue.maximumPendingCommands) + } + + private func mouse( + type: String, + x: Double = 0, + deltaX: Double = 0, + deltaY: Double = 0 + ) -> ChromiumViewportInputCommand { + .mouse(parameters: [ + "type": .string(type), + "x": .number(x), + "deltaX": .number(deltaX), + "deltaY": .number(deltaY), + ]) + } +} From 2c2700d6c07985a636670fd1444331e5d50a8413 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:13:58 -0700 Subject: [PATCH 03/39] Test saturated Chromium input ordering --- .../ChromiumViewportInputQueueTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift index 9bf687575a2..91d74deda36 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -41,6 +41,30 @@ import Testing #expect(queue.count == ChromiumViewportInputQueue.maximumPendingCommands) } + @Test func saturatedOrderedInputKeepsTheNewestCompleteKeyGesture() { + var queue = ChromiumViewportInputQueue() + + for index in 0..<(ChromiumViewportInputQueue.maximumPendingCommands / 2) { + queue.enqueue(key(type: "keyDown", code: "Key\(index)")) + queue.enqueue(key(type: "keyUp", code: "Key\(index)")) + } + + queue.enqueue(key(type: "keyDown", code: "NewestKey")) + queue.enqueue(key(type: "keyUp", code: "NewestKey")) + + #expect(queue.count == ChromiumViewportInputQueue.maximumPendingCommands) + #expect(queue.commands.contains(where: { command in + command.parameters["code"] == .string("Key0") + }) == false) + #expect(queue.commands.suffix(2).map { $0.parameters["type"] } == [ + .string("keyDown"), + .string("keyUp"), + ]) + #expect(queue.commands.suffix(2).allSatisfy { command in + command.parameters["code"] == .string("NewestKey") + }) + } + private func mouse( type: String, x: Double = 0, @@ -54,4 +78,11 @@ import Testing "deltaY": .number(deltaY), ]) } + + private func key(type: String, code: String) -> ChromiumViewportInputCommand { + .key(parameters: [ + "type": .string(type), + "code": .string(code), + ]) + } } From a3efcdbe053b7796013d88c580772de6b99117d1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:29:37 -0700 Subject: [PATCH 04/39] Complete Chromium engine automation parity --- .../Engine/BrowserEngineCookie.swift | 59 ++++ .../Engine/BrowserEngineSession.swift | 18 ++ .../BrowserChromiumProfileDirectory.swift | 7 +- .../Engine/Chromium/CDPConnection.swift | 33 ++- .../Chromium/ChromiumBrowserCookieCodec.swift | 58 ++++ ...hromiumBrowserEngineSession+Viewport.swift | 91 ++++++- .../ChromiumBrowserEngineSession.swift | 77 +++++- .../Engine/WebKitBrowserEngineSession.swift | 69 ++++- .../ChromiumBrowserCookieCodecTests.swift | 91 +++++++ .../ChromiumViewportInputQueueTests.swift | 14 + Resources/Localizable.xcstrings | 12 +- Sources/Panels/BrowserPanel.swift | 10 +- Sources/TerminalController.swift | 257 +++++++++++------- 13 files changed, 675 insertions(+), 121 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineCookie.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserCookieCodec.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserCookieCodecTests.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineCookie.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineCookie.swift new file mode 100644 index 00000000000..9467adac298 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineCookie.swift @@ -0,0 +1,59 @@ +public import Foundation + +/// A browser cookie copied across the engine-neutral automation boundary. +/// +/// Use this value when reading or mutating cookies without depending on WebKit +/// or Chrome DevTools Protocol types. +public struct BrowserEngineCookie: Hashable, Sendable { + /// The cookie name. + public let name: String + + /// The cookie value. + public let value: String + + /// The host or domain scope, including a leading dot when supplied by the engine. + public let domain: String + + /// The URL path scope. + public let path: String + + /// Whether the cookie is restricted to secure transports. + public let isSecure: Bool + + /// Whether page JavaScript is prevented from reading the cookie. + public let isHTTPOnly: Bool + + /// The expiration time, or `nil` for a session cookie. + public let expiresDate: Date? + + /// Whether the cookie expires with its browser-engine session. + public var isSessionOnly: Bool { expiresDate == nil } + + /// Creates an engine-neutral cookie. + /// + /// - Parameters: + /// - name: The cookie name. + /// - value: The cookie value. + /// - domain: The host or domain scope. + /// - path: The URL path scope. Defaults to `/`. + /// - isSecure: Whether the cookie requires a secure transport. + /// - isHTTPOnly: Whether page JavaScript is prevented from reading the cookie. + /// - expiresDate: The expiration time, or `nil` for a session cookie. + public init( + name: String, + value: String, + domain: String, + path: String = "/", + isSecure: Bool = false, + isHTTPOnly: Bool = false, + expiresDate: Date? = nil + ) { + self.name = name + self.value = value + self.domain = domain + self.path = path + self.isSecure = isSecure + self.isHTTPOnly = isHTTPOnly + self.expiresDate = expiresDate + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index f1d3d13f7e6..9329d863e19 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -55,6 +55,24 @@ public protocol BrowserEngineSession: AnyObject { /// - Throws: An engine error when the script cannot be installed. func addInitializationScript(_ script: String) async throws + /// Reads all cookies visible to this engine session's browser context. + /// + /// - Returns: Engine-neutral cookie snapshots. + /// - Throws: An engine error when the cookie store cannot be read. + func cookies() async throws -> [BrowserEngineCookie] + + /// Creates or replaces one cookie in this engine session's browser context. + /// + /// - Parameter cookie: The cookie to create or replace. + /// - Throws: An engine error when the cookie cannot be stored. + func setCookie(_ cookie: BrowserEngineCookie) async throws + + /// Deletes one cookie from this engine session's browser context. + /// + /// - Parameter cookie: A cookie snapshot identifying the name, domain, and path to delete. + /// - Throws: An engine error when the cookie cannot be deleted. + func deleteCookie(_ cookie: BrowserEngineCookie) async throws + /// Captures the current page viewport as PNG data. /// /// - Returns: PNG-encoded viewport pixels. diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift index 87fae820ba1..0cb646b0bdf 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift @@ -1,6 +1,11 @@ public import Foundation -/// Builds isolated profile directories for Chromium engine process sessions. +/// Builds isolated temporary profile directories for Chromium engine process sessions. +/// +/// These directories intentionally do not model a persistent cmux browser profile. +/// Chromium owns a process-wide profile lock, so persistent data shared by several +/// panes requires one higher-level process owner per cmux profile rather than reuse +/// or copying of concurrently active `--user-data-dir` trees. public struct BrowserChromiumProfileDirectory { private let fileManager: FileManager diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index 2becc2dfac7..7de217302f1 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -53,6 +53,8 @@ actor CDPConnection { private let transport: any CDPWebSocketTransport private let requestTimeout: Duration + private let encoder = JSONEncoder() + private let decoder = JSONDecoder() private let eventContinuation: AsyncStream.Continuation private let eventStream: AsyncStream private var receiveTask: Task? @@ -103,7 +105,7 @@ actor CDPConnection { "params": .object(parameters), ] if let sessionID { message["sessionId"] = .string(sessionID) } - let data = try JSONEncoder().encode(message) + let data = try encoder.encode(message) return try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { continuation in guard !Task.isCancelled else { @@ -112,6 +114,8 @@ actor CDPConnection { } let timeoutTask = Task { [weak self, requestTimeout] in do { + // A bounded request deadline is intentional; a missing CDP reply + // must not retain its continuation forever. try await ContinuousClock().sleep(for: requestTimeout) } catch { return @@ -142,6 +146,31 @@ actor CDPConnection { } } + /// Sends an ordered command without retaining a request continuation. + /// + /// Input commands use this path because their replies carry no state and waiting + /// for each reply would serialize typing behind a full protocol round trip. + func sendUnacknowledged( + method: String, + parameters: [String: CDPJSONValue] = [:], + sessionID: String? = nil + ) async throws { + guard !isClosed else { + throw BrowserEngineSessionError.chromiumProtocol("DevTools connection is closed.") + } + let requestID = nextRequestID + nextRequestID += 1 + var message: [String: CDPJSONValue] = [ + "id": .number(Double(requestID)), + "method": .string(method), + "params": .object(parameters), + ] + if let sessionID { + message["sessionId"] = .string(sessionID) + } + try await transport.send(encoder.encode(message)) + } + func close() { guard !isClosed else { return } isClosed = true @@ -186,7 +215,7 @@ actor CDPConnection { } private func handleMessage(_ data: Data) throws { - let payload = try JSONDecoder().decode([String: CDPJSONValue].self, from: data) + let payload = try decoder.decode([String: CDPJSONValue].self, from: data) if let requestID = payload["id"]?.intValue, let request = pendingRequests.removeValue(forKey: requestID) { request.timeoutTask.cancel() diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserCookieCodec.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserCookieCodec.swift new file mode 100644 index 00000000000..03a7928e4eb --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserCookieCodec.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Converts cookie values between the engine-neutral model and Chrome DevTools Protocol. +struct ChromiumBrowserCookieCodec { + func cookies(from response: CDPJSONValue) throws -> [BrowserEngineCookie] { + guard let rows = response.objectValue?["cookies"]?.arrayValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium returned an invalid cookie payload.") + } + return try rows.map { row in + guard let object = row.objectValue, + let name = object["name"]?.stringValue, + let value = object["value"]?.stringValue, + let domain = object["domain"]?.stringValue, + let path = object["path"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol( + "Chromium returned an invalid cookie payload." + ) + } + let expiresDate: Date? = if case .number(let seconds) = object["expires"], seconds > 0 { + Date(timeIntervalSince1970: seconds) + } else { + nil + } + return BrowserEngineCookie( + name: name, + value: value, + domain: domain, + path: path, + isSecure: object["secure"] == .bool(true), + isHTTPOnly: object["httpOnly"] == .bool(true), + expiresDate: expiresDate + ) + } + } + + func setParameters(for cookie: BrowserEngineCookie) -> [String: CDPJSONValue] { + var parameters: [String: CDPJSONValue] = [ + "name": .string(cookie.name), + "value": .string(cookie.value), + "domain": .string(cookie.domain), + "path": .string(cookie.path), + "secure": .bool(cookie.isSecure), + "httpOnly": .bool(cookie.isHTTPOnly), + ] + if let expiresDate = cookie.expiresDate { + parameters["expires"] = .number(expiresDate.timeIntervalSince1970) + } + return parameters + } + + func deleteParameters(for cookie: BrowserEngineCookie) -> [String: CDPJSONValue] { + [ + "name": .string(cookie.name), + "domain": .string(cookie.domain), + "path": .string(cookie.path), + ] + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index 2cc8bbb7042..d2bb0db66f7 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -6,10 +6,30 @@ struct ChromiumViewportInputCommand: Equatable { case mouseWheel } + enum GestureTransition: Equatable { + case began(String) + case ended(String) + } + let method: String var parameters: [String: CDPJSONValue] let coalescingKind: CoalescingKind? + var gestureTransition: GestureTransition? { + switch (method, parameters["type"], parameters["code"], parameters["button"]) { + case ("Input.dispatchKeyEvent", .string("keyDown"), .string(let code), _): + return .began("key:\(code)") + case ("Input.dispatchKeyEvent", .string("keyUp"), .string(let code), _): + return .ended("key:\(code)") + case ("Input.dispatchMouseEvent", .string("mousePressed"), _, .string(let button)): + return .began("mouse:\(button)") + case ("Input.dispatchMouseEvent", .string("mouseReleased"), _, .string(let button)): + return .ended("mouse:\(button)") + default: + return nil + } + } + static func mouse(parameters: [String: CDPJSONValue]) -> Self { let coalescingKind: CoalescingKind? = switch parameters["type"] { case .string("mouseMoved"): @@ -61,7 +81,12 @@ struct ChromiumViewportInputQueue { var count: Int { commands.count } - mutating func enqueue(_ command: ChromiumViewportInputCommand) { + /// Enqueues input while retaining the newest complete gestures under pressure. + /// + /// - Returns: `false` only when the queue contains no fully closed gesture that + /// can be discarded without separating a press from its release. + @discardableResult + mutating func enqueue(_ command: ChromiumViewportInputCommand) -> Bool { if let coalescingKind = command.coalescingKind { let currentOrderingSegmentStart = commands.lastIndex(where: { $0.coalescingKind == nil @@ -70,19 +95,21 @@ struct ChromiumViewportInputQueue { $0.coalescingKind == coalescingKind }) { commands[existingIndex] = commands[existingIndex].coalescing(with: command) - return + return true } } if commands.count == Self.maximumPendingCommands { - guard let coalescibleIndex = commands.firstIndex(where: { + if let coalescibleIndex = commands.firstIndex(where: { $0.coalescingKind != nil - }) else { - return + }) { + commands.remove(at: coalescibleIndex) + } else if !discardOldestCompleteGesture() { + return false } - commands.remove(at: coalescibleIndex) } commands.append(command) + return true } mutating func popFirst() -> ChromiumViewportInputCommand? { @@ -93,6 +120,38 @@ struct ChromiumViewportInputQueue { mutating func removeAll() { commands.removeAll(keepingCapacity: true) } + + private mutating func discardOldestCompleteGesture() -> Bool { + for startIndex in commands.indices { + guard case .began(let firstGesture)? = commands[startIndex].gestureTransition else { + continue + } + var activeGestures: Set = [firstGesture] + var endIndex = commands.index(after: startIndex) + var isBalanced = true + while endIndex < commands.endIndex { + switch commands[endIndex].gestureTransition { + case .began(let gesture): + activeGestures.insert(gesture) + case .ended(let gesture): + if activeGestures.contains(gesture) { + activeGestures.remove(gesture) + } else { + isBalanced = false + } + case nil: + break + } + guard isBalanced else { break } + if activeGestures.isEmpty { + commands.removeSubrange(startIndex...endIndex) + return true + } + endIndex = commands.index(after: endIndex) + } + } + return false + } } extension ChromiumBrowserEngineSession { @@ -175,7 +234,20 @@ extension ChromiumBrowserEngineSession { } private func enqueueViewportInput(_ command: ChromiumViewportInputCommand) { - viewportInputQueue.enqueue(command) + guard viewportInputQueue.enqueue(command) else { + failViewportInput() + return + } + startViewportInputDrainingIfNeeded() + } + + func startViewportInputDrainingIfNeeded() { + guard !viewportInputFailed, + viewportInputQueue.count > 0, + connection != nil, + cdpSessionID != nil else { + return + } guard viewportInputTask == nil else { return } viewportInputTask = Task { [weak self] in await self?.drainViewportInput() @@ -186,17 +258,16 @@ extension ChromiumBrowserEngineSession { defer { viewportInputTask = nil } while !Task.isCancelled, let command = viewportInputQueue.popFirst() { guard let connection, let cdpSessionID else { - viewportInputQueue.removeAll() return } do { - _ = try await connection.send( + try await connection.sendUnacknowledged( method: command.method, parameters: command.parameters, sessionID: cdpSessionID ) } catch { - viewportInputQueue.removeAll() + failViewportInput() return } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 0a74ab8f004..1994b25cc4c 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -24,6 +24,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private let userDataDirectory: URL private let processController = ChromiumProcessController() private let viewportHandler = ChromiumViewportMessageHandler() + private let cookieCodec = ChromiumBrowserCookieCodec() var connection: CDPConnection? var cdpSessionID: String? private var startupTask: Task? @@ -33,6 +34,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private var initializationScriptInstallations: [Int: Task] = [:] var viewportInputQueue = ChromiumViewportInputQueue() var viewportInputTask: Task? + var viewportInputFailed = false var deviceMetricsPending = false var deviceMetricsTask: Task? var viewportWidth = 1280 @@ -57,7 +59,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { self.application = application self.userDataDirectory = userDataDirectory self.initializationScripts = initializationScripts - (stateUpdates, stateContinuation) = AsyncStream.makeStream() + (stateUpdates, stateContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) viewportHandler.session = self let controller = viewportWebView.configuration.userContentController controller.removeScriptMessageHandler(forName: "cmuxChromiumViewport") @@ -182,6 +186,51 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { ) } + /// Reads cookies from the Chromium browser context. + public func cookies() async throws -> [BrowserEngineCookie] { + if connection == nil { + await startupTask?.value + } + guard let connection else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + let response = try await connection.send(method: "Storage.getCookies") + return try cookieCodec.cookies(from: response) + } + + /// Creates or replaces a cookie in the Chromium browser context. + public func setCookie(_ cookie: BrowserEngineCookie) async throws { + if connection == nil { + await startupTask?.value + } + guard let connection, let cdpSessionID else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + let response = try await connection.send( + method: "Network.setCookie", + parameters: cookieCodec.setParameters(for: cookie), + sessionID: cdpSessionID + ) + if response.objectValue?["success"] == .bool(false) { + throw BrowserEngineSessionError.chromiumProtocol("Chromium rejected the cookie.") + } + } + + /// Deletes a cookie from the Chromium browser context. + public func deleteCookie(_ cookie: BrowserEngineCookie) async throws { + if connection == nil { + await startupTask?.value + } + guard let connection, let cdpSessionID else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium is not ready.") + } + _ = try await connection.send( + method: "Network.deleteCookies", + parameters: cookieCodec.deleteParameters(for: cookie), + sessionID: cdpSessionID + ) + } + private func isolatedExecutionContextID( connection: CDPConnection, sessionID: String @@ -236,6 +285,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { viewportInputTask?.cancel() viewportInputTask = nil viewportInputQueue.removeAll() + viewportInputFailed = true deviceMetricsTask?.cancel() deviceMetricsTask = nil deviceMetricsPending = false @@ -303,6 +353,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { throw BrowserEngineSessionError.chromiumProtocol("Chromium did not attach to the page target.") } cdpSessionID = sessionID + startViewportInputDrainingIfNeeded() beginEvents(connection: connection, sessionID: sessionID) _ = try await connection.send(method: "Page.enable", sessionID: sessionID) _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) @@ -343,8 +394,12 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { case "Page.screencastFrame": guard let data = event.parameters["data"]?.stringValue, let frameSessionID = event.parameters["sessionId"]?.intValue else { return } - let literal = ChromiumViewportDocumentJSONLiteral().encode("data:image/jpeg;base64,\(data)") - _ = try? await viewportWebView.evaluateJavaScript("window.cmuxChromiumFrame(\(literal))") + _ = try? await viewportWebView.callAsyncJavaScript( + "return await window.cmuxChromiumFrame('data:image/jpeg;base64,' + base64)", + arguments: ["base64": data], + in: nil, + contentWorld: .page + ) _ = try? await connection.send( method: "Page.screencastFrameAck", parameters: ["sessionId": .number(Double(frameSessionID))], @@ -474,6 +529,22 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { )) } + func failViewportInput() { + guard !viewportInputFailed else { return } + viewportInputFailed = true + viewportInputTask?.cancel() + viewportInputTask = nil + viewportInputQueue.removeAll() + let connection = connection + self.connection = nil + cdpSessionID = nil + Task { [processController] in + await processController.close() + await connection?.close() + } + presentOperationFailure() + } + private func presentMessage(_ message: String) { updateState { state in state.isLoading = false diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index 2e0c3888795..06f17fe41be 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -6,6 +6,10 @@ public import WebKit /// A ``BrowserEngineSession`` backed by an in-process `WKWebView`. @MainActor public final class WebKitBrowserEngineSession: BrowserEngineSession { + private enum CookieError: Error { + case invalidPayload + } + /// The engine family implementing this session. public let kind = BrowserEngineKind.webKit @@ -36,7 +40,9 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { canGoBack: webView.canGoBack, canGoForward: webView.canGoForward ) - (stateUpdates, stateContinuation) = AsyncStream.makeStream() + (stateUpdates, stateContinuation) = AsyncStream.makeStream( + bufferingPolicy: .bufferingNewest(1) + ) installObservations() } @@ -90,6 +96,45 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { ) } + /// Reads cookies from the WebKit website data store. + public func cookies() async throws -> [BrowserEngineCookie] { + await withCheckedContinuation { continuation in + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + continuation.resume(returning: cookies.map { cookie in + BrowserEngineCookie( + name: cookie.name, + value: cookie.value, + domain: cookie.domain, + path: cookie.path, + isSecure: cookie.isSecure, + isHTTPOnly: cookie.isHTTPOnly, + expiresDate: cookie.expiresDate + ) + }) + } + } + } + + /// Creates or replaces a cookie in the WebKit website data store. + public func setCookie(_ cookie: BrowserEngineCookie) async throws { + let httpCookie = try makeHTTPCookie(cookie) + await withCheckedContinuation { continuation in + webView.configuration.websiteDataStore.httpCookieStore.setCookie(httpCookie) { + continuation.resume() + } + } + } + + /// Deletes a cookie from the WebKit website data store. + public func deleteCookie(_ cookie: BrowserEngineCookie) async throws { + let httpCookie = try makeHTTPCookie(cookie) + await withCheckedContinuation { continuation in + webView.configuration.websiteDataStore.httpCookieStore.delete(httpCookie) { + continuation.resume() + } + } + } + /// Captures a PNG snapshot of the current WebKit viewport. public func captureScreenshot() async throws -> Data { let image = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in @@ -138,6 +183,28 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { ] } + private func makeHTTPCookie(_ cookie: BrowserEngineCookie) throws -> HTTPCookie { + var properties: [HTTPCookiePropertyKey: Any] = [ + .name: cookie.name, + .value: cookie.value, + .domain: cookie.domain, + .path: cookie.path, + ] + if cookie.isSecure { + properties[.secure] = "TRUE" + } + if cookie.isHTTPOnly { + properties[HTTPCookiePropertyKey("HttpOnly")] = "TRUE" + } + if let expiresDate = cookie.expiresDate { + properties[.expires] = expiresDate + } + guard let httpCookie = HTTPCookie(properties: properties) else { + throw CookieError.invalidPayload + } + return httpCookie + } + private func publishState() { state = BrowserEngineState( url: webView.url, diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserCookieCodecTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserCookieCodecTests.swift new file mode 100644 index 00000000000..173f1fe085c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserCookieCodecTests.swift @@ -0,0 +1,91 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite struct ChromiumBrowserCookieCodecTests { + @Test func decodesPersistentAndSessionCookies() throws { + let codec = ChromiumBrowserCookieCodec() + let response = CDPJSONValue.object([ + "cookies": .array([ + .object([ + "name": .string("persistent"), + "value": .string("one"), + "domain": .string(".example.com"), + "path": .string("/account"), + "secure": .bool(true), + "httpOnly": .bool(true), + "expires": .number(2_000_000_000), + ]), + .object([ + "name": .string("session"), + "value": .string("two"), + "domain": .string("example.com"), + "path": .string("/"), + "secure": .bool(false), + "httpOnly": .bool(false), + "expires": .number(-1), + ]), + ]), + ]) + + let cookies = try codec.cookies(from: response) + + #expect(cookies.count == 2) + #expect(cookies[0].name == "persistent") + #expect(cookies[0].isSecure) + #expect(cookies[0].isHTTPOnly) + #expect(cookies[0].expiresDate == Date(timeIntervalSince1970: 2_000_000_000)) + #expect(cookies[1].name == "session") + #expect(cookies[1].isSessionOnly) + } + + @Test func encodesSetAndDeleteParameters() { + let codec = ChromiumBrowserCookieCodec() + let cookie = BrowserEngineCookie( + name: "session", + value: "value", + domain: ".example.com", + path: "/account", + isSecure: true, + isHTTPOnly: true, + expiresDate: Date(timeIntervalSince1970: 2_000_000_000) + ) + + let setParameters = codec.setParameters(for: cookie) + let deleteParameters = codec.deleteParameters(for: cookie) + + #expect(setParameters["name"] == .string("session")) + #expect(setParameters["value"] == .string("value")) + #expect(setParameters["secure"] == .bool(true)) + #expect(setParameters["httpOnly"] == .bool(true)) + #expect(setParameters["expires"] == .number(2_000_000_000)) + #expect(deleteParameters == [ + "name": .string("session"), + "domain": .string(".example.com"), + "path": .string("/account"), + ]) + } + + @Test func rejectsMalformedCookieRowsInsteadOfReturningPartialState() { + let codec = ChromiumBrowserCookieCodec() + let response = CDPJSONValue.object([ + "cookies": .array([ + .object([ + "name": .string("complete"), + "value": .string("one"), + "domain": .string("example.com"), + "path": .string("/"), + ]), + .object([ + "name": .string("missing-domain"), + "value": .string("two"), + "path": .string("/"), + ]), + ]), + ]) + + #expect(throws: BrowserEngineSessionError.self) { + try codec.cookies(from: response) + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift index 91d74deda36..81e8b84a6f5 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -65,6 +65,20 @@ import Testing }) } + @Test func saturatedUnfinishedInputReportsBackpressureWithoutSplittingTransitions() { + var queue = ChromiumViewportInputQueue() + + for index in 0.. [String: Any] { + private enum V2BrowserCookieReadResult { + case success([BrowserEngineCookie]) + case timeout + case failure(String) + } + + private enum V2BrowserCookieMutationResult { + case success + case timeout + case failure(String) + } + + private nonisolated func v2BrowserCookieDict(_ cookie: BrowserEngineCookie) -> [String: Any] { var out: [String: Any] = [ "name": cookie.name, "value": cookie.value, "domain": cookie.domain, "path": cookie.path, "secure": cookie.isSecure, + "http_only": cookie.isHTTPOnly, "session_only": cookie.isSessionOnly ] if let expiresDate = cookie.expiresDate { @@ -9338,83 +9351,100 @@ class TerminalController { return out } - private nonisolated func v2BrowserCookieStoreAll(_ store: WKHTTPCookieStore, timeout: TimeInterval = 3.0) -> [HTTPCookie]? { - v2AwaitCallback(timeout: timeout) { finish in - v2MainSync { - store.getAllCookies { items in - finish(items) + private nonisolated func v2BrowserEngineCookies( + _ browserPanel: BrowserPanel, + timeout: TimeInterval = 12.0 + ) -> V2BrowserCookieReadResult { + let outcome: V2BrowserCookieReadResult? = v2AwaitCallback(timeout: timeout) { finish in + Task { @MainActor in + do { + finish(.success(try await browserPanel.engineSession.cookies())) + } catch { + finish(.failure(error.localizedDescription)) } } } + return outcome ?? .timeout } - private nonisolated func v2BrowserCookieStoreSet(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - v2AwaitCallback(timeout: timeout) { finish in - v2MainSync { - store.setCookie(cookie) { - finish(true) + private nonisolated func v2BrowserEngineSetCookie( + _ cookie: BrowserEngineCookie, + browserPanel: BrowserPanel, + timeout: TimeInterval = 12.0 + ) -> V2BrowserCookieMutationResult { + let outcome: V2BrowserCookieMutationResult? = v2AwaitCallback(timeout: timeout) { finish in + Task { @MainActor in + do { + try await browserPanel.engineSession.setCookie(cookie) + finish(.success) + } catch { + finish(.failure(error.localizedDescription)) } } - } ?? false + } + return outcome ?? .timeout } - private nonisolated func v2BrowserCookieStoreDelete(_ store: WKHTTPCookieStore, cookie: HTTPCookie, timeout: TimeInterval = 3.0) -> Bool { - v2AwaitCallback(timeout: timeout) { finish in - v2MainSync { - store.delete(cookie) { - finish(true) + private nonisolated func v2BrowserEngineDeleteCookie( + _ cookie: BrowserEngineCookie, + browserPanel: BrowserPanel, + timeout: TimeInterval = 12.0 + ) -> V2BrowserCookieMutationResult { + let outcome: V2BrowserCookieMutationResult? = v2AwaitCallback(timeout: timeout) { finish in + Task { @MainActor in + do { + try await browserPanel.engineSession.deleteCookie(cookie) + finish(.success) + } catch { + finish(.failure(error.localizedDescription)) } } - } ?? false - } - - private nonisolated func v2BrowserCookieFromObject(_ raw: [String: Any], fallbackURL: URL?) -> HTTPCookie? { - var props: [HTTPCookiePropertyKey: Any] = [:] - if let name = raw["name"] as? String { - props[.name] = name - } - if let value = raw["value"] as? String { - props[.value] = value } + return outcome ?? .timeout + } - if let urlStr = raw["url"] as? String, let url = URL(string: urlStr) { - props[.originURL] = url - } else if let fallbackURL { - props[.originURL] = fallbackURL + private nonisolated func v2BrowserCookieFromObject( + _ raw: [String: Any], + fallbackURL: URL? + ) -> BrowserEngineCookie? { + guard let name = raw["name"] as? String, + let value = raw["value"] as? String else { + return nil } - - if let domain = raw["domain"] as? String { - props[.domain] = domain - } else if let host = fallbackURL?.host { - props[.domain] = host + let explicitURL = (raw["url"] as? String).flatMap(URL.init(string:)) + guard let domain = raw["domain"] as? String ?? explicitURL?.host ?? fallbackURL?.host else { + return nil } - - if let path = raw["path"] as? String { - props[.path] = path + let expiresDate: Date? = if raw["session_only"] as? Bool == true { + nil + } else if let expires = raw["expires"] as? TimeInterval { + Date(timeIntervalSince1970: expires) + } else if let expires = raw["expires"] as? Int { + Date(timeIntervalSince1970: TimeInterval(expires)) } else { - props[.path] = "/" - } - - if let secure = raw["secure"] as? Bool, secure { - props[.secure] = "TRUE" - } - if let expires = raw["expires"] as? TimeInterval { - props[.expires] = Date(timeIntervalSince1970: expires) - } else if let expiresInt = raw["expires"] as? Int { - props[.expires] = Date(timeIntervalSince1970: TimeInterval(expiresInt)) + nil } - - return HTTPCookie(properties: props) + return BrowserEngineCookie( + name: name, + value: value, + domain: domain, + path: raw["path"] as? String ?? "/", + isSecure: raw["secure"] as? Bool ?? false, + isHTTPOnly: raw["http_only"] as? Bool ?? raw["httpOnly"] as? Bool ?? false, + expiresDate: expiresDate + ) } private nonisolated func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - guard let webView = v2WebKitView(ctx) else { - return v2WebKitOnlyError(method: "browser.cookies.get", engineKind: ctx.engineKind) - } - let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } - guard var cookies = v2BrowserCookieStoreAll(store) else { + var cookies: [BrowserEngineCookie] + switch v2BrowserEngineCookies(ctx.browserPanel) { + case .success(let engineCookies): + cookies = engineCookies + case .timeout: return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + case .failure(let message): + return .err(code: "engine_error", message: message, data: nil) } if let name = v2String(params, "name") { @@ -9433,14 +9463,10 @@ class TerminalController { private nonisolated func v2BrowserCookiesSet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - guard let webView = v2WebKitView(ctx) else { - return v2WebKitOnlyError(method: "browser.cookies.set", engineKind: ctx.engineKind) - } - let cookieContext = v2MainSync { - ( - store: webView.configuration.websiteDataStore.httpCookieStore, - fallbackURL: ctx.browserPanel.currentURL - ) + let fallbackURL = v2MainSync { + ctx.engineKind == .chromium + ? (ctx.browserPanel.engineSession.state.url ?? ctx.browserPanel.currentURL) + : ctx.browserPanel.currentURL } var cookieObjects: [[String: Any]] = [] @@ -9466,13 +9492,16 @@ class TerminalController { var setCount = 0 for raw in cookieObjects { - guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: cookieContext.fallbackURL) else { + guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: fallbackURL) else { return .err(code: "invalid_params", message: "Invalid cookie payload", data: ["cookie": raw]) } - if v2BrowserCookieStoreSet(cookieContext.store, cookie: cookie) { + switch v2BrowserEngineSetCookie(cookie, browserPanel: ctx.browserPanel) { + case .success: setCount += 1 - } else { + case .timeout: return .err(code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name]) + case .failure(let message): + return .err(code: "engine_error", message: message, data: ["name": cookie.name]) } } @@ -9482,12 +9511,14 @@ class TerminalController { private nonisolated func v2BrowserCookiesClear(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in - guard let webView = v2WebKitView(ctx) else { - return v2WebKitOnlyError(method: "browser.cookies.clear", engineKind: ctx.engineKind) - } - let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } - guard let cookies = v2BrowserCookieStoreAll(store) else { + let cookies: [BrowserEngineCookie] + switch v2BrowserEngineCookies(ctx.browserPanel) { + case .success(let engineCookies): + cookies = engineCookies + case .timeout: return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + case .failure(let message): + return .err(code: "engine_error", message: message, data: nil) } let name = v2String(params, "name") @@ -9502,8 +9533,13 @@ class TerminalController { var removed = 0 for cookie in targets { - if v2BrowserCookieStoreDelete(store, cookie: cookie) { + switch v2BrowserEngineDeleteCookie(cookie, browserPanel: ctx.browserPanel) { + case .success: removed += 1 + case .timeout: + return .err(code: "timeout", message: "Timed out deleting cookie", data: ["name": cookie.name]) + case .failure(let message): + return .err(code: "engine_error", message: message, data: ["name": cookie.name]) } } @@ -9995,9 +10031,6 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in - guard let webView = v2WebKitView(ctx) else { - return v2WebKitOnlyError(method: "browser.state.save", engineKind: ctx.engineKind) - } let storageScript = """ (() => { const readStorage = (st) => { @@ -10024,18 +10057,28 @@ class TerminalController { storageValue = v2NormalizeJSValue(value) } - let store = v2MainSync { webView.configuration.websiteDataStore.httpCookieStore } - let cookies = (v2BrowserCookieStoreAll(store) ?? []).map(v2BrowserCookieDict) + let cookies: [BrowserEngineCookie] + switch v2BrowserEngineCookies(ctx.browserPanel) { + case .success(let engineCookies): + cookies = engineCookies + case .timeout: + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + case .failure(let message): + return .err(code: "engine_error", message: message, data: nil) + } let stateSnapshot = v2MainSync { - ( - url: ctx.browserPanel.currentURL?.absoluteString ?? "", + let currentURL = ctx.engineKind == .chromium + ? (ctx.browserPanel.engineSession.state.url ?? ctx.browserPanel.currentURL) + : ctx.browserPanel.currentURL + return ( + url: currentURL?.absoluteString ?? "", frameSelector: v2BrowserFrameSelectorBySurface[ctx.surfaceId] ) } let state: [String: Any] = [ "url": stateSnapshot.url, - "cookies": cookies, + "cookies": cookies.map(v2BrowserCookieDict), "storage": storageValue, "frame_selector": v2OrNull(stateSnapshot.frameSelector) ] @@ -10072,35 +10115,57 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in - guard let webView = v2WebKitView(ctx) else { - return v2WebKitOnlyError(method: "browser.state.load", engineKind: ctx.engineKind) - } - let cookieContext = v2MainSync { + let loadContext = v2MainSync { if let frameSelector = raw["frame_selector"] as? String, !frameSelector.isEmpty { v2BrowserFrameSelectorBySurface[ctx.surfaceId] = frameSelector } else { v2BrowserFrameSelectorBySurface.removeValue(forKey: ctx.surfaceId) } - if let urlStr = raw["url"] as? String, - !urlStr.isEmpty, - let parsed = URL(string: urlStr) { - ctx.browserPanel.navigate(to: parsed) + let targetURL: URL? + if let rawURL = raw["url"] as? String, !rawURL.isEmpty { + targetURL = URL(string: rawURL) + } else { + targetURL = nil } - - return ( - store: webView.configuration.websiteDataStore.httpCookieStore, - fallbackURL: ctx.browserPanel.currentURL - ) + let currentURL = ctx.engineKind == .chromium + ? (ctx.browserPanel.engineSession.state.url ?? ctx.browserPanel.currentURL) + : ctx.browserPanel.currentURL + return (targetURL: targetURL, fallbackURL: currentURL) } if let cookieRows = raw["cookies"] as? [[String: Any]] { for row in cookieRows { - if let cookie = v2BrowserCookieFromObject(row, fallbackURL: cookieContext.fallbackURL) { - _ = v2BrowserCookieStoreSet(cookieContext.store, cookie: cookie) + guard let cookie = v2BrowserCookieFromObject( + row, + fallbackURL: loadContext.targetURL ?? loadContext.fallbackURL + ) else { + continue + } + switch v2BrowserEngineSetCookie(cookie, browserPanel: ctx.browserPanel) { + case .success: + break + case .timeout: + return .err( + code: "timeout", + message: "Timed out setting cookie", + data: ["name": cookie.name] + ) + case .failure(let message): + return .err( + code: "engine_error", + message: message, + data: ["name": cookie.name] + ) } } } + if let targetURL = loadContext.targetURL { + v2MainSync { + ctx.browserPanel.navigate(to: targetURL) + } + } + if let storage = raw["storage"] as? [String: Any] { let storageLiteral = v2JSONLiteral(storage) let script = """ From 8d0d2a0b0de98aa5ee0990420ac9da483295ee77 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:37:34 -0700 Subject: [PATCH 05/39] Test browser engine preference on new tabs --- cmuxTests/WorkspaceUnitTests.swift | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index b4cec45415f..d13619b58b2 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -5352,6 +5352,55 @@ final class WorkspaceBrowserProfileSelectionTests: XCTestCase { "Expected a failed browser split to leave the workspace preferred profile unchanged" ) } + + func testUserInitiatedBrowserCreationUsesCurrentEnginePreference() throws { + let defaults = UserDefaults.standard + let catalog = SettingCatalog() + let engineKey = catalog.browser.engine.userDefaultsKey + let disabledKey = catalog.browser.disabled.userDefaultsKey + let previousEngine = defaults.object(forKey: engineKey) + let previousDisabled = defaults.object(forKey: disabledKey) + defer { + if let previousEngine { + defaults.set(previousEngine, forKey: engineKey) + } else { + defaults.removeObject(forKey: engineKey) + } + if let previousDisabled { + defaults.set(previousDisabled, forKey: disabledKey) + } else { + defaults.removeObject(forKey: disabledKey) + } + } + + defaults.set(BrowserEnginePreference.webKit.rawValue, forKey: engineKey) + defaults.set(false, forKey: disabledKey) + + let workspace = Workspace() + defer { workspace.panels.values.forEach { $0.close() } } + let paneId = try XCTUnwrap(workspace.bonsplitController.focusedPaneId) + let chromiumSource = try XCTUnwrap( + workspace.newBrowserSurface( + inPane: paneId, + focus: true, + browserEngineKind: .chromium + ) + ) + + let newTab = try XCTUnwrap( + workspace.newBrowserSurface(inPane: paneId, focus: false) + ) + let newSplit = try XCTUnwrap( + workspace.newBrowserSplit( + from: chromiumSource.id, + orientation: .horizontal, + focus: false + ) + ) + + XCTAssertEqual(newTab.engineKind, .webKit) + XCTAssertEqual(newSplit.engineKind, .webKit) + } } From 1857d3c67160a05de688d94e7637fc7f92b7c0ae Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:43:31 -0700 Subject: [PATCH 06/39] Respect browser engine preference for new tabs --- Sources/Workspace.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/Sources/Workspace.swift b/Sources/Workspace.swift index 8c94ad34fa9..cdeb5badf24 100644 --- a/Sources/Workspace.swift +++ b/Sources/Workspace.swift @@ -7919,7 +7919,6 @@ final class Workspace: Identifiable, ObservableObject { guard let paneId = sourcePaneId else { return nil } // Create browser panel - let inheritedEngineKind = browserEngineKind ?? (panels[panelId] as? BrowserPanel)?.engineKind let browserPanel = BrowserPanel( workspaceId: id, profileID: resolvedNewBrowserProfileID( @@ -7935,7 +7934,7 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: bypassRemoteProxy, isRemoteWorkspace: isRemoteWorkspace, remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil, - engineSelection: .current(restoring: inheritedEngineKind) + engineSelection: .current(restoring: browserEngineKind) ) configureBrowserPanel(browserPanel) panels[browserPanel.id] = browserPanel @@ -8029,9 +8028,6 @@ final class Workspace: Identifiable, ObservableObject { let previousFocusedPanelId = focusedPanelId let previousHostedView = focusedTerminalPanel?.hostedView - let inheritedEngineKind = browserEngineKind ?? sourcePanelId.flatMap { - (panels[$0] as? BrowserPanel)?.engineKind - } let browserPanel = BrowserPanel( workspaceId: id, profileID: resolvedNewBrowserProfileID( @@ -8049,7 +8045,7 @@ final class Workspace: Identifiable, ObservableObject { bypassRemoteProxy: bypassRemoteProxy, isRemoteWorkspace: isRemoteWorkspace, remoteWebsiteDataStoreIdentifier: isRemoteWorkspace && !bypassRemoteProxy ? id : nil, - engineSelection: .current(restoring: inheritedEngineKind) + engineSelection: .current(restoring: browserEngineKind) ) configureBrowserPanel(browserPanel) panels[browserPanel.id] = browserPanel From 608a6c033aadeb7479100cbdcc75c2fad9d4dd2b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:47:28 -0700 Subject: [PATCH 07/39] Test Chromium navigation request rejection --- .../ChromiumBrowserEngineSessionTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift new file mode 100644 index 00000000000..1476ddb5344 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing +import WebKit +@testable import CmuxBrowser + +@Suite("Chromium browser engine session") +@MainActor +struct ChromiumBrowserEngineSessionTests { + @Test + func rejectsNavigationRequestsWhoseSemanticsCannotBePreserved() { + let url = URL(string: "https://example.com/submit")! + + var postRequest = URLRequest(url: url) + postRequest.httpMethod = "POST" + + var headerRequest = URLRequest(url: url) + headerRequest.setValue("Bearer test-token", forHTTPHeaderField: "Authorization") + + var bodyRequest = URLRequest(url: url) + bodyRequest.httpBody = Data("payload".utf8) + + let uncachedRequest = URLRequest( + url: url, + cachePolicy: .reloadIgnoringLocalCacheData + ) + + for request in [postRequest, headerRequest, bodyRequest, uncachedRequest] { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let initialError = session.state.errorMessage + session.load(request) + + #expect(session.state.url == nil) + #expect(session.state.errorMessage != initialError) + session.close() + } + } +} From c482722f23083d8c074c6f0a1f49c624825068b4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:50:30 -0700 Subject: [PATCH 08/39] Reject unsupported Chromium navigation requests --- .../Engine/BrowserEngineSessionError.swift | 8 ++ .../ChromiumBrowserEngineSession.swift | 13 ++ Resources/Localizable.xcstrings | 125 ++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift index 465139cde30..50797293523 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift @@ -8,6 +8,9 @@ public enum BrowserEngineSessionError: LocalizedError, Sendable { /// A Chromium application could not be found. case chromiumUnavailable + /// A Chromium navigation would discard URL request semantics. + case unsupportedChromiumNavigationRequest + /// A Chrome DevTools Protocol operation failed. case chromiumProtocol(String) @@ -30,6 +33,11 @@ public enum BrowserEngineSessionError: LocalizedError, Sendable { localized: "browser.chromium.error.notInstalled", defaultValue: "Chromium is selected, but no supported Chromium browser is installed." ) + case .unsupportedChromiumNavigationRequest: + return String( + localized: "browser.chromium.error.unsupportedNavigationRequest", + defaultValue: "This request requires WebKit because Chromium cannot preserve its method, headers, body, or cache policy." + ) case .chromiumProtocol: return String( localized: "browser.chromium.error.operationFailed", diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 1994b25cc4c..058b1734f4a 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -84,6 +84,11 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { /// Queues or performs a top-level CDP navigation. public func load(_ request: URLRequest) { guard let url = request.url else { return } + guard supportsNavigation(request) else { + pendingRequest = nil + presentMessage(BrowserEngineSessionError.unsupportedChromiumNavigationRequest.localizedDescription) + return + } guard application != nil else { updateState { state in state.url = url @@ -522,6 +527,14 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { )) } + private func supportsNavigation(_ request: URLRequest) -> Bool { + let method = request.httpMethod ?? "GET" + guard method.caseInsensitiveCompare("GET") == .orderedSame else { return false } + guard request.allHTTPHeaderFields?.isEmpty != false else { return false } + guard request.httpBody == nil, request.httpBodyStream == nil else { return false } + return request.cachePolicy == .useProtocolCachePolicy + } + private func presentOperationFailure() { presentMessage(String( localized: "browser.chromium.error.operationFailed", diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1df5b948105..d1ecf6b1479 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -238257,6 +238257,131 @@ } } }, + "browser.chromium.error.unsupportedNavigationRequest": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This request requires WebKit because Chromium cannot preserve its method, headers, body, or cache policy." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Chromiumではメソッド、ヘッダー、本文、キャッシュポリシーを保持できないため、このリクエストにはWebKitが必要です。" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يتطلب هذا الطلب WebKit لأن Chromium لا يمكنه الحفاظ على الطريقة أو الرؤوس أو النص أو سياسة ذاكرة التخزين المؤقت." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Ovaj zahtjev zahtijeva WebKit jer Chromium ne može sačuvati njegovu metodu, zaglavlja, tijelo ili politiku predmemorije." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Denne anmodning kræver WebKit, fordi Chromium ikke kan bevare dens metode, headere, indhold eller cachepolitik." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Diese Anfrage erfordert WebKit, da Chromium ihre Methode, Header, ihren Inhalt oder ihre Cache-Richtlinie nicht beibehalten kann." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta solicitud requiere WebKit porque Chromium no puede conservar su método, encabezados, cuerpo o política de caché." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Cette requête nécessite WebKit, car Chromium ne peut pas conserver sa méthode, ses en-têtes, son corps ou sa stratégie de cache." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Questa richiesta richiede WebKit perché Chromium non può conservarne il metodo, le intestazioni, il corpo o i criteri della cache." + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "សំណើនេះតម្រូវឱ្យប្រើ WebKit ពីព្រោះ Chromium មិនអាចរក្សាទុកវិធីសាស្ត្រ បឋមកថា តួសំណើ ឬគោលការណ៍ឃ្លាំងសម្ងាត់របស់វាបានទេ។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Chromium은 메서드, 헤더, 본문 또는 캐시 정책을 유지할 수 없으므로 이 요청에는 WebKit이 필요합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Denne forespørselen krever WebKit fordi Chromium ikke kan bevare metoden, topptekstene, innholdet eller hurtigbufferpolicyen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "To żądanie wymaga WebKit, ponieważ Chromium nie może zachować jego metody, nagłówków, treści ani zasad pamięci podręcznej." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Esta solicitação requer o WebKit porque o Chromium não consegue preservar o método, os cabeçalhos, o corpo ou a política de cache." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Для этого запроса требуется WebKit, поскольку Chromium не может сохранить его метод, заголовки, тело или политику кэширования." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "คำขอนี้ต้องใช้ WebKit เนื่องจาก Chromium ไม่สามารถรักษาเมธอด ส่วนหัว เนื้อหา หรือนโยบายแคชไว้ได้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Chromium yöntemi, üst bilgileri, gövdeyi veya önbellek politikasını koruyamadığı için bu istek WebKit gerektirir." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Для цього запиту потрібен WebKit, оскільки Chromium не може зберегти його метод, заголовки, тіло або політику кешування." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此请求需要使用 WebKit,因为 Chromium 无法保留其方法、标头、正文或缓存策略。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此要求需要使用 WebKit,因為 Chromium 無法保留其方法、標頭、本文或快取政策。" + } + } + } + }, "browser.chromium.error.notInstalled": { "extractionState": "manual", "localizations": { From 5a1e9f09bd209a58ef12412c050f0c942c373653 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:54:12 -0700 Subject: [PATCH 09/39] Test Chromium composition and paste input --- .../ChromiumBrowserEngineSessionTests.swift | 27 +++++++++ ...ChromiumViewportDocumentLoadDelegate.swift | 35 ++++++++++++ .../ChromiumViewportDocumentTests.swift | 55 +++++++++++++++++++ 3 files changed, 117 insertions(+) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentLoadDelegate.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 1476ddb5344..1b9dcde1e18 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -6,6 +6,33 @@ import WebKit @Suite("Chromium browser engine session") @MainActor struct ChromiumBrowserEngineSessionTests { + @Test + func forwardsCompositionAndCommittedTextToCDPInputCommands() { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + defer { session.close() } + + session.handleViewportMessage([ + "type": "composition", + "text": "に", + "selectionStart": 1, + "selectionEnd": 1, + ]) + session.handleViewportMessage([ + "type": "text", + "text": "日本", + ]) + + #expect(session.viewportInputQueue.commands.map(\.method) == [ + "Input.imeSetComposition", + "Input.insertText", + ]) + } + @Test func rejectsNavigationRequestsWhoseSemanticsCannotBePreserved() { let url = URL(string: "https://example.com/submit")! diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentLoadDelegate.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentLoadDelegate.swift new file mode 100644 index 00000000000..a413d0313ab --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentLoadDelegate.swift @@ -0,0 +1,35 @@ +import WebKit + +@MainActor +final class ChromiumViewportDocumentLoadDelegate: NSObject, WKNavigationDelegate { + private var continuation: CheckedContinuation? + + func load(_ html: String, in webView: WKWebView) async throws { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + webView.loadHTMLString(html, baseURL: nil) + } + } + + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + finish(.success(())) + } + + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: any Error) { + finish(.failure(error)) + } + + func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: any Error + ) { + finish(.failure(error)) + } + + private func finish(_ result: Result) { + guard let continuation else { return } + self.continuation = nil + continuation.resume(with: result) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift new file mode 100644 index 00000000000..8f1225578fd --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -0,0 +1,55 @@ +import Testing +import WebKit +@testable import CmuxBrowser + +@Suite("Chromium viewport document") +@MainActor +struct ChromiumViewportDocumentTests { + @Test + func forwardsCompositionAndPasteAsCommittedText() async throws { + let webView = WKWebView() + let loadDelegate = ChromiumViewportDocumentLoadDelegate() + webView.navigationDelegate = loadDelegate + try await loadDelegate.load( + ChromiumViewportDocument().html( + loadingText: "Loading", + accessibilityLabel: "Viewport" + ), + in: webView + ) + + let result = try await webView.callAsyncJavaScript( + """ + window.__cmuxTestMessages = []; + post = (type, values = {}) => window.__cmuxTestMessages.push({ type, ...values }); + const target = document.getElementById('textInput') || document.getElementById('viewport'); + target.dispatchEvent(new CompositionEvent('compositionstart', { data: '', bubbles: true })); + target.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'に', bubbles: true })); + target.dispatchEvent(new CompositionEvent('compositionend', { data: '日本', bubbles: true })); + const paste = new Event('paste', { bubbles: true, cancelable: true }); + Object.defineProperty(paste, 'clipboardData', { + value: { getData: type => type === 'text/plain' ? 'pasted text' : '' } + }); + target.dispatchEvent(paste); + return window.__cmuxTestMessages; + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + let messages = try #require(result as? [[String: Any]]) + + #expect(messages.compactMap { $0["type"] as? String } == [ + "composition", + "composition", + "text", + "text", + ]) + #expect(messages.compactMap { $0["text"] as? String } == [ + "に", + "", + "日本", + "pasted text", + ]) + } +} From c82056e4147c81e7b34eb9a5e0ba24a314031323 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 03:57:32 -0700 Subject: [PATCH 10/39] Complete Chromium viewport input harness --- .../Engine/ChromiumViewportDocumentTests.swift | 8 +++++++- .../Engine/ChromiumViewportNoOpMessageHandler.swift | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportNoOpMessageHandler.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift index 8f1225578fd..c0e46f08a5c 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -7,7 +7,13 @@ import WebKit struct ChromiumViewportDocumentTests { @Test func forwardsCompositionAndPasteAsCommittedText() async throws { - let webView = WKWebView() + let configuration = WKWebViewConfiguration() + let messageHandler = ChromiumViewportNoOpMessageHandler() + configuration.userContentController.add( + messageHandler, + name: "cmuxChromiumViewport" + ) + let webView = WKWebView(frame: .zero, configuration: configuration) let loadDelegate = ChromiumViewportDocumentLoadDelegate() webView.navigationDelegate = loadDelegate try await loadDelegate.load( diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportNoOpMessageHandler.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportNoOpMessageHandler.swift new file mode 100644 index 00000000000..8a8dea1baef --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportNoOpMessageHandler.swift @@ -0,0 +1,9 @@ +import WebKit + +@MainActor +final class ChromiumViewportNoOpMessageHandler: NSObject, WKScriptMessageHandler { + func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) {} +} From b9b654a105e9112714981da7a5eca565958a71f9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:15:31 -0700 Subject: [PATCH 11/39] Support Chromium composition and paste input --- ...hromiumBrowserEngineSession+Viewport.swift | 36 ++++++++++ .../Chromium/ChromiumViewportDocument.swift | 67 +++++++++++++++++-- .../ChromiumBrowserEngineSessionTests.swift | 8 +++ .../ChromiumViewportDocumentTests.swift | 11 +++ 4 files changed, 116 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index d2bb0db66f7..25c5788411e 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -54,6 +54,22 @@ struct ChromiumViewportInputCommand: Equatable { ) } + static func text(parameters: [String: CDPJSONValue]) -> Self { + Self( + method: "Input.insertText", + parameters: parameters, + coalescingKind: nil + ) + } + + static func composition(parameters: [String: CDPJSONValue]) -> Self { + Self( + method: "Input.imeSetComposition", + parameters: parameters, + coalescingKind: nil + ) + } + func coalescing(with newer: Self) -> Self { guard coalescingKind == .mouseWheel, newer.coalescingKind == .mouseWheel else { return newer @@ -167,6 +183,10 @@ extension ChromiumBrowserEngineSession { dispatchMouse(payload) case "key": dispatchKey(payload) + case "text": + dispatchText(payload) + case "composition": + dispatchComposition(payload) default: break } @@ -233,6 +253,22 @@ extension ChromiumBrowserEngineSession { enqueueViewportInput(.key(parameters: parameters)) } + private func dispatchText(_ payload: [String: Any]) { + guard let text = payload["text"] as? String, !text.isEmpty else { return } + enqueueViewportInput(.text(parameters: ["text": .string(text)])) + } + + private func dispatchComposition(_ payload: [String: Any]) { + guard let text = payload["text"] as? String else { return } + let selectionStart = max(0, payload["selectionStart"] as? Int ?? text.utf16.count) + let selectionEnd = max(selectionStart, payload["selectionEnd"] as? Int ?? selectionStart) + enqueueViewportInput(.composition(parameters: [ + "text": .string(text), + "selectionStart": .number(Double(selectionStart)), + "selectionEnd": .number(Double(selectionEnd)), + ])) + } + private func enqueueViewportInput(_ command: ChromiumViewportInputCommand) { guard viewportInputQueue.enqueue(command) else { failViewportInput() diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift index 368f551c769..e7ebe9c889e 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift @@ -14,18 +14,22 @@ struct ChromiumViewportDocument { html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; background: #fff; } body { font: 13px -apple-system, system-ui, sans-serif; color: #666; } canvas { display: block; width: 100%; height: 100%; outline: none; cursor: default; } + #textInput { position: fixed; top: 0; left: 0; width: 1px; height: 1px; opacity: 0; pointer-events: none; } #status { position: fixed; inset: 0; display: grid; place-items: center; padding: 24px; text-align: center; } body.ready #status { display: none; }
- + + diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 1b9dcde1e18..bd7b79c2b4a 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -31,6 +31,14 @@ struct ChromiumBrowserEngineSessionTests { "Input.imeSetComposition", "Input.insertText", ]) + #expect(session.viewportInputQueue.commands[0].parameters == [ + "text": .string("に"), + "selectionStart": .number(1), + "selectionEnd": .number(1), + ]) + #expect(session.viewportInputQueue.commands[1].parameters == [ + "text": .string("日本"), + ]) } @Test diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift index c0e46f08a5c..61f40cce847 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -29,14 +29,23 @@ struct ChromiumViewportDocumentTests { window.__cmuxTestMessages = []; post = (type, values = {}) => window.__cmuxTestMessages.push({ type, ...values }); const target = document.getElementById('textInput') || document.getElementById('viewport'); + target.dispatchEvent(new InputEvent('beforeinput', { + inputType: 'insertText', data: 'é', bubbles: true, cancelable: true + })); target.dispatchEvent(new CompositionEvent('compositionstart', { data: '', bubbles: true })); target.dispatchEvent(new CompositionEvent('compositionupdate', { data: 'に', bubbles: true })); target.dispatchEvent(new CompositionEvent('compositionend', { data: '日本', bubbles: true })); + target.dispatchEvent(new KeyboardEvent('keydown', { + key: 'v', code: 'KeyV', metaKey: true, bubbles: true, cancelable: true + })); const paste = new Event('paste', { bubbles: true, cancelable: true }); Object.defineProperty(paste, 'clipboardData', { value: { getData: type => type === 'text/plain' ? 'pasted text' : '' } }); target.dispatchEvent(paste); + target.dispatchEvent(new KeyboardEvent('keyup', { + key: 'v', code: 'KeyV', metaKey: true, bubbles: true, cancelable: true + })); return window.__cmuxTestMessages; """, arguments: [:], @@ -46,12 +55,14 @@ struct ChromiumViewportDocumentTests { let messages = try #require(result as? [[String: Any]]) #expect(messages.compactMap { $0["type"] as? String } == [ + "text", "composition", "composition", "text", "text", ]) #expect(messages.compactMap { $0["text"] as? String } == [ + "é", "に", "", "日本", From 4ae8917fb0d87c8f7ca35c2ffa47a0c29b384b20 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:20:07 -0700 Subject: [PATCH 12/39] Test Chromium screencast and cleanup safeguards --- .../ChromiumBrowserEngineSession.swift | 38 +++++++++++++------ ...BrowserChromiumProfileDirectoryTests.swift | 3 ++ .../ChromiumBrowserEngineSessionTests.swift | 23 +++++++++++ 3 files changed, 52 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 058b1734f4a..59e41ebf038 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -246,11 +246,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } let isolatedWorld = try await connection.send( method: "Page.createIsolatedWorld", - parameters: [ - "frameId": .string(frameID), - "worldName": .string("cmux.browser.automation"), - "grantUniveralAccess": .bool(true), - ], + parameters: Self.isolatedWorldParameters(frameID: frameID), sessionID: sessionID ) guard let contextID = isolatedWorld.objectValue?["executionContextId"]?.intValue else { @@ -259,6 +255,14 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { return contextID } + static func isolatedWorldParameters(frameID: String) -> [String: CDPJSONValue] { + [ + "frameId": .string(frameID), + "worldName": .string("cmux.browser.automation"), + "grantUniveralAccess": .bool(true), + ] + } + /// Captures the current Chromium viewport through CDP. public func captureScreenshot() async throws -> Data { if connection == nil { @@ -365,13 +369,10 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { try await sendDeviceMetrics(connection: connection, sessionID: sessionID) _ = try await connection.send( method: "Page.startScreencast", - parameters: [ - "format": .string("jpeg"), - "quality": .number(75), - "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), - "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), - "everyNthFrame": .number(1), - ], + parameters: Self.screencastParameters( + viewportWidth: viewportWidth, + viewportHeight: viewportHeight + ), sessionID: sessionID ) try await installAllInitializationScripts(connection: connection, sessionID: sessionID) @@ -382,6 +383,19 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } } + static func screencastParameters( + viewportWidth: Int, + viewportHeight: Int + ) -> [String: CDPJSONValue] { + [ + "format": .string("jpeg"), + "quality": .number(75), + "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), + "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), + "everyNthFrame": .number(1), + ] + } + private func beginEvents(connection: CDPConnection, sessionID: String) { eventTask = Task { [weak self] in let events = await connection.events() diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift index 8a87f5ac335..9c3b7bbf396 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift @@ -20,6 +20,7 @@ import Testing let profileDirectory = firstSession .deletingLastPathComponent() .deletingLastPathComponent() + let surfaceDirectory = firstSession.deletingLastPathComponent() try FileManager.default.createDirectory(at: firstSession, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: secondSession, withIntermediateDirectories: true) defer { @@ -36,6 +37,8 @@ import Testing try builder.removeSessionDirectoryIfOwned(secondSession) #expect(!FileManager.default.fileExists(atPath: secondSession.path)) + #expect(!FileManager.default.fileExists(atPath: surfaceDirectory.path)) + #expect(!FileManager.default.fileExists(atPath: profileDirectory.path)) } @Test func refusesToRemoveDirectoryOutsideOwnedUUIDHierarchy() throws { diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index bd7b79c2b4a..ab7ebc7539f 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -6,6 +6,29 @@ import WebKit @Suite("Chromium browser engine session") @MainActor struct ChromiumBrowserEngineSessionTests { + @Test + func createsIsolatedWorldWithUniversalAccess() { + #expect(ChromiumBrowserEngineSession.isolatedWorldParameters(frameID: "main-frame") == [ + "frameId": .string("main-frame"), + "worldName": .string("cmux.browser.automation"), + "grantUniversalAccess": .bool(true), + ]) + } + + @Test + func boundsScreencastFrameCadence() { + #expect(ChromiumBrowserEngineSession.screencastParameters( + viewportWidth: 640, + viewportHeight: 480 + ) == [ + "format": .string("jpeg"), + "quality": .number(75), + "maxWidth": .number(1_280), + "maxHeight": .number(960), + "everyNthFrame": .number(2), + ]) + } + @Test func forwardsCompositionAndCommittedTextToCDPInputCommands() { let session = ChromiumBrowserEngineSession( From 2ebefdbd4b03e9f3f1adc329fcfe5e331c633020 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:23:45 -0700 Subject: [PATCH 13/39] Bound Chromium viewport resource usage --- .../Engine/BrowserEngineSession.swift | 7 ++ .../BrowserChromiumProfileDirectory.swift | 13 +++ .../ChromiumBrowserEngineSession.swift | 84 ++++++++++++++++--- .../Engine/WebKitBrowserEngineSession.swift | 3 + .../ChromiumBrowserEngineSessionTests.swift | 16 ++++ .../ChromiumViewportDocumentTests.swift | 8 +- Sources/Panels/BrowserPanel.swift | 8 ++ 7 files changed, 127 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index 9329d863e19..eb733d266f0 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -17,6 +17,13 @@ public protocol BrowserEngineSession: AnyObject { /// State snapshots emitted when navigation or engine lifecycle changes. var stateUpdates: AsyncStream { get } + /// Updates whether this engine's viewport is currently visible in cmux. + /// + /// Engines may suspend offscreen presentation while preserving page state and navigation. + /// + /// - Parameter visible: Whether the pane is visible and should actively present frames. + func setViewportVisible(_ visible: Bool) + /// Loads a request in the current browser surface. /// /// - Parameter request: The top-level request to load. diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift index 0cb646b0bdf..bde5e119f52 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift @@ -50,5 +50,18 @@ public struct BrowserChromiumProfileDirectory { guard fileManager.fileExists(atPath: candidate.path) else { return } try fileManager.removeItem(at: candidate) + removeDirectoryIfEmpty(candidate.deletingLastPathComponent()) + removeDirectoryIfEmpty(candidate.deletingLastPathComponent().deletingLastPathComponent()) + } + + private func removeDirectoryIfEmpty(_ directory: URL) { + guard (try? fileManager.contentsOfDirectory( + at: directory, + includingPropertiesForKeys: nil + ).isEmpty) == true else { + return + } + // A concurrent session creation makes removal fail safely once the directory is nonempty. + try? fileManager.removeItem(at: directory) } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 59e41ebf038..c50b1e18de5 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -32,6 +32,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private var pendingRequest: URLRequest? private var initializationScripts: [String] private var initializationScriptInstallations: [Int: Task] = [:] + private var isViewportVisible = false + private var isScreencastActive = false + private var screencastUpdateTask: Task? var viewportInputQueue = ChromiumViewportInputQueue() var viewportInputTask: Task? var viewportInputFailed = false @@ -136,6 +139,12 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { updateState { $0.isLoading = false } } + /// Starts or suspends Chromium frame delivery for the viewport's visibility. + public func setViewportVisible(_ visible: Bool) { + isViewportVisible = visible + startScreencastUpdateIfNeeded() + } + /// Evaluates JavaScript through `Runtime.evaluate` and returns its by-value result. public func evaluateJavaScript( _ script: String, @@ -259,7 +268,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { [ "frameId": .string(frameID), "worldName": .string("cmux.browser.automation"), - "grantUniveralAccess": .bool(true), + "grantUniversalAccess": .bool(true), ] } @@ -291,6 +300,10 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { eventTask?.cancel() initializationScriptInstallations.values.forEach { $0.cancel() } initializationScriptInstallations.removeAll() + screencastUpdateTask?.cancel() + screencastUpdateTask = nil + isViewportVisible = false + isScreencastActive = false viewportInputTask?.cancel() viewportInputTask = nil viewportInputQueue.removeAll() @@ -367,14 +380,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { _ = try await connection.send(method: "Page.enable", sessionID: sessionID) _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) try await sendDeviceMetrics(connection: connection, sessionID: sessionID) - _ = try await connection.send( - method: "Page.startScreencast", - parameters: Self.screencastParameters( - viewportWidth: viewportWidth, - viewportHeight: viewportHeight - ), - sessionID: sessionID - ) + startScreencastUpdateIfNeeded() try await installAllInitializationScripts(connection: connection, sessionID: sessionID) if let request = pendingRequest { load(request) @@ -392,10 +398,68 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { "quality": .number(75), "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), - "everyNthFrame": .number(1), + "everyNthFrame": .number(2), ] } + static func screencastMethod( + isViewportVisible: Bool, + isScreencastActive: Bool + ) -> String? { + guard isViewportVisible != isScreencastActive else { return nil } + return isViewportVisible ? "Page.startScreencast" : "Page.stopScreencast" + } + + private func startScreencastUpdateIfNeeded() { + guard !isClosed, + connection != nil, + cdpSessionID != nil, + screencastUpdateTask == nil, + Self.screencastMethod( + isViewportVisible: isViewportVisible, + isScreencastActive: isScreencastActive + ) != nil else { + return + } + screencastUpdateTask = Task { [weak self] in + await self?.drainScreencastUpdates() + } + } + + private func drainScreencastUpdates() async { + defer { screencastUpdateTask = nil } + while !Task.isCancelled, + let connection, + let cdpSessionID { + let targetVisible = isViewportVisible + guard let method = Self.screencastMethod( + isViewportVisible: targetVisible, + isScreencastActive: isScreencastActive + ) else { + return + } + let parameters = targetVisible + ? Self.screencastParameters( + viewportWidth: viewportWidth, + viewportHeight: viewportHeight + ) + : [:] + do { + _ = try await connection.send( + method: method, + parameters: parameters, + sessionID: cdpSessionID + ) + isScreencastActive = targetVisible + } catch is CancellationError { + return + } catch { + presentOperationFailure() + return + } + } + } + private func beginEvents(connection: CDPConnection, sessionID: String) { eventTask = Task { [weak self] in let events = await connection.events() diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index 06f17fe41be..b6ac1a9c3d5 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -64,6 +64,9 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { /// Stops the active WebKit load. public func stopLoading() { webView.stopLoading() } + /// Keeps WebKit active because its native view owns offscreen presentation throttling. + public func setViewportVisible(_: Bool) {} + /// Evaluates JavaScript in a WebKit content world. public func evaluateJavaScript( _ script: String, diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index ab7ebc7539f..275320168eb 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -29,6 +29,22 @@ struct ChromiumBrowserEngineSessionTests { ]) } + @Test + func startsAndStopsScreencastWithViewportVisibility() { + #expect(ChromiumBrowserEngineSession.screencastMethod( + isViewportVisible: false, + isScreencastActive: false + ) == nil) + #expect(ChromiumBrowserEngineSession.screencastMethod( + isViewportVisible: true, + isScreencastActive: false + ) == "Page.startScreencast") + #expect(ChromiumBrowserEngineSession.screencastMethod( + isViewportVisible: false, + isScreencastActive: true + ) == "Page.stopScreencast") + } + @Test func forwardsCompositionAndCommittedTextToCDPInputCommands() { let session = ChromiumBrowserEngineSession( diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift index 61f40cce847..d57917a86c8 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -53,15 +53,19 @@ struct ChromiumViewportDocumentTests { contentWorld: .page ) let messages = try #require(result as? [[String: Any]]) + let inputMessages = messages.filter { + guard let type = $0["type"] as? String else { return false } + return type == "text" || type == "composition" + } - #expect(messages.compactMap { $0["type"] as? String } == [ + #expect(inputMessages.compactMap { $0["type"] as? String } == [ "text", "composition", "composition", "text", "text", ]) - #expect(messages.compactMap { $0["text"] as? String } == [ + #expect(inputMessages.compactMap { $0["text"] as? String } == [ "é", "に", "", diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 7a0245f3b22..6a85c4d0750 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2801,6 +2801,7 @@ final class BrowserPanel: Panel, ObservableObject { @Published var shouldRenderWebView: Bool = false { didSet { if oldValue != shouldRenderWebView { + syncEngineViewportVisibility() refreshWebViewLifecycleState() applyConfiguredWebViewBackground() } @@ -3181,6 +3182,7 @@ final class BrowserPanel: Panel, ObservableObject { now: Date = Date(), recordIfUnchanged: Bool = false ) { + engineSessionStorage?.setViewportVisible(visible && shouldRenderWebView) let changed = isWebViewVisibleInUI != visible let isFirstVisibilityRecord = webViewLastVisibilityChangeReason == nil let shouldRecordVisibleHeartbeat = visible && recordIfUnchanged @@ -3267,6 +3269,7 @@ final class BrowserPanel: Panel, ObservableObject { webViewLastVisibilityChangeReason = nil isWebViewVisibleInUI = false } + syncEngineViewportVisibility() hiddenWebViewDiscardManager.resetMetadata() isClosingWebViewLifecycle = false } @@ -4306,6 +4309,10 @@ final class BrowserPanel: Panel, ObservableObject { } } + private func syncEngineViewportVisibility() { + engineSessionStorage?.setViewportVisible(isWebViewVisibleInUI && shouldRenderWebView) + } + private func replaceEngineSession(for webView: WKWebView) { engineStateTask?.cancel() engineStateTask = nil @@ -4318,6 +4325,7 @@ final class BrowserPanel: Panel, ObservableObject { surfaceID: id, initializationScripts: engineInitializationScripts ) + syncEngineViewportVisibility() if engineKind == .chromium, let webView = webView as? CmuxWebView { bindChromiumViewport(webView) observeEngineState() From 470db8f2c15b253200e0493142571d8087f698b9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:42:59 -0700 Subject: [PATCH 14/39] Test Chromium topology and drag input --- .../ChromiumViewportDocumentTests.swift | 64 ++++++++++++++----- Sources/TerminalController.swift | 14 +++- cmuxTests/BrowserWebContentProcessTests.swift | 14 ++++ 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift index d57917a86c8..0f0cf2c56ab 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -7,22 +7,7 @@ import WebKit struct ChromiumViewportDocumentTests { @Test func forwardsCompositionAndPasteAsCommittedText() async throws { - let configuration = WKWebViewConfiguration() - let messageHandler = ChromiumViewportNoOpMessageHandler() - configuration.userContentController.add( - messageHandler, - name: "cmuxChromiumViewport" - ) - let webView = WKWebView(frame: .zero, configuration: configuration) - let loadDelegate = ChromiumViewportDocumentLoadDelegate() - webView.navigationDelegate = loadDelegate - try await loadDelegate.load( - ChromiumViewportDocument().html( - loadingText: "Loading", - accessibilityLabel: "Viewport" - ), - in: webView - ) + let webView = try await makeLoadedWebView() let result = try await webView.callAsyncJavaScript( """ @@ -73,4 +58,51 @@ struct ChromiumViewportDocumentTests { "pasted text", ]) } + + @Test + func forwardsTheHeldButtonDuringMouseDrag() async throws { + let webView = try await makeLoadedWebView() + let result = try await webView.callAsyncJavaScript( + """ + window.__cmuxTestMessages = []; + post = (type, values = {}) => window.__cmuxTestMessages.push({ type, ...values }); + document.getElementById('viewport').dispatchEvent(new MouseEvent('mousemove', { + clientX: 10, + clientY: 20, + button: 0, + buttons: 2, + bubbles: true + })); + return window.__cmuxTestMessages; + """, + arguments: [:], + in: nil, + contentWorld: .page + ) + let messages = try #require(result as? [[String: Any]]) + let drag = try #require(messages.first { $0["type"] as? String == "mouse" }) + + #expect(drag["event"] as? String == "mouseMoved") + #expect((drag["button"] as? NSNumber)?.intValue == 2) + } + + private func makeLoadedWebView() async throws -> WKWebView { + let configuration = WKWebViewConfiguration() + let messageHandler = ChromiumViewportNoOpMessageHandler() + configuration.userContentController.add( + messageHandler, + name: "cmuxChromiumViewport" + ) + let webView = WKWebView(frame: .zero, configuration: configuration) + let loadDelegate = ChromiumViewportDocumentLoadDelegate() + webView.navigationDelegate = loadDelegate + try await loadDelegate.load( + ChromiumViewportDocument().html( + loadingText: "Loading", + accessibilityLabel: "Viewport" + ), + in: webView + ) + return webView + } } diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 736fa719366..599f210d88f 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3130,7 +3130,11 @@ class TerminalController { return item } - let webContentPID = CmuxWebContentProcessIdentifier.pid(for: browserPanel.webView) + let webContentPID = Self.resolvedBrowserContentProcessIdentifier( + engineKind: browserPanel.engineKind, + chromiumProcessIdentifier: nil, + webKitProcessIdentifier: CmuxWebContentProcessIdentifier.pid(for: browserPanel.webView) + ) let url = browserPanel.currentURL?.absoluteString ?? "" item["url"] = url item["browser_web_content_pid"] = v2OrNull(webContentPID) @@ -3152,6 +3156,14 @@ class TerminalController { return item } + static func resolvedBrowserContentProcessIdentifier( + engineKind: BrowserEngineKind, + chromiumProcessIdentifier: Int32?, + webKitProcessIdentifier: Int? + ) -> Int? { + webKitProcessIdentifier + } + private func v2TopTagNodes(for workspace: Workspace) -> [[String: Any]] { var tags: [[String: Any]] = [] var seenKeys = Set() diff --git a/cmuxTests/BrowserWebContentProcessTests.swift b/cmuxTests/BrowserWebContentProcessTests.swift index 97447f016bc..9843867a5c1 100644 --- a/cmuxTests/BrowserWebContentProcessTests.swift +++ b/cmuxTests/BrowserWebContentProcessTests.swift @@ -13,6 +13,20 @@ import WebKit struct BrowserWebContentProcessTests { private let recoveryURL = URL(string: "data:text/html,cmux-recovery")! + @Test + func topologyUsesTheContentProcessForEachBrowserEngine() { + #expect(TerminalController.resolvedBrowserContentProcessIdentifier( + engineKind: .webKit, + chromiumProcessIdentifier: 41, + webKitProcessIdentifier: 42 + ) == 42) + #expect(TerminalController.resolvedBrowserContentProcessIdentifier( + engineKind: .chromium, + chromiumProcessIdentifier: 41, + webKitProcessIdentifier: 42 + ) == 41) + } + @Test func browserPanelsShareDefaultWebsiteDataStore() { let first = BrowserPanel(workspaceId: UUID()) From 5ddc90509262d07992bd945d48c7796d7bbaed1d Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:52:25 -0700 Subject: [PATCH 15/39] Report Chromium topology and drag input correctly --- .../CmuxBrowser/Engine/BrowserEngineSession.swift | 3 +++ .../Chromium/ChromiumBrowserEngineSession.swift | 8 ++++++++ .../Chromium/ChromiumProcessController.swift | 6 ++++++ .../Chromium/ChromiumViewportDocument.swift | 10 +++++++++- .../Engine/WebKitBrowserEngineSession.swift | 3 +++ .../Engine/ChromiumViewportDocumentTests.swift | 15 +++++++++++---- Sources/TerminalController.swift | 9 +++++++-- 7 files changed, 47 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index eb733d266f0..88c372c9e0b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -8,6 +8,9 @@ public protocol BrowserEngineSession: AnyObject { /// The engine family implementing this session. var kind: BrowserEngineKind { get } + /// The operating-system process identifier for an out-of-process browser engine. + var contentProcessIdentifier: Int32? { get } + /// The native view hosted by the browser pane portal. var contentView: NSView { get } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index c50b1e18de5..fd8958aeb9d 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -9,6 +9,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { /// The engine family implementing this session. public let kind = BrowserEngineKind.chromium + /// The launched Chromium process used by topology and diagnostics. + public private(set) var contentProcessIdentifier: Int32? + /// The local viewport view hosted by the browser pane portal. public var contentView: NSView { viewportWebView } @@ -308,6 +311,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { viewportInputTask = nil viewportInputQueue.removeAll() viewportInputFailed = true + contentProcessIdentifier = nil deviceMetricsTask?.cancel() deviceMetricsTask = nil deviceMetricsPending = false @@ -343,10 +347,13 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { application: application, userDataDirectory: userDataDirectory ) + contentProcessIdentifier = await processController.processIdentifier() try await connect(to: endpoint) } catch is CancellationError { return } catch { + contentProcessIdentifier = nil + await processController.close() presentLaunchFailure() } } @@ -629,6 +636,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { let connection = connection self.connection = nil cdpSessionID = nil + contentProcessIdentifier = nil Task { [processController] in await processController.close() await connection?.close() diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift index bf545f20ab6..38c749d9e4b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -13,6 +13,12 @@ actor ChromiumProcessController { self.launchTimeout = launchTimeout } + /// Returns the identifier of the Chromium process while it is running. + func processIdentifier() -> Int32? { + guard let process, process.isRunning else { return nil } + return process.processIdentifier + } + func start(application: BrowserApplication, userDataDirectory: URL) async throws -> URL { guard process == nil else { throw BrowserEngineSessionError.chromiumLaunch("Chromium is already running for this session.") diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift index e7ebe9c889e..7520087b877 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportDocument.swift @@ -70,6 +70,14 @@ struct ChromiumViewportDocument { return (event.altKey ? 1 : 0) | (event.ctrlKey ? 2 : 0) | (event.metaKey ? 4 : 0) | (event.shiftKey ? 8 : 0); } + function heldButton(buttons) { + switch (buttons) { + case 1: return 0; + case 2: return 2; + case 4: return 1; + default: return -1; + } + } function focusTextInput() { textInput.focus({ preventScroll: true }); textInput.setSelectionRange(textInput.value.length, textInput.value.length); @@ -87,7 +95,7 @@ struct ChromiumViewportDocument { }); canvas.addEventListener('mousemove', event => { const p = point(event); - post('mouse', { event: 'mouseMoved', x: p.x, y: p.y, button: event.buttons ? event.button : -1, modifiers: modifiers(event), clickCount: 0 }); + post('mouse', { event: 'mouseMoved', x: p.x, y: p.y, button: heldButton(event.buttons), modifiers: modifiers(event), clickCount: 0 }); }); canvas.addEventListener('wheel', event => { const p = point(event); diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index b6ac1a9c3d5..dc811fe7ba8 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -13,6 +13,9 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { /// The engine family implementing this session. public let kind = BrowserEngineKind.webKit + /// WebKit owns its helper processes outside this engine session. + public var contentProcessIdentifier: Int32? { nil } + /// The wrapped WebKit view. public let webView: WKWebView diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift index 0f0cf2c56ab..b0f51285144 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportDocumentTests.swift @@ -60,7 +60,7 @@ struct ChromiumViewportDocumentTests { } @Test - func forwardsTheHeldButtonDuringMouseDrag() async throws { + func forwardsTheHeldButtonsDuringMouseDrag() async throws { let webView = try await makeLoadedWebView() let result = try await webView.callAsyncJavaScript( """ @@ -73,6 +73,13 @@ struct ChromiumViewportDocumentTests { buttons: 2, bubbles: true })); + document.getElementById('viewport').dispatchEvent(new MouseEvent('mousemove', { + clientX: 30, + clientY: 40, + button: 0, + buttons: 4, + bubbles: true + })); return window.__cmuxTestMessages; """, arguments: [:], @@ -80,10 +87,10 @@ struct ChromiumViewportDocumentTests { contentWorld: .page ) let messages = try #require(result as? [[String: Any]]) - let drag = try #require(messages.first { $0["type"] as? String == "mouse" }) + let drags = messages.filter { $0["type"] as? String == "mouse" } - #expect(drag["event"] as? String == "mouseMoved") - #expect((drag["button"] as? NSNumber)?.intValue == 2) + #expect(drags.allSatisfy { $0["event"] as? String == "mouseMoved" }) + #expect(drags.compactMap { ($0["button"] as? NSNumber)?.intValue } == [2, 1]) } private func makeLoadedWebView() async throws -> WKWebView { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 599f210d88f..12a7d9f2d36 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -3132,7 +3132,7 @@ class TerminalController { let webContentPID = Self.resolvedBrowserContentProcessIdentifier( engineKind: browserPanel.engineKind, - chromiumProcessIdentifier: nil, + chromiumProcessIdentifier: browserPanel.engineSession.contentProcessIdentifier, webKitProcessIdentifier: CmuxWebContentProcessIdentifier.pid(for: browserPanel.webView) ) let url = browserPanel.currentURL?.absoluteString ?? "" @@ -3161,7 +3161,12 @@ class TerminalController { chromiumProcessIdentifier: Int32?, webKitProcessIdentifier: Int? ) -> Int? { - webKitProcessIdentifier + switch engineKind { + case .webKit: + webKitProcessIdentifier + case .chromium: + chromiumProcessIdentifier.map(Int.init) + } } private func v2TopTagNodes(for workspace: Workspace) -> [[String: Any]] { From e2bd4cb2be62f801b25c349228f1bd9fa6ff1d17 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 04:53:20 -0700 Subject: [PATCH 16/39] Test Chromium input backpressure isolation --- .../ChromiumBrowserEngineSessionTests.swift | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 275320168eb..829b01a1cde 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -80,6 +80,35 @@ struct ChromiumBrowserEngineSessionTests { ]) } + @Test + func localInputBackpressureDoesNotEndTheSession() { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + defer { session.close() } + + for index in 0.. Date: Wed, 15 Jul 2026 04:53:59 -0700 Subject: [PATCH 17/39] Keep Chromium alive under input backpressure --- .../Chromium/ChromiumBrowserEngineSession+Viewport.swift | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index 25c5788411e..c98a98b0de0 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -270,10 +270,7 @@ extension ChromiumBrowserEngineSession { } private func enqueueViewportInput(_ command: ChromiumViewportInputCommand) { - guard viewportInputQueue.enqueue(command) else { - failViewportInput() - return - } + guard viewportInputQueue.enqueue(command) else { return } startViewportInputDrainingIfNeeded() } From 36dabc298f38eb0d3dcedd58c04733fee73e73ca Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:08:56 -0700 Subject: [PATCH 18/39] Test Chromium zoom and live title updates --- .../ChromiumBrowserEngineSession.swift | 2 +- .../ChromiumBrowserEngineSessionTests.swift | 28 +++++++++++++++++++ cmuxTests/BrowserWebContentProcessTests.swift | 17 +++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index fd8958aeb9d..534fcc5f3f6 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -479,7 +479,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } } - private func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { + func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { switch event.method { case "Page.screencastFrame": guard let data = event.parameters["data"]?.stringValue, diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 829b01a1cde..31f5c29944b 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -109,6 +109,34 @@ struct ChromiumBrowserEngineSessionTests { #expect(session.viewportInputFailed == false) } + @Test + func runtimeTitleBindingUpdatesTheLiveEngineTitle() async { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let connection = CDPConnection(url: URL(string: "ws://localhost.invalid")!) + defer { session.close() } + + await session.handle( + CDPEvent( + method: "Runtime.bindingCalled", + parameters: [ + "name": .string("__cmuxChromiumTitleChanged"), + "payload": .string("Updated by the SPA"), + ], + sessionID: "test-session" + ), + connection: connection, + sessionID: "test-session" + ) + + #expect(session.state.title == "Updated by the SPA") + await connection.close() + } + @Test func rejectsNavigationRequestsWhoseSemanticsCannotBePreserved() { let url = URL(string: "https://example.com/submit")! diff --git a/cmuxTests/BrowserWebContentProcessTests.swift b/cmuxTests/BrowserWebContentProcessTests.swift index 9843867a5c1..23ff82f8b0f 100644 --- a/cmuxTests/BrowserWebContentProcessTests.swift +++ b/cmuxTests/BrowserWebContentProcessTests.swift @@ -1,6 +1,7 @@ import Foundation import Testing import WebKit +import CmuxBrowser #if canImport(cmux_DEV) @testable import cmux_DEV @@ -27,6 +28,22 @@ struct BrowserWebContentProcessTests { ) == 41) } + @Test + func chromiumZoomChangesThePageEngineWithoutScalingItsCanvasTransport() { + let panel = BrowserPanel( + workspaceId: UUID(), + renderInitialNavigation: false, + engineSelection: BrowserEngineSelection(kind: .chromium) + ) + defer { panel.close() } + let canvasZoom = panel.webView.pageZoom + + #expect(panel.zoomIn()) + + #expect(panel.currentPageZoomFactor() == 1.1) + #expect(panel.webView.pageZoom == canvasZoom) + } + @Test func browserPanelsShareDefaultWebsiteDataStore() { let first = BrowserPanel(workspaceId: UUID()) From 2511ed6d2da906388ee779d7f26f0755034b79ee Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:23:13 -0700 Subject: [PATCH 19/39] Test Chromium URL restoration on engine downgrade --- cmuxTests/BrowserWebContentProcessTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/cmuxTests/BrowserWebContentProcessTests.swift b/cmuxTests/BrowserWebContentProcessTests.swift index 23ff82f8b0f..5369641bef0 100644 --- a/cmuxTests/BrowserWebContentProcessTests.swift +++ b/cmuxTests/BrowserWebContentProcessTests.swift @@ -44,6 +44,31 @@ struct BrowserWebContentProcessTests { #expect(panel.webView.pageZoom == canvasZoom) } + @Test + func remoteWorkspaceDowngradeRestoresTheChromiumPageURL() { + let panel = BrowserPanel( + workspaceId: UUID(), + renderInitialNavigation: false, + engineSelection: BrowserEngineSelection(kind: .chromium) + ) + defer { panel.close() } + let pageURL = URL(string: "https://example.com/chromium-page")! + + panel.navigate(to: pageURL) + #expect(panel.engineSession.state.url == pageURL) + + panel.reattachToWorkspace( + UUID(), + isRemoteWorkspace: true, + remoteWebsiteDataStoreIdentifier: UUID(), + proxyEndpoint: nil, + remoteStatus: nil + ) + + #expect(panel.engineKind == .webKit) + #expect(panel.currentURL == pageURL) + } + @Test func browserPanelsShareDefaultWebsiteDataStore() { let first = BrowserPanel(workspaceId: UUID()) From 016aa3f33e5788e5d982d936b93f2734da984523 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:32:48 -0700 Subject: [PATCH 20/39] Fix Chromium live title and zoom state --- .../Engine/BrowserEngineSession.swift | 8 ++ .../ChromiumBrowserEngineSession.swift | 97 +++++++++++++++++++ .../ChromiumDocumentTitleObservation.swift | 76 +++++++++++++++ .../Engine/WebKitBrowserEngineSession.swift | 8 ++ Sources/Panels/BrowserPanel.swift | 24 ++--- 5 files changed, 201 insertions(+), 12 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumDocumentTitleObservation.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index 88c372c9e0b..4c6bfd5630e 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -14,6 +14,9 @@ public protocol BrowserEngineSession: AnyObject { /// The native view hosted by the browser pane portal. var contentView: NSView { get } + /// The page zoom factor applied by the browser engine. + var pageZoomFactor: CGFloat { get } + /// The latest browser state snapshot. var state: BrowserEngineState { get } @@ -47,6 +50,11 @@ public protocol BrowserEngineSession: AnyObject { /// Stops the current navigation. func stopLoading() + /// Applies a page zoom factor through the browser engine. + /// + /// - Parameter pageZoomFactor: A positive scale where `1.0` is the default page size. + func setPageZoomFactor(_ pageZoomFactor: CGFloat) + /// Evaluates JavaScript in an engine-defined content world. /// /// - Parameters: diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 534fcc5f3f6..4060f792306 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -15,6 +15,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { /// The local viewport view hosted by the browser pane portal. public var contentView: NSView { viewportWebView } + /// The page zoom factor requested from Chromium. + public private(set) var pageZoomFactor: CGFloat = 1.0 + /// The latest Chromium state snapshot. public private(set) var state = BrowserEngineState() @@ -28,6 +31,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private let processController = ChromiumProcessController() private let viewportHandler = ChromiumViewportMessageHandler() private let cookieCodec = ChromiumBrowserCookieCodec() + private let documentTitleObservation = ChromiumDocumentTitleObservation() var connection: CDPConnection? var cdpSessionID: String? private var startupTask: Task? @@ -35,6 +39,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private var pendingRequest: URLRequest? private var initializationScripts: [String] private var initializationScriptInstallations: [Int: Task] = [:] + private var appliedPageZoomFactor: CGFloat? + private var isPageZoomReady = false + private var pageZoomUpdateTask: Task? private var isViewportVisible = false private var isScreencastActive = false private var screencastUpdateTask: Task? @@ -142,6 +149,13 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { updateState { $0.isLoading = false } } + /// Applies Chromium's native page scale without changing the canvas transport. + public func setPageZoomFactor(_ pageZoomFactor: CGFloat) { + guard pageZoomFactor.isFinite, pageZoomFactor > 0 else { return } + self.pageZoomFactor = pageZoomFactor + startPageZoomUpdateIfNeeded() + } + /// Starts or suspends Chromium frame delivery for the viewport's visibility. public func setViewportVisible(_ visible: Bool) { isViewportVisible = visible @@ -303,6 +317,10 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { eventTask?.cancel() initializationScriptInstallations.values.forEach { $0.cancel() } initializationScriptInstallations.removeAll() + pageZoomUpdateTask?.cancel() + pageZoomUpdateTask = nil + appliedPageZoomFactor = nil + isPageZoomReady = false screencastUpdateTask?.cancel() screencastUpdateTask = nil isViewportVisible = false @@ -386,6 +404,16 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { beginEvents(connection: connection, sessionID: sessionID) _ = try await connection.send(method: "Page.enable", sessionID: sessionID) _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) + try await installDocumentTitleObservation(connection: connection, sessionID: sessionID) + let initialPageZoomFactor = pageZoomFactor + try await sendPageZoomFactor( + initialPageZoomFactor, + connection: connection, + sessionID: sessionID + ) + appliedPageZoomFactor = initialPageZoomFactor + isPageZoomReady = true + startPageZoomUpdateIfNeeded() try await sendDeviceMetrics(connection: connection, sessionID: sessionID) startScreencastUpdateIfNeeded() try await installAllInitializationScripts(connection: connection, sessionID: sessionID) @@ -506,11 +534,80 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { let rawURL = frame["url"]?.stringValue { updateState { $0.url = URL(string: rawURL) } } + case "Runtime.bindingCalled": + guard let title = documentTitleObservation.title(from: event) else { return } + updateState { $0.title = title } default: break } } + private func installDocumentTitleObservation( + connection: CDPConnection, + sessionID: String + ) async throws { + _ = try await connection.send( + method: "Runtime.addBinding", + parameters: documentTitleObservation.bindingParameters, + sessionID: sessionID + ) + _ = try await connection.send( + method: "Page.addScriptToEvaluateOnNewDocument", + parameters: documentTitleObservation.scriptParameters, + sessionID: sessionID + ) + } + + private func startPageZoomUpdateIfNeeded() { + guard isPageZoomReady, + pageZoomUpdateTask == nil, + appliedPageZoomFactor != pageZoomFactor, + let connection, + let cdpSessionID else { return } + pageZoomUpdateTask = Task { [weak self] in + await self?.drainPageZoomUpdates(connection: connection, sessionID: cdpSessionID) + } + } + + private func drainPageZoomUpdates( + connection: CDPConnection, + sessionID: String + ) async { + defer { pageZoomUpdateTask = nil } + while !Task.isCancelled, + self.connection === connection, + cdpSessionID == sessionID, + appliedPageZoomFactor != pageZoomFactor { + let nextPageZoomFactor = pageZoomFactor + do { + try await sendPageZoomFactor( + nextPageZoomFactor, + connection: connection, + sessionID: sessionID + ) + } catch is CancellationError { + return + } catch { + presentOperationFailure() + return + } + guard self.connection === connection, cdpSessionID == sessionID else { return } + appliedPageZoomFactor = nextPageZoomFactor + } + } + + private func sendPageZoomFactor( + _ pageZoomFactor: CGFloat, + connection: CDPConnection, + sessionID: String + ) async throws { + _ = try await connection.send( + method: "Emulation.setPageScaleFactor", + parameters: ["pageScaleFactor": .number(Double(pageZoomFactor))], + sessionID: sessionID + ) + } + private func refreshDocumentState(connection: CDPConnection, sessionID: String) async { if case .string(let title) = try? await evaluateJavaScript("document.title") { updateState { $0.title = title } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumDocumentTitleObservation.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumDocumentTitleObservation.swift new file mode 100644 index 00000000000..b5c912ddd67 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumDocumentTitleObservation.swift @@ -0,0 +1,76 @@ +/// Defines Chromium's isolated document-title observation bridge. +struct ChromiumDocumentTitleObservation { + private let bindingName = "__cmuxChromiumTitleChanged" + private let worldName = "cmux.browser.title-observation" + + /// Parameters that expose the native binding only in cmux's isolated world. + var bindingParameters: [String: CDPJSONValue] { + [ + "name": .string(bindingName), + "executionContextName": .string(worldName), + ] + } + + /// Parameters that install the title observer before page scripts run. + var scriptParameters: [String: CDPJSONValue] { + [ + "source": .string(scriptSource), + "worldName": .string(worldName), + ] + } + + /// Decodes a title delivered through the isolated Runtime binding. + func title(from event: CDPEvent) -> String? { + guard event.method == "Runtime.bindingCalled", + event.parameters["name"]?.stringValue == bindingName else { return nil } + return event.parameters["payload"]?.stringValue + } + + private var scriptSource: String { + """ + (() => { + if (window !== window.top || globalThis.__cmuxChromiumTitleObserverInstalled === true) { + return; + } + globalThis.__cmuxChromiumTitleObserverInstalled = true; + + let lastTitle; + let observedTitleElement = null; + const reportTitle = () => { + const title = document.title || ''; + if (title === lastTitle) return; + lastTitle = title; + globalThis.__cmuxChromiumTitleChanged(title); + }; + const titleObserver = new MutationObserver(reportTitle); + const observeTitleElement = () => { + const titleElement = document.querySelector('head > title'); + if (titleElement !== observedTitleElement) { + titleObserver.disconnect(); + observedTitleElement = titleElement; + if (titleElement) { + titleObserver.observe(titleElement, { + childList: true, + characterData: true, + subtree: true + }); + } + } + reportTitle(); + }; + const headObserver = new MutationObserver(observeTitleElement); + const attach = () => { + if (!document.head) return false; + headObserver.observe(document.head, { childList: true }); + observeTitleElement(); + return true; + }; + + if (!attach()) { + document.addEventListener('readystatechange', attach, { once: true }); + document.addEventListener('DOMContentLoaded', attach, { once: true }); + } + })() + """ + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index dc811fe7ba8..4facb27abbc 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -22,6 +22,9 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { /// The native view hosted by the browser pane portal. public var contentView: NSView { webView } + /// The page zoom factor applied by WebKit. + public var pageZoomFactor: CGFloat { webView.pageZoom } + /// The latest WebKit state snapshot. public private(set) var state: BrowserEngineState @@ -67,6 +70,11 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { /// Stops the active WebKit load. public func stopLoading() { webView.stopLoading() } + /// Applies WebKit's native page zoom factor. + public func setPageZoomFactor(_ pageZoomFactor: CGFloat) { + webView.pageZoom = pageZoomFactor + } + /// Keeps WebKit active because its native view owns offscreen presentation throttling. public func setViewportVisible(_: Bool) {} diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 6a85c4d0750..e9c99428ca1 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3313,7 +3313,7 @@ final class BrowserPanel: Panel, ObservableObject { let restoreURL = restorableDisplayURLForCurrentErrorPage(liveURL: oldWebView.url) let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() ?? restoreURL?.absoluteString - let desiredZoom = max(minPageZoom, min(maxPageZoom, oldWebView.pageZoom)) + let desiredZoom = max(minPageZoom, min(maxPageZoom, currentPageZoomFactor())) clearBrowserFocusMode(reason: "webViewDiscard") invalidateSearchFocusRequests(reason: "webViewDiscard") @@ -3339,11 +3339,11 @@ final class BrowserPanel: Panel, ObservableObject { profileID: profileID, websiteDataStore: websiteDataStore ) - replacement.pageZoom = desiredZoom webViewInstanceID = UUID() hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false webView = replacement replaceEngineSession(for: replacement) + engineSession.setPageZoomFactor(desiredZoom) hiddenWebViewDiscardManager.markDiscarded(reason: reason, now: now) currentURL = restoreURL shouldRenderWebView = false @@ -4753,7 +4753,7 @@ final class BrowserPanel: Panel, ObservableObject { let shouldRestoreURL = wasRenderable && restoreURLString != nil && restoreURLString != blankURLString let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() - let desiredZoom = max(minPageZoom, min(maxPageZoom, previousWebView.pageZoom)) + let desiredZoom = max(minPageZoom, min(maxPageZoom, currentPageZoomFactor())) let restoreDeveloperTools = preferredDeveloperToolsVisible || isDeveloperToolsVisible() invalidateSearchFocusRequests(reason: "profileSwitch") @@ -4791,12 +4791,12 @@ final class BrowserPanel: Panel, ObservableObject { profileID: resolvedProfileID, websiteDataStore: websiteDataStore ) - replacement.pageZoom = desiredZoom webViewInstanceID = UUID() hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement replaceEngineSession(for: replacement) + engineSession.setPageZoomFactor(desiredZoom) currentURL = restoreURL shouldRenderWebView = wasRenderable refreshWebViewLifecycleState() @@ -5254,7 +5254,7 @@ final class BrowserPanel: Panel, ObservableObject { let wasRenderable = shouldRenderWebView let attemptedURL = Self.remoteProxyDisplayURL(for: navigationDelegate?.lastAttemptedURL) ?? navigationDelegate?.lastAttemptedURL - let engineURL = engineKind == .chromium + let engineURL = engineSession.kind == .chromium ? (engineSession.state.url ?? currentURL) : oldWebView.url let liveURL = restorableDisplayURLForCurrentErrorPage(liveURL: engineURL) @@ -5268,7 +5268,7 @@ final class BrowserPanel: Panel, ObservableObject { let shouldShowManualRecovery = waitForManualRecovery && wasRenderable && hasRecoveryTarget let history = sessionNavigationHistorySnapshot() let historyCurrentURL = preferredURLStringForOmnibar() - let desiredZoom = max(minPageZoom, min(maxPageZoom, oldWebView.pageZoom)) + let desiredZoom = max(minPageZoom, min(maxPageZoom, currentPageZoomFactor())) let restoreDevTools = preferredDeveloperToolsVisible if oldWebView.configuration.websiteDataStore !== websiteDataStore { @@ -5308,12 +5308,12 @@ final class BrowserPanel: Panel, ObservableObject { profileID: profileID, websiteDataStore: websiteDataStore ) - replacement.pageZoom = desiredZoom webViewInstanceID = UUID() hasCommittedDocumentSinceWebViewReplacement = false; userStoppedLoadSinceWebViewReplacement = false resetWebViewLifecycleMetadata(resetVisibility: false) webView = replacement replaceEngineSession(for: replacement) + engineSession.setPageZoomFactor(desiredZoom) shouldRenderWebView = wasRenderable refreshWebViewLifecycleState() @@ -7209,12 +7209,12 @@ extension BrowserPanel { @discardableResult func zoomIn() -> Bool { - applyPageZoom(webView.pageZoom + pageZoomStep) + applyPageZoom(currentPageZoomFactor() + pageZoomStep) } @discardableResult func zoomOut() -> Bool { - applyPageZoom(webView.pageZoom - pageZoomStep) + applyPageZoom(currentPageZoomFactor() - pageZoomStep) } @discardableResult @@ -7223,7 +7223,7 @@ extension BrowserPanel { } func currentPageZoomFactor() -> CGFloat { - webView.pageZoom + engineSession.pageZoomFactor } @discardableResult @@ -8217,10 +8217,10 @@ private extension BrowserPanel { @discardableResult func applyPageZoom(_ candidate: CGFloat) -> Bool { let clamped = max(minPageZoom, min(maxPageZoom, candidate)) - if abs(webView.pageZoom - clamped) < 0.0001 { + if abs(engineSession.pageZoomFactor - clamped) < 0.0001 { return false } - webView.pageZoom = clamped + engineSession.setPageZoomFactor(clamped) return true } From 986bd397a1409fb603320f4639c5ebbd5cff2415 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:44:49 -0700 Subject: [PATCH 21/39] Test WebKit async JavaScript evaluation --- .../WebKitBrowserEngineSessionTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift new file mode 100644 index 00000000000..38ab3521d13 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift @@ -0,0 +1,28 @@ +import Testing +import WebKit +@testable import CmuxBrowser + +@Suite("WebKit browser engine session") +@MainActor +struct WebKitBrowserEngineSessionTests { + @Test + func evaluationAwaitsPromiseResolution() async throws { + let webView = WKWebView(frame: .zero) + let loadDelegate = ChromiumViewportDocumentLoadDelegate() + webView.navigationDelegate = loadDelegate + defer { webView.navigationDelegate = nil } + try await loadDelegate.load("", in: webView) + let session = WebKitBrowserEngineSession(webView: webView) + + let result = try await session.evaluateJavaScript( + """ + (async () => { + await new Promise(resolve => setTimeout(resolve, 1)); + return 42; + })() + """ + ) + + #expect(result == .number(42)) + } +} From a673dcda5b0acd560bd738a8a1f5511388fe101e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:45:48 -0700 Subject: [PATCH 22/39] Await WebKit browser evaluation promises --- .../CmuxBrowser/Engine/BrowserEngineSession.swift | 2 +- .../Engine/WebKitBrowserEngineSession.swift | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index 4c6bfd5630e..f0c4fc5395c 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -58,7 +58,7 @@ public protocol BrowserEngineSession: AnyObject { /// Evaluates JavaScript in an engine-defined content world. /// /// - Parameters: - /// - script: The JavaScript expression or program to evaluate. + /// - script: The JavaScript expression to evaluate. /// - world: The page or isolated world that should execute the script. /// - Returns: A Sendable value copied from the page. /// - Throws: An engine or JavaScript evaluation error. diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index 4facb27abbc..c7c9fec7d7e 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -78,7 +78,7 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { /// Keeps WebKit active because its native view owns offscreen presentation throttling. public func setViewportVisible(_: Bool) {} - /// Evaluates JavaScript in a WebKit content world. + /// Evaluates a JavaScript expression in a WebKit content world and awaits any returned promise. public func evaluateJavaScript( _ script: String, in world: BrowserJavaScriptWorld @@ -87,8 +87,14 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { case .page: .page case .isolated: .defaultClient } + let functionBody = "return await (\n\(script)\n);" return try await withCheckedThrowingContinuation { continuation in - webView.evaluateJavaScript(script, in: nil, in: contentWorld) { result in + webView.callAsyncJavaScript( + functionBody, + arguments: [:], + in: nil, + in: contentWorld + ) { result in switch result { case .success(let value): do { From 4b92120ef7258015a90eff737e34a1e887ecbbb8 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 05:55:38 -0700 Subject: [PATCH 23/39] Test Chromium engine policy invariants --- .../BrowserChromiumProfileDirectoryTests.swift | 18 ++++++++++++++++++ .../Engine/BrowserEngineResolverTests.swift | 4 ++-- .../ChromiumViewportInputQueueTests.swift | 11 ++++++----- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift index 9c3b7bbf396..34962802164 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift @@ -3,6 +3,24 @@ import Testing @testable import CmuxBrowser @Suite struct BrowserChromiumProfileDirectoryTests { + @Test func sameProfileUsesOnePersistentDirectory() { + let builder = BrowserChromiumProfileDirectory() + let profileID = UUID() + + let firstSession = builder.url( + profileID: profileID, + surfaceID: UUID(), + sessionID: UUID() + ) + let secondSession = builder.url( + profileID: profileID, + surfaceID: UUID(), + sessionID: UUID() + ) + + #expect(firstSession == secondSession) + } + @Test func removesOnlyTheOwnedSession() throws { let builder = BrowserChromiumProfileDirectory() let profileID = UUID() diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift index e0cc38b6538..2dc55a847b8 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserEngineResolverTests.swift @@ -48,10 +48,10 @@ import Testing ) == .chromium) } - @Test func automaticConsidersBothHTTPSAndHTTPHandlers() { + @Test func automaticUsesThePreferredHTTPSHandler() { #expect(resolver.resolve( preference: .automatic, defaultHandlerBundleIdentifiers: ["com.example.UnknownBrowser", "com.brave.Browser"] - ) == .chromium) + ) == .webKit) } } diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift index 81e8b84a6f5..21e6f236cd7 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -65,18 +65,19 @@ import Testing }) } - @Test func saturatedUnfinishedInputReportsBackpressureWithoutSplittingTransitions() { + @Test func saturatedUnfinishedInputStillAcceptsAReleaseTransition() { var queue = ChromiumViewportInputQueue() for index in 0.. Date: Wed, 15 Jul 2026 06:16:24 -0700 Subject: [PATCH 24/39] Fix Chromium engine policy invariants --- .../Engine/BrowserEngineResolver.swift | 7 +- .../BrowserChromiumProfileDirectory.swift | 84 +++----- .../Engine/Chromium/CDPConnection.swift | 62 ++++-- ...hromiumBrowserEngineSession+Viewport.swift | 9 + .../ChromiumBrowserEngineSession.swift | 154 ++++++++------ .../Chromium/ChromiumProcessController.swift | 41 +++- .../Chromium/ChromiumProfileRuntime.swift | 200 ++++++++++++++++++ .../ChromiumProfileRuntimeConnection.swift | 7 + .../ChromiumProfileRuntimeLease.swift | 9 + ...BrowserChromiumProfileDirectoryTests.swift | 79 +++---- .../Engine/CDPConnectionTests.swift | 81 +++++++ .../ChromiumViewportInputQueueTests.swift | 16 ++ Sources/Panels/BrowserPanel.swift | 77 +++++-- 13 files changed, 614 insertions(+), 212 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeConnection.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeLease.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift index 9e5c020eca2..8c8792c8d04 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineResolver.swift @@ -28,7 +28,12 @@ public struct BrowserEngineResolver: Sendable { case .chromium: return .chromium case .automatic: - return defaultHandlerBundleIdentifiers.contains(where: isChromiumFamilyBundleIdentifier) + guard let preferredHandler = defaultHandlerBundleIdentifiers.first(where: { + !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + }) else { + return .webKit + } + return isChromiumFamilyBundleIdentifier(preferredHandler) ? .chromium : .webKit } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift index bde5e119f52..7510f140631 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/BrowserChromiumProfileDirectory.swift @@ -1,67 +1,35 @@ public import Foundation -/// Builds isolated temporary profile directories for Chromium engine process sessions. -/// -/// These directories intentionally do not model a persistent cmux browser profile. -/// Chromium owns a process-wide profile lock, so persistent data shared by several -/// panes requires one higher-level process owner per cmux profile rather than reuse -/// or copying of concurrently active `--user-data-dir` trees. -public struct BrowserChromiumProfileDirectory { - private let fileManager: FileManager +/// Resolves persistent Chromium storage owned by one cmux browser profile. +public struct BrowserChromiumProfileDirectory: Sendable { + private let applicationSupportDirectory: URL + private let bundleIdentifier: String - /// Creates a profile-directory builder with an injectable filesystem boundary. - /// - /// - Parameter fileManager: The file manager that supplies the temporary directory. - public init(fileManager: FileManager = .default) { - self.fileManager = fileManager - } - - /// Returns the directory for one engine process session. + /// Creates a profile-directory resolver over explicit application storage. /// /// - Parameters: - /// - profileID: The cmux browser profile identifier. - /// - surfaceID: The browser surface identifier. - /// - sessionID: A unique process-session identifier that prevents overlapping processes from sharing a lock file. - /// - Returns: A temporary directory URL namespaced by profile, surface, and process session. - public func url(profileID: UUID, surfaceID: UUID, sessionID: UUID) -> URL { - fileManager.temporaryDirectory - .appendingPathComponent("cmux-chromium", isDirectory: true) - .appendingPathComponent(profileID.uuidString, isDirectory: true) - .appendingPathComponent(surfaceID.uuidString, isDirectory: true) - .appendingPathComponent(sessionID.uuidString, isDirectory: true) - } - - /// Removes a session directory only when it has the exact cmux-owned UUID path shape. - func removeSessionDirectoryIfOwned(_ sessionDirectory: URL) throws { - let root = fileManager.temporaryDirectory - .appendingPathComponent("cmux-chromium", isDirectory: true) - .standardizedFileURL - .resolvingSymlinksInPath() - let candidate = sessionDirectory - .standardizedFileURL - .resolvingSymlinksInPath() - let rootComponents = root.pathComponents - let candidateComponents = candidate.pathComponents - guard candidateComponents.count == rootComponents.count + 3, - candidateComponents.starts(with: rootComponents), - candidateComponents.suffix(3).allSatisfy({ UUID(uuidString: $0) != nil }) else { - return - } - guard fileManager.fileExists(atPath: candidate.path) else { return } - - try fileManager.removeItem(at: candidate) - removeDirectoryIfEmpty(candidate.deletingLastPathComponent()) - removeDirectoryIfEmpty(candidate.deletingLastPathComponent().deletingLastPathComponent()) + /// - applicationSupportDirectory: The user Application Support directory. + /// - bundleIdentifier: The running app's bundle identifier. + public init(applicationSupportDirectory: URL, bundleIdentifier: String) { + self.applicationSupportDirectory = applicationSupportDirectory + self.bundleIdentifier = bundleIdentifier } - private func removeDirectoryIfEmpty(_ directory: URL) { - guard (try? fileManager.contentsOfDirectory( - at: directory, - includingPropertiesForKeys: nil - ).isEmpty) == true else { - return - } - // A concurrent session creation makes removal fail safely once the directory is nonempty. - try? fileManager.removeItem(at: directory) + /// Returns the persistent Chromium user-data directory for one cmux profile. + /// + /// Tagged debug and staging builds share their normalized cmux namespace so + /// a profile survives tagged rebuilds in the same lane. + /// + /// - Parameter profileID: The cmux browser profile identifier. + /// - Returns: A stable directory unique to that profile and build lane. + public func url(profileID: UUID) -> URL { + applicationSupportDirectory + .appendingPathComponent( + BrowserHistoryLocation.normalizedNamespace(bundleIdentifier: bundleIdentifier), + isDirectory: true + ) + .appendingPathComponent("browser_profiles", isDirectory: true) + .appendingPathComponent(profileID.uuidString.lowercased(), isDirectory: true) + .appendingPathComponent("chromium", isDirectory: true) } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index 7de217302f1..1e7d3b67566 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -51,23 +51,24 @@ actor CDPConnection { let timeoutTask: Task } + private struct EventSubscriber { + let sessionID: String + let continuation: AsyncStream.Continuation + } + private let transport: any CDPWebSocketTransport private let requestTimeout: Duration private let encoder = JSONEncoder() private let decoder = JSONDecoder() - private let eventContinuation: AsyncStream.Continuation - private let eventStream: AsyncStream private var receiveTask: Task? private var nextRequestID = 1 private var pendingRequests: [Int: PendingRequest] = [:] + private var eventSubscribers: [UUID: EventSubscriber] = [:] private var isClosed = false init(url: URL, requestTimeout: Duration = .seconds(10)) { self.transport = URLSessionCDPWebSocketTransport(url: url) self.requestTimeout = requestTimeout - (eventStream, eventContinuation) = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(256) - ) } init( @@ -76,9 +77,6 @@ actor CDPConnection { ) { self.transport = transport self.requestTimeout = requestTimeout - (eventStream, eventContinuation) = AsyncStream.makeStream( - bufferingPolicy: .bufferingNewest(256) - ) } func connect() { @@ -89,7 +87,29 @@ actor CDPConnection { } } - func events() -> AsyncStream { eventStream } + func events(sessionID: String) -> AsyncStream { + let subscriberID = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: CDPEvent.self, + bufferingPolicy: .bufferingNewest(256) + ) + guard !isClosed else { + continuation.finish() + return stream + } + eventSubscribers[subscriberID] = EventSubscriber( + sessionID: sessionID, + continuation: continuation + ) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeEventSubscriber(subscriberID) + } + } + return stream + } + + func isOpen() -> Bool { !isClosed } func send( method: String, @@ -179,7 +199,7 @@ actor CDPConnection { transport.cancel() let error = BrowserEngineSessionError.chromiumProtocol("DevTools connection closed.") failAllPendingRequests(error: error) - eventContinuation.finish() + finishEventSubscribers() } private func failPendingRequest(_ requestID: Int, error: any Error) { @@ -210,7 +230,7 @@ actor CDPConnection { transport.cancel() receiveTask = nil failAllPendingRequests(error: error) - eventContinuation.finish() + finishEventSubscribers() } } @@ -228,10 +248,26 @@ actor CDPConnection { return } guard let method = payload["method"]?.stringValue else { return } - eventContinuation.yield(CDPEvent( + let event = CDPEvent( method: method, parameters: payload["params"]?.objectValue ?? [:], sessionID: payload["sessionId"]?.stringValue - )) + ) + for subscriber in eventSubscribers.values where + event.sessionID == nil || event.sessionID == subscriber.sessionID { + subscriber.continuation.yield(event) + } + } + + private func removeEventSubscriber(_ subscriberID: UUID) { + eventSubscribers.removeValue(forKey: subscriberID) + } + + private func finishEventSubscribers() { + let subscribers = Array(eventSubscribers.values) + eventSubscribers.removeAll() + for subscriber in subscribers { + subscriber.continuation.finish() + } } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index c98a98b0de0..4b3ec9d5a6d 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -120,6 +120,15 @@ struct ChromiumViewportInputQueue { $0.coalescingKind != nil }) { commands.remove(at: coalescibleIndex) + } else if case .ended(let gesture)? = command.gestureTransition, + commands.contains(where: { + $0.gestureTransition == .began(gesture) + }) { + commands.removeAll(where: { + $0.gestureTransition == .began(gesture) + }) + commands.append(command) + return true } else if !discardOldestCompleteGesture() { return false } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 4060f792306..3fcac27e95b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -27,12 +27,12 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private let stateContinuation: AsyncStream.Continuation private let viewportWebView: WKWebView private let application: BrowserApplication? - private let userDataDirectory: URL - private let processController = ChromiumProcessController() + private let profileRuntime: ChromiumProfileRuntime private let viewportHandler = ChromiumViewportMessageHandler() private let cookieCodec = ChromiumBrowserCookieCodec() private let documentTitleObservation = ChromiumDocumentTitleObservation() var connection: CDPConnection? + private var targetID: String? var cdpSessionID: String? private var startupTask: Task? private var eventTask: Task? @@ -55,22 +55,45 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { var deviceScaleFactor = 1.0 private var isClosed = false - /// Creates and starts a Chromium engine session. + /// Creates and starts a Chromium engine session with an independently owned runtime. /// /// - Parameters: /// - viewportWebView: The local WebKit transport view used only for canvas presentation and input capture. /// - application: The installed Chromium-family browser to launch, or `nil` to show an unavailable error. /// - userDataDirectory: An isolated Chrome profile directory for this engine session. /// - initializationScripts: Scripts to install before the first requested page loads. - public init( + public convenience init( viewportWebView: WKWebView, application: BrowserApplication?, userDataDirectory: URL, initializationScripts: [String] = [] + ) { + self.init( + viewportWebView: viewportWebView, + profileRuntime: ChromiumProfileRuntime( + userDataDirectory: userDataDirectory + ), + application: application, + initializationScripts: initializationScripts + ) + } + + /// Creates and starts a Chromium engine session on a profile-scoped runtime. + /// + /// - Parameters: + /// - viewportWebView: The local WebKit transport view used only for canvas presentation and input capture. + /// - profileRuntime: The process owner shared by every pane using the same cmux profile. + /// - application: The installed Chromium-family browser to launch, or `nil` to show an unavailable error. + /// - initializationScripts: Scripts to install before the first requested page loads. + public init( + viewportWebView: WKWebView, + profileRuntime: ChromiumProfileRuntime, + application: BrowserApplication?, + initializationScripts: [String] = [] ) { self.viewportWebView = viewportWebView self.application = application - self.userDataDirectory = userDataDirectory + self.profileRuntime = profileRuntime self.initializationScripts = initializationScripts (stateUpdates, stateContinuation) = AsyncStream.makeStream( bufferingPolicy: .bufferingNewest(1) @@ -339,17 +362,19 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { forName: "cmuxChromiumViewport" ) stateContinuation.finish() - let connection = connection - Task { [processController] in - await processController.close() - await connection?.close() + let targetID = targetID + self.targetID = nil + Task { [profileRuntime] in + if let targetID { + await profileRuntime.releaseTarget(targetID) + } } self.connection = nil cdpSessionID = nil } private func start() { - guard let application else { + guard application != nil else { let message = String( localized: "browser.chromium.error.notInstalled", defaultValue: "Chromium is selected, but no supported Chromium browser is installed." @@ -361,66 +386,63 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { startupTask = Task { [weak self] in guard let self else { return } do { - let endpoint = try await processController.start( - application: application, - userDataDirectory: userDataDirectory - ) - contentProcessIdentifier = await processController.processIdentifier() - try await connect(to: endpoint) + try await connect() } catch is CancellationError { return } catch { contentProcessIdentifier = nil - await processController.close() presentLaunchFailure() } } } - private func connect(to endpoint: URL) async throws { - let connection = CDPConnection(url: endpoint) - await connection.connect() - self.connection = connection - let created = try await connection.send( - method: "Target.createTarget", - parameters: [ - "url": .string("about:blank"), - "width": .number(Double(viewportWidth)), - "height": .number(Double(viewportHeight)), - ] - ) - guard let targetID = created.objectValue?["targetId"]?.stringValue else { - throw BrowserEngineSessionError.chromiumProtocol("Chromium did not create a page target.") + private func connect() async throws { + guard let application else { + throw BrowserEngineSessionError.chromiumUnavailable } - let attached = try await connection.send( - method: "Target.attachToTarget", - parameters: ["targetId": .string(targetID), "flatten": .bool(true)] + let lease = try await profileRuntime.acquireTarget( + application: application, + width: viewportWidth, + height: viewportHeight ) - guard let sessionID = attached.objectValue?["sessionId"]?.stringValue else { - throw BrowserEngineSessionError.chromiumProtocol("Chromium did not attach to the page target.") - } - cdpSessionID = sessionID - startViewportInputDrainingIfNeeded() - beginEvents(connection: connection, sessionID: sessionID) - _ = try await connection.send(method: "Page.enable", sessionID: sessionID) - _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) - try await installDocumentTitleObservation(connection: connection, sessionID: sessionID) - let initialPageZoomFactor = pageZoomFactor - try await sendPageZoomFactor( - initialPageZoomFactor, - connection: connection, - sessionID: sessionID - ) - appliedPageZoomFactor = initialPageZoomFactor - isPageZoomReady = true - startPageZoomUpdateIfNeeded() - try await sendDeviceMetrics(connection: connection, sessionID: sessionID) - startScreencastUpdateIfNeeded() - try await installAllInitializationScripts(connection: connection, sessionID: sessionID) - if let request = pendingRequest { - load(request) - } else { - updateState { $0.isLoading = false } + do { + try Task.checkCancellation() + guard !isClosed else { throw CancellationError() } + let connection = lease.connection + self.connection = connection + targetID = lease.targetID + contentProcessIdentifier = lease.processIdentifier + let sessionID = lease.sessionID + cdpSessionID = sessionID + startViewportInputDrainingIfNeeded() + beginEvents(connection: connection, sessionID: sessionID) + _ = try await connection.send(method: "Page.enable", sessionID: sessionID) + _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) + try await installDocumentTitleObservation(connection: connection, sessionID: sessionID) + let initialPageZoomFactor = pageZoomFactor + try await sendPageZoomFactor( + initialPageZoomFactor, + connection: connection, + sessionID: sessionID + ) + appliedPageZoomFactor = initialPageZoomFactor + isPageZoomReady = true + startPageZoomUpdateIfNeeded() + try await sendDeviceMetrics(connection: connection, sessionID: sessionID) + startScreencastUpdateIfNeeded() + try await installAllInitializationScripts(connection: connection, sessionID: sessionID) + if let request = pendingRequest { + load(request) + } else { + updateState { $0.isLoading = false } + } + } catch { + self.connection = nil + self.targetID = nil + cdpSessionID = nil + contentProcessIdentifier = nil + await profileRuntime.releaseTarget(lease.targetID) + throw error } } @@ -497,8 +519,8 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private func beginEvents(connection: CDPConnection, sessionID: String) { eventTask = Task { [weak self] in - let events = await connection.events() - for await event in events where event.sessionID == nil || event.sessionID == sessionID { + let events = await connection.events(sessionID: sessionID) + for await event in events { guard let self, !Task.isCancelled else { return } await self.handle(event, connection: connection, sessionID: sessionID) } @@ -730,13 +752,15 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { viewportInputTask?.cancel() viewportInputTask = nil viewportInputQueue.removeAll() - let connection = connection + let targetID = targetID self.connection = nil + self.targetID = nil cdpSessionID = nil contentProcessIdentifier = nil - Task { [processController] in - await processController.close() - await connection?.close() + Task { [profileRuntime] in + if let targetID { + await profileRuntime.releaseTarget(targetID) + } } presentOperationFailure() } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift index 38c749d9e4b..785c2884581 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -7,6 +7,7 @@ actor ChromiumProcessController { private var stderrTask: Task? private var timeoutTask: Task? private var endpointContinuation: CheckedContinuation? + private var terminationContinuations: [CheckedContinuation] = [] private var didResolveEndpoint = false init(launchTimeout: Duration = .seconds(10)) { @@ -23,6 +24,8 @@ actor ChromiumProcessController { guard process == nil else { throw BrowserEngineSessionError.chromiumLaunch("Chromium is already running for this session.") } + didResolveEndpoint = false + endpointContinuation = nil try FileManager.default.createDirectory( at: userDataDirectory, withIntermediateDirectories: true @@ -45,13 +48,14 @@ actor ChromiumProcessController { process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice process.standardError = stderrPipe - process.terminationHandler = { _ in - try? BrowserChromiumProfileDirectory().removeSessionDirectoryIfOwned(userDataDirectory) + process.terminationHandler = { [weak self] _ in + Task { + await self?.processDidTerminate() + } } do { try process.run() } catch { - try? BrowserChromiumProfileDirectory().removeSessionDirectoryIfOwned(userDataDirectory) throw error } self.process = process @@ -72,7 +76,7 @@ actor ChromiumProcessController { } } - func close() { + func close() async { stderrTask?.cancel() stderrTask = nil timeoutTask?.cancel() @@ -81,11 +85,20 @@ actor ChromiumProcessController { failUnresolvedEndpoint(BrowserEngineSessionError.chromiumLaunch( "Chromium stopped before opening its DevTools endpoint." )) - } else if let process, process.isRunning { - process.terminate() } - process = nil + guard let process else { + endpointContinuation = nil + return + } endpointContinuation = nil + guard process.isRunning else { + self.process = nil + return + } + process.terminate() + await withCheckedContinuation { continuation in + terminationContinuations.append(continuation) + } } private func failUnresolvedEndpoint(_ error: any Error) { @@ -115,6 +128,20 @@ actor ChromiumProcessController { } } + private func processDidTerminate() { + if !didResolveEndpoint { + failUnresolvedEndpoint(BrowserEngineSessionError.chromiumLaunch( + "Chromium stopped before opening its DevTools endpoint." + )) + } + process = nil + let continuations = terminationContinuations + terminationContinuations.removeAll() + for continuation in continuations { + continuation.resume() + } + } + private func resolveEndpointIfPresent(in line: String) { guard !didResolveEndpoint else { return } let marker = "DevTools listening on " diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift new file mode 100644 index 00000000000..ca6395b8b91 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift @@ -0,0 +1,200 @@ +public import Foundation + +/// Owns one persistent Chromium process and CDP connection for a cmux browser profile. +/// +/// Every simultaneously open pane for the profile receives its own target on the +/// shared process. This preserves Chromium's process-wide profile lock while +/// allowing cookies and site storage to persist in one profile directory. +public actor ChromiumProfileRuntime { + private let userDataDirectory: URL + private let processController: ChromiumProcessController + private var connection: CDPConnection? + private var startupTask: Task? + private var shutdownTask: Task? + private var activeTargetIDs: Set = [] + private var pendingAcquisitionCount = 0 + private var lifecycleGeneration = 0 + + /// Creates a profile-scoped Chromium runtime. + /// + /// Construction does not launch Chromium. The first engine session starts the + /// process, and the final released target stops it without deleting profile data. + /// + /// - Parameter userDataDirectory: The persistent user-data directory for the cmux profile. + public init(userDataDirectory: URL) { + self.userDataDirectory = userDataDirectory + self.processController = ChromiumProcessController() + } + + func acquireTarget( + application: BrowserApplication, + width: Int, + height: Int + ) async throws -> ChromiumProfileRuntimeLease { + pendingAcquisitionCount += 1 + do { + let shared = try await connected(application: application) + let generation = lifecycleGeneration + try Task.checkCancellation() + let created = try await shared.connection.send( + method: "Target.createTarget", + parameters: [ + "url": .string("about:blank"), + "width": .number(Double(width)), + "height": .number(Double(height)), + ] + ) + guard let targetID = created.objectValue?["targetId"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not create a page target.") + } + guard generation == lifecycleGeneration else { + _ = try? await shared.connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(targetID)] + ) + throw CancellationError() + } + let attached = try await shared.connection.send( + method: "Target.attachToTarget", + parameters: ["targetId": .string(targetID), "flatten": .bool(true)] + ) + guard let sessionID = attached.objectValue?["sessionId"]?.stringValue else { + _ = try? await shared.connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(targetID)] + ) + throw BrowserEngineSessionError.chromiumProtocol("Chromium did not attach to the page target.") + } + guard generation == lifecycleGeneration, !Task.isCancelled else { + _ = try? await shared.connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(targetID)] + ) + throw CancellationError() + } + activeTargetIDs.insert(targetID) + let lease = ChromiumProfileRuntimeLease( + connection: shared.connection, + targetID: targetID, + sessionID: sessionID, + processIdentifier: shared.processIdentifier + ) + pendingAcquisitionCount -= 1 + return lease + } catch { + pendingAcquisitionCount -= 1 + await shutDownIfUnused() + throw error + } + } + + func releaseTarget(_ targetID: String) async { + guard activeTargetIDs.remove(targetID) != nil else { return } + if let connection { + _ = try? await connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(targetID)] + ) + } + await shutDownIfUnused() + } + + /// Stops every target and the profile process while retaining on-disk data. + public func close() async { + lifecycleGeneration &+= 1 + startupTask?.cancel() + startupTask = nil + activeTargetIDs.removeAll() + await shutDown() + } + + private func connected(application: BrowserApplication) async throws -> ChromiumProfileRuntimeConnection { + if let shutdownTask { + await shutdownTask.value + if self.shutdownTask != nil { + self.shutdownTask = nil + } + return try await connected(application: application) + } + if let connection { + if await connection.isOpen() { + return ChromiumProfileRuntimeConnection( + connection: connection, + processIdentifier: await processController.processIdentifier() + ) + } + self.connection = nil + activeTargetIDs.removeAll() + await shutDown() + return try await connected(application: application) + } + + let generation = lifecycleGeneration + let task: Task + if let startupTask { + task = startupTask + } else { + let processController = processController + let userDataDirectory = userDataDirectory + task = Task { + let endpoint = try await processController.start( + application: application, + userDataDirectory: userDataDirectory + ) + let connection = CDPConnection(url: endpoint) + await connection.connect() + return ChromiumProfileRuntimeConnection( + connection: connection, + processIdentifier: await processController.processIdentifier() + ) + } + startupTask = task + } + do { + let shared = try await task.value + guard generation == lifecycleGeneration else { + await shared.connection.close() + throw CancellationError() + } + connection = shared.connection + startupTask = nil + return shared + } catch { + if generation == lifecycleGeneration { + startupTask = nil + } + await processController.close() + throw error + } + } + + private func shutDownIfUnused() async { + guard activeTargetIDs.isEmpty, pendingAcquisitionCount == 0 else { return } + await shutDown() + } + + private func shutDown() async { + if let shutdownTask { + await shutdownTask.value + self.shutdownTask = nil + return + } + guard connection != nil || startupTask != nil else { + await processController.close() + return + } + lifecycleGeneration &+= 1 + startupTask?.cancel() + startupTask = nil + let connection = connection + self.connection = nil + let processController = processController + let task = Task { + await connection?.close() + await processController.close() + } + shutdownTask = task + await task.value + shutdownTask = nil + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeConnection.swift new file mode 100644 index 00000000000..c34b728b180 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeConnection.swift @@ -0,0 +1,7 @@ +import Foundation + +/// The browser-level connection shared by every page target in one profile. +struct ChromiumProfileRuntimeConnection: Sendable { + let connection: CDPConnection + let processIdentifier: Int32? +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeLease.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeLease.swift new file mode 100644 index 00000000000..4d35f5aa19c --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntimeLease.swift @@ -0,0 +1,9 @@ +import Foundation + +/// One page target leased from a profile-scoped Chromium process. +struct ChromiumProfileRuntimeLease: Sendable { + let connection: CDPConnection + let targetID: String + let sessionID: String + let processIdentifier: Int32? +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift index 34962802164..43c516fee48 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BrowserChromiumProfileDirectoryTests.swift @@ -4,70 +4,41 @@ import Testing @Suite struct BrowserChromiumProfileDirectoryTests { @Test func sameProfileUsesOnePersistentDirectory() { - let builder = BrowserChromiumProfileDirectory() + let builder = BrowserChromiumProfileDirectory( + applicationSupportDirectory: URL(fileURLWithPath: "/application-support"), + bundleIdentifier: "com.cmuxterm.app" + ) let profileID = UUID() - let firstSession = builder.url( - profileID: profileID, - surfaceID: UUID(), - sessionID: UUID() - ) - let secondSession = builder.url( - profileID: profileID, - surfaceID: UUID(), - sessionID: UUID() - ) + let firstSession = builder.url(profileID: profileID) + let secondSession = builder.url(profileID: profileID) #expect(firstSession == secondSession) } - @Test func removesOnlyTheOwnedSession() throws { - let builder = BrowserChromiumProfileDirectory() - let profileID = UUID() - let surfaceID = UUID() - let firstSession = builder.url( - profileID: profileID, - surfaceID: surfaceID, - sessionID: UUID() - ) - let secondSession = builder.url( - profileID: profileID, - surfaceID: surfaceID, - sessionID: UUID() + @Test func separatesProfileDirectories() { + let builder = BrowserChromiumProfileDirectory( + applicationSupportDirectory: URL(fileURLWithPath: "/application-support"), + bundleIdentifier: "com.cmuxterm.app" ) - let profileDirectory = firstSession - .deletingLastPathComponent() - .deletingLastPathComponent() - let surfaceDirectory = firstSession.deletingLastPathComponent() - try FileManager.default.createDirectory(at: firstSession, withIntermediateDirectories: true) - try FileManager.default.createDirectory(at: secondSession, withIntermediateDirectories: true) - defer { - try? builder.removeSessionDirectoryIfOwned(firstSession) - try? builder.removeSessionDirectoryIfOwned(secondSession) - try? FileManager.default.removeItem(at: profileDirectory) - } - - try builder.removeSessionDirectoryIfOwned(firstSession) + let firstProfileID = UUID(uuidString: "7F71E21E-4F75-4FD7-B7F0-2DD8566A50CD")! + let secondProfileID = UUID(uuidString: "09BDB8FD-9B35-40F8-82C1-07D6FA4A5905")! - #expect(!FileManager.default.fileExists(atPath: firstSession.path)) - #expect(FileManager.default.fileExists(atPath: secondSession.path)) - - try builder.removeSessionDirectoryIfOwned(secondSession) - - #expect(!FileManager.default.fileExists(atPath: secondSession.path)) - #expect(!FileManager.default.fileExists(atPath: surfaceDirectory.path)) - #expect(!FileManager.default.fileExists(atPath: profileDirectory.path)) + #expect(builder.url(profileID: firstProfileID) != builder.url(profileID: secondProfileID)) } - @Test func refusesToRemoveDirectoryOutsideOwnedUUIDHierarchy() throws { - let builder = BrowserChromiumProfileDirectory() - let outsideDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-chromium-cleanup-test-\(UUID().uuidString)") - try FileManager.default.createDirectory(at: outsideDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: outsideDirectory) } - - try builder.removeSessionDirectoryIfOwned(outsideDirectory) + @Test func normalizesTaggedDebugStorage() { + let profileID = UUID() + let applicationSupport = URL(fileURLWithPath: "/application-support") + let first = BrowserChromiumProfileDirectory( + applicationSupportDirectory: applicationSupport, + bundleIdentifier: "com.cmuxterm.app.debug.first" + ) + let second = BrowserChromiumProfileDirectory( + applicationSupportDirectory: applicationSupport, + bundleIdentifier: "com.cmuxterm.app.debug.second" + ) - #expect(FileManager.default.fileExists(atPath: outsideDirectory.path)) + #expect(first.url(profileID: profileID) == second.url(profileID: profileID)) } } diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift index 9c61efc7c86..a8ed3a5f820 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -32,6 +32,32 @@ import Testing } await connection.close() } + + @Test func broadcastsBrowserEventsToEveryTargetSubscriber() async throws { + let transport = BufferedCDPWebSocketTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + let firstEvents = await connection.events(sessionID: "first-target") + let secondEvents = await connection.events(sessionID: "second-target") + let firstEvent = Task { + for await event in firstEvents { return event } + return nil + } + let secondEvent = Task { + for await event in secondEvents { return event } + return nil + } + let eventData = try JSONSerialization.data(withJSONObject: [ + "method": "Target.targetInfoChanged", + "params": ["targetInfo": ["targetId": "browser"]], + ]) + + await transport.deliverAndWaitUntilConsumed(eventData) + await connection.close() + + #expect(await firstEvent.value?.method == "Target.targetInfoChanged") + #expect(await secondEvent.value?.method == "Target.targetInfoChanged") + } } private final class NoResponseCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { @@ -46,3 +72,58 @@ private final class NoResponseCDPWebSocketTransport: CDPWebSocketTransport, @unc func cancel() {} } + +private actor BufferedCDPWebSocketTransport: CDPWebSocketTransport { + private var bufferedData: [Data] = [] + private var receiveContinuation: CheckedContinuation? + private var receiveCount = 0 + private var receiveCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + nonisolated func resume() {} + + func send(_: Data) async throws {} + + func receive() async throws -> Data { + receiveCount += 1 + resumeReceiveCountWaiters() + if !bufferedData.isEmpty { + return bufferedData.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + nonisolated func cancel() { + Task { + await finish() + } + } + + func deliverAndWaitUntilConsumed(_ data: Data) async { + let nextReceiveCount = receiveCount + 1 + if let continuation = receiveContinuation { + receiveContinuation = nil + continuation.resume(returning: data) + } else { + bufferedData.append(data) + } + guard receiveCount < nextReceiveCount else { return } + await withCheckedContinuation { continuation in + receiveCountWaiters.append((nextReceiveCount, continuation)) + } + } + + private func finish() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + private func resumeReceiveCountWaiters() { + let ready = receiveCountWaiters.filter { $0.count <= receiveCount } + receiveCountWaiters.removeAll { $0.count <= receiveCount } + for waiter in ready { + waiter.continuation.resume() + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift index 21e6f236cd7..dfeb35b8660 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -80,6 +80,22 @@ import Testing }.map { $0.parameters["type"] } == [.string("keyUp")]) } + @Test func releaseDropsEveryQueuedRepeatOfTheSamePress() { + var queue = ChromiumViewportInputQueue() + + for index in 0.. BrowserProfileDefinition? { let result = repository.deleteProfile(id: id) + if result != nil { + removeChromiumRuntimeAndData(for: id) + } mirrorPublishedState() return result } func clearProfileData(id: UUID) async -> BrowserProfileClearOutcome? { + guard repository.profileDefinition(id: id) != nil else { return nil } + let runtime = chromiumRuntimes[id] + if let runtime { + await runtime.close() + } + let directory = chromiumProfileDirectory.url(profileID: id) + await fileRemover.removeItemIfExists(at: directory) let result = await repository.clearProfileData(id: id) mirrorPublishedState() return result @@ -430,6 +457,28 @@ final class BrowserProfileStore: ObservableObject { repository.historyFileURL(for: profileID) } + func chromiumRuntime(for profileID: UUID) -> ChromiumProfileRuntime { + if let runtime = chromiumRuntimes[profileID] { + return runtime + } + let runtime = ChromiumProfileRuntime( + userDataDirectory: chromiumProfileDirectory.url(profileID: profileID) + ) + chromiumRuntimes[profileID] = runtime + return runtime + } + + private func removeChromiumRuntimeAndData(for profileID: UUID) { + let runtime = chromiumRuntimes.removeValue(forKey: profileID) + let directory = chromiumProfileDirectory.url(profileID: profileID) + Task { [fileRemover] in + if let runtime { + await runtime.close() + } + await fileRemover.removeItemIfExists(at: directory) + } + } + func flushPendingSaves() { repository.flushPendingSaves() } @@ -2737,6 +2786,7 @@ final class BrowserPanel: Panel, ObservableObject { /// The engine-neutral owner of navigation, automation, and page capture. private let chromiumApplication: BrowserApplication? + private var chromiumProfileRuntime: ChromiumProfileRuntime private var engineSessionStorage: (any BrowserEngineSession)? private var engineStateTask: Task? var engineInitializationScripts: [String] = [] @@ -3984,6 +4034,9 @@ final class BrowserPanel: Panel, ObservableObject { self.historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) self.engineKind = effectiveEngineSelection.kind self.chromiumApplication = effectiveEngineSelection.chromiumApplication + self.chromiumProfileRuntime = BrowserProfileStore.shared.chromiumRuntime( + for: resolvedProfileID + ) self.insecureHTTPBypassHostOnce = BrowserInsecureHTTPSettings.normalizeHost(bypassInsecureHTTPHostOnce ?? "") self.bypassesRemoteWorkspaceProxy = bypassRemoteProxy self.remoteProxyEndpoint = bypassRemoteProxy ? nil : proxyEndpoint @@ -4018,9 +4071,8 @@ final class BrowserPanel: Panel, ObservableObject { self.engineSessionStorage = Self.makeEngineSession( kind: effectiveEngineSelection.kind, chromiumApplication: effectiveEngineSelection.chromiumApplication, + chromiumProfileRuntime: chromiumProfileRuntime, webView: webView, - profileID: resolvedProfileID, - surfaceID: panelID, initializationScripts: [] ) self.insecureHTTPAlertFactory = { NSAlert() } @@ -4282,9 +4334,8 @@ final class BrowserPanel: Panel, ObservableObject { private static func makeEngineSession( kind: BrowserEngineKind, chromiumApplication: BrowserApplication?, + chromiumProfileRuntime: ChromiumProfileRuntime, webView: WKWebView, - profileID: UUID, - surfaceID: UUID, initializationScripts: [String] ) -> any BrowserEngineSession { switch kind { @@ -4298,12 +4349,8 @@ final class BrowserPanel: Panel, ObservableObject { case .chromium: return ChromiumBrowserEngineSession( viewportWebView: webView, + profileRuntime: chromiumProfileRuntime, application: chromiumApplication, - userDataDirectory: BrowserChromiumProfileDirectory().url( - profileID: profileID, - surfaceID: surfaceID, - sessionID: UUID() - ), initializationScripts: initializationScripts ) } @@ -4320,9 +4367,8 @@ final class BrowserPanel: Panel, ObservableObject { engineSessionStorage = Self.makeEngineSession( kind: engineKind, chromiumApplication: chromiumApplication, + chromiumProfileRuntime: chromiumProfileRuntime, webView: webView, - profileID: profileID, - surfaceID: id, initializationScripts: engineInitializationScripts ) syncEngineViewportVisibility() @@ -4779,6 +4825,9 @@ final class BrowserPanel: Panel, ObservableObject { if let previousCmuxWebView = previousWebView as? CmuxWebView { previousCmuxWebView.clearBrowserDownloadCallbacks() } profileID = resolvedProfileID + chromiumProfileRuntime = BrowserProfileStore.shared.chromiumRuntime( + for: resolvedProfileID + ) historyStore = BrowserProfileStore.shared.historyStore(for: resolvedProfileID) BrowserProfileStore.shared.noteUsed(resolvedProfileID) From 322a2d951e68be06436d587c368cdbe5bbf64a21 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:20:34 -0700 Subject: [PATCH 25/39] Test Chromium pre-commit navigation policy --- .../ChromiumNavigationInterceptorTests.swift | 145 ++++++++++++++++++ .../Engine/NavigationTestCDPTransport.swift | 66 ++++++++ 2 files changed, 211 insertions(+) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NavigationTestCDPTransport.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift new file mode 100644 index 00000000000..f1e6c572114 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift @@ -0,0 +1,145 @@ +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Chromium navigation interception") +@MainActor +struct ChromiumNavigationInterceptorTests { + @Test + func cancelsRejectedTopLevelRequestBeforeCommit() async throws { + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + var observedRequest: BrowserEngineNavigationRequest? + let interceptor = ChromiumNavigationInterceptor( + targetID: "opener-target", + policyHandler: { request in + observedRequest = request + return .cancel + } + ) + try await interceptor.install(connection: connection, sessionID: "opener-session") + + let handled = try await interceptor.handle( + requestPausedEvent(url: "http://insecure.example/redirected"), + connection: connection, + sessionID: "opener-session" + ) + + #expect(handled) + #expect(observedRequest?.request.url?.absoluteString == "http://insecure.example/redirected") + #expect(observedRequest?.disposition == .currentTab) + let commands = await transport.commands() + #expect(commands.contains { command in + command["method"] == .string("Fetch.failRequest") && + command["params"]?.objectValue?["requestId"] == .string("paused-request") && + command["params"]?.objectValue?["errorReason"] == .string("Aborted") + }) + await connection.close() + } + + @Test + func continuesAllowedTopLevelRequest() async throws { + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + let interceptor = ChromiumNavigationInterceptor( + targetID: "opener-target", + policyHandler: { _ in .allow } + ) + try await interceptor.install(connection: connection, sessionID: "opener-session") + + _ = try await interceptor.handle( + requestPausedEvent(url: "https://example.com/allowed"), + connection: connection, + sessionID: "opener-session" + ) + + let commands = await transport.commands() + #expect(commands.contains { command in + command["method"] == .string("Fetch.continueRequest") && + command["params"]?.objectValue?["requestId"] == .string("paused-request") + }) + await connection.close() + } + + @Test + func routesAndClosesNewWindowBeforeItCanResume() async throws { + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + var observedRequest: BrowserEngineNavigationRequest? + let interceptor = ChromiumNavigationInterceptor( + targetID: "opener-target", + policyHandler: { request in + observedRequest = request + return .cancel + } + ) + try await interceptor.install(connection: connection, sessionID: "opener-session") + + let routed = try await interceptor.handle( + CDPEvent( + method: "Page.windowOpen", + parameters: [ + "url": .string("https://example.com/popup"), + "userGesture": .bool(true), + "windowFeatures": .array([]), + ], + sessionID: "opener-session" + ), + connection: connection, + sessionID: "opener-session" + ) + let closed = try await interceptor.handle( + CDPEvent( + method: "Target.attachedToTarget", + parameters: [ + "sessionId": .string("popup-session"), + "targetInfo": .object([ + "targetId": .string("popup-target"), + "type": .string("page"), + "url": .string("https://example.com/popup"), + "openerId": .string("opener-target"), + ]), + "waitingForDebugger": .bool(true), + ], + sessionID: "opener-session" + ), + connection: connection, + sessionID: "opener-session" + ) + + #expect(routed) + #expect(closed) + #expect(observedRequest?.request.url?.absoluteString == "https://example.com/popup") + #expect(observedRequest?.disposition == .newTab) + let commands = await transport.commands() + #expect(commands.contains { command in + command["method"] == .string("Target.closeTarget") && + command["params"]?.objectValue?["targetId"] == .string("popup-target") + }) + #expect(!commands.contains { command in + command["method"] == .string("Runtime.runIfWaitingForDebugger") && + command["sessionId"] == .string("popup-session") + }) + await connection.close() + } + + private func requestPausedEvent(url: String) -> CDPEvent { + CDPEvent( + method: "Fetch.requestPaused", + parameters: [ + "requestId": .string("paused-request"), + "frameId": .string("main-frame"), + "resourceType": .string("Document"), + "request": .object([ + "url": .string(url), + "method": .string("GET"), + "headers": .object([:]), + ]), + ], + sessionID: "opener-session" + ) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NavigationTestCDPTransport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NavigationTestCDPTransport.swift new file mode 100644 index 00000000000..30d9af796fa --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NavigationTestCDPTransport.swift @@ -0,0 +1,66 @@ +import Foundation +@testable import CmuxBrowser + +actor NavigationTestCDPTransport: CDPWebSocketTransport { + private let decoder = JSONDecoder() + private let encoder = JSONEncoder() + private var inboundMessages: [Data] = [] + private var receiveContinuation: CheckedContinuation? + private var sentCommands: [[String: CDPJSONValue]] = [] + + nonisolated func resume() {} + + func send(_ data: Data) async throws { + let command = try decoder.decode([String: CDPJSONValue].self, from: data) + sentCommands.append(command) + guard let requestID = command["id"]?.intValue else { return } + + let result: CDPJSONValue + if command["method"]?.stringValue == "Page.getFrameTree" { + result = .object([ + "frameTree": .object([ + "frame": .object(["id": .string("main-frame")]), + ]), + ]) + } else { + result = .object([:]) + } + try enqueue(encoder.encode([ + "id": CDPJSONValue.number(Double(requestID)), + "result": result, + ])) + } + + func receive() async throws -> Data { + if !inboundMessages.isEmpty { + return inboundMessages.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + nonisolated func cancel() { + Task { + await finish() + } + } + + func commands() -> [[String: CDPJSONValue]] { + sentCommands + } + + private func enqueue(_ data: Data) { + if let receiveContinuation { + self.receiveContinuation = nil + receiveContinuation.resume(returning: data) + } else { + inboundMessages.append(data) + } + } + + private func finish() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } +} From 67f6e3309645b96ff9b3f0a7e5eb443cc6ff1c60 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:27:58 -0700 Subject: [PATCH 26/39] Enforce Chromium navigation policy before commit --- .../BrowserEngineNavigationDecision.swift | 8 + .../BrowserEngineNavigationDisposition.swift | 8 + ...BrowserEngineNavigationPolicyHandler.swift | 4 + .../BrowserEngineNavigationRequest.swift | 23 +++ .../ChromiumBrowserEngineSession.swift | 37 ++++ .../ChromiumNavigationInterceptor.swift | 189 ++++++++++++++++++ .../ChromiumNavigationInterceptorTests.swift | 78 +++++++- Sources/Panels/BrowserPanel.swift | 57 ++++++ 8 files changed, 402 insertions(+), 2 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDecision.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDisposition.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationPolicyHandler.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationRequest.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDecision.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDecision.swift new file mode 100644 index 00000000000..14aa8e3b42f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDecision.swift @@ -0,0 +1,8 @@ +/// A pre-commit decision for an engine-originated browser navigation. +public enum BrowserEngineNavigationDecision: Sendable { + /// Allow the browser engine to continue the original navigation. + case allow + + /// Cancel the browser engine's original navigation. + case cancel +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDisposition.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDisposition.swift new file mode 100644 index 00000000000..0872ec7fe36 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationDisposition.swift @@ -0,0 +1,8 @@ +/// The cmux destination requested by an engine-originated browser navigation. +public enum BrowserEngineNavigationDisposition: Equatable, Sendable { + /// Continue navigation in the current browser pane. + case currentTab + + /// Route a new-window request into a cmux browser tab. + case newTab +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationPolicyHandler.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationPolicyHandler.swift new file mode 100644 index 00000000000..1b625e3b23e --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationPolicyHandler.swift @@ -0,0 +1,4 @@ +/// Evaluates an engine-originated navigation before the browser commits it. +public typealias BrowserEngineNavigationPolicyHandler = @MainActor @Sendable ( + BrowserEngineNavigationRequest +) -> BrowserEngineNavigationDecision diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationRequest.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationRequest.swift new file mode 100644 index 00000000000..a4ed06cf723 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineNavigationRequest.swift @@ -0,0 +1,23 @@ +public import Foundation + +/// An engine-neutral navigation request awaiting cmux policy evaluation. +public struct BrowserEngineNavigationRequest: Sendable { + /// The original browser request represented by the engine event. + public let request: URLRequest + + /// The cmux destination requested by the page. + public let disposition: BrowserEngineNavigationDisposition + + /// Creates a navigation request for policy evaluation. + /// + /// - Parameters: + /// - request: The original request represented by the browser engine. + /// - disposition: The current-pane or new-tab destination requested by the page. + public init( + request: URLRequest, + disposition: BrowserEngineNavigationDisposition + ) { + self.request = request + self.disposition = disposition + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 3fcac27e95b..bae6025b2a3 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -24,6 +24,13 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { /// State snapshots emitted from CDP lifecycle events. public let stateUpdates: AsyncStream + /// The cmux-owned policy applied before Chromium commits a top-level navigation. + public var navigationPolicyHandler: BrowserEngineNavigationPolicyHandler? { + didSet { + navigationInterceptor?.policyHandler = navigationPolicyHandler + } + } + private let stateContinuation: AsyncStream.Continuation private let viewportWebView: WKWebView private let application: BrowserApplication? @@ -31,6 +38,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private let viewportHandler = ChromiumViewportMessageHandler() private let cookieCodec = ChromiumBrowserCookieCodec() private let documentTitleObservation = ChromiumDocumentTitleObservation() + private var navigationInterceptor: ChromiumNavigationInterceptor? var connection: CDPConnection? private var targetID: String? var cdpSessionID: String? @@ -358,6 +366,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { deviceMetricsPending = false startupTask = nil eventTask = nil + navigationInterceptor = nil viewportWebView.configuration.userContentController.removeScriptMessageHandler( forName: "cmuxChromiumViewport" ) @@ -418,6 +427,15 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { beginEvents(connection: connection, sessionID: sessionID) _ = try await connection.send(method: "Page.enable", sessionID: sessionID) _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) + let navigationInterceptor = ChromiumNavigationInterceptor( + targetID: lease.targetID, + policyHandler: navigationPolicyHandler + ) + self.navigationInterceptor = navigationInterceptor + try await navigationInterceptor.install( + connection: connection, + sessionID: sessionID + ) try await installDocumentTitleObservation(connection: connection, sessionID: sessionID) let initialPageZoomFactor = pageZoomFactor try await sendPageZoomFactor( @@ -437,6 +455,9 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { updateState { $0.isLoading = false } } } catch { + eventTask?.cancel() + eventTask = nil + navigationInterceptor = nil self.connection = nil self.targetID = nil cdpSessionID = nil @@ -530,6 +551,21 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { + do { + if try await navigationInterceptor?.handle( + event, + connection: connection, + sessionID: sessionID + ) == true { + return + } + } catch is CancellationError { + return + } catch { + guard !isClosed else { return } + presentOperationFailure() + return + } switch event.method { case "Page.screencastFrame": guard let data = event.parameters["data"]?.stringValue, @@ -752,6 +788,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { viewportInputTask?.cancel() viewportInputTask = nil viewportInputQueue.removeAll() + navigationInterceptor = nil let targetID = targetID self.connection = nil self.targetID = nil diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift new file mode 100644 index 00000000000..31edec9d0c1 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift @@ -0,0 +1,189 @@ +import Foundation + +/// Applies cmux navigation policy while Chromium document requests and child pages are paused. +@MainActor +final class ChromiumNavigationInterceptor { + private let targetID: String + var policyHandler: BrowserEngineNavigationPolicyHandler? + private var mainFrameID: String? + + init( + targetID: String, + policyHandler: BrowserEngineNavigationPolicyHandler? + ) { + self.targetID = targetID + self.policyHandler = policyHandler + } + + func install(connection: CDPConnection, sessionID: String) async throws { + let frameTree = try await connection.send( + method: "Page.getFrameTree", + sessionID: sessionID + ) + mainFrameID = frameTree.objectValue?["frameTree"]? + .objectValue?["frame"]? + .objectValue?["id"]? + .stringValue + + _ = try await connection.send( + method: "Fetch.enable", + parameters: [ + "patterns": .array([ + .object([ + "urlPattern": .string("*"), + "resourceType": .string("Document"), + "requestStage": .string("Request"), + ]), + ]), + ], + sessionID: sessionID + ) + _ = try await connection.send( + method: "Target.setAutoAttach", + parameters: [ + "autoAttach": .bool(true), + "waitForDebuggerOnStart": .bool(true), + "flatten": .bool(true), + ], + sessionID: sessionID + ) + } + + func handle( + _ event: CDPEvent, + connection: CDPConnection, + sessionID: String + ) async throws -> Bool { + switch event.method { + case "Fetch.requestPaused": + try await handlePausedRequest(event, connection: connection, sessionID: sessionID) + return true + case "Page.windowOpen": + routeWindowOpen(event) + return true + case "Target.attachedToTarget": + return try await handleAttachedTarget(event, connection: connection) + case "Page.frameNavigated": + updateMainFrameID(event) + return false + default: + return false + } + } + + private func handlePausedRequest( + _ event: CDPEvent, + connection: CDPConnection, + sessionID: String + ) async throws { + guard let requestID = event.parameters["requestId"]?.stringValue else { + throw BrowserEngineSessionError.chromiumProtocol( + "Chromium paused a request without an identifier." + ) + } + guard event.parameters["resourceType"]?.stringValue == "Document", + event.parameters["frameId"]?.stringValue == mainFrameID, + let request = navigationRequest(from: event.parameters["request"]?.objectValue) else { + try await continueRequest(requestID, connection: connection, sessionID: sessionID) + return + } + + let policyRequest = BrowserEngineNavigationRequest( + request: request, + disposition: .currentTab + ) + switch policyHandler?(policyRequest) ?? .allow { + case .allow: + try await continueRequest(requestID, connection: connection, sessionID: sessionID) + case .cancel: + _ = try await connection.send( + method: "Fetch.failRequest", + parameters: [ + "requestId": .string(requestID), + "errorReason": .string("Aborted"), + ], + sessionID: sessionID + ) + } + } + + private func routeWindowOpen(_ event: CDPEvent) { + guard let rawURL = event.parameters["url"]?.stringValue, + let url = URL(string: rawURL) else { + return + } + _ = policyHandler?(BrowserEngineNavigationRequest( + request: URLRequest(url: url), + disposition: .newTab + )) + } + + private func handleAttachedTarget( + _ event: CDPEvent, + connection: CDPConnection + ) async throws -> Bool { + guard let childSessionID = event.parameters["sessionId"]?.stringValue, + let targetInfo = event.parameters["targetInfo"]?.objectValue else { + return false + } + let isOwnedPage = targetInfo["type"]?.stringValue == "page" && + targetInfo["openerId"]?.stringValue == targetID + if isOwnedPage, let childTargetID = targetInfo["targetId"]?.stringValue { + _ = try await connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(childTargetID)] + ) + } else { + _ = try await connection.send( + method: "Runtime.runIfWaitingForDebugger", + sessionID: childSessionID + ) + } + return true + } + + private func updateMainFrameID(_ event: CDPEvent) { + guard let frame = event.parameters["frame"]?.objectValue, + frame["parentId"] == nil, + let frameID = frame["id"]?.stringValue else { + return + } + mainFrameID = frameID + } + + private func navigationRequest( + from payload: [String: CDPJSONValue]? + ) -> URLRequest? { + guard let payload, + let rawURL = payload["url"]?.stringValue, + let url = URL(string: rawURL) else { + return nil + } + var request = URLRequest(url: url) + if let method = payload["method"]?.stringValue, !method.isEmpty { + request.httpMethod = method + } + if let headers = payload["headers"]?.objectValue { + for (field, value) in headers { + guard let stringValue = value.stringValue else { continue } + request.setValue(stringValue, forHTTPHeaderField: field) + } + } + if let postData = payload["postData"]?.stringValue { + request.httpBody = Data(postData.utf8) + } + return request + } + + private func continueRequest( + _ requestID: String, + connection: CDPConnection, + sessionID: String + ) async throws { + _ = try await connection.send( + method: "Fetch.continueRequest", + parameters: ["requestId": .string(requestID)], + sessionID: sessionID + ) + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift index f1e6c572114..9261eca4ee8 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumNavigationInterceptorTests.swift @@ -63,6 +63,39 @@ struct ChromiumNavigationInterceptorTests { await connection.close() } + @Test + func continuesSubframeDocumentWithoutApplyingTopLevelPolicy() async throws { + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + var policyInvocationCount = 0 + let interceptor = ChromiumNavigationInterceptor( + targetID: "opener-target", + policyHandler: { _ in + policyInvocationCount += 1 + return .cancel + } + ) + try await interceptor.install(connection: connection, sessionID: "opener-session") + + _ = try await interceptor.handle( + requestPausedEvent( + url: "http://subframe.example/embedded", + frameID: "child-frame" + ), + connection: connection, + sessionID: "opener-session" + ) + + #expect(policyInvocationCount == 0) + let commands = await transport.commands() + #expect(commands.contains { command in + command["method"] == .string("Fetch.continueRequest") && + command["params"]?.objectValue?["requestId"] == .string("paused-request") + }) + await connection.close() + } + @Test func routesAndClosesNewWindowBeforeItCanResume() async throws { let transport = NavigationTestCDPTransport() @@ -126,12 +159,53 @@ struct ChromiumNavigationInterceptorTests { await connection.close() } - private func requestPausedEvent(url: String) -> CDPEvent { + @Test + func resumesNonPageTargetsPausedByAutoAttach() async throws { + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + let interceptor = ChromiumNavigationInterceptor( + targetID: "opener-target", + policyHandler: nil + ) + try await interceptor.install(connection: connection, sessionID: "opener-session") + + let handled = try await interceptor.handle( + CDPEvent( + method: "Target.attachedToTarget", + parameters: [ + "sessionId": .string("worker-session"), + "targetInfo": .object([ + "targetId": .string("worker-target"), + "type": .string("worker"), + "url": .string("https://example.com/worker.js"), + ]), + "waitingForDebugger": .bool(true), + ], + sessionID: "opener-session" + ), + connection: connection, + sessionID: "opener-session" + ) + + #expect(handled) + let commands = await transport.commands() + #expect(commands.contains { command in + command["method"] == .string("Runtime.runIfWaitingForDebugger") && + command["sessionId"] == .string("worker-session") + }) + await connection.close() + } + + private func requestPausedEvent( + url: String, + frameID: String = "main-frame" + ) -> CDPEvent { CDPEvent( method: "Fetch.requestPaused", parameters: [ "requestId": .string("paused-request"), - "frameId": .string("main-frame"), + "frameId": .string(frameID), "resourceType": .string("Document"), "request": .object([ "url": .string(url), diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index cc4822c828d..dbc4c4823a5 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -4259,6 +4259,7 @@ final class BrowserPanel: Panel, ObservableObject { self.webViewDidRequestClose?() } self.uiDelegate = browserUIDelegate + configureEngineNavigationPolicy() if engineKind == .webKit { bindWebView(webView) @@ -4331,6 +4332,61 @@ final class BrowserPanel: Panel, ObservableObject { webView.onMouseForwardButton = { [weak self] in self?.goForward() } } + private func configureEngineNavigationPolicy() { + guard let chromiumSession = engineSessionStorage as? ChromiumBrowserEngineSession else { + return + } + chromiumSession.navigationPolicyHandler = { [weak self] request in + self?.engineNavigationDecision(for: request) ?? .cancel + } + } + + private func engineNavigationDecision( + for navigation: BrowserEngineNavigationRequest + ) -> BrowserEngineNavigationDecision { + guard let url = navigation.request.url else { return .cancel } + let insecureIntent: BrowserInsecureHTTPNavigationIntent = switch navigation.disposition { + case .currentTab: .currentTab + case .newTab: .newTab + } + if shouldBlockInsecureHTTPNavigation(to: url) { + presentInsecureHTTPAlert( + for: navigation.request, + intent: insecureIntent, + recordTypedNavigation: false + ) + return .cancel + } + if browserShouldRouteExternalNavigation(url) { + browserHandleExternalNavigation( + url, + source: "chromiumPolicy", + webView: webView, + loadFallbackRequest: { [weak self] request in + self?.requestNavigation(request, intent: .currentTab) + }, + presentAlert: { [weak self] alert, webView, completion, cancel in + guard let self else { + cancel() + return + } + self.presentBrowserAlert( + alert, + in: webView, + completion: completion, + cancel: cancel + ) + } + ) + return .cancel + } + if navigation.disposition == .newTab { + requestNavigation(navigation.request, intent: .newTab) + return .cancel + } + return .allow + } + private static func makeEngineSession( kind: BrowserEngineKind, chromiumApplication: BrowserApplication?, @@ -4371,6 +4427,7 @@ final class BrowserPanel: Panel, ObservableObject { webView: webView, initializationScripts: engineInitializationScripts ) + configureEngineNavigationPolicy() syncEngineViewportVisibility() if engineKind == .chromium, let webView = webView as? CmuxWebView { bindChromiumViewport(webView) From 73a1deb9c6fc3798ec3b429ad10662899f0628c4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:31:15 -0700 Subject: [PATCH 27/39] Test Chromium JavaScript exception reporting --- .../Engine/ChromiumBrowserEngineSessionTests.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 31f5c29944b..7d7c2a5625b 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -170,4 +170,18 @@ struct ChromiumBrowserEngineSessionTests { session.close() } } + + @Test + func preservesJavaScriptExceptionTextWithoutExposingProtocolErrors() { + let exceptionText = "ReferenceError: missingValue is not defined" + + #expect( + BrowserEngineSessionError.chromiumJavaScriptEvaluation(exceptionText) + .localizedDescription == exceptionText + ) + #expect( + BrowserEngineSessionError.chromiumProtocol("secret protocol detail") + .localizedDescription != "secret protocol detail" + ) + } } From e4ad3a98197eee12ba4d736ad744c12596327a1b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:32:09 -0700 Subject: [PATCH 28/39] Preserve Chromium JavaScript exception text --- .../CmuxBrowser/Engine/BrowserEngineSessionError.swift | 7 ++++++- .../Engine/Chromium/ChromiumBrowserEngineSession.swift | 6 ++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift index 50797293523..c917ff12030 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSessionError.swift @@ -14,13 +14,16 @@ public enum BrowserEngineSessionError: LocalizedError, Sendable { /// A Chrome DevTools Protocol operation failed. case chromiumProtocol(String) + /// Chromium reported an exception from caller-supplied JavaScript. + case chromiumJavaScriptEvaluation(String) + /// A Chromium process ended before exposing its DevTools endpoint. case chromiumLaunch(String) /// A browser returned a JavaScript value that cannot cross the engine boundary. case unsupportedJavaScriptValue - /// A localized, user-facing description that omits protocol internals. + /// A user-facing description that omits protocol internals while preserving page script exceptions. public var errorDescription: String? { switch self { case .emptyScreenshot: @@ -43,6 +46,8 @@ public enum BrowserEngineSessionError: LocalizedError, Sendable { localized: "browser.chromium.error.operationFailed", defaultValue: "Chromium stopped responding. Reload the page or switch to WebKit." ) + case .chromiumJavaScriptEvaluation(let message): + return message case .chromiumLaunch: return String( localized: "browser.chromium.error.launchFailed", diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index bae6025b2a3..6af7a38d20f 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -223,8 +223,10 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { ) let object = response.objectValue ?? [:] if let exception = object["exceptionDetails"]?.objectValue { - let text = exception["text"]?.stringValue ?? "JavaScript evaluation failed." - throw BrowserEngineSessionError.chromiumProtocol(text) + if let text = exception["text"]?.stringValue, !text.isEmpty { + throw BrowserEngineSessionError.chromiumJavaScriptEvaluation(text) + } + throw BrowserEngineSessionError.chromiumProtocol("JavaScript evaluation failed.") } guard let remoteObject = object["result"]?.objectValue else { return .undefined } if remoteObject["type"]?.stringValue == "undefined" { return .undefined } From e87c303ecdf7745dda2c771e1805f3f2e55cc061 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:43:30 -0700 Subject: [PATCH 29/39] Test Chromium history completion tracking --- .../ChromiumBrowserEngineSessionTests.swift | 46 ++++++++++++++++++ cmuxTests/BrowserPanelTests.swift | 47 +++++++++++++++++++ 2 files changed, 93 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index 7d7c2a5625b..be11d2ae6cc 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -137,6 +137,52 @@ struct ChromiumBrowserEngineSessionTests { await connection.close() } + @Test + func navigationCompletionRevisionAdvancesOnlyForCompletedLoads() async { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let transport = NavigationTestCDPTransport() + let connection = CDPConnection(transport: transport) + let sessionID = "test-session" + await connection.connect() + session.connection = connection + session.cdpSessionID = sessionID + defer { session.close() } + + let initialRevision = session.state.navigationCompletionRevision + await session.handle( + CDPEvent( + method: "Page.loadEventFired", + parameters: [:], + sessionID: sessionID + ), + connection: connection, + sessionID: sessionID + ) + let completedRevision = session.state.navigationCompletionRevision + + await session.handle( + CDPEvent( + method: "Runtime.bindingCalled", + parameters: [ + "name": .string("__cmuxChromiumTitleChanged"), + "payload": .string("Updated after load"), + ], + sessionID: sessionID + ), + connection: connection, + sessionID: sessionID + ) + + #expect(completedRevision == initialRevision + 1) + #expect(session.state.navigationCompletionRevision == completedRevision) + await connection.close() + } + @Test func rejectsNavigationRequestsWhoseSemanticsCannotBePreserved() { let url = URL(string: "https://example.com/submit")! diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 8bbdf05b18f..97fa13e25fc 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -4364,3 +4364,50 @@ final class OmnibarNativeTextFieldCaretTests: XCTestCase { ) } } + +@MainActor +@Suite(.serialized) +struct BrowserPanelChromiumHistoryRecordingTests { + @Test + func recordsEachCompletedNavigationOnceDespiteTitleUpdates() throws { + let profileStore = BrowserProfileStore.shared + let profile = try #require(profileStore.createProfile( + named: "Chromium-History-\(UUID().uuidString)" + )) + let historyStore = profileStore.historyStore(for: profile.id) + historyStore.clearHistory() + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: profile.id, + renderInitialNavigation: false, + engineSelection: BrowserEngineSelection(kind: .chromium) + ) + defer { + panel.close() + historyStore.clearHistory() + _ = profileStore.deleteProfile(id: profile.id) + } + let url = try #require(URL(string: "https://example.com/chromium-history")) + + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Initial title", + navigationCompletionRevision: 1 + )) + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Updated by the page", + navigationCompletionRevision: 1 + )) + + #expect(historyStore.entries.first?.visitCount == 1) + + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Reloaded title", + navigationCompletionRevision: 2 + )) + + #expect(historyStore.entries.first?.visitCount == 2) + } +} From 49d45af260ba5ff64ede27c80f6ce96b183d9fb7 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:49:22 -0700 Subject: [PATCH 30/39] Record Chromium history on navigation completion --- .../CmuxBrowser/Engine/BrowserEngineState.swift | 6 ++++++ .../Chromium/ChromiumBrowserEngineSession.swift | 7 ++++++- Sources/Panels/BrowserPanel.swift | 11 +++++++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift index 056c4b9dd62..2bf58929e45 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineState.swift @@ -17,6 +17,9 @@ public struct BrowserEngineState: Equatable, Sendable { /// Whether the engine can traverse forward in native history. public var canGoForward: Bool + /// A monotonic revision advanced after each completed top-level navigation. + public var navigationCompletionRevision: UInt64 + /// A visible engine-startup or transport error, when present. public var errorMessage: String? @@ -28,6 +31,7 @@ public struct BrowserEngineState: Equatable, Sendable { /// - isLoading: Whether a top-level navigation is active. /// - canGoBack: Whether native backward history is available. /// - canGoForward: Whether native forward history is available. + /// - navigationCompletionRevision: The number of completed top-level navigations. /// - errorMessage: A user-facing engine error, if present. public init( url: URL? = nil, @@ -35,6 +39,7 @@ public struct BrowserEngineState: Equatable, Sendable { isLoading: Bool = false, canGoBack: Bool = false, canGoForward: Bool = false, + navigationCompletionRevision: UInt64 = 0, errorMessage: String? = nil ) { self.url = url @@ -42,6 +47,7 @@ public struct BrowserEngineState: Equatable, Sendable { self.isLoading = isLoading self.canGoBack = canGoBack self.canGoForward = canGoForward + self.navigationCompletionRevision = navigationCompletionRevision self.errorMessage = errorMessage } } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 6af7a38d20f..bad1c87b417 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -585,9 +585,14 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { ) case "Page.frameStartedLoading": updateState { $0.isLoading = true } - case "Page.frameStoppedLoading", "Page.loadEventFired": + case "Page.frameStoppedLoading": updateState { $0.isLoading = false } + case "Page.loadEventFired": await refreshDocumentState(connection: connection, sessionID: sessionID) + updateState { state in + state.isLoading = false + state.navigationCompletionRevision &+= 1 + } case "Page.frameNavigated": if let frame = event.parameters["frame"]?.objectValue, frame["parentId"] == nil, diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index dbc4c4823a5..c2fc473ef27 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2789,6 +2789,7 @@ final class BrowserPanel: Panel, ObservableObject { private var chromiumProfileRuntime: ChromiumProfileRuntime private var engineSessionStorage: (any BrowserEngineSession)? private var engineStateTask: Task? + private var lastRecordedChromiumNavigationCompletionRevision: UInt64 = 0 var engineInitializationScripts: [String] = [] var engineInitializationScriptCounts: [AutomationInitializationScriptKind: Int] = [:] @@ -4419,6 +4420,7 @@ final class BrowserPanel: Panel, ObservableObject { private func replaceEngineSession(for webView: WKWebView) { engineStateTask?.cancel() engineStateTask = nil + lastRecordedChromiumNavigationCompletionRevision = 0 engineSessionStorage?.close() engineSessionStorage = Self.makeEngineSession( kind: engineKind, @@ -4447,7 +4449,12 @@ final class BrowserPanel: Panel, ObservableObject { } } - private func applyChromiumEngineState(_ state: BrowserEngineState) { + func applyChromiumEngineState(_ state: BrowserEngineState) { + let completedNavigation = state.navigationCompletionRevision + > lastRecordedChromiumNavigationCompletionRevision + if completedNavigation { + lastRecordedChromiumNavigationCompletionRevision = state.navigationCompletionRevision + } currentURL = state.url ?? currentURL let title = state.title.trimmingCharacters(in: .whitespacesAndNewlines) pageTitle = title @@ -4456,7 +4463,7 @@ final class BrowserPanel: Panel, ObservableObject { nativeCanGoBack = state.canGoBack nativeCanGoForward = state.canGoForward refreshNavigationAvailability() - if !state.isLoading, state.errorMessage == nil, let url = state.url { + if completedNavigation, !state.isLoading, state.errorMessage == nil, let url = state.url { historyStore.recordVisit(url: url, title: title.isEmpty ? nil : title) } } From 817a820b72614b143bab6962ed2be78caced9875 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:49:55 -0700 Subject: [PATCH 31/39] Test bounded Chromium process termination --- .../ChromiumProcessControllerTests.swift | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumProcessControllerTests.swift diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumProcessControllerTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumProcessControllerTests.swift new file mode 100644 index 00000000000..5b0e557c961 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumProcessControllerTests.swift @@ -0,0 +1,68 @@ +import Darwin +import Foundation +import Testing +@testable import CmuxBrowser + +@Suite("Chromium process controller") +struct ChromiumProcessControllerTests { + @Test + func closeForcesAProcessThatIgnoresTermination() async throws { + let temporaryDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: temporaryDirectory) } + let executableURL = temporaryDirectory.appendingPathComponent("fake-chromium") + try """ + #!/bin/sh + trap '' TERM + printf 'DevTools listening on ws://127.0.0.1:9222/devtools/browser/test\\n' >&2 + exec /bin/sleep 3600 + """.write(to: executableURL, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: executableURL.path + ) + let controller = ChromiumProcessController(launchTimeout: .seconds(2)) + let application = BrowserApplication( + bundleIdentifier: "com.cmux.test.fake-chromium", + bundleURL: temporaryDirectory, + executableURL: executableURL + ) + + _ = try await controller.start( + application: application, + userDataDirectory: temporaryDirectory.appendingPathComponent("profile", isDirectory: true) + ) + let processIdentifier = try #require(await controller.processIdentifier()) + let closeTask = Task { + await controller.close() + } + + let closedBeforeTestDeadline = await withTaskGroup(of: Bool.self) { group in + group.addTask { + await closeTask.value + return true + } + group.addTask { + do { + try await ContinuousClock().sleep(for: .seconds(2)) + } catch { + return true + } + guard !Task.isCancelled else { return true } + _ = Darwin.kill(processIdentifier, SIGKILL) + return false + } + let result = await group.next() ?? false + group.cancelAll() + await group.waitForAll() + return result + } + + #expect(closedBeforeTestDeadline) + #expect(await controller.processIdentifier() == nil) + } +} From 831165463e4d96fc7c2465cc7b2a7bddddfed075 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 06:53:20 -0700 Subject: [PATCH 32/39] Bound Chromium process termination --- .../Chromium/ChromiumProcessController.swift | 56 +++++++++++++++++-- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift index 785c2884581..7f5fc4c0402 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -1,17 +1,24 @@ +import Darwin import Foundation /// Launches and owns one headless Chromium process for a browser-engine session. actor ChromiumProcessController { private let launchTimeout: Duration + private let terminationTimeout: Duration private var process: Process? private var stderrTask: Task? private var timeoutTask: Task? + private var terminationDeadlineTask: Task? private var endpointContinuation: CheckedContinuation? private var terminationContinuations: [CheckedContinuation] = [] private var didResolveEndpoint = false - init(launchTimeout: Duration = .seconds(10)) { + init( + launchTimeout: Duration = .seconds(10), + terminationTimeout: Duration = .seconds(1) + ) { self.launchTimeout = launchTimeout + self.terminationTimeout = terminationTimeout } /// Returns the identifier of the Chromium process while it is running. @@ -48,9 +55,10 @@ actor ChromiumProcessController { process.standardInput = FileHandle.nullDevice process.standardOutput = FileHandle.nullDevice process.standardError = stderrPipe - process.terminationHandler = { [weak self] _ in + process.terminationHandler = { [weak self] process in + let processIdentifier = process.processIdentifier Task { - await self?.processDidTerminate() + await self?.processDidTerminate(processIdentifier: processIdentifier) } } do { @@ -88,14 +96,17 @@ actor ChromiumProcessController { } guard let process else { endpointContinuation = nil + resumeTerminationContinuations() return } endpointContinuation = nil + let processIdentifier = process.processIdentifier guard process.isRunning else { - self.process = nil + finishProcessTermination(processIdentifier: processIdentifier) return } process.terminate() + startTerminationDeadlineIfNeeded(processIdentifier: processIdentifier) await withCheckedContinuation { continuation in terminationContinuations.append(continuation) } @@ -128,13 +139,46 @@ actor ChromiumProcessController { } } - private func processDidTerminate() { + private func processDidTerminate(processIdentifier: Int32) { if !didResolveEndpoint { failUnresolvedEndpoint(BrowserEngineSessionError.chromiumLaunch( "Chromium stopped before opening its DevTools endpoint." )) } - process = nil + finishProcessTermination(processIdentifier: processIdentifier) + } + + private func startTerminationDeadlineIfNeeded(processIdentifier: Int32) { + guard terminationDeadlineTask == nil else { return } + terminationDeadlineTask = Task { [weak self, terminationTimeout] in + // A bounded shutdown deadline is intentional; a stuck Chromium must not block its profile forever. + do { + try await ContinuousClock().sleep(for: terminationTimeout) + } catch { + return + } + guard !Task.isCancelled else { return } + await self?.forceTerminateProcess(processIdentifier: processIdentifier) + } + } + + private func forceTerminateProcess(processIdentifier: Int32) { + guard let process, process.processIdentifier == processIdentifier else { return } + if process.isRunning { + _ = Darwin.kill(processIdentifier, SIGKILL) + } + finishProcessTermination(processIdentifier: processIdentifier) + } + + private func finishProcessTermination(processIdentifier: Int32) { + guard let process, process.processIdentifier == processIdentifier else { return } + terminationDeadlineTask?.cancel() + terminationDeadlineTask = nil + self.process = nil + resumeTerminationContinuations() + } + + private func resumeTerminationContinuations() { let continuations = terminationContinuations terminationContinuations.removeAll() for continuation in continuations { From c68f744262e3e5f0d7ad9615e890c47dd5f3794e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:11:20 -0700 Subject: [PATCH 33/39] Align browser engine code with architecture policy --- .../Engine/BrowserEngineSession.swift | 1 + .../Engine/BrowserJavaScriptValue.swift | 33 ++-- .../Engine/Chromium/CDPConnection.swift | 62 +----- .../Engine/Chromium/CDPEventSubscriber.swift | 4 + .../Engine/Chromium/CDPPendingRequest.swift | 4 + .../Chromium/CDPWebSocketTransport.swift | 9 + ...hromiumBrowserEngineSession+Viewport.swift | 187 +----------------- .../ChromiumBrowserEngineSession.swift | 43 +--- .../ChromiumIsolatedWorldConfiguration.swift | 11 ++ .../Chromium/ChromiumProcessController.swift | 5 +- .../ChromiumScreencastConfiguration.swift | 14 ++ .../ChromiumScreencastTransition.swift | 9 + .../ChromiumViewportGestureTransition.swift | 4 + .../ChromiumViewportInputCoalescingKind.swift | 4 + .../ChromiumViewportInputCommand.swift | 89 +++++++++ .../Chromium/ChromiumViewportInputQueue.swift | 88 +++++++++ .../URLSessionCDPWebSocketTransport.swift | 39 ++++ .../WebKitBrowserEngineCookieError.swift | 4 + .../Engine/WebKitBrowserEngineSession.swift | 6 +- .../BufferedCDPWebSocketTransport.swift | 57 ++++++ .../Engine/CDPConnectionTests.swift | 68 ------- .../ChromiumBrowserEngineSessionTests.swift | 18 +- .../ChromiumViewportInputQueueTests.swift | 8 +- .../NoResponseCDPWebSocketTransport.swift | 15 ++ .../BrowserPanel+AutomationRecovery.swift | 3 +- Sources/Panels/BrowserPanel.swift | 19 +- Sources/TerminalController.swift | 176 +++++++++-------- cmux.xcodeproj/project.pbxproj | 4 + ...erPanelChromiumHistoryRecordingTests.swift | 56 ++++++ cmuxTests/BrowserPanelTests.swift | 47 ----- 30 files changed, 572 insertions(+), 515 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEventSubscriber.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPPendingRequest.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPWebSocketTransport.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumIsolatedWorldConfiguration.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastConfiguration.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastTransition.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportGestureTransition.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCoalescingKind.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCommand.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputQueue.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/URLSessionCDPWebSocketTransport.swift create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineCookieError.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NoResponseCDPWebSocketTransport.swift create mode 100644 cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift index f0c4fc5395c..871d03b0596 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserEngineSession.swift @@ -101,6 +101,7 @@ public protocol BrowserEngineSession: AnyObject { func close() } +/// Convenience behavior shared by every browser engine session. public extension BrowserEngineSession { /// Evaluates JavaScript in the page's main world. /// diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift index 1793a778119..f61b3a9f625 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/BrowserJavaScriptValue.swift @@ -29,24 +29,33 @@ public enum BrowserJavaScriptValue: Equatable, Sendable { /// - Parameter foundationValue: A WebKit JavaScript result. /// - Throws: ``BrowserEngineSessionError/unsupportedJavaScriptValue`` when the result is not transferable. public init(foundationValue: Any?) throws { - switch foundationValue { - case nil: + guard let foundationValue else { self = .undefined - case is NSNull: + return + } + if foundationValue is NSNull { self = .null - case let value as NSNumber where CFGetTypeID(value) == CFBooleanGetTypeID(): - self = .bool(value.boolValue) - case let value as NSNumber: - self = .number(value.doubleValue) - case let value as String: + return + } + if let value = foundationValue as? NSNumber { + self = CFGetTypeID(value) == CFBooleanGetTypeID() + ? .bool(value.boolValue) + : .number(value.doubleValue) + return + } + if let value = foundationValue as? String { self = .string(value) - case let values as [Any]: + return + } + if let values = foundationValue as? [Any] { self = try .array(values.map { try BrowserJavaScriptValue(foundationValue: $0) }) - case let values as [String: Any]: + return + } + if let values = foundationValue as? [String: Any] { self = try .object(values.mapValues { try BrowserJavaScriptValue(foundationValue: $0) }) - default: - throw BrowserEngineSessionError.unsupportedJavaScriptValue + return } + throw BrowserEngineSessionError.unsupportedJavaScriptValue } /// The Foundation representation expected by existing browser automation callers. diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index 1e7d3b67566..6ca31346083 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -1,69 +1,15 @@ import Foundation -/// Sendable transport boundary for the browser-level DevTools WebSocket. -protocol CDPWebSocketTransport: Sendable { - func resume() - func send(_ data: Data) async throws - func receive() async throws -> Data - func cancel() -} - -private final class URLSessionCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { - private let session: URLSession - private let task: URLSessionWebSocketTask - - init(url: URL) { - let configuration = URLSessionConfiguration.ephemeral - let session = URLSession(configuration: configuration) - self.session = session - self.task = session.webSocketTask(with: url) - } - - func resume() { - task.resume() - } - - func send(_ data: Data) async throws { - try await task.send(.data(data)) - } - - func receive() async throws -> Data { - switch try await task.receive() { - case .data(let data): - return data - case .string(let value): - return Data(value.utf8) - @unknown default: - return Data() - } - } - - func cancel() { - task.cancel(with: .goingAway, reason: nil) - session.invalidateAndCancel() - } -} - /// Owns one browser-level Chrome DevTools Protocol WebSocket connection. actor CDPConnection { - private struct PendingRequest { - let continuation: CheckedContinuation - let timeoutTask: Task - } - - private struct EventSubscriber { - let sessionID: String - let continuation: AsyncStream.Continuation - } - private let transport: any CDPWebSocketTransport private let requestTimeout: Duration private let encoder = JSONEncoder() private let decoder = JSONDecoder() private var receiveTask: Task? private var nextRequestID = 1 - private var pendingRequests: [Int: PendingRequest] = [:] - private var eventSubscribers: [UUID: EventSubscriber] = [:] + private var pendingRequests: [Int: CDPPendingRequest] = [:] + private var eventSubscribers: [UUID: CDPEventSubscriber] = [:] private var isClosed = false init(url: URL, requestTimeout: Duration = .seconds(10)) { @@ -97,7 +43,7 @@ actor CDPConnection { continuation.finish() return stream } - eventSubscribers[subscriberID] = EventSubscriber( + eventSubscribers[subscriberID] = CDPEventSubscriber( sessionID: sessionID, continuation: continuation ) @@ -147,7 +93,7 @@ actor CDPConnection { ) ) } - pendingRequests[requestID] = PendingRequest( + pendingRequests[requestID] = CDPPendingRequest( continuation: continuation, timeoutTask: timeoutTask ) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEventSubscriber.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEventSubscriber.swift new file mode 100644 index 00000000000..a2ac5fced99 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPEventSubscriber.swift @@ -0,0 +1,4 @@ +struct CDPEventSubscriber { + let sessionID: String + let continuation: AsyncStream.Continuation +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPPendingRequest.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPPendingRequest.swift new file mode 100644 index 00000000000..74809a67b97 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPPendingRequest.swift @@ -0,0 +1,4 @@ +struct CDPPendingRequest { + let continuation: CheckedContinuation + let timeoutTask: Task +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPWebSocketTransport.swift new file mode 100644 index 00000000000..d7707208df3 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPWebSocketTransport.swift @@ -0,0 +1,9 @@ +import Foundation + +/// Sendable transport boundary for the browser-level DevTools WebSocket. +protocol CDPWebSocketTransport: Sendable { + func resume() + func send(_ data: Data) async throws + func receive() async throws -> Data + func cancel() +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift index 4b3ec9d5a6d..0f93b4e2bc1 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession+Viewport.swift @@ -1,184 +1,5 @@ import Foundation -struct ChromiumViewportInputCommand: Equatable { - enum CoalescingKind: Equatable { - case mouseMove - case mouseWheel - } - - enum GestureTransition: Equatable { - case began(String) - case ended(String) - } - - let method: String - var parameters: [String: CDPJSONValue] - let coalescingKind: CoalescingKind? - - var gestureTransition: GestureTransition? { - switch (method, parameters["type"], parameters["code"], parameters["button"]) { - case ("Input.dispatchKeyEvent", .string("keyDown"), .string(let code), _): - return .began("key:\(code)") - case ("Input.dispatchKeyEvent", .string("keyUp"), .string(let code), _): - return .ended("key:\(code)") - case ("Input.dispatchMouseEvent", .string("mousePressed"), _, .string(let button)): - return .began("mouse:\(button)") - case ("Input.dispatchMouseEvent", .string("mouseReleased"), _, .string(let button)): - return .ended("mouse:\(button)") - default: - return nil - } - } - - static func mouse(parameters: [String: CDPJSONValue]) -> Self { - let coalescingKind: CoalescingKind? = switch parameters["type"] { - case .string("mouseMoved"): - .mouseMove - case .string("mouseWheel"): - .mouseWheel - default: - nil - } - return Self( - method: "Input.dispatchMouseEvent", - parameters: parameters, - coalescingKind: coalescingKind - ) - } - - static func key(parameters: [String: CDPJSONValue]) -> Self { - Self( - method: "Input.dispatchKeyEvent", - parameters: parameters, - coalescingKind: nil - ) - } - - static func text(parameters: [String: CDPJSONValue]) -> Self { - Self( - method: "Input.insertText", - parameters: parameters, - coalescingKind: nil - ) - } - - static func composition(parameters: [String: CDPJSONValue]) -> Self { - Self( - method: "Input.imeSetComposition", - parameters: parameters, - coalescingKind: nil - ) - } - - func coalescing(with newer: Self) -> Self { - guard coalescingKind == .mouseWheel, newer.coalescingKind == .mouseWheel else { - return newer - } - var parameters = newer.parameters - parameters["deltaX"] = .number(number(for: "deltaX") + newer.number(for: "deltaX")) - parameters["deltaY"] = .number(number(for: "deltaY") + newer.number(for: "deltaY")) - return Self( - method: newer.method, - parameters: parameters, - coalescingKind: newer.coalescingKind - ) - } - - private func number(for key: String) -> Double { - guard case .number(let value) = parameters[key] else { return 0 } - return value - } -} - -struct ChromiumViewportInputQueue { - static let maximumPendingCommands = 64 - - private(set) var commands: [ChromiumViewportInputCommand] = [] - - var count: Int { commands.count } - - /// Enqueues input while retaining the newest complete gestures under pressure. - /// - /// - Returns: `false` only when the queue contains no fully closed gesture that - /// can be discarded without separating a press from its release. - @discardableResult - mutating func enqueue(_ command: ChromiumViewportInputCommand) -> Bool { - if let coalescingKind = command.coalescingKind { - let currentOrderingSegmentStart = commands.lastIndex(where: { - $0.coalescingKind == nil - }).map { $0 + 1 } ?? commands.startIndex - if let existingIndex = commands[currentOrderingSegmentStart...].firstIndex(where: { - $0.coalescingKind == coalescingKind - }) { - commands[existingIndex] = commands[existingIndex].coalescing(with: command) - return true - } - } - - if commands.count == Self.maximumPendingCommands { - if let coalescibleIndex = commands.firstIndex(where: { - $0.coalescingKind != nil - }) { - commands.remove(at: coalescibleIndex) - } else if case .ended(let gesture)? = command.gestureTransition, - commands.contains(where: { - $0.gestureTransition == .began(gesture) - }) { - commands.removeAll(where: { - $0.gestureTransition == .began(gesture) - }) - commands.append(command) - return true - } else if !discardOldestCompleteGesture() { - return false - } - } - commands.append(command) - return true - } - - mutating func popFirst() -> ChromiumViewportInputCommand? { - guard !commands.isEmpty else { return nil } - return commands.removeFirst() - } - - mutating func removeAll() { - commands.removeAll(keepingCapacity: true) - } - - private mutating func discardOldestCompleteGesture() -> Bool { - for startIndex in commands.indices { - guard case .began(let firstGesture)? = commands[startIndex].gestureTransition else { - continue - } - var activeGestures: Set = [firstGesture] - var endIndex = commands.index(after: startIndex) - var isBalanced = true - while endIndex < commands.endIndex { - switch commands[endIndex].gestureTransition { - case .began(let gesture): - activeGestures.insert(gesture) - case .ended(let gesture): - if activeGestures.contains(gesture) { - activeGestures.remove(gesture) - } else { - isBalanced = false - } - case nil: - break - } - guard isBalanced else { break } - if activeGestures.isEmpty { - commands.removeSubrange(startIndex...endIndex) - return true - } - endIndex = commands.index(after: endIndex) - } - } - return false - } -} - extension ChromiumBrowserEngineSession { func handleViewportMessage(_ payload: [String: Any]) { guard let type = payload["type"] as? String else { return } @@ -247,7 +68,7 @@ extension ChromiumBrowserEngineSession { parameters["deltaX"] = .number(payload["deltaX"] as? Double ?? 0) parameters["deltaY"] = .number(payload["deltaY"] as? Double ?? 0) } - enqueueViewportInput(.mouse(parameters: parameters)) + enqueueViewportInput(ChromiumViewportInputCommand(mouseParameters: parameters)) } private func dispatchKey(_ payload: [String: Any]) { @@ -259,19 +80,19 @@ extension ChromiumBrowserEngineSession { "text": .string(payload["text"] as? String ?? ""), "modifiers": .number(Double(payload["modifiers"] as? Int ?? 0)), ] - enqueueViewportInput(.key(parameters: parameters)) + enqueueViewportInput(ChromiumViewportInputCommand(keyParameters: parameters)) } private func dispatchText(_ payload: [String: Any]) { guard let text = payload["text"] as? String, !text.isEmpty else { return } - enqueueViewportInput(.text(parameters: ["text": .string(text)])) + enqueueViewportInput(ChromiumViewportInputCommand(textParameters: ["text": .string(text)])) } private func dispatchComposition(_ payload: [String: Any]) { guard let text = payload["text"] as? String else { return } let selectionStart = max(0, payload["selectionStart"] as? Int ?? text.utf16.count) let selectionEnd = max(selectionStart, payload["selectionEnd"] as? Int ?? selectionStart) - enqueueViewportInput(.composition(parameters: [ + enqueueViewportInput(ChromiumViewportInputCommand(compositionParameters: [ "text": .string(text), "selectionStart": .number(Double(selectionStart)), "selectionEnd": .number(Double(selectionEnd)), diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index bad1c87b417..8f283765d2b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -305,7 +305,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } let isolatedWorld = try await connection.send( method: "Page.createIsolatedWorld", - parameters: Self.isolatedWorldParameters(frameID: frameID), + parameters: ChromiumIsolatedWorldConfiguration(frameID: frameID).parameters, sessionID: sessionID ) guard let contextID = isolatedWorld.objectValue?["executionContextId"]?.intValue else { @@ -314,14 +314,6 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { return contextID } - static func isolatedWorldParameters(frameID: String) -> [String: CDPJSONValue] { - [ - "frameId": .string(frameID), - "worldName": .string("cmux.browser.automation"), - "grantUniversalAccess": .bool(true), - ] - } - /// Captures the current Chromium viewport through CDP. public func captureScreenshot() async throws -> Data { if connection == nil { @@ -469,36 +461,15 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } } - static func screencastParameters( - viewportWidth: Int, - viewportHeight: Int - ) -> [String: CDPJSONValue] { - [ - "format": .string("jpeg"), - "quality": .number(75), - "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), - "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), - "everyNthFrame": .number(2), - ] - } - - static func screencastMethod( - isViewportVisible: Bool, - isScreencastActive: Bool - ) -> String? { - guard isViewportVisible != isScreencastActive else { return nil } - return isViewportVisible ? "Page.startScreencast" : "Page.stopScreencast" - } - private func startScreencastUpdateIfNeeded() { guard !isClosed, connection != nil, cdpSessionID != nil, screencastUpdateTask == nil, - Self.screencastMethod( + ChromiumScreencastTransition( isViewportVisible: isViewportVisible, isScreencastActive: isScreencastActive - ) != nil else { + ).method != nil else { return } screencastUpdateTask = Task { [weak self] in @@ -512,17 +483,17 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { let connection, let cdpSessionID { let targetVisible = isViewportVisible - guard let method = Self.screencastMethod( + guard let method = ChromiumScreencastTransition( isViewportVisible: targetVisible, isScreencastActive: isScreencastActive - ) else { + ).method else { return } let parameters = targetVisible - ? Self.screencastParameters( + ? ChromiumScreencastConfiguration( viewportWidth: viewportWidth, viewportHeight: viewportHeight - ) + ).parameters : [:] do { _ = try await connection.send( diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumIsolatedWorldConfiguration.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumIsolatedWorldConfiguration.swift new file mode 100644 index 00000000000..bd25f4d179f --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumIsolatedWorldConfiguration.swift @@ -0,0 +1,11 @@ +struct ChromiumIsolatedWorldConfiguration { + let frameID: String + + var parameters: [String: CDPJSONValue] { + [ + "frameId": .string(frameID), + "worldName": .string("cmux.browser.automation"), + "grantUniversalAccess": .bool(true), + ] + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift index 7f5fc4c0402..0a306644873 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProcessController.swift @@ -3,6 +3,7 @@ import Foundation /// Launches and owns one headless Chromium process for a browser-engine session. actor ChromiumProcessController { + private let fileManager: FileManager private let launchTimeout: Duration private let terminationTimeout: Duration private var process: Process? @@ -14,9 +15,11 @@ actor ChromiumProcessController { private var didResolveEndpoint = false init( + fileManager: FileManager = .default, launchTimeout: Duration = .seconds(10), terminationTimeout: Duration = .seconds(1) ) { + self.fileManager = fileManager self.launchTimeout = launchTimeout self.terminationTimeout = terminationTimeout } @@ -33,7 +36,7 @@ actor ChromiumProcessController { } didResolveEndpoint = false endpointContinuation = nil - try FileManager.default.createDirectory( + try fileManager.createDirectory( at: userDataDirectory, withIntermediateDirectories: true ) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastConfiguration.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastConfiguration.swift new file mode 100644 index 00000000000..918a383ec7d --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastConfiguration.swift @@ -0,0 +1,14 @@ +struct ChromiumScreencastConfiguration { + let viewportWidth: Int + let viewportHeight: Int + + var parameters: [String: CDPJSONValue] { + [ + "format": .string("jpeg"), + "quality": .number(75), + "maxWidth": .number(Double(max(viewportWidth, 1) * 2)), + "maxHeight": .number(Double(max(viewportHeight, 1) * 2)), + "everyNthFrame": .number(2), + ] + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastTransition.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastTransition.swift new file mode 100644 index 00000000000..698842f0727 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumScreencastTransition.swift @@ -0,0 +1,9 @@ +struct ChromiumScreencastTransition { + let isViewportVisible: Bool + let isScreencastActive: Bool + + var method: String? { + guard isViewportVisible != isScreencastActive else { return nil } + return isViewportVisible ? "Page.startScreencast" : "Page.stopScreencast" + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportGestureTransition.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportGestureTransition.swift new file mode 100644 index 00000000000..25c1cc90cde --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportGestureTransition.swift @@ -0,0 +1,4 @@ +enum ChromiumViewportGestureTransition: Equatable { + case began(String) + case ended(String) +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCoalescingKind.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCoalescingKind.swift new file mode 100644 index 00000000000..cbd3cb1d199 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCoalescingKind.swift @@ -0,0 +1,4 @@ +enum ChromiumViewportInputCoalescingKind: Equatable { + case mouseMove + case mouseWheel +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCommand.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCommand.swift new file mode 100644 index 00000000000..33fcd503d87 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputCommand.swift @@ -0,0 +1,89 @@ +struct ChromiumViewportInputCommand: Equatable { + let method: String + var parameters: [String: CDPJSONValue] + let coalescingKind: ChromiumViewportInputCoalescingKind? + + var gestureTransition: ChromiumViewportGestureTransition? { + switch (method, parameters["type"], parameters["code"], parameters["button"]) { + case ("Input.dispatchKeyEvent", .string("keyDown"), .string(let code), _): + return .began("key:\(code)") + case ("Input.dispatchKeyEvent", .string("keyUp"), .string(let code), _): + return .ended("key:\(code)") + case ("Input.dispatchMouseEvent", .string("mousePressed"), _, .string(let button)): + return .began("mouse:\(button)") + case ("Input.dispatchMouseEvent", .string("mouseReleased"), _, .string(let button)): + return .ended("mouse:\(button)") + default: + return nil + } + } + + private init( + method: String, + parameters: [String: CDPJSONValue], + coalescingKind: ChromiumViewportInputCoalescingKind? + ) { + self.method = method + self.parameters = parameters + self.coalescingKind = coalescingKind + } + + init(mouseParameters parameters: [String: CDPJSONValue]) { + let coalescingKind: ChromiumViewportInputCoalescingKind? = switch parameters["type"] { + case .string("mouseMoved"): + .mouseMove + case .string("mouseWheel"): + .mouseWheel + default: + nil + } + self.init( + method: "Input.dispatchMouseEvent", + parameters: parameters, + coalescingKind: coalescingKind + ) + } + + init(keyParameters parameters: [String: CDPJSONValue]) { + self.init( + method: "Input.dispatchKeyEvent", + parameters: parameters, + coalescingKind: nil + ) + } + + init(textParameters parameters: [String: CDPJSONValue]) { + self.init( + method: "Input.insertText", + parameters: parameters, + coalescingKind: nil + ) + } + + init(compositionParameters parameters: [String: CDPJSONValue]) { + self.init( + method: "Input.imeSetComposition", + parameters: parameters, + coalescingKind: nil + ) + } + + func coalescing(with newer: Self) -> Self { + guard coalescingKind == .mouseWheel, newer.coalescingKind == .mouseWheel else { + return newer + } + var parameters = newer.parameters + parameters["deltaX"] = .number(number(for: "deltaX") + newer.number(for: "deltaX")) + parameters["deltaY"] = .number(number(for: "deltaY") + newer.number(for: "deltaY")) + return Self( + method: newer.method, + parameters: parameters, + coalescingKind: newer.coalescingKind + ) + } + + private func number(for key: String) -> Double { + guard case .number(let value) = parameters[key] else { return 0 } + return value + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputQueue.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputQueue.swift new file mode 100644 index 00000000000..be6399ffe90 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumViewportInputQueue.swift @@ -0,0 +1,88 @@ +struct ChromiumViewportInputQueue { + static let maximumPendingCommands = 64 + + private(set) var commands: [ChromiumViewportInputCommand] = [] + + var count: Int { commands.count } + + /// Enqueues input while retaining the newest complete gestures under pressure. + /// + /// - Returns: `false` only when the queue contains no fully closed gesture that + /// can be discarded without separating a press from its release. + @discardableResult + mutating func enqueue(_ command: ChromiumViewportInputCommand) -> Bool { + if let coalescingKind = command.coalescingKind { + let currentOrderingSegmentStart = commands.lastIndex(where: { + $0.coalescingKind == nil + }).map { $0 + 1 } ?? commands.startIndex + if let existingIndex = commands[currentOrderingSegmentStart...].firstIndex(where: { + $0.coalescingKind == coalescingKind + }) { + commands[existingIndex] = commands[existingIndex].coalescing(with: command) + return true + } + } + + if commands.count == Self.maximumPendingCommands { + if let coalescibleIndex = commands.firstIndex(where: { + $0.coalescingKind != nil + }) { + commands.remove(at: coalescibleIndex) + } else if case .ended(let gesture)? = command.gestureTransition, + commands.contains(where: { + $0.gestureTransition == .began(gesture) + }) { + commands.removeAll(where: { + $0.gestureTransition == .began(gesture) + }) + commands.append(command) + return true + } else if !discardOldestCompleteGesture() { + return false + } + } + commands.append(command) + return true + } + + mutating func popFirst() -> ChromiumViewportInputCommand? { + guard !commands.isEmpty else { return nil } + return commands.removeFirst() + } + + mutating func removeAll() { + commands.removeAll(keepingCapacity: true) + } + + private mutating func discardOldestCompleteGesture() -> Bool { + for startIndex in commands.indices { + guard case .began(let firstGesture)? = commands[startIndex].gestureTransition else { + continue + } + var activeGestures: Set = [firstGesture] + var endIndex = commands.index(after: startIndex) + var isBalanced = true + while endIndex < commands.endIndex { + switch commands[endIndex].gestureTransition { + case .began(let gesture): + activeGestures.insert(gesture) + case .ended(let gesture): + if activeGestures.contains(gesture) { + activeGestures.remove(gesture) + } else { + isBalanced = false + } + case nil: + break + } + guard isBalanced else { break } + if activeGestures.isEmpty { + commands.removeSubrange(startIndex...endIndex) + return true + } + endIndex = commands.index(after: endIndex) + } + } + return false + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/URLSessionCDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/URLSessionCDPWebSocketTransport.swift new file mode 100644 index 00000000000..e2e4e166404 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/URLSessionCDPWebSocketTransport.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Bridges Foundation's thread-safe WebSocket session into the CDP transport boundary. +// URLSession and URLSessionWebSocketTask own synchronization; this wrapper keeps only immutable references. +final class URLSessionCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { + private let session: URLSession + private let task: URLSessionWebSocketTask + + init(url: URL) { + let configuration = URLSessionConfiguration.ephemeral + let session = URLSession(configuration: configuration) + self.session = session + self.task = session.webSocketTask(with: url) + } + + func resume() { + task.resume() + } + + func send(_ data: Data) async throws { + try await task.send(.data(data)) + } + + func receive() async throws -> Data { + switch try await task.receive() { + case .data(let data): + return data + case .string(let value): + return Data(value.utf8) + @unknown default: + return Data() + } + } + + func cancel() { + task.cancel(with: .goingAway, reason: nil) + session.invalidateAndCancel() + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineCookieError.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineCookieError.swift new file mode 100644 index 00000000000..13ff66f2951 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineCookieError.swift @@ -0,0 +1,4 @@ +/// A WebKit cookie could not be represented as an `HTTPCookie`. +enum WebKitBrowserEngineCookieError: Error { + case invalidPayload +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index c7c9fec7d7e..7d372a7c7b2 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -6,10 +6,6 @@ public import WebKit /// A ``BrowserEngineSession`` backed by an in-process `WKWebView`. @MainActor public final class WebKitBrowserEngineSession: BrowserEngineSession { - private enum CookieError: Error { - case invalidPayload - } - /// The engine family implementing this session. public let kind = BrowserEngineKind.webKit @@ -220,7 +216,7 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { properties[.expires] = expiresDate } guard let httpCookie = HTTPCookie(properties: properties) else { - throw CookieError.invalidPayload + throw WebKitBrowserEngineCookieError.invalidPayload } return httpCookie } diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift new file mode 100644 index 00000000000..1bc1ae174cf --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift @@ -0,0 +1,57 @@ +import Foundation +@testable import CmuxBrowser + +actor BufferedCDPWebSocketTransport: CDPWebSocketTransport { + private var bufferedData: [Data] = [] + private var receiveContinuation: CheckedContinuation? + private var receiveCount = 0 + private var receiveCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] + + nonisolated func resume() {} + + func send(_: Data) async throws {} + + func receive() async throws -> Data { + receiveCount += 1 + resumeReceiveCountWaiters() + if !bufferedData.isEmpty { + return bufferedData.removeFirst() + } + return try await withCheckedThrowingContinuation { continuation in + receiveContinuation = continuation + } + } + + nonisolated func cancel() { + Task { + await finish() + } + } + + func deliverAndWaitUntilConsumed(_ data: Data) async { + let nextReceiveCount = receiveCount + 1 + if let continuation = receiveContinuation { + receiveContinuation = nil + continuation.resume(returning: data) + } else { + bufferedData.append(data) + } + guard receiveCount < nextReceiveCount else { return } + await withCheckedContinuation { continuation in + receiveCountWaiters.append((nextReceiveCount, continuation)) + } + } + + private func finish() { + receiveContinuation?.resume(throwing: CancellationError()) + receiveContinuation = nil + } + + private func resumeReceiveCountWaiters() { + let ready = receiveCountWaiters.filter { $0.count <= receiveCount } + receiveCountWaiters.removeAll { $0.count <= receiveCount } + for waiter in ready { + waiter.continuation.resume() + } + } +} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift index a8ed3a5f820..d5671fc85d2 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -59,71 +59,3 @@ import Testing #expect(await secondEvent.value?.method == "Target.targetInfoChanged") } } - -private final class NoResponseCDPWebSocketTransport: CDPWebSocketTransport, @unchecked Sendable { - func resume() {} - - func send(_: Data) async throws {} - - func receive() async throws -> Data { - try await ContinuousClock().sleep(for: .seconds(60)) - return Data() - } - - func cancel() {} -} - -private actor BufferedCDPWebSocketTransport: CDPWebSocketTransport { - private var bufferedData: [Data] = [] - private var receiveContinuation: CheckedContinuation? - private var receiveCount = 0 - private var receiveCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] - - nonisolated func resume() {} - - func send(_: Data) async throws {} - - func receive() async throws -> Data { - receiveCount += 1 - resumeReceiveCountWaiters() - if !bufferedData.isEmpty { - return bufferedData.removeFirst() - } - return try await withCheckedThrowingContinuation { continuation in - receiveContinuation = continuation - } - } - - nonisolated func cancel() { - Task { - await finish() - } - } - - func deliverAndWaitUntilConsumed(_ data: Data) async { - let nextReceiveCount = receiveCount + 1 - if let continuation = receiveContinuation { - receiveContinuation = nil - continuation.resume(returning: data) - } else { - bufferedData.append(data) - } - guard receiveCount < nextReceiveCount else { return } - await withCheckedContinuation { continuation in - receiveCountWaiters.append((nextReceiveCount, continuation)) - } - } - - private func finish() { - receiveContinuation?.resume(throwing: CancellationError()) - receiveContinuation = nil - } - - private func resumeReceiveCountWaiters() { - let ready = receiveCountWaiters.filter { $0.count <= receiveCount } - receiveCountWaiters.removeAll { $0.count <= receiveCount } - for waiter in ready { - waiter.continuation.resume() - } - } -} diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index be11d2ae6cc..ae1d61f70ee 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -8,7 +8,7 @@ import WebKit struct ChromiumBrowserEngineSessionTests { @Test func createsIsolatedWorldWithUniversalAccess() { - #expect(ChromiumBrowserEngineSession.isolatedWorldParameters(frameID: "main-frame") == [ + #expect(ChromiumIsolatedWorldConfiguration(frameID: "main-frame").parameters == [ "frameId": .string("main-frame"), "worldName": .string("cmux.browser.automation"), "grantUniversalAccess": .bool(true), @@ -17,10 +17,10 @@ struct ChromiumBrowserEngineSessionTests { @Test func boundsScreencastFrameCadence() { - #expect(ChromiumBrowserEngineSession.screencastParameters( + #expect(ChromiumScreencastConfiguration( viewportWidth: 640, viewportHeight: 480 - ) == [ + ).parameters == [ "format": .string("jpeg"), "quality": .number(75), "maxWidth": .number(1_280), @@ -31,18 +31,18 @@ struct ChromiumBrowserEngineSessionTests { @Test func startsAndStopsScreencastWithViewportVisibility() { - #expect(ChromiumBrowserEngineSession.screencastMethod( + #expect(ChromiumScreencastTransition( isViewportVisible: false, isScreencastActive: false - ) == nil) - #expect(ChromiumBrowserEngineSession.screencastMethod( + ).method == nil) + #expect(ChromiumScreencastTransition( isViewportVisible: true, isScreencastActive: false - ) == "Page.startScreencast") - #expect(ChromiumBrowserEngineSession.screencastMethod( + ).method == "Page.startScreencast") + #expect(ChromiumScreencastTransition( isViewportVisible: false, isScreencastActive: true - ) == "Page.stopScreencast") + ).method == "Page.stopScreencast") } @Test diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift index dfeb35b8660..76cda0e6c04 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumViewportInputQueueTests.swift @@ -7,7 +7,7 @@ import Testing queue.enqueue(mouse(type: "mouseMoved", x: 1)) queue.enqueue(mouse(type: "mouseMoved", x: 2)) - queue.enqueue(.key(parameters: ["type": .string("keyDown")])) + queue.enqueue(ChromiumViewportInputCommand(keyParameters: ["type": .string("keyDown")])) queue.enqueue(mouse(type: "mouseMoved", x: 3)) queue.enqueue(mouse(type: "mouseMoved", x: 4)) @@ -32,7 +32,7 @@ import Testing var queue = ChromiumViewportInputQueue() for index in 0..<(ChromiumViewportInputQueue.maximumPendingCommands * 2) { - queue.enqueue(.key(parameters: [ + queue.enqueue(ChromiumViewportInputCommand(keyParameters: [ "type": .string("keyDown"), "key": .string(String(index)), ])) @@ -102,7 +102,7 @@ import Testing deltaX: Double = 0, deltaY: Double = 0 ) -> ChromiumViewportInputCommand { - .mouse(parameters: [ + ChromiumViewportInputCommand(mouseParameters: [ "type": .string(type), "x": .number(x), "deltaX": .number(deltaX), @@ -111,7 +111,7 @@ import Testing } private func key(type: String, code: String) -> ChromiumViewportInputCommand { - .key(parameters: [ + ChromiumViewportInputCommand(keyParameters: [ "type": .string(type), "code": .string(code), ]) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NoResponseCDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NoResponseCDPWebSocketTransport.swift new file mode 100644 index 00000000000..5a52c967505 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/NoResponseCDPWebSocketTransport.swift @@ -0,0 +1,15 @@ +import Foundation +@testable import CmuxBrowser + +final class NoResponseCDPWebSocketTransport: CDPWebSocketTransport { + func resume() {} + + func send(_: Data) async throws {} + + func receive() async throws -> Data { + try await ContinuousClock().sleep(for: .seconds(60)) + return Data() + } + + func cancel() {} +} diff --git a/Sources/Panels/BrowserPanel+AutomationRecovery.swift b/Sources/Panels/BrowserPanel+AutomationRecovery.swift index f0c2980daa2..1a28ad54cd4 100644 --- a/Sources/Panels/BrowserPanel+AutomationRecovery.swift +++ b/Sources/Panels/BrowserPanel+AutomationRecovery.swift @@ -5,7 +5,8 @@ import WebKit extension BrowserPanel { func clearBrowserAutomationUserScripts() { engineInitializationScripts.removeAll() - engineInitializationScriptCounts.removeAll() + engineInitializationScriptCount = 0 + engineInitializationStyleCount = 0 } func makeReplacementWebView( diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index c2fc473ef27..58c23676491 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -2668,11 +2668,6 @@ final class BrowserPortalAnchorView: NSView { @MainActor final class BrowserPanel: Panel, ObservableObject { - enum AutomationInitializationScriptKind { - case script - case style - } - /// Popup windows owned by this panel (for lifecycle cleanup) private var popupControllers: [BrowserPopupWindowController] = [] @@ -2791,7 +2786,8 @@ final class BrowserPanel: Panel, ObservableObject { private var engineStateTask: Task? private var lastRecordedChromiumNavigationCompletionRevision: UInt64 = 0 var engineInitializationScripts: [String] = [] - var engineInitializationScriptCounts: [AutomationInitializationScriptKind: Int] = [:] + var engineInitializationScriptCount = 0 + var engineInitializationStyleCount = 0 var engineSession: any BrowserEngineSession { guard let engineSessionStorage else { @@ -7525,13 +7521,16 @@ extension BrowserPanel { /// Installs JavaScript at document start through the active browser engine. func addInitializationScript( _ script: String, - kind: AutomationInitializationScriptKind + wrappingStyleElement: Bool ) async throws -> Int { try await engineSession.addInitializationScript(script) engineInitializationScripts.append(script) - let count = (engineInitializationScriptCounts[kind] ?? 0) + 1 - engineInitializationScriptCounts[kind] = count - return count + if wrappingStyleElement { + engineInitializationStyleCount += 1 + return engineInitializationStyleCount + } + engineInitializationScriptCount += 1 + return engineInitializationScriptCount } // MARK: - Find in Page diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 12a7d9f2d36..478b7192414 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -5966,31 +5966,25 @@ class TerminalController { case failure(String) } - private enum V2InitializationScriptResult { - case success(Int) - case failure(String) - } - private nonisolated func v2InstallBrowserInitializationScript( _ browserPanel: BrowserPanel, script: String, - kind: BrowserPanel.AutomationInitializationScriptKind, + wrappingStyleElement: Bool, timeout: TimeInterval = 10.0 - ) -> V2InitializationScriptResult { - let outcome: V2InitializationScriptResult? = v2AwaitCallback(timeout: timeout) { finish in + ) -> Result? { + v2AwaitCallback(timeout: timeout) { finish in Task { @MainActor in do { - let count = try await browserPanel.addInitializationScript(script, kind: kind) + let count = try await browserPanel.addInitializationScript( + script, + wrappingStyleElement: wrappingStyleElement + ) finish(.success(count)) } catch { - finish(.failure(error.localizedDescription)) + finish(.failure(error)) } } } - return outcome ?? .failure(String( - localized: "browser.automation.initScript.timeout", - defaultValue: "Timed out installing the browser initialization script." - )) } private nonisolated func v2WebKitView(_ ctx: V2BrowserPanelContext) -> WKWebView? { @@ -9338,18 +9332,6 @@ class TerminalController { ]) } - private enum V2BrowserCookieReadResult { - case success([BrowserEngineCookie]) - case timeout - case failure(String) - } - - private enum V2BrowserCookieMutationResult { - case success - case timeout - case failure(String) - } - private nonisolated func v2BrowserCookieDict(_ cookie: BrowserEngineCookie) -> [String: Any] { var out: [String: Any] = [ "name": cookie.name, @@ -9371,53 +9353,50 @@ class TerminalController { private nonisolated func v2BrowserEngineCookies( _ browserPanel: BrowserPanel, timeout: TimeInterval = 12.0 - ) -> V2BrowserCookieReadResult { - let outcome: V2BrowserCookieReadResult? = v2AwaitCallback(timeout: timeout) { finish in + ) -> Result<[BrowserEngineCookie], any Error>? { + v2AwaitCallback(timeout: timeout) { finish in Task { @MainActor in do { finish(.success(try await browserPanel.engineSession.cookies())) } catch { - finish(.failure(error.localizedDescription)) + finish(.failure(error)) } } } - return outcome ?? .timeout } private nonisolated func v2BrowserEngineSetCookie( _ cookie: BrowserEngineCookie, browserPanel: BrowserPanel, timeout: TimeInterval = 12.0 - ) -> V2BrowserCookieMutationResult { - let outcome: V2BrowserCookieMutationResult? = v2AwaitCallback(timeout: timeout) { finish in + ) -> Result? { + v2AwaitCallback(timeout: timeout) { finish in Task { @MainActor in do { try await browserPanel.engineSession.setCookie(cookie) - finish(.success) + finish(.success(())) } catch { - finish(.failure(error.localizedDescription)) + finish(.failure(error)) } } } - return outcome ?? .timeout } private nonisolated func v2BrowserEngineDeleteCookie( _ cookie: BrowserEngineCookie, browserPanel: BrowserPanel, timeout: TimeInterval = 12.0 - ) -> V2BrowserCookieMutationResult { - let outcome: V2BrowserCookieMutationResult? = v2AwaitCallback(timeout: timeout) { finish in + ) -> Result? { + v2AwaitCallback(timeout: timeout) { finish in Task { @MainActor in do { try await browserPanel.engineSession.deleteCookie(cookie) - finish(.success) + finish(.success(())) } catch { - finish(.failure(error.localizedDescription)) + finish(.failure(error)) } } } - return outcome ?? .timeout } private nonisolated func v2BrowserCookieFromObject( @@ -9455,13 +9434,14 @@ class TerminalController { private nonisolated func v2BrowserCookiesGet(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in var cookies: [BrowserEngineCookie] - switch v2BrowserEngineCookies(ctx.browserPanel) { + guard let cookieResult = v2BrowserEngineCookies(ctx.browserPanel) else { + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + } + switch cookieResult { case .success(let engineCookies): cookies = engineCookies - case .timeout: - return .err(code: "timeout", message: "Timed out reading cookies", data: nil) - case .failure(let message): - return .err(code: "engine_error", message: message, data: nil) + case .failure(let error): + return .err(code: "engine_error", message: error.localizedDescription, data: nil) } if let name = v2String(params, "name") { @@ -9512,13 +9492,17 @@ class TerminalController { guard let cookie = v2BrowserCookieFromObject(raw, fallbackURL: fallbackURL) else { return .err(code: "invalid_params", message: "Invalid cookie payload", data: ["cookie": raw]) } - switch v2BrowserEngineSetCookie(cookie, browserPanel: ctx.browserPanel) { + guard let mutationResult = v2BrowserEngineSetCookie( + cookie, + browserPanel: ctx.browserPanel + ) else { + return .err(code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name]) + } + switch mutationResult { case .success: setCount += 1 - case .timeout: - return .err(code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name]) - case .failure(let message): - return .err(code: "engine_error", message: message, data: ["name": cookie.name]) + case .failure(let error): + return .err(code: "engine_error", message: error.localizedDescription, data: ["name": cookie.name]) } } @@ -9529,13 +9513,14 @@ class TerminalController { private nonisolated func v2BrowserCookiesClear(params: [String: Any]) -> V2CallResult { return v2BrowserWithPanelContext(params: params) { ctx in let cookies: [BrowserEngineCookie] - switch v2BrowserEngineCookies(ctx.browserPanel) { + guard let cookieResult = v2BrowserEngineCookies(ctx.browserPanel) else { + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + } + switch cookieResult { case .success(let engineCookies): cookies = engineCookies - case .timeout: - return .err(code: "timeout", message: "Timed out reading cookies", data: nil) - case .failure(let message): - return .err(code: "engine_error", message: message, data: nil) + case .failure(let error): + return .err(code: "engine_error", message: error.localizedDescription, data: nil) } let name = v2String(params, "name") @@ -9550,13 +9535,17 @@ class TerminalController { var removed = 0 for cookie in targets { - switch v2BrowserEngineDeleteCookie(cookie, browserPanel: ctx.browserPanel) { + guard let mutationResult = v2BrowserEngineDeleteCookie( + cookie, + browserPanel: ctx.browserPanel + ) else { + return .err(code: "timeout", message: "Timed out deleting cookie", data: ["name": cookie.name]) + } + switch mutationResult { case .success: removed += 1 - case .timeout: - return .err(code: "timeout", message: "Timed out deleting cookie", data: ["name": cookie.name]) - case .failure(let message): - return .err(code: "engine_error", message: message, data: ["name": cookie.name]) + case .failure(let error): + return .err(code: "engine_error", message: error.localizedDescription, data: ["name": cookie.name]) } } @@ -10075,13 +10064,14 @@ class TerminalController { } let cookies: [BrowserEngineCookie] - switch v2BrowserEngineCookies(ctx.browserPanel) { + guard let cookieResult = v2BrowserEngineCookies(ctx.browserPanel) else { + return .err(code: "timeout", message: "Timed out reading cookies", data: nil) + } + switch cookieResult { case .success(let engineCookies): cookies = engineCookies - case .timeout: - return .err(code: "timeout", message: "Timed out reading cookies", data: nil) - case .failure(let message): - return .err(code: "engine_error", message: message, data: nil) + case .failure(let error): + return .err(code: "engine_error", message: error.localizedDescription, data: nil) } let stateSnapshot = v2MainSync { let currentURL = ctx.engineKind == .chromium @@ -10158,19 +10148,23 @@ class TerminalController { ) else { continue } - switch v2BrowserEngineSetCookie(cookie, browserPanel: ctx.browserPanel) { - case .success: - break - case .timeout: + guard let mutationResult = v2BrowserEngineSetCookie( + cookie, + browserPanel: ctx.browserPanel + ) else { return .err( code: "timeout", message: "Timed out setting cookie", data: ["name": cookie.name] ) - case .failure(let message): + } + switch mutationResult { + case .success: + break + case .failure(let error): return .err( code: "engine_error", - message: message, + message: error.localizedDescription, data: ["name": cookie.name] ) } @@ -10216,15 +10210,25 @@ class TerminalController { } return v2BrowserWithPanelContext(params: params) { ctx in let scriptsCount: Int - switch v2InstallBrowserInitializationScript( + guard let installationResult = v2InstallBrowserInitializationScript( ctx.browserPanel, script: script, - kind: .script - ) { + wrappingStyleElement: false + ) else { + return .err( + code: "browser_error", + message: String( + localized: "browser.automation.initScript.timeout", + defaultValue: "Timed out installing the browser initialization script." + ), + data: ["engine": ctx.engineKind.rawValue] + ) + } + switch installationResult { case .success(let count): scriptsCount = count - case .failure(let message): - return .err(code: "browser_error", message: message, data: ["engine": ctx.engineKind.rawValue]) + case .failure(let error): + return .err(code: "browser_error", message: error.localizedDescription, data: ["engine": ctx.engineKind.rawValue]) } _ = v2RunBrowserJavaScript( ctx.webView, @@ -10268,15 +10272,25 @@ class TerminalController { """ let stylesCount: Int - switch v2InstallBrowserInitializationScript( + guard let installationResult = v2InstallBrowserInitializationScript( ctx.browserPanel, script: source, - kind: .style - ) { + wrappingStyleElement: true + ) else { + return .err( + code: "browser_error", + message: String( + localized: "browser.automation.initScript.timeout", + defaultValue: "Timed out installing the browser initialization script." + ), + data: ["engine": ctx.engineKind.rawValue] + ) + } + switch installationResult { case .success(let count): stylesCount = count - case .failure(let message): - return .err(code: "browser_error", message: message, data: ["engine": ctx.engineKind.rawValue]) + case .failure(let error): + return .err(code: "browser_error", message: error.localizedDescription, data: ["engine": ctx.engineKind.rawValue]) } _ = v2RunBrowserJavaScript( ctx.webView, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c75e6c04772..2362a4c7e82 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -282,6 +282,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C67540060000000000000001 /* BrowserPanel+PDFDocumentActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C67540060000000000000002 /* BrowserPanel+PDFDocumentActions.swift */; }; B424PWAD000000000000PW01 /* BrowserPanel+PrewarmedWebViewAdoption.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424PWAD000000000000PW02 /* BrowserPanel+PrewarmedWebViewAdoption.swift */; }; A5001402 /* BrowserPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001412 /* BrowserPanel.swift */; }; + B81630010000000000000001 /* BrowserPanelChromiumHistoryRecordingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81630010000000000000002 /* BrowserPanelChromiumHistoryRecordingTests.swift */; }; B1F0C0090000000000000001 /* BrowserPanelDeveloperToolsLifecycle.swift in Sources */ = {isa = PBXBuildFile; fileRef = B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */; }; C62530010000000000000001 /* BrowserPanelReloadMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62530010000000000000002 /* BrowserPanelReloadMode.swift */; }; B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */; }; @@ -2257,6 +2258,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C67540060000000000000002 /* BrowserPanel+PDFDocumentActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+PDFDocumentActions.swift"; sourceTree = ""; }; B424PWAD000000000000PW02 /* BrowserPanel+PrewarmedWebViewAdoption.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Panels/BrowserPanel+PrewarmedWebViewAdoption.swift"; sourceTree = ""; }; A5001412 /* BrowserPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanel.swift; sourceTree = ""; }; + B81630010000000000000002 /* BrowserPanelChromiumHistoryRecordingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelChromiumHistoryRecordingTests.swift; sourceTree = ""; }; B1F0C0090000000000000002 /* BrowserPanelDeveloperToolsLifecycle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelDeveloperToolsLifecycle.swift; sourceTree = ""; }; C62530010000000000000002 /* BrowserPanelReloadMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/BrowserPanelReloadMode.swift; sourceTree = ""; }; B65060010000000000000001 /* BrowserPanelSessionRestoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserPanelSessionRestoreTests.swift; sourceTree = ""; }; @@ -5511,6 +5513,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = 8054B0040000000000000004 /* BrowserAutomationRecoveryLifecycleTests.swift */, B75040040000000000000002 /* BrowserInsecureHTTPAlertTestSupport.swift */, B6585002B6585002B658PW02 /* BrowserPrewarmedWebViewPoolTests.swift */, + B81630010000000000000002 /* BrowserPanelChromiumHistoryRecordingTests.swift */, 58C7B1B978620BE162CC057E /* BrowserPanelTests.swift */, C42660040000000000000002 /* BrowserPDFPreviewActionRegressionTests.swift */, C42660050000000000000002 /* BrowserPDFPreviewActionUnitTests.swift */, @@ -7686,6 +7689,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B7380002B7380002B7380002 /* BrowserOmnibarSuggestionClickRoutingTests.swift in Sources */, D0B1000EA1B2C3D4E5F60001 /* BrowserPaneDropRoutingTests.swift in Sources */, D7632003A1B2C3D4E5F60718 /* BrowserPaneFileDropUploadRegressionTests.swift in Sources */, + B81630010000000000000001 /* BrowserPanelChromiumHistoryRecordingTests.swift in Sources */, B65060010000000000000002 /* BrowserPanelSessionRestoreTests.swift in Sources */, 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, 8054BEEF0000000000000001 /* BrowserPanelViewIdentityTests.swift in Sources */, diff --git a/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift b/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift new file mode 100644 index 00000000000..5c0bfe03bb3 --- /dev/null +++ b/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift @@ -0,0 +1,56 @@ +import CmuxBrowser +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@MainActor +@Suite(.serialized) +struct BrowserPanelChromiumHistoryRecordingTests { + @Test + func recordsEachCompletedNavigationOnceDespiteTitleUpdates() throws { + let profileStore = BrowserProfileStore.shared + let profile = try #require(profileStore.createProfile( + named: "Chromium-History-\(UUID().uuidString)" + )) + let historyStore = profileStore.historyStore(for: profile.id) + historyStore.clearHistory() + let panel = BrowserPanel( + workspaceId: UUID(), + profileID: profile.id, + renderInitialNavigation: false, + engineSelection: BrowserEngineSelection(kind: .chromium) + ) + defer { + panel.close() + historyStore.clearHistory() + _ = profileStore.deleteProfile(id: profile.id) + } + let url = try #require(URL(string: "https://example.com/chromium-history")) + + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Initial title", + navigationCompletionRevision: 1 + )) + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Updated by the page", + navigationCompletionRevision: 1 + )) + + #expect(historyStore.entries.first?.visitCount == 1) + + panel.applyChromiumEngineState(BrowserEngineState( + url: url, + title: "Reloaded title", + navigationCompletionRevision: 2 + )) + + #expect(historyStore.entries.first?.visitCount == 2) + } +} diff --git a/cmuxTests/BrowserPanelTests.swift b/cmuxTests/BrowserPanelTests.swift index 97fa13e25fc..8bbdf05b18f 100644 --- a/cmuxTests/BrowserPanelTests.swift +++ b/cmuxTests/BrowserPanelTests.swift @@ -4364,50 +4364,3 @@ final class OmnibarNativeTextFieldCaretTests: XCTestCase { ) } } - -@MainActor -@Suite(.serialized) -struct BrowserPanelChromiumHistoryRecordingTests { - @Test - func recordsEachCompletedNavigationOnceDespiteTitleUpdates() throws { - let profileStore = BrowserProfileStore.shared - let profile = try #require(profileStore.createProfile( - named: "Chromium-History-\(UUID().uuidString)" - )) - let historyStore = profileStore.historyStore(for: profile.id) - historyStore.clearHistory() - let panel = BrowserPanel( - workspaceId: UUID(), - profileID: profile.id, - renderInitialNavigation: false, - engineSelection: BrowserEngineSelection(kind: .chromium) - ) - defer { - panel.close() - historyStore.clearHistory() - _ = profileStore.deleteProfile(id: profile.id) - } - let url = try #require(URL(string: "https://example.com/chromium-history")) - - panel.applyChromiumEngineState(BrowserEngineState( - url: url, - title: "Initial title", - navigationCompletionRevision: 1 - )) - panel.applyChromiumEngineState(BrowserEngineState( - url: url, - title: "Updated by the page", - navigationCompletionRevision: 1 - )) - - #expect(historyStore.entries.first?.visitCount == 1) - - panel.applyChromiumEngineState(BrowserEngineState( - url: url, - title: "Reloaded title", - navigationCompletionRevision: 2 - )) - - #expect(historyStore.entries.first?.visitCount == 2) - } -} From 20359a19a38f57d98835c927025bcd98635400e9 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:20:46 -0700 Subject: [PATCH 34/39] Test browser engine event and script regressions --- .../Engine/CDPConnectionTests.swift | 28 +++++++++++++++++++ .../WebKitBrowserEngineSessionTests.swift | 18 ++++++++++++ 2 files changed, 46 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift index d5671fc85d2..ce6948af18a 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -58,4 +58,32 @@ import Testing #expect(await firstEvent.value?.method == "Target.targetInfoChanged") #expect(await secondEvent.value?.method == "Target.targetInfoChanged") } + + @Test func screencastPressureDoesNotEvictControlEvents() async throws { + let transport = BufferedCDPWebSocketTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + let events = await connection.events(sessionID: "target") + let controlEvent = try JSONSerialization.data(withJSONObject: [ + "method": "Fetch.requestPaused", + "sessionId": "target", + "params": ["requestId": "request-1"], + ]) + await transport.deliverAndWaitUntilConsumed(controlEvent) + + for frameIndex in 0..<300 { + let frame = try JSONSerialization.data(withJSONObject: [ + "method": "Page.screencastFrame", + "sessionId": "target", + "params": ["sessionId": frameIndex, "data": "frame"], + ]) + await transport.deliverAndWaitUntilConsumed(frame) + } + + var iterator = events.makeAsyncIterator() + let retainedEvent = await iterator.next() + await connection.close() + + #expect(retainedEvent?.method == "Fetch.requestPaused") + } } diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift index 38ab3521d13..d3792b1e07a 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/WebKitBrowserEngineSessionTests.swift @@ -25,4 +25,22 @@ struct WebKitBrowserEngineSessionTests { #expect(result == .number(42)) } + + @Test + func evaluationAcceptsStatementsAndTrailingSemicolons() async throws { + let webView = WKWebView(frame: .zero) + let loadDelegate = ChromiumViewportDocumentLoadDelegate() + webView.navigationDelegate = loadDelegate + defer { webView.navigationDelegate = nil } + try await loadDelegate.load("", in: webView) + let session = WebKitBrowserEngineSession(webView: webView) + + let statementResult = try await session.evaluateJavaScript( + "const answer = 40 + 2; answer;" + ) + let trailingSemicolonResult = try await session.evaluateJavaScript("42;") + + #expect(statementResult == .number(42)) + #expect(trailingSemicolonResult == .number(42)) + } } From b0d6af776720ccc1cb6a394a59635c5e9572d9f4 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:39:39 -0700 Subject: [PATCH 35/39] Address browser engine review feedback --- .../Engine/Chromium/CDPConnection.swift | 65 +++++++++++++++++-- .../ChromiumBrowserEngineSession.swift | 50 ++++++++++---- .../Chromium/ChromiumProfileRuntime.swift | 59 ++++++++++------- .../Engine/WebKitBrowserEngineSession.swift | 5 +- .../BufferedCDPWebSocketTransport.swift | 15 ++++- .../Engine/CDPConnectionTests.swift | 6 ++ Sources/Panels/BrowserAutomation.swift | 52 ++++++++------- Sources/Panels/BrowserPanel.swift | 24 +++---- ...erPanelChromiumHistoryRecordingTests.swift | 13 ++-- 9 files changed, 194 insertions(+), 95 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index 6ca31346083..ba0f895ff7b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -10,6 +10,7 @@ actor CDPConnection { private var nextRequestID = 1 private var pendingRequests: [Int: CDPPendingRequest] = [:] private var eventSubscribers: [UUID: CDPEventSubscriber] = [:] + private var screencastFrameSubscribers: [UUID: CDPEventSubscriber] = [:] private var isClosed = false init(url: URL, requestTimeout: Duration = .seconds(10)) { @@ -35,9 +36,11 @@ actor CDPConnection { func events(sessionID: String) -> AsyncStream { let subscriberID = UUID() + // High-volume frames use their own coalescing stream below. The remaining + // control events are lossless because dropping one can suspend protocol state. let (stream, continuation) = AsyncStream.makeStream( of: CDPEvent.self, - bufferingPolicy: .bufferingNewest(256) + bufferingPolicy: .unbounded ) guard !isClosed else { continuation.finish() @@ -55,6 +58,29 @@ actor CDPConnection { return stream } + /// Streams replaceable screencast frames separately from lossless control events. + func screencastFrames(sessionID: String) -> AsyncStream { + let subscriberID = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: CDPEvent.self, + bufferingPolicy: .bufferingNewest(1) + ) + guard !isClosed else { + continuation.finish() + return stream + } + screencastFrameSubscribers[subscriberID] = CDPEventSubscriber( + sessionID: sessionID, + continuation: continuation + ) + continuation.onTermination = { [weak self] _ in + Task { + await self?.removeScreencastFrameSubscriber(subscriberID) + } + } + return stream + } + func isOpen() -> Bool { !isClosed } func send( @@ -168,7 +194,7 @@ actor CDPConnection { while !Task.isCancelled { let data = try await transport.receive() guard !data.isEmpty else { continue } - try handleMessage(data) + try await handleMessage(data) } } catch { guard !isClosed else { return } @@ -180,7 +206,7 @@ actor CDPConnection { } } - private func handleMessage(_ data: Data) throws { + private func handleMessage(_ data: Data) async throws { let payload = try decoder.decode([String: CDPJSONValue].self, from: data) if let requestID = payload["id"]?.intValue, let request = pendingRequests.removeValue(forKey: requestID) { @@ -199,19 +225,46 @@ actor CDPConnection { parameters: payload["params"]?.objectValue ?? [:], sessionID: payload["sessionId"]?.stringValue ) - for subscriber in eventSubscribers.values where + let subscribers = method == "Page.screencastFrame" + ? Array(screencastFrameSubscribers.values) + : Array(eventSubscribers.values) + for subscriber in subscribers where event.sessionID == nil || event.sessionID == subscriber.sessionID { - subscriber.continuation.yield(event) + let yieldResult = subscriber.continuation.yield(event) + if case .dropped(let droppedEvent) = yieldResult { + try await acknowledgeDroppedScreencastFrame( + droppedEvent, + subscriberSessionID: subscriber.sessionID + ) + } } } + private func acknowledgeDroppedScreencastFrame( + _ event: CDPEvent, + subscriberSessionID: String + ) async throws { + guard event.method == "Page.screencastFrame", + let frameSessionID = event.parameters["sessionId"]?.intValue else { return } + try await sendUnacknowledged( + method: "Page.screencastFrameAck", + parameters: ["sessionId": .number(Double(frameSessionID))], + sessionID: event.sessionID ?? subscriberSessionID + ) + } + private func removeEventSubscriber(_ subscriberID: UUID) { eventSubscribers.removeValue(forKey: subscriberID) } + private func removeScreencastFrameSubscriber(_ subscriberID: UUID) { + screencastFrameSubscribers.removeValue(forKey: subscriberID) + } + private func finishEventSubscribers() { - let subscribers = Array(eventSubscribers.values) + let subscribers = Array(eventSubscribers.values) + Array(screencastFrameSubscribers.values) eventSubscribers.removeAll() + screencastFrameSubscribers.removeAll() for subscriber in subscribers { subscriber.continuation.finish() } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index 8f283765d2b..e1e67291157 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -44,6 +44,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { var cdpSessionID: String? private var startupTask: Task? private var eventTask: Task? + private var screencastFrameTask: Task? private var pendingRequest: URLRequest? private var initializationScripts: [String] private var initializationScriptInstallations: [Int: Task] = [:] @@ -340,6 +341,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { isClosed = true startupTask?.cancel() eventTask?.cancel() + screencastFrameTask?.cancel() initializationScriptInstallations.values.forEach { $0.cancel() } initializationScriptInstallations.removeAll() pageZoomUpdateTask?.cancel() @@ -360,6 +362,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { deviceMetricsPending = false startupTask = nil eventTask = nil + screencastFrameTask = nil navigationInterceptor = nil viewportWebView.configuration.userContentController.removeScriptMessageHandler( forName: "cmuxChromiumViewport" @@ -451,6 +454,8 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } catch { eventTask?.cancel() eventTask = nil + screencastFrameTask?.cancel() + screencastFrameTask = nil navigationInterceptor = nil self.connection = nil self.targetID = nil @@ -521,6 +526,17 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { guard let self, !Task.isCancelled, !self.isClosed else { return } self.presentOperationFailure() } + screencastFrameTask = Task { [weak self] in + let frames = await connection.screencastFrames(sessionID: sessionID) + for await frame in frames { + guard let self, !Task.isCancelled else { return } + await self.handleScreencastFrame( + frame, + connection: connection, + sessionID: sessionID + ) + } + } } func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { @@ -540,20 +556,6 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { return } switch event.method { - case "Page.screencastFrame": - guard let data = event.parameters["data"]?.stringValue, - let frameSessionID = event.parameters["sessionId"]?.intValue else { return } - _ = try? await viewportWebView.callAsyncJavaScript( - "return await window.cmuxChromiumFrame('data:image/jpeg;base64,' + base64)", - arguments: ["base64": data], - in: nil, - contentWorld: .page - ) - _ = try? await connection.send( - method: "Page.screencastFrameAck", - parameters: ["sessionId": .number(Double(frameSessionID))], - sessionID: sessionID - ) case "Page.frameStartedLoading": updateState { $0.isLoading = true } case "Page.frameStoppedLoading": @@ -578,6 +580,26 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } } + private func handleScreencastFrame( + _ event: CDPEvent, + connection: CDPConnection, + sessionID: String + ) async { + guard let data = event.parameters["data"]?.stringValue, + let frameSessionID = event.parameters["sessionId"]?.intValue else { return } + _ = try? await viewportWebView.callAsyncJavaScript( + "return await window.cmuxChromiumFrame('data:image/jpeg;base64,' + base64)", + arguments: ["base64": data], + in: nil, + contentWorld: .page + ) + _ = try? await connection.send( + method: "Page.screencastFrameAck", + parameters: ["sessionId": .number(Double(frameSessionID))], + sessionID: sessionID + ) + } + private func installDocumentTitleObservation( connection: CDPConnection, sessionID: String diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift index ca6395b8b91..24ef18023dc 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumProfileRuntime.swift @@ -32,47 +32,42 @@ public actor ChromiumProfileRuntime { height: Int ) async throws -> ChromiumProfileRuntimeLease { pendingAcquisitionCount += 1 + var uncommittedTarget: (connection: CDPConnection, id: String)? do { let shared = try await connected(application: application) let generation = lifecycleGeneration try Task.checkCancellation() - let created = try await shared.connection.send( - method: "Target.createTarget", - parameters: [ - "url": .string("about:blank"), - "width": .number(Double(width)), - "height": .number(Double(height)), - ] - ) + // The non-idempotent creation request must finish even if its caller is cancelled, + // so the returned target can always be registered or explicitly closed. + let creationTask = Task { + try await shared.connection.send( + method: "Target.createTarget", + parameters: [ + "url": .string("about:blank"), + "width": .number(Double(width)), + "height": .number(Double(height)), + ] + ) + } + let created = try await creationTask.value guard let targetID = created.objectValue?["targetId"]?.stringValue else { throw BrowserEngineSessionError.chromiumProtocol("Chromium did not create a page target.") } - guard generation == lifecycleGeneration else { - _ = try? await shared.connection.send( - method: "Target.closeTarget", - parameters: ["targetId": .string(targetID)] - ) - throw CancellationError() - } + uncommittedTarget = (shared.connection, targetID) + try Task.checkCancellation() + guard generation == lifecycleGeneration else { throw CancellationError() } let attached = try await shared.connection.send( method: "Target.attachToTarget", parameters: ["targetId": .string(targetID), "flatten": .bool(true)] ) guard let sessionID = attached.objectValue?["sessionId"]?.stringValue else { - _ = try? await shared.connection.send( - method: "Target.closeTarget", - parameters: ["targetId": .string(targetID)] - ) throw BrowserEngineSessionError.chromiumProtocol("Chromium did not attach to the page target.") } guard generation == lifecycleGeneration, !Task.isCancelled else { - _ = try? await shared.connection.send( - method: "Target.closeTarget", - parameters: ["targetId": .string(targetID)] - ) throw CancellationError() } activeTargetIDs.insert(targetID) + uncommittedTarget = nil let lease = ChromiumProfileRuntimeLease( connection: shared.connection, targetID: targetID, @@ -82,6 +77,12 @@ public actor ChromiumProfileRuntime { pendingAcquisitionCount -= 1 return lease } catch { + if let uncommittedTarget { + await closeUncommittedTarget( + uncommittedTarget.id, + connection: uncommittedTarget.connection + ) + } pendingAcquisitionCount -= 1 await shutDownIfUnused() throw error @@ -173,6 +174,18 @@ public actor ChromiumProfileRuntime { await shutDown() } + private func closeUncommittedTarget(_ targetID: String, connection: CDPConnection) async { + // This awaited cleanup task intentionally does not inherit caller cancellation; a target + // created by a cancelled acquisition still belongs to this runtime until it is closed. + let cleanupTask = Task { + _ = try? await connection.send( + method: "Target.closeTarget", + parameters: ["targetId": .string(targetID)] + ) + } + await cleanupTask.value + } + private func shutDown() async { if let shutdownTask { await shutdownTask.value diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift index 7d372a7c7b2..e26a2f5b864 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/WebKitBrowserEngineSession.swift @@ -83,11 +83,10 @@ public final class WebKitBrowserEngineSession: BrowserEngineSession { case .page: .page case .isolated: .defaultClient } - let functionBody = "return await (\n\(script)\n);" return try await withCheckedThrowingContinuation { continuation in webView.callAsyncJavaScript( - functionBody, - arguments: [:], + "return await eval(script);", + arguments: ["script": script], in: nil, in: contentWorld ) { result in diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift index 1bc1ae174cf..c3813bb398b 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift @@ -3,13 +3,26 @@ import Foundation actor BufferedCDPWebSocketTransport: CDPWebSocketTransport { private var bufferedData: [Data] = [] + private var sentData: [Data] = [] private var receiveContinuation: CheckedContinuation? private var receiveCount = 0 private var receiveCountWaiters: [(count: Int, continuation: CheckedContinuation)] = [] nonisolated func resume() {} - func send(_: Data) async throws {} + func send(_ data: Data) async throws { + sentData.append(data) + } + + func sentCommandCount(method: String) -> Int { + sentData.reduce(into: 0) { count, data in + guard let payload = try? JSONDecoder().decode( + [String: CDPJSONValue].self, + from: data + ), payload["method"] == .string(method) else { return } + count += 1 + } + } func receive() async throws -> Data { receiveCount += 1 diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift index ce6948af18a..ddb3157189d 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -64,6 +64,7 @@ import Testing let connection = CDPConnection(transport: transport) await connection.connect() let events = await connection.events(sessionID: "target") + let frames = await connection.screencastFrames(sessionID: "target") let controlEvent = try JSONSerialization.data(withJSONObject: [ "method": "Fetch.requestPaused", "sessionId": "target", @@ -82,8 +83,13 @@ import Testing var iterator = events.makeAsyncIterator() let retainedEvent = await iterator.next() + let droppedFrameAcknowledgementCount = await transport.sentCommandCount( + method: "Page.screencastFrameAck" + ) + _ = frames await connection.close() #expect(retainedEvent?.method == "Fetch.requestPaused") + #expect(droppedFrameAcknowledgementCount == 299) } } diff --git a/Sources/Panels/BrowserAutomation.swift b/Sources/Panels/BrowserAutomation.swift index c81bb9a8229..bc08131e45a 100644 --- a/Sources/Panels/BrowserAutomation.swift +++ b/Sources/Panels/BrowserAutomation.swift @@ -258,37 +258,39 @@ enum BrowserProfileAutomation { ] } + @MainActor static func delete(params: [String: Any]) async throws -> [String: Any] { let query = try requiredString(params, keys: ["profile", "id", "name"]) - let profile = try await MainActor.run { - let profiles = BrowserProfileStore.shared.profiles - guard let profile = try resolveProfile(query, profiles: profiles) else { - throw BrowserProfileAutomationError.profileNotFound(query) - } - guard !profile.isBuiltInDefault else { - throw BrowserProfileAutomationError.cannotDeleteDefaultProfile - } - let livePanelCount = liveBrowserPanelCount(profileID: profile.id) - guard livePanelCount == 0 else { - throw BrowserProfileAutomationError.profileInUse(profile.displayName, livePanelCount) - } - return profile + let profiles = BrowserProfileStore.shared.profiles + guard let profile = try resolveProfile(query, profiles: profiles) else { + throw BrowserProfileAutomationError.profileNotFound(query) + } + guard !profile.isBuiltInDefault else { + throw BrowserProfileAutomationError.cannotDeleteDefaultProfile + } + let initialLivePanelCount = liveBrowserPanelCount(profileID: profile.id) + guard initialLivePanelCount == 0 else { + throw BrowserProfileAutomationError.profileInUse( + profile.displayName, + initialLivePanelCount + ) } _ = await BrowserProfileStore.shared.clearProfileData(id: profile.id) - return try await MainActor.run { - let livePanelCount = liveBrowserPanelCount(profileID: profile.id) - guard livePanelCount == 0 else { - throw BrowserProfileAutomationError.profileInUse(profile.displayName, livePanelCount) - } - guard let deleted = BrowserProfileStore.shared.deleteProfile(id: profile.id) else { - throw BrowserProfileAutomationError.profileDeleteFailed(profile.displayName) - } - return [ - "deleted": true, - "profile": profilePayload(deleted, currentProfileID: BrowserProfileStore.shared.effectiveLastUsedProfileID), - ] + let livePanelCount = liveBrowserPanelCount(profileID: profile.id) + guard livePanelCount == 0 else { + throw BrowserProfileAutomationError.profileInUse(profile.displayName, livePanelCount) + } + guard let deleted = await BrowserProfileStore.shared.deleteProfile(id: profile.id) else { + throw BrowserProfileAutomationError.profileDeleteFailed(profile.displayName) } + return [ + "deleted": true, + "profile": profilePayload( + deleted, + currentProfileID: BrowserProfileStore.shared.effectiveLastUsedProfileID + ), + ] } @MainActor diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 58c23676491..7cdb67b09f7 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -416,12 +416,15 @@ final class BrowserProfileStore: ObservableObject { repository.canRenameProfile(id: id) } - func deleteProfile(id: UUID) -> BrowserProfileDefinition? { - let result = repository.deleteProfile(id: id) - if result != nil { - removeChromiumRuntimeAndData(for: id) - } + func deleteProfile(id: UUID) async -> BrowserProfileDefinition? { + guard let result = repository.deleteProfile(id: id) else { return nil } mirrorPublishedState() + let runtime = chromiumRuntimes.removeValue(forKey: id) + if let runtime { + await runtime.close() + } + let directory = chromiumProfileDirectory.url(profileID: id) + await fileRemover.removeItemIfExists(at: directory) return result } @@ -468,17 +471,6 @@ final class BrowserProfileStore: ObservableObject { return runtime } - private func removeChromiumRuntimeAndData(for profileID: UUID) { - let runtime = chromiumRuntimes.removeValue(forKey: profileID) - let directory = chromiumProfileDirectory.url(profileID: profileID) - Task { [fileRemover] in - if let runtime { - await runtime.close() - } - await fileRemover.removeItemIfExists(at: directory) - } - } - func flushPendingSaves() { repository.flushPendingSaves() } diff --git a/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift b/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift index 5c0bfe03bb3..ac13ec29dc6 100644 --- a/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift +++ b/cmuxTests/BrowserPanelChromiumHistoryRecordingTests.swift @@ -12,7 +12,8 @@ import Testing @Suite(.serialized) struct BrowserPanelChromiumHistoryRecordingTests { @Test - func recordsEachCompletedNavigationOnceDespiteTitleUpdates() throws { + func recordsEachCompletedNavigationOnceDespiteTitleUpdates() async throws { + let url = try #require(URL(string: "https://example.com/chromium-history")) let profileStore = BrowserProfileStore.shared let profile = try #require(profileStore.createProfile( named: "Chromium-History-\(UUID().uuidString)" @@ -25,12 +26,6 @@ struct BrowserPanelChromiumHistoryRecordingTests { renderInitialNavigation: false, engineSelection: BrowserEngineSelection(kind: .chromium) ) - defer { - panel.close() - historyStore.clearHistory() - _ = profileStore.deleteProfile(id: profile.id) - } - let url = try #require(URL(string: "https://example.com/chromium-history")) panel.applyChromiumEngineState(BrowserEngineState( url: url, @@ -52,5 +47,9 @@ struct BrowserPanelChromiumHistoryRecordingTests { )) #expect(historyStore.entries.first?.visitCount == 2) + panel.close() + historyStore.clearHistory() + let deletedProfile = await profileStore.deleteProfile(id: profile.id) + #expect(deletedProfile?.id == profile.id) } } From 8ec926ee271cb20781d26430271b46a0375dd0bb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:49:51 -0700 Subject: [PATCH 36/39] Test bounded Chromium control event pressure --- .../BufferedCDPWebSocketTransport.swift | 5 +++++ .../Engine/CDPConnectionTests.swift | 21 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift index c3813bb398b..1f467324fe1 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/BufferedCDPWebSocketTransport.swift @@ -58,6 +58,11 @@ actor BufferedCDPWebSocketTransport: CDPWebSocketTransport { private func finish() { receiveContinuation?.resume(throwing: CancellationError()) receiveContinuation = nil + let waiters = receiveCountWaiters + receiveCountWaiters.removeAll() + for waiter in waiters { + waiter.continuation.resume() + } } private func resumeReceiveCountWaiters() { diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift index ddb3157189d..38437179a53 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/CDPConnectionTests.swift @@ -92,4 +92,25 @@ import Testing #expect(retainedEvent?.method == "Fetch.requestPaused") #expect(droppedFrameAcknowledgementCount == 299) } + + @Test func controlEventPressureClosesConnectionInsteadOfGrowingWithoutBound() async throws { + let transport = BufferedCDPWebSocketTransport() + let connection = CDPConnection(transport: transport) + await connection.connect() + let events = await connection.events(sessionID: "target") + let controlEvent = try JSONSerialization.data(withJSONObject: [ + "method": "Page.frameStartedLoading", + "sessionId": "target", + "params": ["frameId": "main"], + ]) + + for _ in 0..<300 { + guard await connection.isOpen() else { break } + await transport.deliverAndWaitUntilConsumed(controlEvent) + } + + _ = events + #expect(await connection.isOpen() == false) + await connection.close() + } } From d12a9e978e701bc528ee8e886ed71b2695d6a50f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:50:29 -0700 Subject: [PATCH 37/39] Bound Chromium control event buffering --- .../Engine/Chromium/CDPConnection.swift | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift index ba0f895ff7b..4922966e5d7 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/CDPConnection.swift @@ -2,6 +2,8 @@ import Foundation /// Owns one browser-level Chrome DevTools Protocol WebSocket connection. actor CDPConnection { + private static let controlEventBufferCapacity = 256 + private let transport: any CDPWebSocketTransport private let requestTimeout: Duration private let encoder = JSONEncoder() @@ -37,10 +39,11 @@ actor CDPConnection { func events(sessionID: String) -> AsyncStream { let subscriberID = UUID() // High-volume frames use their own coalescing stream below. The remaining - // control events are lossless because dropping one can suspend protocol state. + // control events are lossless until this finite queue fills. Overflow closes + // the connection below because dropping one can suspend protocol state. let (stream, continuation) = AsyncStream.makeStream( of: CDPEvent.self, - bufferingPolicy: .unbounded + bufferingPolicy: .bufferingOldest(Self.controlEventBufferCapacity) ) guard !isClosed else { continuation.finish() @@ -232,10 +235,16 @@ actor CDPConnection { event.sessionID == nil || event.sessionID == subscriber.sessionID { let yieldResult = subscriber.continuation.yield(event) if case .dropped(let droppedEvent) = yieldResult { - try await acknowledgeDroppedScreencastFrame( - droppedEvent, - subscriberSessionID: subscriber.sessionID - ) + if method == "Page.screencastFrame" { + try await acknowledgeDroppedScreencastFrame( + droppedEvent, + subscriberSessionID: subscriber.sessionID + ) + } else { + throw BrowserEngineSessionError.chromiumProtocol( + "DevTools control-event buffer overflowed." + ) + } } } } From 4b934cb20de5524108f72bab9da73f9b81103fd1 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:55:08 -0700 Subject: [PATCH 38/39] Test Chromium main-frame loading state --- .../ChromiumBrowserEngineSessionTests.swift | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift index ae1d61f70ee..f773ea09715 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/Engine/ChromiumBrowserEngineSessionTests.swift @@ -183,6 +183,65 @@ struct ChromiumBrowserEngineSessionTests { await connection.close() } + @Test + func subframeLifecycleDoesNotChangeTopLevelLoadingState() async { + let session = ChromiumBrowserEngineSession( + viewportWebView: WKWebView(), + application: nil, + userDataDirectory: FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: true) + ) + let connection = CDPConnection(url: URL(string: "ws://localhost.invalid")!) + let sessionID = "test-session" + defer { session.close() } + + await session.handle( + CDPEvent( + method: "Page.frameNavigated", + parameters: [ + "frame": .object([ + "id": .string("main-frame"), + "url": .string("https://example.com"), + ]), + ], + sessionID: sessionID + ), + connection: connection, + sessionID: sessionID + ) + await session.handle( + loadingEvent(method: "Page.frameStartedLoading", frameID: "child-frame", sessionID: sessionID), + connection: connection, + sessionID: sessionID + ) + #expect(session.state.isLoading == false) + + await session.handle( + loadingEvent(method: "Page.frameStartedLoading", frameID: "main-frame", sessionID: sessionID), + connection: connection, + sessionID: sessionID + ) + await session.handle( + loadingEvent(method: "Page.frameStoppedLoading", frameID: "child-frame", sessionID: sessionID), + connection: connection, + sessionID: sessionID + ) + #expect(session.state.isLoading) + + await session.handle( + loadingEvent(method: "Page.frameStoppedLoading", frameID: "main-frame", sessionID: sessionID), + connection: connection, + sessionID: sessionID + ) + await session.handle( + loadingEvent(method: "Page.frameStartedLoading", frameID: "child-frame", sessionID: sessionID), + connection: connection, + sessionID: sessionID + ) + #expect(session.state.isLoading == false) + await connection.close() + } + @Test func rejectsNavigationRequestsWhoseSemanticsCannotBePreserved() { let url = URL(string: "https://example.com/submit")! @@ -230,4 +289,12 @@ struct ChromiumBrowserEngineSessionTests { .localizedDescription != "secret protocol detail" ) } + + private func loadingEvent(method: String, frameID: String, sessionID: String) -> CDPEvent { + CDPEvent( + method: method, + parameters: ["frameId": .string(frameID)], + sessionID: sessionID + ) + } } From 5bd81fdf2d63624341feb8673addfba4da4993cb Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 15 Jul 2026 07:56:01 -0700 Subject: [PATCH 39/39] Track Chromium main-frame loading state --- .../ChromiumBrowserEngineSession.swift | 11 ++++++- .../Chromium/ChromiumMainFrameIdentity.swift | 29 +++++++++++++++++++ .../ChromiumNavigationInterceptor.swift | 24 +++++---------- 3 files changed, 46 insertions(+), 18 deletions(-) create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumMainFrameIdentity.swift diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift index e1e67291157..1512d02482a 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumBrowserEngineSession.swift @@ -38,6 +38,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { private let viewportHandler = ChromiumViewportMessageHandler() private let cookieCodec = ChromiumBrowserCookieCodec() private let documentTitleObservation = ChromiumDocumentTitleObservation() + private let mainFrameIdentity = ChromiumMainFrameIdentity() private var navigationInterceptor: ChromiumNavigationInterceptor? var connection: CDPConnection? private var targetID: String? @@ -426,7 +427,8 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { _ = try await connection.send(method: "Runtime.enable", sessionID: sessionID) let navigationInterceptor = ChromiumNavigationInterceptor( targetID: lease.targetID, - policyHandler: navigationPolicyHandler + policyHandler: navigationPolicyHandler, + mainFrameIdentity: mainFrameIdentity ) self.navigationInterceptor = navigationInterceptor try await navigationInterceptor.install( @@ -540,6 +542,7 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } func handle(_ event: CDPEvent, connection: CDPConnection, sessionID: String) async { + mainFrameIdentity.observe(event) do { if try await navigationInterceptor?.handle( event, @@ -557,8 +560,14 @@ public final class ChromiumBrowserEngineSession: BrowserEngineSession { } switch event.method { case "Page.frameStartedLoading": + guard mainFrameIdentity.matches( + frameID: event.parameters["frameId"]?.stringValue + ) else { return } updateState { $0.isLoading = true } case "Page.frameStoppedLoading": + guard mainFrameIdentity.matches( + frameID: event.parameters["frameId"]?.stringValue + ) else { return } updateState { $0.isLoading = false } case "Page.loadEventFired": await refreshDocumentState(connection: connection, sessionID: sessionID) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumMainFrameIdentity.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumMainFrameIdentity.swift new file mode 100644 index 00000000000..c34f7c71715 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumMainFrameIdentity.swift @@ -0,0 +1,29 @@ +/// Owns the Chromium main-frame identity shared by navigation policy and pane loading state. +@MainActor +final class ChromiumMainFrameIdentity { + private var frameID: String? + + /// Seeds the identity from `Page.getFrameTree`. + func record(frameTree: CDPJSONValue) { + frameID = frameTree.objectValue?["frameTree"]? + .objectValue?["frame"]? + .objectValue?["id"]? + .stringValue + } + + /// Updates the identity when Chromium reports a top-level frame navigation. + func observe(_ event: CDPEvent) { + guard event.method == "Page.frameNavigated", + let frame = event.parameters["frame"]?.objectValue, + frame["parentId"] == nil, + let nextFrameID = frame["id"]?.stringValue else { + return + } + frameID = nextFrameID + } + + /// Returns whether a CDP frame identifier belongs to the current top-level frame. + func matches(frameID candidate: String?) -> Bool { + candidate != nil && candidate == frameID + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift index 31edec9d0c1..ebeccb5a136 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Engine/Chromium/ChromiumNavigationInterceptor.swift @@ -4,15 +4,17 @@ import Foundation @MainActor final class ChromiumNavigationInterceptor { private let targetID: String + private let mainFrameIdentity: ChromiumMainFrameIdentity var policyHandler: BrowserEngineNavigationPolicyHandler? - private var mainFrameID: String? init( targetID: String, - policyHandler: BrowserEngineNavigationPolicyHandler? + policyHandler: BrowserEngineNavigationPolicyHandler?, + mainFrameIdentity: ChromiumMainFrameIdentity = ChromiumMainFrameIdentity() ) { self.targetID = targetID self.policyHandler = policyHandler + self.mainFrameIdentity = mainFrameIdentity } func install(connection: CDPConnection, sessionID: String) async throws { @@ -20,10 +22,7 @@ final class ChromiumNavigationInterceptor { method: "Page.getFrameTree", sessionID: sessionID ) - mainFrameID = frameTree.objectValue?["frameTree"]? - .objectValue?["frame"]? - .objectValue?["id"]? - .stringValue + mainFrameIdentity.record(frameTree: frameTree) _ = try await connection.send( method: "Fetch.enable", @@ -64,7 +63,7 @@ final class ChromiumNavigationInterceptor { case "Target.attachedToTarget": return try await handleAttachedTarget(event, connection: connection) case "Page.frameNavigated": - updateMainFrameID(event) + mainFrameIdentity.observe(event) return false default: return false @@ -82,7 +81,7 @@ final class ChromiumNavigationInterceptor { ) } guard event.parameters["resourceType"]?.stringValue == "Document", - event.parameters["frameId"]?.stringValue == mainFrameID, + mainFrameIdentity.matches(frameID: event.parameters["frameId"]?.stringValue), let request = navigationRequest(from: event.parameters["request"]?.objectValue) else { try await continueRequest(requestID, connection: connection, sessionID: sessionID) return @@ -142,15 +141,6 @@ final class ChromiumNavigationInterceptor { return true } - private func updateMainFrameID(_ event: CDPEvent) { - guard let frame = event.parameters["frame"]?.objectValue, - frame["parentId"] == nil, - let frameID = frame["id"]?.stringValue else { - return - } - mainFrameID = frameID - } - private func navigationRequest( from payload: [String: CDPJSONValue]? ) -> URLRequest? {