Files
Shutong Wu 301511700c fix(editor): survive domain reloads in HTTP auto-start and reload-resume (#1229)
Auto-start died for the whole session whenever startup included a compile:
the ctor latched SessionState before the delayCall ran, and the reload wiped
the delayCall. Reload-resume died at multi-pass compiles: the one-shot flag
was consumed before the deferred (delayCall) resume ran, and the next
boundary deleted it again.

- Replace delayCall with EditorApplication.update ticks that the
  [InitializeOnLoad] ctor re-arms on every domain load; latch only when the
  deferred work actually dispatches, retry (bounded per domain) while editor
  services are still initializing, and skip the subscription entirely in the
  common case where auto-start is off and nothing is pending.
- Move the resume flag from EditorPrefs (per-user machine-global, survives
  crashes, leaks across concurrently open editors) to SessionState; keep it
  until the resume succeeds, is cancelled, or exhausts its retries, instead
  of consuming it at boundaries where the bridge is down. Manual Connect,
  End Session, transport switch, and orphan cleanup cancel a pending resume
  through a named seam (CancelPendingResume), which also aborts an in-flight
  retry loop; exhaustion erases the flag so later reloads don't replay 49s
  failure loops.
- Serialize TransportManager.StartAsync per mode: concurrent starts coalesce
  onto one in-flight attempt, so a manual Connect can no longer race the
  resume/auto-start loops into bouncing a just-established session
  (WebSocketTransportClient.StartAsync tears down a live connection first).
- A SessionState connect-pending marker lets the next domain load finish an
  auto-start whose connect phase a reload killed — connect-only, never
  re-spawning (StartLocalHttpServer stops a still-booting server first).
  Whether a launch-process handle exists is now answered live by
  ServerManagementService.HasManagedServerLaunchHandle; without one (post-
  reload, or an externally started server) the wait polls to the 5-minute
  hard cap instead of fail-fasting.
- Busy gate uses EditorStateCache.GetActualIsCompiling (now internal, with
  the CompilationPipeline reflection bound once as a delegate): raw
  isCompiling stays true all play session under
  Recompile-After-Finished-Playing (#549).
- One-time (per session) migration deletes the legacy EditorPrefs flag.

The stdio sibling has the same defect class (StdioBridgeReloadHandler.cs:65
delete-when-not-running, :133 delayCall) — follow-up, kept out of scope here,
along with the remaining stdio copies of the isCompiling probe.
2026-07-04 10:46:24 -07:00

421 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using MCPForUnity.Editor.Constants;
using MCPForUnity.Editor.Helpers;
using UnityEditor;
using UnityEngine;
using UnityEngine.UIElements;
namespace MCPForUnity.Editor.Windows
{
/// <summary>
/// Editor window for managing Unity EditorPrefs, specifically for MCP for Unity development
/// </summary>
public class EditorPrefsWindow : EditorWindow
{
// UI Elements
private ScrollView scrollView;
private VisualElement prefsContainer;
private TextField searchField;
private string searchFilter = "";
// Data
private List<EditorPrefItem> currentPrefs = new List<EditorPrefItem>();
private HashSet<string> knownMcpKeys = new HashSet<string>();
// Type mapping for known EditorPrefs
private readonly Dictionary<string, EditorPrefType> knownPrefTypes = new Dictionary<string, EditorPrefType>
{
// Boolean prefs
{ EditorPrefKeys.DebugLogs, EditorPrefType.Bool },
{ EditorPrefKeys.UseHttpTransport, EditorPrefType.Bool },
{ EditorPrefKeys.ResumeStdioAfterReload, EditorPrefType.Bool },
{ EditorPrefKeys.UseEmbeddedServer, EditorPrefType.Bool },
{ EditorPrefKeys.LockCursorConfig, EditorPrefType.Bool },
{ EditorPrefKeys.AutoRegisterEnabled, EditorPrefType.Bool },
{ EditorPrefKeys.SetupCompleted, EditorPrefType.Bool },
{ EditorPrefKeys.SetupDismissed, EditorPrefType.Bool },
{ EditorPrefKeys.CustomToolRegistrationEnabled, EditorPrefType.Bool },
{ EditorPrefKeys.TelemetryDisabled, EditorPrefType.Bool },
{ EditorPrefKeys.DevModeForceServerRefresh, EditorPrefType.Bool },
{ EditorPrefKeys.ProjectScopedToolsLocalHttp, EditorPrefType.Bool },
{ EditorPrefKeys.AllowLanHttpBind, EditorPrefType.Bool },
{ EditorPrefKeys.AllowInsecureRemoteHttp, EditorPrefType.Bool },
// Integer prefs
{ EditorPrefKeys.UnitySocketPort, EditorPrefType.Int },
{ EditorPrefKeys.ValidationLevel, EditorPrefType.Int },
{ EditorPrefKeys.LastUpdateCheck, EditorPrefType.String },
{ EditorPrefKeys.LastStdIoUpgradeVersion, EditorPrefType.Int },
{ EditorPrefKeys.LastLocalHttpServerPid, EditorPrefType.Int },
{ EditorPrefKeys.LastLocalHttpServerPort, EditorPrefType.Int },
// String prefs
{ EditorPrefKeys.EditorWindowActivePanel, EditorPrefType.String },
{ EditorPrefKeys.ClaudeCliPathOverride, EditorPrefType.String },
{ EditorPrefKeys.UvxPathOverride, EditorPrefType.String },
{ EditorPrefKeys.HttpBaseUrl, EditorPrefType.String },
{ EditorPrefKeys.HttpRemoteBaseUrl, EditorPrefType.String },
{ EditorPrefKeys.HttpTransportScope, EditorPrefType.String },
{ EditorPrefKeys.SessionId, EditorPrefType.String },
{ EditorPrefKeys.WebSocketUrlOverride, EditorPrefType.String },
{ EditorPrefKeys.GitUrlOverride, EditorPrefType.String },
{ EditorPrefKeys.PackageDeploySourcePath, EditorPrefType.String },
{ EditorPrefKeys.PackageDeployLastBackupPath, EditorPrefType.String },
{ EditorPrefKeys.PackageDeployLastTargetPath, EditorPrefType.String },
{ EditorPrefKeys.PackageDeployLastSourcePath, EditorPrefType.String },
{ EditorPrefKeys.ServerSrc, EditorPrefType.String },
{ EditorPrefKeys.LastSelectedClientId, EditorPrefType.String },
{ EditorPrefKeys.LatestKnownVersion, EditorPrefType.String },
{ EditorPrefKeys.LastAssetStoreUpdateCheck, EditorPrefType.String },
{ EditorPrefKeys.LatestKnownAssetStoreVersion, EditorPrefType.String },
{ EditorPrefKeys.LastLocalHttpServerStartedUtc, EditorPrefType.String },
{ EditorPrefKeys.LastLocalHttpServerPidArgsHash, EditorPrefType.String },
{ EditorPrefKeys.LastLocalHttpServerPidFilePath, EditorPrefType.String },
{ EditorPrefKeys.LastLocalHttpServerInstanceToken, EditorPrefType.String },
};
// Templates
private VisualTreeAsset itemTemplate;
/// <summary>
/// Show the EditorPrefs window
/// </summary>
public static void ShowWindow()
{
var window = GetWindow<EditorPrefsWindow>("EditorPrefs");
window.minSize = new Vector2(600, 400);
window.Show();
}
public void CreateGUI()
{
// Clear search filter on GUI recreation to avoid stale filtered results
searchFilter = "";
string basePath = AssetPathUtility.GetMcpPackageRootPath();
// Load UXML
var visualTree = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
$"{basePath}/Editor/Windows/EditorPrefs/EditorPrefsWindow.uxml"
);
if (visualTree == null)
{
McpLog.Error("Failed to load EditorPrefsWindow.uxml template");
return;
}
// Load item template
itemTemplate = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(
$"{basePath}/Editor/Windows/EditorPrefs/EditorPrefItem.uxml"
);
if (itemTemplate == null)
{
McpLog.Error("Failed to load EditorPrefItem.uxml template");
return;
}
visualTree.CloneTree(rootVisualElement);
// Add search bar container at the top
var searchContainer = new VisualElement();
searchContainer.style.flexDirection = FlexDirection.Row;
searchContainer.style.marginTop = 8;
searchContainer.style.marginBottom = 20;
searchContainer.style.marginLeft = 4;
searchContainer.style.marginRight = 4;
searchField = new TextField("Search");
searchField.style.flexGrow = 1;
searchField.style.height = 28;
searchField.style.paddingTop = 2;
searchField.style.paddingBottom = 2;
searchField.labelElement.style.unityFontStyleAndWeight = FontStyle.Bold;
searchField.RegisterValueChangedCallback(evt =>
{
searchFilter = evt.newValue ?? "";
RefreshPrefs();
});
var refreshButton = new Button(RefreshPrefs);
refreshButton.text = "↻";
refreshButton.tooltip = "Refresh prefs";
refreshButton.style.width = 30;
refreshButton.style.height = 28;
refreshButton.style.marginLeft = 6;
refreshButton.style.backgroundColor = new Color(0.9f, 0.5f, 0.1f);
searchContainer.Add(searchField);
searchContainer.Add(refreshButton);
rootVisualElement.Insert(0, searchContainer);
// Get references
scrollView = rootVisualElement.Q<ScrollView>("scroll-view");
prefsContainer = rootVisualElement.Q<VisualElement>("prefs-container");
// Load known MCP keys
LoadKnownMcpKeys();
// Load initial data
RefreshPrefs();
}
private void LoadKnownMcpKeys()
{
knownMcpKeys.Clear();
var fields = typeof(EditorPrefKeys).GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static);
foreach (var field in fields)
{
if (field.IsLiteral && !field.IsInitOnly)
{
knownMcpKeys.Add(field.GetValue(null).ToString());
}
}
}
private void RefreshPrefs()
{
currentPrefs.Clear();
prefsContainer.Clear();
// Get all EditorPrefs keys
var allKeys = new List<string>();
// Always show all MCP keys
allKeys.AddRange(knownMcpKeys);
// Try to find additional MCP keys
var mcpKeys = GetAllMcpKeys();
foreach (var key in mcpKeys)
{
if (!allKeys.Contains(key))
{
allKeys.Add(key);
}
}
// Sort keys
allKeys.Sort();
// Pre-trim filter once outside the loop
var filter = searchFilter?.Trim();
// Create items for existing prefs
foreach (var key in allKeys)
{
// Skip Customer UUID but show everything else that's defined
if (key != EditorPrefKeys.CustomerUuid)
{
// Apply search filter using OrdinalIgnoreCase for fewer allocations
if (!string.IsNullOrEmpty(filter) &&
key.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
{
continue;
}
var item = CreateEditorPrefItem(key);
if (item != null)
{
currentPrefs.Add(item);
prefsContainer.Add(CreateItemUI(item));
}
}
}
}
private List<string> GetAllMcpKeys()
{
// This is a simplified approach - in reality, getting all EditorPrefs is platform-specific
// For now, we'll return known MCP keys that might exist
var keys = new List<string>();
// Add some common MCP keys that might not be in EditorPrefKeys
keys.Add("MCPForUnity.TestKey");
// Filter to only those that actually exist
return keys.Where(EditorPrefs.HasKey).ToList();
}
private EditorPrefItem CreateEditorPrefItem(string key)
{
var item = new EditorPrefItem { Key = key, IsKnown = knownMcpKeys.Contains(key) };
// Check if we know the type of this pref
if (knownPrefTypes.TryGetValue(key, out var knownType))
{
// Check if the key actually exists
item.IsUnset = !EditorPrefs.HasKey(key);
// Use the known type
switch (knownType)
{
case EditorPrefType.Bool:
item.Type = EditorPrefType.Bool;
item.Value = item.IsUnset ? "Unset. Default: False" : EditorPrefs.GetBool(key, false).ToString();
break;
case EditorPrefType.Int:
item.Type = EditorPrefType.Int;
item.Value = item.IsUnset ? "Unset. Default: 0" : EditorPrefs.GetInt(key, 0).ToString();
break;
case EditorPrefType.Float:
item.Type = EditorPrefType.Float;
item.Value = item.IsUnset ? "Unset. Default: 0" : EditorPrefs.GetFloat(key, 0f).ToString();
break;
case EditorPrefType.String:
item.Type = EditorPrefType.String;
item.Value = item.IsUnset ? "Unset. Default: (empty)" : EditorPrefs.GetString(key, "");
break;
}
}
else
{
// Only try to detect type for unknown keys that actually exist
if (!EditorPrefs.HasKey(key))
{
// Key doesn't exist and we don't know its type, skip it
return null;
}
// Unknown pref - try to detect type
var stringValue = EditorPrefs.GetString(key, "");
if (int.TryParse(stringValue, out var intValue))
{
item.Type = EditorPrefType.Int;
item.Value = intValue.ToString();
}
else if (float.TryParse(stringValue, out var floatValue))
{
item.Type = EditorPrefType.Float;
item.Value = floatValue.ToString();
}
else if (bool.TryParse(stringValue, out var boolValue))
{
item.Type = EditorPrefType.Bool;
item.Value = boolValue.ToString();
}
else
{
item.Type = EditorPrefType.String;
item.Value = stringValue;
}
}
return item;
}
private VisualElement CreateItemUI(EditorPrefItem item)
{
if (itemTemplate == null)
{
McpLog.Error("Item template not loaded");
return new VisualElement();
}
var itemElement = itemTemplate.CloneTree();
// Set values
itemElement.Q<Label>("key-label").text = item.Key;
var valueField = itemElement.Q<TextField>("value-field");
valueField.value = item.Value;
var typeDropdown = itemElement.Q<DropdownField>("type-dropdown");
typeDropdown.index = (int)item.Type;
// Buttons
var saveButton = itemElement.Q<Button>("save-button");
// Style unset items
if (item.IsUnset)
{
valueField.SetEnabled(false);
valueField.style.opacity = 0.6f;
saveButton.SetEnabled(false);
}
// Callbacks
saveButton.clicked += () => SavePref(item, valueField.value, (EditorPrefType)typeDropdown.index);
return itemElement;
}
private void SavePref(EditorPrefItem item, string newValue, EditorPrefType newType)
{
SaveValue(item.Key, newValue, newType);
RefreshPrefs();
}
private void SaveValue(string key, string value, EditorPrefType type)
{
switch (type)
{
case EditorPrefType.String:
EditorPrefs.SetString(key, value);
break;
case EditorPrefType.Int:
if (int.TryParse(value, out var intValue))
{
EditorPrefs.SetInt(key, intValue);
}
else
{
EditorUtility.DisplayDialog("Error", $"Cannot convert '{value}' to int", "OK");
return;
}
break;
case EditorPrefType.Float:
if (float.TryParse(value, out var floatValue))
{
EditorPrefs.SetFloat(key, floatValue);
}
else
{
EditorUtility.DisplayDialog("Error", $"Cannot convert '{value}' to float", "OK");
return;
}
break;
case EditorPrefType.Bool:
if (bool.TryParse(value, out var boolValue))
{
EditorPrefs.SetBool(key, boolValue);
}
else
{
EditorUtility.DisplayDialog("Error", $"Cannot convert '{value}' to bool (use 'True' or 'False')", "OK");
return;
}
break;
}
}
}
/// <summary>
/// Represents an EditorPrefs item
/// </summary>
public class EditorPrefItem
{
public string Key { get; set; }
public string Value { get; set; }
public EditorPrefType Type { get; set; }
public bool IsKnown { get; set; }
public bool IsUnset { get; set; }
}
/// <summary>
/// EditorPrefs value types
/// </summary>
public enum EditorPrefType
{
String,
Int,
Float,
Bool
}
}