Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions src/main/browser/BrowserPanelManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/main/browser/BrowserPanelManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}

Expand Down Expand Up @@ -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();
Expand Down
81 changes: 81 additions & 0 deletions src/main/browser/BrowserTab.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>>().mockResolvedValue(undefined));

vi.mock("electron", () => ({
webContents: { fromId: vi.fn<() => null>(() => null) },
}));

vi.mock("./cdp/cdpClient", () => ({
CdpClient: class CdpClient {
attach = vi.fn<() => Promise<void>>().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();
});
});
2 changes: 2 additions & 0 deletions src/main/browser/BrowserTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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) => {
Expand Down
35 changes: 35 additions & 0 deletions src/main/browser/userAgent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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",
);
});

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);
});
});
9 changes: 9 additions & 0 deletions src/main/browser/userAgent.ts
Original file line number Diff line number Diff line change
@@ -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();
}
11 changes: 9 additions & 2 deletions src/main/main.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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";
Expand All @@ -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) {
Expand Down Expand Up @@ -182,6 +186,7 @@ if (!hasSingleInstanceLock) {

installLocalFileProtocolHandler();
installPickerProtocolHandler();
electronSession.fromPartition("persist:lightcode-browser").setUserAgent(chromeLikeUserAgent);

lightcodePaths = prepareLightcodeDataRoot(
baseDirOverride ??
Expand Down Expand Up @@ -256,7 +261,7 @@ if (!hasSingleInstanceLock) {
},
);

browserPanelManager = new BrowserPanelManager(lightcodePaths);
browserPanelManager = new BrowserPanelManager(lightcodePaths, chromeLikeUserAgent);
browserMcpIngress = new BrowserMcpIngress();
browserMcpIngress.setManagerAccessor(() => browserPanelManager);
primeBrowserAllowFlags();
Expand Down Expand Up @@ -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: () => {
Expand Down Expand Up @@ -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 }
Expand Down
107 changes: 107 additions & 0 deletions src/main/window/createMainWindow.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> | null = null;
let webContentsHandlers: Record<string, (...args: never[]) => 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<string, unknown>) {
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<void>>().mockResolvedValue(undefined);
loadFile = vi.fn<() => Promise<void>>().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);
});
});
2 changes: 2 additions & 0 deletions src/main/window/createMainWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down