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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

## [0.5.3] - 2026-08-15

### Added
- Long-running commands now finish faster. Test runs, package operations, profiler sampling, recording and `qa run-sequence` all advance one step per Editor update tick, and an idle Editor runs that loop slowly — measured at roughly six ticks per second, whether or not its window is in front. While one of those commands is in flight the bridge now keeps the Editor ticking at full rate and lets it settle back the moment the work finishes, which measured about 1.5× faster end to end (60 ticks: 9.4–10.4 s → 6.0–6.1 s on Unity 6000.3 / macOS). Nothing to turn on, and on an Editor version that does not expose the internal API this relies on, commands simply run at the old speed.
- Component values may now be written as JSON arrays: `"m_Center": [1, 2, 3]` alongside the existing `{"x": 1, "y": 2, "z": 3}`. Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt and Color all accept the short form, and a Color array may leave off alpha.
- Structured values that arrive quoted — `"[1,2,3]"` or `"{\"x\":1,\"y\":2,\"z\":3}"` instead of the JSON value itself — are now parsed instead of rejected. AI agents produce this shape often, and the previous error read as though the value were wrong rather than the quoting. Only strings that open a JSON object or array are re-read, so asset paths, object-reference handles, enum names and plain text are untouched, and a string that fails to parse still produces the original validation error.
- The bridge can now be kept from starting, for build machines that have no use for it. Set `UNITY_CLI_BRIDGE_DISABLE=1` or pass `-noUnityCliBridge` on the Editor command line and the bridge builds nothing at all — no socket, no instance registration, no token file, no console warnings if any of that would have failed. A CI job that only opens the Editor to produce a player build gets a quiet log and no registry lock to contend over with parallel builds.

### Changed
- **`screenshot` now defaults to a JPEG capped at 1024px wide** (quality 75) instead of a full-resolution PNG. A capture is by far the most expensive thing an AI agent can ask for — a 1920×1080 PNG bills at roughly 2,040 image tokens, against about 576 for the same frame capped at 1024px — and the capped JPEG is still perfectly readable for checking UI state, which is what nearly every capture is for. Three things keep the change from surprising anyone: `--format png` still gives you lossless, a `--path` ending in `.png` selects PNG on its own, and an explicit `--width`/`--height` is left exactly as you asked. `--max-width 0` turns the cap off while keeping the automatic size. Tap and dump coordinates are unaffected — they scale from the last capture's size, so a downscaled screenshot's coordinates still land correctly.
- Unity Recorder is no longer installed alongside the package. Only the `record` commands and `qa run-sequence --record` ever used it, so projects that do not record no longer carry the dependency. If you do record, add `com.unity.recorder` to your project — `record start` now fails with that exact instruction when it is missing, and `record stop` / `record status` keep working on recordings you already have. `com.unity.test-framework`, which the test commands genuinely require, is now declared outright instead of arriving as a side effect of the Recorder dependency.

### Fixed
- The package no longer compiles its editor-side plumbing into your game. Everything the bridge uses to talk to the CLI — the instance registry, file-backup transactions, the command catalog and its help text — was being built into player builds as unreachable code, roughly 150 KB of it, along with the strings it carries. Player builds now receive only the QA marker types (`[QaTarget]`, `IQaTappable`, `IQaQueryable`, `QaTappable`) that a project references on purpose, about 7 KB. Nothing changes for editor use, and no code needs updating.
- An Editor whose IPC listener died is no longer advertised as reachable. The listener could stop accepting connections for good — an unexpected socket failure, or a first bind that never succeeded — while the instance kept publishing itself, so the CLI would route to it and fail to connect over and over. The bridge now watches its own listener and re-binds it in place within a few seconds; if it cannot be revived, the instance removes itself from the registry with an Editor console message telling you to restart, instead of staying on the list as a target that never answers.
- The IPC auth token is now compared in fixed time, so the check cannot leak the expected token through timing to other processes on the same machine. Authentication behavior is otherwise unchanged.

## [0.5.2] - 2026-08-13

### Added
Expand Down
20 changes: 15 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ Add the following to your `Packages/manifest.json`:

The bridge starts automatically when the Editor opens. No configuration needed.

`#main` always tracks the latest release. For a build you need to reproduce later, pin a release tag instead — `...#v0.5.3` — and commit `Packages/packages-lock.json`.

Unity Recorder is optional and is not installed for you. Add `com.unity.recorder` if you want the `record` commands or `qa run-sequence --record`; without it those commands fail with an install hint and everything else works normally.

