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
6 changes: 4 additions & 2 deletions .agents/skills/uloop-launch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ uloop launch [project-path] [options]
| Parameter | Type | Description |
|-----------|------|-------------|
| `project-path` | string | Optional. Use only when the target Unity project is not in the current directory. |
| `-r, --restart` | flag | Kill running Unity and restart |
| `-q, --quit` | flag | Kill an existing Unity process for the project without launching |
| `-r, --restart` | flag | Quit running Unity and restart |
| `-q, --quit` | flag | Quit an existing Unity process for the project without launching |
| `--editor-version <version>` | string | Use this Unity Editor version instead of ProjectVersion.txt |
| `-p, --platform <P>` | string | Build target (e.g., StandaloneOSX, Android, iOS) |
| `--max-depth <N>` | number | Search depth when project-path is omitted (default: 3, -1 for unlimited) |
Expand All @@ -42,6 +42,7 @@ The final JSON payload includes:
- `Restarted`: whether this command stopped an existing process and launched a new one
- `Quit`: whether this command stopped Unity without launching a new process
- `PreviousProcessId`: process ID stopped by restart or quit, when available
- `StopMethod`: how restart or quit stopped that process, omitted when there was none — `Quit` when Unity exited on request, `ForceKill` when it had to be killed
- `CurrentProcessId`: current Unity process ID, when available
- `ProjectRoot`: resolved project root
- `Message`: readiness summary
Expand All @@ -56,6 +57,7 @@ running, or still does not respond after retries.
## Notes

- If Unity is already running, focuses the existing window and verifies tool readiness
- `--restart` and `--quit` ask Unity to quit first: it saves its window layout and discards unsaved scene changes without a save dialog. Unity is force-killed instead, losing the layout, when it cannot take the request (frozen, or a package that predates it) or does not exit within 30 seconds
- If process scan is blocked (e.g. sandboxed `ps`), plain launch falls back to IPC probing; `--restart` and `--quit` still fail because they need the process id
- New Unity processes ignore project compiler errors during Editor startup by default when run by dispatcher 3.0.1-beta.8 or newer; run `uloop update` first if an older dispatcher is installed
- `--editor-version` only affects new Unity processes; use it with `--restart` to replace an already-running Editor
Expand Down
6 changes: 4 additions & 2 deletions .claude/skills/uloop-launch/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ uloop launch [project-path] [options]
| Parameter | Type | Description |
|-----------|------|-------------|
| `project-path` | string | Optional. Use only when the target Unity project is not in the current directory. |
| `-r, --restart` | flag | Kill running Unity and restart |
| `-q, --quit` | flag | Kill an existing Unity process for the project without launching |
| `-r, --restart` | flag | Quit running Unity and restart |
| `-q, --quit` | flag | Quit an existing Unity process for the project without launching |
| `--editor-version <version>` | string | Use this Unity Editor version instead of ProjectVersion.txt |
| `-p, --platform <P>` | string | Build target (e.g., StandaloneOSX, Android, iOS) |
| `--max-depth <N>` | number | Search depth when project-path is omitted (default: 3, -1 for unlimited) |
Expand All @@ -42,6 +42,7 @@ The final JSON payload includes:
- `Restarted`: whether this command stopped an existing process and launched a new one
- `Quit`: whether this command stopped Unity without launching a new process
- `PreviousProcessId`: process ID stopped by restart or quit, when available
- `StopMethod`: how restart or quit stopped that process, omitted when there was none — `Quit` when Unity exited on request, `ForceKill` when it had to be killed
- `CurrentProcessId`: current Unity process ID, when available
- `ProjectRoot`: resolved project root
- `Message`: readiness summary
Expand All @@ -56,6 +57,7 @@ running, or still does not respond after retries.
## Notes

- If Unity is already running, focuses the existing window and verifies tool readiness
- `--restart` and `--quit` ask Unity to quit first: it saves its window layout and discards unsaved scene changes without a save dialog. Unity is force-killed instead, losing the layout, when it cannot take the request (frozen, or a package that predates it) or does not exit within 30 seconds
- If process scan is blocked (e.g. sandboxed `ps`), plain launch falls back to IPC probing; `--restart` and `--quit` still fail because they need the process id
- New Unity processes ignore project compiler errors during Editor startup by default when run by dispatcher 3.0.1-beta.8 or newer; run `uloop update` first if an older dispatcher is installed
- `--editor-version` only affects new Unity processes; use it with `--restart` to replace an already-running Editor
Expand Down
88 changes: 88 additions & 0 deletions Assets/Tests/Editor/QuitEditorBridgeCommandTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using System.Collections.Generic;

using NUnit.Framework;

