diff --git a/Editor/Discovery.cs b/Editor/Discovery.cs index 74a7c9d..06c1a45 100644 --- a/Editor/Discovery.cs +++ b/Editor/Discovery.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; +using UnityEngine; namespace Microsoft.Unity.VisualStudio.Editor { @@ -13,7 +14,9 @@ namespace Microsoft.Unity.VisualStudio.Editor { public static IEnumerable GetVisualStudioInstallations() { - foreach (var installation in VisualStudioCodeInstallation.GetVisualStudioInstallations()) + foreach (var installation in VisualStudioCursorInstallation.GetVisualStudioInstallations()) + yield return installation; + foreach (var installation in VisualStudioCodiumInstallation.GetVisualStudioInstallations()) yield return installation; } @@ -21,7 +24,10 @@ namespace Microsoft.Unity.VisualStudio.Editor { try { - if (VisualStudioCodeInstallation.TryDiscoverInstallation(editorPath, out installation)) + Debug.Log(editorPath); + if (VisualStudioCursorInstallation.TryDiscoverInstallation(editorPath, out installation)) + return true; + if (VisualStudioCodiumInstallation.TryDiscoverInstallation(editorPath, out installation)) return true; } catch (IOException) @@ -34,7 +40,8 @@ namespace Microsoft.Unity.VisualStudio.Editor public static void Initialize() { - VisualStudioCodeInstallation.Initialize(); + VisualStudioCursorInstallation.Initialize(); + VisualStudioCodiumInstallation.Initialize(); } } } diff --git a/Editor/VisualStudioCodiumInstallation.cs b/Editor/VisualStudioCodiumInstallation.cs new file mode 100644 index 0000000..36ab8b3 --- /dev/null +++ b/Editor/VisualStudioCodiumInstallation.cs @@ -0,0 +1,479 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using UnityEngine; +using SimpleJSON; +using IOPath = System.IO.Path; + +namespace Microsoft.Unity.VisualStudio.Editor { + internal class VisualStudioCodiumInstallation : VisualStudioInstallation { + private static readonly IGenerator _generator = new SdkStyleProjectGeneration(); + + public override bool SupportsAnalyzers { + get { + return true; + } + } + + public override Version LatestLanguageVersionSupported { + get { + return new Version(11, 0); + } + } + + private string GetExtensionPath() { + var vscode = IsPrerelease ? ".vscode-insiders" : ".vscode"; + var extensionsPath = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), vscode, "extensions"); + if (!Directory.Exists(extensionsPath)) + return null; + + return Directory + .EnumerateDirectories(extensionsPath, $"{MicrosoftUnityExtensionId}*") // publisherid.extensionid + .OrderByDescending(n => n) + .FirstOrDefault(); + } + + public override string[] GetAnalyzers() { + var vstuPath = GetExtensionPath(); + if (string.IsNullOrEmpty(vstuPath)) + return Array.Empty(); + + return GetAnalyzers(vstuPath); + } + + public override IGenerator ProjectGenerator { + get { + return _generator; + } + } + + private static bool IsCandidateForDiscovery(string path) { +#if UNITY_EDITOR_OSX + return Directory.Exists(path) && Regex.IsMatch(path, ".*Codium.*.app$", RegexOptions.IgnoreCase); +#elif UNITY_EDITOR_WIN + return File.Exists(path) && Regex.IsMatch(path, ".*Codium.*.exe$", RegexOptions.IgnoreCase); +#else + return File.Exists(path) && path.EndsWith("Codium", StringComparison.OrdinalIgnoreCase); +#endif + } + + [Serializable] + internal class VisualStudioCodeManifest { + public string name; + public string version; + } + + public static bool TryDiscoverInstallation(string editorPath, out IVisualStudioInstallation installation) { + installation = null; + + if (string.IsNullOrEmpty(editorPath)) + return false; + + if (!IsCandidateForDiscovery(editorPath)) + return false; + + Version version = null; + var isPrerelease = false; + + try { + var manifestBase = GetRealPath(editorPath); + +#if UNITY_EDITOR_WIN + // on Windows, editorPath is a file, resources as subdirectory + manifestBase = IOPath.GetDirectoryName(manifestBase); +#elif UNITY_EDITOR_OSX + // on Mac, editorPath is a directory + manifestBase = IOPath.Combine(manifestBase, "Contents"); +#else + // on Linux, editorPath is a file, in a bin sub-directory + var parent = Directory.GetParent(manifestBase); + // but we can link to [vscode]/code or [vscode]/bin/code + manifestBase = parent?.Name == "bin" ? parent.Parent?.FullName : parent?.FullName; +#endif + + if (manifestBase == null) + return false; + + var manifestFullPath = IOPath.Combine(manifestBase, "resources", "app", "package.json"); + if (File.Exists(manifestFullPath)) { + var manifest = JsonUtility.FromJson(File.ReadAllText(manifestFullPath)); + Version.TryParse(manifest.version.Split('-').First(), out version); + isPrerelease = manifest.version.ToLower().Contains("insider"); + } + } catch (Exception) { + // do not fail if we are not able to retrieve the exact version number + } + + isPrerelease = isPrerelease || editorPath.ToLower().Contains("insider"); + installation = new VisualStudioCodiumInstallation() { + IsPrerelease = isPrerelease, + Name = "Codium" + (isPrerelease ? " - Insider" : string.Empty) + (version != null ? $" [{version.ToString(3)}]" : string.Empty), + Path = editorPath, + Version = version ?? new Version() + }; + + return true; + } + + public static IEnumerable GetVisualStudioInstallations() { + var candidates = new List(); + +#if UNITY_EDITOR_WIN + var localAppPath = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Programs"); + var programFiles = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); + + foreach (var basePath in new[] { localAppPath, programFiles }) { + candidates.Add(IOPath.Combine(basePath, "Codium", "Codium.exe")); + } +#elif UNITY_EDITOR_OSX + var appPath = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); + candidates.AddRange(Directory.EnumerateDirectories(appPath, "Codium*.app")); +#elif UNITY_EDITOR_LINUX + // Well known locations + candidates.Add("/usr/bin/Codium"); + candidates.Add("/bin/Codium"); + candidates.Add("/usr/local/bin/Codium"); + + // Preference ordered base directories relative to which desktop files should be searched + candidates.AddRange(GetXdgCandidates()); +#endif + + foreach (var candidate in candidates.Distinct()) { + if (TryDiscoverInstallation(candidate, out var installation)) + yield return installation; + } + } + +#if UNITY_EDITOR_LINUX + private static readonly Regex DesktopFileExecEntry = new Regex(@"Exec=(\S+)", RegexOptions.Singleline | RegexOptions.Compiled); + + private static IEnumerable GetXdgCandidates() + { + var envdirs = Environment.GetEnvironmentVariable("XDG_DATA_DIRS"); + if (string.IsNullOrEmpty(envdirs)) + yield break; + + var dirs = envdirs.Split(':'); + foreach(var dir in dirs) + { + Match match = null; + + try + { + var desktopFile = IOPath.Combine(dir, "applications/code.desktop"); + if (!File.Exists(desktopFile)) + continue; + + var content = File.ReadAllText(desktopFile); + match = DesktopFileExecEntry.Match(content); + } + catch + { + // do not fail if we cannot read desktop file + } + + if (match == null || !match.Success) + continue; + + yield return match.Groups[1].Value; + break; + } + } + + [System.Runtime.InteropServices.DllImport ("libc")] + private static extern int readlink(string path, byte[] buffer, int buflen); + + internal static string GetRealPath(string path) + { + byte[] buf = new byte[512]; + int ret = readlink(path, buf, buf.Length); + if (ret == -1) return path; + char[] cbuf = new char[512]; + int chars = System.Text.Encoding.Default.GetChars(buf, 0, ret, cbuf, 0); + return new String(cbuf, 0, chars); + } +#else + internal static string GetRealPath(string path) { + return path; + } +#endif + + public override void CreateExtraFiles(string projectDirectory) { + try { + var vscodeDirectory = IOPath.Combine(projectDirectory.NormalizePathSeparators(), ".vscode"); + Directory.CreateDirectory(vscodeDirectory); + + var enablePatch = !File.Exists(IOPath.Combine(vscodeDirectory, ".vstupatchdisable")); + + CreateRecommendedExtensionsFile(vscodeDirectory, enablePatch); + CreateSettingsFile(vscodeDirectory, enablePatch); + CreateLaunchFile(vscodeDirectory, enablePatch); + } catch (IOException) { + } + } + + private const string DefaultLaunchFileContent = @"{ + ""version"": ""0.2.0"", + ""configurations"": [ + { + ""name"": ""Attach to Unity"", + ""type"": ""vstuc"", + ""request"": ""attach"" + } + ] +}"; + + private static void CreateLaunchFile(string vscodeDirectory, bool enablePatch) { + var launchFile = IOPath.Combine(vscodeDirectory, "launch.json"); + if (File.Exists(launchFile)) { + if (enablePatch) + PatchLaunchFile(launchFile); + + return; + } + + File.WriteAllText(launchFile, DefaultLaunchFileContent); + } + + private static void PatchLaunchFile(string launchFile) { + try { + const string configurationsKey = "configurations"; + const string typeKey = "type"; + + var content = File.ReadAllText(launchFile); + var launch = JSONNode.Parse(content); + + var configurations = launch[configurationsKey] as JSONArray; + if (configurations == null) { + configurations = new JSONArray(); + launch.Add(configurationsKey, configurations); + } + + if (configurations.Linq.Any(entry => entry.Value[typeKey].Value == "vstuc")) + return; + + var defaultContent = JSONNode.Parse(DefaultLaunchFileContent); + configurations.Add(defaultContent[configurationsKey][0]); + + WriteAllTextFromJObject(launchFile, launch); + } catch (Exception) { + // do not fail if we cannot patch the launch.json file + } + } + + private void CreateSettingsFile(string vscodeDirectory, bool enablePatch) { + var settingsFile = IOPath.Combine(vscodeDirectory, "settings.json"); + if (File.Exists(settingsFile)) { + if (enablePatch) + PatchSettingsFile(settingsFile); + + return; + } + + const string excludes = @" ""files.exclude"": { + ""**/.DS_Store"": true, + ""**/.git"": true, + ""**/.vs"": true, + ""**/.gitmodules"": true, + ""**/.vsconfig"": true, + ""**/*.booproj"": true, + ""**/*.pidb"": true, + ""**/*.suo"": true, + ""**/*.user"": true, + ""**/*.userprefs"": true, + ""**/*.unityproj"": true, + ""**/*.dll"": true, + ""**/*.exe"": true, + ""**/*.pdf"": true, + ""**/*.mid"": true, + ""**/*.midi"": true, + ""**/*.wav"": true, + ""**/*.gif"": true, + ""**/*.ico"": true, + ""**/*.jpg"": true, + ""**/*.jpeg"": true, + ""**/*.png"": true, + ""**/*.psd"": true, + ""**/*.tga"": true, + ""**/*.tif"": true, + ""**/*.tiff"": true, + ""**/*.3ds"": true, + ""**/*.3DS"": true, + ""**/*.fbx"": true, + ""**/*.FBX"": true, + ""**/*.lxo"": true, + ""**/*.LXO"": true, + ""**/*.ma"": true, + ""**/*.MA"": true, + ""**/*.obj"": true, + ""**/*.OBJ"": true, + ""**/*.asset"": true, + ""**/*.cubemap"": true, + ""**/*.flare"": true, + ""**/*.mat"": true, + ""**/*.meta"": true, + ""**/*.prefab"": true, + ""**/*.unity"": true, + ""build/"": true, + ""Build/"": true, + ""Library/"": true, + ""library/"": true, + ""obj/"": true, + ""Obj/"": true, + ""Logs/"": true, + ""logs/"": true, + ""ProjectSettings/"": true, + ""UserSettings/"": true, + ""temp/"": true, + ""Temp/"": true + }"; + + var content = @"{ +" + excludes + @", + ""dotnet.defaultSolution"": """ + IOPath.GetFileName(ProjectGenerator.SolutionFile()) + @""" +}"; + + File.WriteAllText(settingsFile, content); + } + + private void PatchSettingsFile(string settingsFile) { + try { + const string excludesKey = "files.exclude"; + const string solutionKey = "dotnet.defaultSolution"; + + var content = File.ReadAllText(settingsFile); + var settings = JSONNode.Parse(content); + + var excludes = settings[excludesKey] as JSONObject; + if (excludes == null) + return; + + var patchList = new List(); + var patched = false; + + // Remove files.exclude for solution+project files in the project root + foreach (var exclude in excludes) { + if (!bool.TryParse(exclude.Value, out var exc) || !exc) + continue; + + var key = exclude.Key; + + if (!key.EndsWith(".sln") && !key.EndsWith(".csproj")) + continue; + + if (!Regex.IsMatch(key, "^(\\*\\*[\\\\\\/])?\\*\\.(sln|csproj)$")) + continue; + + patchList.Add(key); + patched = true; + } + + // Check default solution + var defaultSolution = settings[solutionKey]; + var solutionFile = IOPath.GetFileName(ProjectGenerator.SolutionFile()); + if (defaultSolution == null || defaultSolution.Value != solutionFile) { + settings[solutionKey] = solutionFile; + patched = true; + } + + if (!patched) + return; + + foreach (var patch in patchList) + excludes.Remove(patch); + + WriteAllTextFromJObject(settingsFile, settings); + } catch (Exception) { + // do not fail if we cannot patch the settings.json file + } + } + + private const string MicrosoftUnityExtensionId = "visualstudiotoolsforunity.vstuc"; + private const string DefaultRecommendedExtensionsContent = @"{ + ""recommendations"": [ + """ + MicrosoftUnityExtensionId + @""" + ] +} +"; + + private static void CreateRecommendedExtensionsFile(string vscodeDirectory, bool enablePatch) { + // see https://tattoocoder.com/recommending-vscode-extensions-within-your-open-source-projects/ + var extensionFile = IOPath.Combine(vscodeDirectory, "extensions.json"); + if (File.Exists(extensionFile)) { + if (enablePatch) + PatchRecommendedExtensionsFile(extensionFile); + + return; + } + + File.WriteAllText(extensionFile, DefaultRecommendedExtensionsContent); + } + + private static void PatchRecommendedExtensionsFile(string extensionFile) { + try { + const string recommendationsKey = "recommendations"; + + var content = File.ReadAllText(extensionFile); + var extensions = JSONNode.Parse(content); + + var recommendations = extensions[recommendationsKey] as JSONArray; + if (recommendations == null) { + recommendations = new JSONArray(); + extensions.Add(recommendationsKey, recommendations); + } + + if (recommendations.Linq.Any(entry => entry.Value.Value == MicrosoftUnityExtensionId)) + return; + + recommendations.Add(MicrosoftUnityExtensionId); + WriteAllTextFromJObject(extensionFile, extensions); + } catch (Exception) { + // do not fail if we cannot patch the extensions.json file + } + } + + private static void WriteAllTextFromJObject(string file, JSONNode node) { + using (var fs = File.Open(file, FileMode.Create)) + using (var sw = new StreamWriter(fs)) { + // Keep formatting/indent in sync with default contents + sw.Write(node.ToString(aIndent: 4)); + } + } + + public override bool Open(string path, int line, int column, string solution) { + line = Math.Max(1, line); + column = Math.Max(0, column); + + var directory = IOPath.GetDirectoryName(solution); + var application = Path; + + ProcessRunner.Start(string.IsNullOrEmpty(path) ? + ProcessStartInfoFor(application, $"\"{directory}\"") : + ProcessStartInfoFor(application, $"\"{directory}\" -g \"{path}\":{line}:{column}")); + + return true; + } + + private static ProcessStartInfo ProcessStartInfoFor(string application, string arguments) { +#if UNITY_EDITOR_OSX + // wrap with built-in OSX open feature + arguments = $"-n \"{application}\" --args {arguments}"; + application = "open"; + return ProcessRunner.ProcessStartInfoFor(application, arguments, redirect:false, shell: true); +#else + return ProcessRunner.ProcessStartInfoFor(application, arguments, redirect: false); +#endif + } + + public static void Initialize() { + } + } +} diff --git a/Editor/VisualStudioCodiumInstallation.cs.meta b/Editor/VisualStudioCodiumInstallation.cs.meta new file mode 100644 index 0000000..964355c --- /dev/null +++ b/Editor/VisualStudioCodiumInstallation.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: 67fb09553bf91c34cb2e7b383a9907ba +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Editor/VisualStudioCodeInstallation.cs b/Editor/VisualStudioCursorInstallation.cs similarity index 98% rename from Editor/VisualStudioCodeInstallation.cs rename to Editor/VisualStudioCursorInstallation.cs index ce23ddc..75813be 100644 --- a/Editor/VisualStudioCodeInstallation.cs +++ b/Editor/VisualStudioCursorInstallation.cs @@ -14,7 +14,7 @@ using SimpleJSON; using IOPath = System.IO.Path; namespace Microsoft.Unity.VisualStudio.Editor { - internal class VisualStudioCodeInstallation : VisualStudioInstallation { + internal class VisualStudioCursorInstallation : VisualStudioInstallation { private static readonly IGenerator _generator = new SdkStyleProjectGeneration(); public override bool SupportsAnalyzers { @@ -113,7 +113,7 @@ namespace Microsoft.Unity.VisualStudio.Editor { } isPrerelease = isPrerelease || editorPath.ToLower().Contains("insider"); - installation = new VisualStudioCodeInstallation() { + installation = new VisualStudioCursorInstallation() { IsPrerelease = isPrerelease, Name = "Cursor" + (isPrerelease ? " - Insider" : string.Empty) + (version != null ? $" [{version.ToString(3)}]" : string.Empty), Path = editorPath, @@ -131,7 +131,7 @@ namespace Microsoft.Unity.VisualStudio.Editor { var programFiles = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); foreach (var basePath in new[] { localAppPath, programFiles }) { - candidates.Add(IOPath.Combine(basePath, "Microsoft VS Code Insiders", "Code - Insiders.exe")); + candidates.Add(IOPath.Combine(basePath, "cursor", "cursor.exe")); } #elif UNITY_EDITOR_OSX var appPath = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles)); diff --git a/Editor/VisualStudioCodeInstallation.cs.meta b/Editor/VisualStudioCursorInstallation.cs.meta similarity index 100% rename from Editor/VisualStudioCodeInstallation.cs.meta rename to Editor/VisualStudioCursorInstallation.cs.meta