> **Upgrading from 0.3.x?** `0.4.0` bumps the wire protocol to `5`, so a `0.3.x` CLI cannot talk to a `0.4.0` package. From `0.4.1` on you no longer have to choose: install the CLI from each project's **Window > Unity CLI Manager**, and the `unity-cli` on your PATH hands off to the version that matches whichever project you are pointing at.

### 2. Install the CLI
Expand Down Expand Up @@ -152,8 +156,9 @@ unity-cli play / pause / stop # Play Mode control
unity-cli compile # Trigger recompile
unity-cli compile --wait # Wait until compile/import finishes and bridge is reachable
unity-cli refresh --wait # Refresh assets and wait for Editor readiness
unity-cli screenshot --path /tmp/shot.png # Game View capture (default), or --view scene
unity-cli screenshot --format jpg --quality 70 --max-width 1024 --path /tmp/shot.jpg
unity-cli screenshot --path /tmp/shot.jpg # Game View capture (default), or --view scene
# Defaults: JPEG q75, downscaled to 1024px wide
unity-cli screenshot --format png --max-width 0 --path /tmp/shot.png # Lossless, full resolution
unity-cli record start --duration 5 --wait --path /tmp/play.mp4
unity-cli record start # Manual recording; stop with record stop
unity-cli record status
Expand Down Expand Up @@ -285,8 +290,8 @@ A typical AI repair loop is: make a focused code change, `unity-cli refresh`, ru

```bash
unity-cli qa click --qa-id StartButton
unity-cli screenshot --view game --path /tmp/qa-reference.png
unity-cli screenshot --view game --format jpg --quality 70 --max-width 1024 --path /tmp/qa-reference.jpg
unity-cli screenshot --view game --path /tmp/qa-reference.jpg
unity-cli screenshot --view game --format png --max-width 0 --path /tmp/qa-reference.png
unity-cli qa ui-dump --json
unity-cli qa ui-dump --text Start --interactable-only --limit 20 --omit-rect --json
unity-cli qa world-dump --json
Expand Down Expand Up @@ -406,8 +411,25 @@ docs/ Generated CLI reference, specs
- **Scene paths:** `/Root[0]/Child[0]` format with sibling indices. `/` is the virtual scene root.
- **Inspect before patch:** Always `scene inspect --with-values` or `prefab inspect --with-values` before writing patch specs.
- **Friendly component keys:** Common Rigidbody, Collider, Renderer, Light, and Camera patch keys are resolved to Unity `SerializedProperty.propertyPath` values.
- **Component value shapes:** Vector2/3/4, Vector2Int/Vector3Int, Quaternion, Rect, RectInt, and Color accept both the member object (`{"x":1,"y":2,"z":3}`) and the array shorthand (`[1,2,3]`); a Color array may omit alpha. A structured value that arrives quoted (`"[1,2,3]"`) is re-parsed, but send real JSON values — the fallback never applies to genuine string fields such as asset paths or enum names.
- **set-node warnings:** Unrecognized keys now return warnings instead of silent success.

## Release Builds & CI

The bridge is editor-only. The single assembly that reaches a shipped player is `UnityCliBridge.Bridge.Runtime`, which holds nothing but the QA marker types you reference on purpose (`[QaTarget]`, `IQaTappable`, `IQaQueryable`, `QaTappable`). No listener is bound, no registry entry is written, and no bridge code runs in a build.

The bridge *does* start in any main editor process, including the headless editor a build job launches. That is usually harmless but pointless on a build machine — it binds a socket, publishes a registry entry, and contends for the registry lock when several builds share a host. Turn it off for those jobs:

```bash
# Environment variable — anything but 0/false/empty counts as "disable"
UNITY_CLI_BRIDGE_DISABLE=1 Unity -batchmode -quit -projectPath . -executeMethod Build.Run

