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 } /// /// 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. /// 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; } /// /// 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. /// [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 ImportOverrideForTests; internal static double PollIntervalSeconds = 3.0; internal static double TimeoutSeconds = 600.0; private static readonly Dictionary Jobs = new(); private static readonly Dictionary Runners = new(); private static readonly List _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(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); /// Most-recent-first snapshot of known jobs (for the GUI readout). Never contains keys. public static IReadOnlyList RecentJobs(int max = 20) { var all = new List(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> SubmitFn; public Func> PollFn; public Func 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 SubmitTask; public Task PollTask; public Task 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); } } /// Advance one job one step. Returns true when the job is terminal. 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(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; /// Only http(s) download URLs are allowed; provider responses are untrusted. 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(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; } } }