diff --git a/AGENTS.md b/AGENTS.md index e98ec5386..a2743f2ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ The MCP server (TypeScriptServer~) exists as a separate component but is not the The C# namespace is `io.github.hatayama.uLoopMCP` for historical reasons, but this is a CLI-based tool, not an MCP tool. -Comments in the code, commit messages, and PR titles and bodies should be written in English. +Comments in the code, commit messages, PR titles, and PR descriptions must all be written in English. ## Skill Description Guidelines diff --git a/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts b/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts index 4a10144ac..fc26464d3 100644 --- a/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts +++ b/Packages/src/Cli~/src/__tests__/cli-project-error.test.ts @@ -1,5 +1,5 @@ import { getProjectResolutionErrorLines } from '../cli-project-error.js'; -import { UnityNotRunningError } from '../port-resolver.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js'; import { ProjectMismatchError } from '../project-validator.js'; describe('getProjectResolutionErrorLines', () => { @@ -30,4 +30,16 @@ describe('getProjectResolutionErrorLines', () => { 'Start the Unity Editor for this project, or use --project-path to specify the target.', ]); }); + + it('returns server-not-running guidance for UnityServerNotRunningError', () => { + const lines = getProjectResolutionErrorLines(new UnityServerNotRunningError('/project/root')); + + expect(lines).toEqual([ + 'Error: Unity Editor is running, but Unity CLI Loop server is not.', + '', + ' Project: /project/root', + '', + 'Start the server from: Window > Unity CLI Loop > Server', + ]); + }); }); diff --git a/Packages/src/Cli~/src/__tests__/execute-tool.test.ts b/Packages/src/Cli~/src/__tests__/execute-tool.test.ts index 11e192f8c..4afb5f00d 100644 --- a/Packages/src/Cli~/src/__tests__/execute-tool.test.ts +++ b/Packages/src/Cli~/src/__tests__/execute-tool.test.ts @@ -1,5 +1,8 @@ -import { isTransportDisconnectError } from '../execute-tool.js'; -import { UnityNotRunningError } from '../port-resolver.js'; +import { + diagnoseRetryableProjectConnectionError, + isTransportDisconnectError, +} from '../execute-tool.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from '../port-resolver.js'; import { ProjectMismatchError } from '../project-validator.js'; describe('isTransportDisconnectError', () => { @@ -35,7 +38,69 @@ describe('isTransportDisconnectError', () => { expect(isTransportDisconnectError(new UnityNotRunningError('/project'))).toBe(false); }); + it('returns false for UnityServerNotRunningError', () => { + expect(isTransportDisconnectError(new UnityServerNotRunningError('/project'))).toBe(false); + }); + it('returns false for ProjectMismatchError', () => { expect(isTransportDisconnectError(new ProjectMismatchError('/a', '/b'))).toBe(false); }); }); + +describe('diagnoseRetryableProjectConnectionError', () => { + it('returns UnityNotRunningError when connection fails and Unity is not running', async () => { + const error = await diagnoseRetryableProjectConnectionError( + new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'), + '/project', + true, + { + findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue(null), + }, + ); + + expect(error).toBeInstanceOf(UnityNotRunningError); + }); + + it('returns UnityServerNotRunningError when Unity is running but server is unavailable', async () => { + const error = await diagnoseRetryableProjectConnectionError( + new Error('UNITY_NO_RESPONSE'), + '/project', + true, + { + findRunningUnityProcessForProjectFn: jest.fn().mockResolvedValue({ pid: 1234 }), + }, + ); + + expect(error).toBeInstanceOf(UnityServerNotRunningError); + }); + + it('preserves non-retryable errors', async () => { + const originalError = new ProjectMismatchError('/expected', '/actual'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, { + findRunningUnityProcessForProjectFn: jest.fn(), + }); + + expect(error).toBe(originalError); + }); + + it('preserves retryable errors when project diagnosis is disabled', async () => { + const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', false, { + findRunningUnityProcessForProjectFn: jest.fn(), + }); + + expect(error).toBe(originalError); + }); + + it('preserves the original error when OS-level process inspection fails', async () => { + const originalError = new Error('Connection error: connect ECONNREFUSED 127.0.0.1:8711'); + + const error = await diagnoseRetryableProjectConnectionError(originalError, '/project', true, { + findRunningUnityProcessForProjectFn: jest.fn().mockRejectedValue(new Error('ps failed')), + }); + + expect(error).toBe(originalError); + }); +}); diff --git a/Packages/src/Cli~/src/__tests__/port-resolver.test.ts b/Packages/src/Cli~/src/__tests__/port-resolver.test.ts index 3c4331108..b78a3799a 100644 --- a/Packages/src/Cli~/src/__tests__/port-resolver.test.ts +++ b/Packages/src/Cli~/src/__tests__/port-resolver.test.ts @@ -5,7 +5,6 @@ import { resolvePortFromUnitySettings, validateProjectPath, resolveUnityPort, - UnityNotRunningError, } from '../port-resolver.js'; describe('resolvePortFromUnitySettings', () => { @@ -93,15 +92,14 @@ describe('resolveUnityPort with project settings', () => { rmSync(tempProjectRoot, { recursive: true }); }); - it('throws UnityNotRunningError when isServerRunning is false', async () => { + it('returns port when isServerRunning is false', async () => { writeFileSync( join(tempProjectRoot, 'UserSettings/UnityMcpSettings.json'), JSON.stringify({ isServerRunning: false, customPort: 8700 }), ); - await expect(resolveUnityPort(undefined, tempProjectRoot)).rejects.toThrow( - UnityNotRunningError, - ); + const port = await resolveUnityPort(undefined, tempProjectRoot); + expect(port).toBe(8700); }); it('returns port when isServerRunning is true', async () => { diff --git a/Packages/src/Cli~/src/__tests__/unity-process.test.ts b/Packages/src/Cli~/src/__tests__/unity-process.test.ts new file mode 100644 index 000000000..976ba5ecb --- /dev/null +++ b/Packages/src/Cli~/src/__tests__/unity-process.test.ts @@ -0,0 +1,289 @@ +import { + buildUnityProcessCommand, + extractUnityProjectPath, + findRunningUnityProcessForProject, + isUnityEditorProcess, + isUnityProcessForProject, + normalizeUnityProjectPath, + parseUnityProcesses, + tokenizeCommandLine, +} from '../unity-process.js'; + +describe('buildUnityProcessCommand', () => { + it('builds ps command for macOS', () => { + expect(buildUnityProcessCommand('darwin')).toEqual({ + command: 'ps', + args: ['-Ao', 'pid=,command='], + }); + }); + + it('builds powershell command for Windows', () => { + expect(buildUnityProcessCommand('win32')).toEqual({ + command: 'powershell.exe', + args: [ + '-NoProfile', + '-NonInteractive', + '-Command', + 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress', + ], + }); + }); +}); + +describe('tokenizeCommandLine', () => { + it('keeps quoted project path as one token', () => { + expect( + tokenizeCommandLine( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/My Project"', + ), + ).toEqual([ + '/Applications/Unity.app/Contents/MacOS/Unity', + '-projectPath', + '/Users/me/My Project', + ]); + }); +}); + +describe('extractUnityProjectPath', () => { + it('extracts macOS project path', () => { + expect( + extractUnityProjectPath( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project', + ), + ).toBe('/Users/me/project'); + }); + + it('extracts Windows project path case-insensitively', () => { + expect( + extractUnityProjectPath( + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectpath "C:\\Work\\My Project"', + ), + ).toBe('C:\\Work\\My Project'); + }); +}); + +describe('normalizeUnityProjectPath', () => { + it('normalizes Windows paths case-insensitively', () => { + expect(normalizeUnityProjectPath('C:\\Work\\My Project\\', 'win32')).toBe('c:/work/my project'); + }); +}); + +describe('parseUnityProcesses', () => { + it('parses ps output', () => { + expect( + parseUnityProcesses( + 'darwin', + '123 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project\n', + ), + ).toEqual([ + { + pid: 123, + commandLine: '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/project', + }, + ]); + }); + + it('parses Windows powershell JSON array output', () => { + expect( + parseUnityProcesses( + 'win32', + '[{"ProcessId":101,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\Unity.exe -projectPath \\"C:\\\\Work\\\\Project A\\""}]', + ), + ).toEqual([ + { + pid: 101, + commandLine: + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"', + }, + ]); + }); + + it('returns empty array when Windows output is empty', () => { + expect(parseUnityProcesses('win32', '')).toEqual([]); + }); +}); + +describe('isUnityProcessForProject', () => { + it('matches project path on macOS', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"', + '/Users/me/Project A', + 'darwin', + ), + ).toBe(true); + }); + + it('matches a macOS project path even when ps output has flattened quotes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + + it('matches a macOS project path when ps output keeps trailing slashes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project/', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + + it('matches a macOS project path when ps output keeps repeated trailing slashes', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(true); + }); + + it('does not match a different macOS project that only shares the prefix', () => { + expect( + isUnityProcessForProject( + '/Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project Backup', + '/Users/me/My Project', + 'darwin', + ), + ).toBe(false); + }); + + it('matches project path on Windows case-insensitively', () => { + expect( + isUnityProcessForProject( + 'C:\\Program Files\\Unity\\Editor\\Unity.exe -projectPath "C:\\Work\\Project A"', + 'c:/work/project a', + 'win32', + ), + ).toBe(true); + }); +}); + +describe('isUnityEditorProcess', () => { + it('detects the Unity editor on macOS', () => { + expect( + isUnityEditorProcess( + '/Applications/Unity/Hub/Editor/2022.3.62f3/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/Project A"', + 'darwin', + ), + ).toBe(true); + }); + + it('rejects a non-Unity process on macOS even when projectPath is present', () => { + expect( + isUnityEditorProcess( + '/usr/local/bin/custom-tool -projectPath "/Users/me/Project A"', + 'darwin', + ), + ).toBe(false); + }); + + it('detects the Unity editor on Windows without relying on command line casing', () => { + expect( + isUnityEditorProcess( + 'C:\\Program Files\\Unity\\Editor\\UNITY.EXE -projectPath "C:\\Work\\Project A"', + 'win32', + ), + ).toBe(true); + }); +}); + +describe('findRunningUnityProcessForProject', () => { + it('returns null when no Unity process is running', async () => { + const runCommand = jest.fn, [string, string[]]>().mockResolvedValue(''); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toBeNull(); + }); + + it('returns matching Unity process on macOS', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + [ + '111 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/other', + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"', + ].join('\n'), + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + + it('returns a matching macOS Unity process when ps output has flattened quotes', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project', + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/My Project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + + it('returns a matching macOS Unity process when ps output keeps trailing slashes', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath /Users/me/My Project///', + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/My Project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + + it('ignores non-Unity processes that happen to share the same projectPath', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + [ + '111 /usr/local/bin/custom-tool -projectPath "/Users/me/project"', + '222 /Applications/Unity.app/Contents/MacOS/Unity -projectPath "/Users/me/project"', + ].join('\n'), + ); + + await expect( + findRunningUnityProcessForProject('/Users/me/project', { + platform: 'darwin', + runCommand, + }), + ).resolves.toEqual({ pid: 222 }); + }); + + it('returns matching Unity process on Windows', async () => { + const runCommand = jest + .fn, [string, string[]]>() + .mockResolvedValue( + '[{"ProcessId":333,"CommandLine":"C:\\\\Program Files\\\\Unity\\\\Editor\\\\UNITY.EXE -projectPath \\"C:\\\\Work\\\\My Project\\""}]', + ); + + await expect( + findRunningUnityProcessForProject('c:/work/my project', { + platform: 'win32', + runCommand, + }), + ).resolves.toEqual({ pid: 333 }); + }); +}); diff --git a/Packages/src/Cli~/src/cli-project-error.ts b/Packages/src/Cli~/src/cli-project-error.ts index 5f052396f..393929d14 100644 --- a/Packages/src/Cli~/src/cli-project-error.ts +++ b/Packages/src/Cli~/src/cli-project-error.ts @@ -1,9 +1,19 @@ -import { UnityNotRunningError } from './port-resolver.js'; +import { UnityNotRunningError, UnityServerNotRunningError } from './port-resolver.js'; import { ProjectMismatchError } from './project-validator.js'; export function getProjectResolutionErrorLines( - error: UnityNotRunningError | ProjectMismatchError, + error: UnityNotRunningError | UnityServerNotRunningError | ProjectMismatchError, ): string[] { + if (error instanceof UnityServerNotRunningError) { + return [ + 'Error: Unity Editor is running, but Unity CLI Loop server is not.', + '', + ` Project: ${error.projectRoot}`, + '', + 'Start the server from: Window > Unity CLI Loop > Server', + ]; + } + if (error instanceof UnityNotRunningError) { return [ 'Error: Unity Editor for this project is not running.', diff --git a/Packages/src/Cli~/src/cli.ts b/Packages/src/Cli~/src/cli.ts index bf3acb678..fc1558804 100644 --- a/Packages/src/Cli~/src/cli.ts +++ b/Packages/src/Cli~/src/cli.ts @@ -36,7 +36,11 @@ import { registerLaunchCommand } from './commands/launch.js'; import { registerFocusWindowCommand } from './commands/focus-window.js'; import { VERSION } from './version.js'; import { findUnityProjectRoot } from './project-root.js'; -import { validateProjectPath, UnityNotRunningError } from './port-resolver.js'; +import { + validateProjectPath, + UnityNotRunningError, + UnityServerNotRunningError, +} from './port-resolver.js'; import { ProjectMismatchError } from './project-validator.js'; import { filterEnabledTools, isToolEnabled } from './tool-settings-loader.js'; import { getProjectResolutionErrorLines } from './cli-project-error.js'; @@ -430,7 +434,7 @@ async function runWithErrorHandling(fn: () => Promise): Promise { try { await fn(); } catch (error) { - if (error instanceof UnityNotRunningError) { + if (error instanceof UnityNotRunningError || error instanceof UnityServerNotRunningError) { for (const line of getProjectResolutionErrorLines(error)) { console.error(line.startsWith('Error: ') ? `\x1b[31m${line}\x1b[0m` : line); } diff --git a/Packages/src/Cli~/src/execute-tool.ts b/Packages/src/Cli~/src/execute-tool.ts index c441afbf1..462690177 100644 --- a/Packages/src/Cli~/src/execute-tool.ts +++ b/Packages/src/Cli~/src/execute-tool.ts @@ -13,12 +13,18 @@ import { existsSync } from 'fs'; import { join } from 'path'; import * as semver from 'semver'; import { DirectUnityClient } from './direct-unity-client.js'; -import { resolveUnityPort, validateProjectPath } from './port-resolver.js'; +import { + resolveUnityPort, + UnityNotRunningError, + UnityServerNotRunningError, + validateProjectPath, +} from './port-resolver.js'; import { validateConnectedProject } from './project-validator.js'; import { saveToolsCache, getCacheFilePath, ToolsCache, ToolDefinition } from './tool-cache.js'; import { VERSION } from './version.js'; import { createSpinner } from './spinner.js'; import { findUnityProjectRoot } from './project-root.js'; +import { findRunningUnityProcessForProject } from './unity-process.js'; import { type CompileExecutionOptions, ensureCompileRequestId, @@ -77,6 +83,14 @@ const MAX_RETRIES = 3; const COMPILE_WAIT_TIMEOUT_MS = 90000; const COMPILE_WAIT_POLL_INTERVAL_MS = 100; +interface ConnectionFailureDiagnosisDependencies { + findRunningUnityProcessForProjectFn: typeof findRunningUnityProcessForProject; +} + +const defaultConnectionFailureDiagnosisDependencies: ConnectionFailureDiagnosisDependencies = { + findRunningUnityProcessForProjectFn: findRunningUnityProcessForProject, +}; + function getCompileExecutionOptions( toolName: string, params: Record, @@ -103,6 +117,54 @@ function isRetryableError(error: unknown): boolean { ); } +export async function diagnoseRetryableProjectConnectionError( + error: unknown, + projectRoot: string | null, + shouldDiagnoseProjectState: boolean, + dependencies: ConnectionFailureDiagnosisDependencies = defaultConnectionFailureDiagnosisDependencies, +): Promise { + if (!shouldDiagnoseProjectState || projectRoot === null || !isRetryableError(error)) { + return error; + } + + const runningProcess = await dependencies + .findRunningUnityProcessForProjectFn(projectRoot) + .catch(() => undefined); + + if (runningProcess === undefined) { + return error; + } + + if (runningProcess === null) { + return new UnityNotRunningError(projectRoot); + } + + return new UnityServerNotRunningError(projectRoot); +} + +async function throwFinalToolError( + error: unknown, + projectRoot: string | null, + shouldDiagnoseProjectState: boolean, +): Promise { + const diagnosedError = await diagnoseRetryableProjectConnectionError( + error, + projectRoot, + shouldDiagnoseProjectState, + ); + + if (diagnosedError instanceof Error) { + throw diagnosedError; + } + + if (typeof diagnosedError === 'string') { + throw new Error(diagnosedError); + } + + const serializedError = JSON.stringify(diagnosedError); + throw new Error(serializedError ?? 'Unknown error'); +} + // Distinct from isRetryableError(): that function covers pre-connection failures // (ECONNREFUSED, EADDRNOTAVAIL) which cannot occur after dispatch. // This function covers post-dispatch TCP failures where Unity may have received @@ -317,8 +379,8 @@ export async function executeToolCommand( if (immediateResult === undefined && !requestDispatched) { spinner.stop(); restoreStdin(); - if (lastError instanceof Error) { - throw lastError; + if (lastError !== undefined) { + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } throw new Error( 'Compile request never reached Unity. Check that Unity is running and retry.', @@ -380,15 +442,7 @@ export async function executeToolCommand( if (lastError === undefined) { throw new Error('Tool execution failed without error details.'); } - if (lastError instanceof Error) { - throw lastError; - } - if (typeof lastError === 'string') { - throw new Error(lastError); - } - - const serializedError = JSON.stringify(lastError); - throw new Error(serializedError ?? 'Unknown error'); + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } export async function listAvailableTools(globalOptions: GlobalOptions): Promise { @@ -455,7 +509,7 @@ export async function listAvailableTools(globalOptions: GlobalOptions): Promise< spinner.stop(); restoreStdin(); - throw lastError; + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } interface UnityToolInfo { @@ -573,5 +627,5 @@ export async function syncTools(globalOptions: GlobalOptions): Promise { spinner.stop(); restoreStdin(); - throw lastError; + await throwFinalToolError(lastError, projectRoot, shouldValidateProject); } diff --git a/Packages/src/Cli~/src/port-resolver.ts b/Packages/src/Cli~/src/port-resolver.ts index 406ac30c3..28d8af5a8 100644 --- a/Packages/src/Cli~/src/port-resolver.ts +++ b/Packages/src/Cli~/src/port-resolver.ts @@ -23,6 +23,12 @@ export class UnityNotRunningError extends Error { } } +export class UnityServerNotRunningError extends Error { + constructor(public readonly projectRoot: string) { + super('UNITY_SERVER_NOT_RUNNING'); + } +} + interface UnityMcpSettings { isServerRunning?: boolean; customPort?: number; @@ -130,12 +136,6 @@ async function readPortFromSettingsOrThrow(projectRoot: string): Promise } const settings = parsed as UnityMcpSettings; - // Only block when isServerRunning is explicitly false (Unity clean shutdown). - // undefined/missing means old settings format — proceed to next validation stage. - if (settings.isServerRunning === false) { - throw new UnityNotRunningError(projectRoot); - } - const port = resolvePortFromUnitySettings(settings); if (port !== null) { return port; diff --git a/Packages/src/Cli~/src/unity-process.ts b/Packages/src/Cli~/src/unity-process.ts new file mode 100644 index 000000000..ae8de6e7e --- /dev/null +++ b/Packages/src/Cli~/src/unity-process.ts @@ -0,0 +1,337 @@ +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const WINDOWS_PROCESS_QUERY = + 'Get-CimInstance Win32_Process -Filter "name = \'Unity.exe\'" | Select-Object ProcessId, CommandLine | ConvertTo-Json -Compress'; + +interface RunningUnityProcess { + pid: number; +} + +interface RawUnityProcess { + pid: number; + commandLine: string; +} + +interface UnityProcessCommand { + command: string; + args: string[]; +} + +interface UnityProcessDependencies { + platform: NodeJS.Platform; + runCommand: (command: string, args: string[]) => Promise; +} + +const defaultDependencies: UnityProcessDependencies = { + platform: process.platform, + runCommand: runUnityProcessQuery, +}; + +export function buildUnityProcessCommand(platform: NodeJS.Platform): UnityProcessCommand | null { + if (platform === 'darwin') { + return { + command: 'ps', + args: ['-Ao', 'pid=,command='], + }; + } + + if (platform === 'linux') { + return { + command: 'ps', + args: ['-eo', 'pid=,args='], + }; + } + + if (platform === 'win32') { + return { + command: 'powershell.exe', + args: ['-NoProfile', '-NonInteractive', '-Command', WINDOWS_PROCESS_QUERY], + }; + } + + return null; +} + +export function parseUnityProcesses(platform: NodeJS.Platform, output: string): RawUnityProcess[] { + if (platform === 'win32') { + return parseWindowsUnityProcesses(output); + } + + return parsePsUnityProcesses(output); +} + +export function tokenizeCommandLine(commandLine: string): string[] { + const tokens: string[] = []; + let current = ''; + let inQuotes = false; + + for (let i = 0; i < commandLine.length; i++) { + const character = commandLine[i]; + + if (character === '"') { + inQuotes = !inQuotes; + continue; + } + + if (!inQuotes && /\s/.test(character)) { + if (current.length > 0) { + tokens.push(current); + current = ''; + } + continue; + } + + current += character; + } + + if (current.length > 0) { + tokens.push(current); + } + + return tokens; +} + +export function extractUnityProjectPath(commandLine: string): string | null { + const tokens = tokenizeCommandLine(commandLine); + + for (let i = 0; i < tokens.length; i++) { + const token = tokens[i].toLowerCase(); + if (token !== '-projectpath') { + continue; + } + + const projectPath = tokens[i + 1]; + return projectPath ?? null; + } + + return null; +} + +export function normalizeUnityProjectPath(projectPath: string, platform: NodeJS.Platform): string { + const normalizedSeparators = projectPath.replace(/\\/g, '/').replace(/\/+$/, ''); + if (platform === 'win32') { + return normalizedSeparators.toLowerCase(); + } + + return normalizedSeparators; +} + +export function isUnityProcessForProject( + commandLine: string, + projectRoot: string, + platform: NodeJS.Platform, +): boolean { + if (platform !== 'win32') { + return commandLineContainsProjectRoot(commandLine, projectRoot, platform); + } + + const extractedProjectPath = extractUnityProjectPath(commandLine); + if (extractedProjectPath === null) { + return false; + } + + return ( + normalizeUnityProjectPath(extractedProjectPath, platform) === + normalizeUnityProjectPath(projectRoot, platform) + ); +} + +function commandLineContainsProjectRoot( + commandLine: string, + projectRoot: string, + platform: NodeJS.Platform, +): boolean { + const projectPathFlagIndex = commandLine.toLowerCase().indexOf(' -projectpath'); + if (projectPathFlagIndex === -1) { + return false; + } + + const normalizedProjectRoot = normalizeUnityProjectPath(projectRoot, platform); + let projectRootIndex = commandLine.indexOf(normalizedProjectRoot, projectPathFlagIndex); + + while (projectRootIndex !== -1) { + const beforeProjectRoot = commandLine[projectRootIndex - 1]; + const projectPathEndIndex = skipTrailingProjectPathSeparators( + commandLine, + projectRootIndex + normalizedProjectRoot.length, + ); + if ( + isProjectPathBoundaryCharacter(beforeProjectRoot) && + isProjectPathTerminator(commandLine, projectPathEndIndex) + ) { + return true; + } + + projectRootIndex = commandLine.indexOf(normalizedProjectRoot, projectRootIndex + 1); + } + + return false; +} + +function isProjectPathBoundaryCharacter(character: string | undefined): boolean { + return character === undefined || /\s|["']/.test(character); +} + +function skipTrailingProjectPathSeparators(commandLine: string, startIndex: number): number { + let index = startIndex; + + while (readCharacterAt(commandLine, index) === '/') { + index += 1; + } + + return index; +} + +function isProjectPathTerminator(commandLine: string, projectRootEndIndex: number): boolean { + const character = readCharacterAt(commandLine, projectRootEndIndex); + if (character === null) { + return true; + } + + if (character === '"' || character === "'") { + return true; + } + + if (!/\s/.test(character)) { + return false; + } + + for (let i = projectRootEndIndex; i < commandLine.length; i++) { + const trailingCharacter = readCharacterAt(commandLine, i); + if (trailingCharacter === null) { + return true; + } + + if (/\s/.test(trailingCharacter)) { + continue; + } + + return trailingCharacter === '-'; + } + + return true; +} + +function readCharacterAt(value: string, index: number): string | null { + const character = value.slice(index, index + 1); + if (character.length === 0) { + return null; + } + + return character; +} + +export function isUnityEditorProcess(commandLine: string, platform: NodeJS.Platform): boolean { + const lowerCommandLine = commandLine.toLowerCase(); + if (lowerCommandLine.length === 0) { + return false; + } + + const projectPathFlagIndex = lowerCommandLine.indexOf(' -projectpath'); + const executableSection = + projectPathFlagIndex === -1 + ? lowerCommandLine + : lowerCommandLine.slice(0, projectPathFlagIndex); + + if (platform === 'win32') { + return executableSection.includes('unity.exe'); + } + + if (platform === 'darwin') { + return executableSection.includes('/unity.app/contents/macos/unity'); + } + + if (platform === 'linux') { + return ( + executableSection.endsWith('/unity') || + executableSection.endsWith('/unity-editor') || + executableSection.includes('/editor/unity') + ); + } + + return false; +} + +export async function findRunningUnityProcessForProject( + projectRoot: string, + dependencies: UnityProcessDependencies = defaultDependencies, +): Promise { + const unityProcessCommand = buildUnityProcessCommand(dependencies.platform); + if (unityProcessCommand === null) { + return null; + } + + const output = await dependencies.runCommand( + unityProcessCommand.command, + unityProcessCommand.args, + ); + const runningProcesses = parseUnityProcesses(dependencies.platform, output); + const matchingProcess = runningProcesses.find( + (processInfo) => + isUnityEditorProcess(processInfo.commandLine, dependencies.platform) && + isUnityProcessForProject(processInfo.commandLine, projectRoot, dependencies.platform), + ); + + if (matchingProcess === undefined) { + return null; + } + + return { + pid: matchingProcess.pid, + }; +} + +async function runUnityProcessQuery(command: string, args: string[]): Promise { + const { stdout } = await execFileAsync(command, args, { + encoding: 'utf8', + maxBuffer: 1024 * 1024, + }); + return stdout; +} + +function parsePsUnityProcesses(output: string): RawUnityProcess[] { + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const match = line.match(/^(\d+)\s+(.+)$/); + if (match === null) { + return null; + } + + return { + pid: Number.parseInt(match[1], 10), + commandLine: match[2], + }; + }) + .filter((processInfo): processInfo is RawUnityProcess => processInfo !== null); +} + +function parseWindowsUnityProcesses(output: string): RawUnityProcess[] { + const trimmed = output.trim(); + if (trimmed.length === 0) { + return []; + } + + const parsed = JSON.parse(trimmed) as WindowsUnityProcessJson | WindowsUnityProcessJson[]; + const processArray = Array.isArray(parsed) ? parsed : [parsed]; + + return processArray.filter(isWindowsUnityProcessWithCommandLine).map((processInfo) => ({ + pid: processInfo.ProcessId, + commandLine: processInfo.CommandLine, + })); +} + +interface WindowsUnityProcessJson { + ProcessId: number; + CommandLine?: string; +} + +function isWindowsUnityProcessWithCommandLine( + processInfo: WindowsUnityProcessJson, +): processInfo is WindowsUnityProcessJson & { CommandLine: string } { + return typeof processInfo.ProcessId === 'number' && typeof processInfo.CommandLine === 'string'; +}