index.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. import process from 'node:process';
  2. import path from 'node:path';
  3. import {fileURLToPath} from 'node:url';
  4. import childProcess from 'node:child_process';
  5. import fs, {constants as fsConstants} from 'node:fs/promises';
  6. import {
  7. isWsl,
  8. powerShellPath,
  9. convertWslPathToWindows,
  10. canAccessPowerShell,
  11. wslDefaultBrowser,
  12. } from 'wsl-utils';
  13. import {executePowerShell} from 'powershell-utils';
  14. import defineLazyProperty from 'define-lazy-prop';
  15. import defaultBrowser, {_windowsBrowserProgIdMap} from 'default-browser';
  16. import isInsideContainer from 'is-inside-container';
  17. import isInSsh from 'is-in-ssh';
  18. const fallbackAttemptSymbol = Symbol('fallbackAttempt');
  19. // Path to included `xdg-open`.
  20. const __dirname = import.meta.url ? path.dirname(fileURLToPath(import.meta.url)) : '';
  21. const localXdgOpenPath = path.join(__dirname, 'xdg-open');
  22. const {platform, arch} = process;
  23. const tryEachApp = async (apps, opener) => {
  24. if (apps.length === 0) {
  25. // No app was provided
  26. return;
  27. }
  28. const errors = [];
  29. for (const app of apps) {
  30. try {
  31. return await opener(app); // eslint-disable-line no-await-in-loop
  32. } catch (error) {
  33. errors.push(error);
  34. }
  35. }
  36. throw new AggregateError(errors, 'Failed to open in all supported apps');
  37. };
  38. // eslint-disable-next-line complexity
  39. const baseOpen = async options => {
  40. options = {
  41. wait: false,
  42. background: false,
  43. newInstance: false,
  44. allowNonzeroExitCode: false,
  45. ...options,
  46. };
  47. const isFallbackAttempt = options[fallbackAttemptSymbol] === true;
  48. delete options[fallbackAttemptSymbol];
  49. if (Array.isArray(options.app)) {
  50. return tryEachApp(options.app, singleApp => baseOpen({
  51. ...options,
  52. app: singleApp,
  53. [fallbackAttemptSymbol]: true,
  54. }));
  55. }
  56. let {name: app, arguments: appArguments = []} = options.app ?? {};
  57. appArguments = [...appArguments];
  58. if (Array.isArray(app)) {
  59. return tryEachApp(app, appName => baseOpen({
  60. ...options,
  61. app: {
  62. name: appName,
  63. arguments: appArguments,
  64. },
  65. [fallbackAttemptSymbol]: true,
  66. }));
  67. }
  68. if (app === 'browser' || app === 'browserPrivate') {
  69. // IDs from default-browser for macOS and windows are the same.
  70. // IDs are lowercased to increase chances of a match.
  71. const ids = {
  72. 'com.google.chrome': 'chrome',
  73. 'google-chrome.desktop': 'chrome',
  74. 'com.brave.browser': 'brave',
  75. 'org.mozilla.firefox': 'firefox',
  76. 'firefox.desktop': 'firefox',
  77. 'com.microsoft.msedge': 'edge',
  78. 'com.microsoft.edge': 'edge',
  79. 'com.microsoft.edgemac': 'edge',
  80. 'microsoft-edge.desktop': 'edge',
  81. 'com.apple.safari': 'safari',
  82. };
  83. // Incognito flags for each browser in `apps`.
  84. const flags = {
  85. chrome: '--incognito',
  86. brave: '--incognito',
  87. firefox: '--private-window',
  88. edge: '--inPrivate',
  89. // Safari doesn't support private mode via command line
  90. };
  91. let browser;
  92. if (isWsl) {
  93. const progId = await wslDefaultBrowser();
  94. const browserInfo = _windowsBrowserProgIdMap.get(progId);
  95. browser = browserInfo ?? {};
  96. } else {
  97. browser = await defaultBrowser();
  98. }
  99. if (browser.id in ids) {
  100. const browserName = ids[browser.id.toLowerCase()];
  101. if (app === 'browserPrivate') {
  102. // Safari doesn't support private mode via command line
  103. if (browserName === 'safari') {
  104. throw new Error('Safari doesn\'t support opening in private mode via command line');
  105. }
  106. appArguments.push(flags[browserName]);
  107. }
  108. return baseOpen({
  109. ...options,
  110. app: {
  111. name: apps[browserName],
  112. arguments: appArguments,
  113. },
  114. });
  115. }
  116. throw new Error(`${browser.name} is not supported as a default browser`);
  117. }
  118. let command;
  119. const cliArguments = [];
  120. const childProcessOptions = {};
  121. // Determine if we should use Windows/PowerShell behavior in WSL.
  122. // We only use Windows integration if PowerShell is actually accessible.
  123. // This allows the package to work in sandboxed WSL environments where Windows access is restricted.
  124. let shouldUseWindowsInWsl = false;
  125. if (isWsl && !isInsideContainer() && !isInSsh && !app) {
  126. shouldUseWindowsInWsl = await canAccessPowerShell();
  127. }
  128. if (platform === 'darwin') {
  129. command = 'open';
  130. if (options.wait) {
  131. cliArguments.push('--wait-apps');
  132. }
  133. if (options.background) {
  134. cliArguments.push('--background');
  135. }
  136. if (options.newInstance) {
  137. cliArguments.push('--new');
  138. }
  139. if (app) {
  140. cliArguments.push('-a', app);
  141. }
  142. } else if (platform === 'win32' || shouldUseWindowsInWsl) {
  143. command = await powerShellPath();
  144. cliArguments.push(...executePowerShell.argumentsPrefix);
  145. if (!isWsl) {
  146. childProcessOptions.windowsVerbatimArguments = true;
  147. }
  148. // Convert WSL Linux paths to Windows paths
  149. if (isWsl && options.target) {
  150. options.target = await convertWslPathToWindows(options.target);
  151. }
  152. // Suppress PowerShell progress messages that are written to stderr
  153. const encodedArguments = ['$ProgressPreference = \'SilentlyContinue\';', 'Start'];
  154. if (options.wait) {
  155. encodedArguments.push('-Wait');
  156. }
  157. if (app) {
  158. encodedArguments.push(executePowerShell.escapeArgument(app));
  159. if (options.target) {
  160. appArguments.push(options.target);
  161. }
  162. } else if (options.target) {
  163. encodedArguments.push(executePowerShell.escapeArgument(options.target));
  164. }
  165. if (appArguments.length > 0) {
  166. appArguments = appArguments.map(argument => executePowerShell.escapeArgument(argument));
  167. encodedArguments.push('-ArgumentList', appArguments.join(','));
  168. }
  169. // Using Base64-encoded command, accepted by PowerShell, to allow special characters.
  170. options.target = executePowerShell.encodeCommand(encodedArguments.join(' '));
  171. if (!options.wait) {
  172. // PowerShell will keep the parent process alive unless stdio is ignored.
  173. childProcessOptions.stdio = 'ignore';
  174. }
  175. if (isWsl) {
  176. // The spawned Windows process inherits the Linux working directory, which WSL exposes to Windows as a `\\wsl.localhost\…` UNC path served by the distro's default user, so a directory that user cannot traverse makes the launch fail. PowerShell's own directory is on the Windows drive, so it always resolves to a plain `C:\…` path. Nothing here uses the working directory as the target is a URL or an absolute Windows path.
  177. childProcessOptions.cwd = path.dirname(command);
  178. }
  179. } else {
  180. if (app) {
  181. command = app;
  182. } else {
  183. // When bundled by Webpack, there's no actual package file path and no local `xdg-open`.
  184. const isBundled = !__dirname || __dirname === '/';
  185. // Check if local `xdg-open` exists and is executable.
  186. let exeLocalXdgOpen = false;
  187. try {
  188. await fs.access(localXdgOpenPath, fsConstants.X_OK);
  189. exeLocalXdgOpen = true;
  190. } catch {}
  191. const useSystemXdgOpen = process.versions.electron
  192. ?? (platform === 'android' || isBundled || !exeLocalXdgOpen);
  193. command = useSystemXdgOpen ? 'xdg-open' : localXdgOpenPath;
  194. }
  195. if (appArguments.length > 0) {
  196. cliArguments.push(...appArguments);
  197. }
  198. if (!options.wait) {
  199. // `xdg-open` will block the process unless stdio is ignored
  200. // and it's detached from the parent even if it's unref'd.
  201. childProcessOptions.stdio = 'ignore';
  202. childProcessOptions.detached = true;
  203. }
  204. }
  205. if (platform === 'darwin' && appArguments.length > 0) {
  206. cliArguments.push('--args', ...appArguments);
  207. }
  208. // IMPORTANT: On macOS, the target MUST come AFTER '--args'.
  209. // When using --args, ALL following arguments are passed to the app.
  210. // Example: open -a "chrome" --args --incognito https://site.com
  211. // This passes BOTH --incognito AND https://site.com to Chrome.
  212. // Without this order, Chrome won't open in incognito. See #332.
  213. if (options.target) {
  214. cliArguments.push(options.target);
  215. }
  216. const subprocess = childProcess.spawn(command, cliArguments, childProcessOptions);
  217. if (options.wait) {
  218. return new Promise((resolve, reject) => {
  219. subprocess.once('error', reject);
  220. subprocess.once('close', exitCode => {
  221. if (!options.allowNonzeroExitCode && exitCode !== 0) {
  222. reject(new Error(`Exited with code ${exitCode}`));
  223. return;
  224. }
  225. resolve(subprocess);
  226. });
  227. });
  228. }
  229. // When we're in a fallback attempt, we need to detect launch failures before trying the next app.
  230. // Wait for the close event to check the exit code before unreffing.
  231. // The launcher (open/xdg-open/PowerShell) exits quickly (~10-30ms) even on success.
  232. if (isFallbackAttempt) {
  233. return new Promise((resolve, reject) => {
  234. subprocess.once('error', reject);
  235. subprocess.once('spawn', () => {
  236. // Keep error handler active for post-spawn errors
  237. subprocess.once('close', exitCode => {
  238. subprocess.off('error', reject);
  239. if (exitCode !== 0) {
  240. reject(new Error(`Exited with code ${exitCode}`));
  241. return;
  242. }
  243. subprocess.unref();
  244. resolve(subprocess);
  245. });
  246. });
  247. });
  248. }
  249. subprocess.unref();
  250. // Handle spawn errors before the caller can attach listeners.
  251. // This prevents unhandled error events from crashing the process.
  252. return new Promise((resolve, reject) => {
  253. subprocess.once('error', reject);
  254. // Wait for the subprocess to spawn before resolving.
  255. // This ensures the process is established before the caller continues,
  256. // preventing issues when process.exit() is called immediately after.
  257. subprocess.once('spawn', () => {
  258. subprocess.off('error', reject);
  259. resolve(subprocess);
  260. });
  261. });
  262. };
  263. const open = (target, options) => {
  264. if (typeof target !== 'string') {
  265. throw new TypeError('Expected a `target`');
  266. }
  267. return baseOpen({
  268. ...options,
  269. target,
  270. });
  271. };
  272. export const openApp = (name, options) => {
  273. if (typeof name !== 'string' && !Array.isArray(name)) {
  274. throw new TypeError('Expected a valid `name`');
  275. }
  276. const {arguments: appArguments = []} = options ?? {};
  277. if (appArguments !== undefined && appArguments !== null && !Array.isArray(appArguments)) {
  278. throw new TypeError('Expected `appArguments` as Array type');
  279. }
  280. return baseOpen({
  281. ...options,
  282. app: {
  283. name,
  284. arguments: appArguments,
  285. },
  286. });
  287. };
  288. function detectArchBinary(binary) {
  289. if (typeof binary === 'string' || Array.isArray(binary)) {
  290. return binary;
  291. }
  292. const {[arch]: archBinary} = binary;
  293. if (!archBinary) {
  294. throw new Error(`${arch} is not supported`);
  295. }
  296. return archBinary;
  297. }
  298. function detectPlatformBinary({[platform]: platformBinary}, {wsl} = {}) {
  299. if (wsl && isWsl) {
  300. return detectArchBinary(wsl);
  301. }
  302. if (!platformBinary) {
  303. throw new Error(`${platform} is not supported`);
  304. }
  305. return detectArchBinary(platformBinary);
  306. }
  307. export const apps = {
  308. browser: 'browser',
  309. browserPrivate: 'browserPrivate',
  310. };
  311. defineLazyProperty(apps, 'chrome', () => detectPlatformBinary({
  312. darwin: 'google chrome',
  313. win32: 'chrome',
  314. // `chromium-browser` is the older deb package name used by Ubuntu/Debian before snap.
  315. linux: ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser'],
  316. }, {
  317. wsl: {
  318. ia32: '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe',
  319. x64: ['/mnt/c/Program Files/Google/Chrome/Application/chrome.exe', '/mnt/c/Program Files (x86)/Google/Chrome/Application/chrome.exe'],
  320. },
  321. }));
  322. defineLazyProperty(apps, 'brave', () => detectPlatformBinary({
  323. darwin: 'brave browser',
  324. win32: 'brave',
  325. linux: ['brave-browser', 'brave'],
  326. }, {
  327. wsl: {
  328. ia32: '/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe',
  329. x64: ['/mnt/c/Program Files/BraveSoftware/Brave-Browser/Application/brave.exe', '/mnt/c/Program Files (x86)/BraveSoftware/Brave-Browser/Application/brave.exe'],
  330. },
  331. }));
  332. defineLazyProperty(apps, 'firefox', () => detectPlatformBinary({
  333. darwin: 'firefox',
  334. win32: String.raw`C:\Program Files\Mozilla Firefox\firefox.exe`,
  335. linux: 'firefox',
  336. }, {
  337. wsl: '/mnt/c/Program Files/Mozilla Firefox/firefox.exe',
  338. }));
  339. defineLazyProperty(apps, 'edge', () => detectPlatformBinary({
  340. darwin: 'microsoft edge',
  341. win32: 'msedge',
  342. linux: ['microsoft-edge', 'microsoft-edge-dev'],
  343. }, {
  344. wsl: '/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
  345. }));
  346. defineLazyProperty(apps, 'safari', () => detectPlatformBinary({
  347. darwin: 'Safari',
  348. }));
  349. export default open;