Add Unity Pipeline package support

This commit is contained in:
ud18010
2026-07-31 17:34:04 +08:00
parent d1a526094e
commit 786b7ffff9
590 changed files with 51995 additions and 2 deletions
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b6905de2a6949fc40bea9d708751b0fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,51 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Attribute to mark parameters of CLI command methods.
/// Provides parameter metadata for CLI validation and help generation.
/// Adapted from unity-tools [Parameter] attribute for Pipeline organization requirements.
///
/// Also valid on the fields/properties of an <see cref="IStructuredCommandInput"/> DTO, where it
/// describes a member of a nested object schema (same Name/Description/Required semantics).
/// </summary>
[AttributeUsage(AttributeTargets.Parameter | AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false)]
public class CliArgAttribute : Attribute
{
/// <summary>
/// Name of the parameter as it appears in CLI arguments.
/// Used in "unity request command_name --param_name value" syntax.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the parameter.
/// Used in CLI help text and parameter documentation.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this parameter is required for command execution.
/// Default: false (parameter is optional).
/// </summary>
public bool Required { get; set; } = false;
/// <summary>
/// Default value for the parameter if not provided.
/// Must be compatible with the parameter type.
/// </summary>
public object DefaultValue { get; set; }
/// <summary>
/// Create a new CLI parameter attribute.
/// </summary>
/// <param name="name">Parameter name for CLI arguments</param>
/// <param name="description">Human-readable description</param>
public CliArgAttribute(string name, string description)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ac8886d9f7e92d844b4f1728ce2e2cab
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,50 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Attribute to mark static methods as CLI-accessible commands.
/// Commands with this attribute can be executed remotely via Pipeline Server.
/// Adapted from unity-tools [Tool] attribute for Pipeline organization requirements.
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class CliCommandAttribute : Attribute
{
/// <summary>
/// Unique name of the command for CLI execution.
/// Used in "unity request command_name" syntax.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of what the command does.
/// Used in CLI help text and command discovery.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this command requires Unity main thread execution.
/// Default: true (most Unity APIs require main thread).
/// </summary>
public bool MainThreadRequired { get; set; } = true;
/// <summary>
/// Whether this command belongs to the runtime (Player) command surface only.
/// Runtime-only commands are hidden from an Editor server's command listing
/// (they remain executable, but are not advertised when connected to an Editor).
/// Default: false (listed on the Editor server).
/// </summary>
public bool RuntimeOnly { get; set; } = false;
/// <summary>
/// Create a new CLI command attribute.
/// </summary>
/// <param name="name">Unique command name for CLI execution</param>
/// <param name="description">Human-readable description</param>
public CliCommandAttribute(string name, string description)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ee4da62af1984f442af8ada30f3eee83
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,64 @@
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Reflection;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Information about a discovered CLI command.
/// Contains metadata needed for command execution and CLI help generation.
/// </summary>
public class CommandInfo
{
/// <summary>
/// Unique name of the command for CLI execution.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the command.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this command requires Unity main thread execution.
/// </summary>
public bool MainThreadRequired { get; }
/// <summary>
/// Whether this command is part of the runtime (Player) command surface only.
/// Editor servers hide runtime-only commands from their command listing.
/// </summary>
public bool RuntimeOnly { get; }
/// <summary>
/// Method that implements this command.
/// </summary>
public MethodInfo Method { get; } // TODO Maybe look at generating a dynamic Delegate to increase performance of the command call.
/// <summary>
/// Parameters that this command accepts.
/// </summary>
public IReadOnlyList<CommandParameterInfo> Parameters { get; }
/// <summary>
/// Create command information from discovery.
/// </summary>
public CommandInfo(string name, string description, bool mainThreadRequired,
MethodInfo method, IReadOnlyList<CommandParameterInfo> parameters, bool runtimeOnly = false)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
Method = method ?? throw new ArgumentNullException(nameof(method));
Parameters = parameters ?? throw new ArgumentNullException(nameof(parameters));
MainThreadRequired = mainThreadRequired;
RuntimeOnly = runtimeOnly;
}
public override string ToString()
{
return $"{Name} MainThreadRequired:{MainThreadRequired} Parameters:{Parameters.Count}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 40290d720d0d35b44a9fa5843a5ee294
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,258 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Unity.Pipeline.HotReload;
using UnityEngine;
#if UNITY_6000_3_OR_NEWER
using UnityEngine.Assemblies;
#endif
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Registry for discovering and managing CLI commands via pluggable discovery mechanism.
/// Scans for methods marked with [CliCommand] attribute across all assemblies.
/// Based on unity-tools ToolRegistry patterns adapted for Pipeline requirements.
/// </summary>
public static class CommandRegistry
{
private static IReadOnlyList<CommandInfo> m_CachedCommands;
private static ICommandDiscovery m_Discovery;
/// <summary>
/// Set the command discovery mechanism.
/// Editor assembly provides TypeCache-based discovery, Runtime uses reflection fallback.
/// </summary>
public static void SetDiscovery(ICommandDiscovery discovery)
{
m_Discovery = discovery;
ClearCache(); // Re-discover with new mechanism
}
/// <summary>
/// Discover all CLI commands marked with [CliCommand] attribute.
/// Uses injected discovery mechanism (TypeCache in Editor, reflection in Runtime).
/// Results are cached until domain reload.
/// </summary>
public static IEnumerable<CommandInfo> DiscoverCommands()
{
if (m_CachedCommands == null)
{
m_CachedCommands = DiscoverCommandsInternal().ToList();
// Also discover hot reload methods when commands are discovered
DiscoverHotReloadMethods();
}
return m_CachedCommands;
}
/// <summary>
/// Clear the command cache. Called automatically on domain reload.
/// Can be called manually for testing or dynamic command registration.
/// </summary>
public static void ClearCache()
{
m_CachedCommands = null;
}
/// <summary>
/// Internal implementation of command discovery using injected discovery mechanism.
/// </summary>
private static IEnumerable<CommandInfo> DiscoverCommandsInternal()
{
var commands = new List<CommandInfo>();
try
{
// Use injected discovery mechanism if available, otherwise fallback to reflection
IEnumerable<MethodInfo> methods;
if (m_Discovery != null)
{
methods = m_Discovery.GetMethodsWithAttribute<CliCommandAttribute>();
}
else
{
// Fallback: Use reflection to scan loaded assemblies
methods = GetMethodsWithAttributeViaReflection<CliCommandAttribute>();
}
foreach (var method in methods)
{
try
{
var commandInfo = CreateCommandInfo(method);
if (commandInfo != null)
{
commands.Add(commandInfo);
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to register command from method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to discover commands: {ex.Message}");
}
return commands;
}
/// <summary>
/// Fallback command discovery using reflection when TypeCache is not available.
/// </summary>
private static IEnumerable<MethodInfo> GetMethodsWithAttributeViaReflection<T>() where T : Attribute
{
var methods = new List<MethodInfo>();
try
{
var assemblies = PipelineUtils.GetLoadedAssemblies();
foreach (var assembly in assemblies)
{
// Skip system assemblies for performance
if (IsSystemAssembly(assembly))
continue;
try
{
foreach (var type in assembly.GetTypes())
{
foreach (var method in type.GetMethods(BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))
{
if (method.GetCustomAttribute<T>() != null)
{
methods.Add(method);
}
}
}
}
catch (ReflectionTypeLoadException)
{
// Skip assemblies that can't be loaded
continue;
}
}
}
catch (Exception ex)
{
Debug.LogWarning($"Reflection-based command discovery failed: {ex.Message}");
}
return methods;
}
/// <summary>
/// Check if assembly is a system assembly that should be skipped during discovery.
/// </summary>
private static bool IsSystemAssembly(Assembly assembly)
{
var name = assembly.GetName().Name;
return name.StartsWith("System.") ||
name.StartsWith("Microsoft.") ||
name.StartsWith("mscorlib") ||
name.StartsWith("netstandard") ||
name.Equals("UnityEngine") ||
name.Equals("UnityEditor");
}
/// <summary>
/// Create CommandInfo from a method with CliCommand attribute.
/// </summary>
private static CommandInfo CreateCommandInfo(MethodInfo method)
{
var commandAttr = method.GetCustomAttribute<CliCommandAttribute>();
if (commandAttr == null)
return null;
// Validate method is static (required for CLI commands).
// Any static method may be registered regardless of accessibility
// (public, internal, or private) — invocation goes through MethodInfo.Invoke.
if (!method.IsStatic)
{
Debug.LogWarning($"Command method {method.DeclaringType?.Name}.{method.Name} must be static");
return null;
}
// Discover parameters
var parameters = DiscoverParameters(method).ToList();
return new CommandInfo(
commandAttr.Name,
commandAttr.Description,
commandAttr.MainThreadRequired,
method,
parameters,
commandAttr.RuntimeOnly
);
}
/// <summary>
/// Discover parameter information from method parameters.
/// </summary>
private static IEnumerable<CommandParameterInfo> DiscoverParameters(MethodInfo method)
{
foreach (var param in method.GetParameters())
{
var argAttr = param.GetCustomAttribute<CliArgAttribute>();
// Parameters without CliArg attribute get default metadata
var name = argAttr?.Name ?? param.Name;
var description = argAttr?.Description ?? $"Parameter: {param.Name}";
var required = argAttr?.Required ?? !param.HasDefaultValue;
var defaultValue = param.HasDefaultValue ? param.DefaultValue : argAttr?.DefaultValue;
yield return new CommandParameterInfo(name, description, required, param.ParameterType, defaultValue);
}
}
/// <summary>
/// Discover and register methods marked with [HotReloadWithOverrides] attribute.
/// Called during command discovery to populate the hot reload registry.
/// </summary>
private static void DiscoverHotReloadMethods()
{
try
{
// Use injected discovery mechanism if available, otherwise fallback to reflection
IEnumerable<MethodInfo> methods;
if (m_Discovery != null)
{
methods = m_Discovery.GetMethodsWithAttribute<HotReloadWithOverridesAttribute>();
}
else
{
// Fallback: Use reflection to scan loaded assemblies
methods = GetMethodsWithAttributeViaReflection<HotReloadWithOverridesAttribute>();
}
foreach (var method in methods)
{
try
{
var hotReloadAttr = method.GetCustomAttribute<HotReloadWithOverridesAttribute>();
if (hotReloadAttr != null)
{
HotReloadRegistry.RegisterReloadableMethod(method, hotReloadAttr);
}
}
catch (Exception ex)
{
Debug.LogWarning($"Failed to register hot reload method {method.DeclaringType?.Name}.{method.Name}: {ex.Message}");
}
}
}
catch (Exception ex)
{
Debug.LogError($"Failed to discover hot reload methods: {ex.Message}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c53b0ba553311e14c833aadafdb44999
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,200 @@
using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace Unity.Pipeline.Threading
{
/// <summary>
/// Dispatches work to Unity's main thread from background threads (required for accessing Unity
/// APIs from HTTP request handlers).
///
/// Each pipeline server owns its own instance (no global singleton): it is initialized on Start
/// and pumped from the main thread — auto-pumped via EditorApplication.update in the editor, and
/// by RuntimePipelineManager.Update in a player.
/// </summary>
public class Dispatcher
{
private readonly ConcurrentQueue<WorkItem> m_WorkQueue = new ConcurrentQueue<WorkItem>();
private volatile bool m_IsInitialized;
private int m_MainThreadId = -1;
public bool IsInitialized => m_IsInitialized;
/// <summary>
/// Initialize the dispatcher. Must be called from Unity's main thread.
/// </summary>
public void Initialize()
{
if (m_IsInitialized)
return;
m_MainThreadId = Thread.CurrentThread.ManagedThreadId;
m_IsInitialized = true;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update += ProcessWorkQueue;
#endif
}
/// <summary>
/// Shutdown the dispatcher and cancel any pending work.
/// </summary>
public void Shutdown()
{
if (!m_IsInitialized)
return;
#if UNITY_EDITOR
UnityEditor.EditorApplication.update -= ProcessWorkQueue;
#endif
while (m_WorkQueue.TryDequeue(out var item))
{
try
{
item.SetException(new OperationCanceledException("Dispatcher is shutting down"));
}
catch { }
}
m_IsInitialized = false;
}
/// <summary>
/// Execute a function on the main thread and return the result (synchronous wait).
/// </summary>
public T Invoke<T>(Func<T> function, int timeoutMs = 60000)
{
if (!m_IsInitialized)
throw new InvalidOperationException("Dispatcher must be initialized first");
if (IsMainThread())
return function();
var workItem = new WorkItem<T>(function);
m_WorkQueue.Enqueue(workItem);
var startTime = DateTime.UtcNow;
var task = workItem.TaskCompletionSource.Task;
while (!task.IsCompleted)
{
if ((DateTime.UtcNow - startTime).TotalMilliseconds > timeoutMs)
throw new TimeoutException($"Main thread operation timed out after {timeoutMs}ms");
Thread.Sleep(1);
}
if (task.IsFaulted)
throw task.Exception?.GetBaseException() ?? new Exception("Unknown error");
if (task.IsCanceled)
throw new OperationCanceledException("Main thread operation was cancelled");
return task.Result;
}
/// <summary>
/// Execute an action on the main thread.
/// </summary>
public void Invoke(Action action, int timeoutMs = 60000)
{
Invoke<object>(() =>
{
action();
return null;
}, timeoutMs);
}
/// <summary>
/// Execute a function on the main thread and return the result (async version).
/// </summary>
public async Task<T> InvokeAsync<T>(Func<T> function, int timeoutMs = 60000)
{
return await Task.Run(() => Invoke(function, timeoutMs));
}
/// <summary>
/// Execute an action on the main thread (async version).
/// </summary>
public async Task InvokeAsync(Action action, int timeoutMs = 60000)
{
await Task.Run(() => Invoke(action, timeoutMs));
}
/// <summary>
/// Check if we're currently on Unity's main thread.
/// </summary>
public bool IsMainThread()
{
return m_MainThreadId != -1 && Thread.CurrentThread.ManagedThreadId == m_MainThreadId;
}
/// <summary>
/// Process queued work items. Called from EditorApplication.update or MonoBehaviour.Update.
/// </summary>
/// <param name="maxItemsPerFrame">Max items to process per call, to limit frame-rate impact.</param>
public void ProcessWorkQueue(int maxItemsPerFrame)
{
int processedCount = 0;
while (processedCount < maxItemsPerFrame && m_WorkQueue.TryDequeue(out var workItem))
{
try
{
workItem.Execute();
}
catch (Exception ex)
{
Debug.LogError($"Dispatcher work item failed: {ex.Message}");
workItem.SetException(ex);
}
processedCount++;
}
}
public void ProcessWorkQueue()
{
ProcessWorkQueue(10);
}
private abstract class WorkItem
{
public abstract void Execute();
public abstract void SetException(Exception exception);
}
private class WorkItem<T> : WorkItem
{
private readonly Func<T> m_Function;
public TaskCompletionSource<T> TaskCompletionSource { get; }
public WorkItem(Func<T> function)
{
m_Function = function ?? throw new ArgumentNullException(nameof(function));
TaskCompletionSource = new TaskCompletionSource<T>();
}
public override void Execute()
{
try
{
var result = m_Function();
TaskCompletionSource.SetResult(result);
}
catch (Exception ex)
{
TaskCompletionSource.SetException(ex);
}
}
public override void SetException(Exception exception)
{
TaskCompletionSource.SetException(exception);
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: df7819f4aeb94b2408fd309361f67243
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,109 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using UnityEngine;
using Debug = UnityEngine.Debug;
namespace Unity.Pipeline
{
/// <summary>
/// Restricts a file so that only the user who started the process can read or write it.
/// Used to protect the instance descriptor (port file), which carries the auth token.
///
/// The managed ACL APIs (System.Security.AccessControl) are not part of Unity's runtime
/// profile, so Windows is handled via icacls and Unix via libc chmod.
/// </summary>
public static class FilePermissions
{
/// <summary>
/// Restrict the file at <paramref name="path"/> to the current user only.
/// Failures are logged but never thrown, so they cannot block server startup.
/// </summary>
public static void RestrictToCurrentUser(string path)
{
try
{
switch (Application.platform)
{
case RuntimePlatform.WindowsEditor:
case RuntimePlatform.WindowsPlayer:
RestrictWindows(path);
break;
default:
RestrictUnix(path);
break;
}
}
catch (Exception ex)
{
Debug.LogWarning($"Pipeline: Could not restrict permissions on '{path}': {ex.Message}");
}
}
private static void RestrictWindows(string path)
{
// Grant by SID rather than account name: AzureAD/Entra-joined accounts (e.g.
// "AzureAD\\user") have no name-to-SID mapping for icacls, but the SID always resolves.
var sid = GetCurrentUserSid();
if (string.IsNullOrEmpty(sid))
{
Debug.LogWarning($"Pipeline: Could not resolve current user SID; leaving default permissions on '{path}'.");
return;
}
// /inheritance:r drops inherited ACEs; /grant:r replaces the DACL with a single
// full-control grant to the current user.
Run("icacls", $"\"{path}\" /inheritance:r /grant:r \"*{sid}:(F)\"", out _);
}
private static string GetCurrentUserSid()
{
if (!Run("whoami", "/user /fo csv /nh", out var output) || string.IsNullOrEmpty(output))
return null;
// Output looks like: "domain\user","S-1-5-21-..."
var idx = output.IndexOf("S-1-", StringComparison.Ordinal);
if (idx < 0)
return null;
var end = idx;
while (end < output.Length && (char.IsLetterOrDigit(output[end]) || output[end] == '-'))
end++;
return output.Substring(idx, end - idx);
}
private static bool Run(string fileName, string arguments, out string stdout)
{
stdout = null;
var psi = new ProcessStartInfo(fileName, arguments)
{
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
using (var process = Process.Start(psi))
{
if (process == null)
return false;
// Read before WaitForExit to avoid a full-pipe deadlock.
stdout = process.StandardOutput.ReadToEnd();
process.WaitForExit();
return process.ExitCode == 0;
}
}
[DllImport("libc", SetLastError = true)]
private static extern int chmod(string pathname, uint mode);
private static void RestrictUnix(string path)
{
// 0600 (octal) == owner read/write only.
const uint ownerReadWrite = 0x180;
chmod(path, ownerReadWrite);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e310dee2532338d4e914d3c909ea4db0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,18 @@
using System.Collections.Generic;
using System.Reflection;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Interface for command discovery mechanism.
/// Allows Runtime assembly to discover commands without directly using Editor APIs.
/// </summary>
public interface ICommandDiscovery
{
/// <summary>
/// Find all methods marked with CliCommand attribute.
/// Implementation uses TypeCache in Editor or reflection fallback in Runtime.
/// </summary>
IEnumerable<MethodInfo> GetMethodsWithAttribute<T>() where T : System.Attribute;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: c6e1392c576ec4d4d9b389d5fc899303
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,28 @@
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Marker for a command parameter type that should be advertised to clients as a nested JSON
/// <em>object</em> schema in <c>GET /api/commands</c>, instead of collapsing to <c>string</c>.
///
/// Convention for command authors: when a command needs a structured (multi-field) argument,
/// declare a small DTO that implements this interface and take it as a single parameter. The
/// <see cref="JsonSchemaGenerator"/> reflects over the type's public, writable fields and
/// properties to emit <c>{ "type": "object", "properties": { ... } }</c> (recursing into nested
/// <see cref="IStructuredCommandInput"/> members and arrays/lists of them). At runtime the value
/// deserializes automatically via Newtonsoft in
/// <c>BasePipelineServer.ExtractCommandParameters</c>, so no extra wiring is needed.
///
/// Member metadata:
/// <list type="bullet">
/// <item><description>Annotate members with <c>[CliArg(name, description, Required = ...)]</c> to control the
/// property name, description, and whether it appears in the schema's <c>required</c> array
/// (mirrors how command parameters are described).</description></item>
/// <item><description>Without a <c>[CliArg]</c>, the member name (or its Newtonsoft <c>[JsonProperty]</c> name)
/// is used and the member is optional.</description></item>
/// <item><description><c>[JsonIgnore]</c> members are omitted from the schema.</description></item>
/// </list>
/// </summary>
public interface IStructuredCommandInput
{
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 366b2704c3b5c044aa544fe545d462f0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,324 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Generates JSON Schema for CLI commands to enable parameter validation and help generation.
/// Produces standard JSON Schema format that CLI tools can use for validation and auto-completion.
///
/// Primitive, enum, and array parameters map directly. Parameters whose type implements
/// <see cref="IStructuredCommandInput"/> are emitted as nested object schemas (recursing into
/// structured members and arrays/lists of them) rather than collapsing to <c>string</c>, so
/// agents calling the package — directly or as MCP tools whose schemas come from
/// <c>GET /api/commands</c> — can construct structured arguments reliably.
/// </summary>
public static class JsonSchemaGenerator
{
/// <summary>
/// Generate JSON Schema for a command.
/// Returns standard JSON Schema format for CLI validation.
/// </summary>
public static string GenerateCommandSchema(CommandInfo command)
{
if (command == null)
throw new ArgumentNullException(nameof(command));
var schema = new JObject
{
["$schema"] = "http://json-schema.org/draft-07/schema#",
["type"] = "object",
["title"] = command.Name,
["description"] = command.Description,
["properties"] = GenerateParameterProperties(command.Parameters),
["required"] = new JArray(command.Parameters.Where(p => p.Required).Select(p => p.Name)),
["additionalProperties"] = false
};
// Add command metadata for CLI tools
schema["x-command-metadata"] = new JObject
{
["mainThreadRequired"] = command.MainThreadRequired,
["methodName"] = $"{command.Method.DeclaringType?.FullName}.{command.Method.Name}"
};
return schema.ToString(Formatting.Indented);
}
/// <summary>
/// Generate properties section of JSON Schema from command parameters.
/// </summary>
private static JObject GenerateParameterProperties(IEnumerable<CommandParameterInfo> parameters)
{
var properties = new JObject();
foreach (var parameter in parameters)
{
properties[parameter.Name] = GenerateParameterSchema(parameter);
}
return properties;
}
/// <summary>
/// Generate JSON Schema for a single top-level command parameter.
/// </summary>
private static JObject GenerateParameterSchema(CommandParameterInfo parameter)
{
var schema = GenerateTypeSchema(parameter.ParameterType, new HashSet<Type>());
schema["description"] = parameter.Description;
// Add default value if present (skip for objects/arrays whose default is typically null)
if (parameter.DefaultValue != null)
{
try
{
schema["default"] = JToken.FromObject(parameter.DefaultValue);
}
catch
{
// Non-serializable default; omit rather than fail schema generation.
}
}
return schema;
}
/// <summary>
/// Build the schema fragment for a CLR type: scalar/enum, array/list, or — for
/// <see cref="IStructuredCommandInput"/> types — a nested object schema. <paramref name="visited"/>
/// tracks structured types currently being expanded so self-referential graphs don't recurse
/// forever.
/// </summary>
private static JObject GenerateTypeSchema(Type type, HashSet<Type> visited)
{
// Unwrap Nullable<T> so e.g. int? schemas as "integer".
var underlying = Nullable.GetUnderlyingType(type);
if (underlying != null)
type = underlying;
if (typeof(IStructuredCommandInput).IsAssignableFrom(type) && !type.IsAbstract && !type.IsInterface)
return GenerateObjectSchema(type, visited);
if (TryGetEnumerableElementType(type, out var elementType))
{
return new JObject
{
["type"] = "array",
["items"] = GenerateTypeSchema(elementType, visited)
};
}
var schema = new JObject();
var (jsonType, format) = MapTypeToJsonSchema(type);
schema["type"] = jsonType;
if (!string.IsNullOrEmpty(format))
schema["format"] = format;
if (type.IsEnum)
schema["enum"] = new JArray(Enum.GetNames(type));
return schema;
}
/// <summary>
/// Emit a nested object schema for an <see cref="IStructuredCommandInput"/> type by reflecting
/// its public, writable fields and properties.
/// </summary>
private static JObject GenerateObjectSchema(Type type, HashSet<Type> visited)
{
var schema = new JObject { ["type"] = "object" };
// Cycle guard: if we're already expanding this type higher in the stack, stop recursing
// and emit an open object rather than looping.
if (!visited.Add(type))
{
schema["additionalProperties"] = true;
return schema;
}
try
{
var properties = new JObject();
var required = new JArray();
foreach (var member in GetSchemaMembers(type))
{
var memberSchema = GenerateTypeSchema(member.Type, visited);
if (!string.IsNullOrEmpty(member.Description))
memberSchema["description"] = member.Description;
properties[member.Name] = memberSchema;
if (member.Required)
required.Add(member.Name);
}
schema["properties"] = properties;
if (required.Count > 0)
schema["required"] = required;
schema["additionalProperties"] = false;
}
finally
{
visited.Remove(type);
}
return schema;
}
/// <summary>
/// Enumerate the schema-bearing members of a structured-input type: public instance fields and
/// read/write properties. Honors <c>[JsonIgnore]</c>, and reads name/description/required from
/// <c>[CliArg]</c> (falling back to a Newtonsoft <c>[JsonProperty]</c> name, then the member name).
/// </summary>
private static IEnumerable<SchemaMember> GetSchemaMembers(Type type)
{
const BindingFlags flags = BindingFlags.Public | BindingFlags.Instance;
foreach (var field in type.GetFields(flags))
{
if (field.IsInitOnly || field.IsLiteral)
continue;
if (field.GetCustomAttribute<JsonIgnoreAttribute>() != null)
continue;
yield return ToSchemaMember(field, field.FieldType);
}
foreach (var property in type.GetProperties(flags))
{
if (!property.CanRead || !property.CanWrite)
continue;
if (property.GetIndexParameters().Length > 0)
continue;
if (property.GetCustomAttribute<JsonIgnoreAttribute>() != null)
continue;
yield return ToSchemaMember(property, property.PropertyType);
}
}
private static SchemaMember ToSchemaMember(MemberInfo member, Type memberType)
{
var cliArg = member.GetCustomAttribute<CliArgAttribute>();
var jsonProperty = member.GetCustomAttribute<JsonPropertyAttribute>();
return new SchemaMember
{
Name = cliArg?.Name ?? jsonProperty?.PropertyName ?? member.Name,
Type = memberType,
Description = cliArg?.Description,
Required = cliArg?.Required ?? false
};
}
/// <summary>
/// Detect a JSON-array-shaped type (arrays and common generic collections) and yield its
/// element type. <c>string</c> is intentionally excluded (it is <see cref="IEnumerable{T}"/>
/// of char but maps to a JSON string).
/// </summary>
private static bool TryGetEnumerableElementType(Type type, out Type elementType)
{
elementType = null;
if (type == typeof(string))
return false;
if (type.IsArray)
{
elementType = type.GetElementType();
return elementType != null;
}
if (type.IsGenericType)
{
var definition = type.GetGenericTypeDefinition();
if (definition == typeof(List<>) || definition == typeof(IList<>) ||
definition == typeof(IEnumerable<>) || definition == typeof(ICollection<>) ||
definition == typeof(IReadOnlyList<>) || definition == typeof(IReadOnlyCollection<>))
{
elementType = type.GetGenericArguments()[0];
return true;
}
}
return false;
}
/// <summary>
/// Map a scalar/enum C# type to JSON Schema type and format. Object and array shapes are
/// handled by <see cref="GenerateTypeSchema"/> before this is reached; anything else falls
/// back to <c>string</c>.
/// </summary>
private static (string type, string format) MapTypeToJsonSchema(Type csharpType)
{
// Handle nullable types
if (Nullable.GetUnderlyingType(csharpType) != null)
{
csharpType = Nullable.GetUnderlyingType(csharpType);
}
// String types
if (csharpType == typeof(string) || csharpType == typeof(char))
{
return ("string", null);
}
// Integer types
if (csharpType == typeof(int) || csharpType == typeof(long) ||
csharpType == typeof(short) || csharpType == typeof(byte) ||
csharpType == typeof(uint) || csharpType == typeof(ulong) ||
csharpType == typeof(ushort) || csharpType == typeof(sbyte))
{
return ("integer", null);
}
// Number types
if (csharpType == typeof(float) || csharpType == typeof(double) || csharpType == typeof(decimal))
{
return ("number", null);
}
// Boolean type
if (csharpType == typeof(bool))
{
return ("boolean", null);
}
// Enum types - represent as string with enum constraint (added by the caller)
if (csharpType.IsEnum)
{
return ("string", null);
}
// DateTime types
if (csharpType == typeof(DateTime) || csharpType == typeof(DateTimeOffset))
{
return ("string", "date-time");
}
// Guid type
if (csharpType == typeof(Guid))
{
return ("string", "uuid");
}
// Default to string for unknown types
return ("string", null);
}
/// <summary>A reflected member of a structured-input type, flattened for schema emission.</summary>
private struct SchemaMember
{
public string Name;
public Type Type;
public string Description;
public bool Required;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 779098faca184884f9f588e41c4c0898
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,97 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Unity.Pipeline
{
/// <summary>
/// Read-only stream wrapper that enforces a maximum number of bytes read from an inner stream.
/// Once cumulative reads exceed <c>maxBytes</c>, the next read throws
/// <see cref="RequestTooLargeException"/>. Used to cap HTTP request bodies even when the
/// Content-Length header is absent or untruthful (e.g. chunked transfer-encoding), so an
/// oversized body cannot be buffered into memory.
/// </summary>
internal sealed class MaxLengthStream : Stream
{
private readonly Stream m_Inner;
private readonly long m_MaxBytes;
private readonly bool m_LeaveOpen;
private long m_TotalRead;
/// <param name="leaveOpen">
/// When true, disposing this wrapper does not dispose <paramref name="inner"/>. The server
/// uses this so it can keep reading (draining) the raw request stream after the wrapper is
/// disposed on an over-limit read.
/// </param>
public MaxLengthStream(Stream inner, long maxBytes, bool leaveOpen = false)
{
m_Inner = inner ?? throw new ArgumentNullException(nameof(inner));
m_MaxBytes = maxBytes;
m_LeaveOpen = leaveOpen;
}
public override int Read(byte[] buffer, int offset, int count)
{
var read = m_Inner.Read(buffer, offset, count);
Account(read);
return read;
}
public override async Task<int> ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken)
{
var read = await m_Inner.ReadAsync(buffer, offset, count, cancellationToken);
Account(read);
return read;
}
// Throw as soon as the running total exceeds the cap. Exactly maxBytes is allowed.
private void Account(int read)
{
if (read <= 0)
return;
m_TotalRead += read;
if (m_TotalRead > m_MaxBytes)
throw new RequestTooLargeException(m_MaxBytes);
}
public override bool CanRead => true;
public override bool CanSeek => false;
public override bool CanWrite => false;
public override long Length => throw new NotSupportedException();
public override long Position
{
get => m_TotalRead;
set => throw new NotSupportedException();
}
public override void Flush() { }
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
public override void SetLength(long value) => throw new NotSupportedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
protected override void Dispose(bool disposing)
{
// Own the inner stream's lifetime (matching the previous `using (StreamReader(InputStream))`)
// unless the caller asked to leave it open so it can keep reading from it afterwards.
if (disposing && !m_LeaveOpen)
m_Inner.Dispose();
base.Dispose(disposing);
}
}
/// <summary>
/// Thrown by <see cref="MaxLengthStream"/> when a read would exceed the configured byte cap.
/// </summary>
internal sealed class RequestTooLargeException : Exception
{
public long MaxBytes { get; }
public RequestTooLargeException(long maxBytes)
: base($"Request body exceeds the maximum allowed size of {maxBytes} bytes.")
{
MaxBytes = maxBytes;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: d75fa6089aa06114c9450387a6d816ad
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,55 @@
using System;
namespace Unity.Pipeline.Commands
{
/// <summary>
/// Information about a CLI command parameter.
/// Contains metadata needed for parameter validation and CLI help generation.
/// </summary>
public class CommandParameterInfo
{
/// <summary>
/// Name of the parameter for CLI arguments.
/// </summary>
public string Name { get; }
/// <summary>
/// Human-readable description of the parameter.
/// </summary>
public string Description { get; }
/// <summary>
/// Whether this parameter is required for command execution.
/// </summary>
public bool Required { get; }
/// <summary>
/// Type of the parameter for validation and conversion.
/// </summary>
public Type ParameterType { get; }
/// <summary>
/// Default value for optional parameters.
/// </summary>
public object DefaultValue { get; }
/// <summary>
/// Create parameter information from discovery.
/// </summary>
public CommandParameterInfo(string name, string description, bool required,
Type parameterType, object defaultValue = null)
{
Name = name ?? throw new ArgumentNullException(nameof(name));
Description = description ?? throw new ArgumentNullException(nameof(description));
ParameterType = parameterType ?? throw new ArgumentNullException(nameof(parameterType));
Required = required;
DefaultValue = defaultValue;
}
public override string ToString()
{
return $"{Name} Required:{Required} Type:{ParameterType}";
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7127497e92677024f949772c30aaf79b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,163 @@
using System.Collections.Generic;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using UnityEngine;
#if UNITY_6000_3_OR_NEWER
using UnityEngine.Assemblies;
#endif
namespace Unity.Pipeline
{
/// <summary>
/// Version-stable handle to a Unity object id. In 6000.4 Unity replaced the int instance id with
/// <see cref="EntityId"/> (a ulong-backed handle), made <c>Object.GetInstanceID()</c> /
/// <c>EditorUtility.InstanceIDToObject(int)</c> obsolete-as-error, and is removing the
/// <c>EntityId</c>&lt;-&gt;<c>int</c> conversions. This struct stores the concrete id type for the
/// running editor — <see cref="EntityId"/> on 6000.4+, <c>int</c> below — and converts implicitly to
/// it so it can be passed straight to Unity APIs. All version branching for object ids is confined
/// here. It (de)serializes as a single JSON integer (the raw ulong on 6000.4+, the int below).
/// </summary>
[JsonConverter(typeof(ObjectIdConverter))]
public readonly struct ObjectId : System.IEquatable<ObjectId>
{
#if UNITY_6000_4_OR_NEWER
readonly EntityId m_Value;
public ObjectId(EntityId value) { m_Value = value; }
public static implicit operator EntityId(ObjectId id) => id.m_Value;
public static implicit operator ObjectId(EntityId value) => new ObjectId(value);
/// <summary>Raw 64-bit id — the canonical wire / serialization form.</summary>
public ulong RawValue => EntityId.ToULong(m_Value);
public static ObjectId FromRaw(ulong raw) => new ObjectId(EntityId.FromULong(raw));
public bool Equals(ObjectId other) => m_Value.Equals(other.m_Value);
public override int GetHashCode() => m_Value.GetHashCode();
#else
readonly int m_Value;
public ObjectId(int value) { m_Value = value; }
public static implicit operator int(ObjectId id) => id.m_Value;
public static implicit operator ObjectId(int value) => new ObjectId(value);
/// <summary>Raw id — the canonical wire / serialization form.</summary>
public long RawValue => m_Value;
public static ObjectId FromRaw(long raw) => new ObjectId((int)raw);
public bool Equals(ObjectId other) => m_Value == other.m_Value;
public override int GetHashCode() => m_Value;
#endif
public override bool Equals(object obj) => obj is ObjectId other && Equals(other);
public override string ToString() => RawValue.ToString();
/// <summary>Parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
public static ObjectId Parse(string s)
{
#if UNITY_6000_4_OR_NEWER
return FromRaw(ulong.Parse(s));
#else
return FromRaw(long.Parse(s));
#endif
}
/// <summary>Try to parse the canonical numeric form produced by <see cref="ToString"/>.</summary>
public static bool TryParse(string s, out ObjectId id)
{
#if UNITY_6000_4_OR_NEWER
if (ulong.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
#else
if (long.TryParse(s, out var raw)) { id = FromRaw(raw); return true; }
#endif
id = default;
return false;
}
}
/// <summary>JSON converter: an <see cref="ObjectId"/> is a single integer on the wire.</summary>
public sealed class ObjectIdConverter : JsonConverter<ObjectId>
{
public override void WriteJson(JsonWriter writer, ObjectId value, JsonSerializer serializer)
{
writer.WriteValue(value.RawValue);
}
public override ObjectId ReadJson(JsonReader reader, System.Type objectType, ObjectId existingValue,
bool hasExistingValue, JsonSerializer serializer)
{
var token = JToken.Load(reader);
switch (token.Type)
{
case JTokenType.Integer:
#if UNITY_6000_4_OR_NEWER
return ObjectId.FromRaw(token.Value<ulong>());
#else
return ObjectId.FromRaw(token.Value<long>());
#endif
case JTokenType.String:
return ObjectId.Parse((string)token);
default:
return default;
}
}
}
public static class PipelineUtils
{
public static string GetLoadedAssemblyPath(System.Reflection.Assembly a)
{
#if UNITY_6000_5_OR_NEWER
return a.GetLoadedAssemblyPath();
#else
return a.Location;
#endif
}
public static System.Reflection.Assembly LoadFromBytes(byte[] bytes, byte[] pdb = null)
{
#if UNITY_6000_5_OR_NEWER
return UnityEngine.Assemblies.CurrentAssemblies.LoadFromBytes(bytes, pdb);
#else
return System.Reflection.Assembly.Load(bytes, pdb);
#endif
}
public static T[] FindObjectsByType<T>() where T : Object
{
#if UNITY_6000_4_OR_NEWER
return GameObject.FindObjectsByType<T>();
#else
return GameObject.FindObjectsByType<T>(FindObjectsSortMode.None) ;
#endif
}
public static IReadOnlyList<System.Reflection.Assembly> GetLoadedAssemblies()
{
#if UNITY_6000_5_OR_NEWER
return CurrentAssemblies.GetLoadedAssemblies();
#else
return System.AppDomain.CurrentDomain.GetAssemblies();
#endif
}
/// <summary>The object's id as a version-stable <see cref="ObjectId"/> (EntityId on 6000.4+, int below).</summary>
public static ObjectId GetObjectId(Object obj)
{
#if UNITY_6000_4_OR_NEWER
return new ObjectId(obj.GetEntityId());
#else
return new ObjectId(obj.GetInstanceID());
#endif
}
#if UNITY_EDITOR
/// <summary>Resolve a loaded/scene object from its <see cref="ObjectId"/>, or null if not found.</summary>
public static Object IdToObject(ObjectId id)
{
#if UNITY_6000_4_OR_NEWER
return UnityEditor.EditorUtility.EntityIdToObject(id);
#elif UNITY_6000_3_OR_NEWER
// EntityIdToObject is the non-obsolete resolver from 6000.3, but the ulong-backed ObjectId
// (GetRawData/FromULong) doesn't exist until 6000.4 — so ObjectId is still int-backed here and
// we route the int through the (non-obsolete on 6.3) int->EntityId conversion.
return UnityEditor.EditorUtility.EntityIdToObject((int)id);
#else
return UnityEditor.EditorUtility.InstanceIDToObject((int)id);
#endif
}
#endif
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: e6c6a6cac05ac41438850c53ea3178b1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: