using System; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Text; using UnityEditor; using UnityEngine; namespace MCPForUnity.Editor.Setup { public class McpForUnitySkillInstaller : EditorWindow { private const string RepoUrlKey = "UnityMcpSkillSync.RepoUrl"; private const string BranchKey = "UnityMcpSkillSync.Branch"; private const string CliKey = "UnityMcpSkillSync.Cli"; private const string InstallDirKey = "UnityMcpSkillSync.InstallDir"; private const string CodexCli = "codex"; private const string ClaudeCli = "claude"; private static readonly string[] BranchOptions = { "beta", "main" }; private static readonly string[] CliOptions = { CodexCli, ClaudeCli }; private string _repoUrl; private string _targetBranch; private string _cliType; private string _installDir; private Vector2 _scroll; private volatile bool _isRunning; private readonly ConcurrentQueue _pendingLogs = new(); private readonly StringBuilder _logBuilder = new(4096); public static void OpenWindow() { GetWindow("Unity MCP Skill Install(Sync)"); } private void OnEnable() { var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); _repoUrl = EditorPrefs.GetString(RepoUrlKey, "https://github.com/CoplayDev/unity-mcp"); _targetBranch = EditorPrefs.GetString(BranchKey, "beta"); if (!BranchOptions.Contains(_targetBranch)) { _targetBranch = "beta"; } _cliType = EditorPrefs.GetString(CliKey, CodexCli); if (!CliOptions.Contains(_cliType)) { _cliType = CodexCli; } _installDir = EditorPrefs.GetString(InstallDirKey, GetDefaultInstallDir(userHome, _cliType)); EditorApplication.update += OnEditorUpdate; } private void OnDisable() { EditorApplication.update -= OnEditorUpdate; EditorPrefs.SetString(RepoUrlKey, _repoUrl); EditorPrefs.SetString(BranchKey, _targetBranch); EditorPrefs.SetString(CliKey, _cliType); EditorPrefs.SetString(InstallDirKey, _installDir); } private void OnGUI() { FlushPendingLogs(); EditorGUILayout.HelpBox("Sync Unity MCP Skill to the latest on the selected branch and output the changed file list.", MessageType.Info); EditorGUILayout.Space(4f); EditorGUILayout.LabelField("Config", EditorStyles.boldLabel); using (new EditorGUI.DisabledScope(_isRunning)) { _repoUrl = EditorGUILayout.TextField("Repo URL", _repoUrl); var branchIndex = Array.IndexOf(BranchOptions, _targetBranch); if (branchIndex < 0) { branchIndex = 0; } var selectedBranchIndex = EditorGUILayout.Popup("Branch", branchIndex, BranchOptions); _targetBranch = BranchOptions[selectedBranchIndex]; var cliIndex = Array.IndexOf(CliOptions, _cliType); if (cliIndex < 0) { cliIndex = 0; } var selectedCliIndex = EditorGUILayout.Popup("CLI", cliIndex, CliOptions); if (selectedCliIndex != cliIndex) { var previousCli = _cliType; _cliType = CliOptions[selectedCliIndex]; TryApplyCliDefaultInstallPath(previousCli, _cliType); } _installDir = EditorGUILayout.TextField("Install Dir", _installDir); } EditorGUILayout.Space(8f); EditorGUILayout.BeginHorizontal(); using (new EditorGUI.DisabledScope(_isRunning)) { if (GUILayout.Button($"Sync Latest ({_targetBranch})", GUILayout.Height(32f))) { AppendLineImmediate("Sync task queued..."); AppendLineImmediate("Will use GitHub API to read the remote directory tree and perform incremental sync (no repository clone)."); RunSyncLatest(); } } if (GUILayout.Button("Clear Log", GUILayout.Width(100f), GUILayout.Height(32f))) { _logBuilder.Clear(); while (_pendingLogs.TryDequeue(out _)) { } } EditorGUILayout.EndHorizontal(); EditorGUILayout.Space(8f); EditorGUILayout.LabelField("Output", EditorStyles.boldLabel); _scroll = EditorGUILayout.BeginScrollView(_scroll); EditorGUILayout.TextArea(_logBuilder.ToString(), GUILayout.ExpandHeight(true)); EditorGUILayout.EndScrollView(); } private void OnEditorUpdate() { var changed = FlushPendingLogs(); if (_isRunning || changed) { Repaint(); } } private void RunSyncLatest() { if (_isRunning) { return; } _isRunning = true; SkillSyncService.SyncAsync(_repoUrl, _installDir, _targetBranch, line => _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] {SanitizeLogLine(line)}"), result => { _isRunning = false; if (result.Success) { _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] Sync complete: +{result.Added} ~{result.Updated} -{result.Deleted}"); } else { _pendingLogs.Enqueue($"[{DateTime.Now:HH:mm:ss}] [ERROR] {result.Error}"); } }); } private void TryApplyCliDefaultInstallPath(string previousCli, string currentCli) { var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); var previousDefaultInstall = GetDefaultInstallDir(userHome, previousCli); var currentDefaultInstall = GetDefaultInstallDir(userHome, currentCli); if (string.IsNullOrWhiteSpace(_installDir) || PathsEqual(_installDir, previousDefaultInstall)) { _installDir = currentDefaultInstall; } } private static string GetDefaultInstallDir(string userHome, string cliType) { var baseDir = IsClaudeCli(cliType) ? ".claude" : ".codex"; return Path.Combine(userHome, baseDir, "skills/unity-mcp-skill"); } private static bool IsClaudeCli(string cliType) { return string.Equals(cliType, ClaudeCli, StringComparison.Ordinal); } private static bool PathsEqual(string left, string right) { if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) { return false; } try { return string.Equals( SkillSyncService.ExpandPath(left), SkillSyncService.ExpandPath(right), StringComparison.Ordinal); } catch { return false; } } private void AppendLineImmediate(string line) { var sanitized = SanitizeLogLine(line); if (string.IsNullOrWhiteSpace(sanitized)) { return; } _logBuilder.AppendLine($"[{DateTime.Now:HH:mm:ss}] {sanitized}"); _scroll.y = float.MaxValue; Repaint(); } private bool FlushPendingLogs() { var hasNewLine = false; while (_pendingLogs.TryDequeue(out var line)) { _logBuilder.AppendLine(line); hasNewLine = true; } if (hasNewLine) { _scroll.y = float.MaxValue; } return hasNewLine; } private static string SanitizeLogLine(string line) { if (string.IsNullOrEmpty(line)) { return string.Empty; } var sb = new StringBuilder(line.Length); var inEscape = false; foreach (var ch in line) { if (inEscape) { if (ch >= '@' && ch <= '~') { inEscape = false; } continue; } if (ch == '\u001b') { inEscape = true; continue; } if (ch == '\t' || (ch >= ' ' && ch != 127)) { sb.Append(ch); } } return sb.ToString().Trim(); } } }