Add Unity Pipeline package support
This commit is contained in:
+143
@@ -0,0 +1,143 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Captures a UI Toolkit <see cref="VisualElement"/> from a live runtime panel to a PNG and
|
||||
/// returns it base64-encoded (and writes it to disk). Runtime-only so it is available in a
|
||||
/// development Player as well as the editor's Play mode.
|
||||
///
|
||||
/// A runtime panel is hosted by either a <see cref="UIDocument"/> or a <see cref="PanelRenderer"/>.
|
||||
/// The panel is identified by name (the <see cref="PanelSettings"/> asset name or the host
|
||||
/// GameObject name) rather than an asset path, because a Player has no AssetDatabase.
|
||||
/// </summary>
|
||||
public static class CaptureRuntimeElementCommand
|
||||
{
|
||||
[CliCommand("capture_runtime_element",
|
||||
"Capture a UI Toolkit VisualElement (by selector) from a live runtime panel (UIDocument or PanelRenderer) to a PNG; returns path + base64.",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static CaptureElementResponse CaptureRuntimeElement(
|
||||
[CliArg("panel", "Name of the target panel: matches the PanelSettings asset name or the host GameObject name (UIDocument or PanelRenderer). Optional when exactly one panel exists.")] string panel = "",
|
||||
[CliArg("selector", "Element selector: '#name', '.class', a type name (e.g. Button), descendant (space) / child ('>') chains, optional pseudo-states (:checked,:hover,:focus,:active,:enabled,:disabled,:not(...)).", Required = true)] string selector = "",
|
||||
[CliArg("output", "Output PNG path (absolute, or relative to Application.persistentDataPath). Defaults to a timestamped file under Application.persistentDataPath.")] string output = "")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(selector))
|
||||
return CaptureElementResponse.Fail("A 'selector' is required.");
|
||||
|
||||
var hosts = GatherHosts();
|
||||
if (hosts.Count == 0)
|
||||
return CaptureElementResponse.Fail("No live runtime UI panels found (UIDocument or PanelRenderer). Show the UI first.");
|
||||
|
||||
List<Host> matched;
|
||||
string resolvedName;
|
||||
if (string.IsNullOrWhiteSpace(panel))
|
||||
{
|
||||
if (hosts.Count > 1)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Multiple runtime panels exist; specify --panel as one of: {names}.");
|
||||
}
|
||||
|
||||
matched = hosts;
|
||||
resolvedName = hosts[0].DisplayName;
|
||||
}
|
||||
else
|
||||
{
|
||||
matched = hosts.Where(h => h.PanelSettingsName == panel || h.GameObjectName == panel).ToList();
|
||||
if (matched.Count == 0)
|
||||
{
|
||||
var names = string.Join(", ", hosts.Select(h => h.DisplayName).Distinct());
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No live runtime panel named '{panel}'. Available: {names}.");
|
||||
}
|
||||
|
||||
resolvedName = panel;
|
||||
}
|
||||
|
||||
// Lower sorting order is drawn first (further back); query in that order so the topmost
|
||||
// match is found last only if earlier panels miss. Roots that can't be resolved are skipped.
|
||||
var roots = matched
|
||||
.OrderBy(h => h.SortingOrder)
|
||||
.Select(h => h.GetRoot())
|
||||
.Where(r => r != null)
|
||||
.ToList();
|
||||
|
||||
if (roots.Count == 0)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"Panel '{resolvedName}' has no initialized root VisualElement yet. Show the UI first.");
|
||||
|
||||
var element = VisualElementCaptureSupport.ResolveElement(roots, selector, out _);
|
||||
if (element == null)
|
||||
return CaptureElementResponse.Fail(
|
||||
$"No element matched selector '{selector}' in panel '{resolvedName}'.");
|
||||
|
||||
var dir = Application.persistentDataPath;
|
||||
return VisualElementCaptureSupport.CaptureAndRespond(
|
||||
element, selector, $"panel:{resolvedName}", output,
|
||||
relativeBaseDir: dir, defaultDir: dir, prefix: "element");
|
||||
}
|
||||
|
||||
/// <summary>A live runtime UI host (UIDocument or PanelRenderer) and how to reach its root.</summary>
|
||||
class Host
|
||||
{
|
||||
public string PanelSettingsName;
|
||||
public string GameObjectName;
|
||||
public float SortingOrder;
|
||||
public System.Func<VisualElement> GetRoot;
|
||||
|
||||
public string DisplayName => PanelSettingsName ?? GameObjectName;
|
||||
}
|
||||
|
||||
static List<Host> GatherHosts()
|
||||
{
|
||||
var hosts = new List<Host>();
|
||||
|
||||
foreach (var doc in PipelineUtils.FindObjectsByType<UIDocument>())
|
||||
{
|
||||
var d = doc;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = d.panelSettings != null ? d.panelSettings.name : null,
|
||||
GameObjectName = d.gameObject.name,
|
||||
SortingOrder = d.sortingOrder,
|
||||
GetRoot = () => d.rootVisualElement
|
||||
});
|
||||
}
|
||||
|
||||
// PanelRenderer is a 6000.5+ UI Toolkit API; on older editors only UIDocument panels exist.
|
||||
foreach (var renderer in PipelineUtils.FindObjectsByType<PanelRenderer>())
|
||||
{
|
||||
var r = renderer;
|
||||
hosts.Add(new Host
|
||||
{
|
||||
PanelSettingsName = r.panelSettings != null ? r.panelSettings.name : null,
|
||||
GameObjectName = r.gameObject.name,
|
||||
SortingOrder = r.sortingOrder,
|
||||
GetRoot = () => GetPanelRendererRoot(r)
|
||||
});
|
||||
}
|
||||
|
||||
return hosts;
|
||||
}
|
||||
|
||||
// PanelRenderer.rootVisualElement is internal; the public route to its root is a UI-reload
|
||||
// callback, which fires synchronously when the panel is already initialized. Register, capture
|
||||
// the root, and immediately unregister. PanelRenderer is a 6000.5+ API.
|
||||
static VisualElement GetPanelRendererRoot(PanelRenderer renderer)
|
||||
{
|
||||
VisualElement captured = null;
|
||||
PanelRenderer.VersionedUIReloadCallback callback = (pr, root, version) => captured = root;
|
||||
renderer.RegisterUIReloadCallback(callback);
|
||||
renderer.UnregisterUIReloadCallback(callback);
|
||||
return captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 54dc75c59cc13424c9e6e0e7b237e412
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Models;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using UnityEngine;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Command for evaluating C# code dynamically using Roslyn compilation.
|
||||
/// Requires security token for authorization.
|
||||
/// </summary>
|
||||
public static class CodeEvalCommand
|
||||
{
|
||||
[CliCommand("eval", "Evaluate C# code dynamically using Roslyn compiler", MainThreadRequired = true)]
|
||||
public static EvalResponse EvaluateCode(
|
||||
[CliArg("code", "C# code to evaluate", Required = true)] string code,
|
||||
[CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"Code parameter is required and cannot be empty");
|
||||
}
|
||||
|
||||
return EvaluateSource(code, timeout);
|
||||
}
|
||||
|
||||
[CliCommand("eval_file", "Evaluate C# code read from a .cs file on disk", MainThreadRequired = true)]
|
||||
public static EvalResponse EvaluateFile(
|
||||
[CliArg("file", "Path to a .cs file to evaluate", Required = true)] string file,
|
||||
[CliArg("timeout", "Timeout in milliseconds")] int timeout = 5000)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"File parameter is required and cannot be empty");
|
||||
}
|
||||
|
||||
if (!file.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"File must have a .cs extension");
|
||||
}
|
||||
|
||||
if (!System.IO.File.Exists(file))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"File not found: {file}");
|
||||
}
|
||||
|
||||
string code;
|
||||
try
|
||||
{
|
||||
code = System.IO.File.ReadAllText(file);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"Failed to read file: {ex.Message}");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
{
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
$"File is empty: {file}");
|
||||
}
|
||||
|
||||
return EvaluateSource(code, timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared evaluation path: compiles and executes the given C# source and returns the
|
||||
/// response. Both the <c>eval</c> and <c>eval_file</c> commands funnel their resolved
|
||||
/// source string into here.
|
||||
/// </summary>
|
||||
private static EvalResponse EvaluateSource(string code, int timeout)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Validate timeout
|
||||
if (timeout <= 0 || timeout > 30000)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return EvalResponse.EvalFailure(
|
||||
"Bad Request",
|
||||
"Timeout must be between 1ms and 30000ms",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Execute compilation and evaluation. We're already on the main thread
|
||||
// (MainThreadRequired = true), so call the synchronous path directly.
|
||||
var result = EvalCodeCompiler.CompileAndExecuteOnMainThread(code, timeout, null);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
// Update execution time
|
||||
if (result != null)
|
||||
{
|
||||
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
return result ?? EvalResponse.EvalFailure(
|
||||
"Unknown Error",
|
||||
"Compilation returned null result",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"Pipeline: Eval command failed: {ex.Message}");
|
||||
Debug.LogError($"Pipeline: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return EvalResponse.EvalFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bc1d3406462d2c246a5f84dd30dd02c7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,45 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Console;
|
||||
using Unity.Pipeline.Models;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns captured Unity console output. Backs the CLI's
|
||||
/// <c>console [--tail N] [--level log|warn|error] [--follow]</c>.
|
||||
///
|
||||
/// Lives in the runtime assembly, so it is available from both the Editor and player builds —
|
||||
/// console capture is driven by <see cref="ConsoleLogCapture"/>, which runs in both contexts.
|
||||
///
|
||||
/// The package has no streaming transport (the HTTP server handles one request at a time), so
|
||||
/// <c>--follow</c> is realized client-side: the CLI polls this command, prints new entries, and
|
||||
/// passes the returned <see cref="ConsoleLogResponse.Cursor"/> back as <c>since</c> on the next
|
||||
/// call. The flag mapping is:
|
||||
/// --tail N -> tail
|
||||
/// --level X -> level (minimum severity threshold)
|
||||
/// --follow -> repeated calls with since = previous cursor
|
||||
///
|
||||
/// Reads only the in-memory buffer (no Unity main-thread APIs), so it is marked
|
||||
/// <c>MainThreadRequired = false</c> for fast polling.
|
||||
/// </summary>
|
||||
public static class ConsoleCommand
|
||||
{
|
||||
/// <summary>Default number of entries returned when <c>tail</c> is not supplied.</summary>
|
||||
public const int DefaultTail = 100;
|
||||
|
||||
[CliCommand("console", "Get captured Unity console output (Editor or Player; supports tail, level filtering, and follow via a cursor)", MainThreadRequired = false)]
|
||||
public static ConsoleLogResponse GetConsole(
|
||||
[CliArg("tail", "Maximum number of most-recent entries to return")] int tail = DefaultTail,
|
||||
[CliArg("level", "Minimum severity to include: log | warn | error")] string level = ConsoleLogBuffer.LevelLog,
|
||||
[CliArg("since", "Cursor: only return entries newer than this seq. Use the 'cursor' from a previous response to follow.")] long since = -1)
|
||||
{
|
||||
// Guard against nonsensical tail values; <= 0 would otherwise mean "unlimited" in the
|
||||
// buffer, which is not what a caller passing e.g. --tail 0 expects.
|
||||
if (tail <= 0)
|
||||
tail = DefaultTail;
|
||||
|
||||
var minSeverity = ConsoleLogBuffer.SeverityFromLevelName(level);
|
||||
return ConsoleLogCapture.Buffer.Query(since, tail, minSeverity);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 92be7cfe398a263a7b06a4a8a00a802c
|
||||
+563
@@ -0,0 +1,563 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Compilation;
|
||||
using Unity.Pipeline.HotReload;
|
||||
using Unity.Pipeline.Models;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// CLI commands for hot reload operations.
|
||||
/// Provides runtime compilation and management of hot reload files.
|
||||
/// Follows existing [CliCommand] patterns and integrates with Pipeline Server.
|
||||
/// </summary>
|
||||
public static class HotReloadCommands
|
||||
{
|
||||
[CliCommand("reload_file_override", "Compile and apply hot reload file changes immediately", MainThreadRequired = true)]
|
||||
internal static HotReloadResponse ReloadFileOverride(
|
||||
[CliArg("filename", "Hot reload source file to compile (e.g. PlayerTweaks.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Validate filename input
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Filename parameter is required and cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Ensure filename ends with .cs
|
||||
if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filename += ".cs";
|
||||
}
|
||||
|
||||
// Validate timeout
|
||||
if (timeout <= 0 || timeout > 300000) // Max 5 minutes
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Timeout must be between 1ms and 300000ms (5 minutes)",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Executing reload_file_override command (helper workflow): {filename}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}");
|
||||
|
||||
// Resolve the override file path (absolute, or relative to the project root).
|
||||
var fullPath = Path.IsPathRooted(filename) ? filename : Path.GetFullPath(filename);
|
||||
|
||||
// In a Player build, the file must be inside the project's build-baked roots. The
|
||||
// project layout cannot be resolved from a running build, so this is skipped in the
|
||||
// editor (which resolves files against the live project).
|
||||
if (!Application.isEditor)
|
||||
{
|
||||
var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"File Not Found",
|
||||
$"Override file not found: {fullPath}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// Up-front validation so a misconfigured override file produces a clear, actionable
|
||||
// message instead of a confusing compiler error from generated/duplicate code.
|
||||
var validation = OverrideFileValidator.Validate(File.ReadAllText(fullPath), Path.GetFileName(fullPath));
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Invalid Override File",
|
||||
validation.GetFormattedErrorMessage(),
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
validation.Errors);
|
||||
}
|
||||
|
||||
// Compile and apply (helper / separate override file workflow).
|
||||
var task = ExecuteHotReloadAsync(fullPath, timeout, assemblyDir);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result != null)
|
||||
{
|
||||
result.ExecutionTimeMs = stopwatch.ElapsedMilliseconds;
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: reload_file_override completed in {stopwatch.ElapsedMilliseconds}ms, success: {result?.Success}");
|
||||
|
||||
return result ?? HotReloadResponse.CmdFailure(
|
||||
"Unknown Error",
|
||||
"Hot reload compilation returned null result",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: reload_file_override command failed: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("reload_file", "Compile and apply in-place [HotReload] edits from a source file", MainThreadRequired = true)]
|
||||
public static HotReloadResponse ReloadFile(
|
||||
[CliArg("filename", "Source file containing [HotReload] methods (e.g. Assets/Scripts/Player.cs)", Required = true)] string filename,
|
||||
[CliArg("timeout", "Compilation timeout in milliseconds")] int timeout = 30000,
|
||||
[CliArg("assemblyDir", "Directory to save compiled assemblies to disk (optional, default is in-memory only)")] string assemblyDir = null,
|
||||
[CliArg("pdb", "Emit debug symbols (portable PDB) mapped to the original source so breakpoints bind in your editor. Compiles unoptimized.")] bool pdb = false)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Bad Request",
|
||||
"Filename parameter is required and cannot be empty",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
if (!filename.EndsWith(".cs", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
filename += ".cs";
|
||||
}
|
||||
|
||||
var fullPath = ResolveSourceFilePath(filename);
|
||||
if (string.IsNullOrEmpty(fullPath))
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"File Not Found",
|
||||
$"Could not locate source file: {filename}",
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// In a Player build, the file must be inside the project's build-baked roots. The
|
||||
// project layout cannot be resolved from a running build, so this is skipped in the
|
||||
// editor (which resolves files against the live project).
|
||||
if (!Application.isEditor)
|
||||
{
|
||||
var scopeCheck = ValidateReloadPath(fullPath, HotReloadRegistry.AllowedReloadRoots);
|
||||
if (!scopeCheck.IsValid)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return HotReloadResponse.CmdFailure(
|
||||
scopeCheck.Error,
|
||||
scopeCheck.ErrorDetails,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: Executing reload_file command: {fullPath}, timeout: {timeout}ms, assemblyDir: {assemblyDir ?? "in-memory"}, pdb: {pdb}");
|
||||
|
||||
var task = InPlaceReloadProcessor.ProcessSourceFileAsync(fullPath, assemblyDir, pdb);
|
||||
task.Wait();
|
||||
var result = task.Result;
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (result.Success)
|
||||
{
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
result.AssemblyName,
|
||||
$"In-place hot reload successful: {result.AssemblyName} with {result.RegisteredMethods.Count} methods",
|
||||
result.RegisteredMethods,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"In-Place Reload Failed",
|
||||
result.ErrorMessage ?? "In-place hot reload processing failed",
|
||||
stopwatch.ElapsedMilliseconds,
|
||||
result.CompilationDiagnostics);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: reload_file command failed: {ex.Message}");
|
||||
Debug.LogError($"HotReload: Stack trace: {ex.StackTrace}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("cleanup_hotreload", "Remove old hot reload DLL versions and clear registry", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static HotReloadResponse CleanupHotReload(
|
||||
[CliArg("assemblyDir", "Directory containing assemblies to cleanup", Required = true)] string assemblyDir,
|
||||
[CliArg("force_domain_reload", "Force Unity domain reload after cleanup")] bool forceDomainReload = true)
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
Debug.Log($"HotReload: Executing cleanup_hotreload command for directory: {assemblyDir}");
|
||||
|
||||
// Execute cleanup
|
||||
var cleanupResult = HotReloadCompiler.CleanupHotReloadDlls(assemblyDir);
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
if (cleanupResult.Success)
|
||||
{
|
||||
var message = $"Cleanup successful: {cleanupResult.Message}";
|
||||
if (cleanupResult.DeletedFiles.Count > 0)
|
||||
{
|
||||
message += $" Files removed: {string.Join(", ", cleanupResult.DeletedFiles)}";
|
||||
}
|
||||
|
||||
// Force domain reload if requested (interrupts gameplay but ensures clean state)
|
||||
if (forceDomainReload && Application.isEditor)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
Debug.Log("HotReload: Requesting Unity domain reload to clean memory state");
|
||||
UnityEditor.EditorUtility.RequestScriptReload();
|
||||
message += " Unity domain reload requested.";
|
||||
#endif
|
||||
}
|
||||
|
||||
Debug.Log($"HotReload: cleanup_hotreload completed successfully in {stopwatch.ElapsedMilliseconds}ms");
|
||||
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
"cleanup_completed",
|
||||
message,
|
||||
cleanupResult.DeletedFiles,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Cleanup Failed",
|
||||
cleanupResult.Message,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: cleanup_hotreload command failed: {ex.Message}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
[CliCommand("hotreload_status", "Show current hot reload registry status and statistics", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static HotReloadResponse HotReloadStatus()
|
||||
{
|
||||
var stopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
// Get current registry stats
|
||||
var stats = HotReloadRegistry.GetStats();
|
||||
|
||||
stopwatch.Stop();
|
||||
|
||||
var statusMessage = $"Hot Reload Status - " +
|
||||
$"Reloadable Methods: {stats.ReloadableMethodCount}, " +
|
||||
$"Active Overrides: {stats.ActiveOverrideCount}, " +
|
||||
$"Loaded Types: {stats.LoadedTypeCount}";
|
||||
|
||||
Debug.Log($"HotReload: {statusMessage}");
|
||||
|
||||
return HotReloadResponse.CmdSuccess(
|
||||
"status_retrieved",
|
||||
statusMessage,
|
||||
stats.ActiveOverrideIds,
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
Debug.LogError($"HotReload: hotreload_status command failed: {ex.Message}");
|
||||
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
stopwatch.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate that a resolved file path lives inside one of the allowed project roots and
|
||||
/// exists on disk. "In scope" means under the Assets folder or a loaded package's location.
|
||||
/// The path is normalized (collapsing any <c>..</c> segments) before the scope check, so a
|
||||
/// path that escapes the allowed roots via traversal is rejected. A null or empty root set
|
||||
/// matches nothing, so the path is rejected as out of scope. This is a security boundary —
|
||||
/// it prevents files outside the project from being compiled and injected into the assembly.
|
||||
/// </summary>
|
||||
public static PathValidationResult ValidateReloadPath(string resolvedPath, System.Collections.Generic.IReadOnlyCollection<string> allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(resolvedPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", "Resolved file path is empty.");
|
||||
}
|
||||
|
||||
string fullPath;
|
||||
try
|
||||
{
|
||||
fullPath = Path.GetFullPath(resolvedPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return PathValidationResult.Invalid("Bad Request", $"Invalid path: {ex.Message}");
|
||||
}
|
||||
|
||||
var underAnyRoot = false;
|
||||
if (allowedRoots != null)
|
||||
{
|
||||
foreach (var root in allowedRoots)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string fullRoot;
|
||||
try
|
||||
{
|
||||
fullRoot = Path.GetFullPath(root);
|
||||
}
|
||||
catch
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (IsUnderDirectory(fullPath, fullRoot))
|
||||
{
|
||||
underAnyRoot = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!underAnyRoot)
|
||||
{
|
||||
return PathValidationResult.Invalid(
|
||||
"Out Of Project Scope",
|
||||
$"Hot reload only accepts files inside the project's baked roots (Assets/ or a loaded " +
|
||||
$"package). '{fullPath}' is outside the project scope and will not be compiled.");
|
||||
}
|
||||
|
||||
if (!File.Exists(fullPath))
|
||||
{
|
||||
return PathValidationResult.Invalid("File Not Found", $"Source file not found: {fullPath}");
|
||||
}
|
||||
|
||||
return PathValidationResult.Valid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="path"/> is contained within <paramref name="directory"/>.
|
||||
/// Both are expected to be normalized absolute paths. A trailing separator is appended to
|
||||
/// the directory so that a sibling such as "AssetsExtra" is not treated as being under "Assets".
|
||||
/// </summary>
|
||||
private static bool IsUnderDirectory(string path, string directory)
|
||||
{
|
||||
var prefix = directory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
|
||||
+ Path.DirectorySeparatorChar;
|
||||
|
||||
// Windows file systems are case-insensitive; Unix file systems are case-sensitive.
|
||||
var comparison = Path.DirectorySeparatorChar == '\\'
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal;
|
||||
|
||||
return path.StartsWith(prefix, comparison);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve source file path from various possible locations (used by the in-place command).
|
||||
/// </summary>
|
||||
private static string ResolveSourceFilePath(string filename)
|
||||
{
|
||||
if (Path.IsPathRooted(filename) && File.Exists(filename))
|
||||
{
|
||||
return filename;
|
||||
}
|
||||
|
||||
var potentialPaths = new[]
|
||||
{
|
||||
Path.Combine("Assets", filename),
|
||||
Path.Combine("Assets", "Scripts", filename),
|
||||
filename // Project root
|
||||
};
|
||||
|
||||
foreach (var path in potentialPaths)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
return Path.GetFullPath(path);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Execute hot reload compilation and application asynchronously (for separate override files).
|
||||
/// </summary>
|
||||
private static async Task<HotReloadResponse> ExecuteHotReloadAsync(string filename, int timeoutMs, string assemblyDir)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use the HotReloadCompiler to compile and apply the changes
|
||||
var compileResult = await HotReloadCompiler.CompileAndApplyAsync(filename, assemblyDir);
|
||||
|
||||
if (!compileResult.IsSuccess)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
compileResult.Error ?? "Compilation Failed",
|
||||
compileResult.ErrorDetails ?? "Hot reload compilation failed",
|
||||
compileResult.ExecutionTimeMs,
|
||||
compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
// Compiled, but no override actually bound (e.g. the target is not [HotReloadWithOverrides],
|
||||
// or the component is not in the scene / not in play mode). Report it rather than
|
||||
// claiming success when nothing changed.
|
||||
if (compileResult.RegisteredMethods.Count == 0)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"No Overrides Applied",
|
||||
compileResult.Diagnostics.Count > 0
|
||||
? "No hot reload overrides were applied:\n- " + string.Join("\n- ", compileResult.Diagnostics)
|
||||
: "No [HotReloadOverrideMethod] overrides were applied. Ensure the target component is in the scene and in play mode.",
|
||||
compileResult.ExecutionTimeMs,
|
||||
compileResult.Diagnostics);
|
||||
}
|
||||
|
||||
var message = $"Hot reload successful: {compileResult.AssemblyName} with {compileResult.RegisteredMethods.Count} methods";
|
||||
var response = HotReloadResponse.CmdSuccess(
|
||||
compileResult.AssemblyName,
|
||||
message,
|
||||
compileResult.RegisteredMethods,
|
||||
compileResult.ExecutionTimeMs);
|
||||
response.Diagnostics = compileResult.Diagnostics; // surface any partially-skipped overrides
|
||||
return response;
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Timeout",
|
||||
$"Hot reload compilation exceeded {timeoutMs}ms timeout",
|
||||
timeoutMs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HotReloadResponse.CmdFailure(
|
||||
"Execution Failed",
|
||||
ex.ToString(),
|
||||
0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for hot reload CLI commands.
|
||||
/// Provides consistent response format for all hot reload operations.
|
||||
/// </summary>
|
||||
public class HotReloadResponse : CommandExecutionResponse
|
||||
{
|
||||
/// <summary>
|
||||
/// Assembly name or operation identifier for successful operations.
|
||||
/// </summary>
|
||||
public string AssemblyName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// List of registered method IDs or files processed.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Items { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Compilation or processing diagnostics.
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Diagnostics { get; set; } = new System.Collections.Generic.List<string>();
|
||||
|
||||
/// <summary>
|
||||
/// Create a successful hot reload response.
|
||||
/// </summary>
|
||||
public static HotReloadResponse CmdSuccess(string assemblyName, string message, System.Collections.Generic.List<string> items, long executionTimeMs)
|
||||
{
|
||||
return new HotReloadResponse
|
||||
{
|
||||
Success = true,
|
||||
AssemblyName = assemblyName,
|
||||
Message = message,
|
||||
Items = items ?? new System.Collections.Generic.List<string>(),
|
||||
ExecutionTimeMs = executionTimeMs
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create a failed hot reload response.
|
||||
/// </summary>
|
||||
public static HotReloadResponse CmdFailure(string error, string errorDetails, long executionTimeMs, System.Collections.Generic.List<string> diagnostics = null)
|
||||
{
|
||||
return new HotReloadResponse
|
||||
{
|
||||
Success = false,
|
||||
Error = error,
|
||||
ErrorDetails = errorDetails,
|
||||
ExecutionTimeMs = executionTimeMs,
|
||||
Diagnostics = diagnostics ?? new System.Collections.Generic.List<string>()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a hot reload source path. <see cref="Error"/> is a short category and
|
||||
/// <see cref="ErrorDetails"/> the human-readable explanation, matching the shape consumed by
|
||||
/// <see cref="HotReloadResponse.CmdFailure"/>.
|
||||
/// </summary>
|
||||
public class PathValidationResult
|
||||
{
|
||||
public bool IsValid { get; private set; }
|
||||
public string Error { get; private set; }
|
||||
public string ErrorDetails { get; private set; }
|
||||
|
||||
public static PathValidationResult Valid()
|
||||
{
|
||||
return new PathValidationResult { IsValid = true };
|
||||
}
|
||||
|
||||
public static PathValidationResult Invalid(string error, string errorDetails)
|
||||
{
|
||||
return new PathValidationResult { IsValid = false, Error = error, ErrorDetails = errorDetails };
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 33ec6e594cb07434cb51fbc751ca37f2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime application control commands for Unity Player builds.
|
||||
/// Provides basic application lifecycle and performance control.
|
||||
/// </summary>
|
||||
public static class RuntimeApplicationCommand
|
||||
{
|
||||
[CliCommand("quit", "Gracefully quit the Unity application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string QuitApplication(
|
||||
[CliArg("exitCode", "Exit code for the application")] int exitCode = 0)
|
||||
{
|
||||
// Schedule quit on next frame to allow HTTP response to be sent
|
||||
var quitObject = new GameObject("PipelineQuitScheduler");
|
||||
var quitScheduler = quitObject.AddComponent<QuitScheduler>();
|
||||
quitScheduler.Initialize(exitCode);
|
||||
|
||||
return $"Application quit scheduled with exit code {exitCode}";
|
||||
}
|
||||
|
||||
[CliCommand("set_target_framerate", "Set the target frame rate for the application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string SetTargetFrameRate(
|
||||
[CliArg("frameRate", "Target frame rate (-1 for platform default, 0 for unlimited)", Required = true)] int frameRate)
|
||||
{
|
||||
var previousFrameRate = Application.targetFrameRate;
|
||||
Application.targetFrameRate = frameRate;
|
||||
|
||||
var frameRateDescription = frameRate switch
|
||||
{
|
||||
-1 => "platform default",
|
||||
0 => "unlimited (VSync)",
|
||||
_ => $"{frameRate} FPS"
|
||||
};
|
||||
|
||||
return $"Target frame rate set to {frameRateDescription} (was {previousFrameRate})";
|
||||
}
|
||||
|
||||
[CliCommand("set_timescale", "Set the time scale for the application", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string SetTimeScale(
|
||||
[CliArg("scale", "Time scale multiplier (0.0 to pause, 1.0 for normal speed)", Required = true)] float scale)
|
||||
{
|
||||
if (scale < 0f)
|
||||
{
|
||||
return "Error: Time scale cannot be negative";
|
||||
}
|
||||
|
||||
var previousScale = Time.timeScale;
|
||||
Time.timeScale = scale;
|
||||
|
||||
var scaleDescription = scale switch
|
||||
{
|
||||
0f => "paused",
|
||||
1f => "normal speed",
|
||||
_ => $"{scale}x speed"
|
||||
};
|
||||
|
||||
return $"Time scale set to {scaleDescription} (was {previousScale}x)";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// MonoBehaviour to handle delayed application quit.
|
||||
/// Allows HTTP response to be sent before application terminates.
|
||||
/// </summary>
|
||||
private class QuitScheduler : MonoBehaviour
|
||||
{
|
||||
private int m_ExitCode;
|
||||
private float m_QuitTime;
|
||||
|
||||
public void Initialize(int exitCode)
|
||||
{
|
||||
m_ExitCode = exitCode;
|
||||
m_QuitTime = Time.realtimeSinceStartup + 0.1f; // 100ms delay
|
||||
DontDestroyOnLoad(gameObject);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (Time.realtimeSinceStartup >= m_QuitTime)
|
||||
{
|
||||
Debug.Log($"Pipeline: Quitting application with exit code {m_ExitCode}");
|
||||
Application.Quit(m_ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e508f97d464c66347816d5281c68e276
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+180
@@ -0,0 +1,180 @@
|
||||
using System;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.Controls;
|
||||
using UnityEngine.InputSystem.LowLevel;
|
||||
#endif
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// <para>
|
||||
/// Input-simulation commands that drive a running Player by injecting synthetic input — the enabler
|
||||
/// for automated play-testing (CLI-208). Keyboard and pointer events are injected through the Input
|
||||
/// System (<c>InputSystem.QueueEvent</c> with a state event captured from the live device), which also
|
||||
/// drives uGUI / UI Toolkit through the Input System UI module — so a simulated pointer click lands on
|
||||
/// on-screen UI the same way a real one does.
|
||||
/// </para>
|
||||
///
|
||||
/// <para><b>Input stack:</b> device injection requires the Input System package to be present and active
|
||||
/// (<c>ENABLE_INPUT_SYSTEM</c>). Legacy <c>UnityEngine.Input</c> is poll-only and exposes no injection
|
||||
/// API, so it is intentionally not supported; when the Input System is absent these commands report an
|
||||
/// "unavailable" result rather than silently no-op'ing.</para>
|
||||
///
|
||||
/// <para><b>Safety:</b> these are write/destructive commands. The package has no safety-policy gate yet
|
||||
/// (CAT-2509 is not implemented), so they follow the existing unguarded write-command model (cf.
|
||||
/// <c>set_target_framerate</c>). TODO(CAT-2509): route input commands through the safety policy when it
|
||||
/// lands.</para>
|
||||
/// </summary>
|
||||
public static class RuntimeInputCommand
|
||||
{
|
||||
[CliCommand("simulate_key", "Simulate a keyboard key event (Input System). Drives the running app.",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static InputSimulationResponse SimulateKey(
|
||||
[CliArg("key", "Input System Key name, e.g. Space, W, Enter, LeftArrow", Required = true)] string key,
|
||||
[CliArg("action", "down | up | press (down+up). Default: press")] string action = "press")
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var keyboard = Keyboard.current;
|
||||
if (keyboard == null)
|
||||
return InputSimulationResponse.Fail("simulate_key", "No keyboard device is present.");
|
||||
|
||||
if (!Enum.TryParse<Key>(key, ignoreCase: true, out var parsedKey) || parsedKey == Key.None)
|
||||
return InputSimulationResponse.Fail("simulate_key", $"Unknown key '{key}'. Use an Input System Key name (e.g. Space, W, Enter).");
|
||||
|
||||
var control = keyboard[parsedKey];
|
||||
var act = Normalize(action);
|
||||
switch (act)
|
||||
{
|
||||
case "down":
|
||||
QueueButton(control, true);
|
||||
break;
|
||||
case "up":
|
||||
QueueButton(control, false);
|
||||
break;
|
||||
case "press":
|
||||
QueueButton(control, true);
|
||||
QueueButton(control, false);
|
||||
break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_key", $"Unknown action '{action}'. Use down | up | press.");
|
||||
}
|
||||
|
||||
// Flush queued events so the effect is observable synchronously to the caller.
|
||||
InputSystem.Update();
|
||||
return InputSimulationResponse.Ok("simulate_key", $"key={parsedKey} action={act}");
|
||||
#else
|
||||
return InputSimulationResponse.Unavailable("simulate_key");
|
||||
#endif
|
||||
}
|
||||
|
||||
[CliCommand("simulate_pointer", "Simulate a mouse/pointer event at screen coordinates (Input System).",
|
||||
MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static InputSimulationResponse SimulatePointer(
|
||||
[CliArg("x", "Screen X in pixels (origin bottom-left)", Required = true)] float x,
|
||||
[CliArg("y", "Screen Y in pixels (origin bottom-left)", Required = true)] float y,
|
||||
[CliArg("action", "move | down | up | click (down+up). Default: click")] string action = "click",
|
||||
[CliArg("button", "left | right | middle. Default: left")] string button = "left")
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var mouse = Mouse.current;
|
||||
if (mouse == null)
|
||||
return InputSimulationResponse.Fail("simulate_pointer", "No mouse/pointer device is present.");
|
||||
|
||||
var position = new Vector2(x, y);
|
||||
var act = Normalize(action);
|
||||
|
||||
ButtonControl btn;
|
||||
switch (Normalize(button))
|
||||
{
|
||||
case "left": btn = mouse.leftButton; break;
|
||||
case "right": btn = mouse.rightButton; break;
|
||||
case "middle": btn = mouse.middleButton; break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_pointer", $"Unknown button '{button}'. Use left | right | middle.");
|
||||
}
|
||||
|
||||
switch (act)
|
||||
{
|
||||
case "move":
|
||||
QueuePointer(mouse, position, null, false);
|
||||
break;
|
||||
case "down":
|
||||
QueuePointer(mouse, position, btn, true);
|
||||
break;
|
||||
case "up":
|
||||
QueuePointer(mouse, position, btn, false);
|
||||
break;
|
||||
case "click":
|
||||
QueuePointer(mouse, position, btn, true);
|
||||
QueuePointer(mouse, position, btn, false);
|
||||
break;
|
||||
default:
|
||||
return InputSimulationResponse.Fail("simulate_pointer", $"Unknown action '{action}'. Use move | down | up | click.");
|
||||
}
|
||||
|
||||
InputSystem.Update();
|
||||
return InputSimulationResponse.Ok("simulate_pointer", $"pos=({x},{y}) action={act} button={Normalize(button)}");
|
||||
#else
|
||||
return InputSimulationResponse.Unavailable("simulate_pointer");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
// Capture the device's current state into a state event, override a single control, then queue it.
|
||||
// Capturing current state (rather than sending a zeroed full state) preserves any other controls
|
||||
// that are already held — so chained down/up calls compose correctly.
|
||||
private static void QueueButton(InputControl<float> control, bool pressed)
|
||||
{
|
||||
using (StateEvent.From(control.device, out var eventPtr))
|
||||
{
|
||||
control.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr);
|
||||
InputSystem.QueueEvent(eventPtr);
|
||||
}
|
||||
}
|
||||
|
||||
private static void QueuePointer(Mouse mouse, Vector2 position, ButtonControl button, bool pressed)
|
||||
{
|
||||
using (StateEvent.From(mouse, out var eventPtr))
|
||||
{
|
||||
mouse.position.WriteValueIntoEvent(position, eventPtr);
|
||||
if (button != null)
|
||||
button.WriteValueIntoEvent(pressed ? 1f : 0f, eventPtr);
|
||||
InputSystem.QueueEvent(eventPtr);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private static string Normalize(string s) => (s ?? string.Empty).Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Structured result for an input-simulation command: whether the event was injected, and a short
|
||||
/// human-readable detail (or an error / unavailable reason).
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class InputSimulationResponse
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Command { get; set; }
|
||||
public string Detail { get; set; }
|
||||
public string Error { get; set; }
|
||||
|
||||
public static InputSimulationResponse Ok(string command, string detail) =>
|
||||
new InputSimulationResponse { Success = true, Command = command, Detail = detail };
|
||||
|
||||
public static InputSimulationResponse Fail(string command, string error) =>
|
||||
new InputSimulationResponse { Success = false, Command = command, Error = error };
|
||||
|
||||
public static InputSimulationResponse Unavailable(string command) =>
|
||||
new InputSimulationResponse
|
||||
{
|
||||
Success = false,
|
||||
Command = command,
|
||||
Error = "Input simulation requires the Input System package to be present and active " +
|
||||
"(ENABLE_INPUT_SYSTEM). Legacy input injection is not supported."
|
||||
};
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fa9e0d957acd4b1293e4d40e83bca73f
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime logging commands for Unity Player builds.
|
||||
/// Provides console logging capabilities from CLI.
|
||||
/// </summary>
|
||||
public static class RuntimeLogCommand
|
||||
{
|
||||
[CliCommand("log", "Write a message to Unity console", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static string LogMessage(
|
||||
[CliArg("message", "Message to log to console", Required = true)] string message,
|
||||
[CliArg("level", "Log level: info, warning, error")] string level = "info")
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
return "Error: Message cannot be empty";
|
||||
}
|
||||
|
||||
Debug.developerConsoleVisible = false;
|
||||
var logLevel = level?.ToLowerInvariant() ?? "info";
|
||||
|
||||
switch (logLevel)
|
||||
{
|
||||
case "info":
|
||||
case "log":
|
||||
Debug.Log($"[Pipeline] {message}");
|
||||
return $"Logged info message: {message}";
|
||||
|
||||
case "warning":
|
||||
case "warn":
|
||||
Debug.LogWarning($"[Pipeline] {message}");
|
||||
return $"Logged warning message: {message}";
|
||||
|
||||
case "error":
|
||||
case "err":
|
||||
Debug.LogError($"[Pipeline] {message}");
|
||||
return $"Logged error message: {message}";
|
||||
|
||||
default:
|
||||
Debug.LogWarning($"[Pipeline] Unknown log level '{level}', defaulting to info: {message}");
|
||||
Debug.Log($"[Pipeline] {message}");
|
||||
return $"Logged message with default level: {message}";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8e82ea92b18fc264faf68d664212e013
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Runtime.Telemetry;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Profiling;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Runtime status information for Unity Player builds.
|
||||
/// Provides Player-appropriate status without Editor-specific APIs.
|
||||
/// </summary>
|
||||
public static class RuntimeStatusCommand
|
||||
{
|
||||
[CliCommand("runtime_status", "Get comprehensive runtime application status", MainThreadRequired = true, RuntimeOnly = true)]
|
||||
public static RuntimeStatusResponse GetRuntimeStatus()
|
||||
{
|
||||
try
|
||||
{
|
||||
return new RuntimeStatusResponse
|
||||
{
|
||||
// Application info
|
||||
UnityVersion = Application.unityVersion,
|
||||
Platform = Application.platform.ToString(),
|
||||
BuildGuid = Application.buildGUID,
|
||||
Version = Application.version,
|
||||
|
||||
// Runtime state
|
||||
IsPlaying = true, // Always true in Player builds
|
||||
TargetFrameRate = Application.targetFrameRate,
|
||||
TimeScale = Time.timeScale,
|
||||
RealTimeSinceStartup = Time.realtimeSinceStartup,
|
||||
|
||||
// Performance info
|
||||
FrameCount = Time.frameCount,
|
||||
LoadedLevelName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name,
|
||||
LoadedLevelBuildIndex = UnityEngine.SceneManagement.SceneManager.GetActiveScene().buildIndex,
|
||||
|
||||
// Live performance telemetry (fps, frame time, memory, profiler counters)
|
||||
Performance = BuildPerformanceTelemetry(),
|
||||
|
||||
// Timestamp
|
||||
Timestamp = DateTime.UtcNow
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"Pipeline: Runtime status command failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Assemble the live performance section from two independent sources:
|
||||
/// the per-frame <see cref="FrameStatsSampler"/> (fps / frame time / profiler counters), which only
|
||||
/// exists while a <see cref="RuntimePipelineManager"/> is feeding it, and the Profiler memory APIs,
|
||||
/// which can be read on demand at any time. Frame-stat fields stay at their defaults (and
|
||||
/// <see cref="RuntimePerformanceTelemetry.FrameStatsAvailable"/> stays false) when no sampler is
|
||||
/// running — e.g. in EditMode tests — so a missing sampler degrades gracefully rather than throwing.
|
||||
/// </summary>
|
||||
private static RuntimePerformanceTelemetry BuildPerformanceTelemetry()
|
||||
{
|
||||
var perf = new RuntimePerformanceTelemetry();
|
||||
|
||||
var sampler = FrameStatsSampler.Shared;
|
||||
if (sampler != null)
|
||||
{
|
||||
var snap = sampler.GetSnapshot();
|
||||
perf.FrameStatsAvailable = snap.Available;
|
||||
perf.SampleWindow = snap.SampleWindow;
|
||||
perf.Fps = snap.Fps;
|
||||
perf.AverageFrameTimeMs = snap.AverageFrameTimeMs;
|
||||
perf.MinFrameTimeMs = snap.MinFrameTimeMs;
|
||||
perf.MaxFrameTimeMs = snap.MaxFrameTimeMs;
|
||||
perf.LastFrameTimeMs = snap.LastFrameTimeMs;
|
||||
perf.GpuTimingAvailable = snap.GpuTimingAvailable;
|
||||
perf.CpuFrameTimeMs = snap.CpuFrameTimeMs;
|
||||
perf.GpuFrameTimeMs = snap.GpuFrameTimeMs;
|
||||
perf.Counters = snap.Counters;
|
||||
}
|
||||
else
|
||||
{
|
||||
perf.Counters = new Dictionary<string, double>();
|
||||
}
|
||||
|
||||
// Memory is sampled directly (no per-frame accumulation needed). These return 0 in a
|
||||
// non-development build where the profiler is stripped, which is reported faithfully.
|
||||
perf.TotalAllocatedMemory = Profiler.GetTotalAllocatedMemoryLong();
|
||||
perf.TotalReservedMemory = Profiler.GetTotalReservedMemoryLong();
|
||||
perf.MonoUsedSize = Profiler.GetMonoUsedSizeLong();
|
||||
perf.MonoHeapSize = Profiler.GetMonoHeapSizeLong();
|
||||
perf.GcMemory = GC.GetTotalMemory(false);
|
||||
|
||||
return perf;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Response model for runtime status information.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RuntimeStatusResponse
|
||||
{
|
||||
// Application Information
|
||||
public string UnityVersion { get; set; }
|
||||
public string Platform { get; set; }
|
||||
public string BuildGuid { get; set; }
|
||||
public string Version { get; set; }
|
||||
|
||||
// Runtime State
|
||||
public bool IsPlaying { get; set; } // Always true for Player builds
|
||||
public int TargetFrameRate { get; set; }
|
||||
public float TimeScale { get; set; }
|
||||
public float RealTimeSinceStartup { get; set; }
|
||||
|
||||
// Performance Information
|
||||
public int FrameCount { get; set; }
|
||||
public string LoadedLevelName { get; set; }
|
||||
public int LoadedLevelBuildIndex { get; set; }
|
||||
|
||||
// Live performance telemetry
|
||||
public RuntimePerformanceTelemetry Performance { get; set; }
|
||||
|
||||
// Metadata
|
||||
public DateTime Timestamp { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Live performance telemetry for a running Player: rolling fps / frame-time statistics, optional
|
||||
/// CPU/GPU frame timing, memory usage, and a map of profiler counters. Frame-stat and counter fields
|
||||
/// are populated from the per-frame <see cref="FrameStatsSampler"/>; memory fields are read on demand.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class RuntimePerformanceTelemetry
|
||||
{
|
||||
// Frame timing — sampled over a rolling window by the RuntimePipelineManager.
|
||||
/// <summary>False when no sampler is running; the fps / frame-time fields below are then meaningless.</summary>
|
||||
public bool FrameStatsAvailable { get; set; }
|
||||
public int SampleWindow { get; set; }
|
||||
public float Fps { get; set; }
|
||||
public float AverageFrameTimeMs { get; set; }
|
||||
public float MinFrameTimeMs { get; set; }
|
||||
public float MaxFrameTimeMs { get; set; }
|
||||
public float LastFrameTimeMs { get; set; }
|
||||
|
||||
// CPU/GPU frame timing via FrameTimingManager (not available on every platform).
|
||||
public bool GpuTimingAvailable { get; set; }
|
||||
public double CpuFrameTimeMs { get; set; }
|
||||
public double GpuFrameTimeMs { get; set; }
|
||||
|
||||
// Memory (bytes) via the Profiler API; 0 when the profiler is stripped (non-development build).
|
||||
public long TotalAllocatedMemory { get; set; }
|
||||
public long TotalReservedMemory { get; set; }
|
||||
public long MonoUsedSize { get; set; }
|
||||
public long MonoHeapSize { get; set; }
|
||||
public long GcMemory { get; set; }
|
||||
|
||||
// Profiler counters by reported name (e.g. DrawCalls, Triangles, GcAllocInFrame).
|
||||
public Dictionary<string, double> Counters { get; set; }
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 846a260c41f6d8f4583f1473632c1d62
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+444
@@ -0,0 +1,444 @@
|
||||
#if UNITY_6000_7_OR_NEWER
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Rendering;
|
||||
using UnityEngine.UIElements;
|
||||
using Object = UnityEngine.Object;
|
||||
|
||||
namespace Unity.Pipeline.Runtime.Commands
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared, runtime-safe helpers behind the visual-element capture commands
|
||||
/// (<c>capture_editor_element</c> and <c>capture_runtime_element</c>). Lives in the runtime
|
||||
/// assembly with no <c>UnityEditor</c> dependency so both the Editor command and the
|
||||
/// player-side runtime command can use it.
|
||||
///
|
||||
/// Capture uses <see cref="VisualElementCaptureExtensions.CaptureToRenderTexture"/> (runtime
|
||||
/// module) and encodes the PNG here, rather than the editor-only
|
||||
/// <c>VisualElementCaptureEditorExtensions.CaptureToPNG</c>, so the same path works in a Player.
|
||||
///
|
||||
/// Element selection maps a small USS-like selector string onto UQuery's public
|
||||
/// <see cref="UQueryBuilder{T}"/> (the engine's USS string parser is internal). Supported:
|
||||
/// descendant (space) and child (<c>></c>) combinators; each simple part is
|
||||
/// <c>Type#name.class1.class2</c> (any subset); optional pseudo-state suffixes
|
||||
/// <c>:checked :hover :focus :active :enabled :disabled</c> and <c>:not(<state>)</c>.
|
||||
/// </summary>
|
||||
public static class VisualElementCaptureSupport
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns a failure response when no GPU is available (batchmode/headless), where a capture
|
||||
/// would read back a blank image; otherwise returns null (capture may proceed).
|
||||
/// </summary>
|
||||
public static CaptureElementResponse GpuUnavailableResponse()
|
||||
{
|
||||
if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null)
|
||||
return CaptureElementResponse.Fail("No GPU available (batchmode/headless); cannot capture.");
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the first <see cref="VisualElement"/> under <paramref name="root"/> matching
|
||||
/// <paramref name="selector"/>, or null if the selector is empty/invalid or nothing matches.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(VisualElement root, string selector)
|
||||
{
|
||||
if (root == null || string.IsNullOrWhiteSpace(selector))
|
||||
return null;
|
||||
|
||||
if (!TryBuildQuery(root, selector, out var query))
|
||||
return null;
|
||||
|
||||
return query.First();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the selector against each root in order, returning the first match (and the root
|
||||
/// it was found under via <paramref name="matchedRoot"/>), or null if none match.
|
||||
/// </summary>
|
||||
public static VisualElement ResolveElement(IEnumerable<VisualElement> roots, string selector,
|
||||
out VisualElement matchedRoot)
|
||||
{
|
||||
matchedRoot = null;
|
||||
if (roots == null)
|
||||
return null;
|
||||
|
||||
foreach (var root in roots)
|
||||
{
|
||||
var element = ResolveElement(root, selector);
|
||||
if (element != null)
|
||||
{
|
||||
matchedRoot = root;
|
||||
return element;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture <paramref name="element"/> to PNG bytes at its own pixel size. Restores the active
|
||||
/// RenderTexture and releases all temporaries so the capture leaves no global render state.
|
||||
/// Throws <see cref="InvalidOperationException"/> for camera-drawn (world-space) or detached
|
||||
/// panels (surfaced from <see cref="VisualElementCaptureExtensions.CaptureToRenderTexture"/>).
|
||||
/// </summary>
|
||||
public static byte[] CaptureElementToPng(VisualElement element, out int width, out int height)
|
||||
{
|
||||
if (element == null)
|
||||
throw new ArgumentNullException(nameof(element));
|
||||
|
||||
var rt = element.CaptureToRenderTexture();
|
||||
var prevActive = RenderTexture.active;
|
||||
Texture2D tex = null;
|
||||
try
|
||||
{
|
||||
width = rt.width;
|
||||
height = rt.height;
|
||||
|
||||
RenderTexture.active = rt;
|
||||
tex = new Texture2D(rt.width, rt.height, TextureFormat.RGBA32, false);
|
||||
tex.ReadPixels(new Rect(0, 0, rt.width, rt.height), 0, 0, false);
|
||||
tex.Apply(false, false);
|
||||
return tex.EncodeToPNG();
|
||||
}
|
||||
finally
|
||||
{
|
||||
RenderTexture.active = prevActive;
|
||||
rt.Release();
|
||||
DestroyObject(rt);
|
||||
if (tex != null)
|
||||
DestroyObject(tex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the output path. An explicit rooted path is used as-is; an explicit relative path
|
||||
/// is resolved against <paramref name="relativeBaseDir"/>; an empty path produces a
|
||||
/// timestamped <c><prefix>_yyyyMMdd_HHmmss_fff.png</c> file under
|
||||
/// <paramref name="defaultDir"/>.
|
||||
/// </summary>
|
||||
public static string ResolveOutputPath(string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return Path.IsPathRooted(output)
|
||||
? output
|
||||
: Path.GetFullPath(Path.Combine(relativeBaseDir, output));
|
||||
}
|
||||
|
||||
var stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss_fff", CultureInfo.InvariantCulture);
|
||||
return Path.Combine(defaultDir, $"{prefix}_{stamp}.png");
|
||||
}
|
||||
|
||||
/// <summary>Write PNG bytes to <paramref name="path"/>, creating the directory if needed.</summary>
|
||||
public static void WritePng(string path, byte[] png)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllBytes(path, png);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture the resolved <paramref name="element"/>, write the PNG to disk, and build a
|
||||
/// populated success response (or a failure response if the panel cannot be captured). This
|
||||
/// is the shared tail both commands run once they have an element + a panel root.
|
||||
/// </summary>
|
||||
public static CaptureElementResponse CaptureAndRespond(VisualElement element, string selector,
|
||||
string source, string output, string relativeBaseDir, string defaultDir, string prefix)
|
||||
{
|
||||
var gpuGuard = GpuUnavailableResponse();
|
||||
if (gpuGuard != null)
|
||||
return gpuGuard;
|
||||
|
||||
try
|
||||
{
|
||||
var png = CaptureElementToPng(element, out var width, out var height);
|
||||
var path = ResolveOutputPath(output, relativeBaseDir, defaultDir, prefix);
|
||||
WritePng(path, png);
|
||||
|
||||
return new CaptureElementResponse
|
||||
{
|
||||
Success = true,
|
||||
Selector = selector,
|
||||
Source = source,
|
||||
Path = path,
|
||||
Width = width,
|
||||
Height = height,
|
||||
Encoding = "png",
|
||||
Base64 = Convert.ToBase64String(png),
|
||||
Bytes = png.Length,
|
||||
Message = $"Captured {source} to {path}"
|
||||
};
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
// The capture API throws this for camera-drawn (world-space) or detached panels.
|
||||
return CaptureElementResponse.Fail(ex.Message);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CaptureElementResponse.Fail($"Failed to capture '{selector}' from {source}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Selector parsing -------------------------------------------------------------------
|
||||
|
||||
// Build a UQuery from the selector, mapping tokens onto the public UQueryBuilder fluent API.
|
||||
static bool TryBuildQuery(VisualElement root, string selector, out UQueryBuilder<VisualElement> query)
|
||||
{
|
||||
query = root.Query();
|
||||
|
||||
var parts = Tokenize(selector);
|
||||
if (parts.Count == 0)
|
||||
return false;
|
||||
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
var (isChild, partStr) = parts[i];
|
||||
if (!TryParsePart(partStr, out var part))
|
||||
return false;
|
||||
|
||||
if (i == 0)
|
||||
query = query.Name(part.Name);
|
||||
else
|
||||
query = isChild
|
||||
? query.Children<VisualElement>(part.Name)
|
||||
: query.Descendents<VisualElement>(part.Name);
|
||||
|
||||
foreach (var cls in part.Classes)
|
||||
query = query.Class(cls);
|
||||
|
||||
if (part.Type != null)
|
||||
{
|
||||
var typeName = part.Type;
|
||||
query = query.Where(e => TypeNameMatches(e, typeName));
|
||||
}
|
||||
|
||||
foreach (var pseudo in part.Pseudos)
|
||||
{
|
||||
if (!TryApplyPseudo(ref query, pseudo.Name, pseudo.Negate))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Split a selector into simple parts, flagging each with whether it follows a '>' (child)
|
||||
// combinator. Whitespace separates descendant parts; '>' is treated as its own token.
|
||||
static List<(bool isChild, string part)> Tokenize(string selector)
|
||||
{
|
||||
var result = new List<(bool, string)>();
|
||||
var raw = selector.Replace(">", " > ")
|
||||
.Split((char[])null, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
bool nextIsChild = false;
|
||||
foreach (var token in raw)
|
||||
{
|
||||
if (token == ">")
|
||||
{
|
||||
nextIsChild = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Add((nextIsChild, token));
|
||||
nextIsChild = false;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
class SelectorPart
|
||||
{
|
||||
public string Type;
|
||||
public string Name;
|
||||
public readonly List<string> Classes = new List<string>();
|
||||
public readonly List<(bool Negate, string Name)> Pseudos = new List<(bool, string)>();
|
||||
}
|
||||
|
||||
// Parse "Type#name.class1.class2" with optional ":pseudo" / ":not(pseudo)" suffixes.
|
||||
static bool TryParsePart(string raw, out SelectorPart part)
|
||||
{
|
||||
part = new SelectorPart();
|
||||
|
||||
var simple = ExtractPseudos(raw, part.Pseudos);
|
||||
if (simple == null)
|
||||
return false;
|
||||
|
||||
int i = 0;
|
||||
var sb = new StringBuilder();
|
||||
|
||||
// Leading run (before any '#' or '.') is the type token.
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
if (sb.Length > 0)
|
||||
{
|
||||
var type = sb.ToString();
|
||||
if (type != "*")
|
||||
part.Type = type;
|
||||
}
|
||||
|
||||
while (i < simple.Length)
|
||||
{
|
||||
char kind = simple[i++];
|
||||
sb.Clear();
|
||||
while (i < simple.Length && simple[i] != '#' && simple[i] != '.')
|
||||
sb.Append(simple[i++]);
|
||||
|
||||
var value = sb.ToString();
|
||||
if (value.Length == 0)
|
||||
return false; // dangling '#' or '.'
|
||||
|
||||
if (kind == '#')
|
||||
{
|
||||
if (part.Name != null)
|
||||
return false; // more than one id
|
||||
part.Name = value;
|
||||
}
|
||||
else
|
||||
{
|
||||
part.Classes.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strip ":pseudo" and ":not(pseudo)" suffixes into 'pseudos', returning the simple-selector
|
||||
// remainder, or null if a pseudo is malformed.
|
||||
static string ExtractPseudos(string raw, List<(bool Negate, string Name)> pseudos)
|
||||
{
|
||||
int idx;
|
||||
while ((idx = raw.IndexOf(':')) >= 0)
|
||||
{
|
||||
var head = raw.Substring(0, idx);
|
||||
var rest = raw.Substring(idx + 1);
|
||||
|
||||
if (rest.StartsWith("not(", StringComparison.Ordinal))
|
||||
{
|
||||
int close = rest.IndexOf(')');
|
||||
if (close < 0)
|
||||
return null;
|
||||
var inner = rest.Substring(4, close - 4).Trim().TrimStart(':');
|
||||
if (inner.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((true, inner.ToLowerInvariant()));
|
||||
raw = head + rest.Substring(close + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
int next = rest.IndexOf(':');
|
||||
var name = next < 0 ? rest : rest.Substring(0, next);
|
||||
if (name.Length == 0)
|
||||
return null;
|
||||
pseudos.Add((false, name.ToLowerInvariant()));
|
||||
raw = head + (next < 0 ? string.Empty : rest.Substring(next));
|
||||
}
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
// Apply one pseudo-state to the query, honoring negation. Returns false for an unknown state.
|
||||
static bool TryApplyPseudo(ref UQueryBuilder<VisualElement> query, string name, bool negate)
|
||||
{
|
||||
bool on = !negate;
|
||||
switch (name)
|
||||
{
|
||||
case "checked": query = on ? query.Checked() : query.NotChecked(); return true;
|
||||
case "hover": query = on ? query.Hovered() : query.NotHovered(); return true;
|
||||
case "focus": query = on ? query.Focused() : query.NotFocused(); return true;
|
||||
case "active": query = on ? query.Active() : query.NotActive(); return true;
|
||||
case "enabled": query = on ? query.Enabled() : query.NotEnabled(); return true;
|
||||
case "disabled": query = on ? query.NotEnabled() : query.Enabled(); return true;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Match a type token against the element's runtime type name or any of its base type names,
|
||||
// so "Button" matches a Button and "VisualElement" matches everything. Avoids reflecting the
|
||||
// generic OfType<T> over a runtime-resolved Type.
|
||||
static bool TypeNameMatches(VisualElement element, string typeName)
|
||||
{
|
||||
for (var t = element.GetType(); t != null && t != typeof(object); t = t.BaseType)
|
||||
{
|
||||
if (string.Equals(t.Name, typeName, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static void DestroyObject(Object obj)
|
||||
{
|
||||
#if UNITY_EDITOR
|
||||
if (!Application.isPlaying)
|
||||
{
|
||||
Object.DestroyImmediate(obj);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
Object.Destroy(obj);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a visual-element capture command: the PNG payload (base64), its dimensions, the
|
||||
/// panel/source it was rendered from, and the path it was written to. JSON property names mirror
|
||||
/// the GameView capture command's result shape (PR #13) for easy convergence later.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class CaptureElementResponse
|
||||
{
|
||||
/// <summary>Whether the capture succeeded.</summary>
|
||||
[JsonProperty("success")]
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>Human-readable message (the error text on failure).</summary>
|
||||
[JsonProperty("message")]
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>The selector used to locate the element.</summary>
|
||||
[JsonProperty("selector")]
|
||||
public string Selector { get; set; }
|
||||
|
||||
/// <summary>What was captured, e.g. "window:Inspector" or "panel:MainUI".</summary>
|
||||
[JsonProperty("source")]
|
||||
public string Source { get; set; }
|
||||
|
||||
/// <summary>Absolute filesystem path the PNG was written to.</summary>
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
/// <summary>Captured width in pixels.</summary>
|
||||
[JsonProperty("width")]
|
||||
public int Width { get; set; }
|
||||
|
||||
/// <summary>Captured height in pixels.</summary>
|
||||
[JsonProperty("height")]
|
||||
public int Height { get; set; }
|
||||
|
||||
/// <summary>Image encoding; always "png".</summary>
|
||||
[JsonProperty("encoding")]
|
||||
public string Encoding { get; set; }
|
||||
|
||||
/// <summary>Base64-encoded PNG bytes.</summary>
|
||||
[JsonProperty("base64")]
|
||||
public string Base64 { get; set; }
|
||||
|
||||
/// <summary>Length of the raw PNG byte array.</summary>
|
||||
[JsonProperty("bytes")]
|
||||
public int Bytes { get; set; }
|
||||
|
||||
public static CaptureElementResponse Fail(string message) => new CaptureElementResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
#endif
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3e2225ea88f5b5a4c9be3afc9c5d9f4e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
Reference in New Issue
Block a user