mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
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.
This commit is contained in:
@@ -21,6 +21,7 @@ interface MockView {
|
||||
setPermissionCheckHandler: ReturnType<typeof vi.fn>
|
||||
}
|
||||
on: ReturnType<typeof vi.fn>
|
||||
setUserAgent: ReturnType<typeof vi.fn>
|
||||
setWindowOpenHandler: ReturnType<typeof vi.fn>
|
||||
loadURL: ReturnType<typeof vi.fn>
|
||||
reload: ReturnType<typeof vi.fn>
|
||||
@@ -169,6 +170,18 @@ describe('browser-agent session', () => {
|
||||
expect(onTabNavigated).toHaveBeenCalledWith(contents, true)
|
||||
})
|
||||
|
||||
it('gives every tab a user agent with no Electron token in it', () => {
|
||||
const first = session.ensureTab()
|
||||
const second = session.addTab()
|
||||
|
||||
for (const tab of [first, second]) {
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
const agent = contents.setUserAgent.mock.calls.at(-1)?.[0] as string | undefined
|
||||
expect(agent).toMatch(/^Mozilla\/5\.0 \(.+\) .*Chrome\/\d+\.0\.0\.0 Safari\/537\.36$/)
|
||||
expect(agent).not.toMatch(/Electron|Sim\//)
|
||||
}
|
||||
})
|
||||
|
||||
it('settles the tab spinner when only subresources are still loading', () => {
|
||||
const tab = session.ensureTab()
|
||||
const contents = (tab.view as unknown as MockView).webContents
|
||||
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
isBlockedSubresourceUrl,
|
||||
subresourceNeedsResolution,
|
||||
} from '@/main/browser-agent/url-guard'
|
||||
import { browserUserAgent } from '@/main/browser-agent/user-agent'
|
||||
import type { BrowserSessionSnapshot } from '@/main/desktop-chat-session-store'
|
||||
import { suggestedFilename, uniqueDownloadPath } from '@/main/downloads'
|
||||
import {
|
||||
@@ -819,6 +820,11 @@ function configureAgentPartition(ses: Session): void {
|
||||
configuredPartitions.add(ses)
|
||||
ses.setPermissionRequestHandler((_wc, _permission, callback) => callback(false))
|
||||
ses.setPermissionCheckHandler(() => false)
|
||||
// Service workers do not inherit a tab's user agent. With only the tab's set,
|
||||
// the document request carries the browser string while the worker's own
|
||||
// script request still announces Electron — and on a site that routes its
|
||||
// fetches through a worker, that is the one the server sees.
|
||||
ses.setUserAgent(browserUserAgent())
|
||||
// SSRF choke point for the agent partition. Document navigations (top-level +
|
||||
// iframes) get the full DNS-resolving check — the one seam every navigation
|
||||
// passes through, including page-initiated ones the driver never sees (server
|
||||
@@ -1101,6 +1107,10 @@ function createTabView(): WebContentsView {
|
||||
const contents = view.webContents
|
||||
registerAgentWebContents(contents)
|
||||
configureAgentPartition(contents.session)
|
||||
// The session default does not reach a WebContents that already exists, and
|
||||
// the first tab is what brings the session into being, so each tab sets its
|
||||
// own as well — otherwise tab one browses as Electron and the rest as Chrome.
|
||||
contents.setUserAgent(browserUserAgent())
|
||||
attachAgentContextMenu(contents, {
|
||||
addToChat: (text) => withBrowserScope(scopeId, () => addPageSelectionToChat(contents, text)),
|
||||
openTab: (url) => withBrowserScope(scopeId, () => openTabWithUrl(url, false)),
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { app } from 'electron'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { browserUserAgent, stockChromeUserAgent } from '@/main/browser-agent/user-agent'
|
||||
|
||||
vi.mock('electron', () => import('@/test/electron-mock'))
|
||||
|
||||
const ELECTRON_DEFAULT =
|
||||
'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'
|
||||
|
||||
describe('stockChromeUserAgent', () => {
|
||||
it('drops the application and Electron tokens a browser allowlist rejects', () => {
|
||||
const agent = stockChromeUserAgent(ELECTRON_DEFAULT)
|
||||
expect(agent).not.toMatch(/Electron/)
|
||||
expect(agent).not.toMatch(/Sim\//)
|
||||
})
|
||||
|
||||
it('reproduces the desktop string Chrome sends under user-agent reduction', () => {
|
||||
expect(stockChromeUserAgent(ELECTRON_DEFAULT)).toBe(
|
||||
'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'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the platform token of the machine it is running on', () => {
|
||||
const windowsDefault =
|
||||
'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'
|
||||
expect(stockChromeUserAgent(windowsDefault)).toContain('(Windows NT 10.0; Win64; x64)')
|
||||
})
|
||||
|
||||
it('passes through a string that is not a Chromium user agent', () => {
|
||||
expect(stockChromeUserAgent('curl/8.4.0')).toBe('curl/8.4.0')
|
||||
expect(stockChromeUserAgent('')).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browserUserAgent', () => {
|
||||
it('derives from the string Electron would otherwise have sent', () => {
|
||||
app.userAgentFallback = ELECTRON_DEFAULT
|
||||
|
||||
expect(browserUserAgent()).toBe(
|
||||
'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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* The user agent the browser resource presents to sites.
|
||||
*
|
||||
* Electron's default string carries two tokens no browser sends —
|
||||
* `Sim/<version>` and `Electron/<version>`. Chromium's own token sits right
|
||||
* beside them, but that does not save it: the detection libraries sites gate on
|
||||
* test for Electron BEFORE Chrome (bowser matches `/electron/i` several
|
||||
* descriptors ahead of its Chrome one, ua-parser-js reports `Electron` as the
|
||||
* browser name), so the browser reads as "Electron", which is on nobody's
|
||||
* supported list. Ashby warns "Ashby does not support this browser"; stricter
|
||||
* sites refuse to render at all.
|
||||
*
|
||||
* Reporting stock Chrome is accurate rather than a disguise — the engine is the
|
||||
* Chromium build the token already names, and Electron's user-agent client
|
||||
* hints (`Sec-CH-UA`, `navigator.userAgentData`) only ever carried a Chromium
|
||||
* brand, so dropping the token makes the header and the hints agree instead of
|
||||
* contradicting each other.
|
||||
*/
|
||||
import { app } from 'electron'
|
||||
|
||||
/** Platform token, then the Chromium major version, in the order a Chromium user agent lists them. */
|
||||
const CHROMIUM_USER_AGENT = /^Mozilla\/5\.0 \(([^)]*)\).* Chrome\/(\d+)\./
|
||||
|
||||
/**
|
||||
* Rebuilds the default user agent as the string Chrome itself sends. Chrome's
|
||||
* user-agent reduction fixes the desktop form at
|
||||
* `Mozilla/5.0 (<platform>) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/<major>.0.0.0 Safari/537.36`,
|
||||
* so keeping the platform token and the Chromium major version — and zeroing
|
||||
* the rest — reproduces it exactly, with no room left for an application or
|
||||
* Electron token. A string that is not a Chromium user agent is returned
|
||||
* unchanged rather than replaced with a guess.
|
||||
*/
|
||||
export function stockChromeUserAgent(defaultUserAgent: string): string {
|
||||
const match = defaultUserAgent.match(CHROMIUM_USER_AGENT)
|
||||
if (!match) return defaultUserAgent
|
||||
const [, platform, chromeMajor] = match
|
||||
return `Mozilla/5.0 (${platform}) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chromeMajor}.0.0.0 Safari/537.36`
|
||||
}
|
||||
|
||||
/**
|
||||
* Derived from the string Electron would otherwise have sent, so the reported
|
||||
* Chromium version tracks whatever Chromium the app actually ships.
|
||||
*/
|
||||
export function browserUserAgent(): string {
|
||||
return stockChromeUserAgent(app.userAgentFallback)
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import { vi } from 'vitest'
|
||||
export const app = {
|
||||
name: 'Sim',
|
||||
isPackaged: false,
|
||||
userAgentFallback:
|
||||
'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',
|
||||
getVersion: vi.fn(() => '1.0.0'),
|
||||
getName: vi.fn(() => 'Sim'),
|
||||
setName: vi.fn(),
|
||||
@@ -152,6 +154,7 @@ function createWebContentsMock() {
|
||||
findInPage: vi.fn(() => 1),
|
||||
stopFindInPage: vi.fn(),
|
||||
setBackgroundThrottling: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
setIgnoreMenuShortcuts: vi.fn(),
|
||||
getZoomFactor: vi.fn(() => 1),
|
||||
setZoomFactor: vi.fn(),
|
||||
@@ -185,6 +188,7 @@ function createWebContentsMock() {
|
||||
session: {
|
||||
setPermissionRequestHandler: vi.fn(),
|
||||
setPermissionCheckHandler: vi.fn(),
|
||||
setUserAgent: vi.fn(),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
on: vi.fn(),
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user