mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 16:03:20 +00:00
fix(browser): close Google auth UA navigation races
This commit is contained in:
@@ -74,6 +74,7 @@ import type {
|
||||
BrowserSessionProfileSource
|
||||
} from '../../shared/types'
|
||||
import { browserSessionRegistry } from './browser-session-registry'
|
||||
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import { setupClientHintsOverride } from './browser-session-ua'
|
||||
import {
|
||||
isGoogleSourceBoundCookie,
|
||||
@@ -1876,7 +1877,9 @@ export async function importCookiesFromBrowser(
|
||||
const ua = getUserAgentForBrowser(browser.family)
|
||||
if (ua) {
|
||||
targetSession.setUserAgent(ua)
|
||||
setupClientHintsOverride(targetSession, ua)
|
||||
setupClientHintsOverride(targetSession, ua, {
|
||||
googleAuthOverride: getBrowserSessionUserAgentMode(targetSession) !== 'native'
|
||||
})
|
||||
browserSessionRegistry.persistUserAgent(targetPartition, ua)
|
||||
diag(` set UA for partition: ${ua.substring(0, 80)}...`)
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ vi.mock('./popup-origin-bar-window', () => ({
|
||||
|
||||
import { browserManager } from './browser-manager'
|
||||
import { googleAuthUserAgent } from './browser-google-auth-ua'
|
||||
import { setBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
|
||||
describe('browserManager', () => {
|
||||
const rendererWebContentsId = 5001
|
||||
@@ -1910,6 +1911,34 @@ describe('browserManager', () => {
|
||||
expect(setUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('honors native session mode before the guest registration IPC arrives', () => {
|
||||
const nativeSession = { getUserAgent: vi.fn(() => guestBaseUserAgent) }
|
||||
setBrowserSessionUserAgentMode(nativeSession as never, 'native')
|
||||
const setUserAgent = vi.fn()
|
||||
const guest = {
|
||||
id: 417,
|
||||
isDestroyed: vi.fn(() => false),
|
||||
getType: vi.fn(() => 'webview'),
|
||||
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
|
||||
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
|
||||
on: guestOnMock,
|
||||
off: guestOffMock,
|
||||
openDevTools: guestOpenDevToolsMock,
|
||||
getURL: vi.fn(() => 'https://accounts.google.com/'),
|
||||
getUserAgent: vi.fn(() => guestBaseUserAgent),
|
||||
setUserAgent,
|
||||
session: nativeSession
|
||||
}
|
||||
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
|
||||
expect(setUserAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: popup child windows get attachGuestPolicies but are never entered into tabIdByWebContentsId,
|
||||
// so a direct lookup of the UA mode misses the native opt-out. That is worse than doing nothing —
|
||||
// native sessions skip setupClientHintsOverride, so the popup would send the raw Electron UA on the
|
||||
@@ -3332,10 +3361,10 @@ describe('browserManager', () => {
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true)
|
||||
await flushViewportOps()
|
||||
|
||||
// A mobile preset discriminates cleanly here: the auth branch would emit the Firefox UA, the
|
||||
// non-auth branch an iPhone UA. Asserting the desktop branch instead would conflate this with
|
||||
// applyGoogleAuthUserAgent leaving the WebContents UA on Firefox after an aborted auth nav,
|
||||
// which is pre-existing behavior and out of scope for the ordering fix.
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
|
||||
// A later preset must also resolve the committed, non-auth URL.
|
||||
debuggerSendCommand.mockClear()
|
||||
await browserManager.setViewportOverride('tab-pending-cleared', {
|
||||
width: 375,
|
||||
@@ -3365,6 +3394,198 @@ describe('browserManager', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not let a superseded navigation failure revert a newer target', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4255, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-overlapping-navs',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
const willRedirect = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'will-redirect'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
url: string,
|
||||
isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-overlapping-navs', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
didStartNavigation(null, 'https://example.com/start', false, true)
|
||||
willRedirect({ preventDefault: vi.fn() }, 'https://accounts.google.com/same', false, true)
|
||||
didStartNavigation(null, 'https://accounts.google.com/same', false, true)
|
||||
await flushViewportOps()
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/same', true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
|
||||
'Emulation.setUserAgentOverride',
|
||||
expect.objectContaining({ userAgent: GUEST_CLEAN_UA })
|
||||
)
|
||||
})
|
||||
|
||||
it('switches identity for a server redirect and restores it if the redirect fails', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4256, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-auth-redirect',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
const willRedirect = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'will-redirect'
|
||||
)?.[1] as (
|
||||
event: { preventDefault: () => void },
|
||||
url: string,
|
||||
isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
const didFailLoad = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-fail-load'
|
||||
)?.[1] as (
|
||||
event: unknown,
|
||||
errorCode: number,
|
||||
errorDescription: string,
|
||||
validatedURL: string,
|
||||
isMainFrame: boolean
|
||||
) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-auth-redirect', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
didStartNavigation(null, 'https://example.com/start', false, true)
|
||||
willRedirect(
|
||||
{ preventDefault: vi.fn() },
|
||||
'https://accounts.google.com/redirected',
|
||||
false,
|
||||
true
|
||||
)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/redirected', true)
|
||||
await flushViewportOps()
|
||||
expect(guest.setUserAgent).toHaveBeenLastCalledWith(GUEST_ELECTRON_UA)
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
|
||||
})
|
||||
|
||||
it('reapplies a preset when navigation starts during its final UA write', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4257, 'https://example.com/')
|
||||
let releaseFirstUa = (): void => {}
|
||||
const firstUaGate = new Promise<void>((resolve) => {
|
||||
releaseFirstUa = resolve
|
||||
})
|
||||
let uaWrites = 0
|
||||
debuggerSendCommand.mockImplementation((method: string) => {
|
||||
if (method === 'Emulation.setUserAgentOverride' && uaWrites++ === 0) {
|
||||
return firstUaGate
|
||||
}
|
||||
return Promise.resolve(undefined)
|
||||
})
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-final-apply-race',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
const presetDone = browserManager.setViewportOverride('tab-final-apply-race', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
await flushViewportOps()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
|
||||
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
releaseFirstUa()
|
||||
await presetDone
|
||||
})
|
||||
|
||||
it('does not reinstall a preset while its final UA clear is in flight', async () => {
|
||||
const { guest, debuggerSendCommand } = makeGuest(4258, 'https://example.com/')
|
||||
webContentsFromIdMock.mockReturnValue(guest)
|
||||
browserManager.attachGuestPolicies(guest as never)
|
||||
browserManager.registerGuest({
|
||||
browserPageId: 'tab-final-clear-race',
|
||||
webContentsId: guest.id as number,
|
||||
rendererWebContentsId
|
||||
})
|
||||
const didStartNavigation = guestOnMock.mock.calls.find(
|
||||
([event]) => event === 'did-start-navigation'
|
||||
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
|
||||
|
||||
await browserManager.setViewportOverride('tab-final-clear-race', {
|
||||
width: 1024,
|
||||
height: 768,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false
|
||||
})
|
||||
let releaseClearUa = (): void => {}
|
||||
const clearUaGate = new Promise<void>((resolve) => {
|
||||
releaseClearUa = resolve
|
||||
})
|
||||
debuggerSendCommand.mockImplementation(
|
||||
(method: string, params: { userAgent?: string } | undefined) =>
|
||||
method === 'Emulation.setUserAgentOverride' && params?.userAgent === ''
|
||||
? clearUaGate
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
const clearDone = browserManager.setViewportOverride('tab-final-clear-race', null)
|
||||
await flushViewportOps()
|
||||
|
||||
debuggerSendCommand.mockClear()
|
||||
didStartNavigation(null, 'https://accounts.google.com/', false, true)
|
||||
await flushViewportOps()
|
||||
expect(debuggerSendCommand).not.toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
|
||||
userAgent: googleAuthUserAgent()
|
||||
})
|
||||
|
||||
releaseClearUa()
|
||||
await clearDone
|
||||
})
|
||||
|
||||
// Why: a failed clear leaves the CDP override standing on the target. Dropping the tracking entry
|
||||
// first makes it untracked, so the navigation path can never correct it again and the tab carries
|
||||
// a Chrome-shaped navigator.userAgent onto the auth hosts — the exact failure this PR prevents.
|
||||
@@ -3389,11 +3610,12 @@ describe('browserManager', () => {
|
||||
})
|
||||
await flushViewportOps()
|
||||
|
||||
// The clear fails partway — e.g. DevTools took the debugger, or the guest is tearing down.
|
||||
debuggerSendCommand.mockImplementation((method: string) =>
|
||||
method === 'Emulation.clearDeviceMetricsOverride'
|
||||
? Promise.reject(new Error('debugger detached'))
|
||||
: Promise.resolve(undefined)
|
||||
// The final UA clear fails after tracking was optimistically removed.
|
||||
debuggerSendCommand.mockImplementation(
|
||||
(method: string, params: { userAgent?: string } | undefined) =>
|
||||
method === 'Emulation.setUserAgentOverride' && params?.userAgent === ''
|
||||
? Promise.reject(new Error('debugger detached'))
|
||||
: Promise.resolve(undefined)
|
||||
)
|
||||
await expect(browserManager.setViewportOverride('tab-failed-clear', null)).resolves.toBe(
|
||||
false
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
buildBrowserIframeClickedLinkRoutingScript
|
||||
} from './browser-clicked-link-routing'
|
||||
import { cleanElectronUserAgent } from './browser-session-ua'
|
||||
import { getBrowserSessionUserAgentMode } from './browser-session-user-agent-mode'
|
||||
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
|
||||
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
|
||||
import type {
|
||||
@@ -150,6 +151,10 @@ type PopupOwnerContext = {
|
||||
browserTabId: string
|
||||
rootGuestWebContentsId: number
|
||||
}
|
||||
type PendingMainFrameNavigation = {
|
||||
currentUrl: string
|
||||
supersededUrls: string[]
|
||||
}
|
||||
const SAFE_POPUP_WINDOW_OPTIONS = {
|
||||
alwaysOnTop: false,
|
||||
closable: true,
|
||||
@@ -223,12 +228,12 @@ export class BrowserManager {
|
||||
private readonly rendererWebContentsIdByTabId = new Map<string, number>()
|
||||
// Why: serialize per-tab setViewportOverride so rapid toggles don't interleave CDP commands and leave emulation in a wrong state.
|
||||
private readonly viewportOpsByTabId = new Map<string, Promise<unknown>>()
|
||||
// Why: presence means a CDP UA override is standing for the tab (value = the preset's mobile flag),
|
||||
// so navigation can re-issue it against the target URL's identity.
|
||||
// Why: presence means the preset requires a CDP UA override (installed or in flight), so navigation
|
||||
// can re-issue it against the target URL's identity.
|
||||
private readonly viewportUaOverrideMobileByTabId = new Map<string, boolean>()
|
||||
// Why: the in-flight main-frame navigation target, held only until commit or failure — getURL()
|
||||
// still reports the outgoing page until then. See resolveTabNavigationUrl.
|
||||
private readonly pendingNavigationUrlByTabId = new Map<string, string>()
|
||||
private readonly pendingNavigationByGuestId = new Map<number, PendingMainFrameNavigation>()
|
||||
private readonly contextMenuCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly grabShortcutCleanupByTabId = new Map<string, () => void>()
|
||||
private readonly shortcutForwardingCleanupByTabId = new Map<string, () => void>()
|
||||
@@ -779,20 +784,35 @@ export class BrowserManager {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
const navigationGuard = (event: Electron.Event, url: string): void => {
|
||||
const navigationGuard = (event: Electron.Event, url: string): boolean => {
|
||||
// Why: Turnstile loads challenge resources via blob:; blocking them trips error 600010. Allow only http(s) blobs, not opaque ones.
|
||||
if (url.startsWith('blob:https://') || url.startsWith('blob:http://')) {
|
||||
return
|
||||
return true
|
||||
}
|
||||
// Why: initial file:// attach is allowed for user-opened previews, but block later file:// redirects so remote pages can't probe the FS.
|
||||
if (url.startsWith('file:')) {
|
||||
event.preventDefault()
|
||||
return
|
||||
return false
|
||||
}
|
||||
if (!normalizeBrowserNavigationUrl(url)) {
|
||||
// Why: will-attach-webview only validates the initial src; keep enforcing the allowlist on later navs.
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const willRedirectHandler = (
|
||||
event: Electron.Event,
|
||||
url: string,
|
||||
_isInPlace: boolean,
|
||||
isMainFrame: boolean
|
||||
): void => {
|
||||
if (!navigationGuard(event, url) || !isMainFrame || isChromiumInternalErrorUrl(url)) {
|
||||
return
|
||||
}
|
||||
this.updatePendingNavigationForRedirect(guest.id, url)
|
||||
this.applyGoogleAuthUserAgent(guest, url)
|
||||
}
|
||||
|
||||
const didFailLoadHandler = (
|
||||
@@ -806,7 +826,11 @@ export class BrowserManager {
|
||||
return
|
||||
}
|
||||
// Why: a nav that never committed must not leave its target standing as the tab's host.
|
||||
this.clearPendingNavigationUrl(guest.id)
|
||||
const failedNavigationWasCurrent = this.failPendingNavigation(guest.id, validatedURL)
|
||||
if (failedNavigationWasCurrent) {
|
||||
// The attempted host never committed, so restore every UA layer to the document that remains.
|
||||
this.applyGoogleAuthUserAgent(guest, guest.getURL())
|
||||
}
|
||||
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
|
||||
const certificateFailure = browserPageId
|
||||
? this.certificateTrustController?.getFailure(browserPageId)
|
||||
@@ -850,12 +874,9 @@ export class BrowserManager {
|
||||
if (!isMainFrame || isChromiumInternalErrorUrl(url)) {
|
||||
return
|
||||
}
|
||||
const navigatingTabId = this.tabIdByWebContentsId.get(guest.id)
|
||||
if (navigatingTabId) {
|
||||
// Why: getURL() still reports the previous committed URL until this navigation commits, so
|
||||
// every UA writer must read the in-flight target or they disagree about the tab's host.
|
||||
this.pendingNavigationUrlByTabId.set(navigatingTabId, url)
|
||||
}
|
||||
// Why: getURL() still reports the previous committed URL until this navigation commits, so
|
||||
// every UA writer must read the in-flight target or they disagree about the tab's host.
|
||||
this.startPendingNavigation(guest.id, url)
|
||||
this.applyGoogleAuthUserAgent(guest, url)
|
||||
this.certificateTrustController?.onMainFrameNavigationStarted(guest.id)
|
||||
// Why: a pre-registration failure belongs only to its own nav; a replacement nav must not replay it.
|
||||
@@ -873,14 +894,14 @@ export class BrowserManager {
|
||||
|
||||
const didNavigateHandler = (_event: Electron.Event, url: string): void => {
|
||||
// Why: once committed, getURL() reports this url, so the pending target is redundant.
|
||||
this.clearPendingNavigationUrl(guest.id)
|
||||
this.pendingNavigationByGuestId.delete(guest.id)
|
||||
// Why: a committed nav makes the did-start-navigation stash obsolete; drop it so a later ERR_ABORTED can't restore an error over it.
|
||||
this.clearedLoadErrorsByGuestId.delete(guest.id)
|
||||
this.certificateTrustController?.onMainFrameNavigationCommitted(guest.id, url)
|
||||
}
|
||||
|
||||
guest.on('will-navigate', navigationGuard)
|
||||
guest.on('will-redirect', navigationGuard)
|
||||
guest.on('will-redirect', willRedirectHandler)
|
||||
guest.on('did-start-navigation', didStartNavigationHandler)
|
||||
guest.on('did-navigate', didNavigateHandler)
|
||||
guest.on('did-fail-load', didFailLoadHandler)
|
||||
@@ -914,7 +935,7 @@ export class BrowserManager {
|
||||
}
|
||||
if (!guest.isDestroyed()) {
|
||||
guest.off('will-navigate', navigationGuard)
|
||||
guest.off('will-redirect', navigationGuard)
|
||||
guest.off('will-redirect', willRedirectHandler)
|
||||
guest.off('did-start-navigation', didStartNavigationHandler)
|
||||
guest.off('did-navigate', didNavigateHandler)
|
||||
guest.off('did-fail-load', didFailLoadHandler)
|
||||
@@ -934,7 +955,10 @@ export class BrowserManager {
|
||||
// That is worse than doing nothing: native sessions skip setupClientHintsOverride entirely, so
|
||||
// the popup would send the raw Electron UA on the wire while navigator.userAgent claims Firefox.
|
||||
const ownerTabId = this.resolveBrowserTabIdForGuestWebContentsId(guest.id)
|
||||
const mode = ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined
|
||||
// Session state is authoritative before renderer registration and after a native profile imports a source UA.
|
||||
const mode =
|
||||
getBrowserSessionUserAgentMode(guest.session) ??
|
||||
(ownerTabId ? this.userAgentModeByPageId.get(ownerTabId) : undefined)
|
||||
if (mode === 'native') {
|
||||
return
|
||||
}
|
||||
@@ -956,18 +980,48 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
private clearPendingNavigationUrl(guestId: number): void {
|
||||
const browserTabId = this.tabIdByWebContentsId.get(guestId)
|
||||
if (browserTabId) {
|
||||
this.pendingNavigationUrlByTabId.delete(browserTabId)
|
||||
private startPendingNavigation(guestId: number, url: string): void {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
this.pendingNavigationByGuestId.set(guestId, {
|
||||
currentUrl: url,
|
||||
supersededUrls: pending ? [...pending.supersededUrls, pending.currentUrl] : []
|
||||
})
|
||||
}
|
||||
|
||||
private updatePendingNavigationForRedirect(guestId: number, url: string): void {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
if (!pending) {
|
||||
this.pendingNavigationByGuestId.set(guestId, {
|
||||
currentUrl: url,
|
||||
supersededUrls: []
|
||||
})
|
||||
return
|
||||
}
|
||||
pending.currentUrl = url
|
||||
}
|
||||
|
||||
private failPendingNavigation(guestId: number, failedUrl: string): boolean {
|
||||
const pending = this.pendingNavigationByGuestId.get(guestId)
|
||||
if (!pending) {
|
||||
return false
|
||||
}
|
||||
const supersededIndex = pending.supersededUrls.indexOf(failedUrl)
|
||||
if (supersededIndex !== -1) {
|
||||
pending.supersededUrls.splice(supersededIndex, 1)
|
||||
return false
|
||||
}
|
||||
if (pending.currentUrl !== failedUrl) {
|
||||
return false
|
||||
}
|
||||
this.pendingNavigationByGuestId.delete(guestId)
|
||||
return true
|
||||
}
|
||||
|
||||
// Why: webContents.getURL() reports the last COMMITTED url, so mid-navigation it names the host
|
||||
// the tab is leaving, not the one it is entering. Every UA writer must resolve the host through
|
||||
// here or two writers racing the same navigation will pick opposite identities.
|
||||
private resolveTabNavigationUrl(guest: Electron.WebContents, browserTabId: string): string {
|
||||
return this.pendingNavigationUrlByTabId.get(browserTabId) ?? guest.getURL()
|
||||
private resolveTabNavigationUrl(guest: Electron.WebContents): string {
|
||||
return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL()
|
||||
}
|
||||
|
||||
// Why: Emulation.setUserAgentOverride is set once and stands across every later navigation,
|
||||
@@ -986,12 +1040,11 @@ export class BrowserManager {
|
||||
// Why: no queue needed — debugger.sendCommand dispatches in call order over one channel, so the
|
||||
// later-issued write wins. What matters is that both writers resolve the SAME host, which they
|
||||
// now do via the navigation target rather than the stale committed URL.
|
||||
void this.sendViewportUserAgentOverride(guest, browserTabId, mobile, url).catch(() => {})
|
||||
void this.sendViewportUserAgentOverride(guest, mobile, url).catch(() => {})
|
||||
}
|
||||
|
||||
private async sendViewportUserAgentOverride(
|
||||
guest: Electron.WebContents,
|
||||
browserTabId: string,
|
||||
mobile: boolean,
|
||||
url?: string
|
||||
): Promise<void> {
|
||||
@@ -1001,7 +1054,7 @@ export class BrowserManager {
|
||||
await guest.debugger.sendCommand(
|
||||
'Emulation.setUserAgentOverride',
|
||||
buildViewportUserAgentOverride({
|
||||
url: url ?? this.resolveTabNavigationUrl(guest, browserTabId),
|
||||
url: url ?? this.resolveTabNavigationUrl(guest),
|
||||
mobile,
|
||||
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
|
||||
})
|
||||
@@ -1056,6 +1109,7 @@ export class BrowserManager {
|
||||
this.clickedLinkFrameNameByGuestId.delete(guestWebContentsId)
|
||||
this.offscreenGuestIds.delete(guestWebContentsId)
|
||||
this.popupOwnerContextByGuestId.delete(guestWebContentsId)
|
||||
this.pendingNavigationByGuestId.delete(guestWebContentsId)
|
||||
// Why: a popup must stop inheriting authorization the moment its owner retires, before Chromium destroys the child.
|
||||
if (isPrimaryGuest) {
|
||||
for (const [popupGuestId, owner] of this.popupOwnerContextByGuestId) {
|
||||
@@ -1190,7 +1244,9 @@ export class BrowserManager {
|
||||
// Why: drop the viewport-op chain so the Map doesn't retain a promise keyed to a destroyed guest.
|
||||
this.viewportOpsByTabId.delete(browserTabId)
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
this.pendingNavigationUrlByTabId.delete(browserTabId)
|
||||
if (wcId !== undefined) {
|
||||
this.pendingNavigationByGuestId.delete(wcId)
|
||||
}
|
||||
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
|
||||
}
|
||||
|
||||
@@ -1257,7 +1313,7 @@ export class BrowserManager {
|
||||
this.sessionProfileIdByPageId.clear()
|
||||
this.userAgentModeByPageId.clear()
|
||||
this.viewportUaOverrideMobileByTabId.clear()
|
||||
this.pendingNavigationUrlByTabId.clear()
|
||||
this.pendingNavigationByGuestId.clear()
|
||||
this.pendingLoadFailuresByGuestId.clear()
|
||||
this.loadErrorsByGuestId.clear()
|
||||
this.clearedLoadErrorsByGuestId.clear()
|
||||
@@ -1651,11 +1707,10 @@ export class BrowserManager {
|
||||
})
|
||||
// Why: viewport sizing must not override a profile's explicit native-UA identity.
|
||||
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
|
||||
// Why: same sender as the navigation path, so both resolve the tab's host identically.
|
||||
await this.sendViewportUserAgentOverride(guest, browserTabId, override.mobile)
|
||||
// Why: only claim a standing override once the CDP write actually landed — recording it
|
||||
// first would leave the map asserting an override that was never installed.
|
||||
// Navigation must see the preset intent while the final CDP command is in flight.
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, override.mobile)
|
||||
// Why: same sender as the navigation path, so both resolve the tab's host identically.
|
||||
await this.sendViewportUserAgentOverride(guest, override.mobile)
|
||||
}
|
||||
} else {
|
||||
await dbg.sendCommand('Emulation.clearDeviceMetricsOverride', {})
|
||||
@@ -1663,11 +1718,18 @@ export class BrowserManager {
|
||||
enabled: false,
|
||||
maxTouchPoints: 0
|
||||
})
|
||||
// Why: passing an empty string restores the session default UA.
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
// Why: drop tracking only after the clear landed. Deleting first strands a still-standing
|
||||
// CDP UA override with no map entry, so the navigation path can never correct it again.
|
||||
const trackedMobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
|
||||
// A navigation after this point must not re-install the override behind the clear.
|
||||
this.viewportUaOverrideMobileByTabId.delete(browserTabId)
|
||||
// Why: passing an empty string restores the session default UA.
|
||||
try {
|
||||
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
|
||||
} catch (error) {
|
||||
if (trackedMobile !== undefined) {
|
||||
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
|
||||
@@ -230,8 +230,10 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
browserSessionRegistry.createProfile('isolated', 'Google', { userAgentMode: 'native' })
|
||||
|
||||
const profileSession = sessionFromPartitionMock.mock.results.at(-1)?.value
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(profileSession.setUserAgent).not.toHaveBeenCalled()
|
||||
expect(setupClientHintsOverrideMock).not.toHaveBeenCalled()
|
||||
expect(getBrowserSessionUserAgentMode(profileSession as never)).toBe('native')
|
||||
})
|
||||
|
||||
it('merges partition-keyed pending entries without clobbering unrelated entries', async () => {
|
||||
@@ -391,7 +393,14 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
setupClientHintsOverrideMock.mock.calls.some(
|
||||
(c: unknown[]) =>
|
||||
(c[0] as { partition?: string } | undefined)?.partition === importedPartition &&
|
||||
c[1] === importedUa
|
||||
c[1] === importedUa &&
|
||||
(c[2] as { googleAuthOverride?: boolean } | undefined)?.googleAuthOverride === false
|
||||
)
|
||||
).toBe(true)
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(
|
||||
importedSessions.every(
|
||||
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -432,6 +441,12 @@ describe('BrowserSessionRegistry persistence', () => {
|
||||
([sess]) => (sess as { partition?: string }).partition === importedPartition
|
||||
)
|
||||
).toBe(false)
|
||||
const { getBrowserSessionUserAgentMode } = await import('./browser-session-user-agent-mode')
|
||||
expect(
|
||||
importedSessions.every(
|
||||
(session) => getBrowserSessionUserAgentMode(session as never) === 'native'
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('sets up default-partition policies on restore', async () => {
|
||||
|
||||
@@ -457,6 +457,27 @@ describe('BrowserSessionRegistry', () => {
|
||||
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('keeps an imported native UA on auth hosts while aligning its Chrome hints', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
const importedUa =
|
||||
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.6890.3 Safari/537.36'
|
||||
setupClientHintsOverride(mockSess, importedUa, { googleAuthOverride: false })
|
||||
|
||||
const callback = vi.fn()
|
||||
const listener = onBeforeSendHeaders.mock.calls[0][1]
|
||||
listener(
|
||||
{
|
||||
url: 'https://accounts.google.com/v3/signin/identifier',
|
||||
requestHeaders: { 'User-Agent': importedUa, 'sec-ch-ua': 'old' }
|
||||
},
|
||||
callback
|
||||
)
|
||||
const modified = callback.mock.calls[0][0].requestHeaders
|
||||
expect(modified['User-Agent']).toBe(importedUa)
|
||||
expect(modified['sec-ch-ua']).toContain('Google Chrome')
|
||||
})
|
||||
|
||||
it('leaves non-Client-Hints headers unchanged', () => {
|
||||
const onBeforeSendHeaders = vi.fn()
|
||||
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
|
||||
|
||||
@@ -26,6 +26,10 @@ import type {
|
||||
import { browserManager } from './browser-manager'
|
||||
import { hasSystemMediaAccess, requestSystemMediaAccess } from './browser-media-access'
|
||||
import { cleanElectronUserAgent, setupClientHintsOverride } from './browser-session-ua'
|
||||
import {
|
||||
clearBrowserSessionUserAgentMode,
|
||||
setBrowserSessionUserAgentMode
|
||||
} from './browser-session-user-agent-mode'
|
||||
import { resolveChromiumCookiesPath } from './chromium-cookie-path'
|
||||
import { isAutoGrantedBrowserSessionPermission } from './browser-session-permission-policy'
|
||||
import {
|
||||
@@ -190,10 +194,14 @@ class BrowserSessionRegistry {
|
||||
const partition = profile.partition
|
||||
try {
|
||||
const sess = session.fromPartition(partition)
|
||||
const userAgentMode = profile.userAgentMode ?? 'clean'
|
||||
setBrowserSessionUserAgentMode(sess, userAgentMode)
|
||||
const persistedUa = meta.userAgentByPartition[partition]
|
||||
if (persistedUa) {
|
||||
sess.setUserAgent(persistedUa)
|
||||
setupClientHintsOverride(sess, persistedUa)
|
||||
setupClientHintsOverride(sess, persistedUa, {
|
||||
googleAuthOverride: userAgentMode !== 'native'
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -435,6 +443,7 @@ class BrowserSessionRegistry {
|
||||
// Why: clear the partition's storage so deleting a profile doesn't leave orphaned cookies/cache behind.
|
||||
try {
|
||||
const sess = session.fromPartition(profile.partition)
|
||||
clearBrowserSessionUserAgentMode(sess)
|
||||
this.clearSessionPolicies(profile.partition, sess)
|
||||
await sess.clearStorageData()
|
||||
await sess.clearCache()
|
||||
@@ -523,11 +532,12 @@ class BrowserSessionRegistry {
|
||||
|
||||
private setupSessionPolicies(profile: BrowserSessionProfile): void {
|
||||
const { partition } = profile
|
||||
const sess = session.fromPartition(partition)
|
||||
setBrowserSessionUserAgentMode(sess, profile.userAgentMode ?? 'clean')
|
||||
if (this.configuredPartitions.has(partition)) {
|
||||
return
|
||||
}
|
||||
|
||||
const sess = session.fromPartition(partition)
|
||||
browserManager.installCertificateRequestGuard(sess)
|
||||
if (profile.userAgentMode !== 'native' && typeof sess.getUserAgent === 'function') {
|
||||
const cleanUA = cleanElectronUserAgent(sess.getUserAgent())
|
||||
|
||||
@@ -26,7 +26,11 @@ export function cleanElectronUserAgent(ua: string): string {
|
||||
// reveal the real version, creating a mismatch that Google's anti-fraud
|
||||
// detection flags as CookieMismatch on accounts.google.com. Override Client
|
||||
// Hints on outgoing requests to match the source browser's UA.
|
||||
export function setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
export function setupClientHintsOverride(
|
||||
sess: Session,
|
||||
ua: string,
|
||||
options: { googleAuthOverride?: boolean } = {}
|
||||
): void {
|
||||
// Why: only Chrome-shaped base UAs carry sec-ch-ua hints to rewrite, but the
|
||||
// Google-auth Firefox switch below must install regardless, so keep the hints
|
||||
// optional rather than bailing out of the whole handler.
|
||||
@@ -35,7 +39,7 @@ export function setupClientHintsOverride(sess: Session, ua: string): void {
|
||||
|
||||
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
|
||||
const headers = details.requestHeaders
|
||||
if (isGoogleAuthUrl(details.url)) {
|
||||
if (options.googleAuthOverride !== false && isGoogleAuthUrl(details.url)) {
|
||||
// Why: present a Firefox identity on Google's sign-in hosts so the user logs
|
||||
// in inside the app and Google issues self-refreshing bound cookies. Strip
|
||||
// sec-ch-ua* because real Firefox sends none.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Session } from 'electron'
|
||||
|
||||
import type { BrowserSessionUserAgentMode } from '../../shared/types'
|
||||
|
||||
const userAgentModeBySession = new WeakMap<Session, BrowserSessionUserAgentMode>()
|
||||
|
||||
export function setBrowserSessionUserAgentMode(
|
||||
session: Session,
|
||||
mode: BrowserSessionUserAgentMode
|
||||
): void {
|
||||
userAgentModeBySession.set(session, mode)
|
||||
}
|
||||
|
||||
export function getBrowserSessionUserAgentMode(
|
||||
session: Session
|
||||
): BrowserSessionUserAgentMode | undefined {
|
||||
return userAgentModeBySession.get(session)
|
||||
}
|
||||
|
||||
export function clearBrowserSessionUserAgentMode(session: Session): void {
|
||||
userAgentModeBySession.delete(session)
|
||||
}
|
||||
Reference in New Issue
Block a user