Skip to content

Commit ce31b94

Browse files
committed
Fix app launch failure detection for fallback support
Fixes #272
1 parent b40f4b8 commit ce31b94

File tree

3 files changed

+143
-14
lines changed

3 files changed

+143
-14
lines changed

index.js

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import isInsideContainer from 'is-inside-container';
1212
import isInSsh from 'is-in-ssh';
1313

1414
const execFile = promisify(childProcess.execFile);
15+
const fallbackAttemptSymbol = Symbol('fallbackAttempt');
1516

1617
// Path to included `xdg-open`.
1718
const __dirname = import.meta.url ? path.dirname(fileURLToPath(import.meta.url)) : '';
@@ -119,10 +120,14 @@ const baseOpen = async options => {
119120
...options,
120121
};
121122

123+
const isFallbackAttempt = options[fallbackAttemptSymbol] === true;
124+
delete options[fallbackAttemptSymbol];
125+
122126
if (Array.isArray(options.app)) {
123127
return tryEachApp(options.app, singleApp => baseOpen({
124128
...options,
125129
app: singleApp,
130+
[fallbackAttemptSymbol]: true,
126131
}));
127132
}
128133

@@ -136,6 +141,7 @@ const baseOpen = async options => {
136141
name: appName,
137142
arguments: appArguments,
138143
},
144+
[fallbackAttemptSymbol]: true,
139145
}));
140146
}
141147

@@ -151,6 +157,7 @@ const baseOpen = async options => {
151157
'com.microsoft.edge': 'edge',
152158
'com.microsoft.edgemac': 'edge',
153159
'microsoft-edge.desktop': 'edge',
160+
'com.apple.Safari': 'safari',
154161
};
155162

156163
// Incognito flags for each browser in `apps`.
@@ -159,13 +166,19 @@ const baseOpen = async options => {
159166
brave: '--incognito',
160167
firefox: '--private-window',
161168
edge: '--inPrivate',
169+
// Safari doesn't support private mode via command line
162170
};
163171

