fix(browser): present Firefox UA on Google auth hosts to end ~1h sign-in staleness (#12884)

Google binds a signed-in session to the browser identity that created it, and
rejected Orca's Electron/Chrome-shaped UA on accounts.google.com — so sessions
died at ~1h and re-copying cookies only reset the same losing clock.

Present a Firefox identity scoped to the Google/YouTube sign-in hosts at every
layer Google probes: the request headers (with sec-ch-ua* stripped, since real
Firefox sends none), the per-WebContents UA that auth JS reads, and the CDP
emulation override a viewport preset installs. All three must agree — a
disagreement between them is a sharper bot signal than any single wrong UA.

Native-UA profiles opt out at every layer, including popup child windows and
after a browser-native cookie import.

Validated live against a real account: freshness cookies issued and rotating on
a ~10-min cadence, session still signed in and self-renewing past 10 hours,
against a pre-fix baseline of death at ~1h.

Closes #11518.
This commit is contained in:
Brennan Benson 2026-08-07 01:29:37 -07:00 committed by GitHub
parent ddf58d6d6a
commit 419151dddd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1423 additions and 79 deletions

View File

@ -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)}...`)
}

View File

@ -0,0 +1,65 @@
import { describe, expect, it } from 'vitest'
import {
googleAuthUserAgent,
isGoogleAuthUrl,
setUserAgentHeader,
stripClientHints
} from './browser-google-auth-ua'
describe('isGoogleAuthUrl', () => {
it('matches the Google/YouTube sign-in hosts exactly', () => {
expect(isGoogleAuthUrl('https://accounts.google.com/')).toBe(true)
expect(isGoogleAuthUrl('https://accounts.google.com/v3/signin/identifier')).toBe(true)
expect(isGoogleAuthUrl('https://accounts.youtube.com/signin')).toBe(true)
expect(isGoogleAuthUrl('https://ACCOUNTS.GOOGLE.COM/')).toBe(true)
})
it('does not match post-auth app subdomains or lookalikes', () => {
expect(isGoogleAuthUrl('https://myaccount.google.com/')).toBe(false)
expect(isGoogleAuthUrl('https://mail.google.com/')).toBe(false)
expect(isGoogleAuthUrl('https://accounts.google.com.evil.test/')).toBe(false)
expect(isGoogleAuthUrl('https://google.com/')).toBe(false)
expect(isGoogleAuthUrl('not a url')).toBe(false)
})
})
describe('googleAuthUserAgent', () => {
it('produces an internally consistent Firefox UA for the host platform', () => {
const ua = googleAuthUserAgent()
expect(ua).toMatch(/^Mozilla\/5\.0 \(.+; rv:\d+\.0\) Gecko\/20100101 Firefox\/\d+\.0$/)
expect(ua).not.toContain('Chrome')
expect(ua).not.toContain('Electron')
})
})
describe('stripClientHints', () => {
it('removes every sec-ch-ua* header regardless of case, keeps others', () => {
const headers: Record<string, string> = {
'sec-ch-ua': 'a',
'Sec-CH-UA-Platform': 'b',
'sec-ch-ua-full-version-list': 'c',
'User-Agent': 'ua',
Accept: 'text/html'
}
stripClientHints(headers)
expect(Object.keys(headers).some((k) => k.toLowerCase().startsWith('sec-ch-ua'))).toBe(false)
expect(headers['User-Agent']).toBe('ua')
expect(headers.Accept).toBe('text/html')
})
})
describe('setUserAgentHeader', () => {
it('overwrites an existing user-agent header in place, preserving its casing', () => {
const headers: Record<string, string> = { 'user-agent': 'old' }
setUserAgentHeader(headers, 'new')
expect(headers['user-agent']).toBe('new')
expect(Object.keys(headers)).toEqual(['user-agent'])
})
it('adds a canonical header when none exists', () => {
const headers: Record<string, string> = {}
setUserAgentHeader(headers, 'new')
expect(headers['User-Agent']).toBe('new')
})
})

View File

@ -0,0 +1,51 @@
// Why: Google binds a signed-in session to the browser identity that created it.
// Cookies copied in from another browser (or sent under an Electron/Chrome-shaped
// UA that doesn't match a real first-party browser) get flagged by anti-fraud on
// accounts.google.com and expire within ~1h. Presenting a Firefox identity scoped
// to Google's auth hosts lets the user sign in *inside* the embedded browser, so
// Google issues cookies bound to THIS browser that self-refresh — instead of us
// transplanting cookies that go stale. Scope is deliberately the auth hosts only:
// post-auth app surfaces (mail.google.com, myaccount.google.com, drive, etc.) keep
// the profile's real Chrome-shaped identity so nothing else about the session shifts.
// Why: exact hostname match — subdomains such as myaccount.google.com are post-auth
// app surfaces, not the sign-in flow, and must retain the profile's real identity.
const GOOGLE_AUTH_HOSTS = new Set(['accounts.google.com', 'accounts.youtube.com'])
export function isGoogleAuthUrl(rawUrl: string): boolean {
try {
return GOOGLE_AUTH_HOSTS.has(new URL(rawUrl).hostname.toLowerCase())
} catch {
return false
}
}
// Why: rv:/Gecko/Firefox tokens must line up with a real released build and the
// platform token must match the host OS, or the UA is internally inconsistent and
// itself a bot tell.
export function googleAuthUserAgent(): string {
const platform =
process.platform === 'darwin'
? 'Macintosh; Intel Mac OS X 10.15'
: process.platform === 'win32'
? 'Windows NT 10.0; Win64; x64'
: 'X11; Linux x86_64'
return `Mozilla/5.0 (${platform}; rv:140.0) Gecko/20100101 Firefox/140.0`
}
// Why: real Firefox emits no sec-ch-ua* client hints; leaving Chromium's hints on a
// Firefox UA is a sharper mismatch than either signal alone.
export function stripClientHints(headers: Record<string, string>): void {
for (const key of Object.keys(headers)) {
if (key.toLowerCase().startsWith('sec-ch-ua')) {
delete headers[key]
}
}
}
// Why: the user-agent request header may already carry a base identity under a
// different case; overwrite the existing key rather than adding a duplicate.
export function setUserAgentHeader(headers: Record<string, string>, value: string): void {
const existing = Object.keys(headers).find((key) => key.toLowerCase() === 'user-agent')
headers[existing ?? 'User-Agent'] = value
}

View File

@ -55,9 +55,18 @@ 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
// Base (non-Firefox) UA a guest reports off the Google auth hosts.
const guestBaseUserAgent = 'Mozilla/5.0 (Test) Chrome/140.0.0.0'
const guestUaMethods = () => ({
getUserAgent: vi.fn(() => guestBaseUserAgent),
setUserAgent: vi.fn(),
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
})
type DownloadItemHandlerState = 'progressing' | 'interrupted' | 'completed' | 'cancelled'
type DownloadItemHandler = (event: Electron.Event, state: DownloadItemHandlerState) => void
@ -1610,7 +1619,8 @@ describe('browserManager', () => {
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'http://localhost:3000/')
getURL: vi.fn(() => 'http://localhost:3000/'),
...guestUaMethods()
}
webContentsFromIdMock.mockImplementation((id: number) => {
@ -1686,7 +1696,8 @@ describe('browserManager', () => {
on: guestOnMock,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'https://example.com/')
getURL: vi.fn(() => 'https://example.com/'),
...guestUaMethods()
}
webContentsFromIdMock.mockImplementation((id: number) =>
id === guest.id
@ -1738,7 +1749,8 @@ describe('browserManager', () => {
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
send: vi.fn(),
getURL: vi.fn(() => 'chrome-error://chromewebdata/')
getURL: vi.fn(() => 'chrome-error://chromewebdata/'),
...guestUaMethods()
}
webContentsFromIdMock.mockReturnValue(guest)
@ -1780,7 +1792,8 @@ describe('browserManager', () => {
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
send: vi.fn(),
getURL: vi.fn(() => 'chrome-error://chromewebdata/')
getURL: vi.fn(() => 'chrome-error://chromewebdata/'),
...guestUaMethods()
}
webContentsFromIdMock.mockReturnValue(guest)
@ -1819,6 +1832,172 @@ describe('browserManager', () => {
expect(browserManager.getBrowserPageLoadError('browser-retry-abort-page')).toBeNull()
})
it('presents the Firefox UA on Google auth hosts and restores the base UA off them', () => {
let currentUa = guestBaseUserAgent
const setUserAgent = vi.fn((ua: string) => {
currentUa = ua
})
const guest = {
id: 408,
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(() => currentUa),
setUserAgent,
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-auth-ua',
webContentsId: guest.id,
rendererWebContentsId
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
setUserAgent.mockClear()
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
expect(setUserAgent).toHaveBeenLastCalledWith(googleAuthUserAgent())
// Off the auth host, the guest's base identity is restored.
didStartNavigation(null, 'https://myaccount.google.com/', false, true)
expect(setUserAgent).toHaveBeenLastCalledWith(guestBaseUserAgent)
// A navigation that doesn't change the required UA must not thrash setUserAgent.
setUserAgent.mockClear()
didStartNavigation(null, 'https://example.com/', false, true)
expect(setUserAgent).not.toHaveBeenCalled()
})
it('leaves the UA untouched on Google auth hosts for native-UA profiles', () => {
const setUserAgent = vi.fn()
const guest = {
id: 409,
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: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'browser-native-ua',
webContentsId: guest.id,
rendererWebContentsId,
userAgentMode: 'native'
})
const didStartNavigation = guestOnMock.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
setUserAgent.mockClear()
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
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
// wire while navigator.userAgent claimed Firefox. Google sign-in popups are a first-class surface.
it('leaves the UA untouched on auth hosts for a popup owned by a native-UA profile', () => {
const ownerGuest = {
id: 415,
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: vi.fn(),
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
webContentsFromIdMock.mockReturnValue(ownerGuest)
browserManager.attachGuestPolicies(ownerGuest as never)
browserManager.registerGuest({
browserPageId: 'browser-native-popup-owner',
webContentsId: ownerGuest.id,
rendererWebContentsId,
userAgentMode: 'native'
})
// The popup carries its own listeners so its handler is unambiguous.
const popupOn = vi.fn()
const popupSetUserAgent = vi.fn()
const popupGuest = {
id: 416,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'window'),
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: popupOn,
off: guestOffMock,
openDevTools: guestOpenDevToolsMock,
getURL: vi.fn(() => 'https://accounts.google.com/'),
getUserAgent: vi.fn(() => guestBaseUserAgent),
setUserAgent: popupSetUserAgent,
session: { getUserAgent: vi.fn(() => guestBaseUserAgent) }
}
browserManager.attachGuestPolicies(popupGuest as never, {
browserTabId: 'browser-native-popup-owner',
rootGuestWebContentsId: ownerGuest.id
})
const popupDidStartNavigation = popupOn.mock.calls.find(
([event]) => event === 'did-start-navigation'
)?.[1] as (event: unknown, url: string, isInPlace: boolean, isMainFrame: boolean) => void
expect(popupDidStartNavigation).toBeDefined()
popupDidStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
expect(popupSetUserAgent).not.toHaveBeenCalled()
})
it('queues permission denials and download requests until the guest registers', () => {
const rendererSendMock = vi.fn()
const guest = {
@ -2806,23 +2985,46 @@ describe('browserManager', () => {
})
describe('setViewportOverride', () => {
function makeGuest(id: number): {
const GUEST_ELECTRON_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
const GUEST_CLEAN_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
// Why: viewport UA writes are queued on the per-tab chain, so draining it takes more than one
// microtask hop; loop until the chain is empty rather than guessing a tick count.
async function flushViewportOps(): Promise<void> {
for (let i = 0; i < 20; i++) {
await Promise.resolve()
}
}
function makeGuest(
id: number,
url = 'https://example.com/'
): {
guest: Record<string, unknown>
debuggerSendCommand: ReturnType<typeof vi.fn>
debuggerIsAttached: ReturnType<typeof vi.fn>
debuggerAttach: ReturnType<typeof vi.fn>
setGuestUserAgent: (ua: string) => void
commitNavigationTo: (nextUrl: string) => void
} {
const debuggerSendCommand = vi.fn().mockResolvedValue(undefined)
const debuggerIsAttached = vi.fn(() => true)
const debuggerAttach = vi.fn()
let currentUa = GUEST_ELECTRON_UA
// Why: getURL() reports the last COMMITTED url — it does not move at did-start-navigation.
let committedUrl = url
const guest = {
id,
isDestroyed: vi.fn(() => false),
getType: vi.fn(() => 'webview'),
getUserAgent: vi.fn(
() =>
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) orca/1.0.0 Chrome/134.0.0.0 Electron/30.0.0 Safari/537.36'
),
getURL: vi.fn(() => committedUrl),
getUserAgent: vi.fn(() => currentUa),
setUserAgent: vi.fn((ua: string) => {
currentUa = ua
}),
session: { getUserAgent: vi.fn(() => GUEST_ELECTRON_UA) },
setBackgroundThrottling: guestSetBackgroundThrottlingMock,
setWindowOpenHandler: guestSetWindowOpenHandlerMock,
on: guestOnMock,
@ -2835,7 +3037,18 @@ describe('browserManager', () => {
sendCommand: debuggerSendCommand
}
}
return { guest, debuggerSendCommand, debuggerIsAttached, debuggerAttach }
return {
guest,
debuggerSendCommand,
debuggerIsAttached,
debuggerAttach,
setGuestUserAgent: (ua: string) => {
currentUa = ua
},
commitNavigationTo: (nextUrl: string) => {
committedUrl = nextUrl
}
}
}
it('returns false when the tab is not registered', async () => {
@ -2921,6 +3134,585 @@ describe('browserManager', () => {
}
)
// Why: the CDP override outranks setUserAgent for navigator.userAgent, so a preset applied on an
// auth host must carry the same Firefox identity the header hook sends (verified against real
// Electron 43: Emulation.setUserAgentOverride wins over WebContents.setUserAgent and stands
// across every later navigation until explicitly cleared).
it.each([false, true])(
'presents the Firefox UA for a preset applied on a Google auth host (mobile=%s)',
async (mobile) => {
const { guest, debuggerSendCommand } = makeGuest(
mobile ? 4246 : 4245,
'https://accounts.google.com/v3/signin/identifier'
)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: `tab-auth-${mobile}`,
webContentsId: guest.id as number,
rendererWebContentsId
})
await browserManager.setViewportOverride(`tab-auth-${mobile}`, {
width: mobile ? 375 : 1024,
height: mobile ? 667 : 768,
deviceScaleFactor: mobile ? 2 : 1,
mobile
})
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
}
)
it('re-issues the standing UA override when navigating onto and back off an auth host', async () => {
const { guest, debuggerSendCommand } = makeGuest(4247)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-auth-nav',
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
// Case A: the desktop preset lands first, while the tab is still off the auth host.
await browserManager.setViewportOverride('tab-auth-nav', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
expect(debuggerSendCommand).toHaveBeenLastCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
})
// Navigating to the auth host must move the standing override to the Firefox identity.
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
await flushViewportOps()
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
// Leaving the auth host restores the clean Chrome-shaped preset UA.
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://example.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: GUEST_CLEAN_UA
})
})
// Why: not an ordering race — debugger.sendCommand dispatches in call order over one channel, so
// the later-issued write always wins. The defect is post-await staleness: verified against real
// Electron 43.1.0, did-start-navigation fires while an awaited sendCommand is still pending, and
// getURL() keeps reporting the OUTGOING page until commit. A preset resuming mid-navigation
// therefore resolves the wrong host and wins with the wrong value. Both writers must resolve the
// host from the in-flight navigation target instead.
function lastUserAgentOverride(
debuggerSendCommand: ReturnType<typeof vi.fn>
): Record<string, unknown> | undefined {
const calls = debuggerSendCommand.mock.calls.filter(
(call) => call[0] === 'Emulation.setUserAgentOverride'
)
return calls.at(-1)?.[1] as Record<string, unknown> | undefined
}
// Why mobile: on the desktop branch the break is masked by coincidence — applyGoogleAuthUserAgent
// has already switched the WebContents UA to Firefox, and cleanElectronUserAgent passes a Firefox
// UA through untouched, so the stale-URL desktop path happens to emit Firefox anyway. The mobile
// branch derives a Chrome-shaped iPhone UA from that same base and exposes the real defect.
it('does not leave the Chrome preset UA standing when a mobile preset lands mid-navigation onto an auth host', async () => {
const { guest, debuggerSendCommand } = makeGuest(4251, 'https://example.com/')
// Hold the preset's first CDP command open so the navigation lands inside its await window.
let releaseMetrics = (): void => {}
const metricsGate = new Promise<void>((resolve) => {
releaseMetrics = () => resolve()
})
debuggerSendCommand.mockImplementation((method: string) =>
method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined)
)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-race-onto-auth',
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-race-onto-auth', {
width: 375,
height: 667,
deviceScaleFactor: 2,
mobile: true
})
await flushViewportOps()
// The tab navigates to the auth host while the preset is still awaiting its first command.
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
releaseMetrics()
await presetDone
await flushViewportOps()
// Without a shared chain + shared URL source, the preset resumes, reads getURL() as the
// pre-navigation host, and pins the tab to the Chrome-shaped UA while it is on the auth host.
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
})
it('does not leave the Firefox UA standing when a preset lands mid-navigation off an auth host', async () => {
const { guest, debuggerSendCommand } = makeGuest(4252, 'https://accounts.google.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-race-off-auth',
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
// Establish a standing preset while the tab really is on the auth host.
await browserManager.setViewportOverride('tab-race-off-auth', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
// A second preset change is now in flight when the tab leaves the auth host.
let releaseMetrics = (): void => {}
const metricsGate = new Promise<void>((resolve) => {
releaseMetrics = () => resolve()
})
debuggerSendCommand.mockImplementation((method: string) =>
method === 'Emulation.setDeviceMetricsOverride' ? metricsGate : Promise.resolve(undefined)
)
const presetDone = browserManager.setViewportOverride('tab-race-off-auth', {
width: 1440,
height: 900,
deviceScaleFactor: 2,
mobile: false
})
await flushViewportOps()
didStartNavigation(null, 'https://example.com/', false, true)
releaseMetrics()
await presetDone
await flushViewportOps()
// Without the fix the resuming preset re-reads getURL() as the auth host and clobbers the
// navigation's correct write, stranding the Firefox UA on a non-auth page.
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({ userAgent: GUEST_CLEAN_UA })
})
it('falls back to the committed URL once a navigation commits or fails', async () => {
const { guest, debuggerSendCommand, commitNavigationTo } = makeGuest(
4253,
'https://example.com/'
)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-pending-cleared',
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 didFailLoad = guestOnMock.mock.calls.find(
([event]) => event === 'did-fail-load'
)?.[1] as (
event: unknown,
errorCode: number,
errorDescription: string,
validatedURL: string,
isMainFrame: boolean
) => void
const didNavigate = guestOnMock.mock.calls.find(
([event]) => event === 'did-navigate'
)?.[1] as (event: unknown, url: string) => void
await browserManager.setViewportOverride('tab-pending-cleared', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
// An aborted navigation to the auth host must not leave that host standing as the tab's
// identity: the tab never went there, so a later preset must resolve the committed URL.
didStartNavigation(null, 'https://accounts.google.com/', false, true)
didFailLoad(null, -3, 'Aborted', 'https://accounts.google.com/', true)
await flushViewportOps()
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,
height: 667,
deviceScaleFactor: 2,
mobile: true
})
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)?.userAgent).toContain('iPhone')
// Same after a successful commit: the committed URL takes over from the pending target.
didStartNavigation(null, 'https://accounts.google.com/signin', false, true)
commitNavigationTo('https://accounts.google.com/signin')
didNavigate(null, 'https://accounts.google.com/signin')
await flushViewportOps()
debuggerSendCommand.mockClear()
await browserManager.setViewportOverride('tab-pending-cleared', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
expect(lastUserAgentOverride(debuggerSendCommand)).toEqual({
userAgent: googleAuthUserAgent()
})
})
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.
it('keeps tracking the standing override when the CDP clear fails', async () => {
const { guest, debuggerSendCommand } = makeGuest(4254, 'https://example.com/')
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-failed-clear',
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-failed-clear', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await flushViewportOps()
// 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
)
await flushViewportOps()
// The override is still standing on the target, so navigation must still be able to correct it.
debuggerSendCommand.mockImplementation(() => Promise.resolve(undefined))
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://accounts.google.com/v3/signin/identifier', false, true)
await flushViewportOps()
expect(debuggerSendCommand).toHaveBeenCalledWith('Emulation.setUserAgentOverride', {
userAgent: googleAuthUserAgent()
})
})
it('does not touch the UA override on navigation when no preset is standing', async () => {
const { guest, debuggerSendCommand } = makeGuest(4248)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-no-preset',
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
didStartNavigation(null, 'https://accounts.google.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.anything()
)
})
it('stops re-issuing the UA override once the preset is cleared', async () => {
const { guest, debuggerSendCommand } = makeGuest(4249)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-cleared-preset',
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-cleared-preset', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
await browserManager.setViewportOverride('tab-cleared-preset', null)
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://accounts.google.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.anything()
)
})
it('leaves the UA override alone on navigation for native-UA profiles', async () => {
const { guest, debuggerSendCommand } = makeGuest(4250)
webContentsFromIdMock.mockReturnValue(guest)
browserManager.attachGuestPolicies(guest as never)
browserManager.registerGuest({
browserPageId: 'tab-native-nav',
sessionProfileId: 'native-profile',
userAgentMode: 'native',
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-native-nav', {
width: 1024,
height: 768,
deviceScaleFactor: 1,
mobile: false
})
debuggerSendCommand.mockClear()
didStartNavigation(null, 'https://accounts.google.com/', false, true)
await flushViewportOps()
expect(debuggerSendCommand).not.toHaveBeenCalledWith(
'Emulation.setUserAgentOverride',
expect.anything()
)
})
it('clears device metrics and disables touch for override=null', async () => {
const { guest, debuggerSendCommand } = makeGuest(4343)
webContentsFromIdMock.mockReturnValue(guest)

View File

@ -43,6 +43,9 @@ 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 {
BrowserViewportOverride,
BrowserCertificateFailure,
@ -130,16 +133,6 @@ function isAutomationVisibilityToken(token: unknown): token is string {
return typeof token === 'string' && token.length > 0
}
// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
function buildMobileUserAgent(chromeMajor: string): string {
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
}
function extractChromeMajor(ua: string): string {
const match = ua.match(/Chrome\/(\d+)/)
return match ? match[1] : '134'
}
export type BrowserGuestRegistration = {
browserPageId?: string
browserTabId?: string
@ -158,6 +151,10 @@ type PopupOwnerContext = {
browserTabId: string
rootGuestWebContentsId: number
}
type PendingMainFrameNavigation = {
currentUrl: string
supersededUrls: string[]
}
const SAFE_POPUP_WINDOW_OPTIONS = {
alwaysOnTop: false,
closable: true,
@ -231,6 +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 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 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>()
@ -781,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 = (
@ -807,6 +825,12 @@ export class BrowserManager {
if (!isMainFrame) {
return
}
// Why: a nav that never committed must not leave its target standing as the tab's host.
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,6 +874,10 @@ export class BrowserManager {
if (!isMainFrame || isChromiumInternalErrorUrl(url)) {
return
}
// 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.
this.pendingLoadFailuresByGuestId.delete(guest.id)
@ -865,13 +893,15 @@ 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.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)
@ -905,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)
@ -913,6 +943,124 @@ export class BrowserManager {
})
}
// Why: navigator.userAgent (read by Google's auth JS) reflects the WebContents UA,
// not the request header, so the header-level Firefox switch in setupClientHintsOverride
// must be matched here per navigation or the two layers disagree — itself a bot tell.
// Restores the session's base identity off the auth hosts. Native-UA profiles opt out
// of the whole clean-UA path, so they keep their untouched identity everywhere.
private applyGoogleAuthUserAgent(guest: Electron.WebContents, url: string): void {
const browserPageId = this.tabIdByWebContentsId.get(guest.id)
// Why: popup child windows get these policies but are never in tabIdByWebContentsId, so a direct
// lookup misses the native-UA opt-out and would hand a native profile's popup the Firefox UA.
// 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)
// 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
}
const firefoxUa = googleAuthUserAgent()
const currentUa = guest.getUserAgent()
if (isGoogleAuthUrl(url)) {
if (currentUa !== firefoxUa) {
guest.setUserAgent(firefoxUa)
}
} else if (currentUa === firefoxUa) {
// Only restore when the auth-host override is actually in place, so normal
// navigation never touches the session UA.
guest.setUserAgent(guest.session.getUserAgent())
}
// Why: gate on the DIRECT page id, not ownerTabId — a popup has no device-metrics override of
// its own, so inheriting the owner tab's preset UA would pair a mobile UA with a desktop viewport.
if (browserPageId) {
this.reapplyViewportUserAgentOverride(guest, browserPageId, url)
}
}
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): string {
return this.pendingNavigationByGuestId.get(guest.id)?.currentUrl ?? guest.getURL()
}
// Why: Emulation.setUserAgentOverride is set once and stands across every later navigation,
// outranking setUserAgent for navigator.userAgent. A viewport preset applied before reaching an
// auth host would otherwise pin navigator.userAgent to the Chrome-shaped preset UA while the
// request header says Firefox — the two-layer disagreement this scope exists to remove.
private reapplyViewportUserAgentOverride(
guest: Electron.WebContents,
browserTabId: string,
url: string
): void {
const mobile = this.viewportUaOverrideMobileByTabId.get(browserTabId)
if (mobile === undefined) {
return
}
// 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, mobile, url).catch(() => {})
}
private async sendViewportUserAgentOverride(
guest: Electron.WebContents,
mobile: boolean,
url?: string
): Promise<void> {
if (guest.isDestroyed() || !guest.debugger.isAttached()) {
return
}
await guest.debugger.sendCommand(
'Emulation.setUserAgentOverride',
buildViewportUserAgentOverride({
url: url ?? this.resolveTabNavigationUrl(guest),
mobile,
baseUserAgent: cleanElectronUserAgent(guest.getUserAgent())
})
)
}
private createPopupChildWindowWithOriginBar(
openerGuest: Electron.WebContents,
targetUrl: string,
@ -961,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) {
@ -1094,6 +1243,10 @@ export class BrowserManager {
this.worktreeIdByTabId.delete(browserTabId)
// 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)
if (wcId !== undefined) {
this.pendingNavigationByGuestId.delete(wcId)
}
this.annotationViewportBridgeOpsByTabId.delete(browserTabId)
}
@ -1159,6 +1312,8 @@ export class BrowserManager {
this.worktreeIdByTabId.clear()
this.sessionProfileIdByPageId.clear()
this.userAgentModeByPageId.clear()
this.viewportUaOverrideMobileByTabId.clear()
this.pendingNavigationByGuestId.clear()
this.pendingLoadFailuresByGuestId.clear()
this.loadErrorsByGuestId.clear()
this.clearedLoadErrorsByGuestId.clear()
@ -1552,36 +1707,10 @@ export class BrowserManager {
})
// Why: viewport sizing must not override a profile's explicit native-UA identity.
if (this.userAgentModeByPageId.get(browserTabId) !== 'native') {
if (override.mobile) {
const chromeMajor = extractChromeMajor(cleanElectronUserAgent(guest.getUserAgent()))
// Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak.
await dbg.sendCommand('Emulation.setUserAgentOverride', {
userAgent: buildMobileUserAgent(chromeMajor),
userAgentMetadata: {
brands: [
{ brand: 'Google Chrome', version: chromeMajor },
{ brand: 'Chromium', version: chromeMajor },
{ brand: 'Not/A)Brand', version: '24' }
],
fullVersionList: [
{ brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` },
{ brand: 'Chromium', version: `${chromeMajor}.0.0.0` },
{ brand: 'Not/A)Brand', version: '24.0.0.0' }
],
fullVersion: `${chromeMajor}.0.0.0`,
platform: 'iOS',
platformVersion: '17.0',
architecture: '',
model: 'iPhone',
mobile: true
}
})
} else {
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
await dbg.sendCommand('Emulation.setUserAgentOverride', {
userAgent: cleanElectronUserAgent(guest.getUserAgent())
})
}
// 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', {})
@ -1589,8 +1718,18 @@ export class BrowserManager {
enabled: false,
maxTouchPoints: 0
})
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.
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
try {
await dbg.sendCommand('Emulation.setUserAgentOverride', { userAgent: '' })
} catch (error) {
if (trackedMobile !== undefined) {
this.viewportUaOverrideMobileByTabId.set(browserTabId, trackedMobile)
}
throw error
}
}
return true
} catch {

View File

@ -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 () => {

View File

@ -393,13 +393,89 @@ describe('BrowserSessionRegistry', () => {
expect(modified['sec-ch-ua']).not.toContain('Microsoft Edge')
})
it('does not register handler for non-Chrome UA', () => {
it('registers handler even for non-Chrome UA but leaves sec-ch-ua untouched off auth hosts', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
// Why: the Google-auth Firefox switch must install regardless of the base UA.
setupClientHintsOverride(mockSess, 'Mozilla/5.0 (compatible; MSIE 10.0)')
expect(onBeforeSendHeaders).not.toHaveBeenCalled()
expect(onBeforeSendHeaders).toHaveBeenCalledWith(
{ urls: ['https://*/*'] },
expect.any(Function)
)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener({ url: 'https://example.com/', requestHeaders: { 'sec-ch-ua': 'old' } }, callback)
expect(callback.mock.calls[0][0].requestHeaders['sec-ch-ua']).toBe('old')
})
it('presents a Firefox UA and strips client hints on Google auth hosts', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(
mockSess,
'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'
)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{
url: 'https://accounts.google.com/v3/signin/identifier',
requestHeaders: {
'User-Agent': 'Chrome/147',
'sec-ch-ua': 'old',
'sec-ch-ua-full-version-list': 'old',
'sec-ch-ua-platform': '"macOS"'
}
},
callback
)
const modified = callback.mock.calls[0][0].requestHeaders
expect(modified['User-Agent']).toMatch(/Firefox\/\d/)
expect(modified['User-Agent']).not.toContain('Chrome')
expect(modified['sec-ch-ua']).toBeUndefined()
expect(modified['sec-ch-ua-full-version-list']).toBeUndefined()
expect(modified['sec-ch-ua-platform']).toBeUndefined()
})
it('keeps Chrome client hints on Google app subdomains (not auth hosts)', () => {
const onBeforeSendHeaders = vi.fn()
const mockSess = { webRequest: { onBeforeSendHeaders } } as never
setupClientHintsOverride(
mockSess,
'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'
)
const callback = vi.fn()
const listener = onBeforeSendHeaders.mock.calls[0][1]
listener(
{ url: 'https://myaccount.google.com/', requestHeaders: { 'sec-ch-ua': 'old' } },
callback
)
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', () => {

View File

@ -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())

View File

@ -1,5 +1,12 @@
import type { Session } from 'electron'
import {
googleAuthUserAgent,
isGoogleAuthUrl,
setUserAgentHeader,
stripClientHints
} from './browser-google-auth-ua'
// Why: Electron's default UA includes "Electron/X.X.X" and the app name
// (e.g. "orca/1.2.3"), which Cloudflare Turnstile and other bot detectors
// flag as non-human traffic. Strip those tokens so the webview's UA and
@ -19,10 +26,46 @@ 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.
const chromeHints = buildChromeClientHints(ua)
const firefoxUa = googleAuthUserAgent()
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
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.
setUserAgentHeader(headers, firefoxUa)
stripClientHints(headers)
callback({ requestHeaders: headers })
return
}
if (chromeHints) {
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = chromeHints.secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = chromeHints.secChUaFull
}
}
}
callback({ requestHeaders: headers })
})
}
function buildChromeClientHints(ua: string): { secChUa: string; secChUaFull: string } | null {
const chromeMatch = ua.match(/Chrome\/([\d.]+)/)
if (!chromeMatch) {
return
return null
}
const fullChromeVersion = chromeMatch[1]
const majorVersion = fullChromeVersion.split('.')[0]
@ -37,19 +80,8 @@ export function setupClientHintsOverride(sess: Session, ua: string): void {
}
const brandMajor = brandFullVersion.split('.')[0]
const secChUa = `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`
const secChUaFull = `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
sess.webRequest.onBeforeSendHeaders({ urls: ['https://*/*'] }, (details, callback) => {
const headers = details.requestHeaders
for (const key of Object.keys(headers)) {
const lower = key.toLowerCase()
if (lower === 'sec-ch-ua') {
headers[key] = secChUa
} else if (lower === 'sec-ch-ua-full-version-list') {
headers[key] = secChUaFull
}
}
callback({ requestHeaders: headers })
})
return {
secChUa: `"${brand}";v="${brandMajor}", "Chromium";v="${majorVersion}", "Not/A)Brand";v="24"`,
secChUaFull: `"${brand}";v="${brandFullVersion}", "Chromium";v="${fullChromeVersion}", "Not/A)Brand";v="24.0.0.0"`
}
}

View File

@ -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)
}

View File

@ -0,0 +1,66 @@
import { describe, expect, it } from 'vitest'
import { googleAuthUserAgent } from './browser-google-auth-ua'
import { buildViewportUserAgentOverride } from './browser-viewport-user-agent'
const CHROME_UA =
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36'
describe('buildViewportUserAgentOverride', () => {
it('presents the Firefox UA on Google auth hosts regardless of the preset', () => {
for (const mobile of [false, true]) {
const override = buildViewportUserAgentOverride({
url: 'https://accounts.google.com/v3/signin/identifier',
mobile,
baseUserAgent: CHROME_UA
})
expect(override.userAgent).toBe(googleAuthUserAgent())
// Real Firefox emits no client hints, so Chrome brands would contradict the stripped headers.
expect(override.userAgentMetadata).toBeUndefined()
}
})
it('keeps the clean desktop UA off the auth hosts', () => {
const override = buildViewportUserAgentOverride({
url: 'https://myaccount.google.com/',
mobile: false,
baseUserAgent: CHROME_UA
})
expect(override.userAgent).toBe(CHROME_UA)
expect(override.userAgentMetadata).toBeUndefined()
})
it('splices the real Chrome major into the mobile UA and its client hints', () => {
const override = buildViewportUserAgentOverride({
url: 'https://example.com/',
mobile: true,
baseUserAgent: CHROME_UA
})
expect(override.userAgent).toContain('iPhone')
expect(override.userAgent).toContain('CriOS/134.0.0.0')
expect(override.userAgentMetadata?.mobile).toBe(true)
expect(override.userAgentMetadata?.platform).toBe('iOS')
expect(override.userAgentMetadata?.brands).toContainEqual({
brand: 'Google Chrome',
version: '134'
})
})
it('falls back to a known Chrome major when the base UA carries none', () => {
const override = buildViewportUserAgentOverride({
url: 'https://example.com/',
mobile: true,
baseUserAgent: googleAuthUserAgent()
})
expect(override.userAgent).toContain('CriOS/134.0.0.0')
})
it('treats an unparseable URL as a non-auth host', () => {
const override = buildViewportUserAgentOverride({
url: 'not a url',
mobile: false,
baseUserAgent: CHROME_UA
})
expect(override.userAgent).toBe(CHROME_UA)
})
})

View File

@ -0,0 +1,73 @@
// Why: a CDP Emulation.setUserAgentOverride outranks WebContents.setUserAgent for both
// navigator.userAgent and the outgoing request header, and it stands across every later
// navigation until explicitly cleared. So the viewport preset's UA is a third identity layer
// that must agree with the auth-host Firefox switch, or applying a preset silently reintroduces
// the exact UA mismatch this scope exists to remove.
import { googleAuthUserAgent, isGoogleAuthUrl } from './browser-google-auth-ua'
type UserAgentBrand = { brand: string; version: string }
export type ViewportUserAgentOverride = {
userAgent: string
userAgentMetadata?: {
brands: UserAgentBrand[]
fullVersionList: UserAgentBrand[]
fullVersion: string
platform: string
platformVersion: string
architecture: string
model: string
mobile: boolean
}
}
// Why: responsive sites UA-sniff; this is Chrome DevTools' default iPhone UA template with the real
// Chrome major spliced in to keep sec-ch-ua consistent (see setupClientHintsOverride).
function buildMobileUserAgent(chromeMajor: string): string {
return `Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/${chromeMajor}.0.0.0 Mobile/15E148 Safari/604.1`
}
function extractChromeMajor(ua: string): string {
const match = ua.match(/Chrome\/(\d+)/)
return match ? match[1] : '134'
}
export function buildViewportUserAgentOverride(args: {
url: string
mobile: boolean
baseUserAgent: string
}): ViewportUserAgentOverride {
if (isGoogleAuthUrl(args.url)) {
// Why: match the header-level Firefox switch exactly, and send no userAgentMetadata — real
// Firefox emits no client hints, so Chrome brands here would contradict the stripped headers.
return { userAgent: googleAuthUserAgent() }
}
if (!args.mobile) {
// Why: desktop presets still need the clean (non-Electron) UA so Cloudflare/Turnstile don't flag the session.
return { userAgent: args.baseUserAgent }
}
const chromeMajor = extractChromeMajor(args.baseUserAgent)
// Why: userAgentMetadata must accompany the mobile UA so client hints match, or bot-detection flags the desktop-hint leak.
return {
userAgent: buildMobileUserAgent(chromeMajor),
userAgentMetadata: {
brands: [
{ brand: 'Google Chrome', version: chromeMajor },
{ brand: 'Chromium', version: chromeMajor },
{ brand: 'Not/A)Brand', version: '24' }
],
fullVersionList: [
{ brand: 'Google Chrome', version: `${chromeMajor}.0.0.0` },
{ brand: 'Chromium', version: `${chromeMajor}.0.0.0` },
{ brand: 'Not/A)Brand', version: '24.0.0.0' }
],
fullVersion: `${chromeMajor}.0.0.0`,
platform: 'iOS',
platformVersion: '17.0',
architecture: '',
model: 'iPhone',
mobile: true
}
}
}