Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion src/electron-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface ElectronAppEntry {
port: number;
/** macOS process name for detection via pgrep */
processName: string;
/** Candidate executable names inside Contents/MacOS/, tried in order */
executableNames?: string[];
/** macOS bundle ID for path discovery */
bundleId?: string;
/** Human-readable name for prompts */
Expand All @@ -30,7 +32,13 @@ export const builtinApps: Record<string, ElectronAppEntry> = {
notion: { port: 9230, processName: 'Notion', bundleId: 'notion.id', displayName: 'Notion' },
'discord-app': { port: 9232, processName: 'Discord', bundleId: 'com.discord.app', displayName: 'Discord' },
'doubao-app': { port: 9225, processName: 'Doubao', bundleId: 'com.volcengine.doubao', displayName: 'Doubao' },
antigravity: { port: 9234, processName: 'Antigravity', bundleId: 'dev.antigravity.app', displayName: 'Antigravity' },
antigravity: {
port: 9234,
processName: 'Antigravity',
executableNames: ['Electron', 'Antigravity'],
bundleId: 'dev.antigravity.app',
displayName: 'Antigravity',
},
chatgpt: { port: 9236, processName: 'ChatGPT', bundleId: 'com.openai.chat', displayName: 'ChatGPT' },
};

Expand Down
126 changes: 120 additions & 6 deletions src/launcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { probeCDP, detectProcess, discoverAppPath } from './launcher.js';
import type { ElectronAppEntry } from './electron-apps.js';
import { detectProcess, discoverAppPath, launchDetachedApp, launchElectronApp, probeCDP, resolveExecutableCandidates } from './launcher.js';

interface MockChildProcess {
once: ReturnType<typeof vi.fn>;
off: ReturnType<typeof vi.fn>;
unref: ReturnType<typeof vi.fn>;
emit: (event: string, value?: unknown) => void;
}

function createMockChildProcess(): MockChildProcess {
const listeners = new Map<string, Array<(value?: unknown) => void>>();

return {
once: vi.fn((event: string, handler: (value?: unknown) => void) => {
listeners.set(event, [...(listeners.get(event) ?? []), handler]);
}),
off: vi.fn((event: string, handler: (value?: unknown) => void) => {
listeners.set(event, (listeners.get(event) ?? []).filter((listener) => listener !== handler));
}),
unref: vi.fn(),
emit: (event: string, value?: unknown) => {
for (const listener of listeners.get(event) ?? []) listener(value);
},
};
}

vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
spawn: vi.fn(() => ({
unref: vi.fn(),
pid: 12345,
on: vi.fn(),
})),
spawn: vi.fn(),
}));

const cp = vi.mocked(await import('node:child_process'));
Expand Down Expand Up @@ -65,3 +86,96 @@ describe('discoverAppPath', () => {
expect(result).toBeNull();
});
});

