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,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}");
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 6e718f193c77f724ea4de47fe8a2a3cb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e65c0b78842b492d8f9363ec749bc370
@@ -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;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 37afada24be667e489d7e90a62f2ca1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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}");
}
}
}
}
@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 5b3d3b7a26bf4a5c8f5522cbfdd0574b