From 7ec89c497d5b38ae2a070daec68e97f41d190443 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 21:18:08 +0900 Subject: [PATCH 1/5] Make the auto-tick pump always-on instead of request-scoped The scoped pump (in-flight request plus a 10s trailing window) let an unfocused editor go fully idle once the window expired; macOS then stopped scheduling the process, so the next IPC request could not even be accepted (pre_accept_timeout) and the CLI had to grab OS-level focus to wake Unity. Keep the 16ms SignalTick pump running for the whole editor session instead, matching com.unity.pipeline's AutoTickCommand semantics, so the process never gets parked. - Remove BeginScope/AutoTickPumpController and their tests; the pump no longer needs scope tracking or TRAILING_WINDOW_SECONDS - Drop the BeginScope wrapper in JsonRpcRequestProcessor --- .../Editor/AutoTickPumpControllerTests.cs | 95 --------------- .../AutoTickPumpControllerTests.cs.meta | 11 -- .../Api/JsonRpcRequestProcessor.cs | 109 +++++++++--------- .../Threading/AutoTickPumpConstants.cs | 7 +- .../Threading/AutoTickPumpController.cs | 70 ----------- .../Threading/AutoTickPumpController.cs.meta | 11 -- .../Threading/AutoTickPumpService.cs | 64 +++------- 7 files changed, 66 insertions(+), 301 deletions(-) delete mode 100644 Assets/Tests/Editor/AutoTickPumpControllerTests.cs delete mode 100644 Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta delete mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs delete mode 100644 Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta diff --git a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs deleted file mode 100644 index 8ea0a75156..0000000000 --- a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs +++ /dev/null @@ -1,95 +0,0 @@ -using NUnit.Framework; - -using io.github.hatayama.UnityCliLoop.Infrastructure; - -namespace io.github.hatayama.UnityCliLoop.Tests.Editor -{ - public sealed class AutoTickPumpControllerTests - { - private const double TrailingWindowSeconds = 10.0; - - /// - /// Verifies that a freshly constructed controller does not request pumping. - /// - [Test] - public void ShouldPump_WhenInitial_ReturnsFalse() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - Assert.That(controller.ShouldPump(0.0), Is.False); - Assert.That(controller.ShouldPump(100.0), Is.False); - } - - /// - /// Verifies that an open scope keeps ShouldPump true regardless of elapsed time. - /// - [Test] - public void ShouldPump_AfterScopeStarted_ReturnsTrue() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - controller.NotifyScopeStarted(); - - Assert.That(controller.ShouldPump(0.0), Is.True); - Assert.That(controller.ShouldPump(1000.0), Is.True); - } - - /// - /// Verifies that nested scopes use reference counting and stay active until the last ends. - /// - [Test] - public void ShouldPump_WhenNestedScopesPartiallyEnded_ReturnsTrue() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - controller.NotifyScopeStarted(); - controller.NotifyScopeStarted(); - controller.NotifyScopeEnded(1.0); - - Assert.That(controller.ShouldPump(1.0), Is.True); - Assert.That(controller.ShouldPump(100.0), Is.True); - } - - /// - /// Verifies that the trailing window keeps pumping shortly after the last scope ends. - /// - [Test] - public void ShouldPump_WithinTrailingWindowAfterLastScopeEnded_ReturnsTrue() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - controller.NotifyScopeStarted(); - controller.NotifyScopeEnded(5.0); - - Assert.That(controller.ShouldPump(5.0 + 9.9), Is.True); - } - - /// - /// Verifies that the trailing window is exclusive at the boundary (elapsed == window => false). - /// - [Test] - public void ShouldPump_AtTrailingWindowBoundaryAfterLastScopeEnded_ReturnsFalse() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - controller.NotifyScopeStarted(); - controller.NotifyScopeEnded(5.0); - - Assert.That(controller.ShouldPump(5.0 + TrailingWindowSeconds), Is.False); - } - - /// - /// Verifies that startup completion opens a trailing window that later expires. - /// - [Test] - public void ShouldPump_AfterStartupCompleted_FollowsTrailingWindow() - { - AutoTickPumpController controller = new AutoTickPumpController(TrailingWindowSeconds); - - controller.NotifyStartupCompleted(2.0); - - Assert.That(controller.ShouldPump(2.0 + 9.9), Is.True); - Assert.That(controller.ShouldPump(2.0 + TrailingWindowSeconds), Is.False); - } - } -} diff --git a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta b/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta deleted file mode 100644 index c21f09dc70..0000000000 --- a/Assets/Tests/Editor/AutoTickPumpControllerTests.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 9e102ca622de44e4d98fd7b03aa823a2 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs index 7eeb26abbe..fb50105b3e 100644 --- a/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs +++ b/Packages/src/Editor/Infrastructure/Api/JsonRpcRequestProcessor.cs @@ -132,68 +132,63 @@ private async Task ProcessRpcRequest( CancellationToken ct, JsonRpcEarlyResponseWriter earlyResponseWriter) { - // Why: keep an unfocused editor ticking for the request duration plus a trailing window - // so compile/test work continues in the background without an OS focus kick. - using (AutoTickPumpService.BeginScope()) + try { - try - { - ct.ThrowIfCancellationRequested(); - if (IsCliProtocolMismatch(request.ClientProtocolVersion)) - { - return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse( - request.Id, - request.ClientProjectRunnerVersion, - request.ClientProtocolVersion); - } - - if (request.AcceptsDispatchAck && earlyResponseWriter != null) - { - int heartbeatIntervalSeconds = request.AcceptsHeartbeat - ? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS - : 0; - Func createHeartbeatJson = request.AcceptsHeartbeat - ? () => JsonRpcResponseFactory.CreateHeartbeatResponse( - request.Id, - EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick()) - : null; - await earlyResponseWriter( - JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds), - ShouldCancelAcceptedRequestOnClientDisconnect(request), - createHeartbeatJson); - } - - Stopwatch requestStopwatch = Stopwatch.StartNew(); - - Stopwatch executeMethodStopwatch = Stopwatch.StartNew(); - UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct); - executeMethodStopwatch.Stop(); - - JsonRpcResponseFactory.AppendTimingIfRequested( - result, - $"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms"); - JsonRpcResponseFactory.AppendTimingIfRequested( - result, - $"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms"); - - string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result); - return response; - } - catch (JsonSerializationException ex) - { - UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}"); - return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); - } - catch (UnityCliLoopToolParameterValidationException ex) + ct.ThrowIfCancellationRequested(); + if (IsCliProtocolMismatch(request.ClientProtocolVersion)) { - LogUnityCliLoopToolParameterValidationException(ex); - return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); + return JsonRpcResponseFactory.CreateCliProtocolMismatchResponse( + request.Id, + request.ClientProjectRunnerVersion, + request.ClientProtocolVersion); } - catch (Exception ex) when (!(ex is OperationCanceledException)) + + if (request.AcceptsDispatchAck && earlyResponseWriter != null) { - LogRpcExceptionIfNeeded(ex); - return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); + int heartbeatIntervalSeconds = request.AcceptsHeartbeat + ? UnityCliLoopServerConfig.HEARTBEAT_INTERVAL_SECONDS + : 0; + Func createHeartbeatJson = request.AcceptsHeartbeat + ? () => JsonRpcResponseFactory.CreateHeartbeatResponse( + request.Id, + EditorMainThreadLivenessTracker.SecondsSinceLastMainThreadTick()) + : null; + await earlyResponseWriter( + JsonRpcResponseFactory.CreateDispatchAcceptedResponse(request.Id, heartbeatIntervalSeconds), + ShouldCancelAcceptedRequestOnClientDisconnect(request), + createHeartbeatJson); } + + Stopwatch requestStopwatch = Stopwatch.StartNew(); + + Stopwatch executeMethodStopwatch = Stopwatch.StartNew(); + UnityCliLoopToolResponse result = await ExecuteMethod(request.Method, request.Params, ct); + executeMethodStopwatch.Stop(); + + JsonRpcResponseFactory.AppendTimingIfRequested( + result, + $"[Perf] RpcExecuteMethod: {executeMethodStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + JsonRpcResponseFactory.AppendTimingIfRequested( + result, + $"[Perf] RpcBeforeSerializeTotal: {requestStopwatch.Elapsed.TotalMilliseconds:F1}ms"); + + string response = JsonRpcResponseFactory.CreateSuccessResponse(request.Id, result); + return response; + } + catch (JsonSerializationException ex) + { + UnityEngine.Debug.LogError($"[JsonRpcRequestProcessor] JSON serialization error: {ex.Message}\nStack trace: {ex.StackTrace}"); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); + } + catch (UnityCliLoopToolParameterValidationException ex) + { + LogUnityCliLoopToolParameterValidationException(ex); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + LogRpcExceptionIfNeeded(ex); + return JsonRpcResponseFactory.CreateErrorResponse(request.Id, ex); } } diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs index 208440616e..d612a42913 100644 --- a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs +++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpConstants.cs @@ -1,17 +1,12 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Timing constants for the scoped SignalTick pump that keeps the editor alive while unfocused. + /// Timing constants for the always-on SignalTick pump that keeps the editor alive while unfocused. /// internal static class AutoTickPumpConstants { // Why: ~60Hz matches a focused editor and com.unity.pipeline's AutoTickCommand default. // Smaller intervals waste CPU; larger ones slow frame-dependent compile/test progress. internal const int PUMP_INTERVAL_MS = 16; - - // Why: command teardown and back-to-back CLI polling leave brief idle gaps; without a - // trailing window the editor would re-throttle between requests and stall again. - // Domain-reload recovery also needs a short awake period after Infrastructure startup. - internal const double TRAILING_WINDOW_SECONDS = 10.0; } } diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs deleted file mode 100644 index 7b61179093..0000000000 --- a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs +++ /dev/null @@ -1,70 +0,0 @@ -using System.Diagnostics; - -namespace io.github.hatayama.UnityCliLoop.Infrastructure -{ - /// - /// Pure state machine that decides whether the editor SignalTick pump should keep running. - /// Why: scopes cover in-flight CLI work; a trailing window covers teardown and inter-command gaps - /// without leaving the pump on permanently (which would burn CPU while idle and unfocused). - /// - internal sealed class AutoTickPumpController - { - private readonly double _trailingWindowSeconds; - private readonly object _gate = new object(); - private int _activeScopeCount; - private double _lastActivitySeconds; - private bool _hasActivity; - - public AutoTickPumpController(double trailingWindowSeconds) - { - Debug.Assert(trailingWindowSeconds > 0, "trailingWindowSeconds must be greater than 0"); - _trailingWindowSeconds = trailingWindowSeconds; - } - - public void NotifyScopeStarted() - { - lock (_gate) - { - _activeScopeCount++; - } - } - - public void NotifyScopeEnded(double nowSeconds) - { - lock (_gate) - { - Debug.Assert(_activeScopeCount > 0, "NotifyScopeEnded called with no active scope"); - _activeScopeCount--; - _lastActivitySeconds = nowSeconds; - _hasActivity = true; - } - } - - public void NotifyStartupCompleted(double nowSeconds) - { - lock (_gate) - { - _lastActivitySeconds = nowSeconds; - _hasActivity = true; - } - } - - public bool ShouldPump(double nowSeconds) - { - lock (_gate) - { - if (_activeScopeCount > 0) - { - return true; - } - - if (!_hasActivity) - { - return false; - } - - return (nowSeconds - _lastActivitySeconds) < _trailingWindowSeconds; - } - } - } -} diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta deleted file mode 100644 index 6fea9ed844..0000000000 --- a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpController.cs.meta +++ /dev/null @@ -1,11 +0,0 @@ -fileFormatVersion: 2 -guid: 588e3ffcf8d5f452182ef1ba0b33ca84 -MonoImporter: - externalObjects: {} - serializedVersion: 2 - defaultReferences: [] - executionOrder: 0 - icon: {instanceID: 0} - userData: - assetBundleName: - assetBundleVariant: diff --git a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs index a9aa42aa06..6dcc30bd2a 100644 --- a/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs +++ b/Packages/src/Editor/Infrastructure/Threading/AutoTickPumpService.cs @@ -1,4 +1,3 @@ -using System; using System.Diagnostics; using UnityEditor; @@ -7,20 +6,20 @@ namespace io.github.hatayama.UnityCliLoop.Infrastructure { /// - /// Editor glue that pumps SignalTick while CLI work is in scope (plus a trailing window). - /// Why: a always-on full-rate tick would keep an unfocused editor as expensive as a focused one; - /// scoping to in-flight requests and a short trailing window restores normal throttling when idle. + /// Editor glue that keeps a SignalTick pump running for the whole editor session, + /// mirroring com.unity.pipeline's AutoTickCommand (unconditional 16ms pump). + /// Why always-on: the previous scoped pump (in-flight request + trailing window) let an + /// unfocused editor go fully idle after the window expired; macOS then stopped scheduling + /// the process, so the next IPC request could not even be accepted (pre_accept_timeout) + /// and the CLI had to grab OS-level focus to wake Unity. Continuous ticking keeps the + /// process from ever being parked, so requests are served without a focus kick. /// internal static class AutoTickPumpService { - private static AutoTickPumpController _controller; - private static Stopwatch _clock; private static Stopwatch _throttle; internal static void RegisterForEditorStartup() { - _controller = new AutoTickPumpController(AutoTickPumpConstants.TRAILING_WINDOW_SECONDS); - _clock = Stopwatch.StartNew(); // Why: leave unstarted so the first Pump after an external SignalTick is not throttled. // If that first tick were swallowed, an unfocused editor would never start the pump chain. _throttle = new Stopwatch(); @@ -32,37 +31,21 @@ internal static void RegisterForEditorStartup() EditorApplicationTickBridge.RemoveTickHandler(Pump); EditorApplicationTickBridge.AddTickHandler(Pump); - _controller.NotifyStartupCompleted(NowSeconds()); - // Why: after domain reload the editor may already be unfocused; reserve one tick so the - // trailing-window pump (and delayCall recovery) can start without an OS focus kick. + // Why: after domain reload the editor may already be unfocused and idle; one explicit + // tick starts the self-sustaining pump chain without an OS focus kick. EditorApplicationTickBridge.SignalTick(); } - internal static IDisposable BeginScope() - { - Debug.Assert(_controller != null, "AutoTickPumpService must be registered before BeginScope"); - _controller.NotifyScopeStarted(); - // Why: wake a sleeping unfocused editor as soon as a CLI command arrives (same one-shot - // wake pattern as EditorMainThreadDispatcher.AddContinuation). - EditorApplicationTickBridge.SignalTick(); - return new AutoTickScope(); - } - private static void Pump() { - if (_controller == null) + if (_throttle == null) { return; } - if (!_controller.ShouldPump(NowSeconds())) - { - return; - } - - // Why: !IsRunning covers the first tick after Register/BeginScope wake-ups. Swallowing - // that tick under the interval gate would leave an unfocused editor without a follow-up - // SignalTick, so the self-sustaining pump chain would never start. + // Why: !IsRunning covers the first tick after the Register wake-up. Swallowing + // that tick under the interval gate would leave an unfocused editor without a + // follow-up SignalTick, so the self-sustaining pump chain would never start. if (_throttle.IsRunning && _throttle.ElapsedMilliseconds < AutoTickPumpConstants.PUMP_INTERVAL_MS) { @@ -72,26 +55,5 @@ private static void Pump() _throttle.Restart(); EditorApplicationTickBridge.SignalTick(); } - - private static double NowSeconds() - { - return _clock.Elapsed.TotalSeconds; - } - - private sealed class AutoTickScope : IDisposable - { - private bool _disposed; - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _controller.NotifyScopeEnded(NowSeconds()); - } - } } } From f5b3f365a5ccfb063274f1b125157f1b2f03085c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 22:20:32 +0900 Subject: [PATCH 2/5] Stop launch skill from inviting use as a health check The launch skill description said "Use when Unity is not running or unresponsive", which agents read as "run launch after any failed command". In a real incident, one cancelled command (domain-reload startup protection) led an agent to run uloop launch on an already running Editor, which focused the Unity window and yanked it in front of the user. Narrow the description to "not running or stays frozen after retries", state that launch focuses the Unity window as a side effect, and add a "When not to use" section telling agents to retry a single failed, cancelled, or busy command instead of launching. The focus-on-running behavior itself is unchanged by design. Generated skill copies under .claude/ and .agents/ are regenerated; sync-tool-docs reports no catalog drift (launch is CLI-only and not in default-tools.json). --- .agents/skills/uloop-launch/SKILL.md | 9 ++++++++- .claude/skills/uloop-launch/SKILL.md | 9 ++++++++- Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md | 9 ++++++++- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/.agents/skills/uloop-launch/SKILL.md b/.agents/skills/uloop-launch/SKILL.md index 9ad55f311c..8d2f6096d5 100644 --- a/.agents/skills/uloop-launch/SKILL.md +++ b/.agents/skills/uloop-launch/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-launch -description: "Launch or restart Unity Editor. Use when Unity is not running or unresponsive." +description: "Launch or restart Unity Editor. Use only when Unity is not running or stays frozen after retries — not as a health check after a failed command; it brings the Unity window to the foreground as a side effect." --- # uloop launch @@ -46,6 +46,13 @@ The final JSON payload includes: - `ProjectRoot`: resolved project root - `Message`: readiness summary +## When not to use + +A single failed, cancelled, or busy command (e.g. right after a domain reload) is not a reason +to launch — retry the command instead. Running launch while the Editor is up brings the Unity +window to the foreground, which disrupts the user. Reach for launch only when Unity is not +running, or still does not respond after retries. + ## Notes - If Unity is already running, focuses the existing window and verifies tool readiness diff --git a/.claude/skills/uloop-launch/SKILL.md b/.claude/skills/uloop-launch/SKILL.md index 9ad55f311c..8d2f6096d5 100644 --- a/.claude/skills/uloop-launch/SKILL.md +++ b/.claude/skills/uloop-launch/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-launch -description: "Launch or restart Unity Editor. Use when Unity is not running or unresponsive." +description: "Launch or restart Unity Editor. Use only when Unity is not running or stays frozen after retries — not as a health check after a failed command; it brings the Unity window to the foreground as a side effect." --- # uloop launch @@ -46,6 +46,13 @@ The final JSON payload includes: - `ProjectRoot`: resolved project root - `Message`: readiness summary +## When not to use + +A single failed, cancelled, or busy command (e.g. right after a domain reload) is not a reason +to launch — retry the command instead. Running launch while the Editor is up brings the Unity +window to the foreground, which disrupts the user. Reach for launch only when Unity is not +running, or still does not respond after retries. + ## Notes - If Unity is already running, focuses the existing window and verifies tool readiness diff --git a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md index 9ad55f311c..8d2f6096d5 100644 --- a/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md @@ -1,6 +1,6 @@ --- name: uloop-launch -description: "Launch or restart Unity Editor. Use when Unity is not running or unresponsive." +description: "Launch or restart Unity Editor. Use only when Unity is not running or stays frozen after retries — not as a health check after a failed command; it brings the Unity window to the foreground as a side effect." --- # uloop launch @@ -46,6 +46,13 @@ The final JSON payload includes: - `ProjectRoot`: resolved project root - `Message`: readiness summary +## When not to use + +A single failed, cancelled, or busy command (e.g. right after a domain reload) is not a reason +to launch — retry the command instead. Running launch while the Editor is up brings the Unity +window to the foreground, which disrupts the user. Reach for launch only when Unity is not +running, or still does not respond after retries. + ## Notes - If Unity is already running, focuses the existing window and verifies tool readiness From 7bb2c77a9e85b53d102210b48cdb90a6ab97ff3c Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 22:21:37 +0900 Subject: [PATCH 3/5] Drop "works even when Unity is busy" note from focus-window skill The bullet read as "when Unity seems unresponsive, bring it to front", inviting the same focus-stealing reflex the previous commit removed from the launch skill. Remove the claim without a replacement; the frontmatter description already scopes the tool to visual checks and user-facing interaction. Generated skill copies under .claude/ and .agents/ are regenerated; sync-tool-docs reports no catalog drift (body-only change). --- .agents/skills/uloop-focus-window/SKILL.md | 1 - .claude/skills/uloop-focus-window/SKILL.md | 1 - Packages/src/Editor/CliOnlyTools~/FocusWindow/Skill/SKILL.md | 1 - 3 files changed, 3 deletions(-) diff --git a/.agents/skills/uloop-focus-window/SKILL.md b/.agents/skills/uloop-focus-window/SKILL.md index 31c2af8865..24569db8ca 100644 --- a/.agents/skills/uloop-focus-window/SKILL.md +++ b/.agents/skills/uloop-focus-window/SKILL.md @@ -22,5 +22,4 @@ Returns JSON with: ## Notes -- **Works even when Unity is busy** (compiling, domain reload, etc.) - Useful before `uloop screenshot` to ensure the target window is visible diff --git a/.claude/skills/uloop-focus-window/SKILL.md b/.claude/skills/uloop-focus-window/SKILL.md index 31c2af8865..24569db8ca 100644 --- a/.claude/skills/uloop-focus-window/SKILL.md +++ b/.claude/skills/uloop-focus-window/SKILL.md @@ -22,5 +22,4 @@ Returns JSON with: ## Notes -- **Works even when Unity is busy** (compiling, domain reload, etc.) - Useful before `uloop screenshot` to ensure the target window is visible diff --git a/Packages/src/Editor/CliOnlyTools~/FocusWindow/Skill/SKILL.md b/Packages/src/Editor/CliOnlyTools~/FocusWindow/Skill/SKILL.md index 31c2af8865..24569db8ca 100644 --- a/Packages/src/Editor/CliOnlyTools~/FocusWindow/Skill/SKILL.md +++ b/Packages/src/Editor/CliOnlyTools~/FocusWindow/Skill/SKILL.md @@ -22,5 +22,4 @@ Returns JSON with: ## Notes -- **Works even when Unity is busy** (compiling, domain reload, etc.) - Useful before `uloop screenshot` to ensure the target window is visible From 336c90b224fac45f2724af34fb9c703cea148f37 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 22:37:54 +0900 Subject: [PATCH 4/5] Log focus-restore outcomes in the connection-retry rescue The focus-grab recovery logged every focus attempt but nothing about the restore, so an investigation could not tell whether the restore ran and succeeded, ran and failed, was intentionally suppressed by keepUnityFocusedAfterReturn, or never had a restorer because the previous frontmost PID could not be read. - Add cli_connection_retry_focus_restore_success/_failed/_skipped/ _unavailable vibe log operations covering those four outcomes - Reuse the focus attempt's correlation ID (stored on the controller with the pid and triggering reason) so attempt and restore outcome can be joined in the log - Keep restore() silent when focus never happened, so ordinary commands write no extra log lines - Move the focus vibe-log writers to connection_retry_focus_log.go to keep connection_retry.go under the 500-line architecture cap --- .../projectrunner/connection_retry.go | 119 ++++------ .../connection_retry_focus_log.go | 167 +++++++++++++ .../projectrunner/connection_retry_test.go | 222 ++++++++++++++++++ 3 files changed, 435 insertions(+), 73 deletions(-) create mode 100644 cli/project-runner/internal/projectrunner/connection_retry_focus_log.go diff --git a/cli/project-runner/internal/projectrunner/connection_retry.go b/cli/project-runner/internal/projectrunner/connection_retry.go index bb09179d8e..46d94bc524 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry.go +++ b/cli/project-runner/internal/projectrunner/connection_retry.go @@ -11,7 +11,6 @@ import ( clierrors "github.com/hatayama/unity-cli-loop/common/errors" "github.com/hatayama/unity-cli-loop/common/vibelog" - "github.com/hatayama/unity-cli-loop/common/clicore" "github.com/hatayama/unity-cli-loop/common/unityipc" "github.com/hatayama/unity-cli-loop/common/unityprocess" ) @@ -76,6 +75,11 @@ type connectionRetryFocusController struct { deps connectionRetryDeps attempted bool restoreFocus unityprocess.RestoreFocusFunc + // Captured when the focus succeeds so restore-outcome logs can be joined to the + // attempt logs through the same correlation ID instead of standing alone. + focusCorrelationID string + focusedPid int + focusReason connectionRetryFocusReason } func newConnectionRetryFocusController(connection unityipc.Connection, method string, deps connectionRetryDeps) *connectionRetryFocusController { @@ -87,16 +91,47 @@ func newConnectionRetryFocusController(connection unityipc.Connection, method st } func (controller *connectionRetryFocusController) restore(ctx context.Context) { + // Silent by design when no restorer is stored: focus never happened (the common + // per-command case), the restore was intentionally skipped, or the missing + // restorer was already logged at focus time. if controller.restoreFocus == nil { return } - _ = controller.restoreFocus(ctx) + restoreErr := controller.restoreFocus(ctx) + if restoreErr != nil { + logConnectionRetryFocusRestoreFailure( + controller.connection, + controller.method, + controller.focusedPid, + controller.focusReason, + restoreErr, + controller.focusCorrelationID, + ) + return + } + logConnectionRetryFocusRestoreSuccess( + controller.connection, + controller.method, + controller.focusedPid, + controller.focusReason, + controller.focusCorrelationID, + ) } func (controller *connectionRetryFocusController) keepUnityFocusedAfterReturn() { // Why: terminal timeout recovery has no in-flight request left, so immediate // restore hides the auto-front signal that tells the user Unity needs attention. + if controller.restoreFocus == nil { + return + } controller.restoreFocus = nil + logConnectionRetryFocusRestoreSkipped( + controller.connection, + controller.method, + controller.focusedPid, + controller.focusReason, + controller.focusCorrelationID, + ) } func (controller *connectionRetryFocusController) tryFocus( @@ -133,7 +168,16 @@ func (controller *connectionRetryFocusController) tryFocusProcess( restorer, focusErr := controller.deps.focusUnityProcess(focusContext, pid) if focusErr == nil { controller.restoreFocus = restorer + controller.focusCorrelationID = correlationID + controller.focusedPid = pid + controller.focusReason = reason logConnectionRetryFocusSuccess(controller.connection, controller.method, pid, reason, cause, correlationID) + if restorer == nil { + // A nil restorer with a nil error means the previous frontmost PID could not + // be read, so no restore can ever run for this focus. Logged once here + // because restore() stays silent without a restorer. + logConnectionRetryFocusRestoreUnavailable(controller.connection, controller.method, pid, reason, correlationID) + } return } logConnectionRetryFocusFailure(controller.connection, controller.method, pid, reason, cause, focusErr, correlationID) @@ -372,74 +416,3 @@ func shouldRetryUndispatchedConnection(err error, outcome unityipc.UnitySendOutc // syscall error with the window's own deadline expiry. return !clierrors.IsPermanentConnectError(connectionErr) } - -func logConnectionRetryFocusAttempt( - connection unityipc.Connection, - method string, - pid int, - reason connectionRetryFocusReason, - retryCause error, - correlationID string, -) { - _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ - Level: "INFO", - Operation: "cli_connection_retry_focus_attempt", - Message: "Attempting to focus Unity while recovering a slow or unreachable request.", - Context: map[string]any{ - "command": method, - "pid": pid, - "endpoint": connection.Endpoint.Address, - "reason": string(reason), - "cause": clicore.ErrorMessage(retryCause), - }, - CorrelationID: correlationID, - }) -} - -func logConnectionRetryFocusSuccess( - connection unityipc.Connection, - method string, - pid int, - reason connectionRetryFocusReason, - retryCause error, - correlationID string, -) { - _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ - Level: "INFO", - Operation: "cli_connection_retry_focus_success", - Message: "Focused Unity while recovering a slow or unreachable request.", - Context: map[string]any{ - "command": method, - "pid": pid, - "endpoint": connection.Endpoint.Address, - "reason": string(reason), - "cause": clicore.ErrorMessage(retryCause), - }, - CorrelationID: correlationID, - }) -} - -func logConnectionRetryFocusFailure( - connection unityipc.Connection, - method string, - pid int, - reason connectionRetryFocusReason, - retryCause error, - focusErr error, - correlationID string, -) { - _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ - Level: "WARNING", - Operation: "cli_connection_retry_focus_failed", - Message: "Failed to focus Unity before retrying an undispatched request.", - Context: map[string]any{ - "command": method, - "pid": pid, - "endpoint": connection.Endpoint.Address, - "reason": string(reason), - "cause": clicore.ErrorMessage(retryCause), - "focusError": clicore.ErrorMessage(focusErr), - }, - CorrelationID: correlationID, - }) -} diff --git a/cli/project-runner/internal/projectrunner/connection_retry_focus_log.go b/cli/project-runner/internal/projectrunner/connection_retry_focus_log.go new file mode 100644 index 0000000000..dc8e5d5c9e --- /dev/null +++ b/cli/project-runner/internal/projectrunner/connection_retry_focus_log.go @@ -0,0 +1,167 @@ +package projectrunner + +// CLI vibe log writers for the connection-retry focus rescue: the focus attempt +// and its restore outcome, joined through a shared correlation ID. + +import ( + "github.com/hatayama/unity-cli-loop/common/clicore" + "github.com/hatayama/unity-cli-loop/common/unityipc" + "github.com/hatayama/unity-cli-loop/common/vibelog" +) + +func logConnectionRetryFocusAttempt( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + retryCause error, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_connection_retry_focus_attempt", + Message: "Attempting to focus Unity while recovering a slow or unreachable request.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + "cause": clicore.ErrorMessage(retryCause), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusSuccess( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + retryCause error, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_connection_retry_focus_success", + Message: "Focused Unity while recovering a slow or unreachable request.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + "cause": clicore.ErrorMessage(retryCause), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusFailure( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + retryCause error, + focusErr error, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: "cli_connection_retry_focus_failed", + Message: "Failed to focus Unity before retrying an undispatched request.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + "cause": clicore.ErrorMessage(retryCause), + "focusError": clicore.ErrorMessage(focusErr), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusRestoreSuccess( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_connection_retry_focus_restore_success", + Message: "Restored the previously frontmost application after the focus rescue.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusRestoreFailure( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + restoreErr error, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "WARNING", + Operation: "cli_connection_retry_focus_restore_failed", + Message: "Failed to restore the previously frontmost application after the focus rescue.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + "restoreError": clicore.ErrorMessage(restoreErr), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusRestoreSkipped( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_connection_retry_focus_restore_skipped", + Message: "Skipped the focus restore on purpose: terminal timeout recovery keeps Unity in front so the user notices it needs attention.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + }, + CorrelationID: correlationID, + }) +} + +func logConnectionRetryFocusRestoreUnavailable( + connection unityipc.Connection, + method string, + pid int, + reason connectionRetryFocusReason, + correlationID string, +) { + _ = vibelog.WriteCLIVibeLog(connection.ProjectRoot, vibelog.CLIVibeLogEntry{ + Level: "INFO", + Operation: "cli_connection_retry_focus_restore_unavailable", + Message: "Focused Unity without a restore target because the previously frontmost process could not be read.", + Context: map[string]any{ + "command": method, + "pid": pid, + "endpoint": connection.Endpoint.Address, + "reason": string(reason), + }, + CorrelationID: correlationID, + }) +} diff --git a/cli/project-runner/internal/projectrunner/connection_retry_test.go b/cli/project-runner/internal/projectrunner/connection_retry_test.go index 604f5b9832..93defc4123 100644 --- a/cli/project-runner/internal/projectrunner/connection_retry_test.go +++ b/cli/project-runner/internal/projectrunner/connection_retry_test.go @@ -3,6 +3,7 @@ package projectrunner import ( "bufio" "context" + "encoding/json" "errors" "fmt" "net" @@ -203,6 +204,227 @@ func TestSendWithTransientConnectionRetryWritesFocusFailureVibeLog(t *testing.T) } } +// Verifies a successful focus restore writes a restore-success vibe log joined to the focus +// attempt through the same correlation ID. +func TestConnectionRetryFocusControllerLogsRestoreSuccessWithAttemptCorrelation(t *testing.T) { + enableCliVibeLog(t) + + deps := defaultConnectionRetryDeps() + restoreCallCount := 0 + deps.focusUnityProcess = func(context.Context, int) (unityprocess.RestoreFocusFunc, error) { + return func(context.Context) error { + restoreCallCount++ + return nil + }, nil + } + projectRoot := t.TempDir() + controller := newConnectionRetryFocusController( + unityipc.Connection{ + Endpoint: unityipc.Endpoint{Network: "unix", Address: "/tmp/uloop-test/test.sock"}, + ProjectRoot: projectRoot, + }, + "get-logs", + deps, + ) + + controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.restore(context.Background()) + + if restoreCallCount != 1 { + t.Fatalf("expected one restore call, got %d", restoreCallCount) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + attemptEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_attempt") + restoreEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_restore_success") + if len(attemptEntries) != 1 || len(restoreEntries) != 1 { + t.Fatalf("expected one attempt and one restore-success entry:\n%s", logContent) + } + if restoreEntries[0]["level"] != "INFO" { + t.Fatalf("restore-success entry must be INFO: %#v", restoreEntries[0]) + } + if restoreEntries[0]["correlation_id"] != attemptEntries[0]["correlation_id"] { + t.Fatalf( + "restore-success correlation must match the attempt: %v vs %v", + restoreEntries[0]["correlation_id"], + attemptEntries[0]["correlation_id"], + ) + } + for _, expected := range []string{ + `"command":"get-logs"`, + `"pid":123`, + `"reason":"busy_stall"`, + } { + if !strings.Contains(logContent, expected) { + t.Fatalf("CLI Vibe log missing %q:\n%s", expected, logContent) + } + } +} + +// Verifies a failed focus restore writes a WARNING restore-failure vibe log including the +// restore error, joined to the focus attempt through the same correlation ID. +func TestConnectionRetryFocusControllerLogsRestoreFailure(t *testing.T) { + enableCliVibeLog(t) + + deps := defaultConnectionRetryDeps() + deps.focusUnityProcess = func(context.Context, int) (unityprocess.RestoreFocusFunc, error) { + return func(context.Context) error { + return fmt.Errorf("previous app is gone") + }, nil + } + projectRoot := t.TempDir() + controller := newConnectionRetryFocusController( + unityipc.Connection{ProjectRoot: projectRoot}, + "compile", + deps, + ) + + controller.tryFocusProcess(context.Background(), 456, focusReasonMainThreadStall, errors.New("stall")) + controller.restore(context.Background()) + + logContent := readOnlyCliVibeLog(t, projectRoot) + attemptEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_attempt") + failureEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_restore_failed") + if len(attemptEntries) != 1 || len(failureEntries) != 1 { + t.Fatalf("expected one attempt and one restore-failure entry:\n%s", logContent) + } + if failureEntries[0]["level"] != "WARNING" { + t.Fatalf("restore-failure entry must be WARNING: %#v", failureEntries[0]) + } + if failureEntries[0]["correlation_id"] != attemptEntries[0]["correlation_id"] { + t.Fatalf("restore-failure correlation must match the attempt:\n%s", logContent) + } + if !strings.Contains(logContent, `"restoreError":"previous app is gone"`) { + t.Fatalf("CLI Vibe log missing the restore error:\n%s", logContent) + } +} + +// Verifies keepUnityFocusedAfterReturn logs the intentional restore skip with the triggering +// focus reason, and that the restorer never runs afterward. +func TestConnectionRetryFocusControllerLogsRestoreSkippedWhenKeptFocused(t *testing.T) { + enableCliVibeLog(t) + + deps := defaultConnectionRetryDeps() + restoreCallCount := 0 + deps.focusUnityProcess = func(context.Context, int) (unityprocess.RestoreFocusFunc, error) { + return func(context.Context) error { + restoreCallCount++ + return nil + }, nil + } + projectRoot := t.TempDir() + controller := newConnectionRetryFocusController( + unityipc.Connection{ProjectRoot: projectRoot}, + "get-logs", + deps, + ) + + controller.tryFocusProcess(context.Background(), 123, focusReasonPreAcceptTimeout, errors.New("timeout")) + controller.keepUnityFocusedAfterReturn() + controller.restore(context.Background()) + + if restoreCallCount != 0 { + t.Fatalf("kept focus must not run the restorer, got %d calls", restoreCallCount) + } + logContent := readOnlyCliVibeLog(t, projectRoot) + attemptEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_attempt") + skippedEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_restore_skipped") + if len(attemptEntries) != 1 || len(skippedEntries) != 1 { + t.Fatalf("expected one attempt and one restore-skipped entry:\n%s", logContent) + } + if skippedEntries[0]["correlation_id"] != attemptEntries[0]["correlation_id"] { + t.Fatalf("restore-skipped correlation must match the attempt:\n%s", logContent) + } + if !strings.Contains(logContent, `"reason":"pre_accept_timeout"`) { + t.Fatalf("restore-skipped entry must carry the triggering focus reason:\n%s", logContent) + } + if len(cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_restore_success")) != 0 { + t.Fatalf("a skipped restore must not also log a restore success:\n%s", logContent) + } +} + +// Verifies a focus that could not capture the previous frontmost PID logs the missing restorer +// once at focus time, and the later restore call stays silent. +func TestConnectionRetryFocusControllerLogsMissingRestorerAtFocusTime(t *testing.T) { + enableCliVibeLog(t) + + deps := defaultConnectionRetryDeps() + deps.focusUnityProcess = func(context.Context, int) (unityprocess.RestoreFocusFunc, error) { + // A nil restorer with a nil error means the previous frontmost PID could not be read. + return nil, nil + } + projectRoot := t.TempDir() + controller := newConnectionRetryFocusController( + unityipc.Connection{ProjectRoot: projectRoot}, + "get-logs", + deps, + ) + + controller.tryFocusProcess(context.Background(), 123, focusReasonBusyStall, errors.New("busy")) + controller.restore(context.Background()) + + logContent := readOnlyCliVibeLog(t, projectRoot) + attemptEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_attempt") + unavailableEntries := cliVibeEntriesForOperation(t, logContent, "cli_connection_retry_focus_restore_unavailable") + if len(attemptEntries) != 1 || len(unavailableEntries) != 1 { + t.Fatalf("expected one attempt and one restore-unavailable entry:\n%s", logContent) + } + if unavailableEntries[0]["correlation_id"] != attemptEntries[0]["correlation_id"] { + t.Fatalf("restore-unavailable correlation must match the attempt:\n%s", logContent) + } + for _, unexpected := range []string{ + "cli_connection_retry_focus_restore_success", + "cli_connection_retry_focus_restore_failed", + "cli_connection_retry_focus_restore_skipped", + } { + if len(cliVibeEntriesForOperation(t, logContent, unexpected)) != 0 { + t.Fatalf("a missing restorer must only log at focus time, found %s:\n%s", unexpected, logContent) + } + } +} + +// Verifies restore and keepUnityFocusedAfterReturn stay silent when no focus ever happened, +// so every ordinary command does not write a restore log line. +func TestConnectionRetryFocusControllerStaysSilentWithoutFocus(t *testing.T) { + enableCliVibeLog(t) + + projectRoot := t.TempDir() + controller := newConnectionRetryFocusController( + unityipc.Connection{ProjectRoot: projectRoot}, + "get-logs", + defaultConnectionRetryDeps(), + ) + + controller.keepUnityFocusedAfterReturn() + controller.restore(context.Background()) + + logFiles, err := filepath.Glob(filepath.Join(projectRoot, vibelog.CLIVibeLogDirectory, "*.json")) + if err != nil { + t.Fatalf("failed to glob CLI Vibe logs: %v", err) + } + if len(logFiles) != 0 { + t.Fatalf("no-op restore must not write any vibe log, found %#v", logFiles) + } +} + +// Parses the JSON-lines vibe log and returns the entries whose operation matches. +func cliVibeEntriesForOperation(t *testing.T, logContent string, operation string) []map[string]any { + t.Helper() + entries := []map[string]any{} + for _, line := range strings.Split(strings.TrimSpace(logContent), "\n") { + if line == "" { + continue + } + entry := map[string]any{} + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatalf("failed to parse vibe log line %q: %v", line, err) + } + if entry["operation"] == operation { + entries = append(entries, entry) + } + } + return entries +} + // Verifies a process probe that timed out reports the dial error instead of the // server-not-responding error: a probe that never read the process table cannot be the evidence // for claiming Unity is running. From 0e40f5144a00c0d4ecb10b604218518cd1ef0194 Mon Sep 17 00:00:00 2001 From: hatayama Date: Mon, 27 Jul 2026 23:33:37 +0900 Subject: [PATCH 5/5] Suppress Play Mode window raise while the Editor is unfocused Unity's EditorApplicationLayout raises the Editor window above other apps whenever Play Mode starts or resumes from pause, unless the play mode view is set to PlayUnfocused. For background editors driven by the uloop CLI this pops Unity over the user's windows on every pause-point resume. While the Editor is unfocused, force every PlayFocused PlayModeView to PlayUnfocused; restore them to PlayFocused when focus returns. Views the user set to PlayMaximized or PlayUnfocused are left alone by the suppress direction. - PlayModeViewFocusBridge uses friend access to PlayModeView directly (no reflection) and only flips PlayFocused<->PlayUnfocused, which the setter handles without the PlayMaximized cascade - PlayModeFocusSuppressionService is pure C# with injected delegates and is covered by EditMode unit tests - The suppressed flag persists in EditorUserSettings so a crash or restart while unfocused still restores views on the next focus - A 0.5s-throttled reconcile self-heals after domain reloads and background launches where focusChanged never fires, and re-suppresses Game views opened while still unfocused --- .../PlayModeFocusSuppressionServiceTests.cs | 232 ++++++++++++++++++ ...ayModeFocusSuppressionServiceTests.cs.meta | 11 + .../ControlPlayModeEditorStartup.cs | 1 + .../PlayModeFocusSuppressionConstants.cs | 14 ++ .../PlayModeFocusSuppressionConstants.cs.meta | 11 + .../PlayModeFocusSuppressionService.cs | 104 ++++++++ .../PlayModeFocusSuppressionService.cs.meta | 11 + .../PlayModeFocusSuppressionStartup.cs | 77 ++++++ .../PlayModeFocusSuppressionStartup.cs.meta | 11 + ...stPartyTools.ControlPlayMode.Editor.asmdef | 3 +- .../PlayModeViewFocusBridge.cs | 63 +++++ .../PlayModeViewFocusBridge.cs.meta | 11 + 12 files changed, 548 insertions(+), 1 deletion(-) create mode 100644 Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs create mode 100644 Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs.meta create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs create mode 100644 Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs.meta create mode 100644 Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs create mode 100644 Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs.meta diff --git a/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs b/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs new file mode 100644 index 0000000000..986fff8216 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs @@ -0,0 +1,232 @@ +using System.Collections.Generic; +using NUnit.Framework; + +using io.github.hatayama.UnityCliLoop.FirstPartyTools; + +namespace io.github.hatayama.UnityCliLoop.Tests.Editor +{ + /// + /// Tests the pure focus-gated Play Mode window-raise suppression state machine. + /// + public sealed class PlayModeFocusSuppressionServiceTests + { + private sealed class FakeEnvironment + { + internal bool IsFocused; + internal bool SuppressedFlag; + internal int SuppressCallCount; + internal int RestoreCallCount; + internal int SuppressChangedViews; + internal int RestoreChangedViews; + internal readonly List LoggedOperations = new List(); + + internal PlayModeFocusSuppressionService CreateService() + { + return new PlayModeFocusSuppressionService( + () => IsFocused, + () => + { + SuppressCallCount++; + return SuppressChangedViews; + }, + () => + { + RestoreCallCount++; + return RestoreChangedViews; + }, + () => SuppressedFlag, + value => SuppressedFlag = value, + (operation, message, context) => LoggedOperations.Add(operation)); + } + } + + /// + /// Verifies focus loss calls the suppress action and sets the persisted flag when views changed. + /// + [Test] + public void HandleFocusChanged_FocusLostWithChangedViews_SetsFlagAndSuppresses() + { + FakeEnvironment environment = new FakeEnvironment { SuppressChangedViews = 1 }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(false); + + Assert.That(environment.SuppressCallCount, Is.EqualTo(1)); + Assert.That(environment.SuppressedFlag, Is.True); + } + + /// + /// Verifies focus loss leaves the flag clear when no view needed suppression. + /// + [Test] + public void HandleFocusChanged_FocusLostWithNoChangedViews_LeavesFlagClear() + { + FakeEnvironment environment = new FakeEnvironment { SuppressChangedViews = 0 }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(false); + + Assert.That(environment.SuppressedFlag, Is.False); + } + + /// + /// Verifies focus loss keeps an already-set flag even when this call changed no views. + /// + [Test] + public void HandleFocusChanged_FocusLostWithFlagAlreadySet_KeepsFlagSet() + { + FakeEnvironment environment = new FakeEnvironment { SuppressedFlag = true, SuppressChangedViews = 0 }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(false); + + Assert.That(environment.SuppressedFlag, Is.True); + } + + /// + /// Verifies focus gain restores views and clears the flag when the flag is set. + /// + [Test] + public void HandleFocusChanged_FocusGainedWithFlagSet_RestoresAndClearsFlag() + { + FakeEnvironment environment = new FakeEnvironment + { + IsFocused = true, + SuppressedFlag = true, + RestoreChangedViews = 1 + }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(true); + + Assert.That(environment.RestoreCallCount, Is.EqualTo(1)); + Assert.That(environment.SuppressedFlag, Is.False); + } + + /// + /// Verifies focus gain never calls the restore action while the flag is clear. + /// + [Test] + public void HandleFocusChanged_FocusGainedWithFlagClear_DoesNotCallRestore() + { + FakeEnvironment environment = new FakeEnvironment { IsFocused = true }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(true); + + Assert.That(environment.RestoreCallCount, Is.EqualTo(0)); + } + + /// + /// Verifies focus gain clears the flag even when restore changed no views (views were closed). + /// + [Test] + public void HandleFocusChanged_FocusGainedWithFlagSetAndNoViews_StillClearsFlag() + { + FakeEnvironment environment = new FakeEnvironment + { + IsFocused = true, + SuppressedFlag = true, + RestoreChangedViews = 0 + }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.HandleFocusChanged(true); + + Assert.That(environment.SuppressedFlag, Is.False); + Assert.That(environment.LoggedOperations, Does.Contain("play_focus_suppress_released")); + } + + /// + /// Verifies reconcile arms suppression while the Editor is unfocused (no focus-lost event needed). + /// + [Test] + public void Reconcile_WhileUnfocused_ArmsSuppression() + { + FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 2 }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.Reconcile(); + + Assert.That(environment.SuppressCallCount, Is.EqualTo(1)); + Assert.That(environment.SuppressedFlag, Is.True); + } + + /// + /// Verifies reconcile restores views while focused when a stale flag survived a restart. + /// + [Test] + public void Reconcile_WhileFocusedWithStaleFlag_RestoresAndClearsFlag() + { + FakeEnvironment environment = new FakeEnvironment + { + IsFocused = true, + SuppressedFlag = true, + RestoreChangedViews = 1 + }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.Reconcile(); + + Assert.That(environment.RestoreCallCount, Is.EqualTo(1)); + Assert.That(environment.SuppressedFlag, Is.False); + } + + /// + /// Verifies reconcile is a no-op while focused with a clear flag (the steady state). + /// + [Test] + public void Reconcile_WhileFocusedWithFlagClear_DoesNothing() + { + FakeEnvironment environment = new FakeEnvironment { IsFocused = true }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.Reconcile(); + + Assert.That(environment.SuppressCallCount, Is.EqualTo(0)); + Assert.That(environment.RestoreCallCount, Is.EqualTo(0)); + Assert.That(environment.LoggedOperations, Is.Empty); + } + + /// + /// Verifies the flag round-trips through the injected store across service instances, + /// simulating a domain reload between suppress and restore. + /// + [Test] + public void SuppressedFlag_PersistsAcrossServiceInstances_ViaInjectedStore() + { + FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 1 }; + PlayModeFocusSuppressionService firstService = environment.CreateService(); + firstService.HandleFocusChanged(false); + Assert.That(environment.SuppressedFlag, Is.True); + + environment.IsFocused = true; + environment.RestoreChangedViews = 1; + PlayModeFocusSuppressionService secondService = environment.CreateService(); + secondService.HandleFocusChanged(true); + + Assert.That(environment.RestoreCallCount, Is.EqualTo(1)); + Assert.That(environment.SuppressedFlag, Is.False); + } + + /// + /// Verifies repeated reconcile while unfocused logs the armed operation only when views actually changed. + /// + [Test] + public void Reconcile_RepeatedWhileUnfocused_LogsArmedOnlyOnActualChange() + { + FakeEnvironment environment = new FakeEnvironment { IsFocused = false, SuppressChangedViews = 1 }; + PlayModeFocusSuppressionService service = environment.CreateService(); + + service.Reconcile(); + environment.SuppressChangedViews = 0; + service.Reconcile(); + service.Reconcile(); + + Assert.That(environment.SuppressCallCount, Is.EqualTo(3)); + Assert.That( + environment.LoggedOperations.FindAll(operation => operation == "play_focus_suppress_armed"), + Has.Count.EqualTo(1)); + } + } +} diff --git a/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs.meta b/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs.meta new file mode 100644 index 0000000000..8d5ccf3407 --- /dev/null +++ b/Assets/Tests/Editor/PlayModeFocusSuppressionServiceTests.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 9777890b018ad4b35884f0694dcff64d +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs index f1abc87d01..4cc9eecf6c 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/ControlPlayModeEditorStartup.cs @@ -8,6 +8,7 @@ internal static class ControlPlayModeEditorStartup internal static void Initialize() { ControlPlayModeServices.InitializeForEditorStartup(); + PlayModeFocusSuppressionStartup.Initialize(); } } } diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs new file mode 100644 index 0000000000..641756d988 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs @@ -0,0 +1,14 @@ +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Persistence keys for the focus-gated Play Mode window-raise suppression. + /// + internal static class PlayModeFocusSuppressionConstants + { + // Why EditorUserSettings: the flag must survive both domain reload and editor restart so a + // crash while unfocused still restores PlayFocused views when focus next returns. + internal const string SuppressedConfigKey = + "io.github.hatayama.UnityCliLoop.PlayModeFocusSuppression.Suppressed"; + internal const string SuppressedConfigValue = "1"; + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs.meta new file mode 100644 index 0000000000..651b039797 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionConstants.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: d154ce333bed6446187dfd9901ee64f5 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs new file mode 100644 index 0000000000..c69019f271 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs @@ -0,0 +1,104 @@ +using System; +using UnityEngine; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Suppresses Unity's Play Mode window raise while the Editor is unfocused by forcing + /// PlayFocused views to PlayUnfocused, and restores them when the Editor regains focus. + /// Why not rely on focusChanged alone: background launch never fires focus-lost, and domain + /// reloads or editor restarts can drop the event, so suppression must also be armed and + /// released from startup and periodic reconcile. + /// + internal sealed class PlayModeFocusSuppressionService + { + private readonly Func _isEditorFocused; + private readonly Func _suppressPlayFocusedViews; + private readonly Func _restorePlayUnfocusedViews; + private readonly Func _getSuppressedFlag; + private readonly Action _setSuppressedFlag; + private readonly Action _logVibeInfo; + + internal PlayModeFocusSuppressionService( + Func isEditorFocused, + Func suppressPlayFocusedViews, + Func restorePlayUnfocusedViews, + Func getSuppressedFlag, + Action setSuppressedFlag, + Action logVibeInfo = null) + { + Debug.Assert(isEditorFocused != null, "isEditorFocused must not be null"); + Debug.Assert(suppressPlayFocusedViews != null, "suppressPlayFocusedViews must not be null"); + Debug.Assert(restorePlayUnfocusedViews != null, "restorePlayUnfocusedViews must not be null"); + Debug.Assert(getSuppressedFlag != null, "getSuppressedFlag must not be null"); + Debug.Assert(setSuppressedFlag != null, "setSuppressedFlag must not be null"); + + _isEditorFocused = isEditorFocused ?? throw new ArgumentNullException(nameof(isEditorFocused)); + _suppressPlayFocusedViews = + suppressPlayFocusedViews ?? throw new ArgumentNullException(nameof(suppressPlayFocusedViews)); + _restorePlayUnfocusedViews = + restorePlayUnfocusedViews ?? throw new ArgumentNullException(nameof(restorePlayUnfocusedViews)); + _getSuppressedFlag = getSuppressedFlag ?? throw new ArgumentNullException(nameof(getSuppressedFlag)); + _setSuppressedFlag = setSuppressedFlag ?? throw new ArgumentNullException(nameof(setSuppressedFlag)); + // Why inject: pure C# unit tests stay free of VibeLogger; production wires VibeLogger. + _logVibeInfo = logVibeInfo ?? ((operation, message, context) => { }); + } + + /// + /// Applies suppress or restore for a focus transition. Idempotent for repeated events. + /// + internal void HandleFocusChanged(bool isFocused) + { + if (isFocused) + { + RestoreIfSuppressed(); + return; + } + + SuppressWhileUnfocused(); + } + + /// + /// Aligns suppression with the current focus without depending on focusChanged delivery. + /// Idempotent and cheap, so it also re-suppresses Game views opened while still unfocused. + /// + internal void Reconcile() + { + HandleFocusChanged(_isEditorFocused()); + } + + private void SuppressWhileUnfocused() + { + int changedViews = _suppressPlayFocusedViews(); + if (changedViews <= 0) + { + // Why keep the flag as-is: an already-armed suppression must survive no-op calls. + return; + } + + _setSuppressedFlag(true); + // Why only on actual change: reconcile ticks every 0.5s; spam would drown the log timeline. + _logVibeInfo( + "play_focus_suppress_armed", + "Forced PlayFocused views to PlayUnfocused while the Editor is unfocused", + new { changedViews, isFocused = _isEditorFocused() }); + } + + private void RestoreIfSuppressed() + { + if (!_getSuppressedFlag()) + { + // Why no restore call: views the user set to PlayUnfocused must stay untouched. + return; + } + + int changedViews = _restorePlayUnfocusedViews(); + _setSuppressedFlag(false); + // Why log even with zero changed views: the flag transition itself must stay observable. + _logVibeInfo( + "play_focus_suppress_released", + "Restored PlayUnfocused views to PlayFocused after the Editor regained focus", + new { changedViews, isFocused = _isEditorFocused() }); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs.meta new file mode 100644 index 0000000000..e7f8cbb2e4 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionService.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 16a491903b4dc4dbea0214d7f94ffd85 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs new file mode 100644 index 0000000000..f1b0320464 --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs @@ -0,0 +1,77 @@ +using UnityEditor; + +using io.github.hatayama.UnityCliLoop.InternalAPIBridge; +using io.github.hatayama.UnityCliLoop.ToolContracts; + +namespace io.github.hatayama.UnityCliLoop.FirstPartyTools +{ + /// + /// Wires PlayModeFocusSuppressionService to editor focus events and a throttled update reconcile. + /// + internal static class PlayModeFocusSuppressionStartup + { + // Why throttle: reconcile must not walk the play-mode view list every frame when already aligned. + private const double ReconcileIntervalSeconds = 0.5d; + private static readonly PlayModeFocusSuppressionService Service = + new PlayModeFocusSuppressionService( + () => EditorApplication.isFocused, + PlayModeViewFocusBridge.SetPlayFocusedViewsToPlayUnfocused, + PlayModeViewFocusBridge.SetPlayUnfocusedViewsToPlayFocused, + IsSuppressed, + SetSuppressed, + logVibeInfo: (operation, message, context) => + { + VibeLogger.LogInfo(operation, message, context, includeStackTrace: false); + }); + private static bool _initialized; + private static double _nextReconcileTime; + + internal static void Initialize() + { + if (_initialized) + { + return; + } + + _initialized = true; + EditorApplication.focusChanged -= HandleFocusChanged; + EditorApplication.focusChanged += HandleFocusChanged; + EditorApplication.update -= ReconcileOnUpdate; + EditorApplication.update += ReconcileOnUpdate; + // Why immediate reconcile: background launch never fires focusChanged(false), so views must + // be suppressed right away; a stale flag from a crash while unfocused is released here too. + Service.Reconcile(); + } + + private static void HandleFocusChanged(bool isFocused) + { + Service.HandleFocusChanged(isFocused); + } + + private static void ReconcileOnUpdate() + { + double now = EditorApplication.timeSinceStartup; + if (now < _nextReconcileTime) + { + return; + } + + _nextReconcileTime = now + ReconcileIntervalSeconds; + Service.Reconcile(); + } + + private static bool IsSuppressed() + { + return EditorUserSettings.GetConfigValue(PlayModeFocusSuppressionConstants.SuppressedConfigKey) == + PlayModeFocusSuppressionConstants.SuppressedConfigValue; + } + + private static void SetSuppressed(bool isSuppressed) + { + // Why null on clear: EditorUserSettings removes the entry, keeping the project settings clean. + EditorUserSettings.SetConfigValue( + PlayModeFocusSuppressionConstants.SuppressedConfigKey, + isSuppressed ? PlayModeFocusSuppressionConstants.SuppressedConfigValue : null); + } + } +} diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs.meta b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs.meta new file mode 100644 index 0000000000..5f6b43d5bf --- /dev/null +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/PlayModeFocusSuppressionStartup.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 752d7c35c38fa4ede8d1a4a6caf51586 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor.asmdef b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor.asmdef index ab77309c05..8fb2314027 100644 --- a/Packages/src/Editor/FirstPartyTools/ControlPlayMode/UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor.asmdef +++ b/Packages/src/Editor/FirstPartyTools/ControlPlayMode/UnityCLILoop.FirstPartyTools.ControlPlayMode.Editor.asmdef @@ -3,7 +3,8 @@ "rootNamespace": "io.github.hatayama.UnityCliLoop.FirstPartyTools", "references": [ "GUID:d427b32aad9cb44fc8e962437c9dbcd8", - "GUID:fc3fd32eddbee40e39c2d76dc184957b" + "GUID:fc3fd32eddbee40e39c2d76dc184957b", + "GUID:5079a8d3a72924a81aa1cbc25f65ed1b" ], "includePlatforms": [ "Editor" diff --git a/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs b/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs new file mode 100644 index 0000000000..92315c7a02 --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using UnityEditor; + +namespace io.github.hatayama.UnityCliLoop.InternalAPIBridge +{ + /// + /// Switches PlayModeView.enterPlayModeBehavior between PlayFocused and PlayUnfocused. + /// Why: EditorApplicationLayout raises the Editor window above other apps on Play and on + /// resume-from-pause unless the play-mode view is PlayUnfocused, so background suppression + /// must flip PlayFocused views while the Editor is unfocused. Views set to PlayMaximized or + /// PlayUnfocused by the user are never touched by the suppress direction. + /// + public static class PlayModeViewFocusBridge + { + /// + /// Forces every PlayFocused view to PlayUnfocused. Returns the number of views changed. + /// + public static int SetPlayFocusedViewsToPlayUnfocused() + { + return SetBehaviorForMatchingViews( + PlayModeView.EnterPlayModeBehavior.PlayFocused, + PlayModeView.EnterPlayModeBehavior.PlayUnfocused); + } + + /// + /// Restores every PlayUnfocused view to PlayFocused. Returns the number of views changed. + /// + public static int SetPlayUnfocusedViewsToPlayFocused() + { + return SetBehaviorForMatchingViews( + PlayModeView.EnterPlayModeBehavior.PlayUnfocused, + PlayModeView.EnterPlayModeBehavior.PlayFocused); + } + + private static int SetBehaviorForMatchingViews( + PlayModeView.EnterPlayModeBehavior fromBehavior, + PlayModeView.EnterPlayModeBehavior toBehavior) + { + List playModeViews = PlayModeView.GetAllPlayModeViewWindows(); + if (playModeViews == null) + { + return 0; + } + + int changedViewCount = 0; + for (int i = 0; i < playModeViews.Count; i++) + { + PlayModeView playModeView = playModeViews[i]; + // Why null check: Unity keeps destroyed windows in the static list until it prunes them. + if (playModeView == null || playModeView.enterPlayModeBehavior != fromBehavior) + { + continue; + } + + // Safe transition: the setter's PlayMaximized cascade never runs for these two values. + playModeView.enterPlayModeBehavior = toBehavior; + changedViewCount++; + } + + return changedViewCount; + } + } +} diff --git a/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs.meta b/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs.meta new file mode 100644 index 0000000000..af4505aa47 --- /dev/null +++ b/Packages/src/Editor/InternalAPIBridge/PlayModeViewFocusBridge.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 0b6dc68c395a84b4c8c38009751dcd2c +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: