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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
405 changes: 19 additions & 386 deletions cli/common/clicore/focus.go

Large diffs are not rendered by default.

141 changes: 0 additions & 141 deletions cli/common/clicore/focus_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 3 additions & 2 deletions cli/common/clicore/tool_readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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
}

Expand All @@ -37,7 +38,7 @@ func WaitForToolReadinessWithTimeout(ctx context.Context, projectRoot string, ti

func defaultToolReadinessDeps() toolReadinessDeps {
return toolReadinessDeps{
findRunningUnityProcess: FindRunningUnityProcess,
findRunningUnityProcess: unityprocess.FindRunningUnityProcess,
probeToolReadinessSequence: ProbeToolReadinessSequence,
}
}
Expand Down
17 changes: 17 additions & 0 deletions cli/common/unityprocess/command_error.go
Original file line number Diff line number Diff line change
@@ -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)
}
27 changes: 27 additions & 0 deletions cli/common/unityprocess/command_error_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
45 changes: 45 additions & 0 deletions cli/common/unityprocess/focus_darwin.go
Original file line number Diff line number Diff line change
@@ -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()
}
20 changes: 20 additions & 0 deletions cli/common/unityprocess/focus_unity_process.ps1
Original file line number Diff line number Diff line change
@@ -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' }
23 changes: 23 additions & 0 deletions cli/common/unityprocess/focus_unity_process_with_restore.ps1
Original file line number Diff line number Diff line change
@@ -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()
17 changes: 17 additions & 0 deletions cli/common/unityprocess/focus_unsupported.go
Original file line number Diff line number Diff line change
@@ -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)
}
49 changes: 49 additions & 0 deletions cli/common/unityprocess/focus_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
//go:build windows

package unityprocess

import (
"bytes"
"context"
"os/exec"
)

func FocusUnityProcess(ctx context.Context, pid int) error {
script := buildFocusUnityProcessWindowsScript(pid)
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)
stderr := bytes.Buffer{}
command := exec.CommandContext(ctx, windowsPowerShellCommand, "-NoProfile", "-Command", script)
command.Stderr = &stderr
output, err := command.Output()
if err != nil {
return nil, commandErrorWithStderr(err, stderr.String())
}
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)
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
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Loading
Loading