chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2796223ccc4747ffa205b1bac8b2ceef
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,533 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Import;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Providers;
|
||||
using Newtonsoft.Json;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen
|
||||
{
|
||||
public enum AssetGenJobState { Queued, Running, Importing, Done, Failed, Canceled }
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of a generation/import job. Persisted to SessionState so a `status` query
|
||||
/// still works after an unrelated domain reload. NEVER carries a key or secret.
|
||||
/// </summary>
|
||||
public sealed class AssetGenJob
|
||||
{
|
||||
public string JobId;
|
||||
public string Kind; // model | image | marketplace
|
||||
public string Provider;
|
||||
public string Action;
|
||||
public AssetGenJobState State;
|
||||
public float Progress;
|
||||
public string Format;
|
||||
public float TargetSize = 1f;
|
||||
public string AnimationType; // FBX/OBJ rig mode: null/none | generic | humanoid | legacy
|
||||
public string AssetPath;
|
||||
public string AssetGuid;
|
||||
public string Error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives asset-generation jobs on the Unity main thread via EditorApplication.update. Each
|
||||
/// job runs a generic submit → poll → (download | inline) → import state machine; the model
|
||||
/// and image paths supply their own submit/poll/import delegates. Because UnityWebRequest
|
||||
/// completes on the main thread, polling Task.IsCompleted from the update loop is
|
||||
/// main-thread-safe — we never block or use threadpool waits. The provider key is read once at
|
||||
/// submit time, captured only inside the submit/poll closures — never stored on the job,
|
||||
/// persisted, or logged.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
public static class AssetGenJobManager
|
||||
{
|
||||
private const string JobKeyPrefix = "MCPForUnity.AssetGen.Job.";
|
||||
private const string JobIndexKey = "MCPForUnity.AssetGen.JobIndex";
|
||||
|
||||
// Test seams (overridable; defaults are the production implementations).
|
||||
internal static IHttpTransport TransportOverrideForTests;
|
||||
internal static Func<AssetGenJob, string, AssetGenJob> ImportOverrideForTests;
|
||||
internal static double PollIntervalSeconds = 3.0;
|
||||
internal static double TimeoutSeconds = 600.0;
|
||||
|
||||
private static readonly Dictionary<string, AssetGenJob> Jobs = new();
|
||||
private static readonly Dictionary<string, Runner> Runners = new();
|
||||
private static readonly List<string> _tickIds = new();
|
||||
private static bool _ticking;
|
||||
|
||||
static AssetGenJobManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
string index = SessionState.GetString(JobIndexKey, string.Empty);
|
||||
if (string.IsNullOrEmpty(index)) return;
|
||||
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string json = SessionState.GetString(JobKeyPrefix + id, string.Empty);
|
||||
if (string.IsNullOrEmpty(json)) continue;
|
||||
var job = JsonConvert.DeserializeObject<AssetGenJob>(json);
|
||||
if (job == null) continue;
|
||||
if (!IsTerminal(job.State))
|
||||
{
|
||||
job.State = AssetGenJobState.Failed;
|
||||
job.Error = "Interrupted by an editor reload; please retry.";
|
||||
Persist(job);
|
||||
}
|
||||
Jobs[id] = job;
|
||||
}
|
||||
}
|
||||
catch { /* recovery is best-effort */ }
|
||||
}
|
||||
|
||||
public static AssetGenJob StartModelGeneration(ModelGenRequest req)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
string provider = string.IsNullOrEmpty(req.Provider) ? "tripo" : req.Provider;
|
||||
IModelProviderAdapter adapter = AssetGenProviders.Model(provider); // throws NotSupportedException if unimplemented
|
||||
|
||||
var job = NewJob("model", provider, "generate");
|
||||
job.Format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format;
|
||||
job.TargetSize = req.TargetSize <= 0 ? 1f : req.TargetSize;
|
||||
|
||||
if (!TryResolveKey(provider, job, out string apiKey)) return job;
|
||||
|
||||
IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
|
||||
var runner = new Runner
|
||||
{
|
||||
Job = job,
|
||||
SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct),
|
||||
PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct),
|
||||
ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto,
|
||||
Transport = transport,
|
||||
OutputFolder = req.OutputFolder,
|
||||
Ext = (job.Format ?? "glb").TrimStart('.'),
|
||||
Name = NameFrom(req.Name, req.Prompt, job.JobId),
|
||||
Subfolder = "Models",
|
||||
};
|
||||
Register(job, runner);
|
||||
return job;
|
||||
}
|
||||
|
||||
public static AssetGenJob StartImageGeneration(ImageGenRequest req)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
string provider = string.IsNullOrEmpty(req.Provider) ? "fal" : req.Provider;
|
||||
IImageProviderAdapter adapter = AssetGenProviders.Image(provider); // throws NotSupportedException if unimplemented
|
||||
|
||||
var job = NewJob("image", provider, "generate");
|
||||
job.Format = "png";
|
||||
|
||||
if (!TryResolveKey(provider, job, out string apiKey)) return job;
|
||||
|
||||
IHttpTransport transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
|
||||
bool asSprite = req.AsSprite;
|
||||
bool transparent = req.Transparent;
|
||||
var runner = new Runner
|
||||
{
|
||||
Job = job,
|
||||
SubmitFn = ct => adapter.SubmitAsync(req, apiKey, transport, ct),
|
||||
PollFn = (pid, ct) => adapter.PollAsync(pid, apiKey, transport, ct),
|
||||
ImportFn = ImportOverrideForTests ?? ((j, path) => ImageImportPipeline.ImportInto(j, path, asSprite, transparent, isColor: true)),
|
||||
Transport = transport,
|
||||
OutputFolder = req.OutputFolder,
|
||||
Ext = "png",
|
||||
Name = NameFrom(req.Name, req.Prompt, job.JobId),
|
||||
Subfolder = "Images",
|
||||
};
|
||||
Register(job, runner);
|
||||
return job;
|
||||
}
|
||||
|
||||
public static AssetGenJob StartMarketplaceImport(string uid, float targetSize, string name, string outputFolder)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) throw new ArgumentException("uid required");
|
||||
var adapter = AssetGenProviders.Marketplace("sketchfab"); // throws NotSupported if unimplemented
|
||||
var job = NewJob("marketplace", "sketchfab", "import");
|
||||
job.TargetSize = targetSize <= 0 ? 1f : targetSize;
|
||||
if (!TryResolveKey("sketchfab", job, out string apiKey)) return job;
|
||||
var transport = TransportOverrideForTests ?? new UnityWebRequestTransport();
|
||||
var runner = new Runner
|
||||
{
|
||||
Job = job,
|
||||
SubmitFn = ct => adapter.ResolveDownloadUrlAsync(uid, apiKey, transport, ct), // returns the zip/gltf URL as providerJobId
|
||||
PollFn = (pid, ct) => Task.FromResult(new ProviderPollResult { State = ProviderPollState.Succeeded, Progress = 1f, DownloadUrl = pid, ResultExt = "zip" }),
|
||||
ImportFn = ImportOverrideForTests ?? ModelImportPipeline.ImportInto,
|
||||
Transport = transport,
|
||||
OutputFolder = outputFolder,
|
||||
Ext = "zip",
|
||||
Name = NameFrom(name, uid, job.JobId),
|
||||
Subfolder = "Sketchfab",
|
||||
};
|
||||
Register(job, runner);
|
||||
return job;
|
||||
}
|
||||
|
||||
public static AssetGenJob GetJob(string jobId)
|
||||
=> string.IsNullOrEmpty(jobId) ? null : (Jobs.TryGetValue(jobId, out var j) ? j : null);
|
||||
|
||||
/// <summary>Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys.</summary>
|
||||
public static IReadOnlyList<AssetGenJob> RecentJobs(int max = 20)
|
||||
{
|
||||
var all = new List<AssetGenJob>(Jobs.Values);
|
||||
int start = Math.Max(0, all.Count - max);
|
||||
var slice = all.GetRange(start, all.Count - start);
|
||||
slice.Reverse();
|
||||
return slice;
|
||||
}
|
||||
|
||||
public static bool Cancel(string jobId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(jobId)) return false;
|
||||
if (Runners.TryGetValue(jobId, out var r))
|
||||
{
|
||||
r.Canceled = true;
|
||||
try { r.Cts.Cancel(); } catch { }
|
||||
return true;
|
||||
}
|
||||
if (Jobs.TryGetValue(jobId, out var job) && job.State == AssetGenJobState.Queued)
|
||||
{
|
||||
job.State = AssetGenJobState.Canceled;
|
||||
Persist(job);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ---------- runner ----------
|
||||
|
||||
private enum RunnerPhase { Submit, AwaitSubmit, Poll, AwaitPoll, Download, AwaitDownload, Import }
|
||||
|
||||
private sealed class Runner
|
||||
{
|
||||
public AssetGenJob Job;
|
||||
public Func<CancellationToken, Task<string>> SubmitFn;
|
||||
public Func<string, CancellationToken, Task<ProviderPollResult>> PollFn;
|
||||
public Func<AssetGenJob, string, AssetGenJob> ImportFn;
|
||||
public IHttpTransport Transport;
|
||||
public string OutputFolder;
|
||||
public string Ext;
|
||||
public string OverrideExt;
|
||||
public string Name;
|
||||
public string Subfolder;
|
||||
|
||||
public CancellationTokenSource Cts = new();
|
||||
public RunnerPhase Phase = RunnerPhase.Submit;
|
||||
public double StartedAt;
|
||||
public double NextPollAt;
|
||||
public string ProviderJobId;
|
||||
public string DownloadUrl;
|
||||
public string LocalPath;
|
||||
public Task<string> SubmitTask;
|
||||
public Task<ProviderPollResult> PollTask;
|
||||
public Task<HttpResult> DownloadTask;
|
||||
public bool Canceled;
|
||||
}
|
||||
|
||||
private static bool TryResolveKey(string provider, AssetGenJob job, out string apiKey)
|
||||
{
|
||||
if (!SecureKeyStore.Current.TryGet(provider, out apiKey) || string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
job.State = AssetGenJobState.Failed;
|
||||
job.Error = AssetGenProviders.MissingKeyMessage(provider);
|
||||
Jobs[job.JobId] = job;
|
||||
Persist(job);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void Register(AssetGenJob job, Runner runner)
|
||||
{
|
||||
runner.StartedAt = Now();
|
||||
Jobs[job.JobId] = job;
|
||||
Runners[job.JobId] = runner;
|
||||
Persist(job);
|
||||
EnsureTicking();
|
||||
}
|
||||
|
||||
private static void EnsureTicking()
|
||||
{
|
||||
if (_ticking) return;
|
||||
EditorApplication.update += Tick;
|
||||
_ticking = true;
|
||||
}
|
||||
|
||||
private static void Tick()
|
||||
{
|
||||
if (Runners.Count == 0)
|
||||
{
|
||||
EditorApplication.update -= Tick;
|
||||
_ticking = false;
|
||||
return;
|
||||
}
|
||||
// Snapshot keys into a reused buffer so Advance can mutate Runners mid-iteration
|
||||
// without churning the GC on every editor-update frame.
|
||||
_tickIds.Clear();
|
||||
_tickIds.AddRange(Runners.Keys);
|
||||
foreach (string id in _tickIds)
|
||||
{
|
||||
if (Runners.TryGetValue(id, out var r)) Advance(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Advance one job one step. Returns true when the job is terminal.</summary>
|
||||
internal static bool TryAdvanceForTests(string jobId)
|
||||
{
|
||||
if (Runners.TryGetValue(jobId, out var r))
|
||||
{
|
||||
Advance(r);
|
||||
return IsTerminal(r.Job.State);
|
||||
}
|
||||
return Jobs.TryGetValue(jobId, out var j) && IsTerminal(j.State);
|
||||
}
|
||||
|
||||
private static void Advance(Runner r)
|
||||
{
|
||||
if (IsTerminal(r.Job.State)) { Finalize(r); return; }
|
||||
if (r.Canceled) { r.Job.State = AssetGenJobState.Canceled; Persist(r.Job); Finalize(r); return; }
|
||||
if (Now() - r.StartedAt > TimeoutSeconds) { Fail(r, $"Timed out after {TimeoutSeconds:0}s."); return; }
|
||||
|
||||
try
|
||||
{
|
||||
switch (r.Phase)
|
||||
{
|
||||
case RunnerPhase.Submit:
|
||||
r.Job.State = AssetGenJobState.Running;
|
||||
Persist(r.Job);
|
||||
r.SubmitTask = r.SubmitFn(r.Cts.Token);
|
||||
r.Phase = RunnerPhase.AwaitSubmit;
|
||||
break;
|
||||
|
||||
case RunnerPhase.AwaitSubmit:
|
||||
if (!r.SubmitTask.IsCompleted) break;
|
||||
if (Faulted(r.SubmitTask, out string subErr)) { Fail(r, subErr); break; }
|
||||
r.ProviderJobId = r.SubmitTask.Result;
|
||||
if (string.IsNullOrEmpty(r.ProviderJobId)) { Fail(r, "Provider returned no job id."); break; }
|
||||
r.NextPollAt = Now();
|
||||
r.Phase = RunnerPhase.Poll;
|
||||
break;
|
||||
|
||||
case RunnerPhase.Poll:
|
||||
if (Now() < r.NextPollAt) break;
|
||||
r.PollTask = r.PollFn(r.ProviderJobId, r.Cts.Token);
|
||||
r.Phase = RunnerPhase.AwaitPoll;
|
||||
break;
|
||||
|
||||
case RunnerPhase.AwaitPoll:
|
||||
if (!r.PollTask.IsCompleted) break;
|
||||
if (Faulted(r.PollTask, out string pollErr)) { Fail(r, pollErr); break; }
|
||||
ProviderPollResult pr = r.PollTask.Result;
|
||||
r.Job.Progress = Mathf.Clamp01(pr.Progress);
|
||||
Persist(r.Job);
|
||||
if (pr.State == ProviderPollState.Succeeded)
|
||||
{
|
||||
r.OverrideExt = pr.ResultExt;
|
||||
if (pr.InlineData != null && pr.InlineData.Length > 0)
|
||||
{
|
||||
r.LocalPath = WriteFile(r, pr.InlineData);
|
||||
r.Job.State = AssetGenJobState.Importing;
|
||||
Persist(r.Job);
|
||||
r.Phase = RunnerPhase.Import;
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(pr.DownloadUrl))
|
||||
{
|
||||
r.DownloadUrl = pr.DownloadUrl;
|
||||
r.Phase = RunnerPhase.Download;
|
||||
}
|
||||
else
|
||||
{
|
||||
Fail(r, "Provider succeeded but returned no result data.");
|
||||
}
|
||||
}
|
||||
else if (pr.State == ProviderPollState.Failed)
|
||||
{
|
||||
Fail(r, string.IsNullOrEmpty(pr.Error) ? "Provider reported failure." : pr.Error);
|
||||
}
|
||||
else
|
||||
{
|
||||
r.NextPollAt = Now() + PollIntervalSeconds;
|
||||
r.Phase = RunnerPhase.Poll;
|
||||
}
|
||||
break;
|
||||
|
||||
case RunnerPhase.Download:
|
||||
// The download URL comes from an untrusted provider response. Only fetch
|
||||
// http(s) — refuse file://, ftp://, etc. so a malicious response can't read
|
||||
// a local file into the project or hit an internal host.
|
||||
if (!IsAllowedDownloadUrl(r.DownloadUrl))
|
||||
{
|
||||
Fail(r, "Refusing to fetch a non-http(s) download URL returned by the provider.");
|
||||
break;
|
||||
}
|
||||
r.DownloadTask = r.Transport.SendAsync(
|
||||
new HttpRequestSpec { Method = "GET", Url = r.DownloadUrl }, r.Cts.Token);
|
||||
r.Phase = RunnerPhase.AwaitDownload;
|
||||
break;
|
||||
|
||||
case RunnerPhase.AwaitDownload:
|
||||
if (!r.DownloadTask.IsCompleted) break;
|
||||
if (Faulted(r.DownloadTask, out string dlErr)) { Fail(r, dlErr); break; }
|
||||
HttpResult res = r.DownloadTask.Result;
|
||||
if (res == null || !res.IsSuccess || res.Body == null || res.Body.Length == 0)
|
||||
{
|
||||
Fail(r, $"Download failed (HTTP {res?.Status}).");
|
||||
break;
|
||||
}
|
||||
r.LocalPath = WriteFile(r, res.Body);
|
||||
r.Job.State = AssetGenJobState.Importing;
|
||||
Persist(r.Job);
|
||||
r.Phase = RunnerPhase.Import;
|
||||
break;
|
||||
|
||||
case RunnerPhase.Import:
|
||||
// The result file was just written via File.WriteAllBytes (outside the
|
||||
// AssetDatabase). Refresh so Unity registers it before we import it,
|
||||
// mirroring ImportModelFile. Skipped under the test import seam.
|
||||
if (ImportOverrideForTests == null) AssetDatabase.Refresh();
|
||||
AssetGenJob imported = r.ImportFn(r.Job, r.LocalPath);
|
||||
if (imported != null) r.Job = imported;
|
||||
if (r.Job.State != AssetGenJobState.Failed)
|
||||
{
|
||||
r.Job.State = AssetGenJobState.Done;
|
||||
r.Job.Progress = 1f;
|
||||
}
|
||||
Persist(r.Job);
|
||||
Finalize(r);
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Fail(r, SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static string WriteFile(Runner r, byte[] bytes)
|
||||
{
|
||||
string chosen = !string.IsNullOrEmpty(r.OverrideExt) ? r.OverrideExt : r.Ext;
|
||||
string ext = string.IsNullOrEmpty(chosen) ? "bin" : chosen.TrimStart('.').ToLowerInvariant();
|
||||
string requestedRoot = !string.IsNullOrEmpty(r.OutputFolder) ? r.OutputFolder
|
||||
: (AssetGenPrefs.OutputRoot + "/" + r.Subfolder);
|
||||
if (!AssetGenPaths.TryGetAssetsFolder(requestedRoot, out string root))
|
||||
root = AssetGenPrefs.DefaultOutputRoot + "/" + r.Subfolder;
|
||||
string absRoot = AssetGenPaths.ToAbsolute(root);
|
||||
Directory.CreateDirectory(absRoot);
|
||||
string baseName = SanitizeName(r.Name);
|
||||
string fileName = baseName + "." + ext;
|
||||
string abs = Path.Combine(absRoot, fileName);
|
||||
int n = 1;
|
||||
while (File.Exists(abs)) { fileName = baseName + "_" + n++ + "." + ext; abs = Path.Combine(absRoot, fileName); }
|
||||
File.WriteAllBytes(abs, bytes);
|
||||
return (root.TrimEnd('/') + "/" + fileName).Replace('\\', '/');
|
||||
}
|
||||
|
||||
private static string NameFrom(string explicitName, string prompt, string jobId)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitName)) return explicitName;
|
||||
if (!string.IsNullOrWhiteSpace(prompt)) return prompt;
|
||||
return "asset_" + jobId.Substring(0, 8);
|
||||
}
|
||||
|
||||
private static string SanitizeName(string raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw)) return "asset";
|
||||
var sb = new System.Text.StringBuilder();
|
||||
foreach (char c in raw.Trim())
|
||||
{
|
||||
if (char.IsLetterOrDigit(c) || c == '_' || c == '-') sb.Append(c);
|
||||
else if (c == ' ') sb.Append('_');
|
||||
if (sb.Length >= 48) break;
|
||||
}
|
||||
string s = sb.ToString().Trim('_', '-');
|
||||
return string.IsNullOrEmpty(s) ? "asset" : s;
|
||||
}
|
||||
|
||||
private static void Fail(Runner r, string message)
|
||||
{
|
||||
r.Job.State = AssetGenJobState.Failed;
|
||||
r.Job.Error = SecretRedactor.Scrub(string.IsNullOrEmpty(message) ? "Generation failed." : message);
|
||||
Persist(r.Job);
|
||||
Finalize(r);
|
||||
}
|
||||
|
||||
private static void Finalize(Runner r)
|
||||
{
|
||||
Runners.Remove(r.Job.JobId);
|
||||
try { r.Cts?.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
private static AssetGenJob NewJob(string kind, string provider, string action)
|
||||
{
|
||||
return new AssetGenJob
|
||||
{
|
||||
JobId = Guid.NewGuid().ToString("N"),
|
||||
Kind = kind,
|
||||
Provider = provider,
|
||||
Action = action,
|
||||
State = AssetGenJobState.Queued,
|
||||
Progress = 0f,
|
||||
};
|
||||
}
|
||||
|
||||
private static void Persist(AssetGenJob job)
|
||||
{
|
||||
try
|
||||
{
|
||||
SessionState.SetString(JobKeyPrefix + job.JobId, JsonConvert.SerializeObject(job));
|
||||
string index = SessionState.GetString(JobIndexKey, string.Empty);
|
||||
var ids = new HashSet<string>(index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries));
|
||||
if (ids.Add(job.JobId))
|
||||
SessionState.SetString(JobIndexKey, string.Join(",", ids));
|
||||
}
|
||||
catch { /* persistence is best-effort */ }
|
||||
}
|
||||
|
||||
private static bool Faulted(Task t, out string error)
|
||||
{
|
||||
if (t.IsFaulted)
|
||||
{
|
||||
Exception ex = t.Exception?.GetBaseException();
|
||||
error = SecretRedactor.Scrub(ex?.Message ?? "request failed");
|
||||
return true;
|
||||
}
|
||||
if (t.IsCanceled) { error = "Canceled."; return true; }
|
||||
error = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool IsTerminal(AssetGenJobState s)
|
||||
=> s == AssetGenJobState.Done || s == AssetGenJobState.Failed || s == AssetGenJobState.Canceled;
|
||||
|
||||
/// <summary>Only http(s) download URLs are allowed; provider responses are untrusted.</summary>
|
||||
private static bool IsAllowedDownloadUrl(string url)
|
||||
=> Uri.TryCreate(url, UriKind.Absolute, out Uri u)
|
||||
&& (u.Scheme == Uri.UriSchemeHttps || u.Scheme == Uri.UriSchemeHttp);
|
||||
|
||||
private static double Now() => EditorApplication.timeSinceStartup;
|
||||
|
||||
internal static void ResetForTests()
|
||||
{
|
||||
foreach (var r in new List<Runner>(Runners.Values))
|
||||
{
|
||||
try { r.Cts?.Cancel(); r.Cts?.Dispose(); } catch { }
|
||||
}
|
||||
Runners.Clear();
|
||||
string index = SessionState.GetString(JobIndexKey, string.Empty);
|
||||
foreach (string id in index.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
SessionState.EraseString(JobKeyPrefix + id);
|
||||
SessionState.EraseString(JobIndexKey);
|
||||
Jobs.Clear();
|
||||
TransportOverrideForTests = null;
|
||||
ImportOverrideForTests = null;
|
||||
PollIntervalSeconds = 3.0;
|
||||
TimeoutSeconds = 600.0;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1955ee1b47384a8a880f00d384fff433
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 29756f9999cd4779b156e75b9370ca8a
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Test double for <see cref="IHttpTransport"/>. Records every request it is handed and
|
||||
/// returns a canned response, so provider adapters can be exercised without a network.
|
||||
/// Match responses either with the <see cref="Handler"/> delegate (full control) or with
|
||||
/// <see cref="ByUrlSubstring"/> (first entry whose key is contained in the request URL).
|
||||
/// </summary>
|
||||
public sealed class FakeHttpTransport : IHttpTransport
|
||||
{
|
||||
public List<HttpRequestSpec> RecordedRequests { get; } = new List<HttpRequestSpec>();
|
||||
|
||||
/// <summary>Highest-priority responder; return null to fall through to <see cref="ByUrlSubstring"/>.</summary>
|
||||
public Func<HttpRequestSpec, HttpResult> Handler { get; set; }
|
||||
|
||||
/// <summary>Canned responses keyed by a substring expected to appear in the request URL.</summary>
|
||||
public Dictionary<string, HttpResult> ByUrlSubstring { get; } = new Dictionary<string, HttpResult>();
|
||||
|
||||
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
|
||||
{
|
||||
RecordedRequests.Add(spec);
|
||||
|
||||
HttpResult result = Handler?.Invoke(spec);
|
||||
|
||||
if (result == null && spec?.Url != null)
|
||||
{
|
||||
foreach (var kv in ByUrlSubstring)
|
||||
{
|
||||
if (spec.Url.IndexOf(kv.Key, StringComparison.Ordinal) >= 0)
|
||||
{
|
||||
result = kv.Value;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
result = new HttpResult
|
||||
{
|
||||
Status = 500,
|
||||
IsSuccess = false,
|
||||
Text = "FakeHttpTransport: no canned response matched " + (spec?.Url ?? "<null>")
|
||||
};
|
||||
}
|
||||
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 46a96ea76bac45cab5025a37f56ba671
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport-agnostic description of a single HTTP request. Provider adapters build one
|
||||
/// of these and hand it to an <see cref="IHttpTransport"/>; this keeps adapters free of
|
||||
/// any direct dependency on UnityWebRequest so they can be unit-tested without a network.
|
||||
/// </summary>
|
||||
public sealed class HttpRequestSpec
|
||||
{
|
||||
public string Method;
|
||||
public string Url;
|
||||
public Dictionary<string, string> Headers = new Dictionary<string, string>();
|
||||
public byte[] Body;
|
||||
public string ContentType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 82093efd40c44dc3bd83e481484ef827
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Transport-agnostic result of an <see cref="HttpRequestSpec"/>. <see cref="Status"/> is
|
||||
/// the numeric HTTP status code; <see cref="IsSuccess"/> reflects the transport's own view
|
||||
/// of success (e.g. UnityWebRequest.Result.Success), which adapters combine with their own
|
||||
/// body-level checks.
|
||||
/// </summary>
|
||||
public sealed class HttpResult
|
||||
{
|
||||
public int Status;
|
||||
public byte[] Body;
|
||||
public string Text;
|
||||
public bool IsSuccess;
|
||||
|
||||
/// <summary>True when the transport reports success or the status code is 2xx.</summary>
|
||||
public bool Ok => IsSuccess || (Status >= 200 && Status < 300);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 507ef9b8ac9d4f42be51a3bc9203eeff
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,15 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// The HTTP seam that provider adapters depend on. Production uses
|
||||
/// <see cref="UnityWebRequestTransport"/>; tests inject <see cref="FakeHttpTransport"/> so
|
||||
/// adapter request/response shaping can be verified without touching the network.
|
||||
/// </summary>
|
||||
public interface IHttpTransport
|
||||
{
|
||||
Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 746824fdb60a436b880f22fee8cdb177
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Http
|
||||
{
|
||||
/// <summary>
|
||||
/// Production <see cref="IHttpTransport"/> backed by UnityWebRequest. Must be invoked on the
|
||||
/// Unity main thread (the asset-gen job manager guarantees this in Phase 3). The send is
|
||||
/// awaited via a <see cref="TaskCompletionSource{T}"/> wired to the async op's completed
|
||||
/// callback, so the call never blocks the editor loop.
|
||||
/// </summary>
|
||||
public sealed class UnityWebRequestTransport : IHttpTransport
|
||||
{
|
||||
public Task<HttpResult> SendAsync(HttpRequestSpec spec, CancellationToken ct)
|
||||
{
|
||||
if (spec == null) throw new ArgumentNullException(nameof(spec));
|
||||
|
||||
var tcs = new TaskCompletionSource<HttpResult>();
|
||||
|
||||
var request = new UnityWebRequest(spec.Url, spec.Method ?? UnityWebRequest.kHttpVerbGET)
|
||||
{
|
||||
downloadHandler = new DownloadHandlerBuffer()
|
||||
};
|
||||
if (spec.Body != null)
|
||||
{
|
||||
request.uploadHandler = new UploadHandlerRaw(spec.Body);
|
||||
}
|
||||
if (!string.IsNullOrEmpty(spec.ContentType))
|
||||
{
|
||||
request.SetRequestHeader("Content-Type", spec.ContentType);
|
||||
}
|
||||
if (spec.Headers != null)
|
||||
{
|
||||
foreach (var kv in spec.Headers)
|
||||
{
|
||||
request.SetRequestHeader(kv.Key, kv.Value);
|
||||
}
|
||||
}
|
||||
|
||||
CancellationTokenRegistration ctReg = default;
|
||||
if (ct.CanBeCanceled)
|
||||
{
|
||||
ctReg = ct.Register(() =>
|
||||
{
|
||||
try { request.Abort(); } catch { /* ignore */ }
|
||||
tcs.TrySetCanceled();
|
||||
});
|
||||
}
|
||||
|
||||
var op = request.SendWebRequest();
|
||||
op.completed += _ =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = new HttpResult
|
||||
{
|
||||
Status = (int)request.responseCode,
|
||||
Body = request.downloadHandler?.data,
|
||||
Text = request.downloadHandler?.text,
|
||||
IsSuccess = request.result == UnityWebRequest.Result.Success
|
||||
};
|
||||
tcs.TrySetResult(result);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
tcs.TrySetException(e);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctReg.Dispose();
|
||||
request.Dispose();
|
||||
}
|
||||
};
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 333575db1d8f487a9f2d32fa78dbb009
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8915ae71731f43f1a1c03cc6f901a39d
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,63 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports a generated 2D image (PNG, already under Assets/) and applies TextureImporter
|
||||
/// settings: Sprite vs Default, alpha-is-transparency, and sRGB (color) vs linear (data maps).
|
||||
/// </summary>
|
||||
public static class ImageImportPipeline
|
||||
{
|
||||
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath, bool asSprite, bool transparent, bool isColor)
|
||||
{
|
||||
if (job == null) return null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(localFilePath))
|
||||
return Fail(job, "No file to import.");
|
||||
|
||||
if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel))
|
||||
return Fail(job, "Generated file is not under the Assets folder.");
|
||||
|
||||
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
if (AssetImporter.GetAtPath(rel) is TextureImporter importer)
|
||||
{
|
||||
importer.textureType = asSprite ? TextureImporterType.Sprite : TextureImporterType.Default;
|
||||
importer.alphaIsTransparency = transparent;
|
||||
importer.sRGBTexture = isColor; // color maps sRGB; normal/roughness/metallic would be linear
|
||||
if (asSprite)
|
||||
{
|
||||
importer.spriteImportMode = SpriteImportMode.Single;
|
||||
importer.mipmapEnabled = false;
|
||||
}
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
job.AssetPath = rel;
|
||||
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
|
||||
if (string.IsNullOrEmpty(job.AssetGuid))
|
||||
return Fail(job, "Imported the image but Unity did not register it as an asset.");
|
||||
|
||||
if (job.State != AssetGenJobState.Failed)
|
||||
job.State = AssetGenJobState.Done;
|
||||
return job;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Fail(job, SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static AssetGenJob Fail(AssetGenJob job, string message)
|
||||
{
|
||||
job.State = AssetGenJobState.Failed;
|
||||
job.Error = message;
|
||||
return job;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5088530e3bc348e1b6539464588bc8bf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Imports a downloaded model file (already under Assets/) into the project and applies
|
||||
/// import settings. GLB/glTF require the optional glTFast package (installed from the
|
||||
/// Dependencies tab); FBX/OBJ use Unity's built-in ModelImporter. Optionally normalizes
|
||||
/// the model's scale to a target size.
|
||||
/// </summary>
|
||||
public static class ModelImportPipeline
|
||||
{
|
||||
// Inert asset types permitted out of an UNTRUSTED provider archive (Sketchfab et al.).
|
||||
// Anything else — scripts, assemblies, asmdefs — is skipped on extraction so it can never
|
||||
// compile or load inside the Editor. See SafeZipExtractor for the enforcement.
|
||||
private static readonly HashSet<string> ArchiveAllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
".gltf", ".glb", ".bin", ".fbx", ".obj", ".mtl",
|
||||
".png", ".jpg", ".jpeg", ".tga", ".bmp", ".tif", ".tiff", ".webp", ".exr", ".hdr",
|
||||
".ktx", ".ktx2", ".basis", ".dds",
|
||||
};
|
||||
|
||||
public static AssetGenJob ImportInto(AssetGenJob job, string localFilePath)
|
||||
{
|
||||
if (job == null) return null;
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrEmpty(localFilePath))
|
||||
return Fail(job, "No file to import.");
|
||||
|
||||
if (!AssetGenPaths.TryGetAssetsRelativePath(localFilePath, out string rel))
|
||||
return Fail(job, "Generated file is not under the Assets folder.");
|
||||
|
||||
string ext = Path.GetExtension(rel).ToLowerInvariant();
|
||||
if (ext == ".zip")
|
||||
return ImportArchive(job, rel);
|
||||
|
||||
return ImportModelFile(job, rel, ext);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
return Fail(job, SecretRedactor.Scrub(e.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private static AssetGenJob ImportModelFile(AssetGenJob job, string rel, string ext)
|
||||
{
|
||||
bool isGltf = ext == ".glb" || ext == ".gltf";
|
||||
|
||||
if (isGltf && !IsGltfastAvailable())
|
||||
{
|
||||
return Fail(job,
|
||||
"GLB import requires glTFast. Install it from the MCP for Unity → Dependencies tab, or choose FBX output.");
|
||||
}
|
||||
|
||||
AssetDatabase.ImportAsset(rel, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
if (!isGltf)
|
||||
ApplyModelImporterSettings(rel, job);
|
||||
|
||||
job.AssetPath = rel;
|
||||
job.AssetGuid = AssetDatabase.AssetPathToGUID(rel);
|
||||
if (string.IsNullOrEmpty(job.AssetGuid))
|
||||
return Fail(job, "Imported the file but Unity did not register it as an asset.");
|
||||
|
||||
if (job.State != AssetGenJobState.Failed)
|
||||
job.State = AssetGenJobState.Done;
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unpack a downloaded archive (Sketchfab ships .zip) into a sibling folder named
|
||||
/// after the archive, import it, then locate the first model file inside and import that.
|
||||
/// FBX/OBJ are preferred over glTF; a glTF-only archive still requires glTFast.
|
||||
/// </summary>
|
||||
private static AssetGenJob ImportArchive(AssetGenJob job, string zipRel)
|
||||
{
|
||||
string zipAbs = AssetGenPaths.ToAbsolute(zipRel);
|
||||
if (!File.Exists(zipAbs))
|
||||
return Fail(job, "Downloaded archive was not found on disk.");
|
||||
|
||||
string folderRel = zipRel.Substring(0, zipRel.Length - ".zip".Length);
|
||||
string folderAbs = AssetGenPaths.ToAbsolute(folderRel);
|
||||
|
||||
Directory.CreateDirectory(folderAbs);
|
||||
// Provider archives are untrusted: only inert model/texture files are written under
|
||||
// Assets/ — scripts/assemblies are skipped so they can't be compiled on import.
|
||||
SafeZipExtractor.ExtractTo(zipAbs, folderAbs, ArchiveAllowedExtensions);
|
||||
|
||||
AssetDatabase.Refresh();
|
||||
AssetDatabase.ImportAsset(folderRel, ImportAssetOptions.ImportRecursive | ImportAssetOptions.ForceUpdate);
|
||||
|
||||
string modelRel = FindFirstModel(folderAbs);
|
||||
if (string.IsNullOrEmpty(modelRel))
|
||||
return Fail(job, "Archive extracted but no model file (.fbx/.obj/.glb/.gltf) was found inside.");
|
||||
|
||||
string ext = Path.GetExtension(modelRel).ToLowerInvariant();
|
||||
bool isGltf = ext == ".glb" || ext == ".gltf";
|
||||
if (isGltf && !IsGltfastAvailable())
|
||||
{
|
||||
return Fail(job,
|
||||
"This model is glTF (.glb/.gltf), which requires glTFast. Install it from the MCP for Unity → Dependencies tab.");
|
||||
}
|
||||
|
||||
AssetDatabase.ImportAsset(modelRel, ImportAssetOptions.ForceUpdate);
|
||||
|
||||
if (!isGltf)
|
||||
ApplyModelImporterSettings(modelRel, job);
|
||||
|
||||
job.AssetPath = modelRel;
|
||||
job.AssetGuid = AssetDatabase.AssetPathToGUID(modelRel);
|
||||
if (string.IsNullOrEmpty(job.AssetGuid))
|
||||
return Fail(job, "Imported the extracted model but Unity did not register it as an asset.");
|
||||
|
||||
if (job.State != AssetGenJobState.Failed)
|
||||
job.State = AssetGenJobState.Done;
|
||||
return job;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Walk the extracted directory for a model file, preferring FBX/OBJ (built-in importer)
|
||||
/// over glTF (needs glTFast). Returns a project-relative path or null when none found.
|
||||
/// </summary>
|
||||
private static string FindFirstModel(string folderAbs)
|
||||
{
|
||||
string[] all;
|
||||
try { all = Directory.GetFiles(folderAbs, "*", SearchOption.AllDirectories); }
|
||||
catch { return null; }
|
||||
|
||||
string firstGltf = null;
|
||||
foreach (string abs in all)
|
||||
{
|
||||
string e = Path.GetExtension(abs).ToLowerInvariant();
|
||||
if (e == ".fbx" || e == ".obj")
|
||||
return AssetGenPaths.ToProjectRelative(abs);
|
||||
if (firstGltf == null && (e == ".glb" || e == ".gltf"))
|
||||
firstGltf = abs;
|
||||
}
|
||||
return firstGltf == null ? null : AssetGenPaths.ToProjectRelative(firstGltf);
|
||||
}
|
||||
|
||||
private static void ApplyModelImporterSettings(string rel, AssetGenJob job)
|
||||
{
|
||||
if (!(AssetImporter.GetAtPath(rel) is ModelImporter importer)) return;
|
||||
importer.useFileScale = true;
|
||||
importer.materialImportMode = ModelImporterMaterialImportMode.ImportStandard;
|
||||
importer.animationType = ParseAnimationType(job?.AnimationType);
|
||||
|
||||
if (AssetGenPrefs.AutoNormalize && job.TargetSize > 0f)
|
||||
{
|
||||
float maxDim = ComputeMaxDimension(rel);
|
||||
if (maxDim > 0.0001f)
|
||||
{
|
||||
float scale = job.TargetSize / maxDim;
|
||||
if (scale > 0f && Math.Abs(scale - 1f) > 0.01f)
|
||||
{
|
||||
importer.useFileScale = false;
|
||||
importer.globalScale = Mathf.Clamp(importer.globalScale * scale, 0.0001f, 1_000_000f);
|
||||
}
|
||||
}
|
||||
}
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map the caller's rig mode to a <see cref="ModelImporterAnimationType"/>. Defaults to
|
||||
/// <c>None</c> (no rig) when unset — pass "generic"/"humanoid" to surface a rigged FBX/OBJ's
|
||||
/// AnimationClips, which the None default otherwise hides. "legacy" selects Unity's
|
||||
/// legacy Animation system — rarely needed.
|
||||
/// </summary>
|
||||
internal static ModelImporterAnimationType ParseAnimationType(string value)
|
||||
{
|
||||
switch ((value ?? string.Empty).Trim().ToLowerInvariant())
|
||||
{
|
||||
case "generic": return ModelImporterAnimationType.Generic;
|
||||
case "humanoid":
|
||||
case "human": return ModelImporterAnimationType.Human;
|
||||
case "legacy": return ModelImporterAnimationType.Legacy;
|
||||
default: return ModelImporterAnimationType.None;
|
||||
}
|
||||
}
|
||||
|
||||
private static float ComputeMaxDimension(string rel)
|
||||
{
|
||||
try
|
||||
{
|
||||
var go = AssetDatabase.LoadAssetAtPath<GameObject>(rel);
|
||||
if (go == null) return 0f;
|
||||
|
||||
bool any = false;
|
||||
Bounds acc = new Bounds(Vector3.zero, Vector3.zero);
|
||||
foreach (var mf in go.GetComponentsInChildren<MeshFilter>(true))
|
||||
{
|
||||
if (mf.sharedMesh == null) continue;
|
||||
if (!any) { acc = mf.sharedMesh.bounds; any = true; }
|
||||
else acc.Encapsulate(mf.sharedMesh.bounds);
|
||||
}
|
||||
foreach (var smr in go.GetComponentsInChildren<SkinnedMeshRenderer>(true))
|
||||
{
|
||||
if (smr.sharedMesh == null) continue;
|
||||
if (!any) { acc = smr.sharedMesh.bounds; any = true; }
|
||||
else acc.Encapsulate(smr.sharedMesh.bounds);
|
||||
}
|
||||
if (!any) return 0f;
|
||||
Vector3 s = acc.size;
|
||||
return Mathf.Max(s.x, Mathf.Max(s.y, s.z));
|
||||
}
|
||||
catch { return 0f; }
|
||||
}
|
||||
|
||||
private static bool? _gltfastAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// True when the glTFast package is present. Cached after the first probe — the result only
|
||||
/// changes on a package install/uninstall, which triggers a domain reload that resets this
|
||||
/// static. Shared with the Asset Gen settings tab so the reflection scan runs at most once.
|
||||
/// </summary>
|
||||
internal static bool IsGltfastAvailable()
|
||||
{
|
||||
if (_gltfastAvailable.HasValue) return _gltfastAvailable.Value;
|
||||
bool found = Type.GetType("GLTFast.GltfImport, glTFast") != null;
|
||||
if (!found)
|
||||
{
|
||||
foreach (var asm in AppDomain.CurrentDomain.GetAssemblies())
|
||||
{
|
||||
try { if (asm.GetType("GLTFast.GltfImport") != null) { found = true; break; } }
|
||||
catch { /* dynamic/!resolvable assembly */ }
|
||||
}
|
||||
}
|
||||
_gltfastAvailable = found;
|
||||
return found;
|
||||
}
|
||||
|
||||
private static AssetGenJob Fail(AssetGenJob job, string message)
|
||||
{
|
||||
job.State = AssetGenJobState.Failed;
|
||||
job.Error = message;
|
||||
return job;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d63a324540d4a4a904e8b345ccf81bb
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Import
|
||||
{
|
||||
/// <summary>
|
||||
/// Extracts a .zip into a destination directory while rejecting Zip-Slip path traversal:
|
||||
/// every entry's resolved target must stay inside <c>destDir</c>. Directory entries are
|
||||
/// created; file entries are written by copying the entry stream (no reliance on the
|
||||
/// ZipFileExtensions helper). Used to unpack marketplace model archives (e.g. Sketchfab).
|
||||
///
|
||||
/// When <paramref name="allowedExtensions"/> is supplied, file entries whose extension is not
|
||||
/// on the allowlist are SKIPPED (not written). Callers that extract UNTRUSTED archives into the
|
||||
/// Assets tree MUST pass an allowlist of inert asset types so executable content (.cs/.dll/
|
||||
/// .asmdef) can never land under Assets/ and be compiled/loaded by the Editor.
|
||||
/// </summary>
|
||||
public static class SafeZipExtractor
|
||||
{
|
||||
public static void ExtractTo(string zipPath, string destDir, ISet<string> allowedExtensions = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(zipPath)) throw new ArgumentException("zipPath required", nameof(zipPath));
|
||||
if (string.IsNullOrEmpty(destDir)) throw new ArgumentException("destDir required", nameof(destDir));
|
||||
|
||||
Directory.CreateDirectory(destDir);
|
||||
string destFull = Path.GetFullPath(destDir);
|
||||
string prefix = destFull.EndsWith(Path.DirectorySeparatorChar.ToString())
|
||||
? destFull
|
||||
: destFull + Path.DirectorySeparatorChar;
|
||||
|
||||
using (FileStream fs = File.OpenRead(zipPath))
|
||||
using (var archive = new ZipArchive(fs, ZipArchiveMode.Read))
|
||||
{
|
||||
foreach (ZipArchiveEntry entry in archive.Entries)
|
||||
{
|
||||
string name = entry.FullName;
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
// Reject traversal / absolute paths up front.
|
||||
if (name.Contains("..") || Path.IsPathRooted(name))
|
||||
throw new IOException($"Unsafe zip entry rejected: {name}");
|
||||
|
||||
string target = Path.GetFullPath(Path.Combine(destDir, name));
|
||||
if (!target.StartsWith(prefix, StringComparison.Ordinal))
|
||||
throw new IOException($"Unsafe zip entry escapes destination: {name}");
|
||||
|
||||
// A directory entry has an empty Name (FullName ends with a separator).
|
||||
if (string.IsNullOrEmpty(entry.Name))
|
||||
{
|
||||
Directory.CreateDirectory(target);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allowlist gate: skip anything that isn't an inert asset type the caller permits.
|
||||
if (allowedExtensions != null && allowedExtensions.Count > 0
|
||||
&& !allowedExtensions.Contains(Path.GetExtension(entry.Name).ToLowerInvariant()))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string parent = Path.GetDirectoryName(target);
|
||||
if (!string.IsNullOrEmpty(parent)) Directory.CreateDirectory(parent);
|
||||
|
||||
using (Stream src = entry.Open())
|
||||
using (FileStream dst = File.Create(target))
|
||||
{
|
||||
src.CopyTo(dst);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2dcd4c25a7b44e96b22ea605ffd73409
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b451683dd204e6e9d28fd7dbe4676a6
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Security;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Factory + registry for asset-gen provider adapters. Resolves a provider id to its adapter
|
||||
/// (model: tripo/meshy; image: fal/openrouter; marketplace: sketchfab); unknown ids throw
|
||||
/// <see cref="NotSupportedException"/>. <see cref="List"/> advertises providers and reports
|
||||
/// <c>Configured</c> existence only — never a key value.
|
||||
/// </summary>
|
||||
public static class AssetGenProviders
|
||||
{
|
||||
public static IModelProviderAdapter Model(string id)
|
||||
{
|
||||
switch ((id ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "tripo":
|
||||
return new TripoAdapter();
|
||||
case "meshy":
|
||||
return new MeshyAdapter();
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown model provider '{id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IImageProviderAdapter Image(string id)
|
||||
{
|
||||
switch ((id ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "fal":
|
||||
return new FalAdapter();
|
||||
case "openrouter":
|
||||
return new OpenRouterAdapter();
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown image provider '{id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IMarketplaceProviderAdapter Marketplace(string id)
|
||||
{
|
||||
switch ((id ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "sketchfab":
|
||||
return new SketchfabAdapter();
|
||||
default:
|
||||
throw new NotSupportedException($"Unknown marketplace provider '{id}'.");
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<ProviderInfo> List()
|
||||
{
|
||||
return new List<ProviderInfo>
|
||||
{
|
||||
new ProviderInfo { Id = "tripo", Kind = "model", Configured = IsConfigured("tripo"), Capabilities = new[] { "text", "image" } },
|
||||
new ProviderInfo { Id = "meshy", Kind = "model", Configured = IsConfigured("meshy"), Capabilities = new[] { "text", "image" } },
|
||||
new ProviderInfo { Id = "sketchfab", Kind = "marketplace", Configured = IsConfigured("sketchfab"), Capabilities = new[] { "search", "import" } },
|
||||
new ProviderInfo { Id = "fal", Kind = "image", Configured = IsConfigured("fal"), Capabilities = new[] { "text", "image" } },
|
||||
new ProviderInfo { Id = "openrouter", Kind = "image", Configured = IsConfigured("openrouter"), Capabilities = new[] { "text", "image" } },
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsConfigured(string id)
|
||||
{
|
||||
try { return SecureKeyStore.Current.Has(id); }
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Standard "no key" message: points the user at the Asset Generation tab and the env override.
|
||||
/// Shared by the asset-gen tools and the job manager so the wording stays in one place.
|
||||
/// </summary>
|
||||
public static string MissingKeyMessage(string provider)
|
||||
=> $"No API key configured for '{provider}'. Add it in the MCP for Unity → Asset Generation tab " +
|
||||
$"(or set MCPFORUNITY_{(provider ?? string.Empty).ToUpperInvariant()}_API_KEY).";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0ab11255ce314c7193df3bca43286f83
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,162 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// fal.ai image provider via the queue API. Submits to queue.fal.run/{model} (auth header
|
||||
/// "Authorization: Key <key>"), polls the request's status_url, then fetches the result and
|
||||
/// returns the first image URL for the job manager to download.
|
||||
/// </summary>
|
||||
public sealed class FalAdapter : IImageProviderAdapter
|
||||
{
|
||||
private const string QueueBase = "https://queue.fal.run/";
|
||||
// FLUX.2 [dev] — current SOTA default (cheaper and better than FLUX.1 dev). Alternatives:
|
||||
// fal-ai/flux-2/flash (fastest/cheapest), fal-ai/flux-2-pro (top quality).
|
||||
private const string DefaultModel = "fal-ai/flux-2";
|
||||
|
||||
public string Id => "fal";
|
||||
|
||||
public async Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
|
||||
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
|
||||
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
|
||||
|
||||
var body = new JObject { ["prompt"] = req.Prompt ?? string.Empty, ["num_images"] = 1 };
|
||||
string url;
|
||||
if (image)
|
||||
{
|
||||
// image→image / editing lives on the model's /edit endpoint and takes an image_urls
|
||||
// array; each entry accepts a hosted URL or an inline base64 data URI (local image_path).
|
||||
url = QueueBase + model + "/edit";
|
||||
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
|
||||
body["image_urls"] = new JArray(imageRef);
|
||||
}
|
||||
else
|
||||
{
|
||||
url = QueueBase + model;
|
||||
}
|
||||
// Forward explicit output dimensions for text→image only; fal's image_size accepts a
|
||||
// {width,height} object. (/edit derives size from the source image and may reject it.
|
||||
// FLUX has no transparency param — transparent backgrounds aren't a generation-time option.)
|
||||
if (!image && req.Width > 0 && req.Height > 0)
|
||||
body["image_size"] = new JObject { ["width"] = req.Width, ["height"] = req.Height };
|
||||
|
||||
var spec = new HttpRequestSpec
|
||||
{
|
||||
Method = "POST",
|
||||
Url = url,
|
||||
ContentType = "application/json",
|
||||
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
|
||||
};
|
||||
spec.Headers["Authorization"] = "Key " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseOk(res, apiKey, "submit");
|
||||
|
||||
// Prefer response_url; fall back to building it from request_id.
|
||||
string responseUrl = json["response_url"]?.ToString();
|
||||
if (string.IsNullOrEmpty(responseUrl))
|
||||
{
|
||||
string requestId = json["request_id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(requestId))
|
||||
throw new Exception(SecretRedactor.Scrub("fal submit returned no request_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
|
||||
// Queue request URLs are namespaced by owner/app without the action sub-path,
|
||||
// so build from the base model id (not `url`, which may end in /edit).
|
||||
responseUrl = QueueBase + model + "/requests/" + requestId;
|
||||
}
|
||||
return responseUrl;
|
||||
}
|
||||
|
||||
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
|
||||
string responseUrl = providerJobId;
|
||||
|
||||
var statusSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl + "/status" };
|
||||
statusSpec.Headers["Authorization"] = "Key " + apiKey;
|
||||
HttpResult statusRes = await http.SendAsync(statusSpec, ct);
|
||||
JObject statusJson = ParseOk(statusRes, apiKey, "status");
|
||||
|
||||
string status = (statusJson["status"]?.ToString() ?? string.Empty).ToUpperInvariant();
|
||||
var result = new ProviderPollResult();
|
||||
switch (status)
|
||||
{
|
||||
case "COMPLETED":
|
||||
case "OK":
|
||||
result.State = ProviderPollState.Succeeded;
|
||||
break;
|
||||
case "IN_PROGRESS":
|
||||
result.State = ProviderPollState.Running;
|
||||
return result;
|
||||
case "IN_QUEUE":
|
||||
result.State = ProviderPollState.Queued;
|
||||
return result;
|
||||
case "ERROR":
|
||||
case "FAILED":
|
||||
result.State = ProviderPollState.Failed;
|
||||
result.Error = SecretRedactor.Scrub(statusJson["error"]?.ToString() ?? "fal task failed.", apiKey);
|
||||
return result;
|
||||
default:
|
||||
result.State = ProviderPollState.Running;
|
||||
return result;
|
||||
}
|
||||
|
||||
// Completed: fetch the result payload and extract the first image URL.
|
||||
var resultSpec = new HttpRequestSpec { Method = "GET", Url = responseUrl };
|
||||
resultSpec.Headers["Authorization"] = "Key " + apiKey;
|
||||
HttpResult resultRes = await http.SendAsync(resultSpec, ct);
|
||||
JObject resultJson = ParseOk(resultRes, apiKey, "result");
|
||||
|
||||
result.Progress = 1f;
|
||||
result.DownloadUrl = ExtractImageUrl(resultJson);
|
||||
if (string.IsNullOrEmpty(result.DownloadUrl))
|
||||
{
|
||||
result.State = ProviderPollState.Failed;
|
||||
result.Error = "fal completed but no image URL was present in the result.";
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string ExtractImageUrl(JObject result)
|
||||
{
|
||||
JToken images = result["images"];
|
||||
if (images is JArray arr && arr.Count > 0)
|
||||
{
|
||||
string u = arr[0]?["url"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(u)) return u;
|
||||
}
|
||||
string single = result["image"]?["url"]?.ToString();
|
||||
return string.IsNullOrEmpty(single) ? null : single;
|
||||
}
|
||||
|
||||
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
JObject json = null;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
|
||||
}
|
||||
|
||||
bool ok = res?.Ok == true;
|
||||
if (!ok)
|
||||
{
|
||||
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
|
||||
throw new Exception(SecretRedactor.Scrub($"fal {phase} failed (status={res?.Status}): {detail}", apiKey));
|
||||
}
|
||||
return json ?? new JObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a36ad2c4285c4799ae9eb4495813a44c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,35 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// A generative 3D model provider (Tripo, Meshy, ...). Submit mints a provider-side
|
||||
/// job id; poll reports progress and, on success, the download URL. The api key is passed in
|
||||
/// at call time and never cached on the adapter.
|
||||
/// </summary>
|
||||
public interface IModelProviderAdapter
|
||||
{
|
||||
string Id { get; }
|
||||
Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>A generative 2D image provider (fal, OpenRouter, ...). Phase 7.</summary>
|
||||
public interface IImageProviderAdapter
|
||||
{
|
||||
string Id { get; }
|
||||
Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>A 3D marketplace provider (Sketchfab, ...). Search/preview/resolve, not generative. Phase 6.</summary>
|
||||
public interface IMarketplaceProviderAdapter
|
||||
{
|
||||
string Id { get; }
|
||||
Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f76130e504cd4423abbe82bbb718d25b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Helpers for feeding a LOCAL on-disk image to a provider: resolve+verify the path, and encode
|
||||
/// it as a base64 <c>data:</c> URI — the inline form fal, Meshy, and OpenRouter accept for image
|
||||
/// input (no hosting/upload needed). Tripo does NOT accept data URIs and is handled separately.
|
||||
/// </summary>
|
||||
internal static class LocalImage
|
||||
{
|
||||
// Extensions that can be inlined as a data URI for provider image input.
|
||||
private static readonly HashSet<string> SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{ ".png", ".jpg", ".jpeg", ".webp", ".gif" };
|
||||
|
||||
/// <summary>
|
||||
/// Resolve an image path under the project's Assets folder to an existing absolute file of
|
||||
/// a supported image type. Returns false with a user-facing <paramref name="error"/> for
|
||||
/// an unsafe path, a missing file, or an unsupported extension, so the handler can fail
|
||||
/// fast before any provider request is made.
|
||||
/// </summary>
|
||||
public static bool ResolveExisting(string path, out string absPath, out string error)
|
||||
{
|
||||
absPath = null;
|
||||
error = null;
|
||||
if (string.IsNullOrWhiteSpace(path)) { error = "image_path is empty."; return false; }
|
||||
if (!AssetGenPaths.TryGetAssetsRelativePath(path, out string rel))
|
||||
{
|
||||
error = "image_path must point to a file under the project's Assets folder.";
|
||||
return false;
|
||||
}
|
||||
string abs = AssetGenPaths.ToAbsolute(rel);
|
||||
if (!File.Exists(abs)) { error = $"Source image not found: {path}"; return false; }
|
||||
if (!SupportedExtensions.Contains(Path.GetExtension(abs)))
|
||||
{
|
||||
error = $"Unsupported image type '{Path.GetExtension(abs)}'. Use .png, .jpg, .jpeg, .webp, or .gif.";
|
||||
return false;
|
||||
}
|
||||
absPath = abs;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Read a local image and return a "data:image/<mime>;base64,..." URI. Throws
|
||||
/// <see cref="NotSupportedException"/> for an unsupported extension.
|
||||
/// </summary>
|
||||
public static string ToDataUri(string absPath)
|
||||
{
|
||||
if (!AssetGenPaths.TryGetAssetsRelativePath(absPath, out string rel))
|
||||
throw new UnauthorizedAccessException("image_path must point to a file under the project's Assets folder.");
|
||||
absPath = AssetGenPaths.ToAbsolute(rel);
|
||||
string mime = MimeFromExtension(Path.GetExtension(absPath));
|
||||
byte[] bytes = File.ReadAllBytes(absPath);
|
||||
return "data:" + mime + ";base64," + Convert.ToBase64String(bytes);
|
||||
}
|
||||
|
||||
private static string MimeFromExtension(string ext)
|
||||
{
|
||||
switch ((ext ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case ".png": return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg": return "image/jpeg";
|
||||
case ".webp": return "image/webp";
|
||||
case ".gif": return "image/gif";
|
||||
default:
|
||||
throw new NotSupportedException(
|
||||
$"Unsupported image type '{ext}' for image input. Use .png, .jpg, .jpeg, .webp, or .gif.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1f8290c406df4661b9ad2ff6cd324871
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,208 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Meshy model provider. Text→3D posts a "preview" task to the v2 text-to-3d endpoint (geometry
|
||||
/// only); when textures are requested it then issues a "refine" task and surfaces the textured
|
||||
/// result. Image→3D posts to the v1 image-to-3d endpoint, which textures in a single call. Each
|
||||
/// task is polled at its OWN endpoint (text vs image). The bearer key is supplied per call and
|
||||
/// never logged; every error is run through <see cref="SecretRedactor"/>.
|
||||
/// </summary>
|
||||
public sealed class MeshyAdapter : IModelProviderAdapter
|
||||
{
|
||||
private const string TextEndpoint = "https://api.meshy.ai/openapi/v2/text-to-3d";
|
||||
private const string ImageEndpoint = "https://api.meshy.ai/openapi/v1/image-to-3d";
|
||||
|
||||
public string Id => "meshy";
|
||||
|
||||
// Stashed at submit so poll picks the right endpoint, model_urls entry, and texture flow.
|
||||
private string _format = "glb";
|
||||
private bool _isImage;
|
||||
private bool _wantTexture = true;
|
||||
private string _refineTaskId;
|
||||
private bool _refineSubmitted;
|
||||
|
||||
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
_format = string.IsNullOrEmpty(req.Format) ? "glb" : req.Format.TrimStart('.').ToLowerInvariant();
|
||||
_wantTexture = req.Texture;
|
||||
_isImage = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
|
||||
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
|
||||
|
||||
JObject body;
|
||||
string url;
|
||||
if (_isImage)
|
||||
{
|
||||
// image→3D textures in a single call (no separate refine task). image_url accepts a
|
||||
// hosted URL or an inline base64 data URI (for a local image_path).
|
||||
url = ImageEndpoint;
|
||||
string imageRef = !string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath);
|
||||
body = new JObject
|
||||
{
|
||||
["image_url"] = imageRef,
|
||||
["ai_model"] = "meshy-6",
|
||||
["should_texture"] = _wantTexture
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// text→3D preview is geometry only; texturing happens via a follow-up refine task.
|
||||
url = TextEndpoint;
|
||||
body = new JObject
|
||||
{
|
||||
["mode"] = "preview",
|
||||
["prompt"] = req.Prompt ?? string.Empty,
|
||||
["ai_model"] = "meshy-6"
|
||||
};
|
||||
}
|
||||
|
||||
return await PostTask(url, body, apiKey, http, ct, "submit");
|
||||
}
|
||||
|
||||
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
bool refinePhase = _refineSubmitted;
|
||||
string pollId = refinePhase ? _refineTaskId : providerJobId;
|
||||
// image tasks live on the v1 image endpoint; preview/refine tasks on v2 text-to-3d.
|
||||
string statusBase = (_isImage && !refinePhase) ? ImageEndpoint : TextEndpoint;
|
||||
|
||||
var spec = new HttpRequestSpec { Method = "GET", Url = statusBase + "/" + pollId };
|
||||
spec.Headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseOk(res, apiKey, "poll");
|
||||
|
||||
ProviderPollState state = MapState(json["status"]?.ToString());
|
||||
var result = new ProviderPollResult { State = state };
|
||||
|
||||
// Two-phase (text + texture) splits progress across preview (0..0.5) and refine (0.5..1).
|
||||
bool twoPhase = !_isImage && _wantTexture;
|
||||
float raw = 0f;
|
||||
JToken prog = json["progress"];
|
||||
if (prog != null && prog.Type != JTokenType.Null) raw = Mathf.Clamp01(prog.Value<float>() / 100f);
|
||||
result.Progress = !twoPhase ? raw : (refinePhase ? 0.5f + raw * 0.5f : raw * 0.5f);
|
||||
|
||||
if (state == ProviderPollState.Succeeded)
|
||||
{
|
||||
// Preview just finished and textures were requested: start the refine task and keep
|
||||
// polling it; never surface the untextured preview result.
|
||||
if (twoPhase && !refinePhase)
|
||||
{
|
||||
var refineBody = new JObject
|
||||
{
|
||||
["mode"] = "refine",
|
||||
["preview_task_id"] = providerJobId,
|
||||
["ai_model"] = "meshy-6"
|
||||
};
|
||||
_refineTaskId = await PostTask(TextEndpoint, refineBody, apiKey, http, ct, "refine");
|
||||
_refineSubmitted = true;
|
||||
result.State = ProviderPollState.Running;
|
||||
result.Progress = 0.5f;
|
||||
return result;
|
||||
}
|
||||
|
||||
result.Progress = 1f;
|
||||
result.DownloadUrl = ExtractModelUrl(json["model_urls"] as JObject);
|
||||
if (string.IsNullOrEmpty(result.DownloadUrl))
|
||||
{
|
||||
result.State = ProviderPollState.Failed;
|
||||
result.Error = "Meshy reported success but no model URL was present in the response.";
|
||||
}
|
||||
}
|
||||
else if (state == ProviderPollState.Failed)
|
||||
{
|
||||
string err = json["task_error"]?["message"]?.ToString()
|
||||
?? json["message"]?.ToString()
|
||||
?? "Meshy task failed.";
|
||||
result.Error = SecretRedactor.Scrub(err, apiKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>POST a task body and return its <c>result</c> task id (or null).</summary>
|
||||
private static async Task<string> PostTask(string url, JObject body, string apiKey, IHttpTransport http, CancellationToken ct, string phase)
|
||||
{
|
||||
var spec = new HttpRequestSpec
|
||||
{
|
||||
Method = "POST",
|
||||
Url = url,
|
||||
ContentType = "application/json",
|
||||
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
|
||||
};
|
||||
spec.Headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseOk(res, apiKey, phase);
|
||||
string id = json["result"]?.ToString();
|
||||
if (string.IsNullOrEmpty(id))
|
||||
throw new Exception(SecretRedactor.Scrub(
|
||||
$"Meshy {phase} returned no task id: " + ProviderHttp.Truncate(ProviderHttp.BodyText(res)), apiKey));
|
||||
return id;
|
||||
}
|
||||
|
||||
private string ExtractModelUrl(JObject urls)
|
||||
{
|
||||
if (urls == null) return null;
|
||||
string byFormat = urls[_format]?.ToString();
|
||||
if (!string.IsNullOrEmpty(byFormat)) return byFormat;
|
||||
string glb = urls["glb"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(glb)) return glb;
|
||||
string fbx = urls["fbx"]?.ToString();
|
||||
return string.IsNullOrEmpty(fbx) ? null : fbx;
|
||||
}
|
||||
|
||||
private static ProviderPollState MapState(string status)
|
||||
{
|
||||
switch ((status ?? string.Empty).ToUpperInvariant())
|
||||
{
|
||||
case "SUCCEEDED":
|
||||
return ProviderPollState.Succeeded;
|
||||
case "FAILED":
|
||||
case "EXPIRED":
|
||||
case "CANCELED":
|
||||
case "CANCELLED":
|
||||
return ProviderPollState.Failed;
|
||||
case "IN_PROGRESS":
|
||||
return ProviderPollState.Running;
|
||||
case "PENDING":
|
||||
default:
|
||||
return ProviderPollState.Queued;
|
||||
}
|
||||
}
|
||||
|
||||
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
JObject json = null;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
|
||||
}
|
||||
|
||||
bool ok = res?.Ok == true;
|
||||
if (!ok)
|
||||
{
|
||||
string detail = json?["message"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
|
||||
throw new Exception(SecretRedactor.Scrub($"Meshy {phase} failed (status={res?.Status}): {detail}", apiKey));
|
||||
}
|
||||
return json ?? new JObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: bb6ba98874ed46cca5f019c89b2edb0a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,155 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// OpenRouter image provider via the (synchronous) chat-completions endpoint with an
|
||||
/// image-capable multimodal model. The image is returned inline (base64 data URL), so the
|
||||
/// work happens in <see cref="SubmitAsync"/> and <see cref="PollAsync"/> returns it immediately.
|
||||
/// One adapter instance handles a single job (the job manager captures it for submit+poll).
|
||||
/// </summary>
|
||||
public sealed class OpenRouterAdapter : IImageProviderAdapter
|
||||
{
|
||||
private const string Endpoint = "https://openrouter.ai/api/v1/chat/completions";
|
||||
private const string DefaultModel = "google/gemini-2.5-flash-image";
|
||||
|
||||
public string Id => "openrouter";
|
||||
|
||||
private byte[] _inlineData;
|
||||
private string _downloadUrl;
|
||||
private string _error;
|
||||
|
||||
public async Task<string> SubmitAsync(ImageGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
string model = string.IsNullOrEmpty(req.Model) ? DefaultModel : req.Model;
|
||||
|
||||
// image->image: attach the reference image as an image_url content part alongside the
|
||||
// text prompt (OpenRouter content-array form). image_url.url takes an http(s) URL or a
|
||||
// base64 data URI. Plain text->image uses a string content.
|
||||
bool image = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase)
|
||||
&& (!string.IsNullOrEmpty(req.ImageUrl) || !string.IsNullOrEmpty(req.ImagePath));
|
||||
// image_url.url accepts a hosted URL or an inline base64 data URI (for a local image_path).
|
||||
string imageRef = image
|
||||
? (!string.IsNullOrEmpty(req.ImageUrl) ? req.ImageUrl : LocalImage.ToDataUri(req.ImagePath))
|
||||
: null;
|
||||
JToken content = image
|
||||
? new JArray(
|
||||
new JObject { ["type"] = "text", ["text"] = req.Prompt ?? string.Empty },
|
||||
new JObject { ["type"] = "image_url", ["image_url"] = new JObject { ["url"] = imageRef } })
|
||||
: (JToken)(req.Prompt ?? string.Empty);
|
||||
|
||||
var body = new JObject
|
||||
{
|
||||
["model"] = model,
|
||||
["modalities"] = new JArray("image", "text"),
|
||||
["messages"] = new JArray(new JObject
|
||||
{
|
||||
["role"] = "user",
|
||||
["content"] = content
|
||||
})
|
||||
};
|
||||
|
||||
var spec = new HttpRequestSpec
|
||||
{
|
||||
Method = "POST",
|
||||
Url = Endpoint,
|
||||
ContentType = "application/json",
|
||||
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
|
||||
};
|
||||
spec.Headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseOk(res, apiKey);
|
||||
|
||||
string url = ExtractImageUrl(json);
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
_error = "OpenRouter returned no image. The selected model may not support image output.";
|
||||
return "ready";
|
||||
}
|
||||
|
||||
if (url.StartsWith("data:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
int comma = url.IndexOf("base64,", StringComparison.OrdinalIgnoreCase);
|
||||
if (comma < 0) { _error = "OpenRouter returned an unrecognized image payload."; return "ready"; }
|
||||
try { _inlineData = Convert.FromBase64String(url.Substring(comma + "base64,".Length)); }
|
||||
catch { _error = "OpenRouter image was not valid base64."; }
|
||||
}
|
||||
else
|
||||
{
|
||||
_downloadUrl = url;
|
||||
}
|
||||
return "ready";
|
||||
}
|
||||
|
||||
public Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
var result = new ProviderPollResult { Progress = 1f };
|
||||
if (!string.IsNullOrEmpty(_error) || (_inlineData == null && string.IsNullOrEmpty(_downloadUrl)))
|
||||
{
|
||||
result.State = ProviderPollState.Failed;
|
||||
result.Error = _error ?? "OpenRouter produced no image.";
|
||||
}
|
||||
else
|
||||
{
|
||||
result.State = ProviderPollState.Succeeded;
|
||||
result.InlineData = _inlineData;
|
||||
result.DownloadUrl = _downloadUrl;
|
||||
}
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static string ExtractImageUrl(JObject json)
|
||||
{
|
||||
JToken message = json["choices"]?[0]?["message"];
|
||||
if (message == null) return null;
|
||||
|
||||
// Preferred: message.images[].image_url.url
|
||||
if (message["images"] is JArray imgs && imgs.Count > 0)
|
||||
{
|
||||
string u = imgs[0]?["image_url"]?["url"]?.ToString() ?? imgs[0]?["url"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(u)) return u;
|
||||
}
|
||||
// Fallback: a content array with image_url parts
|
||||
if (message["content"] is JArray parts)
|
||||
{
|
||||
foreach (JToken part in parts)
|
||||
{
|
||||
string u = part?["image_url"]?["url"]?.ToString();
|
||||
if (!string.IsNullOrEmpty(u)) return u;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static JObject ParseOk(HttpResult res, string apiKey)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
JObject json = null;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
|
||||
}
|
||||
|
||||
bool ok = res?.Ok == true;
|
||||
if (!ok)
|
||||
{
|
||||
string detail = json?["error"]?["message"]?.ToString() ?? json?["error"]?.ToString()
|
||||
?? ProviderHttp.Truncate(text);
|
||||
throw new Exception(SecretRedactor.Scrub($"OpenRouter request failed (status={res?.Status}): {detail}", apiKey));
|
||||
}
|
||||
return json ?? new JObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 55b1b120b06948f6aeada0381b51ce86
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,28 @@
|
||||
using System.Text;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared HTTP-response helpers for provider adapters: read the response text (falling back to
|
||||
/// a UTF-8 decode of the raw body) and truncate long bodies for inclusion in error messages.
|
||||
/// </summary>
|
||||
internal static class ProviderHttp
|
||||
{
|
||||
/// <summary>Response text, falling back to a UTF-8 decode of the raw body when Text is empty.</summary>
|
||||
public static string BodyText(HttpResult res)
|
||||
{
|
||||
string text = res?.Text;
|
||||
if (string.IsNullOrEmpty(text) && res?.Body != null)
|
||||
text = Encoding.UTF8.GetString(res.Body);
|
||||
return text;
|
||||
}
|
||||
|
||||
/// <summary>Cap a (possibly null) string at 500 chars for inclusion in an error message.</summary>
|
||||
public static string Truncate(string s)
|
||||
{
|
||||
if (string.IsNullOrEmpty(s)) return string.Empty;
|
||||
return s.Length <= 500 ? s : s.Substring(0, 500) + "…";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1584e5cffb5b45e6816fec509f1bfc8b
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>Normalized lifecycle state reported by a provider poll, across all providers.</summary>
|
||||
public enum ProviderPollState
|
||||
{
|
||||
Queued,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Outcome of a single provider poll. <see cref="Progress"/> is normalized to 0..1.
|
||||
/// On <see cref="ProviderPollState.Succeeded"/>, <see cref="DownloadUrl"/> points at the
|
||||
/// result the C# side will download into the project. On
|
||||
/// <see cref="ProviderPollState.Failed"/>, <see cref="Error"/> carries a redacted message.
|
||||
/// </summary>
|
||||
public sealed class ProviderPollResult
|
||||
{
|
||||
public ProviderPollState State;
|
||||
public float Progress;
|
||||
public string DownloadUrl;
|
||||
/// <summary>Inline result bytes for synchronous providers that return base64 (e.g. OpenRouter),
|
||||
/// so the job manager skips the download step. Takes precedence over <see cref="DownloadUrl"/>.</summary>
|
||||
public byte[] InlineData;
|
||||
/// <summary>Overrides the downloaded file extension, e.g. "zip" for archive results.</summary>
|
||||
public string ResultExt;
|
||||
public string Error;
|
||||
}
|
||||
|
||||
/// <summary>Request to generate a 3D model. Shared by every model provider adapter.</summary>
|
||||
public sealed class ModelGenRequest
|
||||
{
|
||||
public string Provider;
|
||||
public string Mode; // text | image
|
||||
public string Prompt;
|
||||
public string ImagePath;
|
||||
public string ImageUrl;
|
||||
public string Format = "glb";
|
||||
public float TargetSize = 1f;
|
||||
public bool Texture = true;
|
||||
public string Tier;
|
||||
public string Name;
|
||||
public string OutputFolder;
|
||||
}
|
||||
|
||||
/// <summary>Request to generate a 2D image. Shared by every image provider adapter.</summary>
|
||||
public sealed class ImageGenRequest
|
||||
{
|
||||
public string Provider;
|
||||
public string Mode; // text | image
|
||||
public string Prompt;
|
||||
public string ImagePath;
|
||||
public string ImageUrl;
|
||||
public string Model;
|
||||
public bool Transparent;
|
||||
public bool AsSprite = true; // import as Sprite (2D/UI) vs Default texture
|
||||
public int Width;
|
||||
public int Height;
|
||||
public string Name;
|
||||
public string OutputFolder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public, key-free description of a provider for <c>list_providers</c>. Never carries a key
|
||||
/// value — <see cref="Configured"/> reports existence only.
|
||||
/// </summary>
|
||||
public sealed class ProviderInfo
|
||||
{
|
||||
public string Id;
|
||||
public string Kind; // model | image | marketplace
|
||||
public bool Configured;
|
||||
public string[] Capabilities;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 65be6fd6b6384effb45822a57e541f04
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,96 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using Newtonsoft.Json.Linq;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Sketchfab marketplace provider. Search/preview are read-only GETs returning the raw API
|
||||
/// JSON to the caller; <see cref="ResolveDownloadUrlAsync"/> hits the model download endpoint
|
||||
/// and returns the signed glTF archive (.zip) URL the job manager will fetch. Auth uses the
|
||||
/// "Token <key>" scheme; the key is supplied per call and never logged.
|
||||
/// </summary>
|
||||
public sealed class SketchfabAdapter : IMarketplaceProviderAdapter
|
||||
{
|
||||
private const string SearchEndpoint = "https://api.sketchfab.com/v3/search";
|
||||
private const string ModelsEndpoint = "https://api.sketchfab.com/v3/models";
|
||||
|
||||
public string Id => "sketchfab";
|
||||
|
||||
public async Task<string> SearchAsync(string query, string categories, bool downloadable, int? count, string cursor, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
string url = SearchEndpoint + "?type=models&downloadable=" + (downloadable ? "true" : "false")
|
||||
+ "&q=" + Uri.EscapeDataString(query ?? string.Empty);
|
||||
if (!string.IsNullOrEmpty(categories)) url += "&categories=" + Uri.EscapeDataString(categories);
|
||||
if (count.HasValue) url += "&count=" + count.Value;
|
||||
if (!string.IsNullOrEmpty(cursor)) url += "&cursor=" + Uri.EscapeDataString(cursor);
|
||||
var spec = new HttpRequestSpec { Method = "GET", Url = url };
|
||||
spec.Headers["Authorization"] = "Token " + apiKey;
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
// The raw response carries pagination (`cursors.next` / `next`) for the caller to page.
|
||||
return RawOk(res, apiKey, "search");
|
||||
}
|
||||
|
||||
public async Task<string> PreviewAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid };
|
||||
spec.Headers["Authorization"] = "Token " + apiKey;
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
return RawOk(res, apiKey, "preview");
|
||||
}
|
||||
|
||||
public async Task<string> ResolveDownloadUrlAsync(string uid, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(uid)) throw new ArgumentNullException(nameof(uid));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
var spec = new HttpRequestSpec { Method = "GET", Url = ModelsEndpoint + "/" + uid + "/download" };
|
||||
spec.Headers["Authorization"] = "Token " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseOk(res, apiKey, "download");
|
||||
|
||||
string url = json["gltf"]?["url"]?.ToString();
|
||||
if (string.IsNullOrEmpty(url))
|
||||
{
|
||||
throw new Exception(SecretRedactor.Scrub(
|
||||
$"Sketchfab download returned no gltf url for '{uid}': {ProviderHttp.Truncate(res?.Text)}", apiKey));
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
private static string RawOk(HttpResult res, string apiKey, string phase)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
bool ok = res?.Ok == true;
|
||||
if (!ok)
|
||||
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {ProviderHttp.Truncate(text)}", apiKey));
|
||||
return text ?? string.Empty;
|
||||
}
|
||||
|
||||
private static JObject ParseOk(HttpResult res, string apiKey, string phase)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
JObject json = null;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
try { json = JObject.Parse(text); } catch { /* non-JSON */ }
|
||||
}
|
||||
|
||||
bool ok = res?.Ok == true;
|
||||
if (!ok)
|
||||
{
|
||||
string detail = json?["detail"]?.ToString() ?? json?["error"]?.ToString() ?? ProviderHttp.Truncate(text);
|
||||
throw new Exception(SecretRedactor.Scrub($"Sketchfab {phase} failed (status={res?.Status}): {detail}", apiKey));
|
||||
}
|
||||
return json ?? new JObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 860350c25d8d490aaaa925d76ce29407
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Security;
|
||||
using MCPForUnity.Editor.Services.AssetGen.Http;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.AssetGen.Providers
|
||||
{
|
||||
/// <summary>
|
||||
/// Tripo3D model provider. Submits text→3D / image→3D tasks to the OpenAPI task endpoint and
|
||||
/// polls for completion. The bearer key is supplied per call and never logged; every error
|
||||
/// message is run through <see cref="SecretRedactor"/> before it is surfaced.
|
||||
/// </summary>
|
||||
public sealed class TripoAdapter : IModelProviderAdapter
|
||||
{
|
||||
private const string TaskEndpoint = "https://api.tripo3d.ai/v2/openapi/task";
|
||||
// Current recommended Tripo model (v3.1). Premium alternative: P1-20260311.
|
||||
private const string ModelVersion = "v3.1-20260211";
|
||||
|
||||
public string Id => "tripo";
|
||||
|
||||
public async Task<string> SubmitAsync(ModelGenRequest req, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (req == null) throw new ArgumentNullException(nameof(req));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
// Tripo rejects base64 data URIs and needs a multipart upload→token flow for local files,
|
||||
// which isn't wired yet — fail clearly rather than silently falling back to text mode.
|
||||
bool imageMode = string.Equals(req.Mode, "image", StringComparison.OrdinalIgnoreCase);
|
||||
if (imageMode && string.IsNullOrEmpty(req.ImageUrl))
|
||||
throw new Exception("Tripo image input requires a hosted 'image_url'; local 'image_path' upload is not yet supported for Tripo (use Meshy for local-image→3D, or host the image).");
|
||||
|
||||
JObject body;
|
||||
bool image = imageMode && !string.IsNullOrEmpty(req.ImageUrl);
|
||||
if (image)
|
||||
{
|
||||
body = new JObject
|
||||
{
|
||||
["type"] = "image_to_model",
|
||||
["file"] = new JObject
|
||||
{
|
||||
["type"] = "url",
|
||||
["url"] = req.ImageUrl
|
||||
}
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
body = new JObject
|
||||
{
|
||||
["type"] = "text_to_model",
|
||||
["prompt"] = req.Prompt ?? string.Empty,
|
||||
["model_version"] = ModelVersion
|
||||
};
|
||||
}
|
||||
|
||||
var spec = new HttpRequestSpec
|
||||
{
|
||||
Method = "POST",
|
||||
Url = TaskEndpoint,
|
||||
ContentType = "application/json",
|
||||
Body = Encoding.UTF8.GetBytes(body.ToString(Formatting.None))
|
||||
};
|
||||
spec.Headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseAndValidate(res, apiKey, "submit");
|
||||
|
||||
string taskId = json["data"]?["task_id"]?.ToString();
|
||||
if (string.IsNullOrEmpty(taskId))
|
||||
{
|
||||
throw new Exception(SecretRedactor.Scrub(
|
||||
"Tripo submit returned no task_id: " + ProviderHttp.Truncate(res?.Text), apiKey));
|
||||
}
|
||||
return taskId;
|
||||
}
|
||||
|
||||
public async Task<ProviderPollResult> PollAsync(string providerJobId, string apiKey, IHttpTransport http, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrEmpty(providerJobId)) throw new ArgumentNullException(nameof(providerJobId));
|
||||
if (http == null) throw new ArgumentNullException(nameof(http));
|
||||
|
||||
var spec = new HttpRequestSpec
|
||||
{
|
||||
Method = "GET",
|
||||
Url = TaskEndpoint + "/" + providerJobId
|
||||
};
|
||||
spec.Headers["Authorization"] = "Bearer " + apiKey;
|
||||
|
||||
HttpResult res = await http.SendAsync(spec, ct);
|
||||
JObject json = ParseAndValidate(res, apiKey, "poll");
|
||||
JObject data = json["data"] as JObject ?? new JObject();
|
||||
|
||||
var result = new ProviderPollResult { State = MapState(data["status"]?.ToString()) };
|
||||
|
||||
JToken progressTok = data["progress"];
|
||||
if (progressTok != null && progressTok.Type != JTokenType.Null)
|
||||
{
|
||||
result.Progress = Mathf.Clamp01(progressTok.Value<float>() / 100f);
|
||||
}
|
||||
|
||||
if (result.State == ProviderPollState.Succeeded)
|
||||
{
|
||||
result.Progress = 1f;
|
||||
result.DownloadUrl = ExtractDownloadUrl(data);
|
||||
if (string.IsNullOrEmpty(result.DownloadUrl))
|
||||
{
|
||||
result.State = ProviderPollState.Failed;
|
||||
result.Error = "Tripo reported success but no model URL was present in the response.";
|
||||
}
|
||||
}
|
||||
else if (result.State == ProviderPollState.Failed)
|
||||
{
|
||||
string err = data["error"]?.ToString()
|
||||
?? data["message"]?.ToString()
|
||||
?? json["message"]?.ToString()
|
||||
?? "Tripo task failed.";
|
||||
result.Error = SecretRedactor.Scrub(err, apiKey);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static ProviderPollState MapState(string status)
|
||||
{
|
||||
switch ((status ?? string.Empty).ToLowerInvariant())
|
||||
{
|
||||
case "success":
|
||||
case "succeeded":
|
||||
return ProviderPollState.Succeeded;
|
||||
case "failed":
|
||||
case "error":
|
||||
case "cancelled":
|
||||
case "canceled":
|
||||
case "banned":
|
||||
case "expired":
|
||||
return ProviderPollState.Failed;
|
||||
case "running":
|
||||
case "processing":
|
||||
return ProviderPollState.Running;
|
||||
default:
|
||||
return ProviderPollState.Queued;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the model download URL, defensive about Tripo's nested output shapes. Prefer a
|
||||
/// textured / PBR model; fall back to the base model. Accepts both the newer flat form
|
||||
/// (<c>output.pbr_model</c> = url string) and the older nested form
|
||||
/// (<c>result.pbr_model.url</c> = object with a url field).
|
||||
/// </summary>
|
||||
private static string ExtractDownloadUrl(JObject data)
|
||||
{
|
||||
JObject output = data["output"] as JObject;
|
||||
JObject resultObj = data["result"] as JObject;
|
||||
|
||||
return UrlOf(output?["pbr_model"])
|
||||
?? UrlOf(resultObj?["pbr_model"])
|
||||
?? UrlOf(output?["model"])
|
||||
?? UrlOf(resultObj?["model"])
|
||||
?? UrlOf(output?["base_model"])
|
||||
?? UrlOf(resultObj?["base_model"]);
|
||||
}
|
||||
|
||||
/// <summary>A field may be a plain URL string or an object carrying a "url" property.</summary>
|
||||
private static string UrlOf(JToken token)
|
||||
{
|
||||
if (token == null || token.Type == JTokenType.Null) return null;
|
||||
if (token.Type == JTokenType.String)
|
||||
{
|
||||
string s = token.ToString();
|
||||
return string.IsNullOrEmpty(s) ? null : s;
|
||||
}
|
||||
if (token is JObject obj)
|
||||
{
|
||||
string u = obj["url"]?.ToString();
|
||||
return string.IsNullOrEmpty(u) ? null : u;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validate transport success + Tripo's body-level <c>code</c> (0 == ok), parse the JSON,
|
||||
/// and throw a redacted exception otherwise.
|
||||
/// </summary>
|
||||
private static JObject ParseAndValidate(HttpResult res, string apiKey, string phase)
|
||||
{
|
||||
string text = ProviderHttp.BodyText(res);
|
||||
|
||||
JObject json = null;
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
try { json = JObject.Parse(text); }
|
||||
catch { /* non-JSON body; handled below */ }
|
||||
}
|
||||
|
||||
bool httpOk = res?.Ok == true;
|
||||
|
||||
int code = 0;
|
||||
JToken codeTok = json?["code"];
|
||||
if (codeTok != null && codeTok.Type != JTokenType.Null)
|
||||
{
|
||||
try { code = codeTok.Value<int>(); } catch { code = -1; }
|
||||
}
|
||||
|
||||
if (!httpOk || code != 0)
|
||||
{
|
||||
string detail = json?["message"]?.ToString()
|
||||
?? json?["error"]?.ToString()
|
||||
?? ProviderHttp.Truncate(text);
|
||||
throw new Exception(SecretRedactor.Scrub(
|
||||
$"Tripo {phase} failed (status={res?.Status}, code={code}): {detail}", apiKey));
|
||||
}
|
||||
|
||||
return json ?? new JObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0761a0b205e4041950b0ab29a3d6466
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,157 @@
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
using MCPForUnity.Editor.Services.Transport.Transports;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges the editor UI to the active transport (HTTP with WebSocket push, or stdio).
|
||||
/// </summary>
|
||||
public class BridgeControlService : IBridgeControlService
|
||||
{
|
||||
private readonly TransportManager _transportManager;
|
||||
private TransportMode _preferredMode = TransportMode.Http;
|
||||
|
||||
public BridgeControlService()
|
||||
{
|
||||
_transportManager = MCPServiceLocator.TransportManager;
|
||||
}
|
||||
|
||||
private TransportMode ResolvePreferredMode()
|
||||
{
|
||||
bool useHttp = EditorConfigurationCache.Instance.UseHttpTransport;
|
||||
_preferredMode = useHttp ? TransportMode.Http : TransportMode.Stdio;
|
||||
return _preferredMode;
|
||||
}
|
||||
|
||||
private static BridgeVerificationResult BuildVerificationResult(TransportState state, TransportMode mode, bool pingSucceeded, string messageOverride = null, bool? handshakeOverride = null)
|
||||
{
|
||||
bool handshakeValid = handshakeOverride ?? (mode == TransportMode.Stdio ? state.IsConnected : true);
|
||||
string transportLabel = string.IsNullOrWhiteSpace(state.TransportName)
|
||||
? mode.ToString().ToLowerInvariant()
|
||||
: state.TransportName;
|
||||
string detailSuffix = string.IsNullOrWhiteSpace(state.Details) ? string.Empty : $" [{state.Details}]";
|
||||
string message = messageOverride
|
||||
?? state.Error
|
||||
?? (state.IsConnected ? $"Transport '{transportLabel}' connected{detailSuffix}" : $"Transport '{transportLabel}' disconnected{detailSuffix}");
|
||||
|
||||
return new BridgeVerificationResult
|
||||
{
|
||||
Success = pingSucceeded && handshakeValid,
|
||||
HandshakeValid = handshakeValid,
|
||||
PingSucceeded = pingSucceeded,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
public bool IsRunning
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
return _transportManager.IsRunning(mode);
|
||||
}
|
||||
}
|
||||
|
||||
public int CurrentPort
|
||||
{
|
||||
get
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
var state = _transportManager.GetState(mode);
|
||||
if (state.Port.HasValue)
|
||||
{
|
||||
return state.Port.Value;
|
||||
}
|
||||
|
||||
// Legacy fallback while the stdio bridge is still in play
|
||||
return StdioBridgeHost.GetCurrentPort();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAutoConnectMode => StdioBridgeHost.IsAutoConnectMode();
|
||||
public TransportMode? ActiveMode => ResolvePreferredMode();
|
||||
|
||||
public async Task<bool> StartAsync()
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
try
|
||||
{
|
||||
// Treat transports as mutually exclusive for user-driven session starts:
|
||||
// stop the *other* transport first to avoid duplicated sessions (e.g. stdio lingering when switching to HTTP).
|
||||
var otherMode = mode == TransportMode.Http ? TransportMode.Stdio : TransportMode.Http;
|
||||
try
|
||||
{
|
||||
await _transportManager.StopAsync(otherMode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Error stopping other transport ({otherMode}) before start: {ex.Message}");
|
||||
}
|
||||
|
||||
// Legacy safety: stdio may have been started outside TransportManager state.
|
||||
if (otherMode == TransportMode.Stdio)
|
||||
{
|
||||
try { StdioBridgeHost.Stop(); } catch { }
|
||||
}
|
||||
|
||||
bool started = await _transportManager.StartAsync(mode);
|
||||
if (!started)
|
||||
{
|
||||
McpLog.Warn($"Failed to start MCP transport: {mode}");
|
||||
}
|
||||
return started;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Error starting MCP transport {mode}: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
await _transportManager.StopAsync(mode);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Error stopping MCP transport: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<BridgeVerificationResult> VerifyAsync()
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
bool pingSucceeded = await _transportManager.VerifyAsync(mode);
|
||||
var state = _transportManager.GetState(mode);
|
||||
return BuildVerificationResult(state, mode, pingSucceeded);
|
||||
}
|
||||
|
||||
public BridgeVerificationResult Verify(int port)
|
||||
{
|
||||
var mode = ResolvePreferredMode();
|
||||
bool pingSucceeded = _transportManager.VerifyAsync(mode).GetAwaiter().GetResult();
|
||||
var state = _transportManager.GetState(mode);
|
||||
|
||||
if (mode == TransportMode.Stdio)
|
||||
{
|
||||
bool handshakeValid = state.IsConnected && port == CurrentPort;
|
||||
string message = handshakeValid
|
||||
? $"STDIO transport listening on port {CurrentPort}"
|
||||
: $"STDIO transport port mismatch (expected {CurrentPort}, got {port})";
|
||||
return BuildVerificationResult(state, mode, pingSucceeded && handshakeValid, message, handshakeValid);
|
||||
}
|
||||
|
||||
return BuildVerificationResult(state, mode, pingSucceeded);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ed4f9f69d84a945248dafc0f0b5a62dd
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,146 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using MCPForUnity.Editor.Clients;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Models;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of client configuration service
|
||||
/// </summary>
|
||||
public class ClientConfigurationService : IClientConfigurationService
|
||||
{
|
||||
private readonly List<IMcpClientConfigurator> configurators;
|
||||
|
||||
public ClientConfigurationService()
|
||||
{
|
||||
configurators = McpClientRegistry.All.ToList();
|
||||
}
|
||||
|
||||
public IReadOnlyList<IMcpClientConfigurator> GetAllClients() => configurators;
|
||||
|
||||
public void ConfigureClient(IMcpClientConfigurator configurator)
|
||||
{
|
||||
// When using a local server path, clean stale build artifacts first.
|
||||
// This prevents old deleted .py files from being picked up by Python's auto-discovery.
|
||||
if (AssetPathUtility.IsLocalServerPath())
|
||||
{
|
||||
AssetPathUtility.CleanLocalServerBuildArtifacts();
|
||||
}
|
||||
|
||||
ConfigureWithTransportCoercion(configurator);
|
||||
}
|
||||
|
||||
public ClientConfigurationSummary ConfigureAllDetectedClients()
|
||||
{
|
||||
// When using a local server path, clean stale build artifacts once before configuring all clients.
|
||||
if (AssetPathUtility.IsLocalServerPath())
|
||||
{
|
||||
AssetPathUtility.CleanLocalServerBuildArtifacts();
|
||||
}
|
||||
|
||||
var summary = new ClientConfigurationSummary();
|
||||
foreach (var configurator in configurators)
|
||||
{
|
||||
if (!configurator.IsInstalled)
|
||||
{
|
||||
summary.SkippedCount++;
|
||||
continue;
|
||||
}
|
||||
try
|
||||
{
|
||||
// Always re-run configuration so core fields stay current
|
||||
configurator.CheckStatus(attemptAutoRewrite: false);
|
||||
ConfigureWithTransportCoercion(configurator);
|
||||
summary.SuccessCount++;
|
||||
summary.Messages.Add($"✓ {configurator.DisplayName}: Configured successfully");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
summary.FailureCount++;
|
||||
summary.Messages.Add($"⚠ {configurator.DisplayName}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
private static void ConfigureWithTransportCoercion(IMcpClientConfigurator configurator)
|
||||
{
|
||||
bool originalHttp = EditorConfigurationCache.Instance.UseHttpTransport;
|
||||
try
|
||||
{
|
||||
CoerceTransportFor(configurator);
|
||||
configurator.Configure();
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorConfigurationCache.Instance.SetUseHttpTransport(originalHttp);
|
||||
}
|
||||
}
|
||||
|
||||
public bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true)
|
||||
{
|
||||
var previous = configurator.Status;
|
||||
var current = configurator.CheckStatus(attemptAutoRewrite);
|
||||
return current != previous;
|
||||
}
|
||||
|
||||
private static void CoerceTransportFor(IMcpClientConfigurator configurator)
|
||||
{
|
||||
var supported = configurator.SupportedTransports;
|
||||
if (supported == null || supported.Count == 0) return;
|
||||
|
||||
bool currentlyHttp = EditorConfigurationCache.Instance.UseHttpTransport;
|
||||
var requested = currentlyHttp ? ConfiguredTransport.Http : ConfiguredTransport.Stdio;
|
||||
|
||||
// Accept any HTTP variant (Http, HttpRemote) when the user wants HTTP — a client that
|
||||
// only supports HttpRemote should not get coerced to stdio just because Http isn't
|
||||
// explicitly listed.
|
||||
if (SupportsRequested(supported, requested)) return;
|
||||
|
||||
// Fall back in the direction of the user's intent: if they wanted HTTP, prefer any
|
||||
// HTTP variant the client does support; otherwise prefer stdio. Honors the
|
||||
// configurator's declared order when more than one option remains.
|
||||
ConfiguredTransport chosen = PickFallback(supported, requested);
|
||||
bool needHttp = IsHttpVariant(chosen);
|
||||
if (EditorConfigurationCache.Instance.UseHttpTransport != needHttp)
|
||||
{
|
||||
EditorConfigurationCache.Instance.SetUseHttpTransport(needHttp);
|
||||
McpLog.Info(
|
||||
$"[{configurator.DisplayName}] auto-selected {chosen} transport (client does not support {requested}).");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHttpVariant(ConfiguredTransport t)
|
||||
=> t == ConfiguredTransport.Http || t == ConfiguredTransport.HttpRemote;
|
||||
|
||||
private static bool SupportsRequested(IReadOnlyList<ConfiguredTransport> supported, ConfiguredTransport requested)
|
||||
{
|
||||
if (requested == ConfiguredTransport.Http)
|
||||
{
|
||||
foreach (var t in supported)
|
||||
if (IsHttpVariant(t)) return true;
|
||||
return false;
|
||||
}
|
||||
return supported.Contains(requested);
|
||||
}
|
||||
|
||||
private static ConfiguredTransport PickFallback(IReadOnlyList<ConfiguredTransport> supported, ConfiguredTransport requested)
|
||||
{
|
||||
if (requested == ConfiguredTransport.Http)
|
||||
{
|
||||
foreach (var t in supported)
|
||||
if (IsHttpVariant(t)) return t;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var t in supported)
|
||||
if (t == ConfiguredTransport.Stdio) return t;
|
||||
}
|
||||
return supported[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76cad34d10fd24aaa95c4583c1f88fdf
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,321 @@
|
||||
using System;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Centralized cache for frequently-read EditorPrefs values.
|
||||
/// Reduces scattered EditorPrefs.Get* calls and provides change notification.
|
||||
///
|
||||
/// Usage:
|
||||
/// var config = EditorConfigurationCache.Instance;
|
||||
/// if (config.UseHttpTransport) { ... }
|
||||
/// config.OnConfigurationChanged += (key) => { /* refresh UI */ };
|
||||
/// </summary>
|
||||
public class EditorConfigurationCache
|
||||
{
|
||||
private static EditorConfigurationCache _instance;
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Singleton instance. Thread-safe lazy initialization.
|
||||
/// </summary>
|
||||
public static EditorConfigurationCache Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EditorConfigurationCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event fired when any cached configuration value changes.
|
||||
/// The string parameter is the EditorPrefKeys constant name that changed.
|
||||
/// </summary>
|
||||
public event Action<string> OnConfigurationChanged;
|
||||
|
||||
// Cached values - most frequently read
|
||||
private bool _useHttpTransport;
|
||||
private bool _debugLogs;
|
||||
private bool _devModeForceServerRefresh;
|
||||
private string _uvxPathOverride;
|
||||
private string _gitUrlOverride;
|
||||
private string _httpBaseUrl;
|
||||
private string _httpRemoteBaseUrl;
|
||||
private string _claudeCliPathOverride;
|
||||
private string _httpTransportScope;
|
||||
private int _unitySocketPort;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to use HTTP transport (true) or Stdio transport (false).
|
||||
/// Default: true
|
||||
/// </summary>
|
||||
public bool UseHttpTransport => _useHttpTransport;
|
||||
|
||||
/// <summary>
|
||||
/// Whether debug logging is enabled.
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool DebugLogs => _debugLogs;
|
||||
|
||||
/// <summary>
|
||||
/// Whether to force server refresh in dev mode (--no-cache --refresh).
|
||||
/// Default: false
|
||||
/// </summary>
|
||||
public bool DevModeForceServerRefresh => _devModeForceServerRefresh;
|
||||
|
||||
/// <summary>
|
||||
/// Custom path override for uvx executable.
|
||||
/// Default: empty string (auto-detect)
|
||||
/// </summary>
|
||||
public string UvxPathOverride => _uvxPathOverride;
|
||||
|
||||
/// <summary>
|
||||
/// Custom Git URL override for server installation.
|
||||
/// Default: empty string (use default)
|
||||
/// </summary>
|
||||
public string GitUrlOverride => _gitUrlOverride;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP base URL for the local MCP server.
|
||||
/// Default: empty string
|
||||
/// </summary>
|
||||
public string HttpBaseUrl => _httpBaseUrl;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP base URL for the remote-hosted MCP server.
|
||||
/// Default: empty string
|
||||
/// </summary>
|
||||
public string HttpRemoteBaseUrl => _httpRemoteBaseUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Custom path override for Claude CLI executable.
|
||||
/// Default: empty string (auto-detect)
|
||||
/// </summary>
|
||||
public string ClaudeCliPathOverride => _claudeCliPathOverride;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP transport scope: "local" or "remote".
|
||||
/// Default: empty string
|
||||
/// </summary>
|
||||
public string HttpTransportScope => _httpTransportScope;
|
||||
|
||||
/// <summary>
|
||||
/// Unity socket port for Stdio transport.
|
||||
/// Default: 0 (auto-assign)
|
||||
/// </summary>
|
||||
public int UnitySocketPort => _unitySocketPort;
|
||||
|
||||
private EditorConfigurationCache()
|
||||
{
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh all cached values from EditorPrefs.
|
||||
/// Call this after bulk EditorPrefs changes or domain reload.
|
||||
/// </summary>
|
||||
public void Refresh()
|
||||
{
|
||||
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
|
||||
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
|
||||
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
|
||||
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
|
||||
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
|
||||
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
|
||||
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
|
||||
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
|
||||
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set UseHttpTransport and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetUseHttpTransport(bool value)
|
||||
{
|
||||
if (_useHttpTransport != value)
|
||||
{
|
||||
_useHttpTransport = value;
|
||||
EditorPrefs.SetBool(EditorPrefKeys.UseHttpTransport, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(UseHttpTransport));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set DebugLogs and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetDebugLogs(bool value)
|
||||
{
|
||||
if (_debugLogs != value)
|
||||
{
|
||||
_debugLogs = value;
|
||||
EditorPrefs.SetBool(EditorPrefKeys.DebugLogs, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(DebugLogs));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set DevModeForceServerRefresh and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetDevModeForceServerRefresh(bool value)
|
||||
{
|
||||
if (_devModeForceServerRefresh != value)
|
||||
{
|
||||
_devModeForceServerRefresh = value;
|
||||
EditorPrefs.SetBool(EditorPrefKeys.DevModeForceServerRefresh, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(DevModeForceServerRefresh));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set UvxPathOverride and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetUvxPathOverride(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_uvxPathOverride != value)
|
||||
{
|
||||
_uvxPathOverride = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(UvxPathOverride));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set GitUrlOverride and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetGitUrlOverride(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_gitUrlOverride != value)
|
||||
{
|
||||
_gitUrlOverride = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.GitUrlOverride, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(GitUrlOverride));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set HttpBaseUrl and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetHttpBaseUrl(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_httpBaseUrl != value)
|
||||
{
|
||||
_httpBaseUrl = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.HttpBaseUrl, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(HttpBaseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set HttpRemoteBaseUrl and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetHttpRemoteBaseUrl(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_httpRemoteBaseUrl != value)
|
||||
{
|
||||
_httpRemoteBaseUrl = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.HttpRemoteBaseUrl, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(HttpRemoteBaseUrl));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set ClaudeCliPathOverride and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetClaudeCliPathOverride(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_claudeCliPathOverride != value)
|
||||
{
|
||||
_claudeCliPathOverride = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(ClaudeCliPathOverride));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set HttpTransportScope and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetHttpTransportScope(string value)
|
||||
{
|
||||
value = value ?? string.Empty;
|
||||
if (_httpTransportScope != value)
|
||||
{
|
||||
_httpTransportScope = value;
|
||||
EditorPrefs.SetString(EditorPrefKeys.HttpTransportScope, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(HttpTransportScope));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set UnitySocketPort and update cache + EditorPrefs atomically.
|
||||
/// </summary>
|
||||
public void SetUnitySocketPort(int value)
|
||||
{
|
||||
if (_unitySocketPort != value)
|
||||
{
|
||||
_unitySocketPort = value;
|
||||
EditorPrefs.SetInt(EditorPrefKeys.UnitySocketPort, value);
|
||||
OnConfigurationChanged?.Invoke(nameof(UnitySocketPort));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Force refresh of a single cached value from EditorPrefs.
|
||||
/// Useful when external code modifies EditorPrefs directly.
|
||||
/// </summary>
|
||||
public void InvalidateKey(string keyName)
|
||||
{
|
||||
switch (keyName)
|
||||
{
|
||||
case nameof(UseHttpTransport):
|
||||
_useHttpTransport = EditorPrefs.GetBool(EditorPrefKeys.UseHttpTransport, true);
|
||||
break;
|
||||
case nameof(DebugLogs):
|
||||
_debugLogs = EditorPrefs.GetBool(EditorPrefKeys.DebugLogs, false);
|
||||
break;
|
||||
case nameof(DevModeForceServerRefresh):
|
||||
_devModeForceServerRefresh = EditorPrefs.GetBool(EditorPrefKeys.DevModeForceServerRefresh, false);
|
||||
break;
|
||||
case nameof(UvxPathOverride):
|
||||
_uvxPathOverride = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
|
||||
break;
|
||||
case nameof(GitUrlOverride):
|
||||
_gitUrlOverride = EditorPrefs.GetString(EditorPrefKeys.GitUrlOverride, string.Empty);
|
||||
break;
|
||||
case nameof(HttpBaseUrl):
|
||||
_httpBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpBaseUrl, string.Empty);
|
||||
break;
|
||||
case nameof(HttpRemoteBaseUrl):
|
||||
_httpRemoteBaseUrl = EditorPrefs.GetString(EditorPrefKeys.HttpRemoteBaseUrl, string.Empty);
|
||||
break;
|
||||
case nameof(ClaudeCliPathOverride):
|
||||
_claudeCliPathOverride = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
|
||||
break;
|
||||
case nameof(HttpTransportScope):
|
||||
_httpTransportScope = EditorPrefs.GetString(EditorPrefKeys.HttpTransportScope, string.Empty);
|
||||
break;
|
||||
case nameof(UnitySocketPort):
|
||||
_unitySocketPort = EditorPrefs.GetInt(EditorPrefKeys.UnitySocketPort, 0);
|
||||
break;
|
||||
}
|
||||
OnConfigurationChanged?.Invoke(keyName);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b4a183ac9b63c408886bce40ae58f462
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for managing the EditorPrefs window
|
||||
/// Follows the Class-level Singleton pattern
|
||||
/// </summary>
|
||||
public class EditorPrefsWindowService
|
||||
{
|
||||
private static EditorPrefsWindowService _instance;
|
||||
|
||||
/// <summary>
|
||||
/// Get the singleton instance
|
||||
/// </summary>
|
||||
public static EditorPrefsWindowService Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
throw new Exception("EditorPrefsWindowService not initialized");
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the service
|
||||
/// </summary>
|
||||
public static void Initialize()
|
||||
{
|
||||
if (_instance == null)
|
||||
{
|
||||
_instance = new EditorPrefsWindowService();
|
||||
}
|
||||
}
|
||||
|
||||
private EditorPrefsWindowService()
|
||||
{
|
||||
// Private constructor for singleton
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Show the EditorPrefs window
|
||||
/// </summary>
|
||||
public void ShowWindow()
|
||||
{
|
||||
EditorPrefsWindow.ShowWindow();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a1c6e4725a484c0abf10f6eaa1d8d5d
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,573 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using UnityEditorInternal;
|
||||
using UnityEditor.SceneManagement;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Maintains a cached readiness snapshot (v2) so status reads remain fast even when Unity is busy.
|
||||
/// Updated on the main thread via Editor callbacks and periodic update ticks.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class EditorStateCache
|
||||
{
|
||||
private static readonly object LockObj = new();
|
||||
private static long _sequence;
|
||||
private static long _observedUnixMs;
|
||||
|
||||
private static bool _lastIsCompiling;
|
||||
private static long? _lastCompileStartedUnixMs;
|
||||
private static long? _lastCompileFinishedUnixMs;
|
||||
|
||||
private static bool _domainReloadPending;
|
||||
private static long? _domainReloadBeforeUnixMs;
|
||||
private static long? _domainReloadAfterUnixMs;
|
||||
|
||||
private static double _lastUpdateTimeSinceStartup;
|
||||
private const double MinUpdateIntervalSeconds = 1.0; // Reduced frequency: 1s instead of 0.25s
|
||||
|
||||
// State tracking to detect when snapshot actually changes (checked BEFORE building)
|
||||
private static string _lastTrackedScenePath;
|
||||
private static string _lastTrackedSceneName;
|
||||
private static bool _lastTrackedIsFocused;
|
||||
private static bool _lastTrackedIsPlaying;
|
||||
private static bool _lastTrackedIsPaused;
|
||||
private static bool _lastTrackedIsUpdating;
|
||||
private static bool _lastTrackedTestsRunning;
|
||||
private static string _lastTrackedActivityPhase;
|
||||
|
||||
private static JObject _cached;
|
||||
|
||||
private sealed class EditorStateSnapshot
|
||||
{
|
||||
[JsonProperty("schema_version")]
|
||||
public string SchemaVersion { get; set; }
|
||||
|
||||
[JsonProperty("observed_at_unix_ms")]
|
||||
public long ObservedAtUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("sequence")]
|
||||
public long Sequence { get; set; }
|
||||
|
||||
[JsonProperty("unity")]
|
||||
public EditorStateUnity Unity { get; set; }
|
||||
|
||||
[JsonProperty("editor")]
|
||||
public EditorStateEditor Editor { get; set; }
|
||||
|
||||
[JsonProperty("activity")]
|
||||
public EditorStateActivity Activity { get; set; }
|
||||
|
||||
[JsonProperty("compilation")]
|
||||
public EditorStateCompilation Compilation { get; set; }
|
||||
|
||||
[JsonProperty("assets")]
|
||||
public EditorStateAssets Assets { get; set; }
|
||||
|
||||
[JsonProperty("tests")]
|
||||
public EditorStateTests Tests { get; set; }
|
||||
|
||||
[JsonProperty("transport")]
|
||||
public EditorStateTransport Transport { get; set; }
|
||||
|
||||
[JsonProperty("settings")]
|
||||
public EditorStateSettings Settings { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateUnity
|
||||
{
|
||||
[JsonProperty("instance_id")]
|
||||
public string InstanceId { get; set; }
|
||||
|
||||
[JsonProperty("unity_version")]
|
||||
public string UnityVersion { get; set; }
|
||||
|
||||
[JsonProperty("project_id")]
|
||||
public string ProjectId { get; set; }
|
||||
|
||||
[JsonProperty("platform")]
|
||||
public string Platform { get; set; }
|
||||
|
||||
[JsonProperty("is_batch_mode")]
|
||||
public bool? IsBatchMode { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateEditor
|
||||
{
|
||||
[JsonProperty("is_focused")]
|
||||
public bool? IsFocused { get; set; }
|
||||
|
||||
[JsonProperty("play_mode")]
|
||||
public EditorStatePlayMode PlayMode { get; set; }
|
||||
|
||||
[JsonProperty("active_scene")]
|
||||
public EditorStateActiveScene ActiveScene { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStatePlayMode
|
||||
{
|
||||
[JsonProperty("is_playing")]
|
||||
public bool? IsPlaying { get; set; }
|
||||
|
||||
[JsonProperty("is_paused")]
|
||||
public bool? IsPaused { get; set; }
|
||||
|
||||
[JsonProperty("is_changing")]
|
||||
public bool? IsChanging { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateActiveScene
|
||||
{
|
||||
[JsonProperty("path")]
|
||||
public string Path { get; set; }
|
||||
|
||||
[JsonProperty("guid")]
|
||||
public string Guid { get; set; }
|
||||
|
||||
[JsonProperty("name")]
|
||||
public string Name { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateActivity
|
||||
{
|
||||
[JsonProperty("phase")]
|
||||
public string Phase { get; set; }
|
||||
|
||||
[JsonProperty("since_unix_ms")]
|
||||
public long SinceUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("reasons")]
|
||||
public string[] Reasons { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateCompilation
|
||||
{
|
||||
[JsonProperty("is_compiling")]
|
||||
public bool? IsCompiling { get; set; }
|
||||
|
||||
[JsonProperty("is_domain_reload_pending")]
|
||||
public bool? IsDomainReloadPending { get; set; }
|
||||
|
||||
[JsonProperty("last_compile_started_unix_ms")]
|
||||
public long? LastCompileStartedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("last_compile_finished_unix_ms")]
|
||||
public long? LastCompileFinishedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("last_domain_reload_before_unix_ms")]
|
||||
public long? LastDomainReloadBeforeUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("last_domain_reload_after_unix_ms")]
|
||||
public long? LastDomainReloadAfterUnixMs { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateAssets
|
||||
{
|
||||
[JsonProperty("is_updating")]
|
||||
public bool? IsUpdating { get; set; }
|
||||
|
||||
[JsonProperty("external_changes_dirty")]
|
||||
public bool? ExternalChangesDirty { get; set; }
|
||||
|
||||
[JsonProperty("external_changes_last_seen_unix_ms")]
|
||||
public long? ExternalChangesLastSeenUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("external_changes_dirty_since_unix_ms")]
|
||||
public long? ExternalChangesDirtySinceUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("external_changes_last_cleared_unix_ms")]
|
||||
public long? ExternalChangesLastClearedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("refresh")]
|
||||
public EditorStateRefresh Refresh { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateRefresh
|
||||
{
|
||||
[JsonProperty("is_refresh_in_progress")]
|
||||
public bool? IsRefreshInProgress { get; set; }
|
||||
|
||||
[JsonProperty("last_refresh_requested_unix_ms")]
|
||||
public long? LastRefreshRequestedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("last_refresh_finished_unix_ms")]
|
||||
public long? LastRefreshFinishedUnixMs { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateTests
|
||||
{
|
||||
[JsonProperty("is_running")]
|
||||
public bool? IsRunning { get; set; }
|
||||
|
||||
[JsonProperty("mode")]
|
||||
public string Mode { get; set; }
|
||||
|
||||
[JsonProperty("current_job_id")]
|
||||
public string CurrentJobId { get; set; }
|
||||
|
||||
[JsonProperty("started_unix_ms")]
|
||||
public long? StartedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("started_by")]
|
||||
public string StartedBy { get; set; }
|
||||
|
||||
[JsonProperty("last_run")]
|
||||
public EditorStateLastRun LastRun { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateLastRun
|
||||
{
|
||||
[JsonProperty("finished_unix_ms")]
|
||||
public long? FinishedUnixMs { get; set; }
|
||||
|
||||
[JsonProperty("result")]
|
||||
public string Result { get; set; }
|
||||
|
||||
[JsonProperty("counts")]
|
||||
public object Counts { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateTransport
|
||||
{
|
||||
[JsonProperty("unity_bridge_connected")]
|
||||
public bool? UnityBridgeConnected { get; set; }
|
||||
|
||||
[JsonProperty("last_message_unix_ms")]
|
||||
public long? LastMessageUnixMs { get; set; }
|
||||
}
|
||||
|
||||
private sealed class EditorStateSettings
|
||||
{
|
||||
[JsonProperty("batch_execute_max_commands")]
|
||||
public int BatchExecuteMaxCommands { get; set; }
|
||||
}
|
||||
|
||||
static EditorStateCache()
|
||||
{
|
||||
try
|
||||
{
|
||||
_sequence = 0;
|
||||
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
_cached = BuildSnapshot("init");
|
||||
|
||||
EditorApplication.update += OnUpdate;
|
||||
EditorApplication.playModeStateChanged += _ => ForceUpdate("playmode");
|
||||
|
||||
// Tracks whether an assembly compilation is actually running, for
|
||||
// GetActualIsCompiling's Play-mode check. Statics reset on domain reload
|
||||
// and this [InitializeOnLoad] ctor re-subscribes, so the flag is per-domain.
|
||||
UnityEditor.Compilation.CompilationPipeline.compilationStarted += _ => _pipelineCompilationRunning = true;
|
||||
UnityEditor.Compilation.CompilationPipeline.compilationFinished += _ => _pipelineCompilationRunning = false;
|
||||
|
||||
AssemblyReloadEvents.beforeAssemblyReload += () =>
|
||||
{
|
||||
_domainReloadPending = true;
|
||||
_domainReloadBeforeUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
ForceUpdate("before_domain_reload");
|
||||
};
|
||||
AssemblyReloadEvents.afterAssemblyReload += () =>
|
||||
{
|
||||
_domainReloadPending = false;
|
||||
_domainReloadAfterUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
ForceUpdate("after_domain_reload");
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"[EditorStateCache] Failed to initialise: {ex.Message}\n{ex.StackTrace}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnUpdate()
|
||||
{
|
||||
// Throttle to reduce overhead while keeping the snapshot fresh enough for polling clients.
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
// Use GetActualIsCompiling() to avoid Play mode false positives (issue #582)
|
||||
bool isCompiling = GetActualIsCompiling();
|
||||
|
||||
// Check for compilation edge transitions (always update on these)
|
||||
bool compilationEdge = isCompiling != _lastIsCompiling;
|
||||
|
||||
if (!compilationEdge && now - _lastUpdateTimeSinceStartup < MinUpdateIntervalSeconds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Fast state-change detection BEFORE building snapshot.
|
||||
// This avoids the expensive BuildSnapshot() call entirely when nothing changed.
|
||||
// These checks are much cheaper than building a full JSON snapshot.
|
||||
var scene = EditorSceneManager.GetActiveScene();
|
||||
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
|
||||
string sceneName = scene.name ?? string.Empty;
|
||||
bool isFocused = InternalEditorUtility.isApplicationActive;
|
||||
bool isPlaying = EditorApplication.isPlaying;
|
||||
bool isPaused = EditorApplication.isPaused;
|
||||
bool isUpdating = EditorApplication.isUpdating;
|
||||
bool testsRunning = TestRunStatus.IsRunning;
|
||||
|
||||
var activityPhase = "idle";
|
||||
if (testsRunning)
|
||||
{
|
||||
activityPhase = "running_tests";
|
||||
}
|
||||
else if (isCompiling)
|
||||
{
|
||||
activityPhase = "compiling";
|
||||
}
|
||||
else if (_domainReloadPending)
|
||||
{
|
||||
activityPhase = "domain_reload";
|
||||
}
|
||||
else if (isUpdating)
|
||||
{
|
||||
activityPhase = "asset_import";
|
||||
}
|
||||
else if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
{
|
||||
activityPhase = "playmode_transition";
|
||||
}
|
||||
|
||||
bool hasChanges = compilationEdge
|
||||
|| _lastTrackedScenePath != scenePath
|
||||
|| _lastTrackedSceneName != sceneName
|
||||
|| _lastTrackedIsFocused != isFocused
|
||||
|| _lastTrackedIsPlaying != isPlaying
|
||||
|| _lastTrackedIsPaused != isPaused
|
||||
|| _lastTrackedIsUpdating != isUpdating
|
||||
|| _lastTrackedTestsRunning != testsRunning
|
||||
|| _lastTrackedActivityPhase != activityPhase;
|
||||
|
||||
if (!hasChanges)
|
||||
{
|
||||
// No state change - skip the expensive BuildSnapshot entirely.
|
||||
// This is the key optimization that prevents the 28ms GC spikes.
|
||||
return;
|
||||
}
|
||||
|
||||
// Update tracked state
|
||||
_lastTrackedScenePath = scenePath;
|
||||
_lastTrackedSceneName = sceneName;
|
||||
_lastTrackedIsFocused = isFocused;
|
||||
_lastTrackedIsPlaying = isPlaying;
|
||||
_lastTrackedIsPaused = isPaused;
|
||||
_lastTrackedIsUpdating = isUpdating;
|
||||
_lastTrackedTestsRunning = testsRunning;
|
||||
_lastTrackedActivityPhase = activityPhase;
|
||||
|
||||
_lastUpdateTimeSinceStartup = now;
|
||||
ForceUpdate("tick");
|
||||
}
|
||||
|
||||
private static void ForceUpdate(string reason)
|
||||
{
|
||||
lock (LockObj)
|
||||
{
|
||||
_cached = BuildSnapshot(reason);
|
||||
}
|
||||
}
|
||||
|
||||
private static JObject BuildSnapshot(string reason)
|
||||
{
|
||||
_sequence++;
|
||||
_observedUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
bool isCompiling = GetActualIsCompiling();
|
||||
if (isCompiling && !_lastIsCompiling)
|
||||
{
|
||||
_lastCompileStartedUnixMs = _observedUnixMs;
|
||||
}
|
||||
else if (!isCompiling && _lastIsCompiling)
|
||||
{
|
||||
_lastCompileFinishedUnixMs = _observedUnixMs;
|
||||
}
|
||||
_lastIsCompiling = isCompiling;
|
||||
|
||||
var scene = EditorSceneManager.GetActiveScene();
|
||||
string scenePath = string.IsNullOrEmpty(scene.path) ? null : scene.path;
|
||||
string sceneGuid = !string.IsNullOrEmpty(scenePath) ? AssetDatabase.AssetPathToGUID(scenePath) : null;
|
||||
|
||||
bool testsRunning = TestRunStatus.IsRunning;
|
||||
var testsMode = TestRunStatus.Mode?.ToString();
|
||||
string currentJobId = TestJobManager.CurrentJobId;
|
||||
bool isFocused = InternalEditorUtility.isApplicationActive;
|
||||
|
||||
var activityPhase = "idle";
|
||||
if (testsRunning)
|
||||
{
|
||||
activityPhase = "running_tests";
|
||||
}
|
||||
else if (isCompiling)
|
||||
{
|
||||
activityPhase = "compiling";
|
||||
}
|
||||
else if (_domainReloadPending)
|
||||
{
|
||||
activityPhase = "domain_reload";
|
||||
}
|
||||
else if (EditorApplication.isUpdating)
|
||||
{
|
||||
activityPhase = "asset_import";
|
||||
}
|
||||
else if (EditorApplication.isPlayingOrWillChangePlaymode)
|
||||
{
|
||||
activityPhase = "playmode_transition";
|
||||
}
|
||||
|
||||
var snapshot = new EditorStateSnapshot
|
||||
{
|
||||
SchemaVersion = "unity-mcp/editor_state@2",
|
||||
ObservedAtUnixMs = _observedUnixMs,
|
||||
Sequence = _sequence,
|
||||
Unity = new EditorStateUnity
|
||||
{
|
||||
InstanceId = null,
|
||||
UnityVersion = Application.unityVersion,
|
||||
ProjectId = null,
|
||||
Platform = Application.platform.ToString(),
|
||||
IsBatchMode = Application.isBatchMode
|
||||
},
|
||||
Editor = new EditorStateEditor
|
||||
{
|
||||
IsFocused = isFocused,
|
||||
PlayMode = new EditorStatePlayMode
|
||||
{
|
||||
IsPlaying = EditorApplication.isPlaying,
|
||||
IsPaused = EditorApplication.isPaused,
|
||||
IsChanging = EditorApplication.isPlayingOrWillChangePlaymode
|
||||
},
|
||||
ActiveScene = new EditorStateActiveScene
|
||||
{
|
||||
Path = scenePath,
|
||||
Guid = sceneGuid,
|
||||
Name = scene.name ?? string.Empty
|
||||
}
|
||||
},
|
||||
Activity = new EditorStateActivity
|
||||
{
|
||||
Phase = activityPhase,
|
||||
SinceUnixMs = _observedUnixMs,
|
||||
Reasons = new[] { reason }
|
||||
},
|
||||
Compilation = new EditorStateCompilation
|
||||
{
|
||||
IsCompiling = isCompiling,
|
||||
IsDomainReloadPending = _domainReloadPending,
|
||||
LastCompileStartedUnixMs = _lastCompileStartedUnixMs,
|
||||
LastCompileFinishedUnixMs = _lastCompileFinishedUnixMs,
|
||||
LastDomainReloadBeforeUnixMs = _domainReloadBeforeUnixMs,
|
||||
LastDomainReloadAfterUnixMs = _domainReloadAfterUnixMs
|
||||
},
|
||||
Assets = new EditorStateAssets
|
||||
{
|
||||
IsUpdating = EditorApplication.isUpdating,
|
||||
ExternalChangesDirty = false,
|
||||
ExternalChangesLastSeenUnixMs = null,
|
||||
ExternalChangesDirtySinceUnixMs = null,
|
||||
ExternalChangesLastClearedUnixMs = null,
|
||||
Refresh = new EditorStateRefresh
|
||||
{
|
||||
IsRefreshInProgress = false,
|
||||
LastRefreshRequestedUnixMs = null,
|
||||
LastRefreshFinishedUnixMs = null
|
||||
}
|
||||
},
|
||||
Tests = new EditorStateTests
|
||||
{
|
||||
IsRunning = testsRunning,
|
||||
Mode = testsMode,
|
||||
CurrentJobId = string.IsNullOrEmpty(currentJobId) ? null : currentJobId,
|
||||
StartedUnixMs = TestRunStatus.StartedUnixMs,
|
||||
StartedBy = "unknown",
|
||||
LastRun = TestRunStatus.FinishedUnixMs.HasValue
|
||||
? new EditorStateLastRun
|
||||
{
|
||||
FinishedUnixMs = TestRunStatus.FinishedUnixMs,
|
||||
Result = "unknown",
|
||||
Counts = null
|
||||
}
|
||||
: null
|
||||
},
|
||||
Transport = new EditorStateTransport
|
||||
{
|
||||
UnityBridgeConnected = null,
|
||||
LastMessageUnixMs = null
|
||||
},
|
||||
Settings = new EditorStateSettings
|
||||
{
|
||||
BatchExecuteMaxCommands = Tools.BatchExecute.GetMaxCommandsPerBatch()
|
||||
}
|
||||
};
|
||||
|
||||
return JObject.FromObject(snapshot);
|
||||
}
|
||||
|
||||
public static JObject GetSnapshot()
|
||||
{
|
||||
lock (LockObj)
|
||||
{
|
||||
// Defensive: if something went wrong early, rebuild once.
|
||||
if (_cached == null)
|
||||
{
|
||||
_cached = BuildSnapshot("rebuild");
|
||||
}
|
||||
|
||||
// Always return a fresh clone to prevent mutation bugs.
|
||||
// The main GC optimization comes from state-change detection (OnUpdate)
|
||||
// which prevents unnecessary _cached rebuilds, not from caching the clone.
|
||||
var clone = (JObject)_cached.DeepClone();
|
||||
|
||||
// When Unity is backgrounded, OnUpdate is throttled and the
|
||||
// cached timestamp grows stale even though the data is current.
|
||||
// Re-stamp only in that case so the server-side staleness check
|
||||
// still fires for genuinely unresponsive editors when focused.
|
||||
if (!InternalEditorUtility.isApplicationActive)
|
||||
{
|
||||
clone["observed_at_unix_ms"] = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
}
|
||||
|
||||
// Set/cleared by the CompilationPipeline.compilationStarted/Finished events
|
||||
// subscribed in the static ctor. NOTE: CompilationPipeline.isCompiling does not
|
||||
// exist on the supported Unity range (verified by reflection probe on 2021.3 and
|
||||
// 6000.4 — neither public nor non-public), so the reflection this replaced never
|
||||
// resolved and always fell back to the raw signal.
|
||||
private static bool _pipelineCompilationRunning;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the actual compilation state, working around a known Unity quirk where
|
||||
/// EditorApplication.isCompiling can return false positives in Play mode (e.g. a
|
||||
/// recompile deferred by Recompile-After-Finished-Playing keeps it true for the
|
||||
/// whole play session). See: https://github.com/CoplayDev/unity-mcp/issues/549
|
||||
/// </summary>
|
||||
internal static bool GetActualIsCompiling()
|
||||
{
|
||||
// If EditorApplication.isCompiling is false, Unity is definitely not compiling
|
||||
if (!EditorApplication.isCompiling)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// In Play mode, trust the event-tracked pipeline state instead: a deferred
|
||||
// recompile keeps EditorApplication.isCompiling true without any compilation
|
||||
// actually running.
|
||||
if (EditorApplication.isPlaying)
|
||||
{
|
||||
return _pipelineCompilationRunning;
|
||||
}
|
||||
|
||||
// Outside Play mode the raw signal is reliable.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aa7909967ce3c48c493181c978782a54
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,357 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Automatically starts the HTTP MCP bridge on editor load when the user has opted in
|
||||
/// via the "Auto-Start on Editor Load" toggle in Advanced Settings.
|
||||
/// This complements HttpBridgeReloadHandler (which only resumes after domain reloads).
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class HttpAutoStartHandler
|
||||
{
|
||||
internal const string SessionInitKey = "HttpAutoStartHandler.SessionInitialized";
|
||||
|
||||
// Set while AutoStartAsync is in flight. A domain reload kills the in-flight task but
|
||||
// leaves this set, so the next domain load can finish the connect phase without
|
||||
// re-spawning the server (StartLocalHttpServer would first stop a still-booting process).
|
||||
internal const string ConnectPendingKey = "HttpAutoStartHandler.ConnectPending";
|
||||
|
||||
// Bounds the per-frame retry when editor services keep throwing on a fresh launch.
|
||||
// Plain static, so every domain reload grants a fresh budget.
|
||||
private const int MaxServiceNotReadyRetries = 300;
|
||||
private static int _serviceNotReadyRetries;
|
||||
|
||||
internal enum TickDecision
|
||||
{
|
||||
DeferBusy,
|
||||
DeferToResume,
|
||||
Skip,
|
||||
ShouldStart,
|
||||
ShouldReconnect,
|
||||
}
|
||||
|
||||
static HttpAutoStartHandler()
|
||||
{
|
||||
if (Application.isBatchMode &&
|
||||
string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH")))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool latched = SessionState.GetBool(SessionInitKey, false);
|
||||
bool connectPending = SessionState.GetBool(ConnectPendingKey, false);
|
||||
|
||||
// Cheap pre-check so the common case (auto-start off, nothing pending) costs one
|
||||
// EditorPrefs read per domain load instead of an update subscription. The pref is
|
||||
// re-read every domain load, so enabling it takes effect at the next reload.
|
||||
if (!latched && !connectPending &&
|
||||
!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Latched with nothing pending: this session already auto-started.
|
||||
if (latched && !connectPending)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Pending EditorApplication.delayCall/update delegates are wiped by domain reloads,
|
||||
// so the deferred work must NOT latch up front — latching eagerly killed auto-start
|
||||
// for the whole session whenever startup included a compile (#1229). An update tick
|
||||
// is reload-safe because this ctor re-arms it on every domain load.
|
||||
EditorApplication.update += WaitForEditorReady;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drops a reload-interrupted auto-start connect. Called when the user takes manual
|
||||
/// control of the bridge lifecycle, so no later domain load revives the connect.
|
||||
/// </summary>
|
||||
internal static void CancelPendingReconnect() => SessionState.EraseBool(ConnectPendingKey);
|
||||
|
||||
private static void WaitForEditorReady()
|
||||
{
|
||||
switch (TickCore(HttpBridgeReloadHandler.IsEditorBusy()))
|
||||
{
|
||||
case TickDecision.DeferBusy:
|
||||
case TickDecision.DeferToResume:
|
||||
return; // stay registered, try again next tick
|
||||
|
||||
case TickDecision.Skip:
|
||||
EditorApplication.update -= WaitForEditorReady;
|
||||
return;
|
||||
|
||||
case TickDecision.ShouldStart:
|
||||
if (!TryBeginAutoStart())
|
||||
{
|
||||
DeferOrGiveUp();
|
||||
return;
|
||||
}
|
||||
SessionState.SetBool(SessionInitKey, true);
|
||||
EditorApplication.update -= WaitForEditorReady;
|
||||
return;
|
||||
|
||||
case TickDecision.ShouldReconnect:
|
||||
if (!TryBeginReconnect())
|
||||
{
|
||||
DeferOrGiveUp();
|
||||
return;
|
||||
}
|
||||
EditorApplication.update -= WaitForEditorReady;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Services may not be initialized on the first frames of a fresh launch; retry next
|
||||
// tick, but not forever — a persistently broken environment shouldn't churn exceptions
|
||||
// every frame for the whole session. A later domain reload retries with a fresh budget.
|
||||
private static void DeferOrGiveUp()
|
||||
{
|
||||
if (++_serviceNotReadyRetries < MaxServiceNotReadyRetries) return;
|
||||
EditorApplication.update -= WaitForEditorReady;
|
||||
McpLog.Warn("[HTTP Auto-Start] Editor services unavailable; giving up until the next domain reload");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision core for the editor-ready tick, separated so EditMode tests can drive it
|
||||
/// without spawning servers. Never writes the session latch — the caller latches only
|
||||
/// after the start work actually dispatches.
|
||||
/// </summary>
|
||||
internal static TickDecision TickCore(bool editorBusy)
|
||||
{
|
||||
if (editorBusy) return TickDecision.DeferBusy;
|
||||
|
||||
bool connectPending = SessionState.GetBool(ConnectPendingKey, false);
|
||||
if (!connectPending)
|
||||
{
|
||||
if (SessionState.GetBool(SessionInitKey, false)) return TickDecision.Skip;
|
||||
|
||||
// Only check lightweight EditorPrefs here — heavier services are touched in
|
||||
// TryBeginAutoStart once the editor is idle. No latch when disabled: the pref
|
||||
// is re-read on the next domain load.
|
||||
if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return TickDecision.Skip;
|
||||
}
|
||||
|
||||
// A pending reload-resume owns bridge revival — checked only when we would
|
||||
// otherwise act, so a plain Skip never waits out the resume window.
|
||||
if (HttpBridgeReloadHandler.IsResumePending) return TickDecision.DeferToResume;
|
||||
|
||||
return connectPending ? TickDecision.ShouldReconnect : TickDecision.ShouldStart;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns true when the auto-start decision was actually made (including deliberate
|
||||
/// early-outs). Returns false when services were not ready yet, so the caller leaves
|
||||
/// the session latch unset and retries instead of consuming it.
|
||||
/// </summary>
|
||||
private static bool TryBeginAutoStart()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!EditorConfigurationCache.Instance.UseHttpTransport) return true;
|
||||
|
||||
// Don't auto-start if bridge is already running.
|
||||
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return true;
|
||||
|
||||
_ = AutoStartAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns false when services were not ready yet (caller retries). On true the
|
||||
/// pending reconnect was either dispatched or deliberately dropped (auto-start
|
||||
/// disabled, transport switched, bridge already running).
|
||||
/// </summary>
|
||||
internal static bool TryBeginReconnect()
|
||||
{
|
||||
bool proceed;
|
||||
try
|
||||
{
|
||||
proceed = EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)
|
||||
&& EditorConfigurationCache.Instance.UseHttpTransport
|
||||
&& !MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Debug($"[HTTP Auto-Start] Services not ready: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!proceed)
|
||||
{
|
||||
SessionState.EraseBool(ConnectPendingKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
_ = ReconnectAsync();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static async Task AutoStartAsync()
|
||||
{
|
||||
SessionState.SetBool(ConnectPendingKey, true);
|
||||
try
|
||||
{
|
||||
bool isLocal = !HttpEndpointUtility.IsRemoteScope();
|
||||
|
||||
if (isLocal)
|
||||
{
|
||||
// For HTTP Local: launch the server process first, then connect the bridge.
|
||||
// This mirrors what the UI "Start Server" button does.
|
||||
if (!HttpEndpointUtility.IsHttpLocalUrlAllowedForLaunch(
|
||||
HttpEndpointUtility.GetLocalBaseUrl(), out string policyError))
|
||||
{
|
||||
McpLog.Debug($"[HTTP Auto-Start] Local URL blocked by security policy: {policyError}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if server is already reachable (e.g. user started it externally).
|
||||
if (!MCPServiceLocator.Server.IsLocalHttpServerReachable())
|
||||
{
|
||||
bool serverStarted = MCPServiceLocator.Server.StartLocalHttpServer(quiet: true);
|
||||
if (!serverStarted)
|
||||
{
|
||||
McpLog.Warn("[HTTP Auto-Start] Failed to start local HTTP server");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the server to become reachable, then connect.
|
||||
await WaitForServerAndConnectAsync();
|
||||
}
|
||||
else
|
||||
{
|
||||
// For HTTP Remote: server is external, just connect the bridge.
|
||||
await ConnectBridgeAsync();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[HTTP Auto-Start] Failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Reached on every terminal outcome. A domain reload that kills the task
|
||||
// mid-flight skips this, leaving the key set for the reconnect path.
|
||||
SessionState.EraseBool(ConnectPendingKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finishes an auto-start whose connect phase was killed by a domain reload.
|
||||
/// Connect-only: never spawns a server — the previous domain already did.
|
||||
/// </summary>
|
||||
private static async Task ReconnectAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (HttpEndpointUtility.IsRemoteScope())
|
||||
{
|
||||
await ConnectBridgeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await WaitForServerAndConnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"[HTTP Auto-Start] Post-reload reconnect failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
SessionState.EraseBool(ConnectPendingKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the local HTTP server to accept connections, then connects the bridge.
|
||||
/// Mirrors TryAutoStartSessionAsync in McpConnectionSection: while a managed launch
|
||||
/// process is alive, keep polling reachability and declare failure only when it exits
|
||||
/// without the port coming up. Without a launch handle (post-reload reconnect, or a
|
||||
/// server started externally) there is nothing to watch die, so poll to the hard cap.
|
||||
/// </summary>
|
||||
private static async Task WaitForServerAndConnectAsync()
|
||||
{
|
||||
var server = MCPServiceLocator.Server;
|
||||
string url = HttpEndpointUtility.GetLocalBaseUrl();
|
||||
var pollDelay = TimeSpan.FromMilliseconds(500);
|
||||
var hardCap = TimeSpan.FromMinutes(5);
|
||||
double startTime = EditorApplication.timeSinceStartup;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Abort if user changed settings while we were waiting.
|
||||
if (!EditorPrefs.GetBool(EditorPrefKeys.AutoStartOnLoad, false)) return;
|
||||
if (!EditorConfigurationCache.Instance.UseHttpTransport) return;
|
||||
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http)) return;
|
||||
|
||||
if (server.IsLocalHttpServerReachable())
|
||||
{
|
||||
McpLog.Info($"Server ready on {url}");
|
||||
bool started = await MCPServiceLocator.Bridge.StartAsync();
|
||||
if (started)
|
||||
{
|
||||
McpLog.Info("Session connected");
|
||||
MCPForUnityEditorWindow.RequestHealthVerification();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
double elapsed = EditorApplication.timeSinceStartup - startTime;
|
||||
bool launchProcessDied = server.HasManagedServerLaunchHandle
|
||||
&& !server.IsManagedServerLaunchProcessAlive()
|
||||
&& elapsed > 1.0;
|
||||
|
||||
if (launchProcessDied || elapsed > hardCap.TotalSeconds)
|
||||
{
|
||||
// Last-resort connect attempt in case reachability detection missed a live server.
|
||||
if (await MCPServiceLocator.Bridge.StartAsync())
|
||||
{
|
||||
McpLog.Info("Session connected");
|
||||
MCPForUnityEditorWindow.RequestHealthVerification();
|
||||
return;
|
||||
}
|
||||
|
||||
server.LogLocalHttpServerLaunchFailure();
|
||||
return;
|
||||
}
|
||||
|
||||
try { await Task.Delay(pollDelay); }
|
||||
catch { return; }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Connects the bridge directly (for remote HTTP where the server is already running).
|
||||
/// </summary>
|
||||
private static async Task ConnectBridgeAsync()
|
||||
{
|
||||
string url = HttpEndpointUtility.GetRemoteBaseUrl();
|
||||
McpLog.Info($"Connecting to {url}…");
|
||||
bool started = await MCPServiceLocator.Bridge.StartAsync();
|
||||
if (started)
|
||||
{
|
||||
McpLog.Info("Connected");
|
||||
MCPForUnityEditorWindow.RequestHealthVerification();
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn("Connection failed: could not connect to remote HTTP server");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3d8f1790992fe0742938d8a879056ee6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,223 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
using MCPForUnity.Editor.Windows;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures HTTP transports resume after domain reloads similar to the legacy stdio bridge.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class HttpBridgeReloadHandler
|
||||
{
|
||||
// SessionState, not EditorPrefs: it survives domain reloads but dies with the editor
|
||||
// process and is per-editor-instance. EditorPrefs is per-user machine-global, so a
|
||||
// second open editor could consume or delete this editor's pending resume, and a
|
||||
// crash mid-compile would leave a stale flag that resurrects the bridge on the next
|
||||
// launch (#1229).
|
||||
internal const string ResumeSessionKey = "MCPForUnity.ResumeHttpAfterReload";
|
||||
|
||||
private static readonly TimeSpan[] ResumeRetrySchedule =
|
||||
{
|
||||
TimeSpan.Zero,
|
||||
TimeSpan.FromSeconds(1),
|
||||
TimeSpan.FromSeconds(3),
|
||||
TimeSpan.FromSeconds(5),
|
||||
TimeSpan.FromSeconds(10),
|
||||
TimeSpan.FromSeconds(30)
|
||||
};
|
||||
|
||||
static HttpBridgeReloadHandler()
|
||||
{
|
||||
// Migration: the flag lived in EditorPrefs before it moved to SessionState — the
|
||||
// key STRING is shared, so renaming ResumeSessionKey would silently break this
|
||||
// cleanup. Once per session, not per reload: the EditorPrefs key is machine-global,
|
||||
// and an older-version editor open concurrently still uses it for its own resume.
|
||||
// Safe to delete this block a few releases after v10.
|
||||
const string migratedKey = "MCPForUnity.ResumeHttpAfterReload.Migrated";
|
||||
if (!SessionState.GetBool(migratedKey, false))
|
||||
{
|
||||
EditorPrefs.DeleteKey(ResumeSessionKey);
|
||||
SessionState.SetBool(migratedKey, true);
|
||||
}
|
||||
|
||||
AssemblyReloadEvents.beforeAssemblyReload += OnBeforeAssemblyReload;
|
||||
AssemblyReloadEvents.afterAssemblyReload += OnAfterAssemblyReload;
|
||||
}
|
||||
|
||||
internal static bool IsResumePending => SessionState.GetBool(ResumeSessionKey, false);
|
||||
|
||||
/// <summary>
|
||||
/// Drops a pending reload-resume. Called when the user takes manual control of the
|
||||
/// bridge lifecycle (Connect, End Session, transport switch, orphan cleanup); the
|
||||
/// retry loop re-checks the flag per attempt, so this also aborts an in-flight loop.
|
||||
/// </summary>
|
||||
internal static void CancelPendingResume() => SessionState.EraseBool(ResumeSessionKey);
|
||||
|
||||
private static void OnBeforeAssemblyReload()
|
||||
{
|
||||
try
|
||||
{
|
||||
OnBeforeAssemblyReloadCore(MCPServiceLocator.TransportManager);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Failed to evaluate HTTP bridge reload state: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void OnBeforeAssemblyReloadCore(TransportManager transport)
|
||||
{
|
||||
if (transport.IsRunning(TransportMode.Http))
|
||||
{
|
||||
SessionState.SetBool(ResumeSessionKey, true);
|
||||
|
||||
// beforeAssemblyReload is synchronous; force a synchronous teardown so we do not
|
||||
// leave an orphaned socket due to an unfinished async close handshake.
|
||||
transport.ForceStop(TransportMode.Http);
|
||||
}
|
||||
// When the bridge is not running, leave any pending flag alone: during a multi-pass
|
||||
// compile the next reload lands before the deferred resume ran, and deleting the
|
||||
// flag here is what used to lose the resume permanently (#1229). Explicit cancel
|
||||
// paths (End Session, transport switch, orphan cleanup) erase the flag instead.
|
||||
}
|
||||
|
||||
private static void OnAfterAssemblyReload()
|
||||
{
|
||||
if (OnAfterAssemblyReloadCore())
|
||||
{
|
||||
EditorApplication.update += ResumeTick;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decision core, separated so EditMode tests can drive it. Returns true when a resume
|
||||
/// should be scheduled. Does not consume the flag — it survives until the resume
|
||||
/// succeeds, is cancelled, or exhausts its retries, so a further reload in the middle
|
||||
/// of any deferral re-enters here instead of losing the resume.
|
||||
/// </summary>
|
||||
internal static bool OnAfterAssemblyReloadCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!SessionState.GetBool(ResumeSessionKey, false)) return false;
|
||||
|
||||
// Only resume HTTP if it is still the selected transport.
|
||||
if (!EditorConfigurationCache.Instance.UseHttpTransport)
|
||||
{
|
||||
SessionState.EraseBool(ResumeSessionKey);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Transport-config read failed (services racing the reload boundary): schedule
|
||||
// the resume anyway rather than dropping it — the retry loop re-checks the
|
||||
// transport per attempt and erases the flag itself on cancel/switch/exhaustion,
|
||||
// so a stale flag cannot wedge the session.
|
||||
McpLog.Warn($"Failed to read HTTP bridge reload flag: {ex.Message}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResumeTick()
|
||||
{
|
||||
if (IsEditorBusy()) return;
|
||||
EditorApplication.update -= ResumeTick;
|
||||
_ = ResumeHttpWithRetriesAsync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Busy gate for the deferral ticks. Uses the #549-aware compiling check because raw
|
||||
/// EditorApplication.isCompiling stays true for a whole play session under the
|
||||
/// "Recompile After Finished Playing" preference, which would block resume until
|
||||
/// play mode exits.
|
||||
/// </summary>
|
||||
internal static bool IsEditorBusy()
|
||||
=> EditorStateCache.GetActualIsCompiling() || EditorApplication.isUpdating;
|
||||
|
||||
// scheduleOverride lets EditMode tests pass an all-zero schedule so the loop
|
||||
// completes synchronously (the test framework floor cannot run async tests).
|
||||
internal static async Task ResumeHttpWithRetriesAsync(TimeSpan[] scheduleOverride = null)
|
||||
{
|
||||
TimeSpan[] schedule = scheduleOverride ?? ResumeRetrySchedule;
|
||||
Exception lastException = null;
|
||||
|
||||
for (int i = 0; i < schedule.Length; i++)
|
||||
{
|
||||
int attempt = i + 1;
|
||||
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt}/{schedule.Length}");
|
||||
|
||||
TimeSpan delay = schedule[i];
|
||||
if (delay > TimeSpan.Zero)
|
||||
{
|
||||
McpLog.Debug($"[HTTP Reload] Waiting {delay.TotalSeconds:0.#}s before resume attempt {attempt}");
|
||||
try { await Task.Delay(delay); }
|
||||
catch { return; }
|
||||
}
|
||||
|
||||
// The flag doubles as the cancel signal (see CancelPendingResume).
|
||||
if (!IsResumePending) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Inside the attempt try: a service read racing the reload boundary must
|
||||
// burn a retry, not kill this fire-and-forget task with the flag still set
|
||||
// (which would leave nothing scheduled to consume it until the next reload).
|
||||
|
||||
// Abort retries if the user switched transports while we were waiting.
|
||||
if (!EditorConfigurationCache.Instance.UseHttpTransport)
|
||||
{
|
||||
SessionState.EraseBool(ResumeSessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// Never bounce a session someone else established while we were waiting
|
||||
// (WebSocketTransportClient.StartAsync tears down a live connection first).
|
||||
if (MCPServiceLocator.TransportManager.IsRunning(TransportMode.Http))
|
||||
{
|
||||
SessionState.EraseBool(ResumeSessionKey);
|
||||
return;
|
||||
}
|
||||
|
||||
bool started = await MCPServiceLocator.TransportManager.StartAsync(TransportMode.Http);
|
||||
if (started)
|
||||
{
|
||||
SessionState.EraseBool(ResumeSessionKey);
|
||||
McpLog.Debug($"[HTTP Reload] Resume succeeded on attempt {attempt}");
|
||||
MCPForUnityEditorWindow.RequestHealthVerification();
|
||||
return;
|
||||
}
|
||||
|
||||
var state = MCPServiceLocator.TransportManager.GetState(TransportMode.Http);
|
||||
string reason = string.IsNullOrWhiteSpace(state?.Error) ? "no error detail" : state.Error;
|
||||
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} failed: {reason}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastException = ex;
|
||||
McpLog.Debug($"[HTTP Reload] Resume attempt {attempt} threw: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Exhausted: erase the flag so later reload boundaries don't replay this failure
|
||||
// loop for the rest of the session. This cannot swallow a multi-pass resume — a
|
||||
// reload mid-loop kills the task before this line, leaving the flag set.
|
||||
SessionState.EraseBool(ResumeSessionKey);
|
||||
|
||||
if (lastException != null)
|
||||
{
|
||||
McpLog.Warn($"Failed to resume HTTP MCP bridge after domain reload: {lastException.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
McpLog.Warn("Failed to resume HTTP MCP bridge after domain reload");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4c0cf970a7b494a659be151dc0124296
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for controlling the MCP for Unity Bridge connection
|
||||
/// </summary>
|
||||
public interface IBridgeControlService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets whether the bridge is currently running
|
||||
/// </summary>
|
||||
bool IsRunning { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current port the bridge is listening on
|
||||
/// </summary>
|
||||
int CurrentPort { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the bridge is in auto-connect mode
|
||||
/// </summary>
|
||||
bool IsAutoConnectMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the currently active transport mode, if any
|
||||
/// </summary>
|
||||
TransportMode? ActiveMode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
/// <returns>True if the bridge started successfully</returns>
|
||||
Task<bool> StartAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Stops the MCP for Unity Bridge asynchronously
|
||||
/// </summary>
|
||||
Task StopAsync();
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the bridge connection by sending a ping and waiting for a pong response
|
||||
/// </summary>
|
||||
/// <param name="port">The port to verify</param>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
BridgeVerificationResult Verify(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the connection asynchronously (works for both HTTP and stdio transports)
|
||||
/// </summary>
|
||||
/// <returns>Verification result with detailed status</returns>
|
||||
Task<BridgeVerificationResult> VerifyAsync();
|
||||
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of a bridge verification attempt
|
||||
/// </summary>
|
||||
public class BridgeVerificationResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the verification was successful
|
||||
/// </summary>
|
||||
public bool Success { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable message about the verification result
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the handshake was valid (FRAMING=1 protocol)
|
||||
/// </summary>
|
||||
public bool HandshakeValid { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the ping/pong exchange succeeded
|
||||
/// </summary>
|
||||
public bool PingSucceeded { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6b5d9f677f6f54fc59e6fe921b260c61
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.Generic;
|
||||
using MCPForUnity.Editor.Clients;
|
||||
using MCPForUnity.Editor.Models;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for configuring MCP clients
|
||||
/// </summary>
|
||||
public interface IClientConfigurationService
|
||||
{
|
||||
/// <summary>
|
||||
/// Configures a specific MCP client
|
||||
/// </summary>
|
||||
/// <param name="client">The client to configure</param>
|
||||
void ConfigureClient(IMcpClientConfigurator configurator);
|
||||
|
||||
/// <summary>
|
||||
/// Configures all detected/installed MCP clients (skips clients where CLI/tools not found)
|
||||
/// </summary>
|
||||
/// <returns>Summary of configuration results</returns>
|
||||
ClientConfigurationSummary ConfigureAllDetectedClients();
|
||||
|
||||
/// <summary>
|
||||
/// Checks the configuration status of a client
|
||||
/// </summary>
|
||||
/// <param name="client">The client to check</param>
|
||||
/// <param name="attemptAutoRewrite">If true, attempts to auto-fix mismatched paths</param>
|
||||
/// <returns>True if status changed, false otherwise</returns>
|
||||
bool CheckClientStatus(IMcpClientConfigurator configurator, bool attemptAutoRewrite = true);
|
||||
|
||||
/// <summary>Gets the registry of discovered configurators.</summary>
|
||||
IReadOnlyList<IMcpClientConfigurator> GetAllClients();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Summary of configuration results for multiple clients
|
||||
/// </summary>
|
||||
public class ClientConfigurationSummary
|
||||
{
|
||||
/// <summary>
|
||||
/// Number of clients successfully configured
|
||||
/// </summary>
|
||||
public int SuccessCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of clients that failed to configure
|
||||
/// </summary>
|
||||
public int FailureCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Number of clients skipped (already configured or tool not found)
|
||||
/// </summary>
|
||||
public int SkippedCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Detailed messages for each client
|
||||
/// </summary>
|
||||
public System.Collections.Generic.List<string> Messages { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable summary message
|
||||
/// </summary>
|
||||
public string GetSummaryMessage()
|
||||
{
|
||||
return $"✓ {SuccessCount} configured, ⚠ {FailureCount} failed, ➜ {SkippedCount} skipped";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: aae139cfae7ac4044ac52e2658005ea1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public interface IPackageDeploymentService
|
||||
{
|
||||
string GetStoredSourcePath();
|
||||
void SetStoredSourcePath(string path);
|
||||
void ClearStoredSourcePath();
|
||||
|
||||
string GetTargetPath();
|
||||
string GetTargetDisplayPath();
|
||||
|
||||
string GetLastBackupPath();
|
||||
bool HasBackup();
|
||||
|
||||
PackageDeploymentResult DeployFromStoredSource();
|
||||
PackageDeploymentResult RestoreLastBackup();
|
||||
}
|
||||
|
||||
public class PackageDeploymentResult
|
||||
{
|
||||
public bool Success { get; set; }
|
||||
public string Message { get; set; }
|
||||
public string SourcePath { get; set; }
|
||||
public string TargetPath { get; set; }
|
||||
public string BackupPath { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9c7a6f1ce6cd4a8c8a3b5d58d4b760a2
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for checking package updates and version information
|
||||
/// </summary>
|
||||
public interface IPackageUpdateService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if a newer version of the package is available
|
||||
/// </summary>
|
||||
/// <param name="currentVersion">The current package version</param>
|
||||
/// <returns>Update check result containing availability and latest version info</returns>
|
||||
UpdateCheckResult CheckForUpdate(string currentVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a cached update result if one exists for today, or null if a network fetch is needed.
|
||||
/// Main-thread only (reads EditorPrefs).
|
||||
/// </summary>
|
||||
UpdateCheckResult TryGetCachedResult(string currentVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Performs only the network fetch and version comparison (no EditorPrefs access).
|
||||
/// Safe to call from a background thread.
|
||||
/// </summary>
|
||||
UpdateCheckResult FetchAndCompare(string currentVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Performs only the network fetch and version comparison using pre-computed installation info.
|
||||
/// Use this overload when calling from a background thread to avoid main-thread-only API calls.
|
||||
/// </summary>
|
||||
UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch);
|
||||
|
||||
/// <summary>
|
||||
/// Caches a successful fetch result in EditorPrefs. Must be called from the main thread.
|
||||
/// </summary>
|
||||
void CacheFetchResult(string currentVersion, string fetchedVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Compares two version strings to determine if the first is newer than the second
|
||||
/// </summary>
|
||||
/// <param name="version1">First version string</param>
|
||||
/// <param name="version2">Second version string</param>
|
||||
/// <returns>True if version1 is newer than version2</returns>
|
||||
bool IsNewerVersion(string version1, string version2);
|
||||
|
||||
/// <summary>
|
||||
/// Determines if the package was installed via Git or Asset Store
|
||||
/// </summary>
|
||||
/// <returns>True if installed via Git, false if Asset Store or unknown</returns>
|
||||
bool IsGitInstallation();
|
||||
|
||||
/// <summary>
|
||||
/// Determines the Git branch to check for updates (e.g. "main" or "beta").
|
||||
/// Must be called from the main thread (uses Unity PackageManager APIs).
|
||||
/// </summary>
|
||||
string GetGitUpdateBranch(string currentVersion);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the cached update check data, forcing a fresh check on next request
|
||||
/// </summary>
|
||||
void ClearCache();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of an update check operation
|
||||
/// </summary>
|
||||
public class UpdateCheckResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether an update is available
|
||||
/// </summary>
|
||||
public bool UpdateAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// The latest version available (null if check failed or no update)
|
||||
/// </summary>
|
||||
public string LatestVersion { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Whether the check was successful (false if network error, etc.)
|
||||
/// </summary>
|
||||
public bool CheckSucceeded { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional message about the check result
|
||||
/// </summary>
|
||||
public string Message { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e94ae28f193184e4fb5068f62f4f00c6
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for resolving paths to required tools and supporting user overrides
|
||||
/// </summary>
|
||||
public interface IPathResolverService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the uvx package manager path (respects override if set)
|
||||
/// </summary>
|
||||
/// <returns>Path to the uvx executable, or null if not found</returns>
|
||||
string GetUvxPath();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the Claude CLI path (respects override if set)
|
||||
/// </summary>
|
||||
/// <returns>Path to the claude executable, or null if not found</returns>
|
||||
string GetClaudeCliPath();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Python is detected on the system
|
||||
/// </summary>
|
||||
/// <returns>True if Python is found</returns>
|
||||
bool IsPythonDetected();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if Claude CLI is detected on the system
|
||||
/// </summary>
|
||||
/// <returns>True if Claude CLI is found</returns>
|
||||
bool IsClaudeCliDetected();
|
||||
|
||||
/// <summary>
|
||||
/// Sets an override for the uvx path
|
||||
/// </summary>
|
||||
/// <param name="path">Path to override with</param>
|
||||
void SetUvxPathOverride(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Sets an override for the Claude CLI path
|
||||
/// </summary>
|
||||
/// <param name="path">Path to override with</param>
|
||||
void SetClaudeCliPathOverride(string path);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the uvx path override
|
||||
/// </summary>
|
||||
void ClearUvxPathOverride();
|
||||
|
||||
/// <summary>
|
||||
/// Clears the Claude CLI path override
|
||||
/// </summary>
|
||||
void ClearClaudeCliPathOverride();
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a uvx path override is active
|
||||
/// </summary>
|
||||
bool HasUvxPathOverride { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether a Claude CLI path override is active
|
||||
/// </summary>
|
||||
bool HasClaudeCliPathOverride { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether the uvx path used a fallback from override to system path
|
||||
/// </summary>
|
||||
bool HasUvxPathFallback { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Validates the provided uv executable by running "--version" and parsing the output.
|
||||
/// </summary>
|
||||
/// <param name="uvPath">Absolute or relative path to the uv/uvx executable.</param>
|
||||
/// <param name="version">Parsed version string if successful.</param>
|
||||
/// <returns>True when the executable runs and returns a uv version string.</returns>
|
||||
bool TryValidateUvxExecutable(string uvPath, out string version);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1e8d388be507345aeb0eaf27fbd3c022
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for platform detection and platform-specific environment access
|
||||
/// </summary>
|
||||
public interface IPlatformService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if the current platform is Windows
|
||||
/// </summary>
|
||||
/// <returns>True if running on Windows</returns>
|
||||
bool IsWindows();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SystemRoot environment variable (Windows-specific)
|
||||
/// </summary>
|
||||
/// <returns>SystemRoot path, or null if not available</returns>
|
||||
string GetSystemRoot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1d90ff7f9a1e84c9bbbbedee2f7eda2a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata for a discovered resource
|
||||
/// </summary>
|
||||
public class ResourceMetadata
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string ClassName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public string AssemblyName { get; set; }
|
||||
public bool IsBuiltIn { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering MCP resources via reflection
|
||||
/// </summary>
|
||||
public interface IResourceDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all resources marked with [McpForUnityResource]
|
||||
/// </summary>
|
||||
List<ResourceMetadata> DiscoverAllResources();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for a specific resource
|
||||
/// </summary>
|
||||
ResourceMetadata GetResourceMetadata(string resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the resources currently enabled
|
||||
/// </summary>
|
||||
List<ResourceMetadata> GetEnabledResources();
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a resource is currently enabled
|
||||
/// </summary>
|
||||
bool IsResourceEnabled(string resourceName);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the enabled state for a resource
|
||||
/// </summary>
|
||||
void SetResourceEnabled(string resourceName, bool enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the resource discovery cache
|
||||
/// </summary>
|
||||
void InvalidateCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7afb4739669224c74b4b4d706e6bbb49
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for server management operations
|
||||
/// </summary>
|
||||
public interface IServerManagementService
|
||||
{
|
||||
/// <summary>
|
||||
/// Clear the local uvx cache for the MCP server package
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise</returns>
|
||||
bool ClearUvxCache();
|
||||
|
||||
/// <summary>
|
||||
/// Start the local HTTP server headless (no terminal window), redirecting its output to a
|
||||
/// per-port launch log. Stops any existing server on the port and clears stale artifacts first.
|
||||
/// </summary>
|
||||
/// <param name="quiet">When true, skip confirmation dialogs (used by auto-start).</param>
|
||||
/// <returns>True if server was started successfully, false otherwise</returns>
|
||||
bool StartLocalHttpServer(bool quiet = false);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the launch-log path for the configured local HTTP server port, or null if unavailable.
|
||||
/// </summary>
|
||||
string GetLocalHttpServerLaunchLogPath();
|
||||
|
||||
/// <summary>
|
||||
/// Returns true while the most-recently launched headless server process is still alive.
|
||||
/// Used by callers to keep waiting for reachability instead of declaring failure prematurely.
|
||||
/// </summary>
|
||||
bool IsManagedServerLaunchProcessAlive();
|
||||
|
||||
/// <summary>
|
||||
/// True when this domain launched the local HTTP server and still holds its Process
|
||||
/// handle. Handles do not survive domain reloads, so false also means "unknown" —
|
||||
/// callers should wait rather than fail fast.
|
||||
/// </summary>
|
||||
bool HasManagedServerLaunchHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Writes a launch-failure report to the Console (Error): the tail of the launch log,
|
||||
/// the log path, and a copy-command hint pointing at the Manual Server Launch foldout.
|
||||
/// </summary>
|
||||
void LogLocalHttpServerLaunchFailure();
|
||||
|
||||
/// <summary>
|
||||
/// Stop the local HTTP server by finding the process listening on the configured port
|
||||
/// </summary>
|
||||
bool StopLocalHttpServer();
|
||||
|
||||
/// <summary>
|
||||
/// Stop the Unity-managed local HTTP server if a handshake/pidfile exists,
|
||||
/// even if the current transport selection has changed.
|
||||
/// </summary>
|
||||
bool StopManagedLocalHttpServer();
|
||||
|
||||
/// <summary>
|
||||
/// Best-effort detection: returns true if a local MCP HTTP server appears to be running
|
||||
/// on the configured local URL/port (used to drive UI state even if the session is not active).
|
||||
/// </summary>
|
||||
bool IsLocalHttpServerRunning();
|
||||
|
||||
/// <summary>
|
||||
/// Fast reachability check: returns true if a local TCP listener is accepting connections
|
||||
/// for the configured local URL/port (used for UI state without process inspection).
|
||||
/// </summary>
|
||||
bool IsLocalHttpServerReachable();
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the command that will be executed when starting the local HTTP server
|
||||
/// </summary>
|
||||
/// <param name="command">The command that will be executed when available</param>
|
||||
/// <param name="error">Reason why a command could not be produced</param>
|
||||
/// <returns>True if a command is available, false otherwise</returns>
|
||||
bool TryGetLocalHttpServerCommand(out string command, out string error);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the configured HTTP URL is a local address
|
||||
/// </summary>
|
||||
/// <returns>True if URL is local (localhost, 127.0.0.1, etc.)</returns>
|
||||
bool IsLocalUrl();
|
||||
|
||||
/// <summary>
|
||||
/// Check if the local HTTP server can be started
|
||||
/// </summary>
|
||||
/// <returns>True if HTTP transport is enabled and URL satisfies local launch security policy</returns>
|
||||
bool CanStartLocalServer();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d41bfc9780b774affa6afbffd081eb79
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,52 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEditor.TestTools.TestRunner.Api;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Options for filtering which tests to run.
|
||||
/// All properties are optional - null or empty arrays are ignored.
|
||||
/// </summary>
|
||||
public class TestFilterOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Full names of specific tests to run (e.g., "MyNamespace.MyTests.TestMethod").
|
||||
/// </summary>
|
||||
public string[] TestNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Same as TestNames, except it allows for Regex.
|
||||
/// </summary>
|
||||
public string[] GroupNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// NUnit category names to filter by (tests marked with [Category] attribute).
|
||||
/// </summary>
|
||||
public string[] CategoryNames { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Assembly names to filter tests by.
|
||||
/// </summary>
|
||||
public string[] AssemblyNames { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides access to Unity Test Runner data and execution.
|
||||
/// </summary>
|
||||
public interface ITestRunnerService
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieve the list of tests for the requested mode(s).
|
||||
/// When <paramref name="mode"/> is null, tests for both EditMode and PlayMode are returned.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<Dictionary<string, string>>> GetTestsAsync(TestMode? mode);
|
||||
|
||||
/// <summary>
|
||||
/// Execute tests for the supplied mode with optional filtering.
|
||||
/// </summary>
|
||||
/// <param name="mode">The test mode (EditMode or PlayMode).</param>
|
||||
/// <param name="filterOptions">Optional filter options to run specific tests. Pass null to run all tests.</param>
|
||||
Task<TestRunResult> RunTestsAsync(TestMode mode, TestFilterOptions filterOptions = null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d23bf32361ff444beaf3510818c94bae
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,72 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata for a discovered tool
|
||||
/// </summary>
|
||||
public class ToolMetadata
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public bool StructuredOutput { get; set; }
|
||||
public List<ParameterMetadata> Parameters { get; set; }
|
||||
public string ClassName { get; set; }
|
||||
public string Namespace { get; set; }
|
||||
public string AssemblyName { get; set; }
|
||||
public bool AutoRegister { get; set; } = true;
|
||||
public bool RequiresPolling { get; set; } = false;
|
||||
public string PollAction { get; set; } = "status";
|
||||
public int MaxPollSeconds { get; set; } = 0;
|
||||
public bool IsBuiltIn { get; set; }
|
||||
public string Group { get; set; } = "core";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for a tool parameter
|
||||
/// </summary>
|
||||
public class ParameterMetadata
|
||||
{
|
||||
public string Name { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Type { get; set; } // "string", "int", "bool", "float", etc.
|
||||
public bool Required { get; set; }
|
||||
public string DefaultValue { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Service for discovering MCP tools via reflection
|
||||
/// </summary>
|
||||
public interface IToolDiscoveryService
|
||||
{
|
||||
/// <summary>
|
||||
/// Discovers all tools marked with [McpForUnityTool]
|
||||
/// </summary>
|
||||
List<ToolMetadata> DiscoverAllTools();
|
||||
|
||||
/// <summary>
|
||||
/// Gets metadata for a specific tool
|
||||
/// </summary>
|
||||
ToolMetadata GetToolMetadata(string toolName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns only the tools currently enabled for registration
|
||||
/// </summary>
|
||||
List<ToolMetadata> GetEnabledTools();
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether a tool is currently enabled for registration
|
||||
/// </summary>
|
||||
bool IsToolEnabled(string toolName);
|
||||
|
||||
/// <summary>
|
||||
/// Updates the enabled state for a tool
|
||||
/// </summary>
|
||||
void SetToolEnabled(string toolName, bool enabled);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates the tool discovery cache
|
||||
/// </summary>
|
||||
void InvalidateCache();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 497592a93fd994b2cb9803e7c8636ff7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,98 @@
|
||||
using System;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
using MCPForUnity.Editor.Services.Transport.Transports;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service locator for accessing MCP services without dependency injection
|
||||
/// </summary>
|
||||
public static class MCPServiceLocator
|
||||
{
|
||||
private static IBridgeControlService _bridgeService;
|
||||
private static IClientConfigurationService _clientService;
|
||||
private static IPathResolverService _pathService;
|
||||
private static ITestRunnerService _testRunnerService;
|
||||
private static IPackageUpdateService _packageUpdateService;
|
||||
private static IPlatformService _platformService;
|
||||
private static IToolDiscoveryService _toolDiscoveryService;
|
||||
private static IResourceDiscoveryService _resourceDiscoveryService;
|
||||
private static IServerManagementService _serverManagementService;
|
||||
private static TransportManager _transportManager;
|
||||
private static IPackageDeploymentService _packageDeploymentService;
|
||||
|
||||
public static IBridgeControlService Bridge => _bridgeService ??= new BridgeControlService();
|
||||
public static IClientConfigurationService Client => _clientService ??= new ClientConfigurationService();
|
||||
public static IPathResolverService Paths => _pathService ??= new PathResolverService();
|
||||
public static ITestRunnerService Tests => _testRunnerService ??= new TestRunnerService();
|
||||
public static IPackageUpdateService Updates => _packageUpdateService ??= new PackageUpdateService();
|
||||
public static IPlatformService Platform => _platformService ??= new PlatformService();
|
||||
public static IToolDiscoveryService ToolDiscovery => _toolDiscoveryService ??= new ToolDiscoveryService();
|
||||
public static IResourceDiscoveryService ResourceDiscovery => _resourceDiscoveryService ??= new ResourceDiscoveryService();
|
||||
public static IServerManagementService Server => _serverManagementService ??= new ServerManagementService();
|
||||
public static TransportManager TransportManager => _transportManager ??= new TransportManager();
|
||||
public static IPackageDeploymentService Deployment => _packageDeploymentService ??= new PackageDeploymentService();
|
||||
|
||||
/// <summary>
|
||||
/// Registers a custom implementation for a service (useful for testing)
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The service interface type</typeparam>
|
||||
/// <param name="implementation">The implementation to register</param>
|
||||
public static void Register<T>(T implementation) where T : class
|
||||
{
|
||||
if (implementation is IBridgeControlService b)
|
||||
_bridgeService = b;
|
||||
else if (implementation is IClientConfigurationService c)
|
||||
_clientService = c;
|
||||
else if (implementation is IPathResolverService p)
|
||||
_pathService = p;
|
||||
else if (implementation is ITestRunnerService t)
|
||||
_testRunnerService = t;
|
||||
else if (implementation is IPackageUpdateService pu)
|
||||
_packageUpdateService = pu;
|
||||
else if (implementation is IPlatformService ps)
|
||||
_platformService = ps;
|
||||
else if (implementation is IToolDiscoveryService td)
|
||||
_toolDiscoveryService = td;
|
||||
else if (implementation is IResourceDiscoveryService rd)
|
||||
_resourceDiscoveryService = rd;
|
||||
else if (implementation is IServerManagementService sm)
|
||||
_serverManagementService = sm;
|
||||
else if (implementation is IPackageDeploymentService pd)
|
||||
_packageDeploymentService = pd;
|
||||
else if (implementation is TransportManager tm)
|
||||
_transportManager = tm;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets all services to their default implementations (useful for testing)
|
||||
/// </summary>
|
||||
public static void Reset()
|
||||
{
|
||||
(_bridgeService as IDisposable)?.Dispose();
|
||||
(_clientService as IDisposable)?.Dispose();
|
||||
(_pathService as IDisposable)?.Dispose();
|
||||
(_testRunnerService as IDisposable)?.Dispose();
|
||||
(_packageUpdateService as IDisposable)?.Dispose();
|
||||
(_platformService as IDisposable)?.Dispose();
|
||||
(_toolDiscoveryService as IDisposable)?.Dispose();
|
||||
(_resourceDiscoveryService as IDisposable)?.Dispose();
|
||||
(_serverManagementService as IDisposable)?.Dispose();
|
||||
(_transportManager as IDisposable)?.Dispose();
|
||||
(_packageDeploymentService as IDisposable)?.Dispose();
|
||||
|
||||
_bridgeService = null;
|
||||
_clientService = null;
|
||||
_pathService = null;
|
||||
_testRunnerService = null;
|
||||
_packageUpdateService = null;
|
||||
_platformService = null;
|
||||
_toolDiscoveryService = null;
|
||||
_resourceDiscoveryService = null;
|
||||
_serverManagementService = null;
|
||||
_transportManager = null;
|
||||
_packageDeploymentService = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 276d6a9f9a1714ead91573945de78992
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,71 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Services.Transport;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Best-effort cleanup when the Unity Editor is quitting.
|
||||
/// - Stops active transports so clients don't see a "hung" session longer than necessary.
|
||||
/// - Stops the local HTTP server this Unity instance launched (handshake/pidfile-based), so a
|
||||
/// headless server doesn't become an invisible orphan. This runs on quit only, never on domain reload.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class McpEditorShutdownCleanup
|
||||
{
|
||||
static McpEditorShutdownCleanup()
|
||||
{
|
||||
// Guard against duplicate subscriptions across domain reloads.
|
||||
try { EditorApplication.quitting -= OnEditorQuitting; } catch { }
|
||||
EditorApplication.quitting += OnEditorQuitting;
|
||||
}
|
||||
|
||||
// A -batchmode/CI instance resolves the interactive editor's server via the global
|
||||
// pidfile+port handshake, so cleanup there would stop another user's server. Mirror the
|
||||
// sibling guards (HttpAutoStartHandler, StdioBridgeHost): skip in batch unless opted in.
|
||||
internal static bool ShouldRunCleanup() =>
|
||||
ShouldRunCleanup(Application.isBatchMode, Environment.GetEnvironmentVariable("UNITY_MCP_ALLOW_BATCH"));
|
||||
|
||||
internal static bool ShouldRunCleanup(bool isBatchMode, string allowBatchEnv) =>
|
||||
!isBatchMode || !string.IsNullOrWhiteSpace(allowBatchEnv);
|
||||
|
||||
private static void OnEditorQuitting()
|
||||
{
|
||||
if (!ShouldRunCleanup()) return;
|
||||
|
||||
// 1) Stop transports (best-effort, bounded wait).
|
||||
try
|
||||
{
|
||||
var transport = MCPServiceLocator.TransportManager;
|
||||
|
||||
Task stopHttp = transport.StopAsync(TransportMode.Http);
|
||||
Task stopStdio = transport.StopAsync(TransportMode.Stdio);
|
||||
|
||||
try { Task.WaitAll(new[] { stopHttp, stopStdio }, 750); } catch { }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Avoid hard failures on quit.
|
||||
McpLog.Warn($"Shutdown cleanup: failed to stop transports: {ex.Message}");
|
||||
}
|
||||
|
||||
// 2) Stop the local HTTP server this Unity instance launched (best-effort).
|
||||
// Headless servers have no terminal window, so an unstopped one is an invisible orphan.
|
||||
// StopManagedLocalHttpServer only stops the server matching our pidfile+instance-token handshake,
|
||||
// so it never touches servers launched by other Unity instances. This runs on quit only;
|
||||
// domain reloads must NOT stop the server (and don't — this handler is gated on EditorApplication.quitting).
|
||||
try
|
||||
{
|
||||
MCPServiceLocator.Server.StopManagedLocalHttpServer();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Shutdown cleanup: failed to stop local HTTP server: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4150c04e0907c45d7b332260911a0567
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,304 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles copying a local MCPForUnity folder into the current project's package location with backup/restore support.
|
||||
/// </summary>
|
||||
public class PackageDeploymentService : IPackageDeploymentService
|
||||
{
|
||||
private const string BackupRootFolderName = "MCPForUnityDeployBackups";
|
||||
|
||||
public string GetStoredSourcePath()
|
||||
{
|
||||
return EditorPrefs.GetString(EditorPrefKeys.PackageDeploySourcePath, string.Empty);
|
||||
}
|
||||
|
||||
public void SetStoredSourcePath(string path)
|
||||
{
|
||||
ValidateSource(path);
|
||||
EditorPrefs.SetString(EditorPrefKeys.PackageDeploySourcePath, Path.GetFullPath(path));
|
||||
}
|
||||
|
||||
public void ClearStoredSourcePath()
|
||||
{
|
||||
EditorPrefs.DeleteKey(EditorPrefKeys.PackageDeploySourcePath);
|
||||
}
|
||||
|
||||
public string GetTargetPath()
|
||||
{
|
||||
// Prefer Package Manager resolved path for the installed package
|
||||
var packageInfo = PackageInfo.FindForAssembly(typeof(PackageDeploymentService).Assembly);
|
||||
if (packageInfo != null)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(packageInfo.resolvedPath) && Directory.Exists(packageInfo.resolvedPath))
|
||||
{
|
||||
return packageInfo.resolvedPath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(packageInfo.assetPath))
|
||||
{
|
||||
string absoluteFromAsset = MakeAbsolute(packageInfo.assetPath);
|
||||
if (Directory.Exists(absoluteFromAsset))
|
||||
{
|
||||
return absoluteFromAsset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to computed package root
|
||||
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
|
||||
if (!string.IsNullOrEmpty(packageRoot))
|
||||
{
|
||||
string absolutePath = MakeAbsolute(packageRoot);
|
||||
if (Directory.Exists(absolutePath))
|
||||
{
|
||||
return absolutePath;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public string GetTargetDisplayPath()
|
||||
{
|
||||
string target = GetTargetPath();
|
||||
if (string.IsNullOrEmpty(target))
|
||||
return "Not found (check Packages/manifest.json)";
|
||||
// Use forward slashes to avoid backslash escape sequence issues in UI text
|
||||
return target.Replace('\\', '/');
|
||||
}
|
||||
|
||||
public string GetLastBackupPath()
|
||||
{
|
||||
return EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastBackupPath, string.Empty);
|
||||
}
|
||||
|
||||
public bool HasBackup()
|
||||
{
|
||||
string path = GetLastBackupPath();
|
||||
return !string.IsNullOrEmpty(path) && Directory.Exists(path);
|
||||
}
|
||||
|
||||
public PackageDeploymentResult DeployFromStoredSource()
|
||||
{
|
||||
string sourcePath = GetStoredSourcePath();
|
||||
if (string.IsNullOrEmpty(sourcePath))
|
||||
{
|
||||
return Fail("Select a MCPForUnity folder first.");
|
||||
}
|
||||
|
||||
string validationError = ValidateSource(sourcePath, throwOnError: false);
|
||||
if (!string.IsNullOrEmpty(validationError))
|
||||
{
|
||||
return Fail(validationError);
|
||||
}
|
||||
|
||||
string targetPath = GetTargetPath();
|
||||
if (string.IsNullOrEmpty(targetPath))
|
||||
{
|
||||
return Fail("Could not locate the installed MCP package. Check Packages/manifest.json.");
|
||||
}
|
||||
|
||||
if (PathsEqual(sourcePath, targetPath))
|
||||
{
|
||||
return Fail("Source and target are the same. Choose a different MCPForUnity folder.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Creating backup...", 0.25f);
|
||||
string backupPath = CreateBackup(targetPath);
|
||||
|
||||
EditorUtility.DisplayProgressBar("Deploy MCP for Unity", "Replacing package contents...", 0.7f);
|
||||
CopyCoreFolders(sourcePath, targetPath);
|
||||
|
||||
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastBackupPath, backupPath);
|
||||
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastTargetPath, targetPath);
|
||||
EditorPrefs.SetString(EditorPrefKeys.PackageDeployLastSourcePath, sourcePath);
|
||||
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
|
||||
return Success("Deployment completed.", sourcePath, targetPath, backupPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Deployment failed: {ex.Message}");
|
||||
return Fail($"Deployment failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
public PackageDeploymentResult RestoreLastBackup()
|
||||
{
|
||||
string backupPath = GetLastBackupPath();
|
||||
string targetPath = EditorPrefs.GetString(EditorPrefKeys.PackageDeployLastTargetPath, string.Empty);
|
||||
|
||||
if (string.IsNullOrEmpty(backupPath) || !Directory.Exists(backupPath))
|
||||
{
|
||||
return Fail("No backup available to restore.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
|
||||
{
|
||||
targetPath = GetTargetPath();
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(targetPath) || !Directory.Exists(targetPath))
|
||||
{
|
||||
return Fail("Could not locate target package path.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
EditorUtility.DisplayProgressBar("Restore MCP for Unity", "Restoring backup...", 0.5f);
|
||||
ReplaceDirectory(backupPath, targetPath);
|
||||
|
||||
AssetDatabase.Refresh(ImportAssetOptions.ForceUpdate);
|
||||
return Success("Restore completed.", null, targetPath, backupPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Restore failed: {ex.Message}");
|
||||
return Fail($"Restore failed: {ex.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
EditorUtility.ClearProgressBar();
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyCoreFolders(string sourceRoot, string targetRoot)
|
||||
{
|
||||
string sourceEditor = Path.Combine(sourceRoot, "Editor");
|
||||
string sourceRuntime = Path.Combine(sourceRoot, "Runtime");
|
||||
|
||||
ReplaceDirectory(sourceEditor, Path.Combine(targetRoot, "Editor"));
|
||||
ReplaceDirectory(sourceRuntime, Path.Combine(targetRoot, "Runtime"));
|
||||
}
|
||||
|
||||
private static void ReplaceDirectory(string source, string destination)
|
||||
{
|
||||
if (Directory.Exists(destination))
|
||||
{
|
||||
FileUtil.DeleteFileOrDirectory(destination);
|
||||
}
|
||||
|
||||
FileUtil.CopyFileOrDirectory(source, destination);
|
||||
}
|
||||
|
||||
private string CreateBackup(string targetPath)
|
||||
{
|
||||
string backupRoot = Path.Combine(GetProjectRoot(), "Library", BackupRootFolderName);
|
||||
Directory.CreateDirectory(backupRoot);
|
||||
|
||||
string stamp = DateTime.Now.ToString("yyyyMMdd_HHmmss");
|
||||
string backupPath = Path.Combine(backupRoot, $"backup_{stamp}");
|
||||
|
||||
if (Directory.Exists(backupPath))
|
||||
{
|
||||
FileUtil.DeleteFileOrDirectory(backupPath);
|
||||
}
|
||||
|
||||
FileUtil.CopyFileOrDirectory(targetPath, backupPath);
|
||||
return backupPath;
|
||||
}
|
||||
|
||||
private static string ValidateSource(string sourcePath, bool throwOnError = true)
|
||||
{
|
||||
if (string.IsNullOrEmpty(sourcePath))
|
||||
{
|
||||
if (throwOnError)
|
||||
{
|
||||
throw new ArgumentException("Source path cannot be empty.");
|
||||
}
|
||||
|
||||
return "Source path is empty.";
|
||||
}
|
||||
|
||||
if (!Directory.Exists(sourcePath))
|
||||
{
|
||||
if (throwOnError)
|
||||
{
|
||||
throw new ArgumentException("Selected folder does not exist.");
|
||||
}
|
||||
|
||||
return "Selected folder does not exist.";
|
||||
}
|
||||
|
||||
bool hasEditor = Directory.Exists(Path.Combine(sourcePath, "Editor"));
|
||||
bool hasRuntime = Directory.Exists(Path.Combine(sourcePath, "Runtime"));
|
||||
|
||||
if (!hasEditor || !hasRuntime)
|
||||
{
|
||||
string message = "Folder must contain Editor and Runtime subfolders.";
|
||||
if (throwOnError)
|
||||
{
|
||||
throw new ArgumentException(message);
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string MakeAbsolute(string assetPath)
|
||||
{
|
||||
assetPath = assetPath.Replace('\\', '/');
|
||||
|
||||
if (assetPath.StartsWith("Assets/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
|
||||
}
|
||||
|
||||
if (assetPath.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, "..", assetPath));
|
||||
}
|
||||
|
||||
return Path.GetFullPath(assetPath);
|
||||
}
|
||||
|
||||
private static string GetProjectRoot()
|
||||
{
|
||||
return Path.GetFullPath(Path.Combine(Application.dataPath, ".."));
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string a, string b)
|
||||
{
|
||||
string normA = Path.GetFullPath(a).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
string normB = Path.GetFullPath(b).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
|
||||
return string.Equals(normA, normB, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PackageDeploymentResult Success(string message, string source, string target, string backup)
|
||||
{
|
||||
return new PackageDeploymentResult
|
||||
{
|
||||
Success = true,
|
||||
Message = message,
|
||||
SourcePath = source,
|
||||
TargetPath = target,
|
||||
BackupPath = backup
|
||||
};
|
||||
}
|
||||
|
||||
private static PackageDeploymentResult Fail(string message)
|
||||
{
|
||||
return new PackageDeploymentResult
|
||||
{
|
||||
Success = false,
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b1f45e4e5d24413a6f1c8c0d8c5f2f1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,345 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0c8e16aa625e01544beb2468fda53613
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,451 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using UnityEditor;
|
||||
using PackageInfo = UnityEditor.PackageManager.PackageInfo;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Service for checking package updates from GitHub or Asset Store metadata
|
||||
/// </summary>
|
||||
public class PackageUpdateService : IPackageUpdateService
|
||||
{
|
||||
private const int DefaultRequestTimeoutMs = 3000;
|
||||
private const string LastCheckDateKey = EditorPrefKeys.LastUpdateCheck;
|
||||
private const string CachedVersionKey = EditorPrefKeys.LatestKnownVersion;
|
||||
private const string LastBetaCheckDateKey = EditorPrefKeys.LastUpdateCheck + ".beta";
|
||||
private const string CachedBetaVersionKey = EditorPrefKeys.LatestKnownVersion + ".beta";
|
||||
private const string LastAssetStoreCheckDateKey = EditorPrefKeys.LastAssetStoreUpdateCheck;
|
||||
private const string CachedAssetStoreVersionKey = EditorPrefKeys.LatestKnownAssetStoreVersion;
|
||||
private const string MainPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/main/MCPForUnity/package.json";
|
||||
private const string BetaPackageJsonUrl = "https://raw.githubusercontent.com/CoplayDev/unity-mcp/beta/MCPForUnity/package.json";
|
||||
private const string AssetStoreVersionUrl = "https://gqoqjkkptwfbkwyssmnj.supabase.co/storage/v1/object/public/coplay-images/assetstoreversion.json";
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UpdateCheckResult CheckForUpdate(string currentVersion)
|
||||
{
|
||||
bool isGitInstallation = IsGitInstallation();
|
||||
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
|
||||
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
string lastCheckKey = isGitInstallation
|
||||
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
|
||||
: LastAssetStoreCheckDateKey;
|
||||
string cachedVersionKey = isGitInstallation
|
||||
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
|
||||
: CachedAssetStoreVersionKey;
|
||||
|
||||
string lastCheckDate = EditorPrefs.GetString(lastCheckKey, "");
|
||||
string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, "");
|
||||
|
||||
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = cachedLatestVersion,
|
||||
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
|
||||
Message = "Using cached version check"
|
||||
};
|
||||
}
|
||||
|
||||
string latestVersion = isGitInstallation
|
||||
? FetchLatestVersionFromGitHub(gitBranch)
|
||||
: FetchLatestVersionFromAssetStoreJson();
|
||||
|
||||
if (!string.IsNullOrEmpty(latestVersion))
|
||||
{
|
||||
// Cache the result
|
||||
EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
EditorPrefs.SetString(cachedVersionKey, latestVersion);
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = latestVersion,
|
||||
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
|
||||
Message = "Successfully checked for updates"
|
||||
};
|
||||
}
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = false,
|
||||
UpdateAvailable = false,
|
||||
Message = isGitInstallation
|
||||
? "Failed to check for updates (network issue or offline)"
|
||||
: "Failed to check for Asset Store updates (network issue or offline)"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UpdateCheckResult TryGetCachedResult(string currentVersion)
|
||||
{
|
||||
bool isGitInstallation = IsGitInstallation();
|
||||
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
|
||||
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
string lastCheckKey = isGitInstallation
|
||||
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
|
||||
: LastAssetStoreCheckDateKey;
|
||||
string cachedVersionKey = isGitInstallation
|
||||
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
|
||||
: CachedAssetStoreVersionKey;
|
||||
|
||||
string lastCheckDate = EditorPrefs.GetString(lastCheckKey, "");
|
||||
string cachedLatestVersion = EditorPrefs.GetString(cachedVersionKey, "");
|
||||
|
||||
if (lastCheckDate == DateTime.Now.ToString("yyyy-MM-dd") && !string.IsNullOrEmpty(cachedLatestVersion))
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = cachedLatestVersion,
|
||||
UpdateAvailable = IsNewerVersion(cachedLatestVersion, currentVersion),
|
||||
Message = "Using cached version check"
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UpdateCheckResult FetchAndCompare(string currentVersion)
|
||||
{
|
||||
bool isGitInstallation = IsGitInstallation();
|
||||
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
|
||||
return FetchAndCompare(currentVersion, isGitInstallation, gitBranch);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public UpdateCheckResult FetchAndCompare(string currentVersion, bool isGitInstallation, string gitBranch)
|
||||
{
|
||||
string latestVersion = isGitInstallation
|
||||
? FetchLatestVersionFromGitHub(gitBranch)
|
||||
: FetchLatestVersionFromAssetStoreJson();
|
||||
|
||||
if (!string.IsNullOrEmpty(latestVersion))
|
||||
{
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = true,
|
||||
LatestVersion = latestVersion,
|
||||
UpdateAvailable = IsNewerVersion(latestVersion, currentVersion),
|
||||
Message = "Successfully checked for updates"
|
||||
};
|
||||
}
|
||||
|
||||
return new UpdateCheckResult
|
||||
{
|
||||
CheckSucceeded = false,
|
||||
UpdateAvailable = false,
|
||||
Message = isGitInstallation
|
||||
? "Failed to check for updates (network issue or offline)"
|
||||
: "Failed to check for Asset Store updates (network issue or offline)"
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void CacheFetchResult(string currentVersion, string fetchedVersion)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fetchedVersion)) return;
|
||||
|
||||
bool isGitInstallation = IsGitInstallation();
|
||||
string gitBranch = isGitInstallation ? GetGitUpdateBranch(currentVersion) : "main";
|
||||
bool useBetaChannel = isGitInstallation && string.Equals(gitBranch, "beta", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
string lastCheckKey = isGitInstallation
|
||||
? (useBetaChannel ? LastBetaCheckDateKey : LastCheckDateKey)
|
||||
: LastAssetStoreCheckDateKey;
|
||||
string cachedVersionKey = isGitInstallation
|
||||
? (useBetaChannel ? CachedBetaVersionKey : CachedVersionKey)
|
||||
: CachedAssetStoreVersionKey;
|
||||
|
||||
EditorPrefs.SetString(lastCheckKey, DateTime.Now.ToString("yyyy-MM-dd"));
|
||||
EditorPrefs.SetString(cachedVersionKey, fetchedVersion);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsNewerVersion(string version1, string version2)
|
||||
{
|
||||
if (!TryParseVersion(version1, out var left) || !TryParseVersion(version2, out var right))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return CompareVersions(left, right) > 0;
|
||||
}
|
||||
|
||||
private static int CompareVersions(ParsedVersion left, ParsedVersion right)
|
||||
{
|
||||
int cmp = left.Major.CompareTo(right.Major);
|
||||
if (cmp != 0) return cmp;
|
||||
|
||||
cmp = left.Minor.CompareTo(right.Minor);
|
||||
if (cmp != 0) return cmp;
|
||||
|
||||
cmp = left.Patch.CompareTo(right.Patch);
|
||||
if (cmp != 0) return cmp;
|
||||
|
||||
// Stable is newer than prerelease when core version matches.
|
||||
if (!left.IsPrerelease && right.IsPrerelease) return 1;
|
||||
if (left.IsPrerelease && !right.IsPrerelease) return -1;
|
||||
if (!left.IsPrerelease && !right.IsPrerelease) return 0;
|
||||
|
||||
cmp = GetPrereleaseRank(left.PrereleaseLabel).CompareTo(GetPrereleaseRank(right.PrereleaseLabel));
|
||||
if (cmp != 0) return cmp;
|
||||
|
||||
cmp = left.PrereleaseNumber.CompareTo(right.PrereleaseNumber);
|
||||
if (cmp != 0) return cmp;
|
||||
|
||||
return string.Compare(left.PrereleaseLabel, right.PrereleaseLabel, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static int GetPrereleaseRank(string label)
|
||||
{
|
||||
if (string.IsNullOrEmpty(label))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
switch (label.ToLowerInvariant())
|
||||
{
|
||||
case "a":
|
||||
case "alpha":
|
||||
return 1;
|
||||
case "b":
|
||||
case "beta":
|
||||
return 2;
|
||||
case "rc":
|
||||
return 3;
|
||||
case "preview":
|
||||
case "pre":
|
||||
return 4;
|
||||
default:
|
||||
return 5;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseVersion(string version, out ParsedVersion parsed)
|
||||
{
|
||||
parsed = default;
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string normalized = version.Trim().TrimStart('v', 'V');
|
||||
var match = Regex.Match(
|
||||
normalized,
|
||||
@"^(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)(?:-(?<label>[A-Za-z]+)(?:\.(?<number>\d+))?)?$");
|
||||
|
||||
if (!match.Success)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!int.TryParse(match.Groups["major"].Value, out int major) ||
|
||||
!int.TryParse(match.Groups["minor"].Value, out int minor) ||
|
||||
!int.TryParse(match.Groups["patch"].Value, out int patch))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string prereleaseLabel = match.Groups["label"].Success ? match.Groups["label"].Value : string.Empty;
|
||||
int prereleaseNumber = 0;
|
||||
if (match.Groups["number"].Success)
|
||||
{
|
||||
int.TryParse(match.Groups["number"].Value, out prereleaseNumber);
|
||||
}
|
||||
|
||||
parsed = new ParsedVersion
|
||||
{
|
||||
Major = major,
|
||||
Minor = minor,
|
||||
Patch = patch,
|
||||
PrereleaseLabel = prereleaseLabel,
|
||||
PrereleaseNumber = prereleaseNumber,
|
||||
IsPrerelease = !string.IsNullOrEmpty(prereleaseLabel)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsPreReleaseVersion(string version)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(version))
|
||||
{
|
||||
return AssetPathUtility.IsPreReleaseVersion();
|
||||
}
|
||||
|
||||
return version.IndexOf('-', StringComparison.Ordinal) >= 0;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public string GetGitUpdateBranch(string currentVersion)
|
||||
{
|
||||
try
|
||||
{
|
||||
var packageInfo = PackageInfo.FindForAssembly(typeof(PackageUpdateService).Assembly);
|
||||
string packageId = packageInfo?.packageId ?? string.Empty;
|
||||
|
||||
if (packageId.IndexOf("#beta", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return "beta";
|
||||
}
|
||||
|
||||
if (packageId.IndexOf("#main", StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return "main";
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Fall back to version-based inference below.
|
||||
}
|
||||
|
||||
return IsPreReleaseVersion(currentVersion) ? "beta" : "main";
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public virtual bool IsGitInstallation()
|
||||
{
|
||||
// Git packages are installed via Package Manager and have a package.json in Packages/
|
||||
// Asset Store packages are in Assets/
|
||||
string packageRoot = AssetPathUtility.GetMcpPackageRootPath();
|
||||
|
||||
if (string.IsNullOrEmpty(packageRoot))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the package is in Packages/ it's a PM install (likely Git)
|
||||
// If it's in Assets/ it's an Asset Store install
|
||||
return packageRoot.StartsWith("Packages/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void ClearCache()
|
||||
{
|
||||
EditorPrefs.DeleteKey(LastCheckDateKey);
|
||||
EditorPrefs.DeleteKey(CachedVersionKey);
|
||||
EditorPrefs.DeleteKey(LastBetaCheckDateKey);
|
||||
EditorPrefs.DeleteKey(CachedBetaVersionKey);
|
||||
EditorPrefs.DeleteKey(LastAssetStoreCheckDateKey);
|
||||
EditorPrefs.DeleteKey(CachedAssetStoreVersionKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the latest version from GitHub package.json for the requested branch.
|
||||
/// </summary>
|
||||
protected virtual string FetchLatestVersionFromGitHub(string branch)
|
||||
{
|
||||
try
|
||||
{
|
||||
// GitHub API endpoint (Option 1 - has rate limits):
|
||||
// https://api.github.com/repos/CoplayDev/unity-mcp/releases/latest
|
||||
//
|
||||
// We use Option 2 (package.json directly) because:
|
||||
// - No API rate limits (GitHub serves raw files freely)
|
||||
// - Simpler - just parse JSON for version field
|
||||
// - More reliable - doesn't require releases to be published
|
||||
// - Direct source of truth from the main branch
|
||||
|
||||
using (var client = CreateWebClient())
|
||||
{
|
||||
client.Headers.Add("User-Agent", "Unity-MCPForUnity-UpdateChecker");
|
||||
string packageJsonUrl = string.Equals(branch, "beta", StringComparison.OrdinalIgnoreCase)
|
||||
? BetaPackageJsonUrl
|
||||
: MainPackageJsonUrl;
|
||||
string jsonContent = client.DownloadString(packageJsonUrl);
|
||||
|
||||
var packageJson = JObject.Parse(jsonContent);
|
||||
string version = packageJson["version"]?.ToString();
|
||||
|
||||
return string.IsNullOrEmpty(version) ? null : version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Silent fail - don't interrupt the user if network is unavailable
|
||||
McpLog.Info($"Update check failed (this is normal if offline): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private struct ParsedVersion
|
||||
{
|
||||
public int Major;
|
||||
public int Minor;
|
||||
public int Patch;
|
||||
public string PrereleaseLabel;
|
||||
public int PrereleaseNumber;
|
||||
public bool IsPrerelease;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the latest Asset Store version from a hosted JSON file.
|
||||
/// </summary>
|
||||
protected virtual string FetchLatestVersionFromAssetStoreJson()
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var client = CreateWebClient())
|
||||
{
|
||||
client.Headers.Add("User-Agent", "Unity-MCPForUnity-AssetStoreUpdateChecker");
|
||||
string jsonContent = client.DownloadString(AssetStoreVersionUrl);
|
||||
|
||||
var versionJson = JObject.Parse(jsonContent);
|
||||
string version = versionJson["version"]?.ToString();
|
||||
|
||||
return string.IsNullOrEmpty(version) ? null : version;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Silent fail - don't interrupt the user if network is unavailable
|
||||
McpLog.Info($"Asset Store update check failed (this is normal if offline): {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
protected virtual WebClient CreateWebClient()
|
||||
{
|
||||
return new TimeoutWebClient(GetRequestTimeoutMs());
|
||||
}
|
||||
|
||||
protected virtual int GetRequestTimeoutMs()
|
||||
{
|
||||
return DefaultRequestTimeoutMs;
|
||||
}
|
||||
|
||||
private sealed class TimeoutWebClient : WebClient
|
||||
{
|
||||
private readonly int _timeoutMs;
|
||||
|
||||
public TimeoutWebClient(int timeoutMs)
|
||||
{
|
||||
_timeoutMs = timeoutMs;
|
||||
}
|
||||
|
||||
protected override WebRequest GetWebRequest(Uri address)
|
||||
{
|
||||
var request = base.GetWebRequest(address);
|
||||
if (request != null)
|
||||
{
|
||||
request.Timeout = _timeoutMs;
|
||||
|
||||
if (request is HttpWebRequest httpRequest)
|
||||
{
|
||||
httpRequest.ReadWriteTimeout = _timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7c3c2304b14e9485ca54182fad73b035
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Implementation of path resolver service with override support
|
||||
/// </summary>
|
||||
public class PathResolverService : IPathResolverService
|
||||
{
|
||||
private bool _hasUvxPathFallback;
|
||||
|
||||
public bool HasUvxPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, null));
|
||||
public bool HasClaudeCliPathOverride => !string.IsNullOrEmpty(EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, null));
|
||||
public bool HasUvxPathFallback => _hasUvxPathFallback;
|
||||
|
||||
public string GetUvxPath()
|
||||
{
|
||||
// Reset fallback flag at the start of each resolution
|
||||
_hasUvxPathFallback = false;
|
||||
|
||||
// Check override first - only validate if explicitly set
|
||||
if (HasUvxPathOverride)
|
||||
{
|
||||
string overridePath = EditorPrefs.GetString(EditorPrefKeys.UvxPathOverride, string.Empty);
|
||||
// Validate the override - if invalid, fall back to system discovery
|
||||
if (TryValidateUvxExecutable(overridePath, out string version))
|
||||
{
|
||||
return overridePath;
|
||||
}
|
||||
// Override is set but invalid - fall back to system discovery
|
||||
string fallbackPath = ResolveUvxFromSystem();
|
||||
if (!string.IsNullOrEmpty(fallbackPath))
|
||||
{
|
||||
_hasUvxPathFallback = true;
|
||||
return fallbackPath;
|
||||
}
|
||||
// Return null to indicate override is invalid and no system fallback found
|
||||
return null;
|
||||
}
|
||||
|
||||
// No override set - try discovery (uvx first, then uv)
|
||||
string discovered = ResolveUvxFromSystem();
|
||||
if (!string.IsNullOrEmpty(discovered))
|
||||
{
|
||||
return discovered;
|
||||
}
|
||||
|
||||
// Fallback to bare command
|
||||
return "uvx";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves uv/uvx from system by trying both commands.
|
||||
/// Returns the full path if found, null otherwise.
|
||||
/// </summary>
|
||||
private static string ResolveUvxFromSystem()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try uvx first, then uv
|
||||
string[] commandNames = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
|
||||
? new[] { "uvx.exe", "uv.exe" }
|
||||
: new[] { "uvx", "uv" };
|
||||
|
||||
foreach (string commandName in commandNames)
|
||||
{
|
||||
foreach (string candidate in EnumerateCommandCandidates(commandName))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Debug($"PathResolver error: {ex.Message}");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
|
||||
public string GetClaudeCliPath()
|
||||
{
|
||||
// Check override first - only validate if explicitly set
|
||||
if (HasClaudeCliPathOverride)
|
||||
{
|
||||
string overridePath = EditorPrefs.GetString(EditorPrefKeys.ClaudeCliPathOverride, string.Empty);
|
||||
// Validate the override - if invalid, don't fall back to discovery
|
||||
if (File.Exists(overridePath))
|
||||
{
|
||||
return overridePath;
|
||||
}
|
||||
// Override is set but invalid - return null (no fallback)
|
||||
return null;
|
||||
}
|
||||
|
||||
// No override: delegate to the shared discovery in ExecPath, which covers the
|
||||
// native-installer, npm, NVM and PATH-scan locations for every platform. Kept in
|
||||
// one place so both call sites (this and ExecPath's own callers) stay in sync.
|
||||
return ExecPath.ResolveClaude();
|
||||
}
|
||||
|
||||
public bool IsPythonDetected()
|
||||
{
|
||||
return ExecPath.TryRun(
|
||||
RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "python.exe" : "python3",
|
||||
"--version",
|
||||
null,
|
||||
out _,
|
||||
out _,
|
||||
2000);
|
||||
}
|
||||
|
||||
public bool IsClaudeCliDetected()
|
||||
{
|
||||
return !string.IsNullOrEmpty(GetClaudeCliPath());
|
||||
}
|
||||
|
||||
public void SetUvxPathOverride(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
ClearUvxPathOverride();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new ArgumentException("The selected uvx executable does not exist");
|
||||
}
|
||||
|
||||
EditorPrefs.SetString(EditorPrefKeys.UvxPathOverride, path);
|
||||
}
|
||||
|
||||
public void SetClaudeCliPathOverride(string path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
ClearClaudeCliPathOverride();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
throw new ArgumentException("The selected Claude CLI executable does not exist");
|
||||
}
|
||||
|
||||
EditorPrefs.SetString(EditorPrefKeys.ClaudeCliPathOverride, path);
|
||||
}
|
||||
|
||||
public void ClearUvxPathOverride()
|
||||
{
|
||||
EditorPrefs.DeleteKey(EditorPrefKeys.UvxPathOverride);
|
||||
}
|
||||
|
||||
public void ClearClaudeCliPathOverride()
|
||||
{
|
||||
EditorPrefs.DeleteKey(EditorPrefKeys.ClaudeCliPathOverride);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates the provided uv executable by running "--version" and parsing the output.
|
||||
/// </summary>
|
||||
/// <param name="uvxPath">Absolute or relative path to the uv/uvx executable.</param>
|
||||
/// <param name="version">Parsed version string if successful.</param>
|
||||
/// <returns>True when the executable runs and returns a uvx version string.</returns>
|
||||
public bool TryValidateUvxExecutable(string uvxPath, out string version)
|
||||
{
|
||||
version = null;
|
||||
|
||||
if (string.IsNullOrEmpty(uvxPath))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if the path is just a command name (no directory separator)
|
||||
bool isBareCommand = !uvxPath.Contains('/') && !uvxPath.Contains('\\');
|
||||
|
||||
if (isBareCommand)
|
||||
{
|
||||
// For bare commands like "uvx" or "uv", use EnumerateCommandCandidates to find full path first
|
||||
string fullPath = FindUvxExecutableInPath(uvxPath);
|
||||
if (string.IsNullOrEmpty(fullPath))
|
||||
return false;
|
||||
uvxPath = fullPath;
|
||||
}
|
||||
|
||||
// Use ExecPath.TryRun which properly handles async output reading and timeouts
|
||||
if (!ExecPath.TryRun(uvxPath, "--version", null, out string stdout, out string stderr, 5000))
|
||||
return false;
|
||||
|
||||
// Check stdout first, then stderr (some tools output to stderr)
|
||||
string versionOutput = !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : stderr.Trim();
|
||||
|
||||
// uv/uvx outputs "uv x.y.z" or "uvx x.y.z", extract version number
|
||||
if (versionOutput.StartsWith("uvx ") || versionOutput.StartsWith("uv "))
|
||||
{
|
||||
// Extract version: "uv 0.9.18 (hash date)" -> "0.9.18"
|
||||
int spaceIndex = versionOutput.IndexOf(' ');
|
||||
if (spaceIndex >= 0)
|
||||
{
|
||||
string afterCommand = versionOutput.Substring(spaceIndex + 1).Trim();
|
||||
// Version is up to the first space or parenthesis
|
||||
int nextSpace = afterCommand.IndexOf(' ');
|
||||
int parenIndex = afterCommand.IndexOf('(');
|
||||
int endIndex = Math.Min(
|
||||
nextSpace >= 0 ? nextSpace : int.MaxValue,
|
||||
parenIndex >= 0 ? parenIndex : int.MaxValue
|
||||
);
|
||||
version = endIndex < int.MaxValue ? afterCommand.Substring(0, endIndex).Trim() : afterCommand;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore validation errors
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private string FindUvxExecutableInPath(string commandName)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Generic search for any command in PATH and common locations
|
||||
foreach (string candidate in EnumerateCommandCandidates(commandName))
|
||||
{
|
||||
if (!string.IsNullOrEmpty(candidate) && File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates candidate paths for a generic command name.
|
||||
/// Searches PATH and common locations.
|
||||
/// </summary>
|
||||
private static IEnumerable<string> EnumerateCommandCandidates(string commandName)
|
||||
{
|
||||
string exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !commandName.EndsWith(".exe")
|
||||
? commandName + ".exe"
|
||||
: commandName;
|
||||
|
||||
// Search PATH first
|
||||
string pathEnv = Environment.GetEnvironmentVariable("PATH");
|
||||
if (!string.IsNullOrEmpty(pathEnv))
|
||||
{
|
||||
foreach (string rawDir in pathEnv.Split(Path.PathSeparator))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(rawDir)) continue;
|
||||
string dir = rawDir.Trim();
|
||||
yield return Path.Combine(dir, exeName);
|
||||
}
|
||||
}
|
||||
|
||||
// User-local binary directories
|
||||
string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
if (!string.IsNullOrEmpty(home))
|
||||
{
|
||||
yield return Path.Combine(home, ".local", "bin", exeName);
|
||||
yield return Path.Combine(home, ".cargo", "bin", exeName);
|
||||
}
|
||||
|
||||
// System directories (platform-specific)
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
{
|
||||
yield return "/opt/homebrew/bin/" + exeName;
|
||||
yield return "/usr/local/bin/" + exeName;
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
{
|
||||
yield return "/usr/local/bin/" + exeName;
|
||||
yield return "/usr/bin/" + exeName;
|
||||
}
|
||||
else if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
{
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
|
||||
|
||||
if (!string.IsNullOrEmpty(localAppData))
|
||||
{
|
||||
yield return Path.Combine(localAppData, "Programs", "uv", exeName);
|
||||
// WinGet creates shim files in this location
|
||||
yield return Path.Combine(localAppData, "Microsoft", "WinGet", "Links", exeName);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(programFiles))
|
||||
{
|
||||
yield return Path.Combine(programFiles, "uv", exeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 00a6188fd15a847fa8cc7cb7a4ce3dce
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// Default implementation of platform detection service
|
||||
/// </summary>
|
||||
public class PlatformService : IPlatformService
|
||||
{
|
||||
/// <summary>
|
||||
/// Checks if the current platform is Windows
|
||||
/// </summary>
|
||||
/// <returns>True if running on Windows</returns>
|
||||
public bool IsWindows()
|
||||
{
|
||||
return Environment.OSVersion.Platform == PlatformID.Win32NT;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the SystemRoot environment variable (Windows-specific)
|
||||
/// </summary>
|
||||
/// <returns>SystemRoot path, or "C:\\Windows" as fallback on Windows, null on other platforms</returns>
|
||||
public string GetSystemRoot()
|
||||
{
|
||||
if (!IsWindows())
|
||||
return null;
|
||||
|
||||
return Environment.GetEnvironmentVariable("SystemRoot") ?? "C:\\Windows";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3b2d7f32a595c45dd8c01f141c69761c
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,167 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using MCPForUnity.Editor.Constants;
|
||||
using MCPForUnity.Editor.Helpers;
|
||||
using MCPForUnity.Editor.Resources;
|
||||
using UnityEditor;
|
||||
|
||||
namespace MCPForUnity.Editor.Services
|
||||
{
|
||||
public class ResourceDiscoveryService : IResourceDiscoveryService
|
||||
{
|
||||
private Dictionary<string, ResourceMetadata> _cachedResources;
|
||||
|
||||
public List<ResourceMetadata> DiscoverAllResources()
|
||||
{
|
||||
if (_cachedResources != null)
|
||||
{
|
||||
return _cachedResources.Values.ToList();
|
||||
}
|
||||
|
||||
_cachedResources = new Dictionary<string, ResourceMetadata>();
|
||||
|
||||
var resourceTypes = TypeCache.GetTypesWithAttribute<McpForUnityResourceAttribute>();
|
||||
foreach (var type in resourceTypes)
|
||||
{
|
||||
McpForUnityResourceAttribute resourceAttr;
|
||||
try
|
||||
{
|
||||
resourceAttr = type.GetCustomAttribute<McpForUnityResourceAttribute>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Warn($"Failed to read [McpForUnityResource] for {type.FullName}: {ex.Message}");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resourceAttr == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var metadata = ExtractResourceMetadata(type, resourceAttr);
|
||||
if (metadata != null)
|
||||
{
|
||||
if (_cachedResources.ContainsKey(metadata.Name))
|
||||
{
|
||||
McpLog.Warn($"Duplicate resource name '{metadata.Name}' from {type.FullName}; overwriting previous registration.");
|
||||
}
|
||||
_cachedResources[metadata.Name] = metadata;
|
||||
EnsurePreferenceInitialized(metadata);
|
||||
}
|
||||
}
|
||||
|
||||
McpLog.Info($"Discovered {_cachedResources.Count} MCP resources via reflection", false);
|
||||
return _cachedResources.Values.ToList();
|
||||
}
|
||||
|
||||
public ResourceMetadata GetResourceMetadata(string resourceName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resourceName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (_cachedResources == null)
|
||||
{
|
||||
DiscoverAllResources();
|
||||
}
|
||||
|
||||
return _cachedResources.TryGetValue(resourceName, out var metadata) ? metadata : null;
|
||||
}
|
||||
|
||||
public List<ResourceMetadata> GetEnabledResources()
|
||||
{
|
||||
return DiscoverAllResources()
|
||||
.Where(r => IsResourceEnabled(r.Name))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public bool IsResourceEnabled(string resourceName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resourceName))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string key = GetResourcePreferenceKey(resourceName);
|
||||
if (EditorPrefs.HasKey(key))
|
||||
{
|
||||
return EditorPrefs.GetBool(key, true);
|
||||
}
|
||||
|
||||
// Default: all resources enabled
|
||||
return true;
|
||||
}
|
||||
|
||||
public void SetResourceEnabled(string resourceName, bool enabled)
|
||||
{
|
||||
if (string.IsNullOrEmpty(resourceName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = GetResourcePreferenceKey(resourceName);
|
||||
EditorPrefs.SetBool(key, enabled);
|
||||
}
|
||||
|
||||
public void InvalidateCache()
|
||||
{
|
||||
_cachedResources = null;
|
||||
}
|
||||
|
||||
private ResourceMetadata ExtractResourceMetadata(Type type, McpForUnityResourceAttribute resourceAttr)
|
||||
{
|
||||
try
|
||||
{
|
||||
string resourceName = resourceAttr.ResourceName;
|
||||
if (string.IsNullOrEmpty(resourceName))
|
||||
{
|
||||
resourceName = StringCaseUtility.ToSnakeCase(type.Name);
|
||||
}
|
||||
|
||||
string description = resourceAttr.Description ?? $"Resource: {resourceName}";
|
||||
|
||||
var metadata = new ResourceMetadata
|
||||
{
|
||||
Name = resourceName,
|
||||
Description = description,
|
||||
ClassName = type.Name,
|
||||
Namespace = type.Namespace ?? "",
|
||||
AssemblyName = type.Assembly.GetName().Name
|
||||
};
|
||||
|
||||
metadata.IsBuiltIn = StringCaseUtility.IsBuiltInMcpType(
|
||||
type, metadata.AssemblyName, "MCPForUnity.Editor.Resources");
|
||||
|
||||
return metadata;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
McpLog.Error($"Failed to extract metadata for resource {type.Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsurePreferenceInitialized(ResourceMetadata metadata)
|
||||
{
|
||||
if (metadata == null || string.IsNullOrEmpty(metadata.Name))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string key = GetResourcePreferenceKey(metadata.Name);
|
||||
if (!EditorPrefs.HasKey(key))
|
||||
{
|
||||
EditorPrefs.SetBool(key, true);
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetResourcePreferenceKey(string resourceName)
|
||||
{
|
||||
return EditorPrefKeys.ResourceEnabledPrefix + resourceName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 66ce49d2cc47a4bd3aa85ac9f099b757
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 1bb072befc9fe4242a501f46dce3fea1
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for managing PID files and handshake state for the local HTTP server.
|
||||
/// Handles persistence of server process information across Unity domain reloads.
|
||||
/// </summary>
|
||||
public interface IPidFileManager
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the directory where PID files are stored.
|
||||
/// </summary>
|
||||
/// <returns>Path to the PID file directory</returns>
|
||||
string GetPidDirectory();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the path to the PID file for a specific port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <returns>Full path to the PID file</returns>
|
||||
string GetPidFilePath(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to read the PID from a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="pid">Output: the process ID if found</param>
|
||||
/// <returns>True if a valid PID was read</returns>
|
||||
bool TryReadPid(string pidFilePath, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to extract the port number from a PID file path.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="port">Output: the port number</param>
|
||||
/// <returns>True if the port was extracted successfully</returns>
|
||||
bool TryGetPortFromPidFilePath(string pidFilePath, out int port);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a PID file.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file to delete</param>
|
||||
void DeletePidFile(string pidFilePath);
|
||||
|
||||
/// <summary>
|
||||
/// Stores the handshake information (PID file path and instance token) in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Path to the PID file</param>
|
||||
/// <param name="instanceToken">Unique instance token for the server</param>
|
||||
void StoreHandshake(string pidFilePath, string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve stored handshake information from EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pidFilePath">Output: stored PID file path</param>
|
||||
/// <param name="instanceToken">Output: stored instance token</param>
|
||||
/// <returns>True if valid handshake information was found</returns>
|
||||
bool TryGetHandshake(out string pidFilePath, out string instanceToken);
|
||||
|
||||
/// <summary>
|
||||
/// Stores PID tracking information in EditorPrefs.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="port">The port number</param>
|
||||
/// <param name="argsHash">Optional hash of the command arguments</param>
|
||||
void StoreTracking(int pid, int port, string argsHash = null);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to retrieve a stored PID for the expected port.
|
||||
/// Validates that the stored information is still valid (within 6-hour window).
|
||||
/// </summary>
|
||||
/// <param name="expectedPort">The expected port number</param>
|
||||
/// <param name="pid">Output: the stored process ID</param>
|
||||
/// <returns>True if a valid stored PID was found</returns>
|
||||
bool TryGetStoredPid(int expectedPort, out int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the stored args hash for the tracked server.
|
||||
/// </summary>
|
||||
/// <returns>The stored args hash, or empty string if not found</returns>
|
||||
string GetStoredArgsHash();
|
||||
|
||||
/// <summary>
|
||||
/// Clears all PID tracking information from EditorPrefs.
|
||||
/// </summary>
|
||||
void ClearTracking();
|
||||
|
||||
/// <summary>
|
||||
/// Computes a short hash of the input string for fingerprinting.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>A short hash string (16 hex characters)</returns>
|
||||
string ComputeShortHash(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f4a4c5d093da74ce79fb29a0670a58a7
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,55 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process inspection operations.
|
||||
/// Provides methods to detect MCP server processes, query process command lines,
|
||||
/// and find processes listening on specific ports.
|
||||
/// </summary>
|
||||
public interface IProcessDetector
|
||||
{
|
||||
/// <summary>
|
||||
/// Determines if a process looks like an MCP server process based on its command line.
|
||||
/// Checks for indicators like uvx, python, mcp-for-unity, uvicorn, etc.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process appears to be an MCP server</returns>
|
||||
bool LooksLikeMcpServerProcess(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to get the command line arguments for a Unix process.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID</param>
|
||||
/// <param name="argsLower">Output: normalized (lowercase, whitespace removed) command line args</param>
|
||||
/// <returns>True if the command line was retrieved successfully</returns>
|
||||
bool TryGetProcessCommandLine(int pid, out string argsLower);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the process IDs of all processes listening on a specific TCP port.
|
||||
/// </summary>
|
||||
/// <param name="port">The port number to check</param>
|
||||
/// <returns>List of process IDs listening on the port</returns>
|
||||
List<int> GetListeningProcessIdsForPort(int port);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current Unity Editor process ID safely.
|
||||
/// </summary>
|
||||
/// <returns>The current process ID, or -1 if it cannot be determined</returns>
|
||||
int GetCurrentProcessId();
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a process exists on Unix systems.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to check</param>
|
||||
/// <returns>True if the process exists</returns>
|
||||
bool ProcessExists(int pid);
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes a string for matching by removing whitespace and converting to lowercase.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>Normalized string for matching</returns>
|
||||
string NormalizeForMatch(string input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 25f32875fb87541b69ead19c08520836
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for platform-specific process termination.
|
||||
/// Provides methods to terminate processes gracefully or forcefully.
|
||||
/// </summary>
|
||||
public interface IProcessTerminator
|
||||
{
|
||||
/// <summary>
|
||||
/// Terminates a process using platform-appropriate methods.
|
||||
/// On Unix: Tries SIGTERM first with grace period, then SIGKILL.
|
||||
/// On Windows: Tries taskkill, then taskkill /F.
|
||||
/// </summary>
|
||||
/// <param name="pid">The process ID to terminate</param>
|
||||
/// <returns>True if the process was terminated successfully</returns>
|
||||
bool Terminate(int pid);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6a55c18e08b534afa85654410da8a463
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace MCPForUnity.Editor.Services.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for building uvx/server command strings.
|
||||
/// Handles platform-specific command construction for starting the MCP HTTP server.
|
||||
/// </summary>
|
||||
public interface IServerCommandBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Attempts to build the command parts for starting the local HTTP server.
|
||||
/// </summary>
|
||||
/// <param name="fileName">Output: the executable file name (e.g., uvx path)</param>
|
||||
/// <param name="arguments">Output: the command arguments</param>
|
||||
/// <param name="displayCommand">Output: the full command string for display</param>
|
||||
/// <param name="error">Output: error message if the command cannot be built</param>
|
||||
/// <returns>True if the command was built successfully</returns>
|
||||
bool TryBuildCommand(out string fileName, out string arguments, out string displayCommand, out string error);
|
||||
|
||||
/// <summary>
|
||||
/// Builds the uv path from the uvx path by replacing uvx with uv.
|
||||
/// </summary>
|
||||
/// <param name="uvxPath">Path to uvx executable</param>
|
||||
/// <returns>Path to uv executable</returns>
|
||||
string BuildUvPathFromUvx(string uvxPath);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the platform-specific PATH prepend string for finding uv/uvx.
|
||||
/// </summary>
|
||||
/// <returns>Paths to prepend to PATH environment variable</returns>
|
||||
string GetPlatformSpecificPathPrepend();
|
||||
|
||||
/// <summary>
|
||||
/// Quotes a string if it contains spaces.
|
||||
/// </summary>
|
||||
/// <param name="input">The input string</param>
|
||||
/// <returns>The string, wrapped in quotes if it contains spaces</returns>
|
||||
string QuoteIfNeeded(string input);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user