From 2725f4fa4616d3406e23f81c312acd33135c9282 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 02:35:23 +0000 Subject: [PATCH 1/2] Make browser windows use Chrome-like user agent --- src/main/browser/BrowserPanelManager.test.ts | 10 +- src/main/browser/BrowserPanelManager.ts | 6 +- src/main/browser/BrowserTab.test.ts | 81 ++++++++++++++ src/main/browser/BrowserTab.ts | 2 + src/main/browser/userAgent.test.ts | 24 +++++ src/main/browser/userAgent.ts | 9 ++ src/main/main.ts | 11 +- src/main/window/createMainWindow.test.ts | 107 +++++++++++++++++++ src/main/window/createMainWindow.ts | 2 + 9 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 src/main/browser/BrowserTab.test.ts create mode 100644 src/main/browser/userAgent.test.ts create mode 100644 src/main/browser/userAgent.ts create mode 100644 src/main/window/createMainWindow.test.ts diff --git a/src/main/browser/BrowserPanelManager.test.ts b/src/main/browser/BrowserPanelManager.test.ts index 3929a7eb..c8ea366d 100644 --- a/src/main/browser/BrowserPanelManager.test.ts +++ b/src/main/browser/BrowserPanelManager.test.ts @@ -63,7 +63,10 @@ describe("BrowserPanelManager", () => { it("rejects the host window WebContents as a browser tab target", async () => { const { BrowserPanelManager } = await import("./BrowserPanelManager"); - const manager = new BrowserPanelManager({ settingsPath: "settings.json" } as never); + const manager = new BrowserPanelManager( + { settingsPath: "settings.json" } as never, + "Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36", + ); const { tab, host } = createManagerWithTab(); manager.bindHost(host as never); @@ -76,7 +79,10 @@ describe("BrowserPanelManager", () => { it("attaches a non-host WebContents to the browser tab", async () => { const { BrowserPanelManager } = await import("./BrowserPanelManager"); - const manager = new BrowserPanelManager({ settingsPath: "settings.json" } as never); + const manager = new BrowserPanelManager( + { settingsPath: "settings.json" } as never, + "Mozilla/5.0 Chrome/141.0.0.0 Safari/537.36", + ); const { tab, host } = createManagerWithTab(); const guestWebContents = { id: 99 }; resolveWebContentsById.mockReturnValue(guestWebContents); diff --git a/src/main/browser/BrowserPanelManager.ts b/src/main/browser/BrowserPanelManager.ts index bfb9474a..96f28b74 100644 --- a/src/main/browser/BrowserPanelManager.ts +++ b/src/main/browser/BrowserPanelManager.ts @@ -63,7 +63,10 @@ export class BrowserPanelManager { hasHostWindow: () => this.host !== null && !this.host.isDestroyed(), }); - constructor(private readonly paths: LightcodePaths) { + constructor( + private readonly paths: LightcodePaths, + private readonly browserUserAgent: string, + ) { this.unsubscribePicker = onPickerCommit((commit) => this.onPickerCommit(commit)); } @@ -260,6 +263,7 @@ export class BrowserPanelManager { const tab = new BrowserTab({ tabId, ...(payload.url ? { initialUrl: payload.url } : {}), + userAgent: this.browserUserAgent, onUpdate: (snap) => { this.emit({ type: "tab-updated", tab: { ...snap } }); this.schedulePersist(); diff --git a/src/main/browser/BrowserTab.test.ts b/src/main/browser/BrowserTab.test.ts new file mode 100644 index 00000000..88bcf6c6 --- /dev/null +++ b/src/main/browser/BrowserTab.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const installSessionPermissions = vi.hoisted(() => vi.fn<() => void>()); +const installNavigationGuards = vi.hoisted(() => + vi.fn<() => () => void>(() => vi.fn<() => void>()), +); +const dialogEnable = vi.hoisted(() => vi.fn<() => Promise>().mockResolvedValue(undefined)); + +vi.mock("electron", () => ({ + webContents: { fromId: vi.fn<() => null>(() => null) }, +})); + +vi.mock("./cdp/cdpClient", () => ({ + CdpClient: class CdpClient { + attach = vi.fn<() => Promise>().mockResolvedValue(undefined); + detach = vi.fn<() => void>(); + }, +})); + +vi.mock("./cdp/dialogController", () => ({ + DialogController: class DialogController { + enable = dialogEnable; + dispose = vi.fn<() => void>(); + }, +})); + +vi.mock("./cdp/networkCapture", () => ({ + NetworkCapture: class NetworkCapture { + dispose = vi.fn<() => void>(); + }, +})); + +vi.mock("./permissions", () => ({ + installSessionPermissions, + installNavigationGuards, + isNavigationUrlAllowed: () => true, +})); + +function createWebContents() { + return { + session: {}, + setUserAgent: vi.fn<(userAgent: string) => void>(), + getURL: vi.fn<() => string>(() => "https://example.com/"), + getTitle: vi.fn<() => string>(() => "Example"), + isDestroyed: vi.fn<() => boolean>(() => false), + isLoadingMainFrame: vi.fn<() => boolean>(() => false), + on: vi.fn<() => void>(), + removeListener: vi.fn<() => void>(), + navigationHistory: { + canGoBack: vi.fn<() => boolean>(() => false), + canGoForward: vi.fn<() => boolean>(() => false), + clear: vi.fn<() => void>(), + }, + }; +} + +describe("BrowserTab", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("applies the configured browser user agent to attached webContents", async () => { + const { BrowserTab } = await import("./BrowserTab"); + const userAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; + const tab = new BrowserTab({ + tabId: "tab-1", + userAgent, + onUpdate: vi.fn<() => void>(), + onAttention: vi.fn<() => void>(), + onPopup: vi.fn<() => void>(), + }); + const webContents = createWebContents(); + + tab.attach(webContents as never); + + expect(webContents.setUserAgent).toHaveBeenCalledWith(userAgent); + expect(installSessionPermissions).toHaveBeenCalledWith(webContents.session); + expect(installNavigationGuards).toHaveBeenCalled(); + }); +}); diff --git a/src/main/browser/BrowserTab.ts b/src/main/browser/BrowserTab.ts index b50c9cf7..b490c7d4 100644 --- a/src/main/browser/BrowserTab.ts +++ b/src/main/browser/BrowserTab.ts @@ -22,6 +22,7 @@ export interface BrowserTabSnapshot { export interface BrowserTabOptions { tabId: string; initialUrl?: string; + userAgent: string; onUpdate(snapshot: BrowserTabSnapshot): void; onAttention(tabId: string): void; onPopup(tabId: string, url: string): void; @@ -101,6 +102,7 @@ export class BrowserTab { this.teardownListeners(); } this._webContents = webContents; + webContents.setUserAgent(this.opts.userAgent); this._cdp = new CdpClient(webContents); installSessionPermissions(webContents.session); const removeNavGuards = installNavigationGuards(webContents, (popupUrl) => { diff --git a/src/main/browser/userAgent.test.ts b/src/main/browser/userAgent.test.ts new file mode 100644 index 00000000..b1e69ad1 --- /dev/null +++ b/src/main/browser/userAgent.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { buildChromeLikeUserAgent } from "./userAgent"; + +describe("buildChromeLikeUserAgent", () => { + it("removes Electron while preserving the current Chromium user agent", () => { + expect( + buildChromeLikeUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Electron/41.7.0 Safari/537.36", + ), + ).toBe( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + ); + }); + + it("removes an app product token before Chrome", () => { + expect( + buildChromeLikeUserAgent( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Lightcode/1.2.1 Chrome/141.0.0.0 Electron/41.7.0 Safari/537.36", + ), + ).toBe( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + ); + }); +}); diff --git a/src/main/browser/userAgent.ts b/src/main/browser/userAgent.ts new file mode 100644 index 00000000..cd8fcac1 --- /dev/null +++ b/src/main/browser/userAgent.ts @@ -0,0 +1,9 @@ +const ELECTRON_PRODUCT_RE = /\sElectron\/[^\s]+/g; +const APP_PRODUCT_BEFORE_CHROME_RE = + /(\(KHTML, like Gecko\)\s+)(?:(?!Chrome\/)\S+\/\S+\s+)+(Chrome\/)/; + +export function buildChromeLikeUserAgent(defaultUserAgent: string): string { + const withoutElectron = defaultUserAgent.replace(ELECTRON_PRODUCT_RE, ""); + const withoutAppProduct = withoutElectron.replace(APP_PRODUCT_BEFORE_CHROME_RE, "$1$2"); + return withoutAppProduct.replace(/\s{2,}/g, " ").trim(); +} diff --git a/src/main/main.ts b/src/main/main.ts index 4e300e8b..05770aa8 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,7 +1,7 @@ import { watch } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; -import { app, BrowserWindow, Menu, nativeTheme } from "electron"; +import { app, BrowserWindow, Menu, nativeTheme, session as electronSession } from "electron"; import { resolveThemeMode } from "@/shared/themeMode"; import { closeDatabase, dbGetThreads, initDatabase } from "./db"; import { cleanupOrphanedAttachments, prepareLightcodeDataRoot } from "./lightcodeData"; @@ -18,6 +18,7 @@ import { installPickerProtocolHandler, registerPickerProtocolScheme, } from "./browser"; +import { buildChromeLikeUserAgent } from "./browser/userAgent"; import { SupervisorClient } from "./supervisor/SupervisorClient"; import { createAutoUpdaterController } from "./updates/autoUpdater"; import { createMainWindow } from "./window/createMainWindow"; @@ -41,6 +42,9 @@ if (process.env.LIGHTCODE_CDP_PORT) { app.commandLine.appendSwitch("remote-debugging-port", process.env.LIGHTCODE_CDP_PORT); } +const chromeLikeUserAgent = buildChromeLikeUserAgent(app.userAgentFallback); +app.userAgentFallback = chromeLikeUserAgent; + if (baseDirOverride) { app.setPath("userData", join(baseDirOverride, "userData")); } else if (isDev) { @@ -182,6 +186,7 @@ if (!hasSingleInstanceLock) { installLocalFileProtocolHandler(); installPickerProtocolHandler(); + electronSession.fromPartition("persist:lightcode-browser").setUserAgent(chromeLikeUserAgent); lightcodePaths = prepareLightcodeDataRoot( baseDirOverride ?? @@ -256,7 +261,7 @@ if (!hasSingleInstanceLock) { }, ); - browserPanelManager = new BrowserPanelManager(lightcodePaths); + browserPanelManager = new BrowserPanelManager(lightcodePaths, chromeLikeUserAgent); browserMcpIngress = new BrowserMcpIngress(); browserMcpIngress.setManagerAccessor(() => browserPanelManager); primeBrowserAllowFlags(); @@ -290,6 +295,7 @@ if (!hasSingleInstanceLock) { posthogKey, sentryEnabled, windowChromeHeight: WINDOW_CHROME_HEIGHT, + browserUserAgent: chromeLikeUserAgent, appearance: resolveAppAppearance(), ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } : {}), onClosed: () => { @@ -378,6 +384,7 @@ if (!hasSingleInstanceLock) { posthogKey, sentryEnabled, windowChromeHeight: WINDOW_CHROME_HEIGHT, + browserUserAgent: chromeLikeUserAgent, appearance: resolveAppAppearance(), ...(process.env.VITE_DEV_SERVER_URL ? { devServerUrl: process.env.VITE_DEV_SERVER_URL } diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts new file mode 100644 index 00000000..02d3f80b --- /dev/null +++ b/src/main/window/createMainWindow.test.ts @@ -0,0 +1,107 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const installSessionPermissions = vi.hoisted(() => vi.fn<() => void>()); +const dbGetState = vi.hoisted(() => vi.fn<() => string | null>(() => null)); +const dbSetState = vi.hoisted(() => vi.fn<() => void>()); +const setUserAgent = vi.hoisted(() => vi.fn<(userAgent: string) => void>()); + +let browserWindowOptions: Record | null = null; +let webContentsHandlers: Record void> = {}; + +vi.mock("electron", () => ({ + BrowserWindow: class BrowserWindow { + webContents = { + session: {}, + send: vi.fn<() => void>(), + openDevTools: vi.fn<() => void>(), + setWindowOpenHandler: vi.fn<() => void>(), + setUserAgent, + on: vi.fn<(event: string, handler: (...args: never[]) => void) => void>((event, handler) => { + webContentsHandlers[event] = handler; + }), + }; + + constructor(options: Record) { + browserWindowOptions = options; + } + + once = vi.fn<() => void>(); + on = vi.fn<() => void>(); + isMaximized = vi.fn<() => boolean>(() => false); + isDestroyed = vi.fn<() => boolean>(() => false); + getNormalBounds = vi.fn<() => { x: number; y: number; width: number; height: number }>(() => ({ + x: 0, + y: 0, + width: 1460, + height: 920, + })); + loadURL = vi.fn<() => Promise>().mockResolvedValue(undefined); + loadFile = vi.fn<() => Promise>().mockResolvedValue(undefined); + show = vi.fn<() => void>(); + maximize = vi.fn<() => void>(); + }, + screen: { + getDisplayMatching: vi.fn< + () => { workArea: { x: number; y: number; width: number; height: number } } + >(() => ({ + workArea: { x: 0, y: 0, width: 1920, height: 1080 }, + })), + }, +})); + +vi.mock("../db", () => ({ + dbGetState, + dbSetState, +})); + +vi.mock("../browser/permissions", () => ({ + installSessionPermissions, +})); + +describe("createMainWindow", () => { + beforeEach(() => { + vi.clearAllMocks(); + browserWindowOptions = null; + webContentsHandlers = {}; + }); + + it("applies the browser user agent to the window and sanitizes attached webviews", async () => { + const { createMainWindow } = await import("./createMainWindow"); + const userAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; + + createMainWindow({ + title: "Lightcode", + isDev: false, + channel: "stable", + preloadPath: "/tmp/preload.cjs", + rendererHtmlPath: "/tmp/index.html", + appVersion: "1.2.1", + posthogEnableDev: false, + posthogEnabled: false, + posthogHost: "", + posthogKey: "", + sentryEnabled: false, + windowChromeHeight: 32, + browserUserAgent: userAgent, + appearance: "dark", + onClosed: vi.fn<() => void>(), + }); + + expect(setUserAgent).toHaveBeenCalledWith(userAgent); + expect((browserWindowOptions?.webPreferences as { webviewTag?: boolean })?.webviewTag).toBe( + true, + ); + + const webPreferences = { + preload: "/tmp/unsafe.cjs", + nodeIntegration: true, + contextIsolation: false, + }; + webContentsHandlers["will-attach-webview"]?.({} as never, webPreferences as never); + + expect(webPreferences.preload).toBeUndefined(); + expect(webPreferences.nodeIntegration).toBe(false); + expect(webPreferences.contextIsolation).toBe(true); + }); +}); diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 32cd5de0..3e6fc81b 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -60,6 +60,7 @@ export interface CreateMainWindowOptions { posthogKey: string; sentryEnabled: boolean; windowChromeHeight: number; + browserUserAgent: string; /** Saved appearance, so the native window opens matching the theme. */ appearance: "light" | "dark"; onClosed(): void; @@ -118,6 +119,7 @@ export function createMainWindow(options: CreateMainWindowOptions): BrowserWindo }, }); installSessionPermissions(window.webContents.session); + window.webContents.setUserAgent(options.browserUserAgent); // Lock the privileged top frame to the app's own origin. The main renderer // holds the full `lightcode` preload bridge (DB, file pickers, openExternal, From 99887f38b07b684841d6c2556e45b2f962159fc3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 8 Jun 2026 03:16:17 +0000 Subject: [PATCH 2/2] Compare normalized UA with Chrome fixture --- src/main/browser/userAgent.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/main/browser/userAgent.test.ts b/src/main/browser/userAgent.test.ts index b1e69ad1..a0ffde85 100644 --- a/src/main/browser/userAgent.test.ts +++ b/src/main/browser/userAgent.test.ts @@ -21,4 +21,15 @@ describe("buildChromeLikeUserAgent", () => { "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", ); }); + + it("matches the equivalent real Chrome user agent", () => { + const realChromeLinuxUserAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"; + + expect( + buildChromeLikeUserAgent( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Lightcode/1.2.1 Chrome/141.0.0.0 Electron/41.7.0 Safari/537.36", + ), + ).toBe(realChromeLinuxUserAgent); + }); });