diff --git a/Torch.Server/Views/TorchUI.xaml.cs b/Torch.Server/Views/TorchUI.xaml.cs index 26a7294..88c97a0 100644 --- a/Torch.Server/Views/TorchUI.xaml.cs +++ b/Torch.Server/Views/TorchUI.xaml.cs @@ -10,6 +10,7 @@ using System.Windows.Controls; using System.Windows.Data; using System.Windows.Documents; using System.Windows.Input; +using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Navigation; using System.Windows.Shapes; @@ -31,6 +32,8 @@ namespace Torch.Server private TorchServer _server; private TorchConfig _config; + private bool _autoscrollLog = true; + public TorchUI(TorchServer server) { WindowStartupLocation = WindowStartupLocation.CenterScreen; @@ -57,6 +60,14 @@ namespace Torch.Server Themes.uiSource = this; Themes.SetConfig(_config); Title = $"{_config.InstanceName} - Torch {server.TorchVersion}, SE {server.GameVersion}"; + + Loaded += TorchUI_Loaded; + } + + private void TorchUI_Loaded(object sender, RoutedEventArgs e) + { + var scrollViewer = FindDescendant(ConsoleText); + scrollViewer.ScrollChanged += ConsoleText_OnScrollChanged; } private void AttachConsole() @@ -69,7 +80,52 @@ namespace Torch.Server doc = (wrapped?.WrappedTarget as FlowDocumentTarget)?.Document; } ConsoleText.Document = doc ?? new FlowDocument(new Paragraph(new Run("No target!"))); - ConsoleText.TextChanged += (sender, args) => ConsoleText.ScrollToEnd(); + ConsoleText.TextChanged += ConsoleText_OnTextChanged; + } + + public static T FindDescendant(DependencyObject obj) where T : DependencyObject + { + if (obj == null) return default(T); + int numberChildren = VisualTreeHelper.GetChildrenCount(obj); + if (numberChildren == 0) return default(T); + + for (int i = 0; i < numberChildren; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(obj, i); + if (child is T) + { + return (T)child; + } + } + + for (int i = 0; i < numberChildren; i++) + { + DependencyObject child = VisualTreeHelper.GetChild(obj, i); + var potentialMatch = FindDescendant(child); + if (potentialMatch != default(T)) + { + return potentialMatch; + } + } + + return default(T); + } + + private void ConsoleText_OnTextChanged(object sender, TextChangedEventArgs args) + { + var textBox = (RichTextBox) sender; + if (_autoscrollLog) + ConsoleText.ScrollToEnd(); + } + + private void ConsoleText_OnScrollChanged(object sender, ScrollChangedEventArgs e) + { + var scrollViewer = (ScrollViewer) sender; + if (e.ExtentHeightChange == 0) + { + // User change. + _autoscrollLog = scrollViewer.VerticalOffset == scrollViewer.ScrollableHeight; + } } public void LoadConfig(TorchConfig config) diff --git a/Torch/Extensions/LinqExtensions.cs b/Torch/Extensions/LinqExtensions.cs new file mode 100644 index 0000000..4fc4c53 --- /dev/null +++ b/Torch/Extensions/LinqExtensions.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace Torch +{ + public static class LinqExtensions + { + public static IEnumerable TSort( this IEnumerable source, Func> dependencies, bool throwOnCycle = false ) + { + var sorted = new List(); + var visited = new HashSet(); + + foreach( var item in source ) + Visit( item, visited, sorted, dependencies, throwOnCycle ); + + return sorted; + } + + private static void Visit( T item, HashSet visited, List sorted, Func> dependencies, bool throwOnCycle ) + { + if( !visited.Contains( item ) ) + { + visited.Add( item ); + + var resolvedDependencies = dependencies(item); + if (resolvedDependencies != null) + { + foreach (var dep in resolvedDependencies) + Visit(dep, visited, sorted, dependencies, throwOnCycle); + } + + sorted.Add( item ); + } + else + { + if( throwOnCycle && !sorted.Contains( item ) ) + throw new Exception( "Cyclic dependency found" ); + } + } + } +} \ No newline at end of file diff --git a/Torch/Plugins/PluginDependency.cs b/Torch/Plugins/PluginDependency.cs new file mode 100644 index 0000000..4727efe --- /dev/null +++ b/Torch/Plugins/PluginDependency.cs @@ -0,0 +1,17 @@ +using System; + +namespace Torch +{ + public class PluginDependency + { + /// + /// A unique identifier for the plugin that identifies the dependency. + /// + public Guid Plugin { get; set; } + + /// + /// The plugin minimum version. This must include a string in the format of #[.#[.#]] for update checking purposes. + /// + public string MinVersion { get; set; } + } +} \ No newline at end of file diff --git a/Torch/Plugins/PluginManager.cs b/Torch/Plugins/PluginManager.cs index 613354d..b93db8d 100644 --- a/Torch/Plugins/PluginManager.cs +++ b/Torch/Plugins/PluginManager.cs @@ -25,26 +25,39 @@ namespace Torch.Managers /// public class PluginManager : Manager, IPluginManager { + private class PluginItem + { + public string Filename { get; set; } + public string Path { get; set; } + public PluginManifest Manifest { get; set; } + public bool IsZip { get; set; } + public List ResolvedDependencies { get; set; } + } + private static Logger _log = LogManager.GetCurrentClassLogger(); + private const string MANIFEST_NAME = "manifest.xml"; + public readonly string PluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins"); private readonly MtObservableSortedDictionary _plugins = new MtObservableSortedDictionary(); + private CommandManager _mgr; + #pragma warning disable 649 [Dependency] private ITorchSessionManager _sessionManager; #pragma warning restore 649 - + /// public IReadOnlyDictionary Plugins => _plugins.AsReadOnlyObservable(); public event Action> PluginsLoaded; - + public PluginManager(ITorchBase torchInstance) : base(torchInstance) { if (!Directory.Exists(PluginDir)) Directory.CreateDirectory(PluginDir); } - + /// /// Updates loaded plugins in parallel. /// @@ -62,7 +75,7 @@ namespace Torch.Managers } } } - + /// public override void Attach() { @@ -70,8 +83,6 @@ namespace Torch.Managers _sessionManager.SessionStateChanged += SessionManagerOnSessionStateChanged; } - private CommandManager _mgr; - private void SessionManagerOnSessionStateChanged(ITorchSession session, TorchSessionState newState) { _mgr = session.Managers.GetManager(); @@ -93,7 +104,7 @@ namespace Torch.Managers return; } } - + /// /// Unloads all plugins. /// @@ -108,18 +119,102 @@ namespace Torch.Managers public void LoadPlugins() { - bool firstLoad = Torch.Config.Plugins.Count == 0; - List foundPlugins = new List(); - if (Torch.Config.ShouldUpdatePlugins) - DownloadPluginUpdates(); - _log.Info("Loading plugins..."); - var pluginItems = Directory.EnumerateFiles(PluginDir, "*.zip").Union(Directory.EnumerateDirectories(PluginDir)); + + var pluginItems = GetLocalPlugins(PluginDir); + var pluginsToLoad = new List(); + foreach (var item in pluginItems) + { + var pluginItem = item; + if (!TryValidatePluginDependencies(pluginItems, ref pluginItem, out var missingPlugins)) + { + // We have some missing dependencies. + // Future fix would be to download them, but instead for now let's + // just warn the user it's missing + foreach(var missingPlugin in missingPlugins) + _log.Warn($"{item.Manifest.Name} is missing dependency {missingPlugin}. Skipping plugin."); + continue; + } + + pluginsToLoad.Add(pluginItem); + } + + // Download any plugin updates. + bool updatesGotten = DownloadPluginUpdates(pluginsToLoad); + + if (updatesGotten) + { + // Resort the plugins just in case updates changed load hints. + pluginItems = GetLocalPlugins(PluginDir); + pluginsToLoad.Clear(); + foreach (var item in pluginItems) + { + var pluginItem = item; + if (!TryValidatePluginDependencies(pluginItems, ref pluginItem, out var missingPlugins)) + { + foreach (var missingPlugin in missingPlugins) + _log.Warn($"{item.Manifest.Name} is missing dependency {missingPlugin}. Skipping plugin."); + continue; + } + + pluginsToLoad.Add(pluginItem); + } + } + + // Sort based on dependencies. + try + { + pluginsToLoad = pluginsToLoad.TSort(item => item.ResolvedDependencies) + .ToList(); + } + catch (Exception e) + { + // This will happen on cylic dependencies. + _log.Error(e); + } + + // Actually load the plugins now. + foreach (var item in pluginsToLoad) + { + LoadPlugin(item); + } + + foreach (var plugin in _plugins.Values) + { + plugin.Init(Torch); + } + _log.Info($"Loaded {_plugins.Count} plugins."); + PluginsLoaded?.Invoke(_plugins.Values.AsReadOnly()); + } + + private List GetLocalPlugins(string pluginDir) + { + var firstLoad = Torch.Config.Plugins.Count == 0; + + var pluginItems = Directory.EnumerateFiles(pluginDir, "*.zip") + .Union(Directory.EnumerateDirectories(PluginDir)); + var results = new List(); + foreach (var item in pluginItems) { var path = Path.Combine(PluginDir, item); var isZip = item.EndsWith(".zip", StringComparison.CurrentCultureIgnoreCase); var manifest = isZip ? GetManifestFromZip(path) : GetManifestFromDirectory(path); + + if (manifest == null) + { + _log.Warn($"Item '{item}' is missing a manifest, skipping."); + continue; + } + + var duplicatePlugin = results.FirstOrDefault(r => r.Manifest.Guid == manifest.Guid); + if (duplicatePlugin != null) + { + _log.Warn( + $"The GUID provided by {manifest.Name} ({item}) is already in use by {duplicatePlugin.Manifest.Name}."); + continue; + } + if (!Torch.Config.LocalPlugins) { if (isZip && !Torch.Config.Plugins.Contains(manifest.Guid)) @@ -132,126 +227,42 @@ namespace Torch.Managers _log.Info($"First-time load: Plugin {manifest.Name} added to torch.cfg."); Torch.Config.Plugins.Add(manifest.Guid); } - if(isZip) - foundPlugins.Add(manifest.Guid); } - - LoadPlugin(item); + + results.Add(new PluginItem + { + Filename = item, + IsZip = isZip, + Manifest = manifest, + Path = path + }); } + if (!Torch.Config.LocalPlugins && firstLoad) Torch.Config.Save(); - if (!Torch.Config.LocalPlugins) - { - List toLoad = new List(); - - //This is actually the easiest way to batch process async tasks and block until completion (????) - Task.WhenAll(Torch.Config.Plugins.Select(async g => - { - try - { - if (foundPlugins.Contains(g)) - { - return; - } - var item = await PluginQuery.Instance.QueryOne(g); - string s = Path.Combine(PluginDir, item.Name + ".zip"); - await PluginQuery.Instance.DownloadPlugin(item, s); - lock (toLoad) - toLoad.Add(s); - } - catch (Exception ex) - { - _log.Error(ex); - } - })); - - foreach (var l in toLoad) - { - LoadPlugin(l); - } - } - - //just reuse the list from earlier - foundPlugins.Clear(); - foreach (var plugin in _plugins.Values) - { - try - { - plugin.Init(Torch); - } - catch (Exception e) - { - _log.Error(e, $"Plugin {plugin.Name} threw an exception during init! Unloading plugin!"); - foundPlugins.Add(plugin.Id); - } - } - - foreach (var id in foundPlugins) - { - var p = _plugins[id]; - _plugins.Remove(id); - _mgr.UnregisterPluginCommands(p); - p.Dispose(); - } - - _log.Info($"Loaded {_plugins.Count} plugins."); - PluginsLoaded?.Invoke(_plugins.Values.AsReadOnly()); - } - - private void LoadPlugin(string item) - { - var path = Path.Combine(PluginDir, item); - var isZip = item.EndsWith(".zip", StringComparison.CurrentCultureIgnoreCase); - var manifest = isZip ? GetManifestFromZip(path) : GetManifestFromDirectory(path); - if (manifest == null) - { - _log.Warn($"Item '{item}' is missing a manifest, skipping."); - return; - } - - if (_plugins.ContainsKey(manifest.Guid)) - { - _log.Error($"The GUID provided by {manifest.Name} ({item}) is already in use by {_plugins[manifest.Guid].Name}"); - return; - } - - if (isZip) - LoadPluginFromZip(path); - else - LoadPluginFromFolder(path); - } - - private void DownloadPluginUpdates() + return results; + } + + private bool DownloadPluginUpdates(List plugins) { _log.Info("Checking for plugin updates..."); var count = 0; - var pluginItems = Directory.EnumerateFiles(PluginDir, "*.zip"); - Task.WhenAll(pluginItems.Select(async item => + Task.WhenAll(plugins.Select(async item => { - PluginManifest manifest = null; try { - var path = Path.Combine(PluginDir, item); - var isZip = item.EndsWith(".zip", StringComparison.CurrentCultureIgnoreCase); - if (!isZip) + if (!item.IsZip) { _log.Warn($"Unzipped plugins cannot be auto-updated. Skipping plugin {item}"); return; } - manifest = GetManifestFromZip(path); - if (manifest == null) - { - _log.Warn($"Item '{item}' is missing a manifest, skipping update check."); - return; - } - - manifest.Version.TryExtractVersion(out Version currentVersion); - var latest = await PluginQuery.Instance.QueryOne(manifest.Guid); + item.Manifest.Version.TryExtractVersion(out Version currentVersion); + var latest = await PluginQuery.Instance.QueryOne(item.Manifest.Guid); if (latest?.LatestVersion == null) { - _log.Warn($"Plugin {manifest.Name} does not have any releases on torchapi.net. Cannot update."); + _log.Warn($"Plugin {item.Manifest.Name} does not have any releases on torchapi.net. Cannot update."); return; } @@ -259,115 +270,107 @@ namespace Torch.Managers if (currentVersion == null || newVersion == null) { - _log.Error($"Error parsing version from manifest or website for plugin '{manifest.Name}.'"); + _log.Error($"Error parsing version from manifest or website for plugin '{item.Manifest.Name}.'"); return; } if (newVersion <= currentVersion) { - _log.Debug($"{manifest.Name} {manifest.Version} is up to date."); + _log.Debug($"{item.Manifest.Name} {item.Manifest.Version} is up to date."); return; } - _log.Info($"Updating plugin '{manifest.Name}' from {currentVersion} to {newVersion}."); - await PluginQuery.Instance.DownloadPlugin(latest, path); + _log.Info($"Updating plugin '{item.Manifest.Name}' from {currentVersion} to {newVersion}."); + await PluginQuery.Instance.DownloadPlugin(latest, item.Path); Interlocked.Increment(ref count); } catch (Exception e) { - _log.Warn($"An error occurred updating the plugin {manifest?.Name ?? item}."); + _log.Warn($"An error occurred updating the plugin {item.Manifest.Name}."); _log.Warn(e); } })); _log.Info($"Updated {count} plugins."); + return count > 0; } - private void LoadPluginFromFolder(string directory) + private void LoadPlugin(PluginItem item) { var assemblies = new List(); - var files = Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories).ToList(); - - var manifest = GetManifestFromDirectory(directory); - if (manifest == null) + + if (item.IsZip) { - _log.Warn($"Directory {directory} is missing a manifest, skipping load."); - return; - } - - foreach (var file in files) - { - if (!file.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase)) - continue; - - using (var stream = File.OpenRead(file)) + using (var zipFile = ZipFile.OpenRead(item.Path)) { - var data = stream.ReadToEnd(); - byte[] symbol = null; - var symbolPath = Path.Combine(Path.GetDirectoryName(file) ?? ".", - Path.GetFileNameWithoutExtension(file) + ".pdb"); - if (File.Exists(symbolPath)) - try - { - using (var symbolStream = File.OpenRead(symbolPath)) - symbol = symbolStream.ReadToEnd(); - } - catch (Exception e) - { - _log.Warn(e, $"Failed to read debugging symbols from {symbolPath}"); - } - assemblies.Add(symbol != null ? Assembly.Load(data, symbol) : Assembly.Load(data)); - } - } - - RegisterAllAssemblies(assemblies); - InstantiatePlugin(manifest, assemblies); - } - - private void LoadPluginFromZip(string path) - { - PluginManifest manifest; - var assemblies = new List(); - using (var zipFile = ZipFile.OpenRead(path)) - { - manifest = GetManifestFromZip(path); - if (manifest == null) - { - _log.Warn($"Zip file {path} is missing a manifest, skipping."); - return; - } - - foreach (var entry in zipFile.Entries) - { - if (!entry.Name.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase)) - continue; - - - using (var stream = entry.Open()) + foreach (var entry in zipFile.Entries) { - var data = stream.ReadToEnd((int)entry.Length); - byte[] symbol = null; - var symbolEntryName = entry.FullName.Substring(0, entry.FullName.Length - "dll".Length) + "pdb"; - var symbolEntry = zipFile.GetEntry(symbolEntryName); - if (symbolEntry != null) - try - { - using (var symbolStream = symbolEntry.Open()) - symbol = symbolStream.ReadToEnd((int)symbolEntry.Length); - } - catch (Exception e) - { - _log.Warn(e, $"Failed to read debugging symbols from {path}:{symbolEntryName}"); - } - assemblies.Add(symbol != null ? Assembly.Load(data, symbol) : Assembly.Load(data)); + if (!entry.Name.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase)) + continue; + + + using (var stream = entry.Open()) + { + var data = stream.ReadToEnd((int) entry.Length); + byte[] symbol = null; + var symbolEntryName = + entry.FullName.Substring(0, entry.FullName.Length - "dll".Length) + "pdb"; + var symbolEntry = zipFile.GetEntry(symbolEntryName); + if (symbolEntry != null) + try + { + using (var symbolStream = symbolEntry.Open()) + symbol = symbolStream.ReadToEnd((int) symbolEntry.Length); + } + catch (Exception e) + { + _log.Warn(e, $"Failed to read debugging symbols from {item.Filename}:{symbolEntryName}"); + } + + assemblies.Add(symbol != null ? Assembly.Load(data, symbol) : Assembly.Load(data)); + } } } } + else + { + var files = Directory + .EnumerateFiles(item.Path, "*.*", SearchOption.AllDirectories) + .ToList(); + + foreach (var file in files) + { + if (!file.EndsWith(".dll", StringComparison.CurrentCultureIgnoreCase)) + continue; + using (var stream = File.OpenRead(file)) + { + var data = stream.ReadToEnd(); + byte[] symbol = null; + var symbolPath = Path.Combine(Path.GetDirectoryName(file) ?? ".", + Path.GetFileNameWithoutExtension(file) + ".pdb"); + if (File.Exists(symbolPath)) + try + { + using (var symbolStream = File.OpenRead(symbolPath)) + symbol = symbolStream.ReadToEnd(); + } + catch (Exception e) + { + _log.Warn(e, $"Failed to read debugging symbols from {symbolPath}"); + } + + assemblies.Add(symbol != null ? Assembly.Load(data, symbol) : Assembly.Load(data)); + } + } + + + } + RegisterAllAssemblies(assemblies); - InstantiatePlugin(manifest, assemblies); + InstantiatePlugin(item.Manifest, assemblies); } - + private void RegisterAllAssemblies(IReadOnlyCollection assemblies) { Assembly ResolveDependentAssembly(object sender, ResolveEventArgs args) @@ -395,38 +398,12 @@ namespace Torch.Managers TorchBase.RegisterAuxAssembly(asm); } } - + private static bool IsAssemblyCompatible(AssemblyName a, AssemblyName b) { return a.Name == b.Name && a.Version.Major == b.Version.Major && a.Version.Minor == b.Version.Minor; } - - - private PluginManifest GetManifestFromZip(string path) - { - using (var zipFile = ZipFile.OpenRead(path)) - { - foreach (var entry in zipFile.Entries) - { - if (!entry.Name.Equals(MANIFEST_NAME, StringComparison.CurrentCultureIgnoreCase)) - continue; - - using (var stream = new StreamReader(entry.Open())) - { - return PluginManifest.Load(stream); - } - } - } - - return null; - } - - private PluginManifest GetManifestFromDirectory(string directory) - { - var path = Path.Combine(directory, MANIFEST_NAME); - return !File.Exists(path) ? null : PluginManifest.Load(path); - } - + private void InstantiatePlugin(PluginManifest manifest, IEnumerable assemblies) { Type pluginType = null; @@ -495,6 +472,74 @@ namespace Torch.Managers plugin.Torch = Torch; _plugins.Add(manifest.Guid, plugin); } + + private PluginManifest GetManifestFromZip(string path) + { + using (var zipFile = ZipFile.OpenRead(path)) + { + foreach (var entry in zipFile.Entries) + { + if (!entry.Name.Equals(MANIFEST_NAME, StringComparison.CurrentCultureIgnoreCase)) + continue; + + using (var stream = new StreamReader(entry.Open())) + { + return PluginManifest.Load(stream); + } + } + } + + return null; + } + + private bool TryValidatePluginDependencies(List items, ref PluginItem item, out List missingDependencies) + { + var dependencies = new List(); + missingDependencies = new List(); + + foreach (var pluginDependency in item.Manifest.Dependencies) + { + var dependency = items + .FirstOrDefault(pi => pi?.Manifest.Guid == pluginDependency.Plugin); + if (dependency == null) + { + missingDependencies.Add(pluginDependency.Plugin); + continue; + } + + if (!string.IsNullOrEmpty(pluginDependency.MinVersion) + && dependency.Manifest.Version.TryExtractVersion(out var dependencyVersion) + && pluginDependency.MinVersion.TryExtractVersion(out var minVersion)) + { + // really only care about version if it is defined. + if (dependencyVersion < minVersion) + { + // If dependency version is too low, we can try to update. Otherwise + // it's a missing dependency. + + // For now let's just warn the user. bitMuse is lazy. + _log.Warn($"{dependency.Manifest.Name} is below the requested version for {item.Manifest.Name}." + + Environment.NewLine + + $" Desired version: {pluginDependency.MinVersion}, Available version: {dependency.Manifest.Version}"); + missingDependencies.Add(pluginDependency.Plugin); + continue; + } + } + + dependencies.Add(dependency); + } + + item.ResolvedDependencies = dependencies; + if (missingDependencies.Count > 0) + return false; + return true; + } + + private PluginManifest GetManifestFromDirectory(string directory) + { + var path = Path.Combine(directory, MANIFEST_NAME); + return !File.Exists(path) ? null : PluginManifest.Load(path); + } /// public IEnumerator GetEnumerator() diff --git a/Torch/Plugins/PluginManifest.cs b/Torch/Plugins/PluginManifest.cs index 7d1cc89..59ec853 100644 --- a/Torch/Plugins/PluginManifest.cs +++ b/Torch/Plugins/PluginManifest.cs @@ -37,7 +37,7 @@ namespace Torch /// /// A list of dependent plugin repositories. This may be updated to include GUIDs in the future. /// - public List Dependencies { get; } = new List(); + public List Dependencies { get; } = new List(); public void Save(string path) { diff --git a/Torch/Torch.csproj b/Torch/Torch.csproj index f7bc7b6..446c9ef 100644 --- a/Torch/Torch.csproj +++ b/Torch/Torch.csproj @@ -168,9 +168,12 @@ ..\GameBinaries\VRage.OpenVRWrapper.dll False - + ..\bin\x64\Release\DedicatedServer64\VRage.Platform.Windows.dll + + ..\GameBinaries\VRage.Platform.Windows.dll + ..\GameBinaries\VRage.Render.dll False @@ -205,6 +208,7 @@ + @@ -244,6 +248,7 @@ +