Add Unity Pipeline package support
This commit is contained in:
+577
@@ -0,0 +1,577 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using Unity.Pipeline.Commands;
|
||||
using Unity.Pipeline.Editor.Authoring;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEditor.Build.Reporting;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers a Player build and reports the full <see cref="BuildReport"/> (CLI-204). Fills out the
|
||||
/// original <c>build</c>/<c>build_status</c> stub (CLI-127) with the complete param contract and the
|
||||
/// structured report shape so build failures can be diagnosed without the Editor UI.
|
||||
///
|
||||
/// Why this is async (trigger-then-poll, mirroring <see cref="RecompileCommand"/> /
|
||||
/// <see cref="PackageManager.PackageManagerCommand"/>): <see cref="BuildPipeline.BuildPlayer(BuildPlayerOptions)"/>
|
||||
/// runs synchronously and freezes the main thread for the whole build (seconds to minutes). If
|
||||
/// <c>build</c> ran it inline it would block the server's (serial) request loop and the call could
|
||||
/// never return. Instead <c>build</c> validates + queues and returns <c>queued</c> immediately; an
|
||||
/// <see cref="EditorApplication.update"/> hook runs the build on the next tick. Poll <c>build_status</c>
|
||||
/// until <c>status == "completed"</c>.
|
||||
///
|
||||
/// <c>build_status</c> is deliberately <c>MainThreadRequired = false</c>: while the build holds the
|
||||
/// main thread, any main-thread command (e.g. editor_status) would hang, but build_status only reads
|
||||
/// a Temp status file off-thread, so polling keeps working throughout the build. The status file also
|
||||
/// survives the domain reload a build can incur, so the last report is retained until the next build.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class BuildCommand
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_build_status.json";
|
||||
|
||||
// BuildOptions that callers may pass by name (the supported set from the ticket). Parsed
|
||||
// case-insensitively; anything outside this set is a validation error rather than silently
|
||||
// tolerated, so a typo surfaces instead of changing the build shape.
|
||||
static readonly Dictionary<string, BuildOptions> s_SupportedOptions =
|
||||
new Dictionary<string, BuildOptions>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Development"] = BuildOptions.Development,
|
||||
["AllowDebugging"] = BuildOptions.AllowDebugging,
|
||||
["ConnectWithProfiler"] = BuildOptions.ConnectWithProfiler,
|
||||
// EnableHeadlessMode is obsolete in Unity 6 (superseded by StandaloneBuildSubtarget.Server)
|
||||
// but still functions and is part of this command's documented option set (CLI-204), so we
|
||||
// keep accepting it and suppress only the deprecation warning here.
|
||||
#pragma warning disable 618
|
||||
["EnableHeadlessMode"] = BuildOptions.EnableHeadlessMode,
|
||||
#pragma warning restore 618
|
||||
["SymlinkSources"] = BuildOptions.SymlinkSources,
|
||||
["BuildAdditionalStreamedScenes"] = BuildOptions.BuildAdditionalStreamedScenes,
|
||||
["CleanBuildCache"] = BuildOptions.CleanBuildCache,
|
||||
["DetailedBuildReport"] = BuildOptions.DetailedBuildReport,
|
||||
};
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Pending;
|
||||
static bool s_Building;
|
||||
|
||||
// Queued request, captured under s_Lock by build() and consumed by the update pump.
|
||||
static BuildTarget s_PendingTarget;
|
||||
static string s_PendingOutput;
|
||||
static BuildOptions s_PendingOptions;
|
||||
static string[] s_PendingScenes;
|
||||
static string s_PendingProfile;
|
||||
static string s_PendingBuildId;
|
||||
|
||||
static BuildCommand()
|
||||
{
|
||||
EditorApplication.update += OnUpdate;
|
||||
}
|
||||
|
||||
[CliCommand("build",
|
||||
"Trigger an async Player build and report the full BuildReport. Returns immediately (queued); " +
|
||||
"poll build_status until status is 'completed'. DetailedBuildReport is included by default unless " +
|
||||
"'options' is supplied. Use dry_run to validate without building.",
|
||||
MainThreadRequired = false)]
|
||||
public static object Build(
|
||||
[CliArg("target", "BuildTarget name (e.g. StandaloneWindows64). Defaults to the active target. Must be installed.")] string target = "",
|
||||
[CliArg("outputPath", "Output path (absolute, or relative to the project root). Defaults to the last/auto path.")] string outputPath = "",
|
||||
[CliArg("profileName", "Build Profile name to activate before building (Unity 6 only; ignored otherwise).")] string profileName = "",
|
||||
[CliArg("options", "BuildOptions names. Omit to get just DetailedBuildReport; supplying any disables that default.")] string[] options = null,
|
||||
[CliArg("scenes", "Scene asset paths to build (e.g. Assets/Scenes/Main.unity). Defaults to EditorBuildSettings.")] string[] scenes = null,
|
||||
[CliArg("confirm", "Acknowledge and run the build; without it the call is refused. Use dry_run to validate only.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Validate target/outputPath/scenes without building.")] bool dryRun = false)
|
||||
{
|
||||
// Validation + queueing both touch Unity APIs, so run them on the main thread. When invoked
|
||||
// off-thread (the normal HTTP path) this marshals through the server dispatcher; in-process
|
||||
// (tests, no server) it runs inline.
|
||||
return RunOnMain<object>(() => ValidateAndQueue(target, outputPath, profileName, options, scenes, confirm, dryRun));
|
||||
}
|
||||
|
||||
static object ValidateAndQueue(string targetArg, string outputArg, string profileName,
|
||||
string[] optionArgs, string[] sceneArgs, bool confirm, bool dryRun)
|
||||
{
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!dryRun && (s_Building || s_Pending))
|
||||
return new { status = "busy", success = false, message = "A build is already queued or in progress. Poll build_status." };
|
||||
}
|
||||
|
||||
var errors = new List<object>();
|
||||
|
||||
// ---- target ----
|
||||
var target = EditorUserBuildSettings.activeBuildTarget;
|
||||
if (!string.IsNullOrWhiteSpace(targetArg))
|
||||
{
|
||||
if (!TryParseTarget(targetArg, out target))
|
||||
errors.Add(Invalid("target", $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets."));
|
||||
else if (!BuildPipeline.IsBuildTargetSupported(BuildPipeline.GetBuildTargetGroup(target), target))
|
||||
errors.Add(Invalid("target", $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)."));
|
||||
}
|
||||
|
||||
// ---- options ----
|
||||
var options = BuildOptions.None;
|
||||
if (optionArgs != null && optionArgs.Length > 0)
|
||||
{
|
||||
foreach (var name in optionArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
continue;
|
||||
if (s_SupportedOptions.TryGetValue(name.Trim(), out var opt))
|
||||
options |= opt;
|
||||
else
|
||||
errors.Add(Invalid("options", $"Unsupported BuildOptions value '{name}'."));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Strongly recommended: without it BuildReport.packedAssets is empty.
|
||||
options = BuildOptions.DetailedBuildReport;
|
||||
}
|
||||
|
||||
// ---- scenes ----
|
||||
var scenes = (sceneArgs != null && sceneArgs.Length > 0)
|
||||
? sceneArgs
|
||||
: EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray();
|
||||
|
||||
if (sceneArgs != null)
|
||||
{
|
||||
foreach (var scene in sceneArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scene) || string.IsNullOrEmpty(AssetDatabase.AssetPathToGUID(scene)))
|
||||
errors.Add(Invalid("scenes", $"No scene asset at '{scene}'."));
|
||||
}
|
||||
}
|
||||
|
||||
// ---- output path ----
|
||||
// Non-mutating writability check: resolve the path and confirm an existing ancestor
|
||||
// directory exists (so the build's output folder can be created). BuildPipeline.BuildPlayer
|
||||
// creates the leaf directory itself, so we never create anything here — a dry run stays pure.
|
||||
string outputPath = null;
|
||||
try
|
||||
{
|
||||
outputPath = ResolveOutput(outputArg, target);
|
||||
if (!HasExistingAncestor(outputPath))
|
||||
errors.Add(Invalid("outputPath", $"No existing parent directory for '{outputPath}'."));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(Invalid("outputPath", $"Output path is invalid: {ex.Message}"));
|
||||
}
|
||||
|
||||
if (dryRun)
|
||||
{
|
||||
return new
|
||||
{
|
||||
status = "dry_run",
|
||||
valid = errors.Count == 0,
|
||||
validationErrors = errors
|
||||
};
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
// Fail immediately — do not queue (acceptance criteria 5).
|
||||
return new { status = "error", success = false, validationErrors = errors, message = "Build configuration is invalid; nothing was queued." };
|
||||
}
|
||||
|
||||
if (!confirm)
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
success = false,
|
||||
message = "Refused: this triggers a player build. Pass confirm=true to build, or dry_run=true to validate without building."
|
||||
};
|
||||
|
||||
var buildId = NewBuildId();
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_PendingTarget = target;
|
||||
s_PendingOutput = outputPath;
|
||||
s_PendingOptions = options;
|
||||
s_PendingScenes = scenes;
|
||||
s_PendingProfile = profileName;
|
||||
s_PendingBuildId = buildId;
|
||||
s_Pending = true;
|
||||
}
|
||||
|
||||
WriteStatusJson(new { status = "queued", buildId, message = "Build queued. Poll build_status until status is 'completed'." });
|
||||
|
||||
return new { status = "queued", buildId };
|
||||
}
|
||||
|
||||
[CliCommand("build_status",
|
||||
"Status of the current/most recent build: idle | queued | building | completed, with the full " +
|
||||
"BuildReport (files, packedAssets, buildSteps, errors, warnings) once completed. Retained until the next build.",
|
||||
MainThreadRequired = false)]
|
||||
public static string BuildStatus()
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return "{\"status\":\"idle\"}";
|
||||
|
||||
var text = File.ReadAllText(StatusFile);
|
||||
|
||||
// While building, recompute elapsedMs at read time so polling shows live progress.
|
||||
try
|
||||
{
|
||||
var doc = JObject.Parse(text);
|
||||
if ((string)doc["status"] == "building")
|
||||
{
|
||||
long elapsed = 0;
|
||||
var startedStr = (string)doc["buildStartedAt"];
|
||||
if (!string.IsNullOrEmpty(startedStr) &&
|
||||
DateTime.TryParse(startedStr, null, System.Globalization.DateTimeStyles.RoundtripKind, out var started))
|
||||
{
|
||||
elapsed = (long)Math.Max(0, (DateTime.UtcNow - started.ToUniversalTime()).TotalMilliseconds);
|
||||
}
|
||||
|
||||
return JsonConvert.SerializeObject(new
|
||||
{
|
||||
status = "building",
|
||||
buildId = (string)doc["buildId"],
|
||||
elapsedMs = elapsed
|
||||
});
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Malformed file — fall through and return it verbatim.
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Main-thread pump: picks up a queued request and runs the (blocking) build. Guarded so a build
|
||||
/// runs at most once at a time.
|
||||
/// </summary>
|
||||
static void OnUpdate()
|
||||
{
|
||||
BuildTarget target;
|
||||
string output;
|
||||
BuildOptions options;
|
||||
string[] scenes;
|
||||
string profile;
|
||||
string buildId;
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!s_Pending || s_Building)
|
||||
return;
|
||||
s_Pending = false;
|
||||
s_Building = true;
|
||||
target = s_PendingTarget;
|
||||
output = s_PendingOutput;
|
||||
options = s_PendingOptions;
|
||||
scenes = s_PendingScenes;
|
||||
profile = s_PendingProfile;
|
||||
buildId = s_PendingBuildId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RunBuild(buildId, target, output, options, scenes, profile);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
buildId,
|
||||
result = "Failed",
|
||||
totalSizeBytes = 0,
|
||||
errors = new[] { new { message = $"Build failed to start: {ex.Message}" } },
|
||||
warnings = Array.Empty<object>()
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (s_Lock) { s_Building = false; }
|
||||
}
|
||||
}
|
||||
|
||||
static void RunBuild(string buildId, BuildTarget target, string outputPath, BuildOptions options,
|
||||
string[] scenes, string profileName)
|
||||
{
|
||||
var startedAt = DateTime.UtcNow;
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "building",
|
||||
buildId,
|
||||
buildStartedAt = startedAt.ToString("o"),
|
||||
platform = target.ToString(),
|
||||
outputPath
|
||||
});
|
||||
|
||||
// Best-effort Build Profile activation (Unity 6 only). Never fails the build.
|
||||
if (!string.IsNullOrWhiteSpace(profileName))
|
||||
TryActivateBuildProfile(profileName);
|
||||
|
||||
var buildOptions = new BuildPlayerOptions
|
||||
{
|
||||
scenes = scenes,
|
||||
locationPathName = outputPath,
|
||||
target = target,
|
||||
targetGroup = BuildPipeline.GetBuildTargetGroup(target),
|
||||
options = options
|
||||
};
|
||||
|
||||
var report = BuildPipeline.BuildPlayer(buildOptions);
|
||||
WriteStatusJson(Project(buildId, report));
|
||||
}
|
||||
|
||||
// ---- BuildReport projection ------------------------------------------------------------
|
||||
|
||||
static BuildReportResult Project(string buildId, BuildReport report)
|
||||
{
|
||||
var summary = report.summary;
|
||||
var success = summary.result == BuildResult.Succeeded;
|
||||
var started = summary.buildStartedAt;
|
||||
var ended = started + summary.totalTime;
|
||||
|
||||
var result = new BuildReportResult
|
||||
{
|
||||
Status = "completed",
|
||||
BuildId = buildId,
|
||||
Result = summary.result.ToString(),
|
||||
Platform = summary.platform.ToString(),
|
||||
OutputPath = summary.outputPath,
|
||||
TotalSizeBytes = (long)summary.totalSize,
|
||||
BuildTimeMs = (long)summary.totalTime.TotalMilliseconds,
|
||||
BuildStartedAt = started.ToUniversalTime().ToString("o"),
|
||||
BuildEndedAt = ended.ToUniversalTime().ToString("o"),
|
||||
TotalWarnings = summary.totalWarnings,
|
||||
TotalErrors = summary.totalErrors,
|
||||
BuildSteps = ProjectSteps(report, out var errors, out var warnings),
|
||||
Errors = errors,
|
||||
Warnings = warnings
|
||||
};
|
||||
|
||||
// Files / packed assets are only meaningful on success (and packedAssets needs DetailedBuildReport).
|
||||
if (success)
|
||||
{
|
||||
result.Files = ProjectFiles(report);
|
||||
result.PackedAssets = ProjectPackedAssets(report);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static List<BuildFileEntry> ProjectFiles(BuildReport report)
|
||||
{
|
||||
var files = new List<BuildFileEntry>();
|
||||
try
|
||||
{
|
||||
foreach (var f in report.GetFiles())
|
||||
files.Add(new BuildFileEntry { Path = f.path, Role = f.role, SizeBytes = (long)f.size });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build file projection failed: {ex.Message}");
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
static List<PackedAssetEntry> ProjectPackedAssets(BuildReport report)
|
||||
{
|
||||
var packed = new List<PackedAssetEntry>();
|
||||
if (report.packedAssets == null)
|
||||
return packed;
|
||||
|
||||
foreach (var bundle in report.packedAssets)
|
||||
{
|
||||
var entry = new PackedAssetEntry
|
||||
{
|
||||
BundlePath = bundle.shortPath,
|
||||
OverheadBytes = (long)bundle.overhead,
|
||||
Contents = new List<PackedAssetContent>()
|
||||
};
|
||||
|
||||
if (bundle.contents != null)
|
||||
{
|
||||
foreach (var c in bundle.contents)
|
||||
{
|
||||
entry.Contents.Add(new PackedAssetContent
|
||||
{
|
||||
AssetPath = c.sourceAssetPath,
|
||||
Type = c.type != null ? c.type.Name : null,
|
||||
Guid = c.sourceAssetGUID.ToString(),
|
||||
SizeBytes = (long)c.packedSize
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
packed.Add(entry);
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
static List<BuildStepEntry> ProjectSteps(BuildReport report,
|
||||
out List<BuildIssue> errors, out List<BuildIssue> warnings)
|
||||
{
|
||||
errors = new List<BuildIssue>();
|
||||
warnings = new List<BuildIssue>();
|
||||
|
||||
var steps = new List<BuildStepEntry>();
|
||||
if (report.steps == null)
|
||||
return steps;
|
||||
|
||||
foreach (var step in report.steps)
|
||||
{
|
||||
var entry = new BuildStepEntry
|
||||
{
|
||||
Name = step.name,
|
||||
DurationMs = (long)step.duration.TotalMilliseconds,
|
||||
Depth = (int)step.depth,
|
||||
Messages = new List<BuildStepMessageEntry>()
|
||||
};
|
||||
|
||||
if (step.messages != null)
|
||||
{
|
||||
foreach (var m in step.messages)
|
||||
{
|
||||
entry.Messages.Add(new BuildStepMessageEntry { Type = MapLogType(m.type), Content = m.content });
|
||||
|
||||
if (m.type == LogType.Error || m.type == LogType.Assert || m.type == LogType.Exception)
|
||||
errors.Add(BuildIssue.From(m.content));
|
||||
else if (m.type == LogType.Warning)
|
||||
warnings.Add(BuildIssue.From(m.content));
|
||||
}
|
||||
}
|
||||
|
||||
steps.Add(entry);
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
static string MapLogType(LogType type)
|
||||
{
|
||||
switch (type)
|
||||
{
|
||||
case LogType.Warning: return "Warning";
|
||||
case LogType.Error:
|
||||
case LogType.Assert:
|
||||
case LogType.Exception: return "Error";
|
||||
default: return "Log";
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Case-insensitive parse of a <see cref="BuildTarget"/> enum name.</summary>
|
||||
static bool TryParseTarget(string name, out BuildTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
|
||||
return Enum.IsDefined(typeof(BuildTarget), target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
target = EditorUserBuildSettings.activeBuildTarget;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static object Invalid(string field, string message) => new { field, message };
|
||||
|
||||
static string NewBuildId() => "build_" + Guid.NewGuid().ToString("N").Substring(0, 12);
|
||||
|
||||
static string ResolveOutput(string output, BuildTarget target)
|
||||
{
|
||||
var projectRoot = ProjectPaths.ProjectRoot;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(output))
|
||||
return Path.IsPathRooted(output) ? output : Path.GetFullPath(Path.Combine(projectRoot, output));
|
||||
|
||||
var product = string.IsNullOrEmpty(PlayerSettings.productName) ? "Player" : PlayerSettings.productName;
|
||||
return Path.Combine(projectRoot, "Builds", target.ToString(), product + ExtensionFor(target));
|
||||
}
|
||||
|
||||
/// <summary>True if some ancestor directory of <paramref name="path"/> already exists — a cheap,
|
||||
/// non-mutating check that the build output folder is creatable.</summary>
|
||||
static bool HasExistingAncestor(string path)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
if (Directory.Exists(dir))
|
||||
return true;
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static string ExtensionFor(BuildTarget target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows:
|
||||
case BuildTarget.StandaloneWindows64:
|
||||
return ".exe";
|
||||
case BuildTarget.StandaloneOSX:
|
||||
return ".app";
|
||||
case BuildTarget.Android:
|
||||
return ".apk";
|
||||
default:
|
||||
return string.Empty; // Linux standalone, dedicated server, etc.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort: activate a Build Profile by name via reflection so the assembly still compiles on
|
||||
/// Unity versions without the Build Profile API. Any failure is logged and ignored — the build
|
||||
/// proceeds with the current configuration.
|
||||
/// </summary>
|
||||
static void TryActivateBuildProfile(string profileName)
|
||||
{
|
||||
try
|
||||
{
|
||||
var info = BuildProfiles.FindByName(profileName);
|
||||
if (info == null)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build: profile '{profileName}' not found; building with current settings.");
|
||||
return;
|
||||
}
|
||||
if (!BuildProfiles.TrySetActive(info))
|
||||
Debug.LogWarning($"[pipeline] build: could not activate profile '{profileName}'; building with current settings.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] build: profile activation failed ({ex.Message}); building with current settings.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Run <paramref name="fn"/> on the Unity main thread. Off-thread (the HTTP path) it marshals
|
||||
/// through the live server's dispatcher; on the main thread (a direct in-process/test call, or no
|
||||
/// server running) it runs inline. Mirrors <see cref="PackageManager.PackageManagerCommand"/>.
|
||||
/// </summary>
|
||||
static T RunOnMain<T>(Func<T> fn)
|
||||
{
|
||||
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
|
||||
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
|
||||
return dispatcher.Invoke(fn);
|
||||
return fn();
|
||||
}
|
||||
|
||||
static void WriteStatusJson(object status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[Build] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6e718f193c77f724ea4de47fe8a2a3cb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+337
@@ -0,0 +1,337 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Build;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Read/inspect build configuration for agents (CLI-204): enumerate targets, read/write the mutable
|
||||
/// <c>EditorUserBuildSettings</c> fields, and list Build Profile assets. Scene-list management and
|
||||
/// target switching are intentionally elsewhere (<c>add_scene_to_build</c>/<c>remove_scene_from_build</c>
|
||||
/// from CLI-189, and <c>switch_build_target</c>).
|
||||
/// </summary>
|
||||
public static class BuildConfigCommands
|
||||
{
|
||||
[CliCommand("list_build_targets",
|
||||
"List the known BuildTarget values with their group and whether build support is installed.",
|
||||
MainThreadRequired = true)]
|
||||
public static object ListBuildTargets()
|
||||
{
|
||||
var targets = new List<BuildTargetInfo>();
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (BuildTarget value in Enum.GetValues(typeof(BuildTarget)))
|
||||
{
|
||||
// Exclude NoTarget / internal sentinels (negative) and deprecated/obsolete values.
|
||||
if ((int)value <= 0)
|
||||
continue;
|
||||
|
||||
var name = value.ToString();
|
||||
if (!seen.Add(name))
|
||||
continue;
|
||||
|
||||
var field = typeof(BuildTarget).GetField(name);
|
||||
if (field != null && field.GetCustomAttribute<ObsoleteAttribute>() != null)
|
||||
continue;
|
||||
|
||||
BuildTargetGroup group;
|
||||
try { group = BuildPipeline.GetBuildTargetGroup(value); }
|
||||
catch { continue; }
|
||||
|
||||
bool installed;
|
||||
try { installed = BuildPipeline.IsBuildTargetSupported(group, value); }
|
||||
catch { installed = false; }
|
||||
|
||||
targets.Add(new BuildTargetInfo
|
||||
{
|
||||
Name = name,
|
||||
DisplayName = DisplayNameFor(value),
|
||||
TargetGroup = group.ToString(),
|
||||
IsInstalled = installed
|
||||
});
|
||||
}
|
||||
|
||||
return targets.OrderBy(t => t.TargetGroup).ThenBy(t => t.Name).ToList();
|
||||
}
|
||||
|
||||
[CliCommand("get_build_settings",
|
||||
"Read the current build configuration from EditorUserBuildSettings / EditorBuildSettings.",
|
||||
MainThreadRequired = true)]
|
||||
public static object GetBuildSettings()
|
||||
{
|
||||
var target = EditorUserBuildSettings.activeBuildTarget;
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
|
||||
return new BuildSettingsResult
|
||||
{
|
||||
ActiveBuildTarget = target.ToString(),
|
||||
ActiveBuildTargetGroup = group.ToString(),
|
||||
DevelopmentBuild = EditorUserBuildSettings.development,
|
||||
AllowDebugging = EditorUserBuildSettings.allowDebugging,
|
||||
ConnectWithProfiler = EditorUserBuildSettings.connectProfiler,
|
||||
BuildScriptsOnly = EditorUserBuildSettings.buildScriptsOnly,
|
||||
SymlinkSources = EditorUserBuildSettings.symlinkSources,
|
||||
Il2CppCodeGeneration = ReadIl2CppCodeGeneration(group),
|
||||
Scenes = EditorBuildSettings.scenes.Select(s => new BuildSceneEntry
|
||||
{
|
||||
Path = s.path,
|
||||
Guid = s.guid.ToString(),
|
||||
Enabled = s.enabled
|
||||
}).ToList()
|
||||
};
|
||||
}
|
||||
|
||||
[CliCommand("set_build_settings",
|
||||
"Set mutable EditorUserBuildSettings fields. Does NOT manage scenes (use add_scene_to_build / " +
|
||||
"remove_scene_from_build) or switch target (use switch_build_target). Use dry_run to preview.",
|
||||
MainThreadRequired = true)]
|
||||
public static object SetBuildSettings(
|
||||
[CliArg("settings", "Fields to change; omitted fields are left unchanged.")] SetBuildSettingsInput settings = null,
|
||||
[CliArg("confirm", "Apply the changes. Without it the call is refused.")] bool confirm = false,
|
||||
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false)
|
||||
{
|
||||
if (settings == null)
|
||||
return SetBuildSettingsResult.Fail("No 'settings' object provided.");
|
||||
|
||||
if (!dryRun && !confirm)
|
||||
return SetBuildSettingsResult.Fail(
|
||||
"Refused: this operation mutates the project. Re-run with confirm=true to apply, or dry_run=true to preview the change.");
|
||||
var result = new SetBuildSettingsResult { DryRun = dryRun, Success = true };
|
||||
var group = BuildPipeline.GetBuildTargetGroup(EditorUserBuildSettings.activeBuildTarget);
|
||||
|
||||
// For each supplied field: change it (recording in Applied) only if it differs from the
|
||||
// current value; otherwise record it in Skipped as a no-op.
|
||||
Plan(result, "developmentBuild", settings.DevelopmentBuild, EditorUserBuildSettings.development,
|
||||
v => EditorUserBuildSettings.development = v, dryRun);
|
||||
Plan(result, "allowDebugging", settings.AllowDebugging, EditorUserBuildSettings.allowDebugging,
|
||||
v => EditorUserBuildSettings.allowDebugging = v, dryRun);
|
||||
Plan(result, "connectWithProfiler", settings.ConnectWithProfiler, EditorUserBuildSettings.connectProfiler,
|
||||
v => EditorUserBuildSettings.connectProfiler = v, dryRun);
|
||||
Plan(result, "buildScriptsOnly", settings.BuildScriptsOnly, EditorUserBuildSettings.buildScriptsOnly,
|
||||
v => EditorUserBuildSettings.buildScriptsOnly = v, dryRun);
|
||||
Plan(result, "symlinkSources", settings.SymlinkSources, EditorUserBuildSettings.symlinkSources,
|
||||
v => EditorUserBuildSettings.symlinkSources = v, dryRun);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(settings.Il2CppCodeGeneration))
|
||||
{
|
||||
if (!TryParseIl2Cpp(settings.Il2CppCodeGeneration, out var requested))
|
||||
return SetBuildSettingsResult.Fail($"Invalid il2CppCodeGeneration '{settings.Il2CppCodeGeneration}'. Use OptimizeSpeed | OptimizeSize.");
|
||||
|
||||
var current = ReadIl2CppCodeGeneration(group);
|
||||
if (!string.Equals(current, requested.ToString(), StringComparison.Ordinal))
|
||||
{
|
||||
if (!dryRun)
|
||||
PlayerSettings.SetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group), requested);
|
||||
result.Applied["il2CppCodeGeneration"] = requested.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Skipped["il2CppCodeGeneration"] = current;
|
||||
}
|
||||
}
|
||||
|
||||
result.Message = dryRun
|
||||
? $"Dry run — {result.Applied.Count} field(s) would change, {result.Skipped.Count} unchanged."
|
||||
: $"Applied {result.Applied.Count} field(s); {result.Skipped.Count} unchanged.";
|
||||
return result;
|
||||
}
|
||||
|
||||
[CliCommand("list_build_profiles",
|
||||
"List Build Profile assets in the project (Unity 6 only). Returns feature_unavailable on earlier versions.",
|
||||
MainThreadRequired = true)]
|
||||
public static object ListBuildProfiles()
|
||||
{
|
||||
if (!BuildProfiles.IsSupported)
|
||||
return new { error = "Build Profiles require Unity 6 (6000.0) or later", code = "feature_unavailable" };
|
||||
|
||||
return BuildProfiles.List();
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
static void Plan(SetBuildSettingsResult result, string name, bool? requested, bool current,
|
||||
Action<bool> set, bool dryRun)
|
||||
{
|
||||
if (!requested.HasValue)
|
||||
return;
|
||||
|
||||
if (requested.Value != current)
|
||||
{
|
||||
if (!dryRun)
|
||||
set(requested.Value);
|
||||
result.Applied[name] = requested.Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
result.Skipped[name] = current;
|
||||
}
|
||||
}
|
||||
|
||||
static string ReadIl2CppCodeGeneration(BuildTargetGroup group)
|
||||
{
|
||||
try
|
||||
{
|
||||
return PlayerSettings.GetIl2CppCodeGeneration(NamedBuildTarget.FromBuildTargetGroup(group)).ToString();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Il2CppCodeGeneration.OptimizeSpeed.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
static bool TryParseIl2Cpp(string value, out Il2CppCodeGeneration parsed)
|
||||
{
|
||||
return Enum.TryParse(value.Trim(), ignoreCase: true, out parsed)
|
||||
&& Enum.IsDefined(typeof(Il2CppCodeGeneration), parsed);
|
||||
}
|
||||
|
||||
/// <summary>Friendly label for common targets; falls back to the enum name.</summary>
|
||||
static string DisplayNameFor(BuildTarget target)
|
||||
{
|
||||
switch (target)
|
||||
{
|
||||
case BuildTarget.StandaloneWindows: return "Windows (32-bit)";
|
||||
case BuildTarget.StandaloneWindows64: return "Windows 64-bit";
|
||||
case BuildTarget.StandaloneOSX: return "macOS";
|
||||
case BuildTarget.StandaloneLinux64: return "Linux 64-bit";
|
||||
case BuildTarget.Android: return "Android";
|
||||
case BuildTarget.iOS: return "iOS";
|
||||
case BuildTarget.tvOS: return "tvOS";
|
||||
case BuildTarget.WebGL: return "WebGL";
|
||||
case BuildTarget.WSAPlayer: return "Universal Windows Platform";
|
||||
case BuildTarget.PS4: return "PlayStation 4";
|
||||
case BuildTarget.PS5: return "PlayStation 5";
|
||||
case BuildTarget.XboxOne: return "Xbox One";
|
||||
case BuildTarget.Switch: return "Nintendo Switch";
|
||||
default: return target.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reflection shim over the Unity 6 Build Profile API (<c>UnityEditor.Build.Profile.BuildProfile</c>),
|
||||
/// so this assembly compiles and runs on older editors without the type. Used by
|
||||
/// <c>list_build_profiles</c> and by <c>build</c>'s optional <c>profileName</c> activation.
|
||||
/// </summary>
|
||||
internal static class BuildProfiles
|
||||
{
|
||||
const string TypeName = "UnityEditor.Build.Profile.BuildProfile, UnityEditor.CoreModule";
|
||||
|
||||
static readonly Type s_Type = ResolveType();
|
||||
|
||||
public static bool IsSupported => s_Type != null;
|
||||
|
||||
static Type ResolveType()
|
||||
{
|
||||
var t = Type.GetType(TypeName);
|
||||
if (t != null)
|
||||
return t;
|
||||
|
||||
// Fall back to a full-assembly scan — the assembly-qualified name can vary across versions.
|
||||
foreach (var asm in PipelineUtils.GetLoadedAssemblies())
|
||||
{
|
||||
t = asm.GetType("UnityEditor.Build.Profile.BuildProfile");
|
||||
if (t != null)
|
||||
return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static List<BuildProfileInfo> List()
|
||||
{
|
||||
var list = new List<BuildProfileInfo>();
|
||||
if (s_Type == null)
|
||||
return list;
|
||||
|
||||
var active = GetActive();
|
||||
foreach (var guid in AssetDatabase.FindAssets("t:BuildProfile"))
|
||||
{
|
||||
var path = AssetDatabase.GUIDToAssetPath(guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
|
||||
if (asset == null)
|
||||
continue;
|
||||
|
||||
list.Add(new BuildProfileInfo
|
||||
{
|
||||
Name = System.IO.Path.GetFileNameWithoutExtension(path),
|
||||
Guid = guid,
|
||||
Platform = ReadPlatform(asset),
|
||||
IsActive = active != null && ReferenceEquals(active, asset)
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public static BuildProfileInfo FindByName(string name)
|
||||
{
|
||||
return List().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
/// <summary>Activate the named profile via the API's static setter; returns false if unavailable.</summary>
|
||||
public static bool TrySetActive(BuildProfileInfo info)
|
||||
{
|
||||
if (s_Type == null || info == null)
|
||||
return false;
|
||||
|
||||
var path = AssetDatabase.GUIDToAssetPath(info.Guid);
|
||||
var asset = AssetDatabase.LoadAssetAtPath(path, s_Type);
|
||||
if (asset == null)
|
||||
return false;
|
||||
|
||||
// Unity 6 exposes a static settable 'activeProfile' (and/or a SetActiveBuildProfile method).
|
||||
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop != null && prop.CanWrite)
|
||||
{
|
||||
prop.SetValue(null, asset);
|
||||
return true;
|
||||
}
|
||||
|
||||
var method = s_Type.GetMethod("SetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (method != null)
|
||||
{
|
||||
method.Invoke(null, new[] { asset });
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
static UnityEngine.Object GetActive()
|
||||
{
|
||||
try
|
||||
{
|
||||
var prop = s_Type.GetProperty("activeProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (prop != null)
|
||||
return prop.GetValue(null) as UnityEngine.Object;
|
||||
|
||||
var method = s_Type.GetMethod("GetActiveBuildProfile", BindingFlags.Public | BindingFlags.Static);
|
||||
if (method != null)
|
||||
return method.Invoke(null, null) as UnityEngine.Object;
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
static string ReadPlatform(UnityEngine.Object asset)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Public property first, then the serialized backing field.
|
||||
var prop = s_Type.GetProperty("buildTarget", BindingFlags.Public | BindingFlags.Instance);
|
||||
if (prop != null)
|
||||
return prop.GetValue(asset)?.ToString() ?? string.Empty;
|
||||
|
||||
var field = s_Type.GetField("m_BuildTarget", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
if (field != null)
|
||||
return field.GetValue(asset)?.ToString() ?? string.Empty;
|
||||
}
|
||||
catch { /* best effort */ }
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e65c0b78842b492d8f9363ec749bc370
|
||||
+271
@@ -0,0 +1,271 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// One entry of <c>list_build_targets</c> (CLI-204): a <c>BuildTarget</c> enum value with the
|
||||
/// information an agent needs to choose a valid target without guessing — its group and whether the
|
||||
/// platform's build support is actually installed in this editor.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BuildTargetInfo
|
||||
{
|
||||
/// <summary><c>BuildTarget</c> enum name, e.g. "StandaloneWindows64".</summary>
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
|
||||
/// <summary>Friendly label, e.g. "Windows 64-bit".</summary>
|
||||
[JsonProperty("displayName")] public string DisplayName { get; set; }
|
||||
|
||||
/// <summary><c>BuildTargetGroup</c> enum name, e.g. "Standalone".</summary>
|
||||
[JsonProperty("targetGroup")] public string TargetGroup { get; set; }
|
||||
|
||||
/// <summary><c>BuildPipeline.IsBuildTargetSupported(group, target)</c> — module installed and usable.</summary>
|
||||
[JsonProperty("isInstalled")] public bool IsInstalled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One scene in the Build Settings scene list, projected for <c>get_build_settings</c>.</summary>
|
||||
[Serializable]
|
||||
public class BuildSceneEntry
|
||||
{
|
||||
[JsonProperty("path")] public string Path { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
[JsonProperty("enabled")] public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>get_build_settings</c> (CLI-204): the current build configuration read from
|
||||
/// <c>EditorUserBuildSettings</c> / <c>EditorBuildSettings</c> plus the active target's IL2CPP code
|
||||
/// generation mode. Scene-list management lives in <c>add_scene_to_build</c> /
|
||||
/// <c>remove_scene_from_build</c> (CLI-189); this only reports the list.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BuildSettingsResult
|
||||
{
|
||||
[JsonProperty("activeBuildTarget")] public string ActiveBuildTarget { get; set; }
|
||||
[JsonProperty("activeBuildTargetGroup")] public string ActiveBuildTargetGroup { get; set; }
|
||||
[JsonProperty("developmentBuild")] public bool DevelopmentBuild { get; set; }
|
||||
[JsonProperty("allowDebugging")] public bool AllowDebugging { get; set; }
|
||||
[JsonProperty("connectWithProfiler")] public bool ConnectWithProfiler { get; set; }
|
||||
[JsonProperty("buildScriptsOnly")] public bool BuildScriptsOnly { get; set; }
|
||||
[JsonProperty("symlinkSources")] public bool SymlinkSources { get; set; }
|
||||
|
||||
/// <summary>"OptimizeSpeed" | "OptimizeSize" for the active build target.</summary>
|
||||
[JsonProperty("il2CppCodeGeneration")] public string Il2CppCodeGeneration { get; set; }
|
||||
|
||||
[JsonProperty("scenes")] public List<BuildSceneEntry> Scenes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Mutable build-settings fields for <c>set_build_settings</c> (CLI-204). Every field is nullable;
|
||||
/// only the ones the caller supplies are changed. Note that <see cref="AllowDebugging"/> and
|
||||
/// <see cref="ConnectWithProfiler"/> only take effect when <see cref="DevelopmentBuild"/> is also on.
|
||||
/// </summary>
|
||||
public class SetBuildSettingsInput : IStructuredCommandInput
|
||||
{
|
||||
[CliArg("developmentBuild", "Build a Development Player (enables the debugger/profiler).")]
|
||||
public bool? DevelopmentBuild { get; set; }
|
||||
|
||||
[CliArg("allowDebugging", "Allow script debugging (only effective with developmentBuild=true).")]
|
||||
public bool? AllowDebugging { get; set; }
|
||||
|
||||
[CliArg("connectWithProfiler", "Auto-connect the Profiler (only effective with developmentBuild=true).")]
|
||||
public bool? ConnectWithProfiler { get; set; }
|
||||
|
||||
[CliArg("buildScriptsOnly", "Build only the scripts (skip data) for faster iteration.")]
|
||||
public bool? BuildScriptsOnly { get; set; }
|
||||
|
||||
[CliArg("symlinkSources", "Symlink runtime/plugin sources instead of copying (where supported).")]
|
||||
public bool? SymlinkSources { get; set; }
|
||||
|
||||
[CliArg("il2CppCodeGeneration", "IL2CPP code generation for the active target: OptimizeSpeed | OptimizeSize.")]
|
||||
public string Il2CppCodeGeneration { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of <c>set_build_settings</c> (CLI-204): the fields that were actually changed
|
||||
/// (<see cref="Applied"/>) versus the supplied fields that already had the requested value
|
||||
/// (<see cref="Skipped"/>). A dry run reports the same <see cref="Applied"/> set it <em>would</em>
|
||||
/// write, without changing anything.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class SetBuildSettingsResult
|
||||
{
|
||||
[JsonProperty("success")] public bool Success { get; set; }
|
||||
[JsonProperty("dryRun")] public bool DryRun { get; set; }
|
||||
|
||||
/// <summary>Field name → new value, for each field that changed (or would change on a dry run).</summary>
|
||||
[JsonProperty("applied")] public Dictionary<string, object> Applied { get; set; } = new Dictionary<string, object>();
|
||||
|
||||
/// <summary>Field name → current value, for supplied fields that already matched (no-ops).</summary>
|
||||
[JsonProperty("skipped")] public Dictionary<string, object> Skipped { get; set; } = new Dictionary<string, object>();
|
||||
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
public static SetBuildSettingsResult Fail(string message) =>
|
||||
new SetBuildSettingsResult { Success = false, Message = message };
|
||||
}
|
||||
|
||||
/// <summary>One Build Profile asset, projected for <c>list_build_profiles</c> (Unity 6 only).</summary>
|
||||
[Serializable]
|
||||
public class BuildProfileInfo
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
|
||||
/// <summary>The profile's build target name (best-effort; may be empty if unreadable).</summary>
|
||||
[JsonProperty("platform")] public string Platform { get; set; }
|
||||
|
||||
[JsonProperty("isActive")] public bool IsActive { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One output file produced by the build (a row of <c>BuildReport.GetFiles()</c>).</summary>
|
||||
[Serializable]
|
||||
public class BuildFileEntry
|
||||
{
|
||||
[JsonProperty("path")] public string Path { get; set; }
|
||||
|
||||
/// <summary><c>BuildFile.role</c> verbatim, e.g. "MainData", "AssetBundle", "BootConfig".</summary>
|
||||
[JsonProperty("role")] public string Role { get; set; }
|
||||
|
||||
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One asset packed into a build bundle/file (a row of a <c>PackedAssets</c> entry).</summary>
|
||||
[Serializable]
|
||||
public class PackedAssetContent
|
||||
{
|
||||
[JsonProperty("assetPath")] public string AssetPath { get; set; }
|
||||
[JsonProperty("type")] public string Type { get; set; }
|
||||
[JsonProperty("guid")] public string Guid { get; set; }
|
||||
[JsonProperty("sizeBytes")] public long SizeBytes { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A packed bundle/file with its per-asset size breakdown (requires DetailedBuildReport).</summary>
|
||||
[Serializable]
|
||||
public class PackedAssetEntry
|
||||
{
|
||||
[JsonProperty("bundlePath")] public string BundlePath { get; set; }
|
||||
[JsonProperty("overheadBytes")] public long OverheadBytes { get; set; }
|
||||
[JsonProperty("contents")] public List<PackedAssetContent> Contents { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One message emitted during a build step.</summary>
|
||||
[Serializable]
|
||||
public class BuildStepMessageEntry
|
||||
{
|
||||
/// <summary>"Log" | "Warning" | "Error" (mapped from <c>LogType</c>).</summary>
|
||||
[JsonProperty("type")] public string Type { get; set; }
|
||||
[JsonProperty("content")] public string Content { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>One step of the build (a row of <c>BuildReport.steps</c>).</summary>
|
||||
[Serializable]
|
||||
public class BuildStepEntry
|
||||
{
|
||||
[JsonProperty("name")] public string Name { get; set; }
|
||||
[JsonProperty("durationMs")] public long DurationMs { get; set; }
|
||||
[JsonProperty("depth")] public int Depth { get; set; }
|
||||
[JsonProperty("messages")] public List<BuildStepMessageEntry> Messages { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A build error/warning surfaced in <c>build_status</c>, with best-effort source file.</summary>
|
||||
[Serializable]
|
||||
public class BuildIssue
|
||||
{
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
/// <summary>Source file when the message carries a "File.cs(line,col): ..." prefix; omitted otherwise.</summary>
|
||||
[JsonProperty("file", NullValueHandling = NullValueHandling.Ignore)] public string File { get; set; }
|
||||
|
||||
/// <summary>Build a <see cref="BuildIssue"/>, reusing <see cref="BuildMessage.Parse"/> for file extraction.</summary>
|
||||
public static BuildIssue From(string content)
|
||||
{
|
||||
var parsed = BuildMessage.Parse("info", content);
|
||||
return new BuildIssue
|
||||
{
|
||||
Message = parsed.Message,
|
||||
File = string.IsNullOrEmpty(parsed.File) ? null : parsed.File
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Full <c>build_status</c> result for a finished build (CLI-204) — a JSON-friendly projection of
|
||||
/// <c>UnityEditor.Build.Reporting.BuildReport</c>. Optional collections are omitted when null
|
||||
/// (<see cref="NullValueHandling"/>), so a failed build can carry errors/steps without empty
|
||||
/// file/asset arrays. Transient states (queued/building/idle/dry_run) are reported as small,
|
||||
/// purpose-built payloads, not this type.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BuildReportResult
|
||||
{
|
||||
[JsonProperty("status")] public string Status { get; set; }
|
||||
[JsonProperty("buildId")] public string BuildId { get; set; }
|
||||
|
||||
/// <summary>"Succeeded" | "Failed" | "Cancelled" | "Unknown".</summary>
|
||||
[JsonProperty("result")] public string Result { get; set; }
|
||||
|
||||
[JsonProperty("platform")] public string Platform { get; set; }
|
||||
[JsonProperty("outputPath")] public string OutputPath { get; set; }
|
||||
[JsonProperty("totalSizeBytes")] public long TotalSizeBytes { get; set; }
|
||||
[JsonProperty("buildTimeMs")] public long BuildTimeMs { get; set; }
|
||||
|
||||
[JsonProperty("buildStartedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildStartedAt { get; set; }
|
||||
[JsonProperty("buildEndedAt", NullValueHandling = NullValueHandling.Ignore)] public string BuildEndedAt { get; set; }
|
||||
|
||||
[JsonProperty("totalWarnings")] public int TotalWarnings { get; set; }
|
||||
[JsonProperty("totalErrors")] public int TotalErrors { get; set; }
|
||||
|
||||
[JsonProperty("files", NullValueHandling = NullValueHandling.Ignore)] public List<BuildFileEntry> Files { get; set; }
|
||||
[JsonProperty("packedAssets", NullValueHandling = NullValueHandling.Ignore)] public List<PackedAssetEntry> PackedAssets { get; set; }
|
||||
[JsonProperty("buildSteps", NullValueHandling = NullValueHandling.Ignore)] public List<BuildStepEntry> BuildSteps { get; set; }
|
||||
|
||||
[JsonProperty("errors")] public List<BuildIssue> Errors { get; set; } = new List<BuildIssue>();
|
||||
[JsonProperty("warnings")] public List<BuildIssue> Warnings { get; set; } = new List<BuildIssue>();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single build error/warning with best-effort file/line parsed from the message content.
|
||||
/// Retained from the original <c>build</c> stub (CLI-127): it is the shared parser used to extract a
|
||||
/// source location from a raw build-step message and is exercised directly by the test suite.
|
||||
/// </summary>
|
||||
[Serializable]
|
||||
public class BuildMessage
|
||||
{
|
||||
[JsonProperty("severity")] public string Severity { get; set; }
|
||||
[JsonProperty("file")] public string File { get; set; }
|
||||
[JsonProperty("line")] public int Line { get; set; }
|
||||
[JsonProperty("message")] public string Message { get; set; }
|
||||
|
||||
// Matches the standard compiler/build message prefix "Path/To/File.cs(line,col): rest".
|
||||
static readonly Regex s_LocationPattern = new Regex(@"^(?<file>.*?)\((?<line>\d+),\d+\):\s*(?<rest>.*)$", RegexOptions.Singleline);
|
||||
|
||||
/// <summary>
|
||||
/// Build a <see cref="BuildMessage"/> from a raw build-step message, extracting file/line when
|
||||
/// the content carries the standard "File.cs(line,col): ..." prefix; otherwise the whole content
|
||||
/// is kept as the message with no file/line.
|
||||
/// </summary>
|
||||
public static BuildMessage Parse(string severity, string content)
|
||||
{
|
||||
var msg = new BuildMessage { Severity = severity, Message = content ?? string.Empty };
|
||||
|
||||
if (!string.IsNullOrEmpty(content))
|
||||
{
|
||||
var match = s_LocationPattern.Match(content);
|
||||
if (match.Success)
|
||||
{
|
||||
msg.File = match.Groups["file"].Value.Trim();
|
||||
if (int.TryParse(match.Groups["line"].Value, out var line))
|
||||
msg.Line = line;
|
||||
msg.Message = match.Groups["rest"].Value.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 37afada24be667e489d7e90a62f2ca1d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+237
@@ -0,0 +1,237 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using Newtonsoft.Json;
|
||||
using Unity.Pipeline.Commands;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Unity.Pipeline.Editor.Commands.Build
|
||||
{
|
||||
/// <summary>
|
||||
/// Switches the active build target (CLI-204). <see cref="BuildPipeline.SwitchActiveBuildTarget"/> is
|
||||
/// synchronous, blocks the main thread, and triggers a full asset reimport + domain reload for the new
|
||||
/// platform — minutes on a large project. So this follows the trigger-then-poll pattern: the command
|
||||
/// validates and queues the switch, returns <c>switching</c> immediately, and an
|
||||
/// <see cref="EditorApplication.update"/> tick performs the switch. The switch is confirm-gated
|
||||
/// (destructive and long-running) and is not part of Unity's Undo.
|
||||
///
|
||||
/// The status is persisted to a Temp file that survives the domain reload the switch causes; on the
|
||||
/// next load <see cref="RecoverInterruptedOperation"/> reconciles a left-over <c>switching</c> record
|
||||
/// against the now-active target. <c>switch_build_target_status</c> reads the file off the main thread,
|
||||
/// so it keeps answering while the switch holds the main thread.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class SwitchBuildTargetCommand
|
||||
{
|
||||
const string StatusFile = "Temp/pipeline_switch_target_status.json";
|
||||
|
||||
static readonly object s_Lock = new object();
|
||||
static bool s_Pending;
|
||||
static bool s_Switching;
|
||||
static BuildTarget s_PendingTarget;
|
||||
static BuildTarget s_FromTarget;
|
||||
|
||||
static SwitchBuildTargetCommand()
|
||||
{
|
||||
EditorApplication.update += OnUpdate;
|
||||
RecoverInterruptedOperation();
|
||||
}
|
||||
|
||||
[CliCommand("switch_build_target",
|
||||
"Switch the active build target (destructive, long-running: triggers a full reimport + domain " +
|
||||
"reload). Requires confirm=true. Returns immediately; poll switch_build_target_status.",
|
||||
MainThreadRequired = false)]
|
||||
public static object SwitchBuildTarget(
|
||||
[CliArg("target", "BuildTarget name to switch to (must be installed; see list_build_targets).", Required = true)] string target = "",
|
||||
[CliArg("confirm", "Apply the switch. Without it the call is refused.")] bool confirm = false)
|
||||
{
|
||||
return RunOnMain<object>(() => ValidateAndQueue(target, confirm));
|
||||
}
|
||||
|
||||
static object ValidateAndQueue(string targetArg, bool confirm)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(targetArg))
|
||||
return new { status = "error", success = false, message = "A 'target' is required." };
|
||||
|
||||
if (!TryParseTarget(targetArg, out var target))
|
||||
return new { status = "error", success = false, message = $"Unknown BuildTarget '{targetArg}'. Use a name from list_build_targets." };
|
||||
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
if (!BuildPipeline.IsBuildTargetSupported(group, target))
|
||||
return new { status = "error", success = false, message = $"Build target '{target}' is not installed (isInstalled=false in list_build_targets)." };
|
||||
|
||||
var from = EditorUserBuildSettings.activeBuildTarget;
|
||||
if (target == from)
|
||||
return new { status = "completed", success = true, activeBuildTarget = from.ToString(), message = $"Already on build target '{from}'." };
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (s_Pending || s_Switching)
|
||||
return new { status = "busy", success = false, message = "A target switch is already queued or in progress. Poll switch_build_target_status." };
|
||||
}
|
||||
|
||||
// Confirm gate. The queue below only sets pending state; the switch runs on the next tick.
|
||||
if (!confirm)
|
||||
return new
|
||||
{
|
||||
status = "error",
|
||||
success = false,
|
||||
message = "Refused: switching the build target triggers a full reimport + domain reload. Pass confirm=true to apply."
|
||||
};
|
||||
|
||||
lock (s_Lock)
|
||||
{
|
||||
s_FromTarget = from;
|
||||
s_PendingTarget = target;
|
||||
s_Pending = true;
|
||||
}
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "switching",
|
||||
fromTarget = from.ToString(),
|
||||
toTarget = target.ToString(),
|
||||
startedAt = DateTime.UtcNow.ToString("o")
|
||||
});
|
||||
|
||||
return new { status = "switching", fromTarget = from.ToString(), toTarget = target.ToString() };
|
||||
}
|
||||
|
||||
[CliCommand("switch_build_target_status",
|
||||
"Status of the last target switch: idle | switching | completed (with success + activeBuildTarget).",
|
||||
MainThreadRequired = false)]
|
||||
public static string SwitchBuildTargetStatus()
|
||||
{
|
||||
if (File.Exists(StatusFile))
|
||||
return File.ReadAllText(StatusFile);
|
||||
return "{\"status\":\"idle\"}";
|
||||
}
|
||||
|
||||
/// <summary>Main-thread pump: performs the queued switch. Blocks the main thread until it returns.</summary>
|
||||
static void OnUpdate()
|
||||
{
|
||||
BuildTarget target;
|
||||
BuildTarget from;
|
||||
lock (s_Lock)
|
||||
{
|
||||
if (!s_Pending || s_Switching)
|
||||
return;
|
||||
s_Pending = false;
|
||||
s_Switching = true;
|
||||
target = s_PendingTarget;
|
||||
from = s_FromTarget;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var group = BuildPipeline.GetBuildTargetGroup(target);
|
||||
|
||||
// Synchronous and may trigger a domain reload on success — in which case the code below
|
||||
// never runs and RecoverInterruptedOperation finalizes the status after the reload.
|
||||
// (SwitchActiveBuildTarget moved from BuildPipeline to EditorUserBuildSettings in Unity 6.)
|
||||
var ok = EditorUserBuildSettings.SwitchActiveBuildTarget(group, target);
|
||||
|
||||
if (ok)
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = true,
|
||||
activeBuildTarget = EditorUserBuildSettings.activeBuildTarget.ToString()
|
||||
});
|
||||
else
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = target.ToString(),
|
||||
errors = new[] { new { message = $"Failed to switch build target to '{target}'." } }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = target.ToString(),
|
||||
errors = new[] { new { message = $"Switch to '{target}' threw: {ex.Message}" } }
|
||||
});
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (s_Lock) { s_Switching = false; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// On load, settle a status file left at "switching" by the domain reload a successful switch
|
||||
/// causes: if the active target now matches the requested one, the switch completed; otherwise it
|
||||
/// did not take effect.
|
||||
/// </summary>
|
||||
static void RecoverInterruptedOperation()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(StatusFile))
|
||||
return;
|
||||
|
||||
var doc = Newtonsoft.Json.Linq.JObject.Parse(File.ReadAllText(StatusFile));
|
||||
if ((string)doc["status"] != "switching")
|
||||
return;
|
||||
|
||||
var toTarget = (string)doc["toTarget"];
|
||||
var active = EditorUserBuildSettings.activeBuildTarget.ToString();
|
||||
|
||||
if (string.Equals(active, toTarget, StringComparison.Ordinal))
|
||||
WriteStatusJson(new { status = "completed", success = true, activeBuildTarget = active });
|
||||
else
|
||||
WriteStatusJson(new
|
||||
{
|
||||
status = "completed",
|
||||
success = false,
|
||||
target = toTarget,
|
||||
errors = new[] { new { message = $"Switch did not take effect; active build target is '{active}'." } }
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[pipeline] switch_build_target status recovery failed: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Helpers ---------------------------------------------------------------------------
|
||||
|
||||
static bool TryParseTarget(string name, out BuildTarget target)
|
||||
{
|
||||
try
|
||||
{
|
||||
target = (BuildTarget)Enum.Parse(typeof(BuildTarget), name.Trim(), ignoreCase: true);
|
||||
return Enum.IsDefined(typeof(BuildTarget), target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
target = EditorUserBuildSettings.activeBuildTarget;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
static T RunOnMain<T>(Func<T> fn)
|
||||
{
|
||||
var dispatcher = PipelineServerStartup.Server?.Dispatcher;
|
||||
if (dispatcher != null && dispatcher.IsInitialized && !dispatcher.IsMainThread())
|
||||
return dispatcher.Invoke(fn);
|
||||
return fn();
|
||||
}
|
||||
|
||||
static void WriteStatusJson(object status)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[SwitchBuildTarget] Failed to write status file: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b
|
||||
Reference in New Issue
Block a user