418 lines
11 KiB
JavaScript
418 lines
11 KiB
JavaScript
import process from 'node:process';
|
|
import path from 'node:path';
|
|
import {fileURLToPath} from 'node:url';
|
|
import childProcess from 'node:child_process';
|
|
import fs, {constants as fsConstants} from 'node:fs/promises';
|
|
import {
|
|
isWsl,
|
|
powerShellPath,
|
|
convertWslPathToWindows,
|
|
canAccessPowerShell,
|
|
wslDefaultBrowser,
|
|
} from 'wsl-utils';
|
|
import {executePowerShell} from 'powershell-utils';
|
|
import defineLazyProperty from 'define-lazy-prop';
|
|
import defaultBrowser, {_windowsBrowserProgIdMap} from 'default-browser';
|
|
import isInsideContainer from 'is-inside-container';
|
|
import isInSsh from 'is-in-ssh';
|
|
|
|
const fallbackAttemptSymbol = Symbol('fallbackAttempt');
|
|
|
|
// Path to included `xdg-open`.
|
|
const __dirname = import.meta.url ? path.dirname(fileURLToPath(import.meta.url)) : '';
|
|
const localXdgOpenPath = path.join(__dirname, 'xdg-open');
|
|
|
|
const {platform, arch} = process;
|
|
|
|
const tryEachApp = async (apps, opener) => {
|
|
if (apps.length === 0) {
|
|
// No app was provided
|
|
return;
|
|
}
|
|
|
|
const errors = [];
|
|
|
|
for (const app of apps) {
|
|
try {
|
|
return await opener(app); // eslint-disable-line no-await-in-loop
|
|
} catch (error) {
|
|
errors.push(error);
|
|
}
|
|
}
|
|
|
|
throw new AggregateError(errors, 'Failed to open in all supported apps');
|
|
};
|
|
|
|
// eslint-disable-next-line complexity
|
|
const baseOpen = async options => {
|
|
options = {
|
|
wait: false,
|
|
background: false,
|
|
newInstance: false,
|
|
allowNonzeroExitCode: false,
|
|
...options,
|
|
};
|
|
|
|
const isFallbackAttempt = options[fallbackAttemptSymbol] === true;
|
|
delete options[fallbackAttemptSymbol];
|
|
|
|
if (Array.isArray(options.app)) {
|
|
return tryEachApp(options.app, singleApp => baseOpen({
|
|
...options,
|
|
app: singleApp,
|
|
[fallbackAttemptSymbol]: true,
|
|
}));
|
|
}
|
|
|
|
let {name: app, arguments: appArguments = []} = options.app ?? {};
|
|
appArguments = [...appArguments];
|
|
|
|
if (Array.isArray(app)) {
|
|
return tryEachApp(app, appName => baseOpen({
|
|
...options,
|
|
app: {
|
|
name: appName,
|
|
arguments: appArguments,
|
|
},
|
|
[fallbackAttemptSymbol]: true,
|
|
}));
|
|
}
|
|
|
|
if (app === 'browser' || app === 'browserPrivate') {
|
|
// IDs from default-browser for macOS and windows are the same.
|
|
// IDs are lowercased to increase chances of a match.
|
|
const ids = {
|
|
'com.google.chrome': 'chrome',
|
|
'google-chrome.desktop': 'chrome',
|
|
'com.brave.browser': 'brave',
|
|
'org.mozilla.firefox': 'firefox',
|
|
'firefox.desktop': 'firefox',
|
|
'com.microsoft.msedge': 'edge',
|
|
'com.microsoft.edge': 'edge',
|
|
'com.microsoft.edgemac': 'edge',
|
|
'microsoft-edge.desktop': 'edge',
|
|
'com.apple.safari': 'safari',
|
|
};
|
|
|
|
// Incognito flags for each browser in `apps`.
|
|
const flags = {
|
|
chrome: '--incognito',
|
|
brave: '--incognito',
|
|
firefox: '--private-window',
|
|
edge: '--inPrivate',
|
|
// Safari doesn't support private mode via command line
|
|
};
|
|
|
|
let browser;
|
|
if (isWsl) {
|
|
const progId = await wslDefaultBrowser();
|
|
const browserInfo = _windowsBrowserProgIdMap.get(progId);
|
|
browser = browserInfo ?? {};
|
|
} else {
|
|
browser = await defaultBrowser();
|
|
}
|
|
|
|
if (browser.id in ids) {
|
|
const browserName = ids[browser.id.toLowerCase()];
|
|
|
|
if (app === 'browserPrivate') {
|
|
// Safari doesn't support private mode via command line
|
|
if (browserName === 'safari') {
|
|
throw new Error('Safari doesn\'t support opening in private mode via command line');
|
|
}
|
|
|
|
appArguments.push(flags[browserName]);
|
|
}
|
|
|
|
return baseOpen({
|
|
...options,
|
|
app: {
|
|
name: apps[browserName],
|
|
arguments: appArguments,
|
|
},
|
|
});
|
|
}
|
|
|
|
throw new Error(`${browser.name} is not supported as a default browser`);
|
|
}
|
|
|
|
let command;
|
|
const cliArguments = [];
|
|
const childProcessOptions = {};
|
|
|
|
// Determine if we should use Windows/PowerShell behavior in WSL.
|
|
// We only use Windows integration if PowerShell is actually accessible.
|
|
// This allows the package to work in sandboxed WSL environments where Windows access is restricted.
|
|
let shouldUseWindowsInWsl = false;
|
|
if (isWsl && !isInsideContainer() && !isInSsh && !app) {
|
|
shouldUseWindowsInWsl = await canAccessPowerShell();
|
|
}
|
|
|
|
if (platform === 'darwin') {
|
|
command = 'open';
|
|
|
|
if (options.wait) {
|
|
cliArguments.push('--wait-apps');
|
|
}
|
|
|
|
if (options.background) {
|
|
cliArguments.push('--background');
|
|
}
|
|
|
|
if (options.newInstance) {
|
|
cliArguments.push('--new');
|
|
}
|
|
|
|
if (app) {
|
|
cliArguments.push('-a', app);
|
|
}
|
|
} else if (platform === 'win32' || shouldUseWindowsInWsl) {
|
|
command = await powerShellPath();
|
|
|
|
cliArguments.push(...executePowerShell.argumentsPrefix);
|
|
|
|
if (!isWsl) {
|
|
childProcessOptions.windowsVerbatimArguments = true;
|
|
}
|
|
|
|
// Convert WSL Linux paths to Windows paths
|
|
if (isWsl && options.target) {
|
|
options.target = await convertWslPathToWindows(options.target);
|
|
}
|
|
|
|
// Suppress PowerShell progress messages that are written to stderr
|
|
const encodedArguments = ['$ProgressPreference = \'SilentlyContinue\';', 'Start'];
|
|
|
|
if (options.wait) {
|
|
encodedArguments.push('-Wait');
|
|
}
|
|
|
|
if (app) {
|
|
encodedArguments.push(executePowerShell.escapeArgument(app));
|
|
if (options.target) {
|
|
appArguments.push(options.target);
|
|
}
|
|
} else if (options.target) {
|
|
encodedArguments.push(executePowerShell.escapeArgument(options.target));
|
|
}
|
|
|
|
if (appArguments.length > 0) {
|
|
appArguments = appArguments.map(argument => executePowerShell.escapeArgument(argument));
|
|
encodedArguments.push('-ArgumentList', appArguments.join(','));
|
|
}
|
|
|
|
// Using Base64-encoded command, accepted by PowerShell, to allow special characters.
|
|
options.target = executePowerShell.encodeCommand(encodedArguments.join(' '));
|
|
|
|
if (!options.wait) {
|
|
// PowerShell will keep the parent process alive unless stdio is ignored.
|
|
childProcessOptions.stdio = 'ignore';
|
|
}
|
|
} else {
|
|
if (app) {
|
|
command = app;
|
|
} else {
|
|
// When bundled by Webpack, there's no actual package file path and no local `xdg-open`.
|
|
const isBundled = !__dirname || __dirname === '/';
|
|
|
|
// Check if local `xdg-open` exists and is executable.
|
|
let exeLocalXdgOpen = false;
|
|
try {
|
|
await fs.access(localXdgOpenPath, fsConstants.X_OK);
|
|
exeLocalXdgOpen = true;
|
|
} catch {}
|
|
|
|
const useSystemXdgOpen = process.versions.electron
|
|
?? (platform === 'android' || isBundled || !exeLocalXdgOpen);
|
|
command = useSystemXdgOpen ? 'xdg-open' : localXdgOpenPath;
|
|
}
|
|
|
|
if (appArguments.length > 0) {
|
|
cliArguments.push(...appArguments);
|
|
}
|
|
|
|
if (!options.wait) {
|
|
// `xdg-open` will block the process unless stdio is ignored
|
|
// and it's detached from the parent even if it's unref'd.
|
|
childProcessOptions.stdio = 'ignore';
|
|
childProcessOptions.detached = true;
|
|
}
|
|
}
|
|
|
|
if (platform === 'darwin' && appArguments.length > 0) {
|
|
cliArguments.push('--args', ...appArguments);
|
|
}
|
|
|
|
// IMPORTANT: On macOS, the target MUST come AFTER '--args'.
|
|
// When using --args, ALL following arguments are passed to the app.
|
|
// Example: open -a "chrome" --args --incognito https://site.com
|
|
// This passes BOTH --incognito AND https://site.com to Chrome.
|
|
// Without this order, Chrome won't open in incognito. See #332.
|
|
if (options.target) {
|
|
cliArguments.push(options.target);
|
|
}
|
|
|
|
const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions);
|
|
|
|
if (options.wait) {
|
|
return new Promise((resolve, reject) => {
|
|
subprocess.once('error', reject);
|
|
|
|
subprocess.once('close', exitCode => {
|
|
if (!options.allowNonzeroExitCode && exitCode !== 0) {
|
|
reject(new Error(`Exited with code ${exitCode}`));
|
|
return;
|
|
}
|
|
|
|
resolve(subprocess);
|
|
});
|
|
});
|
|
}
|
|
|
|
// When we're in a fallback attempt, we need to detect launch failures before trying the next app.
|
|
// Wait for the close event to check the exit code before unreffing.
|
|
// The launcher (open/xdg-open/PowerShell) exits quickly (~10-30ms) even on success.
|
|
if (isFallbackAttempt) {
|
|
return new Promise((resolve, reject) => {
|
|
subprocess.once('error', reject);
|
|
|
|
subprocess.once('spawn', () => {
|
|
// Keep error handler active for post-spawn errors
|
|
subprocess.once('close', exitCode => {
|
|
subprocess.off('error', reject);
|
|
|
|
if (exitCode !== 0) {
|
|
reject(new Error(`Exited with code ${exitCode}`));
|
|
return;
|
|
}
|
|
|
|
subprocess.unref();
|
|
resolve(subprocess);
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
subprocess.unref();
|
|
|
|
// Handle spawn errors before the caller can attach listeners.
|
|
// This prevents unhandled error events from crashing the process.
|
|
return new Promise((resolve, reject) => {
|
|
subprocess.once('error', reject);
|
|
|
|
// Wait for the subprocess to spawn before resolving.
|
|
// This ensures the process is established before the caller continues,
|
|
// preventing issues when process.exit() is called immediately after.
|
|
subprocess.once('spawn', () => {
|
|
subprocess.off('error', reject);
|
|
resolve(subprocess);
|
|
});
|
|
});
|
|
};
|
|
|
|
const open = (target, options) => {
|
|
if (typeof target !== 'string') {
|
|
throw new TypeError('Expected a `target`');
|
|
}
|
|
|
|
return baseOpen({
|
|
...options,
|
|
target,
|
|
});
|
|
};
|
|
|
|
export const openApp = (name, options) => {
|
|
if (typeof name !== 'string' && !Array.isArray(name)) {
|
|
throw new TypeError('Expected a valid `name`');
|
|
}
|
|
|
|
const {arguments: appArguments = []} = options ?? {};
|
|
if (appArguments !== undefined && appArguments !== null && !Array.isArray(appArguments)) {
|
|
throw new TypeError('Expected `appArguments` as Array type');
|
|
}
|
|
|
|
return baseOpen({
|
|
...options,
|
|
app: {
|
|
name,
|
|
arguments: appArguments,
|
|
},
|
|
});
|
|
};
|
|
|
|
function detectArchBinary(binary) {
|
|
if (typeof binary === 'string' || Array.isArray(binary)) {
|
|
return binary;
|
|
}
|
|
|
|
const {[arch]: archBinary} = binary;
|
|
|
|
if (!archBinary) {
|
|
throw new Error(`${arch} is not supported`);
|
|
}
|
|
|
|
return archBinary;
|
|
}
|
|
|
|
function detectPlatformBinary({[platform]: platformBinary}, {wsl} = {}) {
|
|
if (wsl && isWsl) {
|
|
return detectArchBinary(wsl);
|
|
}
|
|
|
|
if (!platformBinary) {
|
|
throw new Error(`${platform} is not supported`);
|
|
}
|
|
|
|
return detectArchBinary(platformBinary);
|
|
}
|
|
|
|
export const apps = {
|
|
browser: 'browser',
|
|
browserPrivate: 'browserPrivate',
|
|
};
|
|
|
|
defineLazyProperty(apps, 'chrome', () => detectPlatformBinary({
|
|
darwin: 'google chrome',
|
|
win32: 'chrome',
|
|
// `chromium-browser` is the older deb package name used by Ubuntu/Debian before snap.
|
|
linux: ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'],
|
|
}, {
|
|
wsl: {
|
|
ia32: '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
|
|
x64: ['/mnt/c/Program Files/Google/Chrome/Application/chrome.exe', '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe'],
|
|
},
|
|
}));
|
|
|
|
defineLazyProperty(apps, 'brave', () => detectPlatformBinary({
|
|
darwin: 'brave browser',
|
|
win32: 'brave',
|
|
linux: ['brave-browser', 'brave'],
|
|
}, {
|
|
wsl: {
|
|
ia32: '/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe',
|
|
x64: ['/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe', '/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe'],
|
|
},
|
|
}));
|
|
|
|
defineLazyProperty(apps, 'firefox', () => detectPlatformBinary({
|
|
darwin: 'firefox',
|
|
win32: String.raw`C:\Program Files\Mozilla Firefox\firefox.exe`,
|
|
linux: 'firefox',
|
|
}, {
|
|
wsl: '/mnt/c/Program Files/Mozilla Firefox/firefox.exe',
|
|
}));
|
|
|
|
defineLazyProperty(apps, 'edge', () => detectPlatformBinary({
|
|
darwin: 'microsoft edge',
|
|
win32: 'msedge',
|
|
linux: ['microsoft-edge', 'microsoft-edge-dev'],
|
|
}, {
|
|
wsl: '/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
|
|
}));
|
|
|
|
defineLazyProperty(apps, 'safari', () => detectPlatformBinary({
|
|
darwin: 'Safari',
|
|
}));
|
|
|
|
export default open;
|