using io.github.hatayama.UnityCliLoop.Infrastructure;
using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Tests.Editor
{
/// <summary>
/// Tests the quit-editor bridge command: its routing, and when it exits the Editor. The handler itself is
/// not run here because it exits the Editor that runs the tests.
/// </summary>
[TestFixture]
public sealed class QuitEditorBridgeCommandTests
{
private const double ExitAt = 10.0;

/// <summary>
/// Verifies quit-editor is routed as an internal bridge command, which uloop launch --restart and
/// --quit rely on before they fall back to force-killing the Editor.
/// </summary>
[Test]
public void InternalBridgeCommandRouter_QuitEditorCommand_IsInternal()
{
bool isInternal = InternalBridgeCommandRouter.IsInternalCommand(
UnityCliLoopConstants.COMMAND_NAME_QUIT_EDITOR);

Assert.That(isInternal, Is.True);
}

/// <summary>
/// Verifies the command name matches the one the CLI sends.
/// </summary>
[Test]
public void QuitEditorCommandName_MatchesTheCliRequest()
{
Assert.That(UnityCliLoopConstants.COMMAND_NAME_QUIT_EDITOR, Is.EqualTo("quit-editor"));
}

/// <summary>
/// Verifies the Editor is not exited before the deadline, which leaves quit-editor time to answer.
/// </summary>
[Test]
public void DelayedEditorExit_BeforeTheDeadline_DoesNotExit()
{
List<int> exitCodes = new();
DelayedEditorExit delayedExit = new(ExitAt, exitCodes.Add);

bool exitRequested = delayedExit.ExitIfDue(ExitAt - 0.1);

Assert.That(exitRequested, Is.False);
Assert.That(exitCodes, Is.Empty);
}

/// <summary>
/// Verifies the Editor exits with code 0 once the deadline is reached.
/// </summary>
[Test]
public void DelayedEditorExit_AtTheDeadline_ExitsWithCodeZero()
{
List<int> exitCodes = new();
DelayedEditorExit delayedExit = new(ExitAt, exitCodes.Add);

bool exitRequested = delayedExit.ExitIfDue(ExitAt);

Assert.That(exitRequested, Is.True);
Assert.That(exitCodes, Is.EqualTo(new[] { 0 }));
}

/// <summary>
/// Verifies a check after the exit does not exit again, since the update callback can run once more
/// before it is removed.
/// </summary>
[Test]
public void DelayedEditorExit_AfterExiting_DoesNotExitAgain()
{
List<int> exitCodes = new();
DelayedEditorExit delayedExit = new(ExitAt, exitCodes.Add);
delayedExit.ExitIfDue(ExitAt);

bool exitRequested = delayedExit.ExitIfDue(ExitAt + 1.0);

Assert.That(exitRequested, Is.True);
Assert.That(exitCodes, Is.EqualTo(new[] { 0 }));
}
}
}
11 changes: 11 additions & 0 deletions Assets/Tests/Editor/QuitEditorBridgeCommandTests.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions Packages/src/Editor/CliOnlyTools~/Launch/Skill/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ uloop launch [project-path] [options]
| Parameter | Type | Description |
|-----------|------|-------------|
| `project-path` | string | Optional. Use only when the target Unity project is not in the current directory. |
| `-r, --restart` | flag | Kill running Unity and restart |
| `-q, --quit` | flag | Kill an existing Unity process for the project without launching |
| `-r, --restart` | flag | Quit running Unity and restart |
| `-q, --quit` | flag | Quit an existing Unity process for the project without launching |
| `--editor-version <version>` | string | Use this Unity Editor version instead of ProjectVersion.txt |
| `-p, --platform <P>` | string | Build target (e.g., StandaloneOSX, Android, iOS) |
| `--max-depth <N>` | number | Search depth when project-path is omitted (default: 3, -1 for unlimited) |
Expand All @@ -42,6 +42,7 @@ The final JSON payload includes:
- `Restarted`: whether this command stopped an existing process and launched a new one
- `Quit`: whether this command stopped Unity without launching a new process
- `PreviousProcessId`: process ID stopped by restart or quit, when available
- `StopMethod`: how restart or quit stopped that process, omitted when there was none — `Quit` when Unity exited on request, `ForceKill` when it had to be killed
- `CurrentProcessId`: current Unity process ID, when available
- `ProjectRoot`: resolved project root
- `Message`: readiness summary
Expand All @@ -56,6 +57,7 @@ running, or still does not respond after retries.
## Notes