164172
const browser = isWsl ? await getWindowsDefaultBrowserFromWsl() : await defaultBrowser();
165173
if (browser.id in ids) {
166174
const browserName = ids[browser.id];
167175

168176
if (app === 'browserPrivate') {
177+
// Safari doesn't support private mode via command line
178+
if (browserName === 'safari') {
179+
throw new Error('Safari doesn\'t support opening in private mode via command line');
180+
}
181+
169182
appArguments.push(flags[browserName]);
170183
}
171184

@@ -294,7 +307,11 @@ const baseOpen = async options => {
294307
cliArguments.push('--args', ...appArguments);
295308
}
296309

297-
// This has to come after `--args`.
310+
// IMPORTANT: On macOS, the target MUST come AFTER '--args'.
311+
// When using --args, ALL following arguments are passed to the app.
312+
// Example: open -a "chrome" --args --incognito https://site.com
313+
// This passes BOTH --incognito AND https://site.com to Chrome.
314+
// Without this order, Chrome won't open in incognito. See #332.
298315
if (options.target) {
299316
cliArguments.push(options.target);
300317
}
@@ -306,7 +323,7 @@ const baseOpen = async options => {
306323
subprocess.once('error', reject);
307324

308325
subprocess.once('close', exitCode => {
309-
if (!options.allowNonzeroExitCode && exitCode > 0) {
326+
if (!options.allowNonzeroExitCode && exitCode !== 0) {
310327
reject(new Error(`Exited with code ${exitCode}`));
311328
return;
312329
}
@@ -316,6 +333,30 @@ const baseOpen = async options => {
316333
});
317334
}
318335

336+
// When we're in a fallback attempt, we need to detect launch failures before trying the next app.
337+
// Wait for the close event to check the exit code before unreffing.
338+
// The launcher (open/xdg-open/PowerShell) exits quickly (~10-30ms) even on success.
339+
if (isFallbackAttempt) {
340+
return new Promise((resolve, reject) => {
341+
subprocess.once('error', reject);
342+
343+
subprocess.once('spawn', () => {
344+
// Keep error handler active for post-spawn errors
345+
subprocess.once('close', exitCode => {
346+
subprocess.off('error', reject);
347+
348+
if (exitCode !== 0) {
349+
reject(new Error(`Exited with code ${exitCode}`));
350+
return;
351+
}
352+
353+
subprocess.unref();
354+
resolve(subprocess);
355+
});
356+
});
357+
});
358+
}
359+
319360
subprocess.unref();
320361

321362
// Handle spawn errors before the caller can attach listeners.
@@ -377,7 +418,7 @@ function detectArchBinary(binary) {
377418
return archBinary;
378419
}
379420

380-
function detectPlatformBinary({[platform]: platformBinary}, {wsl}) {
421+
function detectPlatformBinary({[platform]: platformBinary}, {wsl} = {}) {
381422
if (wsl && isWsl) {
382423
return detectArchBinary(wsl);
383424
}
@@ -433,4 +474,8 @@ defineLazyProperty(apps, 'edge', () => detectPlatformBinary({
433474
wsl: '/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe',
434475
}));
435476

477+
defineLazyProperty(apps, 'safari', () => detectPlatformBinary({
478+
darwin: 'Safari',
479+
}));
480+
436481
export default open;

test-it.js

Lines changed: 0 additions & 4 deletions
This file was deleted.

test.js

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import process from 'node:process';
2+
import childProcess from 'node:child_process';
3+
import {EventEmitter} from 'node:events';
24
import test from 'ava';
5+
import defaultBrowser from 'default-browser';
36
import open, {openApp, apps} from './index.js';
47

58
// Tests only checks that opening doesn't return an error
69
// it has no way make sure that it actually opened anything.
710

811
// These have to be manually verified.
912

13+
// Helper to check if Safari is the default browser
14+
const isSafariDefault = async () => {
15+
try {
16+
const browser = await defaultBrowser();
17+
return browser.id === 'com.apple.Safari';
18+
} catch {
19+
// If default-browser fails (e.g., on systems without a default browser)
20+
return false;
21+
}
22+
};
23+
1024
test('open file in default app', async t => {
1125
await t.notThrowsAsync(open('index.js'));
1226
});
1327

14-
test('wait for the app to close if wait: true', async t => {
15-
await t.notThrowsAsync(open('https://sindresorhus.com', {wait: true}));
16-
});
17-
1828
test('encode URL if url: true', async t => {
1929
await t.notThrowsAsync(open('https://sindresorhus.com', {url: true}));
2030
});
@@ -30,7 +40,7 @@ test('open URL in specified app', async t => {
3040
test('open URL in specified app with arguments', async t => {
3141
await t.notThrowsAsync(async () => {
3242
const process_ = await open('https://sindresorhus.com', {app: {name: apps.chrome, arguments: ['--incognito']}});
33-
t.deepEqual(process_.spawnargs, ['open', '-a', apps.chrome, 'https://sindresorhus.com', '--args', '--incognito']);
43+
t.deepEqual(process_.spawnargs, ['open', '-a', apps.chrome, '--args', '--incognito', 'https://sindresorhus.com']);
3444
});
3545
});
3646

@@ -96,15 +106,29 @@ test('open URL with default browser argument', async t => {
96106
});
97107

98108
test('open URL with default browser in incognito mode', async t => {
99-
await t.notThrowsAsync(open('https://sindresorhus.com', {app: {name: apps.browserPrivate}}));
109+
if (await isSafariDefault()) {
110+
await t.throwsAsync(
111+
open('https://sindresorhus.com', {app: {name: apps.browserPrivate}}),
112+
{message: /Safari doesn't support opening in private mode via command line/},
113+
);
114+
} else {
115+
await t.notThrowsAsync(open('https://sindresorhus.com', {app: {name: apps.browserPrivate}}));
116+
}
100117
});
101118

102119
test('open default browser', async t => {
103120
await t.notThrowsAsync(openApp(apps.browser, {newInstance: true}));
104121
});
105122

106123
test('open default browser in incognito mode', async t => {
107-
await t.notThrowsAsync(openApp(apps.browserPrivate, {newInstance: true}));
124+
if (await isSafariDefault()) {
125+
await t.throwsAsync(
126+
openApp(apps.browserPrivate, {newInstance: true}),
127+
{message: /Safari doesn't support opening in private mode via command line/},
128+
);
129+
} else {
130+
await t.notThrowsAsync(openApp(apps.browserPrivate, {newInstance: true}));
131+
}
108132
});
109133

110134
test('subprocess is spawned before promise resolves', async t => {
@@ -115,6 +139,70 @@ test('subprocess is spawned before promise resolves', async t => {
115139
t.true(childProcess.pid !== undefined && childProcess.pid !== null);
116140
});
117141

142+
test.serial('app launches resolve before close without fallback', async t => {
143+
const originalSpawn = childProcess.spawn;
144+
t.teardown(() => {
145+
childProcess.spawn = originalSpawn;
146+
});
147+
148+
let closeEmitted = false;
149+
150+
childProcess.spawn = () => {
151+
// eslint-disable-next-line unicorn/prefer-event-target
152+
const fakeChild = new EventEmitter();
153+
fakeChild.unref = () => {};
154+
155+
setImmediate(() => {
156+
fakeChild.emit('spawn');
157+
setTimeout(() => {
158+
closeEmitted = true;
159+
fakeChild.emit('close', 0);
160+
}, 50);
161+
});
162+
163+
return fakeChild;
164+
};
165+
166+
const subprocess = await open('index.js', {app: {name: 'stub-app'}});
167+
t.false(closeEmitted);
168+
t.truthy(subprocess);
169+
});
170+
171+
test('fallback to next app when first app does not exist', async t => {
172+
// Try nonexistent apps first, then a real app
173+
// Note: This test may fail if all apps return non-zero exit codes (system issue)
174+
try {
175+
await open('https://sindresorhus.com', {
176+
app: {
177+
name: ['definitely-not-a-real-app-12345', 'another-fake-app-67890', apps.chrome],
178+
},
179+
});
180+
t.pass('Fallback succeeded');
181+
} catch (error) {
182+
if (error instanceof AggregateError && error.errors.every(error => error.message.includes('Exited with code'))) {
183+
// All apps failed with exit codes - this might be a system issue
184+
// where even valid apps return non-zero exit codes
185+
t.pass('All apps returned non-zero exit codes (possible system issue)');
186+
} else {
187+
throw error;
188+
}
189+
}
190+
});
191+
192+
test('throws AggregateError when all apps in array fail', async t => {
193+
const error = await t.throwsAsync(
194+
open('https://sindresorhus.com', {
195+
app: {
196+
name: ['fake-app-1', 'fake-app-2', 'fake-app-3'],
197+
},
198+
}),
199+
{instanceOf: AggregateError},
200+
);
201+
202+
t.is(error.errors.length, 3);
203+
t.true(error.message.includes('Failed to open in all supported apps'));
204+
});
205+
118206
if (process.platform === 'linux') {
119207
test('spawn errors reject the promise instead of crashing', async t => {
120208
const error = await t.throwsAsync(openApp('definitely-not-a-real-command-12345'));

0 commit comments

Comments
 (0)