/*--------------------------------------------------------------------------------------------- * Copyright (c) Unity Technologies. * 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.IO; using System.Linq; using System.Runtime.InteropServices; using System.Runtime.CompilerServices; using UnityEditor; using UnityEngine; using Unity.CodeEditor; using System.Threading; using System.Collections.Concurrent; [assembly: InternalsVisibleTo("Unity.VisualStudio.EditorTests")] [assembly: InternalsVisibleTo("Unity.VisualStudio.Standalone.EditorTests")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] namespace Microsoft.Unity.VisualStudio.Editor { [InitializeOnLoad] public class VisualStudioEditor : IExternalCodeEditor { internal static bool IsOSX => Application.platform == RuntimePlatform.OSXEditor; internal static bool IsWindows => !IsOSX && Path.DirectorySeparatorChar == FileUtility.WinSeparator && Environment.NewLine == "\r\n"; CodeEditor.Installation[] IExternalCodeEditor.Installations => _discoverInstallations.Result .Select(i => i.ToCodeEditorInstallation()) .ToArray(); private static readonly AsyncOperation _discoverInstallations; private readonly IGenerator _generator = new ProjectGeneration(); static VisualStudioEditor() { if (!UnityInstallation.IsMainUnityEditorProcess) return; if (IsWindows) Discovery.FindVSWhere(); CodeEditor.Register(new VisualStudioEditor()); _discoverInstallations = AsyncOperation.Run(DiscoverInstallations); } private static IVisualStudioInstallation[] DiscoverInstallations() { try { return Discovery .GetVisualStudioInstallations() .ToArray(); } catch (Exception ex) { UnityEngine.Debug.LogError($"Error detecting Visual Studio installations: {ex}"); return Array.Empty(); } } internal static bool IsEnabled => CodeEditor.CurrentEditor is VisualStudioEditor && UnityInstallation.IsMainUnityEditorProcess; // this one seems legacy and not used anymore // keeping it for now given it is public, so we need a major bump to remove it public void CreateIfDoesntExist() { if (!_generator.HasSolutionBeenGenerated()) _generator.Sync(); } public void Initialize(string editorInstallationPath) { } internal virtual bool TryGetVisualStudioInstallationForPath(string editorPath, bool searchInstallations, out IVisualStudioInstallation installation) { if (searchInstallations) { // lookup for well known installations foreach (var candidate in _discoverInstallations.Result) { if (!string.Equals(Path.GetFullPath(editorPath), Path.GetFullPath(candidate.Path), StringComparison.OrdinalIgnoreCase)) continue; installation = candidate; return true; } } return Discovery.TryDiscoverInstallation(editorPath, out installation); } public virtual bool TryGetInstallationForPath(string editorPath, out CodeEditor.Installation installation) { var result = TryGetVisualStudioInstallationForPath(editorPath, searchInstallations: false, out var vsi); installation = vsi == null ? default : vsi.ToCodeEditorInstallation(); return result; } public void OnGUI() { GUILayout.BeginHorizontal(); GUILayout.FlexibleSpace(); var package = UnityEditor.PackageManager.PackageInfo.FindForAssembly(GetType().Assembly); var style = new GUIStyle { richText = true, margin = new RectOffset(0, 4, 0, 0) }; GUILayout.Label($"{package.displayName} v{package.version} enabled", style); GUILayout.EndHorizontal(); EditorGUILayout.LabelField("Generate .csproj files for:"); EditorGUI.indentLevel++; SettingsButton(ProjectGenerationFlag.Embedded, "Embedded packages", ""); SettingsButton(ProjectGenerationFlag.Local, "Local packages", ""); SettingsButton(ProjectGenerationFlag.Registry, "Registry packages", ""); SettingsButton(ProjectGenerationFlag.Git, "Git packages", ""); SettingsButton(ProjectGenerationFlag.BuiltIn, "Built-in packages", ""); SettingsButton(ProjectGenerationFlag.LocalTarBall, "Local tarball", ""); SettingsButton(ProjectGenerationFlag.Unknown, "Packages from unknown sources", ""); SettingsButton(ProjectGenerationFlag.PlayerAssemblies, "Player projects", "For each player project generate an additional csproj with the name 'project-player.csproj'"); RegenerateProjectFiles(); EditorGUI.indentLevel--; } void RegenerateProjectFiles() { var rect = EditorGUI.IndentedRect(EditorGUILayout.GetControlRect(new GUILayoutOption[] { })); rect.width = 252; if (GUI.Button(rect, "Regenerate project files")) { _generator.Sync(); } } void SettingsButton(ProjectGenerationFlag preference, string guiMessage, string toolTip) { var prevValue = _generator.AssemblyNameProvider.ProjectGenerationFlag.HasFlag(preference); var newValue = EditorGUILayout.Toggle(new GUIContent(guiMessage, toolTip), prevValue); if (newValue != prevValue) { _generator.AssemblyNameProvider.ToggleProjectGeneration(preference); } } public void SyncIfNeeded(string[] addedFiles, string[] deletedFiles, string[] movedFiles, string[] movedFromFiles, string[] importedFiles) { _generator.SyncIfNeeded(addedFiles.Union(deletedFiles).Union(movedFiles).Union(movedFromFiles), importedFiles); foreach (var file in importedFiles.Where(a => Path.GetExtension(a) == ".pdb")) { var pdbFile = FileUtility.GetAssetFullPath(file); // skip Unity packages like com.unity.ext.nunit if (pdbFile.IndexOf($"{Path.DirectorySeparatorChar}com.unity.", StringComparison.OrdinalIgnoreCase) > 0) continue; var asmFile = Path.ChangeExtension(pdbFile, ".dll"); if (!File.Exists(asmFile) || !Image.IsAssembly(asmFile)) continue; if (Symbols.IsPortableSymbolFile(pdbFile)) continue; UnityEngine.Debug.LogWarning($"Unity is only able to load mdb or portable-pdb symbols. {file} is using a legacy pdb format."); } } public void SyncAll() { _generator.Sync(); } bool IsSupportedPath(string path) { // Path is empty with "Open C# Project", as we only want to open the solution without specific files if (string.IsNullOrEmpty(path)) return true; // cs, uxml, uss, shader, compute, cginc, hlsl, glslinc, template are part of Unity builtin extensions // txt, xml, fnt, cd are -often- par of Unity user extensions // asdmdef is mandatory included if (_generator.IsSupportedFile(path)) return true; return false; } private static void CheckCurrentEditorInstallation() { var editorPath = CodeEditor.CurrentEditorInstallation; try { if (Discovery.TryDiscoverInstallation(editorPath, out _)) return; } catch (IOException) { } UnityEngine.Debug.LogWarning($"Visual Studio executable {editorPath} is not found. Please change your settings in Edit > Preferences > External Tools."); } public bool OpenProject(string path, int line, int column) { CheckCurrentEditorInstallation(); if (!IsSupportedPath(path)) return false; if (!IsProjectGeneratedFor(path, out var missingFlag)) UnityEngine.Debug.LogWarning($"You are trying to open {path} outside a generated project. This might cause problems with IntelliSense and debugging. To avoid this, you can change your .csproj preferences in Edit > Preferences > External Tools and enable {GetProjectGenerationFlagDescription(missingFlag)} generation."); if (IsOSX) return OpenOSXApp(path, line, column); if (IsWindows) return OpenWindowsApp(path, line); return false; } private static string GetProjectGenerationFlagDescription(ProjectGenerationFlag flag) { switch (flag) { case ProjectGenerationFlag.BuiltIn: return "Built-in packages"; case ProjectGenerationFlag.Embedded: return "Embedded packages"; case ProjectGenerationFlag.Git: return "Git packages"; case ProjectGenerationFlag.Local: return "Local packages"; case ProjectGenerationFlag.LocalTarBall: return "Local tarball"; case ProjectGenerationFlag.PlayerAssemblies: return "Player projects"; case ProjectGenerationFlag.Registry: return "Registry packages"; case ProjectGenerationFlag.Unknown: return "Packages from unknown sources"; default: return string.Empty; } } private bool IsProjectGeneratedFor(string path, out ProjectGenerationFlag missingFlag) { missingFlag = ProjectGenerationFlag.None; // No need to check when opening the whole solution if (string.IsNullOrEmpty(path)) return true; // We only want to check for cs scripts if (ProjectGeneration.ScriptingLanguageForFile(path) != ScriptingLanguage.CSharp) return true; // Even on windows, the package manager requires relative path + unix style separators for queries var basePath = _generator.ProjectDirectory; var relativePath = FileUtility .NormalizeWindowsToUnix(path) .Replace(basePath, string.Empty) .Trim(FileUtility.UnixSeparator); var packageInfo = UnityEditor.PackageManager.PackageInfo.FindForAssetPath(relativePath); if (packageInfo == null) return true; var source = packageInfo.source; if (!Enum.TryParse(source.ToString(), out var flag)) return true; if (_generator.AssemblyNameProvider.ProjectGenerationFlag.HasFlag(flag)) return true; // Return false if we found a source not flagged for generation missingFlag = flag; return false; } private enum COMIntegrationState { Running, DisplayProgressBar, ClearProgressBar, Exited } private bool OpenWindowsApp(string path, int line) { var progpath = FileUtility.GetPackageAssetFullPath("Editor", "COMIntegration", "Release", "COMIntegration.exe"); if (string.IsNullOrWhiteSpace(progpath)) return false; string absolutePath = ""; if (!string.IsNullOrWhiteSpace(path)) { absolutePath = Path.GetFullPath(path); } // We remove all invalid chars from the solution filename, but we cannot prevent the user from using a specific path for the Unity project // So process the fullpath to make it compatible with VS var solution = GetOrGenerateSolutionFile(path); if (!string.IsNullOrWhiteSpace(solution)) { solution = $"\"{solution}\""; solution = solution.Replace("^", "^^"); } var psi = ProcessRunner.ProcessStartInfoFor(progpath, $"\"{CodeEditor.CurrentEditorInstallation}\" {solution} \"{absolutePath}\" {line}"); psi.StandardOutputEncoding = System.Text.Encoding.Unicode; psi.StandardErrorEncoding = System.Text.Encoding.Unicode; // inter thread communication var messages = new BlockingCollection(); var asyncStart = AsyncOperation.Run( () => ProcessRunner.StartAndWaitForExit(psi, onOutputReceived: data => OnOutputReceived(data, messages)), e => new ProcessRunnerResult {Success = false, Error = e.Message, Output = string.Empty}, () => messages.Add(COMIntegrationState.Exited) ); MonitorCOMIntegration(messages); var result = asyncStart.Result; if (!result.Success && !string.IsNullOrWhiteSpace(result.Error)) Debug.LogError($"Error while starting Visual Studio: {result.Error}"); return result.Success; } private static void MonitorCOMIntegration(BlockingCollection messages) { var displayingProgress = false; COMIntegrationState state; do { state = messages.Take(); switch (state) { case COMIntegrationState.ClearProgressBar: EditorUtility.ClearProgressBar(); displayingProgress = false; break; case COMIntegrationState.DisplayProgressBar: EditorUtility.DisplayProgressBar("Opening Visual Studio", "Starting up Visual Studio, this might take some time.", .5f); displayingProgress = true; break; } } while (state != COMIntegrationState.Exited); // Make sure the progress bar is properly cleared in case of COMIntegration failure if (displayingProgress) EditorUtility.ClearProgressBar(); } private static readonly COMIntegrationState[] ProgressBarCommands = {COMIntegrationState.DisplayProgressBar, COMIntegrationState.ClearProgressBar}; private static void OnOutputReceived(string data, BlockingCollection messages) { if (data == null) return; foreach (var cmd in ProgressBarCommands) { if (data.IndexOf(cmd.ToString(), StringComparison.OrdinalIgnoreCase) >= 0) messages.Add(cmd); } } [DllImport("AppleEventIntegration")] static extern bool OpenVisualStudio(string appPath, string solutionPath, string filePath, int line); bool OpenOSXApp(string path, int line, int column) { string absolutePath = ""; if (!string.IsNullOrWhiteSpace(path)) { absolutePath = Path.GetFullPath(path); } var solution = GetOrGenerateSolutionFile(path); return OpenVisualStudio(CodeEditor.CurrentEditorInstallation, solution, absolutePath, line); } private string GetOrGenerateSolutionFile(string path) { _generator.Sync(); return _generator.SolutionFile(); } } }