- If Unity is already running, focuses the existing window and verifies tool readiness
- `--restart` and `--quit` ask Unity to quit first: it saves its window layout and discards unsaved scene changes without a save dialog. Unity is force-killed instead, losing the layout, when it cannot take the request (frozen, or a package that predates it) or does not exit within 30 seconds
- If process scan is blocked (e.g. sandboxed `ps`), plain launch falls back to IPC probing; `--restart` and `--quit` still fail because they need the process id
- New Unity processes ignore project compiler errors during Editor startup by default when run by dispatcher 3.0.1-beta.8 or newer; run `uloop update` first if an older dispatcher is installed
- `--editor-version` only affects new Unity processes; use it with `--restart` to replace an already-running Editor
Expand Down
42 changes: 42 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/DelayedEditorExit.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
using System;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Exits the Editor once a deadline has passed, and only once, so that quit-editor can answer before
/// the process ends.
/// </summary>
internal sealed class DelayedEditorExit
{
private readonly double _exitAt;
private readonly Action<int> _exit;
private bool _hasExited;

internal DelayedEditorExit(double exitAt, Action<int> exit)
{
_exitAt = exitAt;
_exit = exit;
}

/// <summary>
/// Exits with code 0 once now has reached the deadline. Returns true once the exit has been
/// requested, so the caller can stop checking.
/// </summary>
internal bool ExitIfDue(double now)
{
if (_hasExited)
{
return true;
}

if (now < _exitAt)
{
return false;
}

_hasExited = true;
_exit(0);
return true;
}
}
}
11 changes: 11 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/DelayedEditorExit.cs.meta

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@ public static bool IsInternalCommand(string methodName)
methodName == UnityCliLoopConstants.COMMAND_NAME_EXTEND_PAUSE_POINT_STATUS ||
methodName == UnityCliLoopConstants.COMMAND_NAME_SET_CODE_OPTIMIZATION_DEBUG ||
methodName == UnityCliLoopConstants.COMMAND_NAME_SET_CODE_OPTIMIZATION_DEBUG_STARTUP ||
methodName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS;
methodName == UnityCliLoopConstants.COMMAND_NAME_GET_TOOL_DETAILS ||
methodName == UnityCliLoopConstants.COMMAND_NAME_QUIT_EDITOR;
}

public static UnityCliLoopToolResponse Execute(
Expand Down Expand Up @@ -83,6 +84,11 @@ public static UnityCliLoopToolResponse Execute(
return SetCodeOptimizationDebugStartupBridgeCommand.Execute();
}

if (methodName == UnityCliLoopConstants.COMMAND_NAME_QUIT_EDITOR)
{
return QuitEditorBridgeCommand.Execute();
}

throw new ArgumentException($"Unknown internal bridge command: {methodName}", nameof(methodName));
}
}
Expand Down
47 changes: 47 additions & 0 deletions Packages/src/Editor/Infrastructure/Api/QuitEditorBridgeCommand.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
using UnityEditor;

using io.github.hatayama.UnityCliLoop.ToolContracts;

namespace io.github.hatayama.UnityCliLoop.Infrastructure
{
/// <summary>
/// Answers quit-editor; its Success is the only payload.
/// </summary>
public class QuitEditorResponse : UnityCliLoopToolResponse
{
}

/// <summary>
/// Runs the quit-editor command behind uloop launch --restart and --quit: closes the Editor so that it
/// saves its window layout, which a force-kill loses.
/// </summary>
internal static class QuitEditorBridgeCommand
{
// Long enough for this answer to be written before the process ends.
private const double ExitDelaySeconds = 0.5;

public static QuitEditorResponse Execute()
{
// Why Exit rather than the quit the Editor menu runs: that quit asks whether to save modified
// scenes and waits for an answer nobody gives, while Exit discards them without asking and still
// saves the window layout.
DelayedEditorExit delayedExit = new(
EditorApplication.timeSinceStartup + ExitDelaySeconds,
EditorApplication.Exit);
EditorApplication.CallbackFunction exitWhenDue = null;
exitWhenDue = () =>
{
if (!delayedExit.ExitIfDue(EditorApplication.timeSinceStartup))
{
return;
}

EditorApplication.update -= exitWhenDue;
};
// Why update rather than delayCall: an Editor in the background that is not repainting does not
// run delayCall, so the exit would never come.
EditorApplication.update += exitWhenDue;
return new QuitEditorResponse();
}
}
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Packages/src/Editor/ToolContracts/UnityCliLoopConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ public static UnityEditor.PackageManager.PackageInfo PackageInfo
public const string COMMAND_NAME_GET_RUN_TESTS_STATUS = "get-run-tests-status";
public const string COMMAND_NAME_GET_EDITOR_STATUS = "get-editor-status";
public const string COMMAND_NAME_CANCEL_RUNNING_TOOL = "cancel-running-tool";
public const string COMMAND_NAME_QUIT_EDITOR = "quit-editor";
public const string COMMAND_NAME_AWAIT_PAUSE_POINT = "await-pause-point";
public const string SETTINGS_TOOL_NAME_PAUSE_POINT = "pause-point";
public const string COMMAND_NAME_PAUSE_POINT_STATUS = "pause-point-status";
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,7 @@ uloop launch
# Launch with build target (Android, iOS, StandaloneOSX, etc.)
uloop launch -p Android

# Kill running Unity and restart
# Quit running Unity and restart
uloop launch -r

# Check .meta files, GUIDs, and conflict markers without Unity
Expand Down
Loading
Loading