645 lines
28 KiB
C#
645 lines
28 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Threading;
|
|
using Newtonsoft.Json;
|
|
using Unity.Pipeline.Commands;
|
|
using UnityEditor;
|
|
using UnityEditor.PackageManager;
|
|
using UnityEditor.PackageManager.Requests;
|
|
using UnityEngine;
|
|
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
|
|
|
|
namespace Unity.Pipeline.Editor.Commands.PackageManager
|
|
{
|
|
/// <summary>
|
|
/// UPM package management over the Client API (CLI-203): list / search / add / remove / resolve.
|
|
///
|
|
/// Threading: UPM <see cref="Client"/> operations are asynchronous (the <see cref="Request"/> only
|
|
/// progresses while the editor ticks) and their members are main-thread only. Commands that wait on a
|
|
/// request therefore run off the main thread (<c>MainThreadRequired = false</c>) and marshal every
|
|
/// UPM touch back onto the main thread via the server dispatcher (<see cref="RunOnMain"/>), so they
|
|
/// can block without freezing the editor.
|
|
///
|
|
/// Command surface:
|
|
/// - <c>package_list</c> (installed scope) reads the resolved set inline; <c>available</c>/<c>all</c>
|
|
/// and <c>package_search</c> query the registry and wait for it — all return <b>synchronously</b>.
|
|
/// - <c>package_add</c>/<c>package_remove</c> mutate the manifest and trigger a <b>domain reload</b>
|
|
/// that tears down the in-flight request and the HTTP connection. They are <b>dual-mode</b>:
|
|
/// <list type="bullet">
|
|
/// <item><description><b>async (default)</b> — kick the op off, return <c>in_progress</c> immediately, and let
|
|
/// an <see cref="EditorApplication.update"/> poller finalize a Temp status file. Poll
|
|
/// <c>package_status</c> until <c>completed</c>/<c>failed</c>, then <c>recompile_status</c>. This
|
|
/// keeps the (single-request) server responsive and survives the reload.</description></item>
|
|
/// <item><description><b>synchronous</b> (<c>wait=true</c>) — block until the request completes and return the
|
|
/// full result. The result is captured in one main-thread hop the moment the request completes
|
|
/// (before the compile-triggered reload). Reliable for add; for remove the reload can fire fast
|
|
/// enough to drop the reply — the status file still settles, so <c>package_status</c> confirms it.</description></item>
|
|
/// </list>
|
|
/// Both write the status file, so a lost reply (or a reload mid-wait) is recoverable via
|
|
/// <c>package_status</c>; <see cref="RecoverInterruptedOperation"/> settles an <c>in_progress</c>
|
|
/// file on the next load.
|
|
/// - <c>package_resolve</c> records its status too, so <c>package_status</c> validates it.
|
|
///
|
|
/// Mutating commands (add / remove) follow the shared <c>confirm</c>/<c>dry_run</c> convention.
|
|
/// UPM operations are not part of Unity's Undo.
|
|
/// </summary>
|
|
[InitializeOnLoad]
|
|
public static class PackageManagerCommand
|
|
{
|
|
const string StatusFile = "Temp/pipeline_package_status.json";
|
|
|
|
// Bound on a synchronous wait, kept under the CLI's default request timeout so a stuck operation
|
|
// surfaces as a clean error rather than a dropped connection.
|
|
const int WaitTimeoutSeconds = 25;
|
|
const int PollIntervalMs = 50;
|
|
|
|
static readonly object s_Lock = new object();
|
|
|
|
// The in-flight mutating request (add/remove) and how to project its result. s_InProgress is a
|
|
// plain flag (read off-thread by IsBusy) so the busy check never touches main-thread-only Request
|
|
// members. Both are reset by a domain reload; RecoverInterruptedOperation settles the status file.
|
|
static volatile bool s_InProgress;
|
|
static Request s_Request;
|
|
static string s_Operation;
|
|
static string s_Argument;
|
|
static Func<Request, PackageSummary> s_ReadPackage;
|
|
|
|
static PackageManagerCommand()
|
|
{
|
|
RecoverInterruptedOperation();
|
|
}
|
|
|
|
// ---- List / search (synchronous) -------------------------------------------------------
|
|
|
|
[CliCommand("package_list",
|
|
"List packages by scope: installed (default) | available (registry) | all (both). Returns the full " +
|
|
"result synchronously — available/all block until the registry query completes.",
|
|
MainThreadRequired = false)]
|
|
public static object PackageList(
|
|
[CliArg("scope", "Which packages to list: installed (default) | available | all.")] string scope = "installed",
|
|
[CliArg("include_indirect", "Include indirect (transitive) installed dependencies (applies to scope=installed/all).")]
|
|
bool includeIndirect = true,
|
|
[CliArg("offline", "For available/all: query the local cache instead of the registry.")] bool offline = false)
|
|
{
|
|
switch ((scope ?? "installed").Trim().ToLowerInvariant())
|
|
{
|
|
case "installed":
|
|
return RunOnMain(() => ListInstalled(includeIndirect));
|
|
case "available":
|
|
return ListFromRegistry("available", includeIndirect, offline);
|
|
case "all":
|
|
return ListFromRegistry("all", includeIndirect, offline);
|
|
default:
|
|
return new PackageListResponse
|
|
{
|
|
Success = false,
|
|
Scope = scope,
|
|
Message = $"Unknown scope '{scope}'. Use installed | available | all."
|
|
};
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Installed scope: <see cref="PackageInfo.GetAllRegisteredPackages"/> reflects the resolved
|
|
/// set without a registry round-trip. Runs on the main thread (via <see cref="RunOnMain"/>).
|
|
/// </summary>
|
|
static PackageListResponse ListInstalled(bool includeIndirect)
|
|
{
|
|
var summaries = new List<PackageSummary>();
|
|
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
|
{
|
|
if (includeIndirect || p.isDirectDependency)
|
|
summaries.Add(Map(p, isInstalled: true));
|
|
}
|
|
|
|
return new PackageListResponse
|
|
{
|
|
Success = true,
|
|
Scope = "installed",
|
|
Count = summaries.Count,
|
|
Packages = summaries,
|
|
Manifest = SafeReadManifest(),
|
|
Message = $"{summaries.Count} installed package(s)."
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// available / all scope: these need the registry, so a <c>Client.SearchAll</c> request is
|
|
/// started on the main thread and awaited here (on the command's background thread). For
|
|
/// <c>all</c>, the installed set is merged in (and marked) so callers see one unified list.
|
|
/// </summary>
|
|
static object ListFromRegistry(string scope, bool includeIndirect, bool offline)
|
|
{
|
|
var request = RunOnMain(() => Client.SearchAll(offline));
|
|
|
|
if (!WaitForCompletion(request, out var waitError))
|
|
return new PackageListResponse { Success = false, Scope = scope, Message = waitError };
|
|
|
|
return RunOnMain<object>(() =>
|
|
{
|
|
if (request.Status != StatusCode.Success)
|
|
return new PackageListResponse
|
|
{
|
|
Success = false,
|
|
Scope = scope,
|
|
Message = $"Registry query failed: {request.Error?.message ?? "unknown error"}"
|
|
};
|
|
|
|
var packages = BuildScopedList(scope, request.Result, includeIndirect);
|
|
return new PackageListResponse
|
|
{
|
|
Success = true,
|
|
Scope = scope,
|
|
Count = packages.Count,
|
|
Packages = packages,
|
|
Manifest = SafeReadManifest(),
|
|
Message = $"{packages.Count} package(s)."
|
|
};
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Combine the registry search result with the installed set per <paramref name="scope"/>:
|
|
/// <c>available</c> returns every registry package (flagged whether it is installed);
|
|
/// <c>all</c> returns installed packages (resolved info) plus the registry packages that are
|
|
/// not installed.
|
|
/// </summary>
|
|
static List<PackageSummary> BuildScopedList(string scope, PackageInfo[] available, bool includeIndirect)
|
|
{
|
|
var installed = new Dictionary<string, PackageInfo>();
|
|
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
|
installed[p.name] = p;
|
|
|
|
var result = new List<PackageSummary>();
|
|
|
|
if (scope == "available")
|
|
{
|
|
foreach (var p in available ?? Array.Empty<PackageInfo>())
|
|
result.Add(Map(p, isInstalled: installed.ContainsKey(p.name)));
|
|
return result;
|
|
}
|
|
|
|
// scope == "all": installed entries first (with their resolved info), then registry-only ones.
|
|
var seen = new HashSet<string>();
|
|
foreach (var entry in installed.Values)
|
|
{
|
|
if (!includeIndirect && !entry.isDirectDependency)
|
|
continue;
|
|
result.Add(Map(entry, isInstalled: true));
|
|
seen.Add(entry.name);
|
|
}
|
|
foreach (var p in available ?? Array.Empty<PackageInfo>())
|
|
{
|
|
if (!seen.Contains(p.name))
|
|
result.Add(Map(p, isInstalled: false));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
[CliCommand("package_search",
|
|
"Search packages available in the registry. Provide a name (e.g. com.unity.foo) or omit to list all. " +
|
|
"Returns the full result synchronously (blocks until the registry query completes).",
|
|
MainThreadRequired = false)]
|
|
public static object PackageSearch(
|
|
[CliArg("query", "Package name to search for. Omit/empty to list all available packages.")] string query = "",
|
|
[CliArg("offline", "Search the local cache only.")] bool offline = false)
|
|
{
|
|
var trimmed = (query ?? string.Empty).Trim();
|
|
|
|
var request = RunOnMain(() => string.IsNullOrEmpty(trimmed)
|
|
? Client.SearchAll(offline)
|
|
: Client.Search(trimmed, offline));
|
|
|
|
if (!WaitForCompletion(request, out var waitError))
|
|
return new PackageSearchResponse { Success = false, Query = trimmed, Message = waitError };
|
|
|
|
return RunOnMain<object>(() =>
|
|
{
|
|
if (request.Status != StatusCode.Success)
|
|
return new PackageSearchResponse
|
|
{
|
|
Success = false,
|
|
Query = trimmed,
|
|
Message = $"Search failed: {request.Error?.message ?? "unknown error"}"
|
|
};
|
|
|
|
var packages = MapMarkingInstalled(request.Result);
|
|
return new PackageSearchResponse
|
|
{
|
|
Success = true,
|
|
Query = trimmed,
|
|
Count = packages.Count,
|
|
Packages = packages,
|
|
Message = $"{packages.Count} package(s)."
|
|
};
|
|
});
|
|
}
|
|
|
|
// ---- Mutating operations (dual-mode, CAT-2509 gated) -----------------------------------
|
|
|
|
[CliCommand("package_add",
|
|
"Add a UPM package by name@version, git URL, or 'file:' local path. Async by default (returns " +
|
|
"in_progress; poll package_status); pass wait=true to block until added. A recompile/domain reload " +
|
|
"follows — poll recompile_status. Requires confirm=true; use dry_run to preview.",
|
|
MainThreadRequired = false)]
|
|
public static object PackageAdd(
|
|
[CliArg("identifier", "Package to add: 'com.unity.foo@1.2.3', a git URL, or 'file:../Path'.", Required = true)] string identifier = "",
|
|
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
|
|
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
|
|
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
|
|
{
|
|
if (!PackageIdentifier.TryParse(identifier, out var parsed, out var parseError))
|
|
return PackageMutationResponse.Failed("add", identifier, parseError);
|
|
|
|
if (IsBusy())
|
|
return PackageMutationResponse.Busy("add");
|
|
|
|
return ExecuteMutation(
|
|
operation: "add",
|
|
commandName: "package_add",
|
|
argument: parsed.Identifier,
|
|
planText: $"Add package {parsed.Description} [{parsed.Kind}]",
|
|
confirm: confirm,
|
|
dryRun: dryRun,
|
|
wait: wait,
|
|
start: () => Client.Add(parsed.Identifier),
|
|
readPackage: req =>
|
|
{
|
|
var added = (req as AddRequest)?.Result;
|
|
return added != null ? Map(added, isInstalled: true) : null;
|
|
});
|
|
}
|
|
|
|
[CliCommand("package_remove",
|
|
"Remove a UPM package by name. Async by default (returns in_progress; poll package_status); pass " +
|
|
"wait=true to block until removed. A recompile/domain reload follows — poll recompile_status. " +
|
|
"Requires confirm=true; use dry_run to preview.",
|
|
MainThreadRequired = false)]
|
|
public static object PackageRemove(
|
|
[CliArg("name", "Package name to remove (e.g. com.unity.foo).", Required = true)] string name = "",
|
|
[CliArg("confirm", "Apply the change. Without it the call is refused.")] bool confirm = false,
|
|
[CliArg("dry_run", "Preview the change without applying it.")] bool dryRun = false,
|
|
[CliArg("wait", "Block until the operation completes and return the result (synchronous). Default: return immediately and poll package_status.")] bool wait = false)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
return PackageMutationResponse.Failed("remove", name, "A package name is required.");
|
|
|
|
var packageName = name.Trim();
|
|
|
|
if (IsBusy())
|
|
return PackageMutationResponse.Busy("remove");
|
|
|
|
return ExecuteMutation(
|
|
operation: "remove",
|
|
commandName: "package_remove",
|
|
argument: packageName,
|
|
planText: $"Remove package '{packageName}'",
|
|
confirm: confirm,
|
|
dryRun: dryRun,
|
|
wait: wait,
|
|
start: () => Client.Remove(packageName),
|
|
readPackage: null);
|
|
}
|
|
|
|
[CliCommand("package_resolve",
|
|
"Resolve/refresh packages from the manifest (re-fetch and re-link). May trigger a recompile/domain " +
|
|
"reload — poll recompile_status. Its outcome is recorded for package_status.",
|
|
MainThreadRequired = true)]
|
|
public static object PackageResolve()
|
|
{
|
|
WriteStatus(new PackageStatus
|
|
{
|
|
Status = "in_progress",
|
|
Operation = "resolve",
|
|
StartedAt = NowIso(),
|
|
Message = "Resolving packages..."
|
|
});
|
|
|
|
try
|
|
{
|
|
// Client.Resolve() is fire-and-forget (returns void) and schedules a resolve on a later
|
|
// tick, so the response flushes before any reload — no request to wait on.
|
|
Client.Resolve();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
var failed = new PackageStatus
|
|
{
|
|
Status = "failed",
|
|
Operation = "resolve",
|
|
Success = false,
|
|
Error = ex.Message,
|
|
Manifest = SafeReadManifest(),
|
|
CompletedAt = NowIso(),
|
|
Message = $"Resolve failed: {ex.Message}"
|
|
};
|
|
WriteStatus(failed);
|
|
return PackageMutationResponse.Failed("resolve", null, failed.Error);
|
|
}
|
|
|
|
var done = new PackageStatus
|
|
{
|
|
Status = "completed",
|
|
Operation = "resolve",
|
|
Success = true,
|
|
RequiresRecompile = true,
|
|
Manifest = SafeReadManifest(),
|
|
CompletedAt = NowIso(),
|
|
Message = "Resolve requested. If assemblies changed, a domain reload follows — poll recompile_status."
|
|
};
|
|
WriteStatus(done);
|
|
return ToMutationResponse(done, plan: null);
|
|
}
|
|
|
|
// ---- Status ----------------------------------------------------------------------------
|
|
|
|
[CliCommand("package_status",
|
|
"Status of the last async package operation (add/remove/resolve): idle | in_progress | completed | " +
|
|
"failed, with the added package, manifest, and any error.",
|
|
MainThreadRequired = false)]
|
|
public static string GetPackageStatus()
|
|
{
|
|
if (File.Exists(StatusFile))
|
|
return File.ReadAllText(StatusFile);
|
|
return "{\"status\":\"idle\"}";
|
|
}
|
|
|
|
// ---- Mutation execution ----------------------------------------------------------------
|
|
|
|
/// <summary>
|
|
/// Gate (confirm / dry-run) and kick off the mutating op, then
|
|
/// either return <c>in_progress</c> (async, default) or block until completion (<paramref name="wait"/>).
|
|
/// Either way the operation is tracked and a status file is written, so the result is recoverable
|
|
/// via <c>package_status</c> even if the domain reload severs a synchronous reply.
|
|
/// </summary>
|
|
static object ExecuteMutation(
|
|
string operation, string commandName, string argument, string planText,
|
|
bool confirm, bool dryRun, bool wait,
|
|
Func<Request> start, Func<Request, PackageSummary> readPackage)
|
|
{
|
|
if (dryRun)
|
|
return PackageMutationResponse.DryRunPreview(operation, argument, planText, $"Dry run — {planText}");
|
|
if (!confirm)
|
|
return PackageMutationResponse.Rejected(operation, argument,
|
|
"Refused: this changes project packages. Re-run with confirm=true to apply, or dry_run=true to preview.");
|
|
|
|
// UPM operations are not part of Unity's Undo system, so there is no undo scope here.
|
|
var request = RunOnMain(() =>
|
|
{
|
|
var r = start();
|
|
// async mode finalizes via the update-loop poller; sync mode finalizes itself.
|
|
BeginTracking(operation, argument, r, readPackage, subscribePoll: !wait);
|
|
return r;
|
|
});
|
|
|
|
if (request == null)
|
|
return PackageMutationResponse.Failed(operation, argument, "Operation was confirmed but failed to start.");
|
|
|
|
if (!wait)
|
|
return PackageMutationResponse.InProgress(operation, argument, planText);
|
|
|
|
// Synchronous: poll until complete, capturing status + result atomically on the main thread
|
|
// (a reload can only fire between ticks, so the snapshot can't be interleaved).
|
|
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
|
|
while (true)
|
|
{
|
|
var status = RunOnMain(TryFinalize);
|
|
if (status != null)
|
|
return ToMutationResponse(status, planText);
|
|
|
|
if (DateTime.UtcNow > deadline)
|
|
{
|
|
// Hand off to the update-loop poller so package_status still settles after we bail.
|
|
RunOnMain<object>(() => { SubscribePoll(); return null; });
|
|
return PackageMutationResponse.Failed(operation, argument,
|
|
$"Timed out after {WaitTimeoutSeconds}s; the operation may still be in progress — poll package_status.");
|
|
}
|
|
|
|
Thread.Sleep(PollIntervalMs);
|
|
}
|
|
}
|
|
|
|
/// <summary>Record the in-flight op, write the in_progress status, and (async only) start polling.</summary>
|
|
static void BeginTracking(string operation, string argument, Request request,
|
|
Func<Request, PackageSummary> readPackage, bool subscribePoll)
|
|
{
|
|
lock (s_Lock)
|
|
{
|
|
s_Request = request;
|
|
s_Operation = operation;
|
|
s_Argument = argument;
|
|
s_ReadPackage = readPackage;
|
|
s_InProgress = true;
|
|
}
|
|
|
|
WriteStatus(new PackageStatus
|
|
{
|
|
Status = "in_progress",
|
|
Operation = operation,
|
|
Argument = argument,
|
|
StartedAt = NowIso(),
|
|
Message = $"{operation} in progress. Poll package_status."
|
|
});
|
|
|
|
if (subscribePoll)
|
|
SubscribePoll();
|
|
}
|
|
|
|
static void SubscribePoll()
|
|
{
|
|
EditorApplication.update -= Poll;
|
|
EditorApplication.update += Poll;
|
|
}
|
|
|
|
/// <summary>Update-loop poller for async mode (and a timed-out sync wait): finalize when done.</summary>
|
|
static void Poll() => TryFinalize();
|
|
|
|
/// <summary>
|
|
/// If the tracked request has completed, write its final status, clear tracking, and return the
|
|
/// status; otherwise return null. Main-thread only (reads Request members); guarded so the sync
|
|
/// waiter and the update-loop poller can't double-finalize.
|
|
/// </summary>
|
|
static PackageStatus TryFinalize()
|
|
{
|
|
lock (s_Lock)
|
|
{
|
|
var request = s_Request;
|
|
if (request == null)
|
|
{
|
|
EditorApplication.update -= Poll;
|
|
return null;
|
|
}
|
|
if (!request.IsCompleted)
|
|
return null;
|
|
|
|
var status = BuildStatus(s_Operation, s_Argument, request, s_ReadPackage);
|
|
WriteStatus(status);
|
|
|
|
s_Request = null;
|
|
s_Operation = null;
|
|
s_Argument = null;
|
|
s_ReadPackage = null;
|
|
s_InProgress = false;
|
|
EditorApplication.update -= Poll;
|
|
return status;
|
|
}
|
|
}
|
|
|
|
static PackageStatus BuildStatus(string operation, string argument, Request request,
|
|
Func<Request, PackageSummary> readPackage)
|
|
{
|
|
var status = new PackageStatus
|
|
{
|
|
Operation = operation,
|
|
Argument = argument,
|
|
CompletedAt = NowIso(),
|
|
Manifest = SafeReadManifest()
|
|
};
|
|
|
|
if (request.Status == StatusCode.Success)
|
|
{
|
|
status.Status = "completed";
|
|
status.Success = true;
|
|
status.RequiresRecompile = true;
|
|
try { status.Package = readPackage?.Invoke(request); }
|
|
catch (Exception ex) { Debug.LogWarning($"[pipeline] package result projection failed: {ex.Message}"); }
|
|
status.Message = $"{operation} completed.";
|
|
}
|
|
else
|
|
{
|
|
status.Status = "failed";
|
|
status.Success = false;
|
|
status.Error = request.Error?.message ?? "Unknown package manager error.";
|
|
status.Message = $"{operation} failed: {status.Error}";
|
|
}
|
|
|
|
return status;
|
|
}
|
|
|
|
static PackageMutationResponse ToMutationResponse(PackageStatus s, string plan) => new PackageMutationResponse
|
|
{
|
|
Success = s.Success,
|
|
Operation = s.Operation,
|
|
Argument = s.Argument,
|
|
Status = s.Status,
|
|
Applied = s.Success,
|
|
Plan = plan,
|
|
Package = s.Package,
|
|
Manifest = s.Manifest,
|
|
RequiresRecompile = s.RequiresRecompile,
|
|
Message = s.Message
|
|
};
|
|
|
|
/// <summary>
|
|
/// On load, settle a status file left at "in_progress" by a domain reload (the expected outcome of
|
|
/// a successful add/remove). The manifest change that triggered the reload has taken effect, so
|
|
/// the op is recorded completed; the follow-on recompile is observed via recompile_status.
|
|
/// </summary>
|
|
static void RecoverInterruptedOperation()
|
|
{
|
|
try
|
|
{
|
|
if (!File.Exists(StatusFile))
|
|
return;
|
|
|
|
var status = JsonConvert.DeserializeObject<PackageStatus>(File.ReadAllText(StatusFile));
|
|
if (status == null || status.Status != "in_progress")
|
|
return;
|
|
|
|
status.Status = "completed";
|
|
status.Success = true;
|
|
status.RequiresRecompile = true;
|
|
status.CompletedAt = NowIso();
|
|
status.Manifest = SafeReadManifest();
|
|
status.Message = $"{status.Operation} completed (domain reload). Manifest updated; poll recompile_status for the recompile.";
|
|
WriteStatus(status);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogWarning($"[pipeline] package status recovery failed: {ex.Message}");
|
|
}
|
|
}
|
|
|
|
// ---- Helpers ---------------------------------------------------------------------------
|
|
|
|
static bool IsBusy() => s_InProgress;
|
|
|
|
/// <summary>
|
|
/// Block until <paramref name="request"/> completes, polling its (main-thread-only) state via
|
|
/// <see cref="RunOnMain"/> while this thread sleeps between checks. Returns false with
|
|
/// <paramref name="error"/> set on timeout. Used by the read-only registry queries.
|
|
/// </summary>
|
|
static bool WaitForCompletion(Request request, out string error)
|
|
{
|
|
error = null;
|
|
var deadline = DateTime.UtcNow.AddSeconds(WaitTimeoutSeconds);
|
|
while (!RunOnMain(() => request.IsCompleted))
|
|
{
|
|
if (DateTime.UtcNow > deadline)
|
|
{
|
|
error = $"Timed out after {WaitTimeoutSeconds}s waiting for the package manager.";
|
|
return false;
|
|
}
|
|
Thread.Sleep(PollIntervalMs);
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Run <paramref name="fn"/> on the Unity main thread. When invoked off-thread (the normal case
|
|
/// for the waiting commands), it marshals through the live server's dispatcher; when already on
|
|
/// the main thread (e.g. a direct in-process/test call, or no server running) it runs inline.
|
|
/// </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 Dictionary<string, string> SafeReadManifest() =>
|
|
PackageManifest.TryRead(out var deps, out _) ? deps : null;
|
|
|
|
static PackageSummary Map(PackageInfo p, bool isInstalled = false) => new PackageSummary
|
|
{
|
|
Name = p.name,
|
|
Version = p.version,
|
|
DisplayName = p.displayName,
|
|
Source = p.source.ToString(),
|
|
ResolvedPath = p.resolvedPath,
|
|
IsDirectDependency = p.isDirectDependency,
|
|
IsInstalled = isInstalled
|
|
};
|
|
|
|
/// <summary>Map registry results, flagging which are currently installed.</summary>
|
|
static List<PackageSummary> MapMarkingInstalled(IEnumerable<PackageInfo> packages)
|
|
{
|
|
var installed = new HashSet<string>();
|
|
foreach (var p in PackageInfo.GetAllRegisteredPackages())
|
|
installed.Add(p.name);
|
|
|
|
var list = new List<PackageSummary>();
|
|
if (packages != null)
|
|
foreach (var p in packages)
|
|
list.Add(Map(p, installed.Contains(p.name)));
|
|
return list;
|
|
}
|
|
|
|
static string NowIso() => DateTime.UtcNow.ToString("o");
|
|
|
|
static void WriteStatus(PackageStatus status)
|
|
{
|
|
try
|
|
{
|
|
File.WriteAllText(StatusFile, JsonConvert.SerializeObject(status));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[Package] Failed to write status file: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
}
|