Files
com.unity.ide.cursor/Editor/VisualStudioCursorInstallation.cs
Copilot d17d7814f6 Fix CS1525: Use C# 7.3-compatible null-coalescing for Unity 2019.4 (#37)
* Initial plan

* Fix CS1525 compiler error for Unity 2019.4 by replacing ??= with version-compatible code

Co-authored-by: boxqkrtm <8157743+boxqkrtm@users.noreply.github.com>

* Add clarifying comment for Unity 2019.4 C# 7.3 compatibility

Co-authored-by: OmarAlFarajat <OmarAlFarajat@users.noreply.github.com>

Co-authored-by: boxqkrtm <8157743+boxqkrtm@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: boxqkrtm <8157743+boxqkrtm@users.noreply.github.com>
2026-02-12 19:53:35 +09:00

641 lines
18 KiB
C#

/*---------------------------------------------------------------------------------------------
* 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 UnityEditor;
using SimpleJSON;
using IOPath = System.IO.Path;
using Debug = UnityEngine.Debug;
namespace Microsoft.Unity.VisualStudio.Editor
{
internal class VisualStudioCursorInstallation : VisualStudioInstallation
{
private static readonly IGenerator _generator = new SdkStyleProjectGeneration();
internal const string ReuseExistingWindowKey = "cursor_reuse_existing_window";
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<string>();
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, ".*Cursor.*.app$", RegexOptions.IgnoreCase);
#elif UNITY_EDITOR_WIN
return File.Exists(path) && Regex.IsMatch(path, ".*Cursor.*.exe$", RegexOptions.IgnoreCase);
#else
return File.Exists(path) && path.EndsWith("cursor", 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<VisualStudioCodeManifest>(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 VisualStudioCursorInstallation()
{
IsPrerelease = isPrerelease,
Name = "Cursor" + (isPrerelease ? " - Insider" : string.Empty) + (version != null ? $" [{version.ToString(3)}]" : string.Empty),
Path = editorPath,
Version = version ?? new Version()
};
return true;
}
public static IEnumerable<IVisualStudioInstallation> GetVisualStudioInstallations()
{
var candidates = new List<string>();
#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, "cursor", "cursor.exe"));
}
#elif UNITY_EDITOR_OSX
var appPath = IOPath.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles));
candidates.AddRange(Directory.EnumerateDirectories(appPath, "Cursor*.app"));
#elif UNITY_EDITOR_LINUX
// Well known locations
candidates.Add("/usr/bin/cursor");
candidates.Add("/bin/cursor");
candidates.Add("/usr/local/bin/cursor");
// 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<string> 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<string>();
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));
}
}
private Process FindRunningCursorWithSolution(string solutionPath)
{
var normalizedTargetPath = solutionPath.Replace('\\', '/').TrimEnd('/').ToLowerInvariant();
#if UNITY_EDITOR_WIN
// Keep as is for Windows platform since path already includes drive letter
#else
// Ensure path starts with / for macOS and Linux platforms
if (!normalizedTargetPath.StartsWith("/"))
{
normalizedTargetPath = "/" + normalizedTargetPath;
}
#endif
var processes = new List<Process>();
// Get process name list based on different operating systems
#if UNITY_EDITOR_OSX
processes.AddRange(Process.GetProcessesByName("Cursor"));
processes.AddRange(Process.GetProcessesByName("Cursor Helper"));
#elif UNITY_EDITOR_LINUX
processes.AddRange(Process.GetProcessesByName("cursor"));
processes.AddRange(Process.GetProcessesByName("Cursor"));
#else
processes.AddRange(Process.GetProcessesByName("cursor"));
#endif
foreach (var process in processes)
{
try
{
var workspaces = ProcessRunner.GetProcessWorkspaces(process);
if (workspaces != null && workspaces.Length > 0)
{
foreach (var workspace in workspaces)
{
var normalizedWorkspaceDir = workspace.Replace('\\', '/').TrimEnd('/').ToLowerInvariant();
#if UNITY_EDITOR_WIN
// Keep as is for Windows platform
#else
// Ensure path starts with / for macOS and Linux platforms
if (!normalizedWorkspaceDir.StartsWith("/"))
{
normalizedWorkspaceDir = "/" + normalizedWorkspaceDir;
}
#endif
if (string.Equals(normalizedWorkspaceDir, normalizedTargetPath, StringComparison.OrdinalIgnoreCase) ||
normalizedTargetPath.StartsWith(normalizedWorkspaceDir + "/", StringComparison.OrdinalIgnoreCase) ||
normalizedWorkspaceDir.StartsWith(normalizedTargetPath + "/", StringComparison.OrdinalIgnoreCase))
{
return process;
}
}
}
}
catch (Exception ex)
{
Debug.LogError($"[Cursor] Error checking process: {ex}");
continue;
}
}
return null;
}
private static string TryFindWorkspace(string directory)
{
var files = Directory.GetFiles(directory, "*.code-workspace", SearchOption.TopDirectoryOnly);
if (files.Length == 0 || files.Length > 1)
return null;
return files[0];
}
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;
var workspace = TryFindWorkspace(directory);
// Use version-compatible null-coalescing for Unity 2019.4 (C# 7.3) support
#if UNITY_2020_2_OR_NEWER
workspace ??= directory;
#else
workspace = workspace ?? directory;
#endif
directory = workspace;
if (EditorPrefs.GetBool(ReuseExistingWindowKey, false))
{
var existingProcess = FindRunningCursorWithSolution(directory);
if (existingProcess != null)
{
try
{
var args = string.IsNullOrEmpty(path) ?
$"--reuse-window \"{directory}\"" :
$"--reuse-window -g \"{path}\":{line}:{column}";
ProcessRunner.Start(ProcessStartInfoFor(application, args));
return true;
}
catch (Exception ex)
{
Debug.LogError($"[Cursor] Error using existing instance: {ex}");
}
}
}
var newArgs = string.IsNullOrEmpty(path) ?
$"--new-window \"{directory}\"" :
$"--new-window \"{directory}\" -g \"{path}\":{line}:{column}";
ProcessRunner.Start(ProcessStartInfoFor(application, newArgs));
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()
{
}
}
}