unity/EditorPlugin/Protocol/UnityEditorProtocol.cs (456 lines of code) (raw):
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using JetBrains.Collections.Viewable;
using JetBrains.Core;
using JetBrains.Diagnostics;
using JetBrains.Lifetimes;
using JetBrains.Rd;
using JetBrains.Rd.Base;
using JetBrains.Rd.Impl;
using JetBrains.Rd.Tasks;
using JetBrains.Rider.Model.Unity;
using JetBrains.Rider.Model.Unity.BackendUnity;
using JetBrains.Rider.Unity.Editor.NonUnity;
using JetBrains.Rider.PathLocator;
using JetBrains.Rider.Unity.Editor.FindUsages;
using JetBrains.Rider.Unity.Editor.Profiler;
using JetBrains.Rider.Unity.Editor.UnitTesting;
using JetBrains.Rider.Unity.Editor.Utils;
using UnityEditor;
using Debug = UnityEngine.Debug;
namespace JetBrains.Rider.Unity.Editor
{
internal static class UnityEditorProtocol
{
private static ILog ourLogger;
private static long ourInitTime;
// We cannot guarantee a valid, connected model.
// Model creation, lifetime termination, protocol callbacks and Unity API events are all called on the main thread,
// so we know that inside a callback, the model has a lifetime that has not yet been terminated.
// But a model can lose its socket connection at any time, even mid-callback. If the connection is closed
// gracefully, the Model.Connected property is updated on a background thread. But there is an inherent race
// condition here. Even if we check Model.Connected, the connection might disappear before we invoke the protocol
// method.
// More importantly, we don't get notified about socket connection failing until the model/connection lifetime is
// queued for termination on the main thread.
// In short: we can assume that a model is valid inside a callback. We cannot assume that it is connected. We can
// use Model.Connected to potentially reduce the scope of the race condition, but we will not be notified if the
// connection fails.
#region Details
// Details:
// * The protocol's Wire creates a background thread for socket communication. It sets/resets the Wire.Connected
// property before/after listening for incoming messages. It uses the given scheduler (MainThreadDispatcher) to
// queue changing the property to the correct thread - the main thread
// * The model is created when Wire.Connected becomes true on the scheduled thread
// * Protocol messages are received on the background thread and notified via the scheduler, so callbacks happen on
// the main thread
// * Unity API events are always called on the main thread
// * When the Wire stops receiving incoming events (either gracefully or with a socket exception), Wire.Connected is
// set to false. This is queued with the scheduler and the property is set on the main thread. The existing
// lifetime is terminated and handlers are immediately invoked, still on the main thread
// * The model's base class (RdExtBase) sends a handshake when it's created. When the other side responds, the
// Model.Connected property is set to true. This is invoked on the model's default scheduler, which is the
// SynchronousScheduler, so Model.Connected is set (and notified) on a background thread
// * If the other side (Rider) is shut down gracefully, it sends a disconnected event. Again, this is invoked on the
// model's default scheduler, which means Model.Connected is set and notified on a background thread
// * If the other side does not shut down gracefully, it does not send a disconnected event, and Model.Connected
// remains true. Any access to the socket will throw an exception, causing the protocol's background thread to
// close down, queuing Wire.Connected = false with the scheduler
#endregion
public static readonly IViewableList<BackendUnityModel> Models = new ViewableList<BackendUnityModel>();
public static void Initialise(Lifetime appDomainLifetime, long initTime, ILog logger)
{
ourLogger = logger;
ourInitTime = initTime;
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());
var solutionNames = new List<string> { currentDirectory.Name };
ourLogger.Verbose("Initialising protocol. Looking for solution files");
var solutionFiles = currentDirectory.GetFiles("*.sln", SearchOption.TopDirectoryOnly);
foreach (var solutionFile in solutionFiles)
{
var solutionName = Path.GetFileNameWithoutExtension(solutionFile.FullName);
if (!solutionName.Equals(currentDirectory.Name))
{
solutionNames.Add(solutionName);
}
}
var protocols = new List<ProtocolInstance>();
// If any protocol connection is lost, we will drop all connections and recreate them
var allProtocolsLifetimeDefinition = appDomainLifetime.CreateNested();
foreach (var solutionName in GetSolutionNames())
{
var port = CreateProtocolForSolution(appDomainLifetime, allProtocolsLifetimeDefinition.Lifetime, solutionName,
() => allProtocolsLifetimeDefinition.Terminate());
if (port == -1)
continue;
protocols.Add(new ProtocolInstance(solutionName, port));
}
if (!protocols.Any())
{
ourLogger.Warn("Initialising protocol failed.");
return;
}
allProtocolsLifetimeDefinition.Lifetime.OnTermination(() =>
{
if (appDomainLifetime.IsAlive)
{
ourLogger.Verbose("Schedule recreating protocol, project lifetime is alive");
new Thread(() =>
{
Thread.Sleep(1000);
if (appDomainLifetime.IsAlive)
{
ourLogger.Verbose("Before MainThreadDispatcher.Instance.Queue(() =>");
MainThreadDispatcher.Instance.Queue(() =>
{
ourLogger.Verbose("Inside MainThreadDispatcher.Instance.Queue(() =>");
if (appDomainLifetime.IsAlive)
{
ourLogger.Verbose("Recreating protocol, project lifetime is alive");
Initialise(appDomainLifetime, initTime, logger);
}
});
}
}).Start();
}
else
{
ourLogger.Verbose("Protocol will be recreated on next domain load, plugin lifetime is not alive");
}
});
ourLogger.Verbose("Writing Library/ProtocolInstance.json");
var protocolInstancePath = Path.GetFullPath("Library/ProtocolInstance.json");
var result = ProtocolInstance.ToJson(protocols);
File.WriteAllText(protocolInstancePath, result);
// TODO: Will this cause problems if we call Initialise a second time?
// Perhaps we need another lifetime?
appDomainLifetime.OnTermination(() =>
{
ourLogger.Verbose("Deleting Library/ProtocolInstance.json");
File.Delete(protocolInstancePath);
});
}
private static HashSet<string> GetSolutionNames()
{
// Get a list of all the solutions in the Unity project. We'll have at least the generated solution, but there
// might be others, e.g. class libraries. We'll create a protocol connection for all such solutions
var currentDirectory = new DirectoryInfo(Directory.GetCurrentDirectory());
var solutionNames = new HashSet<string> { currentDirectory.Name };
var solutionFiles = currentDirectory.GetFiles("*.sln", SearchOption.TopDirectoryOnly);
foreach (var solutionFile in solutionFiles)
{
var solutionName = Path.GetFileNameWithoutExtension(solutionFile.FullName);
solutionNames.Add(solutionName);
}
return solutionNames;
}
private static int CreateProtocolForSolution(Lifetime appDomainLifetime, Lifetime lifetime, string solutionName, Action onDisconnected)
{
ourLogger.Verbose($"Initialising protocol for {solutionName}");
try
{
var dispatcher = MainThreadDispatcher.Instance;
var currentWireAndProtocolLifetimeDef = lifetime.CreateNested();
var currentWireAndProtocolLifetime = currentWireAndProtocolLifetimeDef.Lifetime;
var riderProtocolController = new RiderProtocolController(dispatcher, currentWireAndProtocolLifetime);
var serializers = new Serializers();
var identities = new Identities(IdKind.Server);
MainThreadDispatcher.AssertThread();
var protocol = new Protocol("UnityEditorPlugin" + solutionName, serializers, identities,
MainThreadDispatcher.Instance, riderProtocolController.Wire, currentWireAndProtocolLifetime);
riderProtocolController.Wire.Connected.WhenTrue(currentWireAndProtocolLifetime, connectionLifetime =>
{
ourLogger.Log(LoggingLevel.VERBOSE, "Create UnityModel and advise for new sessions...");
var model = new BackendUnityModel(connectionLifetime, protocol);
SetApplicationData(model);
SetProjectSettings(model);
AdvisePlayControls(model, connectionLifetime);
AdviseOnGetEditorState(model);
AdviseOnRefresh(model);
AdviseShowPreferences(model, connectionLifetime, ourLogger);
AdviseOnGenerateUIElementsSchema(model);
AdviseOnExitUnity(model);
AdviseOnRunMethod(model);
AdviseOnStartProfiling(model);
AdviseLoggingStateChangeTimes(connectionLifetime, model);
BuildPipelineModelHelper.Advise(connectionLifetime, model);
UnitTestingModelHelper.Advise(appDomainLifetime, connectionLifetime, model);
FindUsagesModelHelper.Advise(connectionLifetime, model);
UnsavedChangesModelHelper.Advise(connectionLifetime, model);
PackageManagerModelHelper.Advise(connectionLifetime, model);
ProfilerWindowEventsHandler.Advise(connectionLifetime, new UnityProfilerModel(connectionLifetime, protocol), model);
PlatformModuleInfoProvider.Advise(connectionLifetime, model);
Models.AddLifetimed(connectionLifetime, model);
ourLogger.Verbose("UnityModel initialized.");
connectionLifetime.OnTermination(() =>
{
ourLogger.Verbose($"Connection lifetime is not alive for {solutionName}, destroying protocol");
onDisconnected();
});
});
return riderProtocolController.Wire.Port;
}
catch (Exception ex)
{
ourLogger.Error("Init Rider Plugin " + ex);
return -1;
}
}
private static void SetApplicationData(BackendUnityModel model)
{
var paths = GetLogPaths();
model.UnityApplicationData.Value = new UnityApplicationData(
EditorApplication.applicationPath,
EditorApplication.applicationContentsPath,
UnityUtils.UnityApplicationVersion,
paths[0], paths[1],
Process.GetCurrentProcess().Id);
model.UnityApplicationSettings.ScriptCompilationDuringPlay.Value = EditorPrefsWrapper.ScriptCompilationDuringPlay;
}
private static string[] GetLogPaths()
{
// https://docs.unity3d.com/Manual/LogFiles.html
//PlayerSettings.productName;
//PlayerSettings.companyName;
//~/Library/Logs/Unity/Editor.log
//C:\Users\username\AppData\Local\Unity\Editor\Editor.log
//~/.config/unity3d/Editor.log
var editorLogPath = string.Empty;
var playerLogPath = string.Empty;
switch (PluginSettings.SystemInfoRiderPlugin.OS)
{
case OS.Windows:
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
editorLogPath = Path.Combine(localAppData, @"Unity\Editor\Editor.log");
var userProfile = Environment.GetEnvironmentVariable("USERPROFILE");
if (!string.IsNullOrEmpty(userProfile))
{
var folder = Path.Combine(userProfile, @"AppData\LocalLow", PlayerSettings.companyName, PlayerSettings.productName);
playerLogPath = Path.Combine(folder, File.Exists(Path.Combine(folder, "output_log.txt")) ? "output_log.txt" : "Player.log");
}
break;
}
case OS.MacOSX:
{
var home = Environment.GetEnvironmentVariable("HOME");
if (!string.IsNullOrEmpty(home))
{
editorLogPath = Path.Combine(home, "Library/Logs/Unity/Editor.log");
playerLogPath = Path.Combine(home, "Library/Logs", PlayerSettings.companyName, PlayerSettings.productName, "Player.log");
}
break;
}
case OS.Linux:
{
var home = Environment.GetEnvironmentVariable("HOME");
if (!string.IsNullOrEmpty(home))
{
editorLogPath = Path.Combine(home, ".config/unity3d/Editor.log");
playerLogPath = Path.Combine(home, ".config/unity3d", PlayerSettings.companyName, PlayerSettings.productName, "Player.log");
}
break;
}
}
return new[] { editorLogPath, playerLogPath };
}
private static void SetProjectSettings(BackendUnityModel model)
{
var path = EditorUserBuildSettings.GetBuildLocation(EditorUserBuildSettings.selectedStandaloneTarget);
if (PluginSettings.SystemInfoRiderPlugin.OS == OS.MacOSX)
path = Path.Combine(Path.Combine(Path.Combine(path, "Contents"), "MacOS"), PlayerSettings.productName);
if (!string.IsNullOrEmpty(path) && File.Exists(path))
model.UnityProjectSettings.BuildLocation.Value = path;
}
private static void AdvisePlayControls(BackendUnityModel model, Lifetime connectionLifetime)
{
var syncPlayState = new Action(() =>
{
MainThreadDispatcher.AssertThread();
var isPlaying = EditorApplication.isPlayingOrWillChangePlaymode || EditorApplication.isPlaying;
if (!model.PlayControls.Play.HasValue() ||
model.PlayControls.Play.HasValue() && model.PlayControls.Play.Value != isPlaying)
{
ourLogger.Verbose("Reporting play mode change to model: {0}", isPlaying);
model.PlayControls.Play.SetValue(isPlaying);
}
var isPaused = EditorApplication.isPaused;
if (!model.PlayControls.Pause.HasValue() ||
model.PlayControls.Pause.HasValue() && model.PlayControls.Pause.Value != isPaused)
{
ourLogger.Verbose("Reporting pause mode change to model: {0}", isPaused);
model.PlayControls.Pause.SetValue(isPaused);
}
});
syncPlayState();
model.PlayControls.Play.Advise(connectionLifetime, play =>
{
MainThreadDispatcher.AssertThread();
var current = EditorApplication.isPlayingOrWillChangePlaymode && EditorApplication.isPlaying;
if (current != play)
{
ourLogger.Verbose("Request to change play mode from model: {0}", play);
EditorApplication.isPlaying = play;
}
});
model.PlayControls.Pause.Advise(connectionLifetime, pause =>
{
MainThreadDispatcher.AssertThread();
ourLogger.Verbose("Request to change pause mode from model: {0}", pause);
EditorApplication.isPaused = pause;
});
model.PlayControls.Step.Advise(connectionLifetime, _ => EditorApplication.Step());
PlayModeStateTracker.Current.Advise(connectionLifetime, _ => syncPlayState());
}
private static void AdviseOnGetEditorState(BackendUnityModel modelValue)
{
modelValue.GetUnityEditorState.Set(_ =>
{
if (EditorApplication.isPaused)
return UnityEditorState.Pause;
if (EditorApplication.isPlaying)
return UnityEditorState.Play;
if (EditorApplication.isCompiling || EditorApplication.isUpdating)
return UnityEditorState.Refresh;
return UnityEditorState.Idle;
});
}
private static void AdviseOnRefresh(BackendUnityModel model)
{
model.Refresh.Set((_, force) =>
{
var refreshTask = new RdTask<Unit>();
void SendResult()
{
if (!EditorApplication.isCompiling)
{
// ReSharper disable once DelegateSubtraction
EditorApplication.update -= SendResult;
ourLogger.Verbose("Refresh: SyncSolution Completed");
refreshTask.Set(Unit.Instance);
}
}
MainThreadDispatcher.AssertThread();
ourLogger.Verbose("Refresh: SyncSolution Enqueue");
if (!EditorApplication.isPlaying && EditorPrefsWrapper.AutoRefresh || force != RefreshType.Normal)
{
try
{
if (force == RefreshType.ForceRequestScriptReload)
{
ourLogger.Verbose("Refresh: RequestScriptReload");
UnityEditorInternal.InternalEditorUtility.RequestScriptReload();
}
ourLogger.Verbose("Refresh: SyncSolution Started");
RiderPackageInterop.SyncSolution();
}
catch (Exception e)
{
ourLogger.Error(e, "Refresh failed with exception");
}
finally
{
EditorApplication.update += SendResult;
}
}
else
{
if (EditorApplication.isPlaying)
{
refreshTask.Set(Unit.Instance);
ourLogger.Verbose("Avoid calling Refresh, when EditorApplication.isPlaying.");
}
else if (!EditorPrefsWrapper.AutoRefresh)
{
refreshTask.Set(Unit.Instance);
ourLogger.Verbose("AutoRefresh is disabled by Unity preferences.");
}
else
{
refreshTask.Set(Unit.Instance);
ourLogger.Verbose("Avoid calling Refresh, for the unknown reason.");
}
}
return refreshTask;
});
}
private static void AdviseShowPreferences(BackendUnityModel model, Lifetime connectionLifetime, ILog log)
{
model.ShowPreferences.Advise(connectionLifetime, result =>
{
if (result == null) return;
MainThreadDispatcher.AssertThread();
try
{
var tab = UnityUtils.UnityVersion >= new Version(2018, 2) ? "_General" : "Rider";
var type = typeof(SceneView).Assembly.GetType("UnityEditor.SettingsService");
if (type != null)
{
// 2018+
var method = type.GetMethod("OpenUserPreferences", BindingFlags.Static | BindingFlags.Public);
if (method == null)
log.Error("'OpenUserPreferences' was not found");
else
method.Invoke(null, new object[] { $"Preferences/{tab}" });
}
else
{
// 5.5, 2017 ...
type = typeof(SceneView).Assembly.GetType("UnityEditor.PreferencesWindow");
var method = type?.GetMethod("ShowPreferencesWindow", BindingFlags.Static | BindingFlags.NonPublic);
if (method == null)
log.Error("'ShowPreferencesWindow' was not found");
else
method.Invoke(null, null);
}
}
catch (Exception ex)
{
log.Error("Show preferences " + ex);
}
});
}
private static void AdviseOnGenerateUIElementsSchema(BackendUnityModel model)
{
model.GenerateUIElementsSchema.Set(_ => UIElementsSupport.GenerateSchema());
}
private static void AdviseOnExitUnity(BackendUnityModel model)
{
model.ExitUnity.Set((_, __) =>
{
var task = new RdTask<bool>();
MainThreadDispatcher.AssertThread();
try
{
ourLogger.Verbose("ExitUnity: Started");
EditorApplication.Exit(0);
ourLogger.Verbose("ExitUnity: Completed");
task.Set(true);
}
catch (Exception e)
{
ourLogger.Log(LoggingLevel.WARN, "EditorApplication.Exit failed.", e);
task.Set(false);
}
return task;
});
}
private static void AdviseOnRunMethod(BackendUnityModel model)
{
model.RunMethodInUnity.Set((lifetime, data) =>
{
var task = new RdTask<RunMethodResult>();
MainThreadDispatcher.AssertThread();
if (!lifetime.IsAlive)
{
task.SetCancelled();
return task;
}
try
{
ourLogger.Verbose($"Attempt to execute {data.MethodName}");
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var assembly = assemblies
.FirstOrDefault(a => a.GetName().Name.Equals(data.AssemblyName));
if (assembly == null)
throw new Exception($"Could not find {data.AssemblyName} assembly in current AppDomain");
var type = assembly.GetType(data.TypeName);
if (type == null)
throw new Exception($"Could not find {data.TypeName} in assembly {data.AssemblyName}.");
var method = type.GetMethod(data.MethodName,
BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public);
if (method == null)
throw new Exception($"Could not find {data.MethodName} in type {data.TypeName}");
try
{
method.Invoke(null, null);
}
catch (Exception e)
{
Debug.LogException(e);
}
task.Set(new RunMethodResult(true, string.Empty, string.Empty));
}
catch (Exception e)
{
ourLogger.Log(LoggingLevel.WARN, $"Execute {data.MethodName} failed.", e);
task.Set(new RunMethodResult(false, e.Message, e.StackTrace));
}
return task;
});
}
private static void AdviseOnStartProfiling(BackendUnityModel model)
{
model.StartProfiling.Set((_, data) =>
{
MainThreadDispatcher.AssertThread();
try
{
UnityProfilerApiInterop.StartProfiling(data.UnityProfilerApiPath, data.NeedRestartScripts);
var current = EditorApplication.isPlayingOrWillChangePlaymode && EditorApplication.isPlaying;
if (current != data.EnterPlayMode)
{
ourLogger.Verbose("StartProfiling. Request to change play mode from model: {0}", data.EnterPlayMode);
EditorApplication.isPlaying = data.EnterPlayMode;
}
}
catch (Exception e)
{
if (PluginSettings.SelectedLoggingLevel >= LoggingLevel.VERBOSE)
Debug.LogError(e);
throw;
}
return Unit.Instance;
});
model.StopProfiling.Set((_, data) =>
{
MainThreadDispatcher.AssertThread();
try
{
UnityProfilerApiInterop.StopProfiling(data.UnityProfilerApiPath);
}
catch (Exception e)
{
if (PluginSettings.SelectedLoggingLevel >= LoggingLevel.VERBOSE)
Debug.LogError(e);
throw;
}
return Unit.Instance;
});
}
private static void AdviseLoggingStateChangeTimes(Lifetime modelLifetime, BackendUnityModel model)
{
model.ConsoleLogging.LastInitTime.Value = ourInitTime;
PlayModeStateTracker.Current.Advise(modelLifetime, state =>
{
if (state == PlayModeState.Playing)
model.ConsoleLogging.LastPlayTime.Value = DateTime.UtcNow.Ticks;
});
}
}
}
// Empty namespaces to avoid #if for Unity 4.7
// ReSharper disable EmptyNamespace
namespace JetBrains.Rider.Unity.Editor.FindUsages {}
namespace JetBrains.Rider.Unity.Editor.UnitTesting {}