describe('launchDetachedApp', () => {
beforeEach(() => {
vi.restoreAllMocks();
cp.spawn.mockReset();
});

it('unrefs the process after spawn succeeds', async () => {
const child = createMockChildProcess();
cp.spawn.mockImplementation(() => {
queueMicrotask(() => child.emit('spawn'));
return child as unknown as ReturnType<typeof cp.spawn>;
});

await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity'))
.resolves
.toBeUndefined();
expect(child.unref).toHaveBeenCalledTimes(1);
});

it('converts ENOENT into a controlled launch error', async () => {
const child = createMockChildProcess();
cp.spawn.mockImplementation(() => {
queueMicrotask(() => child.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' })));
return child as unknown as ReturnType<typeof cp.spawn>;
});

await expect(launchDetachedApp('/Applications/Antigravity.app/Contents/MacOS/Antigravity', ['--remote-debugging-port=9234'], 'Antigravity'))
.rejects
.toThrow('Could not launch Antigravity');
expect(child.unref).not.toHaveBeenCalled();
});
});

describe('resolveExecutableCandidates', () => {
it('prefers explicit executable candidates over processName', () => {
const app: ElectronAppEntry = {
port: 9234,
processName: 'Antigravity',
executableNames: ['Electron', 'Antigravity'],
};

expect(resolveExecutableCandidates('/Applications/Antigravity.app', app)).toEqual([
'/Applications/Antigravity.app/Contents/MacOS/Electron',
'/Applications/Antigravity.app/Contents/MacOS/Antigravity',
]);
});
});

describe('launchElectronApp', () => {
beforeEach(() => {
vi.restoreAllMocks();
cp.spawn.mockReset();
});

it('falls back to the next executable candidate when the first is missing', async () => {
const firstChild = createMockChildProcess();
const secondChild = createMockChildProcess();
const app: ElectronAppEntry = {
port: 9234,
processName: 'Antigravity',
executableNames: ['Electron', 'Antigravity'],
};

cp.spawn
.mockImplementationOnce(() => {
queueMicrotask(() => firstChild.emit('error', Object.assign(new Error('missing binary'), { code: 'ENOENT' })));
return firstChild as unknown as ReturnType<typeof cp.spawn>;
})
.mockImplementationOnce(() => {
queueMicrotask(() => secondChild.emit('spawn'));
return secondChild as unknown as ReturnType<typeof cp.spawn>;
});

await expect(
launchElectronApp('/Applications/Antigravity.app', app, ['--remote-debugging-port=9234'], 'Antigravity'),
).resolves.toBeUndefined();

expect(cp.spawn).toHaveBeenNthCalledWith(
1,
'/Applications/Antigravity.app/Contents/MacOS/Electron',
['--remote-debugging-port=9234'],
{ detached: true, stdio: 'ignore' },
);
expect(cp.spawn).toHaveBeenNthCalledWith(
2,
'/Applications/Antigravity.app/Contents/MacOS/Antigravity',
['--remote-debugging-port=9234'],
{ detached: true, stdio: 'ignore' },
);
expect(secondChild.unref).toHaveBeenCalledTimes(1);
});
});
82 changes: 74 additions & 8 deletions src/launcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

import { execFileSync, spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import * as path from 'node:path';
import type { ElectronAppEntry } from './electron-apps.js';
import { getElectronApp } from './electron-apps.js';
import { confirmPrompt } from './tui.js';
Expand Down Expand Up @@ -101,6 +102,78 @@ function resolveExecutable(appPath: string, processName: string): string {
return `${appPath}/Contents/MacOS/${processName}`;
}

function isMissingExecutableError(err: unknown, label: string): boolean {
return err instanceof CommandExecutionError
&& err.message.startsWith(`Could not launch ${label}: executable not found at `);
}

export function resolveExecutableCandidates(appPath: string, app: ElectronAppEntry): string[] {
const executableNames = app.executableNames?.length ? app.executableNames : [app.processName];
return [...new Set(executableNames)].map((name) => resolveExecutable(appPath, name));
}

export async function launchDetachedApp(executable: string, args: string[], label: string): Promise<void> {
await new Promise<void>((resolve, reject) => {
const child = spawn(executable, args, {
detached: true,
stdio: 'ignore',
});

const onError = (err: NodeJS.ErrnoException): void => {
if (err.code === 'ENOENT') {
reject(new CommandExecutionError(
`Could not launch ${label}: executable not found at ${executable}`,
`Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`,
));
return;
}

reject(new CommandExecutionError(
`Failed to launch ${label}`,
err.message,
));
};

child.once('error', onError);
child.once('spawn', () => {
child.off('error', onError);
child.unref();
resolve();
});
});
}

export async function launchElectronApp(appPath: string, app: ElectronAppEntry, args: string[], label: string): Promise<void> {
const executables = resolveExecutableCandidates(appPath, app);
let lastMissingExecutableError: CommandExecutionError | undefined;

for (const executable of executables) {
log.debug(`[launcher] Launching: ${executable} ${args.join(' ')}`);
try {
await launchDetachedApp(executable, args, label);
return;
} catch (err) {
if (isMissingExecutableError(err, label)) {
lastMissingExecutableError = err as CommandExecutionError;
continue;
}
throw err;
}
}

if (executables.length > 1) {
throw new CommandExecutionError(
`Could not launch ${label}: no compatible executable found in ${path.join(appPath, 'Contents', 'MacOS')}`,
`Tried: ${executables.map((executable) => path.basename(executable)).join(', ')}. Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`,
);
}

throw lastMissingExecutableError ?? new CommandExecutionError(
`Could not launch ${label}`,
`Install ${label}, reinstall it, or register a custom app path in ~/.opencli/apps.yaml`,
);
}

async function pollForReady(port: number): Promise<void> {
const deadline = Date.now() + POLL_TIMEOUT_MS;
while (Date.now() < deadline) {
Expand Down Expand Up @@ -166,15 +239,8 @@ export async function resolveElectronEndpoint(site: string): Promise<string> {
}

// Step 4: Launch
const executable = resolveExecutable(appPath, processName);
const args = [`--remote-debugging-port=${port}`, ...(app.extraArgs ?? [])];
log.debug(`[launcher] Launching: ${executable} ${args.join(' ')}`);

const child = spawn(executable, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
await launchElectronApp(appPath, app, args, label);

// Step 5: Poll for readiness
process.stderr.write(` Waiting for ${label} on port ${port}...\n`);
Expand Down
Loading