Skip to content

Commit

Permalink
Merge pull request #604 from marp-team/wsl2-mirrored-network
Browse files Browse the repository at this point in the history
Find Chrome and Edge from the host Windows as fallback when WSL 2 is mirrored network mode
  • Loading branch information
yhatt authored Oct 5, 2024
2 parents e0cb4e2 + 1c0bc35 commit 440193f
Show file tree
Hide file tree
Showing 11 changed files with 286 additions and 83 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

- Initial support for Firefox / WebDriver BiDi protocol during conversion ([#565](https://github.com/marp-team/marp-cli/issues/565), [#597](https://github.com/marp-team/marp-cli/pull/597))
- `--browser` and some related options to control the browser for conversion ([#603](https://github.com/marp-team/marp-cli/pull/603))
- Find Chrome and Edge from the host Windows as a fallback when [WSL 2 networking is mirrored mode](https://learn.microsoft.com/windows/wsl/networking#mirrored-mode-networking) ([#604](https://github.com/marp-team/marp-cli/pull/604))
- `--debug` (`-d`) option to CLI interface ([#599](https://github.com/marp-team/marp-cli/pull/599))
- CI testing against Node.js v22 ([#591](https://github.com/marp-team/marp-cli/pull/591))

Expand Down
66 changes: 56 additions & 10 deletions src/browser/browser.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { EventEmitter } from 'node:events'
import { launch } from 'puppeteer-core'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { nanoid } from 'nanoid'
import type {
Browser as PuppeteerBrowser,
ProtocolType,
PuppeteerLaunchOptions,
Page,
} from 'puppeteer-core'
import type TypedEventEmitter from 'typed-emitter'
import { isWSL } from '../utils/wsl'
import { debugBrowser } from '../utils/debug'
import { getWindowsEnv, isWSL, translateWindowsPathToWSL } from '../utils/wsl'

export type BrowserKind = 'chrome' | 'firefox'
export type BrowserProtocol = ProtocolType
Expand All @@ -24,6 +28,8 @@ type BrowserEvents = {
launch: (browser: PuppeteerBrowser) => void
}

let wslTmp: string | undefined

const wslHostMatcher = /^\/mnt\/[a-z]\//

export abstract class Browser
Expand All @@ -37,10 +43,14 @@ export abstract class Browser
protocolTimeout: number
puppeteer: PuppeteerBrowser | undefined
timeout: number
#dataDirName: string

private _puppeteerDataDir?: string

constructor(opts: BrowserOptions) {
super()

this.#dataDirName = `marp-cli-${nanoid(10)}`
this.path = opts.path
this.timeout = opts.timeout ?? 30000
this.protocolTimeout =
Expand All @@ -62,6 +72,8 @@ export abstract class Browser
puppeteer.once('disconnected', () => {
this.emit('disconnect', puppeteer)
this.puppeteer = undefined

debugBrowser('Browser disconnected (Cleaned up puppeteer instance)')
})

this.puppeteer = puppeteer
Expand Down Expand Up @@ -110,18 +122,16 @@ export abstract class Browser
)
}

/** @internal Overload in subclass to customize launch behavior */
protected async launchPuppeteer(
/** @internal Overload launch behavior in subclass */
protected abstract launchPuppeteer(
opts: PuppeteerLaunchOptions
): Promise<PuppeteerBrowser> {
return await launch(this.generateLaunchOptions(opts))
}
): Promise<PuppeteerBrowser>

/** @internal */
protected generateLaunchOptions(
protected async generateLaunchOptions(
mergeOptions: PuppeteerLaunchOptions = {}
): PuppeteerLaunchOptions {
return {
): Promise<PuppeteerLaunchOptions> {
const opts = {
browser: this.kind,
executablePath: this.path,
headless: true,
Expand All @@ -130,5 +140,41 @@ export abstract class Browser
timeout: this.timeout,
...mergeOptions,
}

// Don't pass Linux environment variables to Windows process
if (await this.browserInWSLHost()) opts.env = {}

return opts
}

/** @internal */
protected async puppeteerDataDir() {
if (this._puppeteerDataDir === undefined) {
let needToTranslateWindowsPathToWSL = false

this._puppeteerDataDir = await (async () => {
// In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has
// located in host OS (Windows), we have to specify Windows path.
if (await this.browserInWSLHost()) {
if (wslTmp === undefined) wslTmp = await getWindowsEnv('TMP')
if (wslTmp !== undefined) {
needToTranslateWindowsPathToWSL = true
return path.win32.resolve(wslTmp, this.#dataDirName)
}
}
return path.resolve(os.tmpdir(), this.#dataDirName)
})()

debugBrowser(`Chrome data directory: %s`, this._puppeteerDataDir)

// Ensure the data directory is created
const mkdirPath = needToTranslateWindowsPathToWSL
? await translateWindowsPathToWSL(this._puppeteerDataDir)
: this._puppeteerDataDir

await fs.promises.mkdir(mkdirPath, { recursive: true })
debugBrowser(`Created data directory: %s`, mkdirPath)
}
return this._puppeteerDataDir
}
}
57 changes: 3 additions & 54 deletions src/browser/browsers/chrome.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,19 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { nanoid } from 'nanoid'
import { launch } from 'puppeteer-core'
import type {
Browser as PuppeteerBrowser,
PuppeteerLaunchOptions,
} from 'puppeteer-core'
import { CLIErrorCode, error, isError } from '../../error'
import { isInsideContainer } from '../../utils/container'
import { debugBrowser } from '../../utils/debug'
import {
isWSL,
getWindowsEnv,
translateWindowsPathToWSL,
} from '../../utils/wsl'
import { isWSL } from '../../utils/wsl'
import { Browser } from '../browser'
import type { BrowserKind, BrowserProtocol, BrowserOptions } from '../browser'
import type { BrowserKind, BrowserProtocol } from '../browser'
import { isSnapBrowser } from '../finders/utils'

let wslTmp: string | undefined

export class ChromeBrowser extends Browser {
static readonly kind: BrowserKind = 'chrome'
static readonly protocol: BrowserProtocol = 'webDriverBiDi'

private _puppeteerDataDir?: string

#dataDirName: string

constructor(opts: BrowserOptions) {
super(opts)

this.#dataDirName = `marp-cli-${nanoid(10)}`
}

protected async launchPuppeteer(
opts: Omit<PuppeteerLaunchOptions, 'userDataDir'> // userDataDir cannot overload in current implementation
): Promise<PuppeteerBrowser> {
Expand All @@ -49,7 +28,7 @@ export class ChromeBrowser extends Browser {
ignoreDefaultArgsSet.add('--disable-extensions')
}

const baseOpts = this.generateLaunchOptions({
const baseOpts = await this.generateLaunchOptions({
headless: this.puppeteerHeadless(),
pipe: await this.puppeteerPipe(),
// userDataDir: await this.puppeteerDataDir(), // userDataDir will set in args option due to wrong path normalization in WSL
Expand Down Expand Up @@ -125,34 +104,4 @@ export class ChromeBrowser extends Browser {
const modeEnv = process.env.PUPPETEER_HEADLESS_MODE?.toLowerCase() ?? ''
return ['old', 'legacy', 'shell'].includes(modeEnv) ? 'shell' : true
}

private async puppeteerDataDir() {
if (this._puppeteerDataDir === undefined) {
let needToTranslateWindowsPathToWSL = false

this._puppeteerDataDir = await (async () => {
// In WSL environment, Marp CLI may use Chrome on Windows. If Chrome has
// located in host OS (Windows), we have to specify Windows path.
if (await this.browserInWSLHost()) {
if (wslTmp === undefined) wslTmp = await getWindowsEnv('TMP')
if (wslTmp !== undefined) {
needToTranslateWindowsPathToWSL = true
return path.win32.resolve(wslTmp, this.#dataDirName)
}
}
return path.resolve(os.tmpdir(), this.#dataDirName)
})()

debugBrowser(`Chrome data directory: %s`, this._puppeteerDataDir)

// Ensure the data directory is created
const mkdirPath = needToTranslateWindowsPathToWSL
? await translateWindowsPathToWSL(this._puppeteerDataDir)
: this._puppeteerDataDir

await fs.promises.mkdir(mkdirPath, { recursive: true })
debugBrowser(`Created data directory: %s`, mkdirPath)
}
return this._puppeteerDataDir
}
}
20 changes: 20 additions & 0 deletions src/browser/browsers/firefox.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,27 @@
import { launch } from 'puppeteer-core'
import type {
Browser as PuppeteerBrowser,
PuppeteerLaunchOptions,
} from 'puppeteer-core'
import { Browser } from '../browser'
import type { BrowserKind, BrowserProtocol } from '../browser'

export class FirefoxBrowser extends Browser {
static readonly kind: BrowserKind = 'firefox'
static readonly protocol: BrowserProtocol = 'webDriverBiDi'

protected async launchPuppeteer(
opts: PuppeteerLaunchOptions
): Promise<PuppeteerBrowser> {
return await launch(
await this.generateLaunchOptions({
...opts,

// NOTE: Currently Windows path is incompatible with Puppeteer's preparing
userDataDir: (await this.browserInWSLHost())
? undefined
: await this.puppeteerDataDir(),
})
)
}
}
30 changes: 24 additions & 6 deletions src/browser/finders/chrome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
wsl,
} from 'chrome-launcher/dist/chrome-finder'
import { error, CLIErrorCode } from '../../error'
import { getWSL2NetworkingMode } from '../../utils/wsl'
import { ChromeBrowser } from '../browsers/chrome'
import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
Expand Down Expand Up @@ -32,23 +33,40 @@ export const chromeFinder: BrowserFinder = async ({ preferredPath } = {}) => {
const installation = await (async () => {
switch (platform) {
case 'darwin':
return darwinFast()
return chromeFinderDarwin()
case 'linux':
return linux()[0]
return await chromeFinderLinux()
case 'win32':
return win32()[0]
return chromeFinderWin32()
case 'wsl1':
return wsl()[0]
return chromeFinderWSL()
}
return await fallback()
return await chromeFinderFallack()
})()

if (installation) return chrome(installation)

error('Chrome browser could not be found.', CLIErrorCode.NOT_FOUND_BROWSER)
}

const fallback = async () =>
const chromeFinderDarwin = () => darwinFast()
const chromeFinderLinux = async () => {
try {
const linuxPath = linux()[0]
if (linuxPath) return linuxPath
} catch {
// no ops
}

// WSL2 Fallback
if ((await getWSL2NetworkingMode()) === 'mirrored') return chromeFinderWSL()

return undefined
}
const chromeFinderWin32 = (): string | undefined => win32()[0]
const chromeFinderWSL = (): string | undefined => wsl()[0]

const chromeFinderFallack = async () =>
await findExecutableBinary([
'google-chrome-stable',
'google-chrome',
Expand Down
17 changes: 13 additions & 4 deletions src/browser/finders/edge.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import path from 'node:path'
import { error, CLIErrorCode } from '../../error'
import { translateWindowsPathToWSL, getWindowsEnv } from '../../utils/wsl'
import {
translateWindowsPathToWSL,
getWindowsEnv,
getWSL2NetworkingMode,
} from '../../utils/wsl'
import { ChromeBrowser } from '../browsers/chrome'
import { ChromeCdpBrowser } from '../browsers/chrome-cdp'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
Expand All @@ -20,11 +24,16 @@ export const edgeFinder: BrowserFinder = async ({ preferredPath } = {}) => {
case 'darwin':
return await edgeFinderDarwin()
case 'linux':
return await edgeFinderLinux()
return (
(await edgeFinderLinux()) ||
((await getWSL2NetworkingMode()) === 'mirrored'
? await edgeFinderWSL() // WSL2 Fallback
: undefined)
)
case 'win32':
return await edgeFinderWin32()
case 'wsl1':
return await edgeFinderWSL1()
return await edgeFinderWSL()
}
return undefined
})()
Expand Down Expand Up @@ -79,7 +88,7 @@ const edgeFinderWin32 = async ({
return await findExecutable(paths)
}

const edgeFinderWSL1 = async () => {
const edgeFinderWSL = async () => {
const localAppData = await getWindowsEnv('LOCALAPPDATA')

return await edgeFinderWin32({
Expand Down
15 changes: 11 additions & 4 deletions src/browser/finders/firefox.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import path from 'node:path'
import { error, CLIErrorCode } from '../../error'
// import { getWSL2NetworkingMode } from '../../utils/wsl'
import { FirefoxBrowser } from '../browsers/firefox'
import type { BrowserFinder, BrowserFinderResult } from '../finder'
import {
Expand Down Expand Up @@ -36,9 +37,15 @@ export const firefoxFinder: BrowserFinder = async ({ preferredPath } = {}) => {
case 'win32':
return await firefoxFinderWin32()
case 'wsl1':
return await firefoxFinderWSL1()
return await firefoxFinderWSL()
}
return await firefoxFinderFallback()

return await firefoxFinderLinuxOrFallback()
/*
|| ((await getWSL2NetworkingMode()) === 'mirrored'
? await firefoxFinderWSL() // WSL2 Fallback
: undefined)
*/
})()

if (installation) return firefox(installation)
Expand Down Expand Up @@ -92,7 +99,7 @@ const firefoxFinderWin32 = async () => {
)
}

const firefoxFinderWSL1 = async () => {
const firefoxFinderWSL = async () => {
const prefixes: string[] = []

const winDriveMatcher = /^\/mnt\/[a-z]\//i
Expand Down Expand Up @@ -126,7 +133,7 @@ const firefoxFinderWSL1 = async () => {
)
}

const firefoxFinderFallback = async () =>
const firefoxFinderLinuxOrFallback = async () =>
await findExecutableBinary(
// In Linux, Firefox must have only an executable name `firefox` in every
// editions, but some distributions may have provided different executable
Expand Down
4 changes: 2 additions & 2 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ export class Converter {
const browser = await this.browser

return (await browser.browserInWSLHost())
? `file:${await translateWSLPathToWindows(f.absolutePath)}`
? `file:${await translateWSLPathToWindows(f.absolutePath, true)}`
: f.absoluteFileScheme
}

Expand Down Expand Up @@ -643,7 +643,7 @@ export class Converter {

if (tmpFile) {
if (await browser.browserInWSLHost()) {
uri = `file:${await translateWSLPathToWindows(tmpFile.path)}`
uri = `file:${await translateWSLPathToWindows(tmpFile.path, true)}`
} else {
uri = `file://${tmpFile.path}`
}
Expand Down
Loading

0 comments on commit 440193f

Please sign in to comment.