From c56ee7d57fd4b5db1a17a1175e86a26f122b7d15 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 16:14:25 -0700 Subject: [PATCH 1/9] test: reproduce Google Sheets identity replay --- cmuxTests/BrowserUserAgentPolicyWebKitTests.swift | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index 7b2a4acddf77..5f4701fe8442 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -41,6 +41,16 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } + @Test func emptyCustomUserAgentIsAlreadyEmbeddedIdentityForSheets() { + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + webView.customUserAgent = "" + let request = URLRequest( + url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! + ) + + #expect(webView.browserUserAgentPolicyRestartRequest(for: request) == nil) + } + @Test func restartRequestIgnoresSubframesAndNewWindowTargets() throws { let request = URLRequest(url: URL(string: "https://workspace.google.com/")!) From f170ffef77e5b73ddc789bc7562bddcc4a58ac65 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 3 Aug 2026 16:20:41 -0700 Subject: [PATCH 2/9] fix: make browser identity replay idempotent --- Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift | 5 ++++- cmuxTests/BrowserUserAgentPolicyWebKitTests.swift | 6 +++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift index 68a4344eee8c..68514a6d2e86 100644 --- a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift +++ b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift @@ -6,6 +6,8 @@ extension WKWebView { @MainActor @discardableResult func applyBrowserUserAgentPolicy(for url: URL?) -> Bool { + // WebKit exposes its native identity as either nil or an empty string across load phases. + let currentUserAgent = customUserAgent.flatMap { $0.isEmpty ? nil : $0 } let resolvedUserAgent: String? switch BrowserUserAgentPolicy.system.resolution(for: url) { case .custom(let userAgent): @@ -13,11 +15,12 @@ extension WKWebView { case .webKitDefault: resolvedUserAgent = nil case .notApplicable: + guard currentUserAgent != nil else { return false } customUserAgent = nil return false } - guard customUserAgent != resolvedUserAgent else { return false } + guard currentUserAgent != resolvedUserAgent else { return false } customUserAgent = resolvedUserAgent return true } diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index 5f4701fe8442..d26473eaf33b 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -37,7 +37,7 @@ struct BrowserUserAgentPolicyWebKitTests { webView.browserUserAgentPolicyRestartRequest(for: request) ) - #expect(webView.customUserAgent == nil) + #expect(webView.customUserAgent?.isEmpty != false) #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } @@ -61,7 +61,7 @@ struct BrowserUserAgentPolicyWebKitTests { for: request, targetFrameIsMainFrame: targetFrameIsMainFrame ) == nil) - #expect(webView.customUserAgent == nil) + #expect(webView.customUserAgent?.isEmpty != false) } } @@ -71,6 +71,6 @@ struct BrowserUserAgentPolicyWebKitTests { let request = URLRequest(url: URL(fileURLWithPath: "/tmp/example.html")) #expect(webView.browserUserAgentPolicyRestartRequest(for: request) == nil) - #expect(webView.customUserAgent == nil) + #expect(webView.customUserAgent?.isEmpty != false) } } From 1c906c1a2dcaa924d2eb3980e2384ae3f719cfdc Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Mon, 3 Aug 2026 21:53:07 -0700 Subject: [PATCH 3/9] test: reject stale Sheets identity fallback --- .../BrowserUserAgentPolicyWebKitTests.swift | 34 +++---------------- 1 file changed, 5 insertions(+), 29 deletions(-) diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index d7cfed26e702..a06be4aa3555 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -74,21 +74,7 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.customUserAgent?.isEmpty != false) } - @Test func sheetsDestinationTreatsEmptyReportedIdentityAsWebKitDefault() { - let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) - // Some macOS versions report the native identity as "" instead of nil - // after a restart clears the custom identity; both must satisfy the - // webKitDefault resolution or Sheets restarts forever (issue #9462). - webView.customUserAgent = "" - let request = URLRequest( - url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! - ) - - #expect(BrowserUserAgentPolicy.system.resolution(for: request.url) == .webKitDefault) - #expect(webView.browserUserAgentPolicyRestartRequest(for: request) == nil) - } - - @Test func sheetsDestinationRestartsAtMostOnceWhenIdentityNeverConverges() throws { + @Test func sheetsDestinationNeverAllowsStaleSafariIdentity() throws { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) let request = URLRequest( url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! @@ -107,27 +93,17 @@ struct BrowserUserAgentPolicyWebKitTests { #expect((webView.customUserAgent ?? "").isEmpty) let replacementRequest = try #require(replacementRequests.first) - // Simulate a WebKit whose reported identity never matches the resolved - // policy: the replacement pass must proceed instead of restarting again. - webView.customUserAgent = BrowserUserAgentPolicy.system.safariCompatibleUserAgent - #expect(!webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: replacementRequest, - targetFrameIsMainFrame: true, - decisionHandler: { decisions.append($0) }, - startReplacement: { replacementRequests.append($0) } - )) - #expect(decisions == [.cancel]) - #expect(replacementRequests.count == 1) - - // A later navigation to the same destination may restart once again. + // If another load phase exposes the stale Safari identity again, the + // replacement must correct it instead of silently allowing a downgrade. webView.customUserAgent = BrowserUserAgentPolicy.system.safariCompatibleUserAgent #expect(webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: request, + for: replacementRequest, targetFrameIsMainFrame: true, decisionHandler: { decisions.append($0) }, startReplacement: { replacementRequests.append($0) } )) #expect(decisions == [.cancel, .cancel]) #expect(replacementRequests.count == 2) + #expect((webView.customUserAgent ?? "").isEmpty) } } From 6770f596be305eb76809e400c84041cfff454055 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Mon, 3 Aug 2026 22:03:28 -0700 Subject: [PATCH 4/9] revert: remove stale browser identity fallback --- .../WKWebView+BrowserUserAgentPolicy.swift | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift index 553064a612aa..75d4c34ccddd 100644 --- a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift +++ b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift @@ -1,9 +1,6 @@ import CmuxBrowser -import ObjectiveC import WebKit -private var cmuxBrowserUserAgentPolicyRestartedURLKey: UInt8 = 0 - extension WKWebView { /// Applies the destination identity and reports whether an HTTP(S) navigation must restart. @MainActor @@ -45,26 +42,6 @@ extension WKWebView { return browserUserAgentPolicyRestartRequest(for: request) } - /// Destination of the most recent user-agent-policy restart. Each - /// destination may restart at most once in a row: when the replacement - /// load still reports a mismatched identity, the comparison cannot - /// converge on this system and the load must proceed instead of - /// cancel/reload looping on the main thread (issue #9462). - @MainActor - private var browserUserAgentPolicyRestartedURL: URL? { - get { - objc_getAssociatedObject(self, &cmuxBrowserUserAgentPolicyRestartedURLKey) as? URL - } - set { - objc_setAssociatedObject( - self, - &cmuxBrowserUserAgentPolicyRestartedURLKey, - newValue, - .OBJC_ASSOCIATION_RETAIN_NONATOMIC - ) - } - } - @MainActor func restartNavigationForBrowserUserAgentPolicyIfNeeded( for request: URLRequest, @@ -73,11 +50,6 @@ extension WKWebView { willRestart: () -> Void = {}, startReplacement: (URLRequest) -> Void ) -> Bool { - if targetFrameIsMainFrame == true, let url = request.url { - let alreadyRestartedForDestination = browserUserAgentPolicyRestartedURL == url - browserUserAgentPolicyRestartedURL = nil - if alreadyRestartedForDestination { return false } - } guard let restartRequest = browserUserAgentPolicyRestartRequest( for: request, targetFrameIsMainFrame: targetFrameIsMainFrame @@ -85,7 +57,6 @@ extension WKWebView { return false } - browserUserAgentPolicyRestartedURL = request.url willRestart() decisionHandler(.cancel) startReplacement(restartRequest) From c3cc0d15229c1eeaa4befb5fda6c8f73978c26e8 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Mon, 3 Aug 2026 23:40:34 -0700 Subject: [PATCH 5/9] revert: remove #9483 browser policy changes Restore the original navigation-action API and remove the regression test that encoded the same-URL stale-identity fallback. Keep PR #9482 nil/empty normalization as the sole convergence rule. --- .../Panels/BrowserNavigationDelegate.swift | 3 +- .../Panels/BrowserPopupWindowController.swift | 3 +- .../WKWebView+BrowserUserAgentPolicy.swift | 7 ++-- .../BrowserUserAgentPolicyWebKitTests.swift | 33 ------------------- 4 files changed, 5 insertions(+), 41 deletions(-) diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 9d54c24c1b6c..e8d4ed0ec77d 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -576,8 +576,7 @@ import WebKit let replacedNavigation = activeMainFrameNavigation let reportReplacementWillStart = willReplaceNavigationForUserAgentPolicy return webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: navigationAction.request, - targetFrameIsMainFrame: navigationAction.targetFrame?.isMainFrame, + navigationAction, decisionHandler: decisionHandler, willRestart: { reportReplacementWillStart?(webView, replacedNavigation) diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index cf40e503ffd2..04b959802867 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -789,8 +789,7 @@ private class PopupUIDelegate: BrowserPDFPreviewActionUIDelegate { } return webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: navigationAction.request, - targetFrameIsMainFrame: navigationAction.targetFrame?.isMainFrame, + navigationAction, decisionHandler: decisionHandler, startReplacement: { restartRequest in controller.requestNavigation(restartRequest, in: webView) diff --git a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift index 75d4c34ccddd..68514a6d2e86 100644 --- a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift +++ b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift @@ -44,15 +44,14 @@ extension WKWebView { @MainActor func restartNavigationForBrowserUserAgentPolicyIfNeeded( - for request: URLRequest, - targetFrameIsMainFrame: Bool?, + _ navigationAction: WKNavigationAction, decisionHandler: (WKNavigationActionPolicy) -> Void, willRestart: () -> Void = {}, startReplacement: (URLRequest) -> Void ) -> Bool { guard let restartRequest = browserUserAgentPolicyRestartRequest( - for: request, - targetFrameIsMainFrame: targetFrameIsMainFrame + for: navigationAction.request, + targetFrameIsMainFrame: navigationAction.targetFrame?.isMainFrame ) else { return false } diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index a06be4aa3555..d26473eaf33b 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -73,37 +73,4 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.browserUserAgentPolicyRestartRequest(for: request) == nil) #expect(webView.customUserAgent?.isEmpty != false) } - - @Test func sheetsDestinationNeverAllowsStaleSafariIdentity() throws { - let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) - let request = URLRequest( - url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! - ) - var decisions: [WKNavigationActionPolicy] = [] - var replacementRequests: [URLRequest] = [] - - webView.customUserAgent = BrowserUserAgentPolicy.system.safariCompatibleUserAgent - #expect(webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: request, - targetFrameIsMainFrame: true, - decisionHandler: { decisions.append($0) }, - startReplacement: { replacementRequests.append($0) } - )) - #expect(decisions == [.cancel]) - #expect((webView.customUserAgent ?? "").isEmpty) - let replacementRequest = try #require(replacementRequests.first) - - // If another load phase exposes the stale Safari identity again, the - // replacement must correct it instead of silently allowing a downgrade. - webView.customUserAgent = BrowserUserAgentPolicy.system.safariCompatibleUserAgent - #expect(webView.restartNavigationForBrowserUserAgentPolicyIfNeeded( - for: replacementRequest, - targetFrameIsMainFrame: true, - decisionHandler: { decisions.append($0) }, - startReplacement: { replacementRequests.append($0) } - )) - #expect(decisions == [.cancel, .cancel]) - #expect(replacementRequests.count == 2) - #expect((webView.customUserAgent ?? "").isEmpty) - } } From a224b9a5d5b9ff031166492907805a29745d2512 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 4 Aug 2026 00:46:31 -0700 Subject: [PATCH 6/9] test: require supported Sheets transport identity --- .../BrowserUserAgentPolicyWebKitTests.swift | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index d26473eaf33b..132becbd843e 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -41,6 +41,24 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } + @Test func sheetsRequestAdvertisesCurrentSafariWithoutChangingEmbeddedIdentity() throws { + let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) + let request = URLRequest( + url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! + ) + + let restartRequest = try #require( + webView.browserUserAgentPolicyRestartRequest(for: request) + ) + + #expect( + restartRequest.value(forHTTPHeaderField: "User-Agent") + == BrowserUserAgentPolicy.system.safariCompatibleUserAgent + ) + #expect(webView.customUserAgent?.isEmpty != false) + #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) + } + @Test func emptyCustomUserAgentIsAlreadyEmbeddedIdentityForSheets() { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) webView.customUserAgent = "" From 1b2763d4ce32da777671233bd9c1da0d059b2449 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 4 Aug 2026 00:54:59 -0700 Subject: [PATCH 7/9] fix: separate Sheets transport and WebKit identities --- .../CmuxBrowser/BrowserUserAgentPolicy.swift | 5 +- .../BrowserUserAgentPolicyResolution.swift | 5 +- .../BrowserUserAgentPolicyTests.swift | 11 ++-- Sources/Panels/BrowserPanel.swift | 4 +- .../Panels/BrowserPrewarmedWebViewPool.swift | 3 +- .../WKWebView+BrowserUserAgentPolicy.swift | 50 +++++++++++++++++-- .../BrowserUserAgentPolicyWebKitTests.swift | 13 ++++- 7 files changed, 72 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift index 6f778f20c3fe..115ad6ea765b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift @@ -57,7 +57,8 @@ public struct BrowserUserAgentPolicy: Sendable { /// Resolves the browser identity policy for a top-level destination. /// - /// Google Sheets intentionally uses WebKit's default embedded identity. + /// Google Sheets keeps WebKit's embedded capability identity while its + /// top-level request advertises current Safari for browser support gates. /// Non-web destinations have no applicable user-agent policy. /// /// - Parameter url: The destination of the top-level navigation. @@ -76,7 +77,7 @@ public struct BrowserUserAgentPolicy: Sendable { && url.path.split(separator: "/", omittingEmptySubsequences: true).first? .lowercased() == "spreadsheets" return isSheetsHost || isSheetsPath - ? .webKitDefault + ? .webKitDefault(topLevelRequestUserAgent: safariCompatibleUserAgent) : .custom(safariCompatibleUserAgent) } diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift index 90202698ee52..9765ae79d78b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift @@ -3,8 +3,9 @@ public enum BrowserUserAgentPolicyResolution: Equatable, Sendable { /// Use the supplied custom user-agent identity for an HTTP or HTTPS destination. case custom(String) - /// Use WebKit's default embedded identity for an HTTP or HTTPS destination. - case webKitDefault + /// Keep WebKit's embedded identity while advertising a supported identity + /// on the top-level HTTP request. + case webKitDefault(topLevelRequestUserAgent: String) /// User-agent identity does not apply to this non-web destination. case notApplicable diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift index 389f02f21d45..cb99c7760d7c 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift @@ -27,14 +27,17 @@ struct BrowserUserAgentPolicyTests { #expect(newerPolicy.safariCompatibleUserAgent.contains("Version/27.0 Safari/605.1.15")) } - @Test func googleSheetsKeepsEmbeddedWebKitIdentity() { + @Test func googleSheetsKeepsEmbeddedIdentityWhileAdvertisingCurrentSafari() { let sheetURL = URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! let sheetsRedirectURL = URL(string: "https://sheets.google.com/")! let legacyRedirectURL = URL(string: "https://spreadsheets.google.com/")! + let expectedResolution = BrowserUserAgentPolicyResolution.webKitDefault( + topLevelRequestUserAgent: policy.safariCompatibleUserAgent + ) - #expect(policy.resolution(for: sheetURL) == .webKitDefault) - #expect(policy.resolution(for: sheetsRedirectURL) == .webKitDefault) - #expect(policy.resolution(for: legacyRedirectURL) == .webKitDefault) + #expect(policy.resolution(for: sheetURL) == expectedResolution) + #expect(policy.resolution(for: sheetsRedirectURL) == expectedResolution) + #expect(policy.resolution(for: legacyRedirectURL) == expectedResolution) } @Test func otherGoogleWorkspaceEditorsRemainSafariCompatible() { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index 04f7e2611a66..f75a84a03211 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -973,7 +973,7 @@ func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager @discardableResult func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? { guard let url = request.url else { return nil } - webView.applyBrowserUserAgentPolicy(for: url) + let policyRequest = webView.browserUserAgentPolicyPreparedRequest(for: request) let nudgeReason = "navigationStart:\(url.scheme?.lowercased() ?? "none")" if url.isFileURL { guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil } @@ -981,7 +981,7 @@ func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavig return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL) } webView.browserPortalMarkFirstSizedRevealNudgeIfNavigationStartsWithoutPresentation(reason: nudgeReason) - return webView.load(browserPreparedNavigationRequest(request)) + return webView.load(browserPreparedNavigationRequest(policyRequest)) } private let browserEmbeddedNavigationSchemes: Set = [ diff --git a/Sources/Panels/BrowserPrewarmedWebViewPool.swift b/Sources/Panels/BrowserPrewarmedWebViewPool.swift index bbea1e659c16..f7219c212578 100644 --- a/Sources/Panels/BrowserPrewarmedWebViewPool.swift +++ b/Sources/Panels/BrowserPrewarmedWebViewPool.swift @@ -48,8 +48,7 @@ final class BrowserPrewarmedWebViewPool: NSObject { BrowserPanel.makeWebView(profileID: profileID) }, startLoad: @escaping @MainActor (CmuxWebView, URLRequest) -> Void = { webView, request in - webView.applyBrowserUserAgentPolicy(for: request.url) - webView.load(request) + webView.load(webView.browserUserAgentPolicyPreparedRequest(for: request)) }, expirySleep: @escaping @Sendable (Duration) async throws -> Void = { duration in try await Task.sleep(for: duration) diff --git a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift index 68514a6d2e86..278d12d1de89 100644 --- a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift +++ b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift @@ -6,10 +6,17 @@ extension WKWebView { @MainActor @discardableResult func applyBrowserUserAgentPolicy(for url: URL?) -> Bool { + applyBrowserUserAgentPolicy(BrowserUserAgentPolicy.system.resolution(for: url)) + } + + @MainActor + private func applyBrowserUserAgentPolicy( + _ resolution: BrowserUserAgentPolicyResolution + ) -> Bool { // WebKit exposes its native identity as either nil or an empty string across load phases. let currentUserAgent = customUserAgent.flatMap { $0.isEmpty ? nil : $0 } let resolvedUserAgent: String? - switch BrowserUserAgentPolicy.system.resolution(for: url) { + switch resolution { case .custom(let userAgent): resolvedUserAgent = userAgent case .webKitDefault: @@ -25,12 +32,45 @@ extension WKWebView { return true } + /// Applies the WebKit identity and prepares the top-level request identity. + @MainActor + func browserUserAgentPolicyPreparedRequest(for request: URLRequest) -> URLRequest { + let resolution = BrowserUserAgentPolicy.system.resolution(for: request.url) + _ = applyBrowserUserAgentPolicy(resolution) + + var preparedRequest = request + switch resolution { + case .custom: + preparedRequest.setValue(nil, forHTTPHeaderField: "User-Agent") + case .webKitDefault(let topLevelRequestUserAgent): + preparedRequest.setValue(topLevelRequestUserAgent, forHTTPHeaderField: "User-Agent") + case .notApplicable: + break + } + return preparedRequest + } + @MainActor func browserUserAgentPolicyRestartRequest(for request: URLRequest) -> URLRequest? { - guard applyBrowserUserAgentPolicy(for: request.url) else { return nil } - var restartRequest = request - restartRequest.setValue(nil, forHTTPHeaderField: "User-Agent") - return restartRequest + let resolution = BrowserUserAgentPolicy.system.resolution(for: request.url) + let identityChanged = applyBrowserUserAgentPolicy(resolution) + + switch resolution { + case .custom: + guard identityChanged else { return nil } + var restartRequest = request + restartRequest.setValue(nil, forHTTPHeaderField: "User-Agent") + return restartRequest + case .webKitDefault(let topLevelRequestUserAgent): + let requestIdentityChanged = request.value(forHTTPHeaderField: "User-Agent") + != topLevelRequestUserAgent + guard identityChanged || requestIdentityChanged else { return nil } + var restartRequest = request + restartRequest.setValue(topLevelRequestUserAgent, forHTTPHeaderField: "User-Agent") + return restartRequest + case .notApplicable: + return nil + } } @MainActor diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index 132becbd843e..99b4d38d249c 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -59,14 +59,23 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } - @Test func emptyCustomUserAgentIsAlreadyEmbeddedIdentityForSheets() { + @Test func emptyCustomUserAgentStillRequiresSupportedSheetsRequestIdentity() throws { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) webView.customUserAgent = "" let request = URLRequest( url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! ) - #expect(webView.browserUserAgentPolicyRestartRequest(for: request) == nil) + let restartRequest = try #require( + webView.browserUserAgentPolicyRestartRequest(for: request) + ) + + #expect( + restartRequest.value(forHTTPHeaderField: "User-Agent") + == BrowserUserAgentPolicy.system.safariCompatibleUserAgent + ) + #expect(webView.customUserAgent?.isEmpty != false) + #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } @Test func restartRequestIgnoresSubframesAndNewWindowTargets() throws { From fe19c0223034b414a4e8304822f407b296646b7c Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 4 Aug 2026 01:57:22 -0700 Subject: [PATCH 8/9] test: require current Safari identity for Sheets --- .../BrowserUserAgentPolicyTests.swift | 6 ++-- .../BrowserUserAgentPolicyWebKitTests.swift | 28 ++++++++----------- 2 files changed, 15 insertions(+), 19 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift index cb99c7760d7c..5bab3b30dd91 100644 --- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/BrowserUserAgentPolicyTests.swift @@ -27,12 +27,12 @@ struct BrowserUserAgentPolicyTests { #expect(newerPolicy.safariCompatibleUserAgent.contains("Version/27.0 Safari/605.1.15")) } - @Test func googleSheetsKeepsEmbeddedIdentityWhileAdvertisingCurrentSafari() { + @Test func googleSheetsReceivesCurrentSafariCompatibleIdentity() { let sheetURL = URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! let sheetsRedirectURL = URL(string: "https://sheets.google.com/")! let legacyRedirectURL = URL(string: "https://spreadsheets.google.com/")! - let expectedResolution = BrowserUserAgentPolicyResolution.webKitDefault( - topLevelRequestUserAgent: policy.safariCompatibleUserAgent + let expectedResolution = BrowserUserAgentPolicyResolution.custom( + policy.safariCompatibleUserAgent ) #expect(policy.resolution(for: sheetURL) == expectedResolution) diff --git a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift index 99b4d38d249c..b640268be771 100644 --- a/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift +++ b/cmuxTests/BrowserUserAgentPolicyWebKitTests.swift @@ -26,22 +26,24 @@ struct BrowserUserAgentPolicyWebKitTests { #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } - @Test func restartRequestCanRestoreEmbeddedIdentityForSheets() throws { + @Test func restartRequestReplacesStaleSheetsIdentity() throws { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) - webView.customUserAgent = BrowserUserAgentPolicy.system.safariCompatibleUserAgent - let request = URLRequest( + webView.customUserAgent = "stale-agent" + var request = URLRequest( url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! ) + request.setValue("stale-agent", forHTTPHeaderField: "User-Agent") let restartRequest = try #require( webView.browserUserAgentPolicyRestartRequest(for: request) ) - #expect(webView.customUserAgent?.isEmpty != false) + #expect(restartRequest.value(forHTTPHeaderField: "User-Agent") == nil) + #expect(webView.customUserAgent == BrowserUserAgentPolicy.system.safariCompatibleUserAgent) #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } - @Test func sheetsRequestAdvertisesCurrentSafariWithoutChangingEmbeddedIdentity() throws { + @Test func sheetsRequestUsesCurrentSafariForNetworkAndNavigatorIdentity() throws { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) let request = URLRequest( url: URL(string: "https://docs.google.com/spreadsheets/d/example/edit")! @@ -51,15 +53,12 @@ struct BrowserUserAgentPolicyWebKitTests { webView.browserUserAgentPolicyRestartRequest(for: request) ) - #expect( - restartRequest.value(forHTTPHeaderField: "User-Agent") - == BrowserUserAgentPolicy.system.safariCompatibleUserAgent - ) - #expect(webView.customUserAgent?.isEmpty != false) + #expect(restartRequest.value(forHTTPHeaderField: "User-Agent") == nil) + #expect(webView.customUserAgent == BrowserUserAgentPolicy.system.safariCompatibleUserAgent) #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } - @Test func emptyCustomUserAgentStillRequiresSupportedSheetsRequestIdentity() throws { + @Test func emptyCustomUserAgentIsNotAcceptedAsSheetsIdentity() throws { let webView = WKWebView(frame: .zero, configuration: WKWebViewConfiguration()) webView.customUserAgent = "" let request = URLRequest( @@ -70,11 +69,8 @@ struct BrowserUserAgentPolicyWebKitTests { webView.browserUserAgentPolicyRestartRequest(for: request) ) - #expect( - restartRequest.value(forHTTPHeaderField: "User-Agent") - == BrowserUserAgentPolicy.system.safariCompatibleUserAgent - ) - #expect(webView.customUserAgent?.isEmpty != false) + #expect(restartRequest.value(forHTTPHeaderField: "User-Agent") == nil) + #expect(webView.customUserAgent == BrowserUserAgentPolicy.system.safariCompatibleUserAgent) #expect(webView.browserUserAgentPolicyRestartRequest(for: restartRequest) == nil) } From c7374d1715f4e97c7c6afd7d7f936bc9e2907de5 Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 4 Aug 2026 02:00:26 -0700 Subject: [PATCH 9/9] fix: use current Safari identity for Sheets --- .../CmuxBrowser/BrowserUserAgentPolicy.swift | 14 +---- .../BrowserUserAgentPolicyResolution.swift | 4 -- Sources/Panels/BrowserPanel.swift | 4 +- .../Panels/BrowserPrewarmedWebViewPool.swift | 3 +- .../WKWebView+BrowserUserAgentPolicy.swift | 52 ++----------------- 5 files changed, 11 insertions(+), 66 deletions(-) diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift index 115ad6ea765b..7ae15a62b407 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicy.swift @@ -57,8 +57,7 @@ public struct BrowserUserAgentPolicy: Sendable { /// Resolves the browser identity policy for a top-level destination. /// - /// Google Sheets keeps WebKit's embedded capability identity while its - /// top-level request advertises current Safari for browser support gates. + /// Every web destination receives the current Safari-compatible identity. /// Non-web destinations have no applicable user-agent policy. /// /// - Parameter url: The destination of the top-level navigation. @@ -69,16 +68,7 @@ public struct BrowserUserAgentPolicy: Sendable { scheme == "http" || scheme == "https" else { return .notApplicable } - guard let host = url.host?.lowercased() else { - return .custom(safariCompatibleUserAgent) - } - let isSheetsHost = host == "sheets.google.com" || host == "spreadsheets.google.com" - let isSheetsPath = host == "docs.google.com" - && url.path.split(separator: "/", omittingEmptySubsequences: true).first? - .lowercased() == "spreadsheets" - return isSheetsHost || isSheetsPath - ? .webKitDefault(topLevelRequestUserAgent: safariCompatibleUserAgent) - : .custom(safariCompatibleUserAgent) + return .custom(safariCompatibleUserAgent) } /// Parses a numeric dot-separated browser version. diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift index 9765ae79d78b..a5336cd7990b 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/BrowserUserAgentPolicyResolution.swift @@ -3,10 +3,6 @@ public enum BrowserUserAgentPolicyResolution: Equatable, Sendable { /// Use the supplied custom user-agent identity for an HTTP or HTTPS destination. case custom(String) - /// Keep WebKit's embedded identity while advertising a supported identity - /// on the top-level HTTP request. - case webKitDefault(topLevelRequestUserAgent: String) - /// User-agent identity does not apply to this non-web destination. case notApplicable } diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index f75a84a03211..04f7e2611a66 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -973,7 +973,7 @@ func browserReadAccessURL(forLocalFileURL fileURL: URL, fileManager: FileManager @discardableResult func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavigation? { guard let url = request.url else { return nil } - let policyRequest = webView.browserUserAgentPolicyPreparedRequest(for: request) + webView.applyBrowserUserAgentPolicy(for: url) let nudgeReason = "navigationStart:\(url.scheme?.lowercased() ?? "none")" if url.isFileURL { guard let readAccessURL = browserReadAccessURL(forLocalFileURL: url) else { return nil } @@ -981,7 +981,7 @@ func browserLoadRequest(_ request: URLRequest, in webView: WKWebView) -> WKNavig return webView.loadFileURL(url, allowingReadAccessTo: readAccessURL) } webView.browserPortalMarkFirstSizedRevealNudgeIfNavigationStartsWithoutPresentation(reason: nudgeReason) - return webView.load(browserPreparedNavigationRequest(policyRequest)) + return webView.load(browserPreparedNavigationRequest(request)) } private let browserEmbeddedNavigationSchemes: Set = [ diff --git a/Sources/Panels/BrowserPrewarmedWebViewPool.swift b/Sources/Panels/BrowserPrewarmedWebViewPool.swift index f7219c212578..bbea1e659c16 100644 --- a/Sources/Panels/BrowserPrewarmedWebViewPool.swift +++ b/Sources/Panels/BrowserPrewarmedWebViewPool.swift @@ -48,7 +48,8 @@ final class BrowserPrewarmedWebViewPool: NSObject { BrowserPanel.makeWebView(profileID: profileID) }, startLoad: @escaping @MainActor (CmuxWebView, URLRequest) -> Void = { webView, request in - webView.load(webView.browserUserAgentPolicyPreparedRequest(for: request)) + webView.applyBrowserUserAgentPolicy(for: request.url) + webView.load(request) }, expirySleep: @escaping @Sendable (Duration) async throws -> Void = { duration in try await Task.sleep(for: duration) diff --git a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift index 278d12d1de89..4517e94b7d73 100644 --- a/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift +++ b/Sources/Panels/WKWebView+BrowserUserAgentPolicy.swift @@ -6,21 +6,12 @@ extension WKWebView { @MainActor @discardableResult func applyBrowserUserAgentPolicy(for url: URL?) -> Bool { - applyBrowserUserAgentPolicy(BrowserUserAgentPolicy.system.resolution(for: url)) - } - - @MainActor - private func applyBrowserUserAgentPolicy( - _ resolution: BrowserUserAgentPolicyResolution - ) -> Bool { // WebKit exposes its native identity as either nil or an empty string across load phases. let currentUserAgent = customUserAgent.flatMap { $0.isEmpty ? nil : $0 } let resolvedUserAgent: String? - switch resolution { + switch BrowserUserAgentPolicy.system.resolution(for: url) { case .custom(let userAgent): resolvedUserAgent = userAgent - case .webKitDefault: - resolvedUserAgent = nil case .notApplicable: guard currentUserAgent != nil else { return false } customUserAgent = nil @@ -32,45 +23,12 @@ extension WKWebView { return true } - /// Applies the WebKit identity and prepares the top-level request identity. - @MainActor - func browserUserAgentPolicyPreparedRequest(for request: URLRequest) -> URLRequest { - let resolution = BrowserUserAgentPolicy.system.resolution(for: request.url) - _ = applyBrowserUserAgentPolicy(resolution) - - var preparedRequest = request - switch resolution { - case .custom: - preparedRequest.setValue(nil, forHTTPHeaderField: "User-Agent") - case .webKitDefault(let topLevelRequestUserAgent): - preparedRequest.setValue(topLevelRequestUserAgent, forHTTPHeaderField: "User-Agent") - case .notApplicable: - break - } - return preparedRequest - } - @MainActor func browserUserAgentPolicyRestartRequest(for request: URLRequest) -> URLRequest? { - let resolution = BrowserUserAgentPolicy.system.resolution(for: request.url) - let identityChanged = applyBrowserUserAgentPolicy(resolution) - - switch resolution { - case .custom: - guard identityChanged else { return nil } - var restartRequest = request - restartRequest.setValue(nil, forHTTPHeaderField: "User-Agent") - return restartRequest - case .webKitDefault(let topLevelRequestUserAgent): - let requestIdentityChanged = request.value(forHTTPHeaderField: "User-Agent") - != topLevelRequestUserAgent - guard identityChanged || requestIdentityChanged else { return nil } - var restartRequest = request - restartRequest.setValue(topLevelRequestUserAgent, forHTTPHeaderField: "User-Agent") - return restartRequest - case .notApplicable: - return nil - } + guard applyBrowserUserAgentPolicy(for: request.url) else { return nil } + var restartRequest = request + restartRequest.setValue(nil, forHTTPHeaderField: "User-Agent") + return restartRequest } @MainActor