diff --git a/package-lock.json b/package-lock.json index 3c13811..f70302d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "date-fns": "^4.1.0", "electron-squirrel-startup": "^1.0.1", "fastify": "^5.6.2", + "find-process": "^2.0.0", "happy-dom": "^20.0.10", "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", @@ -10188,7 +10189,6 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", @@ -13074,6 +13074,29 @@ "node": ">=20" } }, + "node_modules/find-process": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/find-process/-/find-process-2.0.0.tgz", + "integrity": "sha512-YUBQnteWGASJoEVVsOXy6XtKAY2O1FCsWnnvQ8y0YwgY1rZiKeVptnFvMu6RSELZAJOGklqseTnUGGs5D0bKmg==", + "license": "MIT", + "dependencies": { + "chalk": "~4.1.2", + "commander": "^12.1.0", + "loglevel": "^1.9.2" + }, + "bin": { + "find-process": "dist/bin/find-process.js" + } + }, + "node_modules/find-process/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -13713,7 +13736,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -16063,6 +16085,19 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loglevel": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz", + "integrity": "sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + }, + "funding": { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/loglevel" + } + }, "node_modules/long": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", @@ -19963,7 +19998,6 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, "license": "MIT", "dependencies": { "has-flag": "^4.0.0" diff --git a/package.json b/package.json index b89b7be..19aef42 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "date-fns": "^4.1.0", "electron-squirrel-startup": "^1.0.1", "fastify": "^5.6.2", + "find-process": "^2.0.0", "happy-dom": "^20.0.10", "i18next": "^25.6.3", "i18next-browser-languagedetector": "^8.2.0", diff --git a/src/ipc/process/handler.ts b/src/ipc/process/handler.ts index 3e1a49a..2104624 100644 --- a/src/ipc/process/handler.ts +++ b/src/ipc/process/handler.ts @@ -1,61 +1,131 @@ import { exec, execSync } from 'child_process'; import { promisify } from 'util'; +import findProcess from 'find-process'; import { getAntigravityExecutablePath, isWsl } from '../../utils/paths'; import { logger } from '../../utils/logger'; const execAsync = promisify(exec); +/** + * Helper process name patterns to exclude (Electron helper processes) + */ +const HELPER_PATTERNS = [ + 'helper', + 'plugin', + 'renderer', + 'gpu', + 'crashpad', + 'utility', + 'audio', + 'sandbox', + 'language_server', +]; + +/** + * Check if a process is a helper/auxiliary process that should be excluded. + * @param name Process name (lowercase) + * @param cmd Process command line (lowercase) + * @returns True if the process is a helper process + */ +function isHelperProcess(name: string, cmd: string): boolean { + const nameLower = name.toLowerCase(); + const cmdLower = cmd.toLowerCase(); + + // Check for --type= argument (Electron helper process indicator) + if (cmdLower.includes('--type=')) { + return true; + } + + // Check for helper patterns in process name + for (const pattern of HELPER_PATTERNS) { + if (nameLower.includes(pattern)) { + return true; + } + } + + // Check for crashpad in path + if (cmdLower.includes('crashpad')) { + return true; + } + + return false; +} + /** * Checks if the Antigravity process is running. + * Uses find-process package for robust cross-platform process detection. * @returns {boolean} True if the Antigravity process is running, false otherwise. */ export async function isProcessRunning(): Promise { try { const platform = process.platform; - let command = ''; + const currentPid = process.pid; - if (platform === 'win32' || isWsl()) { - // Use absolute path for tasklist to be safe on Windows too - const cmd = isWsl() - ? '/mnt/c/Windows/System32/tasklist.exe' - : process.env.SystemRoot // Usually C:\Windows - ? `${process.env.SystemRoot}\\System32\\tasklist.exe` - : 'tasklist'; + // Use find-process to search for Antigravity processes + // 'name' search type matches process name + const processes = await findProcess('name', 'Antigravity', true); - command = `${cmd} /FI "IMAGENAME eq Antigravity.exe" /NH`; - } else { - // macOS and Linux (native) - command = 'pgrep -xi Antigravity'; - } + logger.debug(`Found ${processes.length} processes matching 'Antigravity'`); - logger.debug(`Checking process with command: ${command}`); - // Increased timeout to 4000ms to avoid flakiness with tasklist/wmic - try { - const { stdout } = await execAsync(command, { timeout: 4000 }); - if (platform === 'win32' || isWsl()) { - return stdout.includes('Antigravity.exe'); - } else { - // pgrep returns PIDs if found, empty if not - return stdout.trim().length > 0; + for (const proc of processes) { + // Skip self + if (proc.pid === currentPid) { + continue; } - } catch (cmdError) { - if (platform === 'win32' && !isWsl()) { - logger.warn('tasklist failed, trying wmic fallback...', cmdError); - // Fallback to wmic (filtered) - try { - const wmicCmd = 'wmic process where "name=\'Antigravity.exe\'" get ProcessId'; - const { stdout } = await execAsync(wmicCmd, { timeout: 4000 }); - return stdout.includes('ProcessId'); - } catch (wmicError) { - logger.error('wmic fallback also failed', wmicError); - return false; + + const name = proc.name?.toLowerCase() || ''; + const cmd = proc.cmd?.toLowerCase() || ''; + + // Skip manager process + if ( + name.includes('manager') || + cmd.includes('manager') || + cmd.includes('antigravity-manager') + ) { + continue; + } + + // Skip helper processes + if (isHelperProcess(name, cmd)) { + continue; + } + + if (platform === 'darwin') { + // macOS: Check for Antigravity.app in path + if (cmd.includes('antigravity.app')) { + logger.debug( + `Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`, + ); + return true; + } + // Also check if the process name is exactly 'Antigravity' (main process) + if (name === 'antigravity' && !isHelperProcess(name, cmd)) { + logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`); + return true; + } + } else if (platform === 'win32') { + // Windows: Check for Antigravity.exe + if (name === 'antigravity.exe' || name === 'antigravity') { + logger.debug(`Found Antigravity process: PID=${proc.pid}, name=${name}`); + return true; + } + } else { + // Linux: Check for antigravity in name or path (but not tools) + if ( + (name.includes('antigravity') || cmd.includes('/antigravity')) && + !name.includes('tools') + ) { + logger.debug( + `Found Antigravity process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`, + ); + return true; } } - throw cmdError; } + + return false; } catch (error) { - logger.error('Error checking process status:', error); - // pgrep returns exit code 1 if no process found + logger.error('Error checking process status with find-process:', error); return false; } } diff --git a/src/tests/unit/process.test.ts b/src/tests/unit/process.test.ts index 6cf32b6..1ee5930 100644 --- a/src/tests/unit/process.test.ts +++ b/src/tests/unit/process.test.ts @@ -1,55 +1,182 @@ -import { describe, it, expect, vi } from 'vitest'; -import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler'; -import { exec } from 'child_process'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; -vi.mock('child_process', () => { - return { - exec: vi.fn(), - default: { exec: vi.fn() }, - }; -}); - -// Mock promisify to return a function that returns a promise -vi.mock('util', () => ({ - promisify: (fn: unknown) => fn, - default: { promisify: (fn: unknown) => fn }, +// Mock find-process module +vi.mock('find-process', () => ({ + default: vi.fn(), })); +// Mock logger to avoid console output during tests +vi.mock('../../utils/logger', () => ({ + logger: { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// Mock paths module to avoid child_process issues +vi.mock('../../utils/paths', () => ({ + getAntigravityExecutablePath: vi.fn(() => '/path/to/antigravity'), + isWsl: vi.fn(() => false), +})); + +// Import after mocks are set up +import { isProcessRunning, closeAntigravity, startAntigravity } from '../../ipc/process/handler'; +import findProcess from 'find-process'; + describe('Process Handler', () => { - it('should check if process is running', async () => { - // Mock exec to return stdout - const execMock = exec as unknown as ReturnType; - execMock.mockImplementation((cmd, cb) => { - if (cb) cb(null, { stdout: '12345' }, { stderr: '' }); - return { stdout: '12345' }; + const mockFindProcess = findProcess as unknown as ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('isProcessRunning', () => { + it('should return true when Antigravity main process is found on macOS', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12345, + name: 'Antigravity', + cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(true); + expect(mockFindProcess).toHaveBeenCalledWith('name', 'Antigravity', true); }); - // Wait, promisify wraps exec. - // If I mock exec, promisify(exec) will use the mock. - // But my mock implementation needs to match what promisify expects or I need to mock the promisified function. - // Since I mocked 'util', promisify returns the fn itself. - // So execAsync IS exec. - // But exec signature is (cmd, options, callback) or (cmd, callback). - // My implementation calls execAsync(command). - // So I should mock exec to return a Promise if I mocked promisify to return fn? - // No, if promisify returns fn, then `await execAsync(cmd)` means `await exec(cmd)`. - // `exec` returns a ChildProcess, not a Promise. - // So my mock of `util` is wrong if I want `await execAsync` to work. - // Better to mock the module that exports execAsync if possible, or mock `util` correctly. - // Or just mock `child_process` and use `vi.mocked(exec)`. - // But `promisify` is the key. + it('should return false when only helper processes are found', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); - // Let's rely on vitest mocking capabilities. - // If I don't mock util, promisify will wrap the mocked exec. - // The mocked exec should behave like real exec (taking a callback). + mockFindProcess.mockResolvedValue([ + { + pid: 12346, + name: 'Antigravity Helper (Renderer)', + cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (Renderer).app --type=renderer', + }, + { + pid: 12347, + name: 'Antigravity Helper (GPU)', + cmd: '/Applications/Antigravity.app/Contents/Frameworks/Antigravity Helper (GPU).app --type=gpu-process', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); + + it('should return false when only manager process is found', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12348, + name: 'Antigravity Manager', + cmd: '/Applications/Antigravity Manager.app/Contents/MacOS/Antigravity Manager', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); + + it('should return false when no processes are found', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([]); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); + + it('should skip self process', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 12345, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12345, // Same as current PID + name: 'Antigravity', + cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); + + it('should return true when Antigravity.exe is found on Windows', async () => { + Object.defineProperty(process, 'platform', { value: 'win32', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12345, + name: 'Antigravity.exe', + cmd: 'C:\\Program Files\\Antigravity\\Antigravity.exe', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(true); + }); + + it('should return true when antigravity is found on Linux', async () => { + Object.defineProperty(process, 'platform', { value: 'linux', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12345, + name: 'antigravity', + cmd: '/usr/bin/antigravity', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(true); + }); + + it('should handle find-process errors gracefully', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockRejectedValue(new Error('Process enumeration failed')); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); + + it('should exclude processes with --type= argument', async () => { + Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); + Object.defineProperty(process, 'pid', { value: 1000, configurable: true }); + + mockFindProcess.mockResolvedValue([ + { + pid: 12345, + name: 'Antigravity', + cmd: '/Applications/Antigravity.app/Contents/MacOS/Antigravity --type=utility', + }, + ]); + + const result = await isProcessRunning(); + expect(result).toBe(false); + }); }); - // Skip complex mocking tests for now as they are fragile without proper setup. - // I'll write a basic test structure. - - it('should be defined', () => { - expect(isProcessRunning).toBeDefined(); - expect(closeAntigravity).toBeDefined(); - expect(startAntigravity).toBeDefined(); + describe('Module exports', () => { + it('should export all required functions', () => { + expect(isProcessRunning).toBeDefined(); + expect(closeAntigravity).toBeDefined(); + expect(startAntigravity).toBeDefined(); + }); }); });