# Or the editor command-line flag, for jobs that cannot set an environment variable
Unity -batchmode -quit -projectPath . -noUnityCliBridge -executeMethod Build.Run
```

A disabled bridge constructs nothing and logs one line saying so.

## Documentation

- [CLI Reference (generated)](docs/cli-reference.md)
Expand Down
19 changes: 18 additions & 1 deletion cli/UnityCli.Cli/Models/ParsedCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,7 @@ private string BuildArgumentsJson()
height = ScreenshotHeight ?? 0,
format = ScreenshotFormat,
quality = ScreenshotQuality ?? 0,
maxWidth = ScreenshotMaxWidth ?? 0,
maxWidth = ResolveScreenshotMaxWidth(),
},
CommandKind.PackageList => new PackageListArgs
{
Expand Down Expand Up @@ -749,6 +749,23 @@ private string BuildRawArgumentsJson(JsonElement root)
: ScreenshotView;
}

/// <summary>
/// Maps <c>--max-width</c> onto the wire, where 0 means "unspecified, apply the default cap".
/// A caller who passed <c>--max-width 0</c> is asking for the opposite — no cap at all — so it
/// travels as the uncapped sentinel rather than as a value the bridge would read as silence.
/// </summary>
private int ResolveScreenshotMaxWidth()
{
if (ScreenshotMaxWidth is null)
{
return 0;
}

return ScreenshotMaxWidth.Value > 0
? ScreenshotMaxWidth.Value
: ScreenshotDefaults.MaxWidthUncapped;
}

private string? BuildAssetCreateOptionsJson()
{
var options = new Dictionary<string, object?>(AssetCustomOptions, StringComparer.OrdinalIgnoreCase);
Expand Down
4 changes: 3 additions & 1 deletion cli/UnityCli.Cli/Services/CliArgumentParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,9 @@ private static void ParseCommandOptions(ParsedCommand parsed, Queue<string> toke
parsed.ScreenshotQuality = RequireScreenshotQuality(RequireValue(tokens, "--quality"));
break;
case CommandKind.Screenshot when token == "--max-width":
parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width");
// 0 is allowed and means "no cap" — the way to opt out of the default downscale
// without having to state an explicit --width/--height.
parsed.ScreenshotMaxWidth = RequireInt(RequireValue(tokens, "--max-width"), "--max-width", minimumValue: 0);
break;
case CommandKind.ExecuteCode when token == "--code":
parsed.ExecuteCodeSnippet = RequireValue(tokens, "--code");
Expand Down
2 changes: 1 addition & 1 deletion docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Commands for editor state, compilation, play state, menus, arbitrary code execut
| `editor launch` | `editor launch [--gui] [--nographics] [--no-wait] [--timeout <sec>] [--editor-path <path>]` | local | `None` | Launches the Unity Editor for the selected project (headless -batchmode by default, GPU kept for rendering commands). Idempotent: reuses a live instance when one is already running. Waits for bridge readiness unless --no-wait. |
| `editor stop` | `editor stop [--force] [--no-wait] [--timeout <sec>]` | live | `OnDestructiveOp` | Gracefully quits the running editor for the selected project. Refuses with EDITOR_DIRTY when unsaved scene/prefab-stage changes exist; --force discards them. Waits for process exit unless --no-wait. |
| `execute-menu` | `execute-menu (--path "Menu/Item" \| --list "Prefix")` | live | `None` | Executes a Unity menu item or lists registered menu items matching a prefix in a running editor. |
| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera <name>] [--path <output.png\|output.jpg>] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View; encoding defaults to PNG. Use --format jpg with --quality to reduce file size, and --max-width to downscale proportionally when --width/--height are not specified. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. |
| `screenshot` | `screenshot [--view game\|scene (default: game) \| --camera <name>] [--path <output.jpg\|output.png>] [--width N] [--height N] [--format png\|jpg\|jpeg] [--quality 1-100] [--max-width N\|0]` | live | `None` | Captures a screenshot from the Game View, Scene View, or a named camera. Defaults to Game View, JPEG at quality 75, and a 1024px width cap — agent-friendly defaults that cut image tokens by roughly 72% at 1080p. Override with --format png for lossless, --quality for the JPEG setting, --max-width N for a different cap, or --max-width 0 for no cap. The cap only applies when neither --width nor --height is given, and --path with a .png extension selects PNG when --format is omitted. The response includes image size, actual saved format, and screen-space metadata (`screenWidth`, `screenHeight`, `imageOrigin`, `coordinateOrigin`) for QA coordinate alignment; qa tap/ui-dump scale from the last captured size, so downscaled shots keep their coordinates usable. In Play Mode, --view game can downscale the native Game View capture but does not upscale it. |
| `record start` | `record start [--path <output.mp4>] [--fps <n> (default: 30)] [--max-width <n>] [--duration <seconds>] [--wait]` | live | `None` | Starts recording the Game View to an mp4 file via Unity Recorder. Returns immediately with a recordingId. Requires Play Mode. |
| `record stop` | `record stop` | live | `None` | Stops the active recording, finalizes the mp4, and returns the output path. |
| `record status` | `record status [--recording-id <id>]` | live | `None` | Reports whether a recording is active and the result of a finished recording. |
Expand Down
58 changes: 58 additions & 0 deletions tests/UnityCli.Cli.Tests/AuthTokenComparisonTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
using UnityCli.Protocol;

namespace UnityCli.Cli.Tests;

public sealed class AuthTokenComparisonTests
{
[Fact]
public void FixedTimeEquals_WhenIdentical_ReturnsTrue()
{
string token = new string('a', 64);

Assert.True(AuthTokenComparison.FixedTimeEquals(token, new string('a', 64)));
}

[Fact]
public void FixedTimeEquals_WhenLastCharacterDiffers_ReturnsFalse()
{
string expected = new string('a', 64);
string candidate = expected.Substring(0, 63) + "b";

Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}

[Fact]
public void FixedTimeEquals_WhenFirstCharacterDiffers_ReturnsFalse()
{
string expected = new string('a', 64);
string candidate = "b" + expected.Substring(1);

Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}

[Fact]
public void FixedTimeEquals_IsCaseSensitive()
{
Assert.False(AuthTokenComparison.FixedTimeEquals("abcdef", "ABCDEF"));
}

[Theory]
[InlineData("token", "")]
[InlineData("", "token")]
[InlineData("", "")]
[InlineData("token", null)]
[InlineData(null, "token")]
[InlineData(null, null)]
public void FixedTimeEquals_WhenEitherSideIsMissing_ReturnsFalse(string? expected, string? candidate)
{
Assert.False(AuthTokenComparison.FixedTimeEquals(expected!, candidate!));
}

[Theory]
[InlineData("abcdef", "abcde")]
[InlineData("abcde", "abcdef")]
public void FixedTimeEquals_WhenPrefixMatchesButLengthDiffers_ReturnsFalse(string expected, string candidate)
{
Assert.False(AuthTokenComparison.FixedTimeEquals(expected, candidate));
}
}
88 changes: 88 additions & 0 deletions tests/UnityCli.Cli.Tests/BridgeDisableSwitchTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
using UnityCli.DocGen;
using UnityCli.Protocol;

namespace UnityCli.Cli.Tests;

/// <summary>
/// The opt-out switch a CI/release build job uses to keep the bridge from booting. Parsing is
/// covered directly; the ordering guard below is the part that cannot be unit-tested, because the
/// switch is only worth anything if it runs before the host is constructed.
/// </summary>
public sealed class BridgeDisableSwitchTests
{
private static readonly string[] NoArgs = Array.Empty<string>();

[Theory]
[InlineData("1")]
[InlineData("true")]
[InlineData("TRUE")]
[InlineData("yes")]
[InlineData(" 1 ")]
public void IsDisabled_WhenEnvironmentValueIsSet_DisablesTheBridge(string value)
{
Assert.True(BridgeDisableSwitch.IsDisabled(value, NoArgs));
}

[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("0")]
[InlineData("false")]
[InlineData("False")]
public void IsDisabled_WhenEnvironmentValueMeansOff_LeavesTheBridgeRunning(string? value)
{
Assert.False(BridgeDisableSwitch.IsDisabled(value!, NoArgs));
}

[Fact]
public void IsDisabled_WhenCommandLineFlagIsPresent_DisablesTheBridge()
{
string[] args = { "/Applications/Unity", "-batchmode", "-noUnityCliBridge", "-quit" };

Assert.True(BridgeDisableSwitch.IsDisabled(null!, args));
}

[Fact]
public void IsDisabled_WhenCommandLineFlagCasingDiffers_StillDisablesTheBridge()
{
string[] args = { "-nounityclibridge" };

Assert.True(BridgeDisableSwitch.IsDisabled(null!, args));
}

[Fact]
public void IsDisabled_WhenAnUnrelatedFlagIsPresent_LeavesTheBridgeRunning()
{
string[] args = { "-batchmode", "-nographics", "-quit" };

Assert.False(BridgeDisableSwitch.IsDisabled(null!, args));
}

[Fact]
public void IsDisabled_WhenArgumentsAreNull_DoesNotThrow()
{
Assert.False(BridgeDisableSwitch.IsDisabled(null!, null!));
}

[Fact]
public void Bootstrap_ChecksTheSwitchBeforeConstructingTheHost()
{
string repoRoot = RepositoryPaths.FindRepoRoot(AppContext.BaseDirectory);
string source = File.ReadAllText(Path.Combine(
repoRoot,
"unity-package",
"com.yhc509.unity-cli-bridge",
"Editor",
"BridgeHost.cs"));

int switchIndex = source.IndexOf("BridgeDisableSwitch.IsDisabled(", StringComparison.Ordinal);
int constructionIndex = source.IndexOf("new BridgeHost()", StringComparison.Ordinal);

Assert.True(switchIndex >= 0, "BridgeHost.cs must consult BridgeDisableSwitch.");
Assert.True(constructionIndex >= 0, "BridgeHost.cs must still construct the host.");
Assert.True(
switchIndex < constructionIndex,
"The disable switch must be evaluated before the host is constructed.");
}
}
Loading