Skip to content

Commit 573441c

Browse files
authored
feat(webkit): allow running WebKit via WSL on Windows (#36358)
1 parent ec61c03 commit 573441c

38 files changed

+470
-124
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
$ErrorActionPreference = 'Stop'
2+
3+
# WebKit WSL Installation Script
4+
# See webkit-wsl-transport-server.ts for the complete architecture diagram.
5+
# This script sets up a WSL distribution that will be used to run WebKit.
6+
7+
$Distribution = "playwright"
8+
$Username = "pwuser"
9+
10+
$distributions = (wsl --list --quiet) -split "\r?\n"
11+
if ($distributions -contains $Distribution) {
12+
Write-Host "WSL distribution '$Distribution' already exists. Skipping installation."
13+
} else {
14+
Write-Host "Installing new WSL distribution '$Distribution'..."
15+
$VhdSize = "10GB"
16+
wsl --install -d Ubuntu-24.04 --name $Distribution --no-launch --vhd-size $VhdSize
17+
wsl -d $Distribution -u root adduser --gecos GECOS --disabled-password $Username
18+
}
19+
20+
$pwshDirname = (Resolve-Path -Path $PSScriptRoot).Path;
21+
$playwrightCoreRoot = Resolve-Path (Join-Path $pwshDirname "..")
22+
23+
$initScript = @"
24+
if [ ! -f "/home/$Username/node/bin/node" ]; then
25+
mkdir -p /home/$Username/node
26+
curl -fsSL https://nodejs.org/dist/v22.17.0/node-v22.17.0-linux-x64.tar.xz -o /home/$Username/node/node-v22.17.0-linux-x64.tar.xz
27+
tar -xJf /home/$Username/node/node-v22.17.0-linux-x64.tar.xz -C /home/$Username/node --strip-components=1
28+
fi
29+
/home/$Username/node/bin/node cli.js install-deps webkit
30+
cp lib/server/webkit/wsl/webkit-wsl-transport-client.js /home/$Username/
31+
sudo -u $Username PLAYWRIGHT_SKIP_BROWSER_GC=1 /home/$Username/node/bin/node cli.js install webkit
32+
"@ -replace "\r\n", "`n"
33+
34+
wsl -d $Distribution --cd $playwrightCoreRoot -u root -- bash -c "$initScript"
35+
Write-Host "Done!"

packages/playwright-core/src/server/bidi/bidiChromium.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export class BidiChromium extends BrowserType {
9292
return false;
9393
}
9494

95-
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
95+
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
9696
const chromeArguments = this._innerDefaultArgs(options);
9797
chromeArguments.push(`--user-data-dir=${userDataDir}`);
9898
chromeArguments.push('--remote-debugging-port=0');

packages/playwright-core/src/server/bidi/bidiFirefox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export class BidiFirefox extends BrowserType {
9292
});
9393
}
9494

95-
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
95+
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
9696
const { args = [], headless } = options;
9797
const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile'));
9898
if (userDataDirArg)

packages/playwright-core/src/server/browserType.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -174,9 +174,9 @@ export abstract class BrowserType extends SdkObject {
174174
if (ignoreAllDefaultArgs)
175175
browserArguments.push(...args);
176176
else if (ignoreDefaultArgs)
177-
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
177+
browserArguments.push(...(await this.defaultArgs(options, isPersistent, userDataDir)).filter(arg => ignoreDefaultArgs.indexOf(arg) === -1));
178178
else
179-
browserArguments.push(...this.defaultArgs(options, isPersistent, userDataDir));
179+
browserArguments.push(...await this.defaultArgs(options, isPersistent, userDataDir));
180180

181181
let executable: string;
182182
if (executablePath) {
@@ -212,7 +212,7 @@ export abstract class BrowserType extends SdkObject {
212212
const { launchedProcess, gracefullyClose, kill } = await launchProcess({
213213
command: prepared.executable,
214214
args: prepared.browserArguments,
215-
env: this.amendEnvironment(env, prepared.userDataDir, isPersistent),
215+
env: this.amendEnvironment(env, prepared.userDataDir, isPersistent, options),
216216
handleSIGINT,
217217
handleSIGTERM,
218218
handleSIGHUP,
@@ -338,9 +338,9 @@ export abstract class BrowserType extends SdkObject {
338338
return options.channel || this._name;
339339
}
340340

341-
abstract defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[];
341+
abstract defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): Promise<string[]>;
342342
abstract connectToTransport(transport: ConnectionTransport, options: BrowserOptions, browserLogsCollector: RecentLogsCollector): Promise<Browser>;
343-
abstract amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean): NodeJS.ProcessEnv;
343+
abstract amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv;
344344
abstract doRewriteStartupLog(error: ProtocolError): ProtocolError;
345345
abstract attemptToGracefullyCloseBrowser(transport: ConnectionTransport): void;
346346
}

packages/playwright-core/src/server/chromium/chromium.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -280,7 +280,7 @@ export class Chromium extends BrowserType {
280280
}
281281
}
282282

283-
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
283+
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
284284
const chromeArguments = this._innerDefaultArgs(options);
285285
chromeArguments.push(`--user-data-dir=${userDataDir}`);
286286
if (options.cdpPort !== undefined)

packages/playwright-core/src/server/firefox/firefox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export class Firefox extends BrowserType {
6969
transport.send(message);
7070
}
7171

72-
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
72+
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string) {
7373
const { args = [], headless } = options;
7474
const userDataDirArg = args.find(arg => arg.startsWith('-profile') || arg.startsWith('--profile'));
7575
if (userDataDirArg)

packages/playwright-core/src/server/registry/index.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -510,7 +510,7 @@ const allDownloadable = ['android', 'chromium', 'firefox', 'webkit', 'ffmpeg', '
510510

511511
export interface Executable {
512512
type: 'browser' | 'tool' | 'channel';
513-
name: BrowserName | InternalTool | ChromiumChannel | BidiChannel;
513+
name: BrowserName | InternalTool | ChromiumChannel | BidiChannel | 'webkit-wsl';
514514
browserName: BrowserName | undefined;
515515
installType: 'download-by-default' | 'download-on-demand' | 'install-script' | 'none';
516516
directory: string | undefined;
@@ -519,6 +519,7 @@ export interface Executable {
519519
executablePathOrDie(sdkLanguage: string): string;
520520
executablePath(sdkLanguage: string): string | undefined;
521521
_validateHostRequirements(sdkLanguage: string): Promise<void>;
522+
wslExecutablePath?: string
522523
}
523524

524525
interface ExecutableImpl extends Executable {
@@ -816,6 +817,31 @@ export class Registry {
816817
_dependencyGroup: 'webkit',
817818
_isHermeticInstallation: true,
818819
});
820+
this._executables.push({
821+
type: 'channel',
822+
name: 'webkit-wsl',
823+
browserName: 'webkit',
824+
directory: webkit.dir,
825+
executablePath: () => process.execPath,
826+
executablePathOrDie: () => process.execPath,
827+
wslExecutablePath: `/home/pwuser/.cache/ms-playwright/webkit-${webkit.revision}/pw_run.sh`,
828+
installType: 'download-on-demand',
829+
_validateHostRequirements: (sdkLanguage: string) => Promise.resolve(),
830+
_isHermeticInstallation: true,
831+
_install: async () => {
832+
if (process.platform !== 'win32')
833+
throw new Error(`WebKit via WSL is only supported on Windows`);
834+
const script = path.join(BIN_PATH, 'install_webkit_wsl.ps1');
835+
const { code } = await spawnAsync('powershell.exe', [
836+
'-ExecutionPolicy', 'Bypass',
837+
'-File', script,
838+
], {
839+
stdio: 'inherit',
840+
});
841+
if (code !== 0)
842+
throw new Error(`Failed to install WebKit via WSL`);
843+
},
844+
});
819845

820846
const ffmpeg = descriptors.find(d => d.name === 'ffmpeg')!;
821847
const ffmpegExecutable = findExecutablePath(ffmpeg.dir, 'ffmpeg');

packages/playwright-core/src/server/webkit/webkit.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ import { kBrowserCloseMessageId } from './wkConnection';
2121
import { wrapInASCIIBox } from '../utils/ascii';
2222
import { BrowserType, kNoXServerRunningError } from '../browserType';
2323
import { WKBrowser } from '../webkit/wkBrowser';
24+
import { spawnAsync } from '../utils/spawnAsync';
25+
import { registry } from '../registry';
2426

2527
import type { BrowserOptions } from '../browser';
2628
import type { SdkObject } from '../instrumentation';
@@ -37,10 +39,11 @@ export class WebKit extends BrowserType {
3739
return WKBrowser.connect(this.attribution.playwright, transport, options);
3840
}
3941

40-
override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean): NodeJS.ProcessEnv {
42+
override amendEnvironment(env: NodeJS.ProcessEnv, userDataDir: string, isPersistent: boolean, options: types.LaunchOptions): NodeJS.ProcessEnv {
4143
return {
4244
...env,
4345
CURL_COOKIE_JAR_PATH: process.platform === 'win32' && isPersistent ? path.join(userDataDir, 'cookiejar.db') : undefined,
46+
WEBKIT_EXECUTABLE: options.channel === 'webkit-wsl' ? registry.findExecutable('webkit-wsl')!.wslExecutablePath! : undefined
4447
};
4548
}
4649

@@ -57,20 +60,29 @@ export class WebKit extends BrowserType {
5760
transport.send({ method: 'Playwright.close', params: {}, id: kBrowserCloseMessageId });
5861
}
5962

60-
override defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): string[] {
63+
override async defaultArgs(options: types.LaunchOptions, isPersistent: boolean, userDataDir: string): Promise<string[]> {
6164
const { args = [], headless } = options;
6265
const userDataDirArg = args.find(arg => arg.startsWith('--user-data-dir'));
6366
if (userDataDirArg)
6467
throw this._createUserDataDirArgMisuseError('--user-data-dir');
6568
if (args.find(arg => !arg.startsWith('-')))
6669
throw new Error('Arguments can not specify page to be opened');
6770
const webkitArguments = ['--inspector-pipe'];
68-
if (process.platform === 'win32')
71+
72+
if (options.channel === 'webkit-wsl') {
73+
if (options.executablePath)
74+
throw new Error('Cannot specify executablePath when using the "webkit-wsl" channel.');
75+
webkitArguments.unshift(
76+
path.join(__dirname, 'wsl/webkit-wsl-transport-server.js'),
77+
);
78+
}
79+
80+
if (process.platform === 'win32' && options.channel !== 'webkit-wsl')
6981
webkitArguments.push('--disable-accelerated-compositing');
7082
if (headless)
7183
webkitArguments.push('--headless');
7284
if (isPersistent)
73-
webkitArguments.push(`--user-data-dir=${userDataDir}`);
85+
webkitArguments.push(`--user-data-dir=${options.channel === 'webkit-wsl' ? await translatePathToWSL(userDataDir) : userDataDir}`);
7486
else
7587
webkitArguments.push(`--no-startup-window`);
7688
const proxy = options.proxyOverride || options.proxy;
@@ -79,7 +91,7 @@ export class WebKit extends BrowserType {
7991
webkitArguments.push(`--proxy=${proxy.server}`);
8092
if (proxy.bypass)
8193
webkitArguments.push(`--proxy-bypass-list=${proxy.bypass}`);
82-
} else if (process.platform === 'linux') {
94+
} else if (process.platform === 'linux' || (process.platform === 'win32' && options.channel === 'webkit-wsl')) {
8395
webkitArguments.push(`--proxy=${proxy.server}`);
8496
if (proxy.bypass)
8597
webkitArguments.push(...proxy.bypass.split(',').map(t => `--ignore-host=${t}`));
@@ -97,3 +109,8 @@ export class WebKit extends BrowserType {
97109
return webkitArguments;
98110
}
99111
}
112+
113+
export async function translatePathToWSL(path: string): Promise<string> {
114+
const { stdout } = await spawnAsync('wsl.exe', ['-d', 'playwright', '--cd', '/home/pwuser', 'wslpath', path.replace(/\\/g, '\\\\')]);
115+
return stdout.toString().trim();
116+
}

packages/playwright-core/src/server/webkit/wkBrowser.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as network from '../network';
2222
import { WKConnection, WKSession, kPageProxyMessageReceived } from './wkConnection';
2323
import { WKPage } from './wkPage';
2424
import { TargetClosedError } from '../errors';
25+
import { translatePathToWSL } from './webkit';
2526

2627
import type { BrowserOptions } from '../browser';
2728
import type { SdkObject } from '../instrumentation';
@@ -87,7 +88,7 @@ export class WKBrowser extends Browser {
8788
const createOptions = proxy ? {
8889
// Enable socks5 hostname resolution on Windows.
8990
// See https://github.com/microsoft/playwright/issues/20451
90-
proxyServer: process.platform === 'win32' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
91+
proxyServer: process.platform === 'win32' && this.attribution.browser?.options.channel !== 'webkit-wsl' ? proxy.server.replace(/^socks5:\/\//, 'socks5h://') : proxy.server,
9192
proxyBypassList: proxy.bypass
9293
} : undefined;
9394
const { browserContextId } = await this._browserSession.send('Playwright.createContext', createOptions);
@@ -227,7 +228,7 @@ export class WKBrowserContext extends BrowserContext {
227228
const promises: Promise<any>[] = [super._initialize()];
228229
promises.push(this._browser._browserSession.send('Playwright.setDownloadBehavior', {
229230
behavior: this._options.acceptDownloads === 'accept' ? 'allow' : 'deny',
230-
downloadPath: this._browser.options.downloadsPath,
231+
downloadPath: this._browser.options.channel === 'webkit-wsl' ? await translatePathToWSL(this._browser.options.downloadsPath) : this._browser.options.downloadsPath,
231232
browserContextId
232233
}));
233234
if (this._options.ignoreHTTPSErrors || this._options.internalIgnoreHTTPSErrors)

packages/playwright-core/src/server/webkit/wkPage.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { WKInterceptableRequest, WKRouteImpl } from './wkInterceptableRequest';
3939
import { WKProvisionalPage } from './wkProvisionalPage';
4040
import { WKWorkers } from './wkWorkers';
4141
import { debugLogger } from '../utils/debugLogger';
42+
import { translatePathToWSL } from './webkit';
4243

4344
import type { Protocol } from './protocol';
4445
import type { WKBrowserContext } from './wkBrowser';
@@ -842,7 +843,7 @@ export class WKPage implements PageDelegate {
842843
private async _startVideo(options: types.PageScreencastOptions): Promise<void> {
843844
assert(!this._recordingVideoFile);
844845
const { screencastId } = await this._pageProxySession.send('Screencast.startVideo', {
845-
file: options.outputFile,
846+
file: this._browserContext._browser.options.channel === 'webkit-wsl' ? await translatePathToWSL(options.outputFile) : options.outputFile,
846847
width: options.width,
847848
height: options.height,
848849
toolbarHeight: this._toolbarHeight()
@@ -976,6 +977,8 @@ export class WKPage implements PageDelegate {
976977
async setInputFilePaths(handle: dom.ElementHandle<HTMLInputElement>, paths: string[]): Promise<void> {
977978
const pageProxyId = this._pageProxySession.sessionId;
978979
const objectId = handle._objectId;
980+
if (this._browserContext._browser?.options.channel === 'webkit-wsl')
981+
paths = await Promise.all(paths.map(path => translatePathToWSL(path)));
979982
await Promise.all([
980983
this._pageProxySession.connection.browserSession.send('Playwright.grantFileReadAccess', { pageProxyId, paths }),
981984
this._session.send('DOM.setInputFiles', { objectId, paths })

0 commit comments

Comments
 (0)