From 3c0c9c56744ba16a8e5f9ce05971aeff796a1bf4 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 4 Jul 2026 11:01:11 +0900 Subject: [PATCH 1/3] Extract Unity process focus helpers --- cli/common/clicore/focus.go | 405 +----------------- cli/common/clicore/focus_test.go | 141 ------ cli/common/clicore/tool_readiness.go | 5 +- cli/common/unityprocess/focus_darwin.go | 45 ++ .../unityprocess/focus_unity_process.ps1 | 20 + .../focus_unity_process_with_restore.ps1 | 23 + cli/common/unityprocess/focus_unsupported.go | 17 + cli/common/unityprocess/focus_windows.go | 33 ++ .../unityprocess/focus_windows_scripts.go | 41 ++ cli/common/unityprocess/process.go | 194 +++++++++ cli/common/unityprocess/process_test.go | 151 +++++++ .../restore_windows_foreground_window.ps1 | 43 ++ cli/dispatcher/internal/dispatcher/launch.go | 7 +- .../internal/dispatcher/launch_deps.go | 7 +- cli/dispatcher/shared-inputs-stamp.json | 2 +- .../projectrunner/connection_retry.go | 11 +- .../projectrunner/connection_retry_flow.go | 3 +- cli/project-runner/shared-inputs-stamp.json | 2 +- .../automation/release_trigger_guard.go | 5 +- .../automation/release_trigger_guard_test.go | 22 + scripts/stamp-release-inputs.sh | 5 +- 21 files changed, 635 insertions(+), 547 deletions(-) create mode 100644 cli/common/unityprocess/focus_darwin.go create mode 100644 cli/common/unityprocess/focus_unity_process.ps1 create mode 100644 cli/common/unityprocess/focus_unity_process_with_restore.ps1 create mode 100644 cli/common/unityprocess/focus_unsupported.go create mode 100644 cli/common/unityprocess/focus_windows.go create mode 100644 cli/common/unityprocess/focus_windows_scripts.go create mode 100644 cli/common/unityprocess/process.go create mode 100644 cli/common/unityprocess/process_test.go create mode 100644 cli/common/unityprocess/restore_windows_foreground_window.ps1 diff --git a/cli/common/clicore/focus.go b/cli/common/clicore/focus.go index e15cf95f95..4559ec37c1 100644 --- a/cli/common/clicore/focus.go +++ b/cli/common/clicore/focus.go @@ -5,38 +5,21 @@ import ( "encoding/json" "fmt" "io" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" -) - -const windowsPowerShellCommand = "powershell" -var ( - macUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.app/Contents/MacOS/Unity`) - windowsUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.exe`) - macProcessLinePattern = regexp.MustCompile(`^\s*(\d+)\s+(.*)$`) - projectPathFlagPattern = regexp.MustCompile(`(?i)-projectpath(?:=|\s+)(.+)$`) - nextUnityFlagPattern = regexp.MustCompile(`\s-[A-Za-z][A-Za-z0-9-]*(?:=|\s|$)`) + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) -type UnityProcess struct { - Pid int - projectPath string -} +type UnityProcess = unityprocess.UnityProcess + +type RestoreFocusFunc = unityprocess.RestoreFocusFunc type focusResponse struct { Success bool `json:"Success"` Message string `json:"Message"` } -type RestoreFocusFunc func(context.Context) error - type focusWindowDeps struct { - findRunningUnityProcess func(context.Context, string) (*UnityProcess, error) + findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error) focusUnityProcess func(context.Context, int) error } @@ -44,10 +27,22 @@ func RunFocusWindow(ctx context.Context, projectRoot string, stdout io.Writer, s return runFocusWindow(ctx, projectRoot, stdout, stderr, defaultFocusWindowDeps()) } +func FindRunningUnityProcess(ctx context.Context, projectRoot string) (*UnityProcess, error) { + return unityprocess.FindRunningUnityProcess(ctx, projectRoot) +} + +func FocusUnityProcess(ctx context.Context, pid int) error { + return unityprocess.FocusUnityProcess(ctx, pid) +} + +func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { + return unityprocess.FocusUnityProcessWithRestore(ctx, pid) +} + func defaultFocusWindowDeps() focusWindowDeps { return focusWindowDeps{ - findRunningUnityProcess: FindRunningUnityProcess, - focusUnityProcess: FocusUnityProcess, + findRunningUnityProcess: unityprocess.FindRunningUnityProcess, + focusUnityProcess: unityprocess.FocusUnityProcess, } } @@ -124,365 +119,3 @@ func logFocusWindowFocusFailure(projectRoot string, pid int, focusErr error, cor CorrelationID: correlationID, }) } - -func FindRunningUnityProcess(ctx context.Context, projectRoot string) (*UnityProcess, error) { - processes, err := listUnityProcesses(ctx) - if err != nil { - return nil, err - } - - normalizedTarget, err := normalizeComparablePath(projectRoot) - if err != nil { - return nil, err - } - - for _, candidate := range processes { - normalizedCandidate, err := normalizeComparablePath(candidate.projectPath) - if err != nil { - continue - } - if normalizedCandidate == normalizedTarget { - process := candidate - return &process, nil - } - } - return nil, nil -} - -func listUnityProcesses(ctx context.Context) ([]UnityProcess, error) { - switch runtime.GOOS { - case "darwin": - return listUnityProcessesMac(ctx) - case "windows": - return listUnityProcessesWindows(ctx) - default: - return []UnityProcess{}, nil - } -} - -func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { - output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,command=", "-ww").Output() - if err != nil { - return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err) - } - return parseMacUnityProcesses(string(output)), nil -} - -func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) { - scriptLines := []string{ - "$ErrorActionPreference = 'Stop'", - "$processes = Get-CimInstance Win32_Process -Filter \"Name = 'Unity.exe'\" | Where-Object { $_.CommandLine }", - "foreach ($process in $processes) {", - " $commandLine = $process.CommandLine -replace \"`r\", ' ' -replace \"`n\", ' '", - " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)", - "}", - } - output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output() - if err != nil { - return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err) - } - return parseWindowsUnityProcesses(string(output)), nil -} - -func parseMacUnityProcesses(output string) []UnityProcess { - processes := []UnityProcess{} - for _, line := range strings.Split(output, "\n") { - matches := macProcessLinePattern.FindStringSubmatch(line) - if len(matches) != 3 { - continue - } - - pid, err := strconv.Atoi(matches[1]) - if err != nil { - continue - } - - command := matches[2] - if !isUnityEditorCommand(command, macUnityExecutablePattern) { - continue - } - projectPath := extractProjectPath(command) - if projectPath == "" { - continue - } - - processes = append(processes, UnityProcess{Pid: pid, projectPath: projectPath}) - } - return processes -} - -func parseWindowsUnityProcesses(output string) []UnityProcess { - processes := []UnityProcess{} - for _, line := range strings.Split(output, "\n") { - trimmed := strings.TrimSpace(line) - if trimmed == "" { - continue - } - - delimiterIndex := strings.Index(trimmed, "|") - if delimiterIndex < 0 { - continue - } - - pid, err := strconv.Atoi(strings.TrimSpace(trimmed[:delimiterIndex])) - if err != nil { - continue - } - - command := strings.TrimSpace(trimmed[delimiterIndex+1:]) - if !isUnityEditorCommand(command, windowsUnityExecutablePattern) { - continue - } - projectPath := extractProjectPath(command) - if projectPath == "" { - continue - } - - processes = append(processes, UnityProcess{Pid: pid, projectPath: projectPath}) - } - return processes -} - -func isUnityEditorCommand(command string, executablePattern *regexp.Regexp) bool { - lowerCommand := strings.ToLower(command) - if strings.Contains(lowerCommand, "-batchmode") || strings.Contains(lowerCommand, "assetimportworker") { - return false - } - return executablePattern.MatchString(command) -} - -func extractProjectPath(command string) string { - matches := projectPathFlagPattern.FindStringSubmatch(command) - if len(matches) != 2 { - return "" - } - - value := strings.TrimSpace(matches[1]) - if value == "" { - return "" - } - - if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, `'`) { - return extractQuotedProjectPath(value) - } - - nextFlagIndex := nextUnityFlagPattern.FindStringIndex(value) - if nextFlagIndex != nil { - value = strings.TrimSpace(value[:nextFlagIndex[0]]) - } - return strings.Trim(value, `"'`) -} - -func extractQuotedProjectPath(value string) string { - quote := value[0] - endIndex := strings.IndexByte(value[1:], quote) - if endIndex < 0 { - return "" - } - return value[1 : endIndex+1] -} - -func normalizeComparablePath(path string) (string, error) { - absolutePath, err := filepath.Abs(path) - if err != nil { - return "", err - } - return strings.ToLower(filepath.ToSlash(filepath.Clean(absolutePath))), nil -} - -func FocusUnityProcess(ctx context.Context, pid int) error { - switch runtime.GOOS { - case "darwin": - return focusUnityProcessMac(ctx, pid) - case "windows": - return focusUnityProcessWindows(ctx, pid) - default: - return fmt.Errorf("focus-window is not supported on %s", runtime.GOOS) - } -} - -func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { - switch runtime.GOOS { - case "darwin": - return focusUnityProcessMacWithRestore(ctx, pid) - case "windows": - return focusUnityProcessWindowsWithRestore(ctx, pid) - default: - return nil, fmt.Errorf("focus-window is not supported on %s", runtime.GOOS) - } -} - -func focusUnityProcessMac(ctx context.Context, pid int) error { - return setFrontmostProcessMac(ctx, pid) -} - -func focusUnityProcessMacWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { - previousPID := readFrontmostProcessIDMac(ctx) - if err := setFrontmostProcessMac(ctx, pid); err != nil { - return nil, err - } - if previousPID <= 0 { - return nil, nil - } - return func(ctx context.Context) error { - return setFrontmostProcessMac(ctx, previousPID) - }, nil -} - -func readFrontmostProcessIDMac(ctx context.Context) int { - output, err := exec.CommandContext(ctx, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output() - if err != nil { - return 0 - } - pid, err := strconv.Atoi(strings.TrimSpace(string(output))) - if err != nil { - return 0 - } - return pid -} - -func setFrontmostProcessMac(ctx context.Context, pid int) error { - script := fmt.Sprintf(`tell application "System Events" to set frontmost of (first process whose unix id is %d) to true`, pid) - return exec.CommandContext(ctx, "osascript", "-e", script).Run() -} - -func focusUnityProcessWindows(ctx context.Context, pid int) error { - script := buildFocusUnityProcessWindowsScript(pid) - return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() -} - -func focusUnityProcessWindowsWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { - script := buildFocusUnityProcessWindowsWithRestoreScript(pid) - output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Output() - if err != nil { - return nil, err - } - previousHandle := parseWindowsForegroundHandle(string(output)) - if previousHandle == 0 { - return nil, nil - } - return func(ctx context.Context) error { - return restoreWindowsForegroundWindow(ctx, previousHandle) - }, nil -} - -func buildFocusUnityProcessWindowsScript(pid int) string { - scriptLines := []string{ - "$ErrorActionPreference = 'Stop'", - } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(false, false)...) - scriptLines = append(scriptLines, buildWindowsFocusTargetScriptLines(pid)...) - return strings.Join(scriptLines, "\n") -} - -func buildFocusUnityProcessWindowsWithRestoreScript(pid int) string { - scriptLines := []string{ - "$ErrorActionPreference = 'Stop'", - } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(true, false)...) - scriptLines = append(scriptLines, - "$previous = [Win32Interop]::GetForegroundWindow()", - ) - scriptLines = append(scriptLines, buildWindowsFocusTargetScriptLines(pid)...) - scriptLines = append(scriptLines, "Write-Output $previous.ToInt64()") - return strings.Join(scriptLines, "\n") -} - -func buildWindowsFocusInteropTypeDefinition(includeGetForegroundWindow bool, includeThreadFocus bool) []string { - addTypeLines := []string{ - "Add-Type -TypeDefinition @\"", - "using System;", - "using System.Runtime.InteropServices;", - "public static class Win32Interop {", - } - if includeGetForegroundWindow { - addTypeLines = append(addTypeLines, - " [DllImport(\"user32.dll\")] public static extern IntPtr GetForegroundWindow();", - ) - } - addTypeLines = append(addTypeLines, - " [DllImport(\"user32.dll\")] public static extern bool SetForegroundWindow(IntPtr hWnd);", - " [DllImport(\"user32.dll\")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow);", - ) - if includeThreadFocus { - addTypeLines = append(addTypeLines, - " [DllImport(\"user32.dll\")] public static extern bool BringWindowToTop(IntPtr hWnd);", - " [DllImport(\"user32.dll\")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId);", - " [DllImport(\"kernel32.dll\")] public static extern uint GetCurrentThreadId();", - " [DllImport(\"user32.dll\")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);", - " [DllImport(\"user32.dll\")] public static extern bool IsIconic(IntPtr hWnd);", - ) - } - addTypeLines = append(addTypeLines, - "}", - "\"@", - ) - return addTypeLines -} - -func buildWindowsFocusTargetScriptLines(pid int) []string { - return []string{ - fmt.Sprintf("try { $process = Get-Process -Id %d -ErrorAction Stop } catch { throw 'Unity process was not found: %d' }", pid, pid), - "$handle = $process.MainWindowHandle", - fmt.Sprintf("if ($handle -eq 0) { throw 'Unity process has no main window handle: %d' }", pid), - "$shown = [Win32Interop]::ShowWindowAsync($handle, 9)", - "if (-not $shown) { throw 'Failed to show Unity window' }", - "$focused = [Win32Interop]::SetForegroundWindow($handle)", - "if (-not $focused) {", - " $shell = New-Object -ComObject WScript.Shell", - fmt.Sprintf(" $focused = $shell.AppActivate(%d)", pid), - "}", - "if (-not $focused) { throw 'Failed to focus Unity window' }", - } -} - -func parseWindowsForegroundHandle(output string) int64 { - handle, err := strconv.ParseInt(strings.TrimSpace(output), 10, 64) - if err != nil { - return 0 - } - return handle -} - -func restoreWindowsForegroundWindow(ctx context.Context, handle int64) error { - script := buildRestoreWindowsForegroundWindowScript(handle) - return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() -} - -func buildRestoreWindowsForegroundWindowScript(handle int64) string { - scriptLines := []string{ - "$ErrorActionPreference = 'Stop'", - } - scriptLines = append(scriptLines, buildWindowsFocusInteropTypeDefinition(true, true)...) - scriptLines = append(scriptLines, - fmt.Sprintf("$handle = [IntPtr]::new(%d)", handle), - "if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' }", - "$targetThreadId = [Win32Interop]::GetWindowThreadProcessId($handle, [IntPtr]::Zero)", - "if ($targetThreadId -eq 0) { throw 'Saved foreground window thread is invalid' }", - "$foreground = [Win32Interop]::GetForegroundWindow()", - "$foregroundThreadId = [Win32Interop]::GetWindowThreadProcessId($foreground, [IntPtr]::Zero)", - "$currentThreadId = [Win32Interop]::GetCurrentThreadId()", - "$attachedCurrent = $false", - "$attachedForeground = $false", - "try {", - " if ($targetThreadId -ne $currentThreadId) {", - " $attachedCurrent = [Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $true)", - " }", - " if ($foregroundThreadId -ne 0 -and $foregroundThreadId -ne $targetThreadId) {", - " $attachedForeground = [Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $true)", - " }", - " $isMinimized = [Win32Interop]::IsIconic($handle)", - " if ($isMinimized) {", - " $shown = [Win32Interop]::ShowWindowAsync($handle, 9)", - " if (-not $shown) { throw 'Failed to show previous foreground window' }", - " }", - " [void][Win32Interop]::BringWindowToTop($handle)", - " $restored = [Win32Interop]::SetForegroundWindow($handle)", - "} finally {", - " if ($attachedForeground) { [void][Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $false) }", - " if ($attachedCurrent) { [void][Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $false) }", - "}", - "if (-not $restored) { throw 'Failed to restore previous foreground window' }", - ) - return strings.Join(scriptLines, "\n") -} diff --git a/cli/common/clicore/focus_test.go b/cli/common/clicore/focus_test.go index 3b85e33fcf..85633f63cf 100644 --- a/cli/common/clicore/focus_test.go +++ b/cli/common/clicore/focus_test.go @@ -8,147 +8,6 @@ import ( "testing" ) -func TestParseMacUnityProcessesExtractsProjectPath(t *testing.T) { - output := `123 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath "/Users/ExampleUser/My Project" -useHub -hubIPC -456 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -batchmode -projectPath "/Users/ExampleUser/Batch" -789 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath /Users/ExampleUser/Other -logFile - -` - - processes := parseMacUnityProcesses(output) - - if len(processes) != 2 { - t.Fatalf("process count mismatch: %#v", processes) - } - if processes[0].Pid != 123 || processes[0].projectPath != "/Users/ExampleUser/My Project" { - t.Fatalf("first process mismatch: %#v", processes[0]) - } - if processes[1].Pid != 789 || processes[1].projectPath != "/Users/ExampleUser/Other" { - t.Fatalf("second process mismatch: %#v", processes[1]) - } -} - -func TestParseWindowsUnityProcessesExtractsProjectPath(t *testing.T) { - output := `123|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -projectPath "C:\Users\ExampleUser\My Project" -useHub -456|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -batchmode -projectPath "C:\Users\ExampleUser\Batch" -` - - processes := parseWindowsUnityProcesses(output) - - if len(processes) != 1 { - t.Fatalf("process count mismatch: %#v", processes) - } - if processes[0].Pid != 123 || processes[0].projectPath != `C:\Users\ExampleUser\My Project` { - t.Fatalf("process mismatch: %#v", processes[0]) - } -} - -func TestExtractProjectPathSupportsEqualsAndSpaces(t *testing.T) { - cases := map[string]string{ - `Unity -projectPath="/Users/ExampleUser/My Project" -useHub`: "/Users/ExampleUser/My Project", - `Unity -projectpath '/Users/ExampleUser/Other Project' -flag`: "/Users/ExampleUser/Other Project", - `Unity -projectPath /Users/ExampleUser/Plain -flag`: "/Users/ExampleUser/Plain", - `Unity Hub -- --silent -- -projectPath /Users/ExampleUser/SampleWorkspace/SampleUnityProject -cacheServerEnableUpload`: "/Users/ExampleUser/SampleWorkspace/SampleUnityProject", - `Unity -projectPath /Users/ExampleUser/SampleWorkspace/SampleUnityProject -acceptSoftwareTermsForThisRunOnly -useHub`: "/Users/ExampleUser/SampleWorkspace/SampleUnityProject", - `Unity -projectPath /Users/ExampleUser/SampleWorkspace/SampleUnityProject -cacheServerEnableDownload=false -useHub`: "/Users/ExampleUser/SampleWorkspace/SampleUnityProject", - `Unity -projectPath /Users/ExampleUser/SampleWorkspace/SampleUnityProject -hubSessionId 715810a5-220d-411e-a7d2-28cf46f`: "/Users/ExampleUser/SampleWorkspace/SampleUnityProject", - } - - for command, expected := range cases { - actual := extractProjectPath(command) - if actual != expected { - t.Fatalf("project path mismatch for %q: %q", command, actual) - } - } -} - -func TestBuildFocusUnityProcessWindowsScriptThrowsOnFailures(t *testing.T) { - script := buildFocusUnityProcessWindowsScript(123) - - for _, expected := range []string{ - "throw 'Unity process was not found: 123'", - "throw 'Unity process has no main window handle: 123'", - "throw 'Failed to show Unity window'", - "$focused = $shell.AppActivate(123)", - "throw 'Failed to focus Unity window'", - } { - if !strings.Contains(script, expected) { - t.Fatalf("script missing %q: %s", expected, script) - } - } - if strings.Contains(script, "catch { return }") || strings.Contains(script, "{ return }") { - t.Fatalf("script should not silently return: %s", script) - } -} - -// Verifies the Windows focus-with-restore script captures the previous foreground window. -func TestBuildFocusUnityProcessWindowsWithRestoreScriptCapturesForegroundWindow(t *testing.T) { - script := buildFocusUnityProcessWindowsWithRestoreScript(123) - - for _, expected := range []string{ - "GetForegroundWindow", - "$previous = [Win32Interop]::GetForegroundWindow()", - "Write-Output $previous.ToInt64()", - "$focused = $shell.AppActivate(123)", - } { - if !strings.Contains(script, expected) { - t.Fatalf("script missing %q: %s", expected, script) - } - } -} - -// Verifies the Windows restore script fails when the saved foreground window cannot be restored. -func TestBuildRestoreWindowsForegroundWindowScriptThrowsOnRestoreFailure(t *testing.T) { - script := buildRestoreWindowsForegroundWindowScript(123) - - for _, expected := range []string{ - "$handle = [IntPtr]::new(123)", - "if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' }", - "GetWindowThreadProcessId", - "GetCurrentThreadId", - "AttachThreadInput", - "BringWindowToTop", - "try {", - "} finally {", - "AttachThreadInput($foregroundThreadId, $targetThreadId, $false)", - "AttachThreadInput($currentThreadId, $targetThreadId, $false)", - "$restored = [Win32Interop]::SetForegroundWindow($handle)", - "if (-not $restored) { throw 'Failed to restore previous foreground window' }", - } { - if !strings.Contains(script, expected) { - t.Fatalf("script missing %q: %s", expected, script) - } - } -} - -// Verifies Windows restore avoids resizing a saved maximized foreground window. -func TestBuildRestoreWindowsForegroundWindowScriptRestoresOnlyMinimizedWindow(t *testing.T) { - script := buildRestoreWindowsForegroundWindowScript(123) - - for _, expected := range []string{ - "IsIconic", - "$isMinimized = [Win32Interop]::IsIconic($handle)", - "if ($isMinimized) {", - " $shown = [Win32Interop]::ShowWindowAsync($handle, 9)", - } { - if !strings.Contains(script, expected) { - t.Fatalf("script missing %q: %s", expected, script) - } - } - if strings.Contains(script, "\n $shown = [Win32Interop]::ShowWindowAsync($handle, 9)") { - t.Fatalf("restore script should not unconditionally restore the previous window: %s", script) - } -} - -// Verifies Windows foreground handle parsing ignores invalid saved state output. -func TestParseWindowsForegroundHandle(t *testing.T) { - if parseWindowsForegroundHandle("123\r\n") != 123 { - t.Fatal("expected numeric handle to parse") - } - if parseWindowsForegroundHandle("not-a-handle") != 0 { - t.Fatal("expected invalid handle to be ignored") - } -} - // Verifies focus-window persists successful focus attempts to CLI Vibe logs. func TestRunFocusWindowWritesFocusSuccessVibeLog(t *testing.T) { enableCliVibeLog(t) diff --git a/cli/common/clicore/tool_readiness.go b/cli/common/clicore/tool_readiness.go index acd77534ba..8d64e748c6 100644 --- a/cli/common/clicore/tool_readiness.go +++ b/cli/common/clicore/tool_readiness.go @@ -9,6 +9,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/project" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) const ( @@ -23,7 +24,7 @@ const ( const executeDynamicCodeReadinessProbe = `return "Unity CLI Loop dynamic code prewarm";` type toolReadinessDeps struct { - findRunningUnityProcess func(context.Context, string) (*UnityProcess, error) + findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error) probeToolReadinessSequence func(context.Context, string) error } @@ -37,7 +38,7 @@ func WaitForToolReadinessWithTimeout(ctx context.Context, projectRoot string, ti func defaultToolReadinessDeps() toolReadinessDeps { return toolReadinessDeps{ - findRunningUnityProcess: FindRunningUnityProcess, + findRunningUnityProcess: unityprocess.FindRunningUnityProcess, probeToolReadinessSequence: ProbeToolReadinessSequence, } } diff --git a/cli/common/unityprocess/focus_darwin.go b/cli/common/unityprocess/focus_darwin.go new file mode 100644 index 0000000000..aa6d763517 --- /dev/null +++ b/cli/common/unityprocess/focus_darwin.go @@ -0,0 +1,45 @@ +//go:build darwin + +package unityprocess + +import ( + "context" + "fmt" + "os/exec" + "strconv" + "strings" +) + +func FocusUnityProcess(ctx context.Context, pid int) error { + return setFrontmostProcessMac(ctx, pid) +} + +func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { + previousPID := readFrontmostProcessIDMac(ctx) + if err := setFrontmostProcessMac(ctx, pid); err != nil { + return nil, err + } + if previousPID <= 0 { + return nil, nil + } + return func(ctx context.Context) error { + return setFrontmostProcessMac(ctx, previousPID) + }, nil +} + +func readFrontmostProcessIDMac(ctx context.Context) int { + output, err := exec.CommandContext(ctx, "osascript", "-e", `tell application "System Events" to get unix id of first process whose frontmost is true`).Output() + if err != nil { + return 0 + } + pid, err := strconv.Atoi(strings.TrimSpace(string(output))) + if err != nil { + return 0 + } + return pid +} + +func setFrontmostProcessMac(ctx context.Context, pid int) error { + script := fmt.Sprintf(`tell application "System Events" to set frontmost of (first process whose unix id is %d) to true`, pid) + return exec.CommandContext(ctx, "osascript", "-e", script).Run() +} diff --git a/cli/common/unityprocess/focus_unity_process.ps1 b/cli/common/unityprocess/focus_unity_process.ps1 new file mode 100644 index 0000000000..64d904e43f --- /dev/null +++ b/cli/common/unityprocess/focus_unity_process.ps1 @@ -0,0 +1,20 @@ +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class Win32Interop { + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); +} +"@ +try { $process = Get-Process -Id {{PID}} -ErrorAction Stop } catch { throw 'Unity process was not found: {{PID}}' } +$handle = $process.MainWindowHandle +if ($handle -eq 0) { throw 'Unity process has no main window handle: {{PID}}' } +$shown = [Win32Interop]::ShowWindowAsync($handle, 9) +if (-not $shown) { throw 'Failed to show Unity window' } +$focused = [Win32Interop]::SetForegroundWindow($handle) +if (-not $focused) { + $shell = New-Object -ComObject WScript.Shell + $focused = $shell.AppActivate({{PID}}) +} +if (-not $focused) { throw 'Failed to focus Unity window' } diff --git a/cli/common/unityprocess/focus_unity_process_with_restore.ps1 b/cli/common/unityprocess/focus_unity_process_with_restore.ps1 new file mode 100644 index 0000000000..8c1c396e6d --- /dev/null +++ b/cli/common/unityprocess/focus_unity_process_with_restore.ps1 @@ -0,0 +1,23 @@ +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class Win32Interop { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); +} +"@ +$previous = [Win32Interop]::GetForegroundWindow() +try { $process = Get-Process -Id {{PID}} -ErrorAction Stop } catch { throw 'Unity process was not found: {{PID}}' } +$handle = $process.MainWindowHandle +if ($handle -eq 0) { throw 'Unity process has no main window handle: {{PID}}' } +$shown = [Win32Interop]::ShowWindowAsync($handle, 9) +if (-not $shown) { throw 'Failed to show Unity window' } +$focused = [Win32Interop]::SetForegroundWindow($handle) +if (-not $focused) { + $shell = New-Object -ComObject WScript.Shell + $focused = $shell.AppActivate({{PID}}) +} +if (-not $focused) { throw 'Failed to focus Unity window' } +Write-Output $previous.ToInt64() diff --git a/cli/common/unityprocess/focus_unsupported.go b/cli/common/unityprocess/focus_unsupported.go new file mode 100644 index 0000000000..6bcd63b47f --- /dev/null +++ b/cli/common/unityprocess/focus_unsupported.go @@ -0,0 +1,17 @@ +//go:build !darwin && !windows + +package unityprocess + +import ( + "context" + "fmt" + "runtime" +) + +func FocusUnityProcess(context.Context, int) error { + return fmt.Errorf("focus-window is not supported on %s", runtime.GOOS) +} + +func FocusUnityProcessWithRestore(context.Context, int) (RestoreFocusFunc, error) { + return nil, fmt.Errorf("focus-window is not supported on %s", runtime.GOOS) +} diff --git a/cli/common/unityprocess/focus_windows.go b/cli/common/unityprocess/focus_windows.go new file mode 100644 index 0000000000..2507688d3a --- /dev/null +++ b/cli/common/unityprocess/focus_windows.go @@ -0,0 +1,33 @@ +//go:build windows + +package unityprocess + +import ( + "context" + "os/exec" +) + +func FocusUnityProcess(ctx context.Context, pid int) error { + script := buildFocusUnityProcessWindowsScript(pid) + return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() +} + +func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { + script := buildFocusUnityProcessWindowsWithRestoreScript(pid) + output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Output() + if err != nil { + return nil, err + } + previousHandle := parseWindowsForegroundHandle(string(output)) + if previousHandle == 0 { + return nil, nil + } + return func(ctx context.Context) error { + return restoreWindowsForegroundWindow(ctx, previousHandle) + }, nil +} + +func restoreWindowsForegroundWindow(ctx context.Context, handle int64) error { + script := buildRestoreWindowsForegroundWindowScript(handle) + return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() +} diff --git a/cli/common/unityprocess/focus_windows_scripts.go b/cli/common/unityprocess/focus_windows_scripts.go new file mode 100644 index 0000000000..a4a2ac2c00 --- /dev/null +++ b/cli/common/unityprocess/focus_windows_scripts.go @@ -0,0 +1,41 @@ +package unityprocess + +import ( + _ "embed" + "strconv" + "strings" +) + +const ( + windowsFocusPIDPlaceholder = "{{PID}}" + windowsFocusHandlePlaceholder = "{{HANDLE}}" +) + +//go:embed focus_unity_process.ps1 +var focusUnityProcessWindowsTemplate string + +//go:embed focus_unity_process_with_restore.ps1 +var focusUnityProcessWithRestoreWindowsTemplate string + +//go:embed restore_windows_foreground_window.ps1 +var restoreWindowsForegroundWindowTemplate string + +func buildFocusUnityProcessWindowsScript(pid int) string { + return strings.ReplaceAll(focusUnityProcessWindowsTemplate, windowsFocusPIDPlaceholder, strconv.Itoa(pid)) +} + +func buildFocusUnityProcessWindowsWithRestoreScript(pid int) string { + return strings.ReplaceAll(focusUnityProcessWithRestoreWindowsTemplate, windowsFocusPIDPlaceholder, strconv.Itoa(pid)) +} + +func parseWindowsForegroundHandle(output string) int64 { + handle, err := strconv.ParseInt(strings.TrimSpace(output), 10, 64) + if err != nil { + return 0 + } + return handle +} + +func buildRestoreWindowsForegroundWindowScript(handle int64) string { + return strings.ReplaceAll(restoreWindowsForegroundWindowTemplate, windowsFocusHandlePlaceholder, strconv.FormatInt(handle, 10)) +} diff --git a/cli/common/unityprocess/process.go b/cli/common/unityprocess/process.go new file mode 100644 index 0000000000..edcd2b208c --- /dev/null +++ b/cli/common/unityprocess/process.go @@ -0,0 +1,194 @@ +package unityprocess + +import ( + "context" + "fmt" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "strconv" + "strings" +) + +const windowsPowerShellCommand = "powershell" + +var ( + macUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.app/Contents/MacOS/Unity`) + windowsUnityExecutablePattern = regexp.MustCompile(`(?i)Unity\.exe`) + macProcessLinePattern = regexp.MustCompile(`^\s*(\d+)\s+(.*)$`) + projectPathFlagPattern = regexp.MustCompile(`(?i)-projectpath(?:=|\s+)(.+)$`) + nextUnityFlagPattern = regexp.MustCompile(`\s-[A-Za-z][A-Za-z0-9-]*(?:=|\s|$)`) +) + +type UnityProcess struct { + Pid int + projectPath string +} + +type RestoreFocusFunc func(context.Context) error + +func FindRunningUnityProcess(ctx context.Context, projectRoot string) (*UnityProcess, error) { + processes, err := listUnityProcesses(ctx) + if err != nil { + return nil, err + } + + normalizedTarget, err := normalizeComparablePath(projectRoot) + if err != nil { + return nil, err + } + + for _, candidate := range processes { + normalizedCandidate, err := normalizeComparablePath(candidate.projectPath) + if err != nil { + continue + } + if normalizedCandidate == normalizedTarget { + process := candidate + return &process, nil + } + } + return nil, nil +} + +func listUnityProcesses(ctx context.Context) ([]UnityProcess, error) { + switch runtime.GOOS { + case "darwin": + return listUnityProcessesMac(ctx) + case "windows": + return listUnityProcessesWindows(ctx) + default: + return []UnityProcess{}, nil + } +} + +func listUnityProcessesMac(ctx context.Context) ([]UnityProcess, error) { + output, err := exec.CommandContext(ctx, "ps", "-axo", "pid=,command=", "-ww").Output() + if err != nil { + return nil, fmt.Errorf("failed to retrieve Unity process list: %w", err) + } + return parseMacUnityProcesses(string(output)), nil +} + +func listUnityProcessesWindows(ctx context.Context) ([]UnityProcess, error) { + scriptLines := []string{ + "$ErrorActionPreference = 'Stop'", + "$processes = Get-CimInstance Win32_Process -Filter \"Name = 'Unity.exe'\" | Where-Object { $_.CommandLine }", + "foreach ($process in $processes) {", + " $commandLine = $process.CommandLine -replace \"`r\", ' ' -replace \"`n\", ' '", + " Write-Output (\"{0}|{1}\" -f $process.ProcessId, $commandLine)", + "}", + } + output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", strings.Join(scriptLines, "\n")).Output() + if err != nil { + return nil, fmt.Errorf("failed to retrieve Unity process list on Windows: %w", err) + } + return parseWindowsUnityProcesses(string(output)), nil +} + +func parseMacUnityProcesses(output string) []UnityProcess { + processes := []UnityProcess{} + for _, line := range strings.Split(output, "\n") { + matches := macProcessLinePattern.FindStringSubmatch(line) + if len(matches) != 3 { + continue + } + + pid, err := strconv.Atoi(matches[1]) + if err != nil { + continue + } + + command := matches[2] + if !isUnityEditorCommand(command, macUnityExecutablePattern) { + continue + } + projectPath := extractProjectPath(command) + if projectPath == "" { + continue + } + + processes = append(processes, UnityProcess{Pid: pid, projectPath: projectPath}) + } + return processes +} + +func parseWindowsUnityProcesses(output string) []UnityProcess { + processes := []UnityProcess{} + for _, line := range strings.Split(output, "\n") { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + + delimiterIndex := strings.Index(trimmed, "|") + if delimiterIndex < 0 { + continue + } + + pid, err := strconv.Atoi(strings.TrimSpace(trimmed[:delimiterIndex])) + if err != nil { + continue + } + + command := strings.TrimSpace(trimmed[delimiterIndex+1:]) + if !isUnityEditorCommand(command, windowsUnityExecutablePattern) { + continue + } + projectPath := extractProjectPath(command) + if projectPath == "" { + continue + } + + processes = append(processes, UnityProcess{Pid: pid, projectPath: projectPath}) + } + return processes +} + +func isUnityEditorCommand(command string, executablePattern *regexp.Regexp) bool { + lowerCommand := strings.ToLower(command) + if strings.Contains(lowerCommand, "-batchmode") || strings.Contains(lowerCommand, "assetimportworker") { + return false + } + return executablePattern.MatchString(command) +} + +func extractProjectPath(command string) string { + matches := projectPathFlagPattern.FindStringSubmatch(command) + if len(matches) != 2 { + return "" + } + + value := strings.TrimSpace(matches[1]) + if value == "" { + return "" + } + + if strings.HasPrefix(value, `"`) || strings.HasPrefix(value, `'`) { + return extractQuotedProjectPath(value) + } + + nextFlagIndex := nextUnityFlagPattern.FindStringIndex(value) + if nextFlagIndex != nil { + value = strings.TrimSpace(value[:nextFlagIndex[0]]) + } + return strings.Trim(value, `"'`) +} + +func extractQuotedProjectPath(value string) string { + quote := value[0] + endIndex := strings.IndexByte(value[1:], quote) + if endIndex < 0 { + return "" + } + return value[1 : endIndex+1] +} + +func normalizeComparablePath(path string) (string, error) { + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", err + } + return strings.ToLower(filepath.ToSlash(filepath.Clean(absolutePath))), nil +} diff --git a/cli/common/unityprocess/process_test.go b/cli/common/unityprocess/process_test.go new file mode 100644 index 0000000000..7bfcbc46ee --- /dev/null +++ b/cli/common/unityprocess/process_test.go @@ -0,0 +1,151 @@ +package unityprocess + +import ( + "strings" + "testing" +) + +// Verifies macOS Unity process parsing extracts project paths and skips batchmode workers. +func TestParseMacUnityProcessesExtractsProjectPath(t *testing.T) { + output := `123 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath "/Users//My Project" -useHub -hubIPC +456 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -batchmode -projectPath "/Users//Batch" +789 /Applications/Unity/Hub/Editor/6000.0.0f1/Unity.app/Contents/MacOS/Unity -projectPath /Users//Other -logFile - +` + + processes := parseMacUnityProcesses(output) + + if len(processes) != 2 { + t.Fatalf("process count mismatch: %#v", processes) + } + if processes[0].Pid != 123 || processes[0].projectPath != "/Users//My Project" { + t.Fatalf("first process mismatch: %#v", processes[0]) + } + if processes[1].Pid != 789 || processes[1].projectPath != "/Users//Other" { + t.Fatalf("second process mismatch: %#v", processes[1]) + } +} + +// Verifies Windows Unity process parsing extracts project paths and skips batchmode workers. +func TestParseWindowsUnityProcessesExtractsProjectPath(t *testing.T) { + output := `123|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -projectPath "C:\Users\\My Project" -useHub +456|C:\Program Files\Unity\Hub\Editor\6000.0.0f1\Editor\Unity.exe -batchmode -projectPath "C:\Users\\Batch" +` + + processes := parseWindowsUnityProcesses(output) + + if len(processes) != 1 { + t.Fatalf("process count mismatch: %#v", processes) + } + if processes[0].Pid != 123 || processes[0].projectPath != `C:\Users\\My Project` { + t.Fatalf("process mismatch: %#v", processes[0]) + } +} + +// Verifies Unity -projectPath extraction supports quoted, unquoted, and equals forms. +func TestExtractProjectPathSupportsEqualsAndSpaces(t *testing.T) { + cases := map[string]string{ + `Unity -projectPath="/Users//My Project" -useHub`: "/Users//My Project", + `Unity -projectpath '/Users//Other Project' -flag`: "/Users//Other Project", + `Unity -projectPath /Users//Plain -flag`: "/Users//Plain", + `Unity Hub -- --silent -- -projectPath /Users//SampleWorkspace/SampleUnityProject -cacheServerEnableUpload`: "/Users//SampleWorkspace/SampleUnityProject", + `Unity -projectPath /Users//SampleWorkspace/SampleUnityProject -acceptSoftwareTermsForThisRunOnly -useHub`: "/Users//SampleWorkspace/SampleUnityProject", + `Unity -projectPath /Users//SampleWorkspace/SampleUnityProject -cacheServerEnableDownload=false -useHub`: "/Users//SampleWorkspace/SampleUnityProject", + `Unity -projectPath /Users//SampleWorkspace/SampleUnityProject -hubSessionId 715810a5-220d-411e-a7d2-28cf46f`: "/Users//SampleWorkspace/SampleUnityProject", + } + + for command, expected := range cases { + actual := extractProjectPath(command) + if actual != expected { + t.Fatalf("project path mismatch for %q: %q", command, actual) + } + } +} + +// Verifies the embedded Windows focus script throws instead of silently returning on failures. +func TestBuildFocusUnityProcessWindowsScriptThrowsOnFailures(t *testing.T) { + script := buildFocusUnityProcessWindowsScript(123) + + for _, expected := range []string{ + "throw 'Unity process was not found: 123'", + "throw 'Unity process has no main window handle: 123'", + "throw 'Failed to show Unity window'", + "$focused = $shell.AppActivate(123)", + "throw 'Failed to focus Unity window'", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } + if strings.Contains(script, "catch { return }") || strings.Contains(script, "{ return }") { + t.Fatalf("script should not silently return: %s", script) + } +} + +// Verifies the embedded Windows focus-with-restore script captures the previous foreground window. +func TestBuildFocusUnityProcessWindowsWithRestoreScriptCapturesForegroundWindow(t *testing.T) { + script := buildFocusUnityProcessWindowsWithRestoreScript(123) + + for _, expected := range []string{ + "GetForegroundWindow", + "$previous = [Win32Interop]::GetForegroundWindow()", + "Write-Output $previous.ToInt64()", + "$focused = $shell.AppActivate(123)", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } +} + +// Verifies the embedded Windows restore script fails when the saved foreground window cannot be restored. +func TestBuildRestoreWindowsForegroundWindowScriptThrowsOnRestoreFailure(t *testing.T) { + script := buildRestoreWindowsForegroundWindowScript(123) + + for _, expected := range []string{ + "$handle = [IntPtr]::new(123)", + "if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' }", + "GetWindowThreadProcessId", + "GetCurrentThreadId", + "AttachThreadInput", + "BringWindowToTop", + "try {", + "} finally {", + "AttachThreadInput($foregroundThreadId, $targetThreadId, $false)", + "AttachThreadInput($currentThreadId, $targetThreadId, $false)", + "$restored = [Win32Interop]::SetForegroundWindow($handle)", + "if (-not $restored) { throw 'Failed to restore previous foreground window' }", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } +} + +// Verifies the embedded Windows restore script avoids resizing a saved maximized foreground window. +func TestBuildRestoreWindowsForegroundWindowScriptRestoresOnlyMinimizedWindow(t *testing.T) { + script := buildRestoreWindowsForegroundWindowScript(123) + + for _, expected := range []string{ + "IsIconic", + "$isMinimized = [Win32Interop]::IsIconic($handle)", + "if ($isMinimized) {", + " $shown = [Win32Interop]::ShowWindowAsync($handle, 9)", + } { + if !strings.Contains(script, expected) { + t.Fatalf("script missing %q: %s", expected, script) + } + } + if strings.Contains(script, "\n $shown = [Win32Interop]::ShowWindowAsync($handle, 9)") { + t.Fatalf("restore script should not unconditionally restore the previous window: %s", script) + } +} + +// Verifies Windows foreground handle parsing ignores invalid saved state output. +func TestParseWindowsForegroundHandle(t *testing.T) { + if parseWindowsForegroundHandle("123\r\n") != 123 { + t.Fatal("expected numeric handle to parse") + } + if parseWindowsForegroundHandle("not-a-handle") != 0 { + t.Fatal("expected invalid handle to be ignored") + } +} diff --git a/cli/common/unityprocess/restore_windows_foreground_window.ps1 b/cli/common/unityprocess/restore_windows_foreground_window.ps1 new file mode 100644 index 0000000000..eb32735d95 --- /dev/null +++ b/cli/common/unityprocess/restore_windows_foreground_window.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = 'Stop' +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public static class Win32Interop { + [DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow(); + [DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool ShowWindowAsync(IntPtr hWnd, int nCmdShow); + [DllImport("user32.dll")] public static extern bool BringWindowToTop(IntPtr hWnd); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, IntPtr lpdwProcessId); + [DllImport("kernel32.dll")] public static extern uint GetCurrentThreadId(); + [DllImport("user32.dll")] public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach); + [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr hWnd); +} +"@ +$handle = [IntPtr]::new({{HANDLE}}) +if ($handle -eq [IntPtr]::Zero) { throw 'Saved foreground window handle is invalid' } +$targetThreadId = [Win32Interop]::GetWindowThreadProcessId($handle, [IntPtr]::Zero) +if ($targetThreadId -eq 0) { throw 'Saved foreground window thread is invalid' } +$foreground = [Win32Interop]::GetForegroundWindow() +$foregroundThreadId = [Win32Interop]::GetWindowThreadProcessId($foreground, [IntPtr]::Zero) +$currentThreadId = [Win32Interop]::GetCurrentThreadId() +$attachedCurrent = $false +$attachedForeground = $false +try { + if ($targetThreadId -ne $currentThreadId) { + $attachedCurrent = [Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $true) + } + if ($foregroundThreadId -ne 0 -and $foregroundThreadId -ne $targetThreadId) { + $attachedForeground = [Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $true) + } + $isMinimized = [Win32Interop]::IsIconic($handle) + if ($isMinimized) { + $shown = [Win32Interop]::ShowWindowAsync($handle, 9) + if (-not $shown) { throw 'Failed to show previous foreground window' } + } + [void][Win32Interop]::BringWindowToTop($handle) + $restored = [Win32Interop]::SetForegroundWindow($handle) +} finally { + if ($attachedForeground) { [void][Win32Interop]::AttachThreadInput($foregroundThreadId, $targetThreadId, $false) } + if ($attachedCurrent) { [void][Win32Interop]::AttachThreadInput($currentThreadId, $targetThreadId, $false) } +} +if (-not $restored) { throw 'Failed to restore previous foreground window' } diff --git a/cli/dispatcher/internal/dispatcher/launch.go b/cli/dispatcher/internal/dispatcher/launch.go index c69ff1a314..1d43c715d5 100644 --- a/cli/dispatcher/internal/dispatcher/launch.go +++ b/cli/dispatcher/internal/dispatcher/launch.go @@ -15,6 +15,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/project" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) const ( @@ -149,7 +150,7 @@ func findLaunchRunningProcess( stdout io.Writer, stderr io.Writer, deps launchDeps, -) (*clicore.UnityProcess, bool, int) { +) (*unityprocess.UnityProcess, bool, int) { runningProcess, err := deps.findRunningUnityProcess(ctx, projectRoot) if err == nil { return runningProcess, false, 0 @@ -168,7 +169,7 @@ func handleExistingLaunchProcess( ctx context.Context, options launchOptions, projectRoot string, - runningProcess *clicore.UnityProcess, + runningProcess *unityprocess.UnityProcess, stdout io.Writer, stderr io.Writer, deps launchDeps, @@ -223,7 +224,7 @@ func startUnityAndWaitForReadiness( ctx context.Context, options launchOptions, projectRoot string, - runningProcess *clicore.UnityProcess, + runningProcess *unityprocess.UnityProcess, stdout io.Writer, stderr io.Writer, deps launchDeps, diff --git a/cli/dispatcher/internal/dispatcher/launch_deps.go b/cli/dispatcher/internal/dispatcher/launch_deps.go index e9c5d6dd81..42a0da6ed1 100644 --- a/cli/dispatcher/internal/dispatcher/launch_deps.go +++ b/cli/dispatcher/internal/dispatcher/launch_deps.go @@ -5,10 +5,11 @@ import ( "time" "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) type launchDeps struct { - findRunningUnityProcess func(context.Context, string) (*clicore.UnityProcess, error) + findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error) focusUnityProcess func(context.Context, int) error killUnityProcess func(int) error resolveUnityExecutablePath func(string) (string, error) @@ -20,8 +21,8 @@ type launchDeps struct { func defaultLaunchDeps() launchDeps { return launchDeps{ - findRunningUnityProcess: clicore.FindRunningUnityProcess, - focusUnityProcess: clicore.FocusUnityProcess, + findRunningUnityProcess: unityprocess.FindRunningUnityProcess, + focusUnityProcess: unityprocess.FocusUnityProcess, killUnityProcess: killUnityProcess, resolveUnityExecutablePath: resolveUnityExecutablePath, waitForUnityProcessExit: waitForUnityProcessExit, diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 6767c332f2..d0527af9a0 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "4ffd3b6d8156471cab11096b0f52f3696937bdba" + "sharedInputsHash": "3ffc7a8c10ff0b5e13116a7f2576670389527199" } diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index 755a24a649..60b23c1143 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -10,6 +10,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) const ( @@ -19,16 +20,16 @@ const ( ) type connectionRetryDeps struct { - findRunningUnityProcess func(context.Context, string) (*clicore.UnityProcess, error) - focusUnityProcess func(context.Context, int) (clicore.RestoreFocusFunc, error) + findRunningUnityProcess func(context.Context, string) (*unityprocess.UnityProcess, error) + focusUnityProcess func(context.Context, int) (unityprocess.RestoreFocusFunc, error) retryTimeout time.Duration retryPoll time.Duration } func defaultConnectionRetryDeps() connectionRetryDeps { return connectionRetryDeps{ - findRunningUnityProcess: clicore.FindRunningUnityProcess, - focusUnityProcess: clicore.FocusUnityProcessWithRestore, + findRunningUnityProcess: unityprocess.FindRunningUnityProcess, + focusUnityProcess: unityprocess.FocusUnityProcessWithRestore, retryTimeout: serverConnectionRetryDefaultTimeout, retryPoll: serverConnectionRetryDefaultPoll, } @@ -61,7 +62,7 @@ type connectionRetryFocusController struct { method string deps connectionRetryDeps attempted bool - restoreFocus clicore.RestoreFocusFunc + restoreFocus unityprocess.RestoreFocusFunc } func newConnectionRetryFocusController(connection unityipc.Connection, method string, deps connectionRetryDeps) *connectionRetryFocusController { diff --git a/cli/project-runner/internal/projectrunner/connection_retry_flow.go b/cli/project-runner/internal/projectrunner/connection_retry_flow.go index bee154b460..2de01922b1 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_flow.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_flow.go @@ -6,6 +6,7 @@ import ( "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/unityprocess" ) func newConnectionRetryClient( @@ -81,7 +82,7 @@ func finishUndispatchedRetryProbe( outcome unityipc.UnitySendOutcome, err error, processErr error, - runningProcess *clicore.UnityProcess, + runningProcess *unityprocess.UnityProcess, lastOutcome unityipc.UnitySendOutcome, lastErr error, ) (bool, unityipc.UnitySendOutcome, error) { diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 2f71facd18..587c8bb61b 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "552a3bbd8757b2357b7fca535453fdf2e416577c" + "sharedInputsHash": "43724cc079cfddb1a27f326ef31bfd7be980787e" } diff --git a/cli/release-automation/internal/automation/release_trigger_guard.go b/cli/release-automation/internal/automation/release_trigger_guard.go index 163f9a6f37..06bc38b7a0 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard.go +++ b/cli/release-automation/internal/automation/release_trigger_guard.go @@ -157,6 +157,7 @@ var sharedCommonPackageRoots = []string{ "cli/common/skills/", "cli/common/tools/", "cli/common/unityipc/", + "cli/common/unityprocess/", } var dispatcherOnlyCommonPackageRoots = []string{ @@ -182,8 +183,8 @@ func isCommonGoSourceUnderPackageRoots(file string, packageRoots []string) bool } // JSON files under common (contract.json, default-tools.json) are // release-please stamp targets rather than binary inputs, and test files - // never ship, so only non-test Go sources count as release inputs. - if !strings.HasSuffix(file, ".go") || strings.HasSuffix(file, "_test.go") { + // never ship, so only code and embedded runtime scripts count as release inputs. + if strings.HasSuffix(file, "_test.go") || (!strings.HasSuffix(file, ".go") && !strings.HasSuffix(file, ".ps1")) { return false } for _, packageRoot := range packageRoots { diff --git a/cli/release-automation/internal/automation/release_trigger_guard_test.go b/cli/release-automation/internal/automation/release_trigger_guard_test.go index 240c3ffb1f..203762e629 100644 --- a/cli/release-automation/internal/automation/release_trigger_guard_test.go +++ b/cli/release-automation/internal/automation/release_trigger_guard_test.go @@ -44,6 +44,28 @@ func TestReleaseTriggerGuardRequiresBothTriggersForSharedCommonChanges(t *testin } } +// Verifies embedded shared common scripts require triggers in both release package roots. +func TestReleaseTriggerGuardRequiresBothTriggersForSharedEmbeddedScripts(t *testing.T) { + result := AnalyzeReleaseTriggerGuard([]string{"cli/common/unityprocess/focus_unity_process.ps1"}) + + if len(result.Violations) != 1 { + t.Fatalf("expected one violation, got %v", result.Violations) + } + violation := result.Violations[0] + if len(violation.ChangedInputs) != 1 || violation.ChangedInputs[0] != "cli/common/unityprocess/focus_unity_process.ps1" { + t.Fatalf("expected the changed embedded script to be listed, got %v", violation.ChangedInputs) + } + expectedRoots := []string{"cli/dispatcher/", "cli/project-runner/"} + if len(violation.MissingTriggerRoots) != len(expectedRoots) { + t.Fatalf("expected missing roots %v, got %v", expectedRoots, violation.MissingTriggerRoots) + } + for index, expectedRoot := range expectedRoots { + if violation.MissingTriggerRoots[index] != expectedRoot { + t.Fatalf("expected missing roots %v, got %v", expectedRoots, violation.MissingTriggerRoots) + } + } +} + // Verifies a partial trigger for shared common changes still fails for the missing package root. func TestReleaseTriggerGuardDetectsMissingDispatcherTrigger(t *testing.T) { result := AnalyzeReleaseTriggerGuard([]string{ diff --git a/scripts/stamp-release-inputs.sh b/scripts/stamp-release-inputs.sh index a466097a25..8bfd9f9ace 100755 --- a/scripts/stamp-release-inputs.sh +++ b/scripts/stamp-release-inputs.sh @@ -23,8 +23,9 @@ list_shared_common_inputs() { 'cli/common/project/' \ 'cli/common/skills/' \ 'cli/common/tools/' \ - 'cli/common/unityipc/' | - grep -E '\.go$|/go\.mod$|/go\.sum$' | + 'cli/common/unityipc/' \ + 'cli/common/unityprocess/' | + grep -E '\.go$|\.ps1$|/go\.mod$|/go\.sum$' | grep -v '_test\.go$' || true } From 2ddcb5cd624be564e7c4e5106e6e1d89b428dc54 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 4 Jul 2026 11:15:10 +0900 Subject: [PATCH 2/3] Surface Windows focus script stderr --- cli/common/unityprocess/command_error.go | 17 ++++++++++++ cli/common/unityprocess/command_error_test.go | 27 +++++++++++++++++++ cli/common/unityprocess/focus_windows.go | 24 ++++++++++++++--- cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 5 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 cli/common/unityprocess/command_error.go create mode 100644 cli/common/unityprocess/command_error_test.go diff --git a/cli/common/unityprocess/command_error.go b/cli/common/unityprocess/command_error.go new file mode 100644 index 0000000000..c259dfa36d --- /dev/null +++ b/cli/common/unityprocess/command_error.go @@ -0,0 +1,17 @@ +package unityprocess + +import ( + "fmt" + "strings" +) + +func commandErrorWithStderr(err error, stderr string) error { + if err == nil { + return nil + } + trimmedStderr := strings.TrimSpace(stderr) + if trimmedStderr == "" { + return err + } + return fmt.Errorf("%w: %s", err, trimmedStderr) +} diff --git a/cli/common/unityprocess/command_error_test.go b/cli/common/unityprocess/command_error_test.go new file mode 100644 index 0000000000..3f1c7c3b7c --- /dev/null +++ b/cli/common/unityprocess/command_error_test.go @@ -0,0 +1,27 @@ +package unityprocess + +import ( + "errors" + "strings" + "testing" +) + +// Verifies command stderr is included in the returned error message. +func TestCommandErrorWithStderrAppendsTrimmedStderr(t *testing.T) { + err := commandErrorWithStderr(errors.New("exit status 1"), " PowerShell throw text\r\n") + + if err == nil || !strings.Contains(err.Error(), "PowerShell throw text") { + t.Fatalf("expected stderr in error, got %v", err) + } +} + +// Verifies empty command stderr leaves the original error message intact. +func TestCommandErrorWithStderrKeepsOriginalErrorWithoutStderr(t *testing.T) { + err := errors.New("exit status 1") + + actual := commandErrorWithStderr(err, " \r\n") + + if actual != err { + t.Fatalf("expected original error, got %v", actual) + } +} diff --git a/cli/common/unityprocess/focus_windows.go b/cli/common/unityprocess/focus_windows.go index 2507688d3a..53fc89744b 100644 --- a/cli/common/unityprocess/focus_windows.go +++ b/cli/common/unityprocess/focus_windows.go @@ -3,20 +3,30 @@ package unityprocess import ( + "bytes" "context" "os/exec" ) func FocusUnityProcess(ctx context.Context, pid int) error { script := buildFocusUnityProcessWindowsScript(pid) - return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() + stderr := bytes.Buffer{} + command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command.Stderr = &stderr + if err := command.Run(); err != nil { + return commandErrorWithStderr(err, stderr.String()) + } + return nil } func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFunc, error) { script := buildFocusUnityProcessWindowsWithRestoreScript(pid) - output, err := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Output() + stderr := bytes.Buffer{} + command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command.Stderr = &stderr + output, err := command.Output() if err != nil { - return nil, err + return nil, commandErrorWithStderr(err, stderr.String()) } previousHandle := parseWindowsForegroundHandle(string(output)) if previousHandle == 0 { @@ -29,5 +39,11 @@ func FocusUnityProcessWithRestore(ctx context.Context, pid int) (RestoreFocusFun func restoreWindowsForegroundWindow(ctx context.Context, handle int64) error { script := buildRestoreWindowsForegroundWindowScript(handle) - return exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script).Run() + stderr := bytes.Buffer{} + command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script) + command.Stderr = &stderr + if err := command.Run(); err != nil { + return commandErrorWithStderr(err, stderr.String()) + } + return nil } diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index d0527af9a0..340cccd46b 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "3ffc7a8c10ff0b5e13116a7f2576670389527199" + "sharedInputsHash": "51a5426ed8950f581f0ded099a6f7e5a7a2d965e" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index 587c8bb61b..c948c73f6b 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "43724cc079cfddb1a27f326ef31bfd7be980787e" + "sharedInputsHash": "b522101f5a03ae9ddb5033e44509e8dc70ecaa61" } From f2303db4261c1412d6ac012d5aeff71103bd4989 Mon Sep 17 00:00:00 2001 From: hatayama Date: Sat, 4 Jul 2026 11:20:58 +0900 Subject: [PATCH 3/3] Preserve Unity project path case outside Windows --- cli/common/unityprocess/process.go | 6 +++++- cli/common/unityprocess/process_test.go | 18 ++++++++++++++++++ cli/dispatcher/shared-inputs-stamp.json | 2 +- cli/project-runner/shared-inputs-stamp.json | 2 +- 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/cli/common/unityprocess/process.go b/cli/common/unityprocess/process.go index edcd2b208c..99b167bcb5 100644 --- a/cli/common/unityprocess/process.go +++ b/cli/common/unityprocess/process.go @@ -190,5 +190,9 @@ func normalizeComparablePath(path string) (string, error) { if err != nil { return "", err } - return strings.ToLower(filepath.ToSlash(filepath.Clean(absolutePath))), nil + normalizedPath := filepath.ToSlash(filepath.Clean(absolutePath)) + if runtime.GOOS == "windows" { + return strings.ToLower(normalizedPath), nil + } + return normalizedPath, nil } diff --git a/cli/common/unityprocess/process_test.go b/cli/common/unityprocess/process_test.go index 7bfcbc46ee..f29976f524 100644 --- a/cli/common/unityprocess/process_test.go +++ b/cli/common/unityprocess/process_test.go @@ -1,6 +1,8 @@ package unityprocess import ( + "path/filepath" + "runtime" "strings" "testing" ) @@ -149,3 +151,19 @@ func TestParseWindowsForegroundHandle(t *testing.T) { t.Fatal("expected invalid handle to be ignored") } } + +// Verifies comparable project paths preserve case on case-sensitive-capable platforms. +func TestNormalizeComparablePathPreservesCaseOutsideWindows(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows project matching is intentionally case-insensitive.") + } + + path := filepath.Join(t.TempDir(), "CaseSensitiveProject") + normalizedPath, err := normalizeComparablePath(path) + if err != nil { + t.Fatalf("normalizeComparablePath failed: %v", err) + } + if !strings.Contains(normalizedPath, "CaseSensitiveProject") { + t.Fatalf("expected normalized path to preserve case, got %q", normalizedPath) + } +} diff --git a/cli/dispatcher/shared-inputs-stamp.json b/cli/dispatcher/shared-inputs-stamp.json index 340cccd46b..a2b00405fe 100644 --- a/cli/dispatcher/shared-inputs-stamp.json +++ b/cli/dispatcher/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "51a5426ed8950f581f0ded099a6f7e5a7a2d965e" + "sharedInputsHash": "06b6f9bf6cbdd8b02f1213905048a3eb6e4e5ef7" } diff --git a/cli/project-runner/shared-inputs-stamp.json b/cli/project-runner/shared-inputs-stamp.json index c948c73f6b..1aeb3ab5ac 100644 --- a/cli/project-runner/shared-inputs-stamp.json +++ b/cli/project-runner/shared-inputs-stamp.json @@ -1,4 +1,4 @@ { "schemaVersion": 1, - "sharedInputsHash": "b522101f5a03ae9ddb5033e44509e8dc70ecaa61" + "sharedInputsHash": "4d69c805f009f427144ecab4a78119d537e99c97" }