Skip to content

Commit 0239db8

Browse files
authored
fix(desktop): report a stock Chrome user agent in the browser tab (#6695)
Electron's default user agent carries Sim/<version> and Electron/<version> tokens, and the detection libraries sites gate on test for Electron before Chrome — so the browser read as "Electron", which is on no site's supported list. Ashby warned "Ashby does not support this browser"; stricter sites refuse to render. Rebuild the string as the desktop form Chrome's user-agent reduction specifies — same platform token and Chromium major version, the rest zeroed, no application or Electron token — and apply it to both the browser partition session and each tab's WebContents. Service workers do not inherit a tab's user agent, so without the session-level call a worker's script request still announced Electron. Scoped to the browser partition: app.userAgentFallback is left alone so the Sim shell's own user agent is unchanged.
1 parent b2b6e55 commit 0239db8

5 files changed

Lines changed: 116 additions & 0 deletions

File tree

apps/desktop/src/main/browser-agent/session.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ interface MockView {
2121
setPermissionCheckHandler: ReturnType<typeof vi.fn>
2222
}
2323
on: ReturnType<typeof vi.fn>
24+
setUserAgent: ReturnType<typeof vi.fn>
2425
setWindowOpenHandler: ReturnType<typeof vi.fn>
2526
loadURL: ReturnType<typeof vi.fn>
2627
reload: ReturnType<typeof vi.fn>
@@ -169,6 +170,18 @@ describe('browser-agent session', () => {
169170
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
170171
})
171172

173+
it('gives every tab a user agent with no Electron token in it', () => {
174+
const first = session.ensureTab()
175+
const second = session.addTab()
176+
177+
for (const tab of [first, second]) {
178+
const contents = (tab.view as unknown as MockView).webContents
179+
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
180+
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
181+
expect(agent).not.toMatch(/Electron|Sim\//)
182+
}
183+
})
184+
172185
it('settles the tab spinner when only subresources are still loading', () => {
173186
const tab = session.ensureTab()
174187
const contents = (tab.view as unknown as MockView).webContents

apps/desktop/src/main/browser-agent/session.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import {
6262
isBlockedSubresourceUrl,
6363
subresourceNeedsResolution,
6464
} from '@/main/browser-agent/url-guard'
65+
import { browserUserAgent } from '@/main/browser-agent/user-agent'
6566
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
6667
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
6768
import {
@@ -819,6 +820,11 @@ function configureAgentPartition(ses: Session): void {
819820
configuredPartitions.add(ses)
820821
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
821822
ses.setPermissionCheckHandler(() => false)
823+
// Service workers do not inherit a tab's user agent. With only the tab's set,
824+
// the document request carries the browser string while the worker's own
825+
// script request still announces Electron — and on a site that routes its
826+
// fetches through a worker, that is the one the server sees.
827+
ses.setUserAgent(browserUserAgent())
822828
// SSRF choke point for the agent partition. Document navigations (top-level +
823829
// iframes) get the full DNS-resolving check — the one seam every navigation
824830
// passes through, including page-initiated ones the driver never sees (server
@@ -1101,6 +1107,10 @@ function createTabView(): WebContentsView {
11011107
const contents = view.webContents
11021108
registerAgentWebContents(contents)
11031109
configureAgentPartition(contents.session)
1110+
// The session default does not reach a WebContents that already exists, and
1111+
// the first tab is what brings the session into being, so each tab sets its
1112+
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
1113+
contents.setUserAgent(browserUserAgent())
11041114
attachAgentContextMenu(contents, {
11051115
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
11061116
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)),
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { app } from 'electron'
2+
import { describe, expect, it, vi } from 'vitest'
3+
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
4+
5+
vi.mock('electron', () => import('@/test/electron-mock'))
6+
7+
const ELECTRON_DEFAULT =
8+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
9+
10+
describe('stockChromeUserAgent', () => {
11+
it('drops the application and Electron tokens a browser allowlist rejects', () => {
12+
const agent = stockChromeUserAgent(ELECTRON_DEFAULT)
13+
expect(agent).not.toMatch(/Electron/)
14+
expect(agent).not.toMatch(/Sim\//)
15+
})
16+
17+
it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
18+
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
19+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
20+
)
21+
})
22+
23+
it('keeps the platform token of the machine it is running on', () => {
24+
const windowsDefault =
25+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36'
26+
expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)')
27+
})
28+
29+
it('passes through a string that is not a Chromium user agent', () => {
30+
expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
31+
expect(stockChromeUserAgent('')).toBe('')
32+
})
33+
})
34+
35+
describe('browserUserAgent', () => {
36+
it('derives from the string Electron would otherwise have sent', () => {
37+
app.userAgentFallback = ELECTRON_DEFAULT
38+
39+
expect(browserUserAgent()).toBe(
40+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36'
41+
)
42+
})
43+
})
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* The user agent the browser resource presents to sites.
3+
*
4+
* Electron's default string carries two tokens no browser sends —
5+
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
6+
* beside them, but that does not save it: the detection libraries sites gate on
7+
* test for Electron BEFORE Chrome (bowser matches `/electron/i` several
8+
* descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the
9+
* browser name), so the browser reads as "Electron", which is on nobody's
10+
* supported list. Ashby warns "Ashby does not support this browser"; stricter
11+
* sites refuse to render at all.
12+
*
13+
* Reporting stock Chrome is accurate rather than a disguise — the engine is the
14+
* Chromium build the token already names, and Electron's user-agent client
15+
* hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium
16+
* brand, so dropping the token makes the header and the hints agree instead of
17+
* contradicting each other.
18+
*/
19+
import { app } from 'electron'
20+
21+
/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */
22+
const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./
23+
24+
/**
25+
* Rebuilds the default user agent as the string Chrome itself sends. Chrome's
26+
* user-agent reduction fixes the desktop form at
27+
* `Mozilla/5.0 (<platform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36`,
28+
* so keeping the platform token and the Chromium major version — and zeroing
29+
* the rest — reproduces it exactly, with no room left for an application or
30+
* Electron token. A string that is not a Chromium user agent is returned
31+
* unchanged rather than replaced with a guess.
32+
*/
33+
export function stockChromeUserAgent(defaultUserAgent: string): string {
34+
const match = defaultUserAgent.match(CHROMIUM_USER_AGENT)
35+
if (!match) return defaultUserAgent
36+
const [, platform, chromeMajor] = match
37+
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`
38+
}
39+
40+
/**
41+
* Derived from the string Electron would otherwise have sent, so the reported
42+
* Chromium version tracks whatever Chromium the app actually ships.
43+
*/
44+
export function browserUserAgent(): string {
45+
return stockChromeUserAgent(app.userAgentFallback)
46+
}

apps/desktop/src/test/electron-mock.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { vi } from 'vitest'
1111
export const app = {
1212
name: 'Sim',
1313
isPackaged: false,
14+
userAgentFallback:
15+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Sim/1.0.0 Chrome/140.0.7339.207 Electron/43.1.1 Safari/537.36',
1416
getVersion: vi.fn(() => '1.0.0'),
1517
getName: vi.fn(() => 'Sim'),
1618
setName: vi.fn(),
@@ -152,6 +154,7 @@ function createWebContentsMock() {
152154
findInPage: vi.fn(() => 1),
153155
stopFindInPage: vi.fn(),
154156
setBackgroundThrottling: vi.fn(),
157+
setUserAgent: vi.fn(),
155158
setIgnoreMenuShortcuts: vi.fn(),
156159
getZoomFactor: vi.fn(() => 1),
157160
setZoomFactor: vi.fn(),
@@ -185,6 +188,7 @@ function createWebContentsMock() {
185188
session: {
186189
setPermissionRequestHandler: vi.fn(),
187190
setPermissionCheckHandler: vi.fn(),
191+
setUserAgent: vi.fn(),
188192
webRequest: { onBeforeRequest: vi.fn() },
189193
on: vi.fn(),
190194
},

0 commit comments

Comments
 (0)