Files
2026-07-13 12:49:17 +08:00

346 lines
13 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using MCPForUnity.Editor.Helpers;
using Newtonsoft.Json;
using UnityEditor;
using UnityEditor.PackageManager;
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
namespace MCPForUnity.Editor.Services
{
internal enum PackageJobStatus { Running, Succeeded, Failed }
internal sealed class PackageJob
{
public string JobId { get; set; }
public PackageJobStatus Status { get; set; }
public string Operation { get; set; }
public string Package { get; set; }
public long StartedUnixMs { get; set; }
public long? FinishedUnixMs { get; set; }
public long LastUpdateUnixMs { get; set; }
public string Error { get; set; }
public string ResultVersion { get; set; }
public string ResultName { get; set; }
}
internal static class PackageJobManager
{
private const string SessionKeyJobs = "MCPForUnity.PackageJobsV1";
private const int MaxJobsToKeep = 10;
private const long DomainReloadTimeoutMs = 120_000;
private static readonly object LockObj = new();
private static readonly Dictionary<string, PackageJob> Jobs = new();
static PackageJobManager()
{
TryRestoreFromSessionState();
}
private sealed class PersistedState
{
public List<PersistedJob> jobs { get; set; }
}
private sealed class PersistedJob
{
public string job_id { get; set; }
public string status { get; set; }
public string operation { get; set; }
public string package_ { get; set; }
public long started_unix_ms { get; set; }
public long? finished_unix_ms { get; set; }
public long last_update_unix_ms { get; set; }
public string error { get; set; }
public string result_version { get; set; }
public string result_name { get; set; }
}
private static PackageJobStatus ParseStatus(string status)
{
if (string.IsNullOrWhiteSpace(status))
return PackageJobStatus.Running;
return status.Trim().ToLowerInvariant() switch
{
"succeeded" => PackageJobStatus.Succeeded,
"failed" => PackageJobStatus.Failed,
_ => PackageJobStatus.Running
};
}
private static void TryRestoreFromSessionState()
{
try
{
string json = SessionState.GetString(SessionKeyJobs, string.Empty);
if (string.IsNullOrWhiteSpace(json))
return;
var state = JsonConvert.DeserializeObject<PersistedState>(json);
if (state?.jobs == null)
return;
lock (LockObj)
{
Jobs.Clear();
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
foreach (var pj in state.jobs)
{
if (pj == null || string.IsNullOrWhiteSpace(pj.job_id))
continue;
var job = new PackageJob
{
JobId = pj.job_id,
Status = ParseStatus(pj.status),
Operation = pj.operation,
Package = pj.package_,
StartedUnixMs = pj.started_unix_ms,
FinishedUnixMs = pj.finished_unix_ms,
LastUpdateUnixMs = pj.last_update_unix_ms,
Error = pj.error,
ResultVersion = pj.result_version,
ResultName = pj.result_name
};
// Domain reload recovery for running jobs
if (job.Status == PackageJobStatus.Running)
{
TryRecoverJob(job, now);
}
Jobs[pj.job_id] = job;
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Failed to restore SessionState: {ex.Message}");
}
}
internal static void TryRecoverJob(PackageJob job, long nowMs)
{
try
{
string packageName = ExtractPackageName(job.Package);
var allPackages = PackageInfo.GetAllRegisteredPackages();
var info = FindPackageInfo(allPackages, packageName, job.Package);
if (job.Operation == "add" || job.Operation == "embed")
{
if (info != null)
{
job.Status = PackageJobStatus.Succeeded;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.ResultVersion = info.version;
job.ResultName = info.name;
McpLog.Info($"[PackageJobManager] Recovered {job.Operation} job {job.JobId}: {info.name}@{info.version} installed.");
}
else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs)
{
job.Status = PackageJobStatus.Failed;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.Error = $"Package {job.Operation} timed out after domain reload.";
McpLog.Warn($"[PackageJobManager] Timed out {job.Operation} job {job.JobId} for '{job.Package}'.");
}
}
else if (job.Operation == "remove")
{
if (info == null)
{
job.Status = PackageJobStatus.Succeeded;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
McpLog.Info($"[PackageJobManager] Recovered remove job {job.JobId}: '{packageName}' is no longer installed.");
}
else if (nowMs - job.StartedUnixMs > DomainReloadTimeoutMs)
{
job.Status = PackageJobStatus.Failed;
job.FinishedUnixMs = nowMs;
job.LastUpdateUnixMs = nowMs;
job.Error = "Package removal timed out after domain reload.";
McpLog.Warn($"[PackageJobManager] Timed out remove job {job.JobId} for '{job.Package}'.");
}
}
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Recovery check failed for job {job.JobId}: {ex.Message}");
}
}
/// <summary>
/// Find a PackageInfo by name, falling back to packageId or git/local source for non-standard identifiers.
/// </summary>
private static PackageInfo FindPackageInfo(PackageInfo[] allPackages, string packageName, string originalIdentifier)
{
// Direct name match (handles normal com.company.package identifiers)
var info = allPackages.FirstOrDefault(p =>
string.Equals(p.name, packageName, StringComparison.OrdinalIgnoreCase));
if (info != null)
return info;
// For git URLs / file: paths, packageName == originalIdentifier and won't match .name.
// Try matching by packageId or source (git/local).
bool isGitOrFile = originalIdentifier.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.StartsWith("git", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.StartsWith("file:", StringComparison.OrdinalIgnoreCase)
|| originalIdentifier.EndsWith(".git", StringComparison.OrdinalIgnoreCase);
if (!isGitOrFile)
return null;
return allPackages.FirstOrDefault(p =>
p.source == PackageSource.Git || p.source == PackageSource.Local
? p.packageId != null && p.packageId.Contains(originalIdentifier)
|| p.resolvedPath != null && p.resolvedPath.Contains(originalIdentifier)
: false);
}
internal static string ExtractPackageName(string packageIdentifier)
{
if (string.IsNullOrEmpty(packageIdentifier))
return packageIdentifier;
// Strip version: "com.unity.foo@1.0.0" -> "com.unity.foo"
int atIndex = packageIdentifier.IndexOf('@');
if (atIndex > 0)
return packageIdentifier.Substring(0, atIndex);
// Git URLs and file: paths — can't reliably extract name, return as-is
return packageIdentifier;
}
internal static void PersistToSessionState()
{
try
{
PersistedState snapshot;
lock (LockObj)
{
var jobs = Jobs.Values
.OrderByDescending(j => j.LastUpdateUnixMs)
.Take(MaxJobsToKeep)
.Select(j => new PersistedJob
{
job_id = j.JobId,
status = j.Status.ToString().ToLowerInvariant(),
operation = j.Operation,
package_ = j.Package,
started_unix_ms = j.StartedUnixMs,
finished_unix_ms = j.FinishedUnixMs,
last_update_unix_ms = j.LastUpdateUnixMs,
error = j.Error,
result_version = j.ResultVersion,
result_name = j.ResultName
})
.ToList();
snapshot = new PersistedState { jobs = jobs };
}
SessionState.SetString(SessionKeyJobs, JsonConvert.SerializeObject(snapshot));
}
catch (Exception ex)
{
McpLog.Warn($"[PackageJobManager] Failed to persist SessionState: {ex.Message}");
}
}
public static string StartJob(string operation, string package)
{
string jobId = Guid.NewGuid().ToString("N");
long started = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var job = new PackageJob
{
JobId = jobId,
Status = PackageJobStatus.Running,
Operation = operation,
Package = package,
StartedUnixMs = started,
FinishedUnixMs = null,
LastUpdateUnixMs = started,
Error = null,
ResultVersion = null,
ResultName = null
};
lock (LockObj)
{
Jobs[jobId] = job;
}
PersistToSessionState();
return jobId;
}
public static void CompleteJob(string jobId, bool success, string error = null,
string version = null, string name = null)
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lock (LockObj)
{
if (!Jobs.TryGetValue(jobId, out var job))
return;
job.Status = success ? PackageJobStatus.Succeeded : PackageJobStatus.Failed;
job.FinishedUnixMs = now;
job.LastUpdateUnixMs = now;
job.Error = error;
job.ResultVersion = version;
job.ResultName = name;
}
PersistToSessionState();
}
public static PackageJob GetJob(string jobId)
{
if (string.IsNullOrWhiteSpace(jobId))
return null;
lock (LockObj)
{
Jobs.TryGetValue(jobId, out var job);
return job;
}
}
public static PackageJob GetLatestJob()
{
lock (LockObj)
{
return Jobs.Values
.OrderByDescending(j => j.StartedUnixMs)
.FirstOrDefault();
}
}
public static object ToSerializable(PackageJob job)
{
if (job == null)
return null;
return new
{
job_id = job.JobId,
status = job.Status.ToString().ToLowerInvariant(),
operation = job.Operation,
package_ = job.Package,
started_unix_ms = job.StartedUnixMs,
finished_unix_ms = job.FinishedUnixMs,
last_update_unix_ms = job.LastUpdateUnixMs,
error = job.Error,
result_version = job.ResultVersion,
result_name = job.ResultName
};
}
}
}