diff --git a/MCPForUnity/Editor/Dependencies/UvInstaller.cs b/MCPForUnity/Editor/Dependencies/UvInstaller.cs new file mode 100644 index 000000000..002ae69e0 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/UvInstaller.cs @@ -0,0 +1,93 @@ +using System; +using System.Runtime.InteropServices; +using MCPForUnity.Editor.Helpers; + +namespace MCPForUnity.Editor.Dependencies +{ + /// + /// Runs the official uv installer for the current platform. UI-agnostic: build the command, + /// run it (off the main thread), and report the outcome. The caller owns confirmation and + /// re-checking dependencies afterwards. + /// Installer reference: https://docs.astral.sh/uv/getting-started/installation/ + /// + public static class UvInstaller + { + public readonly struct UvInstallResult + { + public readonly bool Success; + public readonly string Output; + + public UvInstallResult(bool success, string output) + { + Success = success; + Output = output ?? string.Empty; + } + } + + /// One-click install is available on Windows, macOS and Linux. + public static bool IsSupported => + RuntimeInformation.IsOSPlatform(OSPlatform.Windows) || + RuntimeInformation.IsOSPlatform(OSPlatform.OSX) || + RuntimeInformation.IsOSPlatform(OSPlatform.Linux); + + /// + /// Build the installer invocation for the current platform. Pure and testable — the + /// returned command is exactly what executes and what the confirm + /// dialog shows the user. + /// + public static (string file, string arguments) BuildInstallCommand() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return ("powershell", + "-NoProfile -ExecutionPolicy ByPass -c \"irm https://astral.sh/uv/install.ps1 | iex\""); + } + + // macOS and Linux share the POSIX shell installer. + return ("/bin/sh", "-c \"curl -LsSf https://astral.sh/uv/install.sh | sh\""); + } + + /// Human-readable one-line description of the command that will run. + public static string DescribeCommand() + { + var (file, arguments) = BuildInstallCommand(); + return $"{file} {arguments}"; + } + + /// + /// Run the installer and return the outcome. Blocks until the installer exits or the + /// timeout elapses, so call it off the main thread (e.g. via Task.Run). + /// + public static UvInstallResult Run(int timeoutMs = 180000) + { + try + { + var (file, arguments) = BuildInstallCommand(); + bool ok = ExecPath.TryRun(file, arguments, null, out string stdout, out string stderr, timeoutMs); + return new UvInstallResult(ok, Combine(stdout, stderr)); + } + catch (Exception ex) + { + return new UvInstallResult(false, ex.Message); + } + } + + private static string Combine(string stdout, string stderr) + { + string outText = (stdout ?? string.Empty).Trim(); + string errText = (stderr ?? string.Empty).Trim(); + string combined = + string.IsNullOrEmpty(errText) ? outText : + string.IsNullOrEmpty(outText) ? errText : + outText + "\n" + errText; + + // Keep dialogs readable — echo only the tail of long installer output. + const int max = 1500; + if (combined.Length > max) + { + combined = "…" + combined.Substring(combined.Length - max); + } + return combined; + } + } +} diff --git a/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta b/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta new file mode 100644 index 000000000..d3fe2b3c1 --- /dev/null +++ b/MCPForUnity/Editor/Dependencies/UvInstaller.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 55a5a123f9454b75906bdb5b7d008990 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Helpers/ExecPath.cs b/MCPForUnity/Editor/Helpers/ExecPath.cs index 350689821..412542139 100644 --- a/MCPForUnity/Editor/Helpers/ExecPath.cs +++ b/MCPForUnity/Editor/Helpers/ExecPath.cs @@ -34,6 +34,7 @@ internal static string ResolveClaude() "/opt/homebrew/bin/claude", "/usr/local/bin/claude", Path.Combine(home, ".local", "bin", "claude"), + Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location }; foreach (string c in candidates) { if (File.Exists(c)) return c; } // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude @@ -59,6 +60,7 @@ internal static string ResolveClaude() Path.Combine(localAppData, "Programs", "claude", "claude.exe"), Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), Path.Combine(home, ".local", "bin", "claude.exe"), + Path.Combine(home, ".claude", "local", "claude.exe"), // `claude migrate-installer` location // npm global locations (.cmd preferred for non-interactive processes) Path.Combine(appData, "npm", "claude.cmd"), Path.Combine(localAppData, "npm", "claude.cmd"), @@ -81,6 +83,7 @@ internal static string ResolveClaude() "/usr/local/bin/claude", "/usr/bin/claude", Path.Combine(home, ".local", "bin", "claude"), + Path.Combine(home, ".claude", "local", "claude"), // `claude migrate-installer` location }; foreach (string c in candidates) { if (File.Exists(c)) return c; } // Try NVM-installed claude under ~/.nvm/versions/node/*/bin/claude diff --git a/MCPForUnity/Editor/Services/PathResolverService.cs b/MCPForUnity/Editor/Services/PathResolverService.cs index 94adb88e5..c233d8680 100644 --- a/MCPForUnity/Editor/Services/PathResolverService.cs +++ b/MCPForUnity/Editor/Services/PathResolverService.cs @@ -107,60 +107,10 @@ public string GetClaudeCliPath() return null; } - // No override - use platform-specific discovery - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - { - string[] candidates = new[] - { - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs", "claude", "claude.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "claude", "claude.exe"), - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude.exe"), - }; - - foreach (var c in candidates) - { - if (File.Exists(c)) return c; - } - -#if UNITY_EDITOR_WIN - // Fall back to PATH search (handles non-standard install locations and npm shims) - foreach (var name in new[] { "claude.exe", "claude.cmd", "claude.ps1" }) - { - string fromPath = ExecPath.FindInPathWindows(name); - if (!string.IsNullOrEmpty(fromPath)) return fromPath; - } -#endif - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) - { - string[] candidates = new[] - { - "/opt/homebrew/bin/claude", - "/usr/local/bin/claude", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") - }; - - foreach (var c in candidates) - { - if (File.Exists(c)) return c; - } - } - else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) - { - string[] candidates = new[] - { - "/usr/bin/claude", - "/usr/local/bin/claude", - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".local", "bin", "claude") - }; - - foreach (var c in candidates) - { - if (File.Exists(c)) return c; - } - } - - 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() diff --git a/MCPForUnity/Editor/Windows/Components/Branding.meta b/MCPForUnity/Editor/Windows/Components/Branding.meta new file mode 100644 index 000000000..1dd751339 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Branding.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d20507fd66884816930ea8f431fd6ce8 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs b/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs new file mode 100644 index 000000000..c84346d59 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs @@ -0,0 +1,163 @@ +using UnityEngine; +using UnityEngine.UIElements; + +namespace MCPForUnity.Editor.Windows.Components.Branding +{ + /// + /// The MCP for Unity "Ocean split-cube" brand mark. + /// + /// On Unity 2022.1+ it is drawn as resolution-independent vector geometry via UI Toolkit's + /// Painter2D — crisp at any DPI, theme-independent (fixed brand colors matching the source + /// SVG), and with no package dependency. Painter2D does not exist on the 2021.3 floor, so + /// there we fall back to the raster brand icon (package-icon.png) that ships with the package. + /// + /// Geometry is transcribed from website/static/img/logo-mark.svg (viewBox "30 30 140 140"), + /// which remains the human-facing source of truth. Size the element via USS (width/height); + /// the drawing scales to the resolved content box. + /// + public sealed class OceanMark : VisualElement + { + public OceanMark() + { +#if UNITY_2022_1_OR_NEWER + generateVisualContent += OnGenerateVisualContent; + RegisterCallback(_ => MarkDirtyRepaint()); +#else + ApplyRasterFallback(); +#endif + pickingMode = PickingMode.Ignore; // purely decorative + } + +#if UNITY_2022_1_OR_NEWER + // The source SVG viewBox spans 30..170 on both axes (a 140-unit square). + private const float SvgOrigin = 30f; + private const float SvgSize = 140f; + + // Brand palette (hex values lifted directly from logo-mark.svg). + private static readonly Color LeftFill = FromHex(0x2563EB); // blue cube faces + private static readonly Color LeftStroke = FromHex(0x60A5FA); // blue cube outline + private static readonly Color RightFill = FromHex(0x0D9488); // teal cube faces + private static readonly Color RightStroke = FromHex(0x2DD4BF);// teal cube outline + private static readonly Color Bridge = FromHex(0x22D3EE); // cyan bridge + + private void OnGenerateVisualContent(MeshGenerationContext mgc) + { + Rect rect = contentRect; + float box = Mathf.Min(rect.width, rect.height); + if (box <= 1f) return; // not laid out yet / too small to draw + + float scale = box / SvgSize; + Vector2 P(float x, float y) => MapSvgPoint(x, y, rect); + float S(float w) => w * scale; + + Painter2D p = mgc.painter2D; + p.lineJoin = LineJoin.Round; + p.lineCap = LineCap.Round; + + // --- Left cube (blue) --- + FillPoly(p, WithAlpha(LeftFill, 0.22f), P(94, 40), P(42, 70), P(94, 100)); + FillPoly(p, WithAlpha(LeftFill, 0.12f), P(42, 70), P(42, 130), P(94, 160), P(94, 100)); + StrokePath(p, LeftStroke, S(3f), true, P(94, 40), P(42, 70), P(42, 130), P(94, 160)); + StrokePath(p, WithAlpha(LeftStroke, 0.65f), S(1.8f), false, P(42, 70), P(94, 100)); + + // --- Right cube (teal) --- + FillPoly(p, WithAlpha(RightFill, 0.22f), P(106, 40), P(158, 70), P(106, 100)); + FillPoly(p, WithAlpha(RightFill, 0.12f), P(158, 70), P(158, 130), P(106, 160), P(106, 100)); + StrokePath(p, RightStroke, S(3f), true, P(106, 40), P(158, 70), P(158, 130), P(106, 160)); + StrokePath(p, WithAlpha(RightStroke, 0.65f), S(1.8f), false, P(158, 70), P(106, 100)); + + // --- Bridge rungs (cyan) --- + StrokePath(p, Bridge, S(3f), false, P(94, 70), P(106, 70)); + StrokePath(p, Bridge, S(3f), false, P(94, 100), P(106, 100)); + StrokePath(p, Bridge, S(3f), false, P(94, 130), P(106, 130)); + + // --- Bridge dots (cyan) --- + float dot = S(2.2f); + FillDot(p, Bridge, P(94, 70), dot); + FillDot(p, Bridge, P(106, 70), dot); + FillDot(p, Bridge, P(94, 100), dot); + FillDot(p, Bridge, P(106, 100), dot); + FillDot(p, Bridge, P(94, 130), dot); + FillDot(p, Bridge, P(106, 130), dot); + } + + private static void FillPoly(Painter2D p, Color color, params Vector2[] pts) + { + p.fillColor = color; + p.BeginPath(); + p.MoveTo(pts[0]); + for (int i = 1; i < pts.Length; i++) p.LineTo(pts[i]); + p.ClosePath(); + p.Fill(); + } + + private static void StrokePath(Painter2D p, Color color, float width, bool closed, params Vector2[] pts) + { + p.strokeColor = color; + p.lineWidth = width; + p.BeginPath(); + p.MoveTo(pts[0]); + for (int i = 1; i < pts.Length; i++) p.LineTo(pts[i]); + if (closed) p.ClosePath(); + p.Stroke(); + } + + private static void FillDot(Painter2D p, Color color, Vector2 center, float radius) + { + if (radius <= 0.01f) return; + p.fillColor = color; + p.BeginPath(); + p.Arc(center, radius, new Angle(0f, AngleUnit.Degree), new Angle(360f, AngleUnit.Degree)); + p.Fill(); + } + + private static Color WithAlpha(Color c, float a) + { + c.a = a; + return c; + } + + /// Map a point from SVG viewBox space into the element's content rect (square, centered). + private static Vector2 MapSvgPoint(float svgX, float svgY, Rect content) + { + float box = Mathf.Min(content.width, content.height); + float scale = box / SvgSize; + float offsetX = (content.width - box) * 0.5f; + float offsetY = (content.height - box) * 0.5f; + return new Vector2( + offsetX + (svgX - SvgOrigin) * scale, + offsetY + (svgY - SvgOrigin) * scale); + } + + /// Convert a 0xRRGGBB literal into an opaque Color. + private static Color FromHex(int rgb) => new Color( + ((rgb >> 16) & 0xFF) / 255f, + ((rgb >> 8) & 0xFF) / 255f, + (rgb & 0xFF) / 255f, + 1f); +#else + // Painter2D is unavailable before Unity 2022.1 — show the raster brand icon instead. + private void ApplyRasterFallback() + { + Texture2D tex = LoadBrandTexture(); + if (tex == null) return; + style.backgroundImage = new StyleBackground(tex); + style.unityBackgroundScaleMode = ScaleMode.ScaleToFit; + } + + private static Texture2D LoadBrandTexture() + { + try + { + string root = MCPForUnity.Editor.Helpers.AssetPathUtility.GetMcpPackageRootPath(); + return UnityEditor.AssetDatabase.LoadAssetAtPath($"{root}/package-icon.png"); + } + catch (System.Exception ex) + { + MCPForUnity.Editor.Helpers.McpLog.Warn($"OceanMark: failed to load brand icon fallback: {ex.Message}"); + return null; + } + } +#endif + } +} diff --git a/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs.meta b/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs.meta new file mode 100644 index 000000000..3526141e2 --- /dev/null +++ b/MCPForUnity/Editor/Windows/Components/Branding/OceanMark.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: a5a6cc86fe9844309b97362a718c3edd +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/MCPForUnity/Editor/Windows/Components/Common.uss b/MCPForUnity/Editor/Windows/Components/Common.uss index ecffd6a9c..c380799d7 100644 --- a/MCPForUnity/Editor/Windows/Components/Common.uss +++ b/MCPForUnity/Editor/Windows/Components/Common.uss @@ -170,6 +170,23 @@ -unity-text-align: middle-left; } +/* At narrow widths the connection status label (e.g. "Session Active (Project)") must wrap/shrink + instead of sliding under the toggle button. Scoped to the connection section so other shared + .status-text rows are unaffected; uses wrapping (not ellipsis) so the text stays fully readable. */ +#connection-section .status-container { + flex-shrink: 1; + min-width: 0; +} + +#connection-section .status-text { + flex-shrink: 1; + white-space: normal; +} + +#connection-section #connection-toggle { + flex-shrink: 0; +} + .status-indicator-small { width: 8px; height: 8px; diff --git a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs index bd1e05844..085593905 100644 --- a/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs +++ b/MCPForUnity/Editor/Windows/MCPForUnityEditorWindow.cs @@ -7,6 +7,7 @@ using MCPForUnity.Editor.Services; using MCPForUnity.Editor.Windows.Components.Advanced; using MCPForUnity.Editor.Windows.Components.AssetGen; +using MCPForUnity.Editor.Windows.Components.Branding; using MCPForUnity.Editor.Windows.Components.ClientConfig; using MCPForUnity.Editor.Windows.Components.Connection; using MCPForUnity.Editor.Windows.Components.Resources; @@ -185,6 +186,15 @@ public void CreateGUI() rootVisualElement.styleSheets.Add(commonStyleSheet); } + // Embed the Ocean brand mark at the left of the header bar + var headerLeft = rootVisualElement.Q("header-left"); + if (headerLeft != null && headerLeft.Q() == null) + { + var logo = new OceanMark { name = "header-logo" }; + logo.AddToClassList("header-logo"); + headerLeft.Insert(0, logo); + } + // Cache UI elements versionLabel = rootVisualElement.Q