Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fallback to Microsoft Edge if not installed Chrome / Chromium #292

Merged
merged 9 commits into from
Oct 17, 2020
Merged
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

## [Unreleased]

### Added

- Fallback to Microsoft Edge if not installed Chrome ([#199](https://github.com/marp-team/marp-cli/issues/199), [#292](https://github.com/marp-team/marp-cli/pull/292))

### Fixed

- Better support for custom Chrome path via `CHROME_PATH` env in WSL ([#288](https://github.com/marp-team/marp-cli/issues/288), [#292](https://github.com/marp-team/marp-cli/pull/292))

## v0.21.1 - 2020-09-12

### Fixed
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ npx @marp-team/marp-cli -w slide-deck.md
npx @marp-team/marp-cli -s ./slides
```

> :information_source: You have to install [Google Chrome] (or [Chromium]) to convert slide deck into PDF, PPTX, and image(s).
> :information_source: You have to install [Google Chrome], [Chromium], or [Microsoft Edge] to convert slide deck into PDF, PPTX, and image(s).

[google chrome]: https://www.google.com/chrome/
[chromium]: https://www.chromium.org/
[microsoft edge]: https://www.microsoft.com/edge

### Docker

Expand Down Expand Up @@ -113,14 +114,14 @@ When you want to output the converted result to another directory with keeping t

### Convert to PDF (`--pdf`)

If you passed `--pdf` option or the output filename specified by `--output` (`-o`) option ends with `.pdf`, Marp CLI will try to convert into PDF file by using the installed [Google Chrome] or [Chromium].
If you passed `--pdf` option or the output filename specified by `--output` (`-o`) option ends with `.pdf`, Marp CLI will try to convert into PDF file by using the installed [Google Chrome], [Chromium], or [Microsoft Edge].

```bash
marp --pdf slide-deck.md
marp slide-deck.md -o converted.pdf
```

> :information_source: The all kind of conversions except HTML require [Google Chrome] or [Chromium]. When any problem has occurred while converting, please update your Chrome/Chromium to the latest version or try using [Google Chrome Canary].
> :information_source: All kind of conversions except HTML require [Google Chrome], [Chromium], or [Microsoft Edge]. When an unexpected problem has occurred while converting, please update your Chrome/Chromium to the latest version or try installing [Google Chrome Canary].

[google chrome canary]: https://www.google.com/chrome/canary/

Expand Down
35 changes: 21 additions & 14 deletions src/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,8 @@ import { ThemeSet } from './theme'
import {
generatePuppeteerDataDirPath,
generatePuppeteerLaunchArgs,
isWSL,
resolveWSLPath,
} from './utils/puppeteer'
import { isChromeInWSLHost, resolveWSLPathToHost } from './utils/wsl'
import { notifier } from './watcher'

export enum ConvertType {
Expand Down Expand Up @@ -101,8 +100,8 @@ export class Converter {
!!f && f.type === FileType.File

const resolveBase = async (f: File) =>
isWSL()
? `file:${await resolveWSLPath(f.absolutePath)}`
isChromeInWSLHost(generatePuppeteerLaunchArgs().executablePath)
? `file:${await resolveWSLPathToHost(f.absolutePath)}`
: f.absoluteFileScheme

let additionals = ''
Expand Down Expand Up @@ -356,16 +355,20 @@ export class Converter {
})
})()

const uri = await (async () => {
if (tmpFile) {
if (isWSL()) return `file:${await resolveWSLPath(tmpFile.path)}`
return `file://${tmpFile.path}`
}
return `data:text/html;base64,${baseFile.buffer!.toString('base64')}`
})()

try {
const browser = await Converter.runBrowser()

const uri = await (async () => {
if (tmpFile) {
if (isChromeInWSLHost(browser.process().spawnfile)) {
// Windows Chrome should read file from WSL environment
return `file:${await resolveWSLPathToHost(tmpFile.path)}`
}
return `file://${tmpFile.path}`
}
return `data:text/html;base64,${baseFile.buffer!.toString('base64')}`
})()

const page = await browser.newPage()
const { missingFileSet, failedFileSet } = this.trackFailedLocalFileAccess(
page
Expand Down Expand Up @@ -433,9 +436,13 @@ export class Converter {

private static async runBrowser() {
if (!Converter.browser) {
const baseArgs = generatePuppeteerLaunchArgs()

Converter.browser = await puppeteer.launch({
...generatePuppeteerLaunchArgs(),
userDataDir: await generatePuppeteerDataDirPath('marp-cli-conversion'),
...baseArgs,
userDataDir: await generatePuppeteerDataDirPath('marp-cli-conversion', {
wslHost: isChromeInWSLHost(baseArgs.executablePath),
}),
})
Converter.browser.once('disconnected', () => {
Converter.browser = undefined
Expand Down
5 changes: 4 additions & 1 deletion src/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
generatePuppeteerLaunchArgs,
} from './utils/puppeteer'
import TypedEventEmitter from './utils/typed-event-emitter'
import { isChromeInWSLHost } from './utils/wsl'

export namespace Preview {
export interface Events {
Expand Down Expand Up @@ -142,7 +143,9 @@ export class Preview extends TypedEventEmitter<Preview.Events> {
],
defaultViewport: null,
headless: process.env.NODE_ENV === 'test',
userDataDir: await generatePuppeteerDataDirPath('marp-cli-preview'),
userDataDir: await generatePuppeteerDataDirPath('marp-cli-preview', {
wslHost: isChromeInWSLHost(baseArgs.executablePath),
}),
})

// Set Marp icon asynchrnously
Expand Down
75 changes: 75 additions & 0 deletions src/utils/edge-finder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { accessSync, constants } from 'fs'
import path from 'path'
import { isWSL, resolveWindowsEnvSync, resolveWSLPathToGuestSync } from './wsl'

export const findAccessiblePath = (paths: string[]): string | undefined =>
paths.find((p) => {
try {
accessSync(p, constants.X_OK)
return true
} catch (e) {
// no ops
}
return false
})

const linux = (): string | undefined => {
if (isWSL()) {
const localAppData = resolveWindowsEnvSync('LOCALAPPDATA')

return win32({
programFiles: '/mnt/c/Program Files',
programFilesX86: '/mnt/c/Program Files (x86)',
localAppData: localAppData ? resolveWSLPathToGuestSync(localAppData) : '',
})
}

// TODO: Find out Microsoft Edge for Linux
// https://www.microsoftedgeinsider.com/en-us/download?platform=linux
return undefined
}

const darwin = (): string | undefined =>
findAccessiblePath([
'/Applications/Microsoft Edge Canary.app/Contents/MacOS/Microsoft Edge Canary',
'/Applications/Microsoft Edge Dev.app/Contents/MacOS/Microsoft Edge Dev',
'/Applications/Microsoft Edge Beta.app/Contents/MacOS/Microsoft Edge Beta',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
])

const win32 = ({
programFiles = process.env.PROGRAMFILES,
programFilesX86 = process.env['PROGRAMFILES(X86)'],
localAppData = process.env.LOCALAPPDATA,
}: {
programFiles?: string
programFilesX86?: string
localAppData?: string
} = {}): string | undefined => {
const prefixes = [localAppData, programFiles, programFilesX86].filter(
(p): p is string => !!p
)

return findAccessiblePath(
[
path.join('Microsoft', 'Edge SxS', 'Application', 'msedge.exe'),
path.join('Microsoft', 'Edge Dev', 'Application', 'msedge.exe'),
path.join('Microsoft', 'Edge Beta', 'Application', 'msedge.exe'),
path.join('Microsoft', 'Edge', 'Application', 'msedge.exe'),
].reduce<string[]>(
(acc, suffix) => [
...acc,
...prefixes.map((prefix) => path.join(prefix, suffix)),
],
[]
)
)
}

export const findEdgeInstallation = (): string | undefined => {
if (process.platform === 'linux') return linux()
if (process.platform === 'darwin') return darwin()
if (process.platform === 'win32') return win32()

return undefined
}
61 changes: 17 additions & 44 deletions src/utils/puppeteer.ts
Original file line number Diff line number Diff line change
@@ -1,55 +1,23 @@
import { execFile } from 'child_process'
import { readFileSync } from 'fs'
import os from 'os'
import path from 'path'
import { promisify } from 'util'
import { Launcher } from 'chrome-launcher'
import { warn } from '../cli'
import { CLIErrorCode, error } from '../error'

const execFilePromise = promisify(execFile)
import { findEdgeInstallation } from './edge-finder'
import { isWSL, resolveWindowsEnv } from './wsl'

let executablePath: string | undefined | false = false
let isWsl: number | undefined
let wslTmp: string | undefined

export function isWSL(): number {
if (isWsl === undefined) {
if (require('is-wsl')) {
isWsl = 1

try {
// https://github.com/microsoft/WSL/issues/423#issuecomment-611086412
const release = readFileSync('/proc/sys/kernel/osrelease').toString()
if (release.includes('WSL2')) isWsl = 2
} catch (e) {
// no ops
}
} else {
isWsl = 0
}
}
return isWsl
}

export const resolveWSLPath = async (path: string): Promise<string> =>
(await execFilePromise('wslpath', ['-m', path])).stdout.trim()

export const generatePuppeteerDataDirPath = async (
name: string
name: string,
{ wslHost }: { wslHost?: boolean } = {}
): Promise<string> => {
if (isWSL()) {
if (isWSL() && wslHost) {
// In WSL environment, Marp CLI will use Chrome on Windows. Thus, we have to
// specify Windows path when converting within WSL.
if (wslTmp === undefined) {
const tmpRet = (
await execFilePromise('cmd.exe', ['/c', 'SET', 'TMP'])
).stdout.trim()
if (tmpRet.startsWith('TMP=')) wslTmp = tmpRet.slice(4)
}
if (wslTmp !== undefined) {
return path.win32.resolve(wslTmp, name)
}
if (wslTmp === undefined) wslTmp = await resolveWindowsEnv('TMP')
if (wslTmp !== undefined) return path.win32.resolve(wslTmp, name)
}
return path.resolve(os.tmpdir(), name)
}
Expand All @@ -72,17 +40,22 @@ export const generatePuppeteerLaunchArgs = () => {
executablePath = '/usr/bin/chromium-browser'
} else {
try {
;[executablePath] = Launcher.getInstallations()
executablePath = Launcher.getFirstInstallation()
} catch (e) {
if (e instanceof Error) warn(e.message)
}
}

if (!executablePath) {
error(
'You have to install Google Chrome or Chromium to convert slide deck with current options.',
CLIErrorCode.NOT_FOUND_CHROMIUM
)
// Find Edge as fallback (Edge has pre-installed to almost Windows)
executablePath = findEdgeInstallation()

if (!executablePath) {
error(
'You have to install Google Chrome, Chromium, or Microsoft Edge to convert slide deck with current options.',
CLIErrorCode.NOT_FOUND_CHROMIUM
)
}
}
}

Expand Down
53 changes: 53 additions & 0 deletions src/utils/wsl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { execFile, spawnSync } from 'child_process'
import { readFileSync } from 'fs'

let isWsl: number | undefined

export const resolveWSLPathToHost = async (path: string): Promise<string> =>
await new Promise<string>((res, rej) => {
execFile('wslpath', ['-m', path], (err, stdout) =>
err ? rej(err) : res(stdout.trim())
)
})

export const resolveWSLPathToGuestSync = (path: string): string =>
spawnSync('wslpath', ['-u', path]).stdout.toString().trim()

export const resolveWindowsEnv = async (
key: string
): Promise<string | undefined> => {
const ret = await new Promise<string>((res, rej) => {
execFile('cmd.exe', ['/c', 'SET', key], (err, stdout) =>
err ? rej(err) : res(stdout.trim())
)
})

return ret.startsWith(`${key}=`) ? ret.slice(key.length + 1) : undefined
}

export const resolveWindowsEnvSync = (key: string): string | undefined => {
const ret = spawnSync('cmd.exe', ['/c', 'SET', key]).stdout.toString().trim()
return ret.startsWith(`${key}=`) ? ret.slice(key.length + 1) : undefined
}

export const isWSL = (): number => {
if (isWsl === undefined) {
if (require('is-wsl')) {
isWsl = 1

try {
// https://github.com/microsoft/WSL/issues/423#issuecomment-611086412
const release = readFileSync('/proc/sys/kernel/osrelease').toString()
if (release.includes('WSL2')) isWsl = 2
} catch (e) {
// no ops
}
} else {
isWsl = 0
}
}
return isWsl
}

export const isChromeInWSLHost = (chromePath: string | undefined) =>
!!(isWSL() && chromePath?.match(/^\/mnt\/[a-z]\//))
Loading