diff --git a/Jenkinsfile b/Jenkinsfile index cec3b0b..1dd41a7 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,3 +1,21 @@ +def packageAndArchive(buildMode, packageName, exclude) { + zipFile = "bin\\${packageName}.zip" + packageDir = "bin\\${packageName}\\" + + bat "IF EXIST ${zipFile} DEL ${zipFile}" + bat "IF EXIST ${packageDir} RMDIR /S /Q ${packageDir}" + + bat "xcopy bin\\x64\\${buildMode} ${packageDir}" + if (exclude.length() > 0) { + bat "del ${packageDir}${exclude}" + } + if (buildMode == "Release") { + bat "del ${packageDir}*.pdb" + } + powershell "Add-Type -Assembly System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::CreateFromDirectory(\"\$PWD\\${packageDir}\", \"\$PWD\\${zipFile}\")" + archiveArtifacts artifacts: zipFile, caseSensitive: false, onlyIfSuccessful: true +} + node { stage('Checkout') { checkout scm @@ -16,12 +34,18 @@ node { stage('Build') { currentBuild.description = bat(returnStdout: true, script: '@powershell -File Versioning/version.ps1').trim() - bat "\"${tool 'MSBuild'}msbuild\" Torch.sln /p:Configuration=Release /p:Platform=x64" + if (env.BRANCH_NAME == "master") { + buildMode = "Release" + } else { + buildMode = "Debug" + } + bat "\"${tool 'MSBuild'}msbuild\" Torch.sln /p:Configuration=${buildMode} /p:Platform=x64 /t:Clean" + bat "\"${tool 'MSBuild'}msbuild\" Torch.sln /p:Configuration=${buildMode} /p:Platform=x64" } stage('Test') { bat 'IF NOT EXIST reports MKDIR reports' - bat "\"packages/xunit.runner.console.2.2.0/tools/xunit.console.exe\" \"bin-test/x64/Release/Torch.Tests.dll\" \"bin-test/x64/Release/Torch.Server.Tests.dll\" \"bin-test/x64/Release/Torch.Client.Tests.dll\" -parallel none -xml \"reports/Torch.Tests.xml\"" + bat "\"packages/xunit.runner.console.2.2.0/tools/xunit.console.exe\" \"bin-test/x64/${buildMode}/Torch.Tests.dll\" \"bin-test/x64/${buildMode}/Torch.Server.Tests.dll\" \"bin-test/x64/${buildMode}/Torch.Client.Tests.dll\" -parallel none -xml \"reports/Torch.Tests.xml\"" step([ $class: 'XUnitBuilder', thresholdMode: 1, @@ -38,29 +62,21 @@ node { } stage('Archive') { - bat '''IF EXIST bin\\torch-server.zip DEL bin\\torch-server.zip - IF EXIST bin\\package-server RMDIR /S /Q bin\\package-server - xcopy bin\\x64\\Release bin\\package-server\\ - del bin\\package-server\\Torch.Client*''' - bat "powershell -Command \"Add-Type -Assembly System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::CreateFromDirectory(\\\"\$PWD\\bin\\package-server\\\", \\\"\$PWD\\bin\\torch-server.zip\\\")\"" - archiveArtifacts artifacts: 'bin/torch-server.zip', caseSensitive: false, onlyIfSuccessful: true + archiveArtifacts artifacts: "bin/x64/${buildMode}/Torch*", caseSensitive: false, fingerprint: true, onlyIfSuccessful: true - bat '''IF EXIST bin\\torch-client.zip DEL bin\\torch-client.zip - IF EXIST bin\\package-client RMDIR /S /Q bin\\package-client - xcopy bin\\x64\\Release bin\\package-client\\ - del bin\\package-client\\Torch.Server*''' - bat "powershell -Command \"Add-Type -Assembly System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::CreateFromDirectory(\\\"\$PWD\\bin\\package-client\\\", \\\"\$PWD\\bin\\torch-client.zip\\\")\"" - archiveArtifacts artifacts: 'bin/torch-client.zip', caseSensitive: false, onlyIfSuccessful: true + packageAndArchive(buildMode, "torch-server", "Torch.Client*") - archiveArtifacts artifacts: 'bin/x64/Release/Torch*', caseSensitive: false, fingerprint: true, onlyIfSuccessful: true + packageAndArchive(buildMode, "torch-client", "Torch.Server*") } - gitVersion = bat(returnStdout: true, script: "@git describe --tags").trim() - gitSimpleVersion = bat(returnStdout: true, script: "@git describe --tags --abbrev=0").trim() - if (gitVersion == gitSimpleVersion) { - stage('Release') { - withCredentials([usernamePassword(credentialsId: 'torch-github', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { - powershell "& ./Jenkins/release.ps1 \"https://api.github.com/repos/TorchAPI/Torch/\" \"$gitSimpleVersion\" \"$USERNAME:$PASSWORD\" @(\"bin/torch-server.zip\", \"bin/torch-client.zip\")" + if (env.BRANCH_NAME == "master") { + gitVersion = bat(returnStdout: true, script: "@git describe --tags").trim() + gitSimpleVersion = bat(returnStdout: true, script: "@git describe --tags --abbrev=0").trim() + if (gitVersion == gitSimpleVersion) { + stage('${buildMode}') { + withCredentials([usernamePassword(credentialsId: 'torch-github', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { + powershell "& ./Jenkins/${buildMode}.ps1 \"https://api.github.com/repos/TorchAPI/Torch/\" \"$gitSimpleVersion\" \"$USERNAME:$PASSWORD\" @(\"bin/torch-server.zip\", \"bin/torch-client.zip\")" + } } } } diff --git a/Torch.API/Managers/IPluginManager.cs b/Torch.API/Managers/IPluginManager.cs index 28caf87..2a2ee08 100644 --- a/Torch.API/Managers/IPluginManager.cs +++ b/Torch.API/Managers/IPluginManager.cs @@ -14,12 +14,12 @@ namespace Torch.API.Managers /// /// Fired when plugins are loaded. /// - event Action> PluginsLoaded; + event Action> PluginsLoaded; /// /// Collection of loaded plugins. /// - IList Plugins { get; } + IReadOnlyDictionary Plugins { get; } /// /// Updates all loaded plugins. diff --git a/Torch.API/Plugins/ITorchPlugin.cs b/Torch.API/Plugins/ITorchPlugin.cs index 3e8f9aa..35a34df 100644 --- a/Torch.API/Plugins/ITorchPlugin.cs +++ b/Torch.API/Plugins/ITorchPlugin.cs @@ -17,7 +17,7 @@ namespace Torch.API.Plugins /// /// The version of the plugin. /// - Version Version { get; } + string Version { get; } /// /// The name of the plugin. diff --git a/Torch.API/Plugins/PluginAttribute.cs b/Torch.API/Plugins/PluginAttribute.cs index 7b60e32..d8c1297 100644 --- a/Torch.API/Plugins/PluginAttribute.cs +++ b/Torch.API/Plugins/PluginAttribute.cs @@ -10,6 +10,7 @@ namespace Torch.API.Plugins /// /// Indicates that the given type should be loaded by the plugin manager as a plugin. /// + [Obsolete("All plugin meta-information is now defined in the manifest.xml.")] [AttributeUsage(AttributeTargets.Class)] public class PluginAttribute : Attribute { diff --git a/Torch.Server/ViewModels/PluginManagerViewModel.cs b/Torch.Server/ViewModels/PluginManagerViewModel.cs index ed43547..d910cf8 100644 --- a/Torch.Server/ViewModels/PluginManagerViewModel.cs +++ b/Torch.Server/ViewModels/PluginManagerViewModel.cs @@ -29,7 +29,7 @@ namespace Torch.Server.ViewModels pluginManager.PluginsLoaded += PluginManager_PluginsLoaded; } - private void PluginManager_PluginsLoaded(IList obj) + private void PluginManager_PluginsLoaded(IReadOnlyCollection obj) { Plugins.Clear(); foreach (var plugin in obj) diff --git a/Torch.Server/Views/PluginsControl.xaml b/Torch.Server/Views/PluginsControl.xaml index a9f343e..f0bc7a4 100644 --- a/Torch.Server/Views/PluginsControl.xaml +++ b/Torch.Server/Views/PluginsControl.xaml @@ -12,7 +12,7 @@ - + @@ -27,7 +27,7 @@ - public partial class PluginsControl : UserControl { + private ITorchServer _server; + private PluginManager _plugins; + public PluginsControl() { InitializeComponent(); @@ -31,8 +37,15 @@ namespace Torch.Server.Views public void BindServer(ITorchServer server) { - var pluginManager = new PluginManagerViewModel(server.Plugins); + _server = server; + _plugins = _server.Managers.GetManager(); + var pluginManager = new PluginManagerViewModel(_plugins); DataContext = pluginManager; } + + private void OpenFolder_OnClick(object sender, RoutedEventArgs e) + { + Process.Start("explorer.exe", _plugins.PluginDir); + } } } diff --git a/Torch/Collections/ObservableDictionary.cs b/Torch/Collections/ObservableDictionary.cs index 2d3f955..68a4a33 100644 --- a/Torch/Collections/ObservableDictionary.cs +++ b/Torch/Collections/ObservableDictionary.cs @@ -1,8 +1,10 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Collections.Specialized; using System.ComponentModel; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; using System.Windows.Threading; @@ -10,28 +12,148 @@ using System.Windows.Threading; namespace Torch.Collections { [Serializable] - public class ObservableDictionary : Dictionary, INotifyCollectionChanged, INotifyPropertyChanged + public class ObservableDictionary : ViewModel, IDictionary, INotifyCollectionChanged { - /// - public new void Add(TKey key, TValue value) + private IDictionary _internalDict; + + public ObservableDictionary() { - base.Add(key, value); - var kv = new KeyValuePair(key, value); - OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, kv)); + _internalDict = new Dictionary(); + } + + public ObservableDictionary(IDictionary dictionary) + { + _internalDict = new Dictionary(dictionary); + } + + /// + /// Create a using the given dictionary by reference. The original dictionary should not be used after calling this. + /// + public static ObservableDictionary ByReference(IDictionary dictionary) + { + return new ObservableDictionary + { + _internalDict = dictionary + }; } /// - public new bool Remove(TKey key) + public event NotifyCollectionChangedEventHandler CollectionChanged; + + /// + public event PropertyChangedEventHandler PropertyChanged; + + /// + public IEnumerator> GetEnumerator() { - if (!ContainsKey(key)) + return _internalDict.GetEnumerator(); + } + + /// + IEnumerator IEnumerable.GetEnumerator() + { + return ((IEnumerable)_internalDict).GetEnumerator(); + } + + /// + public void Add(KeyValuePair item) + { + Add(item.Key, item.Value); + } + + /// + public bool Remove(KeyValuePair item) + { + return Remove(item.Key); + } + + /// + public void Clear() + { + _internalDict.Clear(); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + OnPropertyChanged(nameof(Count)); + } + + /// + public bool Contains(KeyValuePair item) + { + return _internalDict.Contains(item); + } + + /// + public void CopyTo(KeyValuePair[] array, int arrayIndex) + { + foreach (var kv in _internalDict) + { + array[arrayIndex] = kv; + arrayIndex++; + } + } + + /// + public int Count => _internalDict.Count; + + /// + public bool IsReadOnly => false; + + /// + public bool ContainsKey(TKey key) + { + return _internalDict.ContainsKey(key); + } + + /// + public void Add(TKey key, TValue value) + { + _internalDict.Add(key, value); + var kv = new KeyValuePair(key, value); + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Add, kv)); + OnPropertyChanged(nameof(Count)); + } + + /// + public bool Remove(TKey key) + { + if (!_internalDict.ContainsKey(key)) return false; var kv = new KeyValuePair(key, this[key]); - base.Remove(key); + if (!_internalDict.Remove(key)) + return false; + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Remove, kv)); + OnPropertyChanged(nameof(Count)); return true; } + /// + public bool TryGetValue(TKey key, out TValue value) + { + return _internalDict.TryGetValue(key, out value); + } + + /// + public TValue this[TKey key] + { + get => _internalDict[key]; + set + { + var oldKv = new KeyValuePair(key, _internalDict[key]); + var newKv = new KeyValuePair(key, value); + _internalDict[key] = value; + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Replace, newKv, oldKv)); + } + + + } + + /// + public ICollection Keys => _internalDict.Keys; + + /// + public ICollection Values => _internalDict.Values; + private void OnCollectionChanged(NotifyCollectionChangedEventArgs e) { NotifyCollectionChangedEventHandler collectionChanged = CollectionChanged; @@ -52,12 +174,5 @@ namespace Torch.Collections nh.Invoke(this, e); } } - - - /// - public event NotifyCollectionChangedEventHandler CollectionChanged; - - /// - public event PropertyChangedEventHandler PropertyChanged; } } diff --git a/Torch/Commands/TorchCommands.cs b/Torch/Commands/TorchCommands.cs index 1430587..cdc7fd8 100644 --- a/Torch/Commands/TorchCommands.cs +++ b/Torch/Commands/TorchCommands.cs @@ -106,7 +106,7 @@ namespace Torch.Commands [Permission(MyPromoteLevel.None)] public void Plugins() { - var plugins = Context.Torch.Managers.GetManager()?.Plugins.Select(p => p.Name) ?? Enumerable.Empty(); + var plugins = Context.Torch.Managers.GetManager()?.Plugins.Select(p => p.Value.Name) ?? Enumerable.Empty(); Context.Respond($"Loaded plugins: {string.Join(", ", plugins)}"); } diff --git a/Torch/DispatcherExtensions.cs b/Torch/Extensions/DispatcherExtensions.cs similarity index 100% rename from Torch/DispatcherExtensions.cs rename to Torch/Extensions/DispatcherExtensions.cs diff --git a/Torch/Extensions/ICollectionExtensions.cs b/Torch/Extensions/ICollectionExtensions.cs new file mode 100644 index 0000000..acc952c --- /dev/null +++ b/Torch/Extensions/ICollectionExtensions.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; + +namespace Torch +{ + public static class ICollectionExtensions + { + /// + /// Returns a read-only wrapped + /// + public static IReadOnlyCollection AsReadOnly(this ICollection source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + return source as IReadOnlyCollection ?? new ReadOnlyCollectionAdapter(source); + } + + /// + /// Returns a read-only wrapped + /// + public static IReadOnlyList AsReadOnly(this IList source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + return source as IReadOnlyList ?? new ReadOnlyCollection(source); + } + + /// + /// Returns a read-only wrapped + /// + public static IReadOnlyDictionary AsReadOnly(this IDictionary source) + { + if (source == null) + throw new ArgumentNullException(nameof(source)); + return source as IReadOnlyDictionary ?? new ReadOnlyDictionary(source); + } + + sealed class ReadOnlyCollectionAdapter : IReadOnlyCollection + { + private readonly ICollection _source; + + public ReadOnlyCollectionAdapter(ICollection source) + { + _source = source; + } + + public int Count => _source.Count; + public IEnumerator GetEnumerator() => _source.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/Torch/Extensions/StringExtensions.cs b/Torch/Extensions/StringExtensions.cs index 29c8a60..10e86d2 100644 --- a/Torch/Extensions/StringExtensions.cs +++ b/Torch/Extensions/StringExtensions.cs @@ -2,51 +2,21 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; namespace Torch { public static class StringExtensions { - public static string Truncate(this string s, int maxLength) + /// + /// Try to extract a 3 component version from the string. Format: #.#.# + /// + public static bool TryExtractVersion(this string version, out Version result) { - return s.Length <= maxLength ? s : s.Substring(0, maxLength); - } - - public static IEnumerable ReadLines(this string s, int max, bool skipEmpty = false, char delim = '\n') - { - var lines = s.Split(delim); - - for (var i = 0; i < lines.Length && i < max; i++) - { - var l = lines[i]; - if (skipEmpty && string.IsNullOrWhiteSpace(l)) - continue; - - yield return l; - } - } - - public static string Wrap(this string s, int lineLength) - { - if (s.Length <= lineLength) - return s; - - var result = new StringBuilder(); - for (var i = 0; i < s.Length;) - { - var next = i + lineLength; - if (s.Length - 1 < next) - { - result.AppendLine(s.Substring(i)); - break; - } - - result.AppendLine(s.Substring(i, next)); - i = next; - } - - return result.ToString(); + result = null; + var match = Regex.Match(version, @"(\d+\.)?(\d+\.)?(\d+)"); + return match.Success && Version.TryParse(match.Value, out result); } } } diff --git a/Torch/Managers/UpdateManager.cs b/Torch/Managers/UpdateManager.cs index 02d0a2a..62b8e25 100644 --- a/Torch/Managers/UpdateManager.cs +++ b/Torch/Managers/UpdateManager.cs @@ -44,7 +44,7 @@ namespace Torch.Managers CheckAndUpdateTorch(); } - private async Task> GetLatestRelease(string owner, string name) + private async Task> TryGetLatestArchiveUrl(string owner, string name) { try { @@ -53,56 +53,17 @@ namespace Torch.Managers return new Tuple(new Version(), null); var zip = latest.Assets.FirstOrDefault(x => x.Name.Contains(".zip")); - var versionName = Regex.Match(latest.TagName, "(\\d+\\.)+\\d+").ToString(); - if (string.IsNullOrWhiteSpace(versionName)) - { - _log.Warn("Unable to parse tag {0} for {1}/{2}", latest.TagName, owner, name); - versionName = "0.0"; - } - return new Tuple(new Version(versionName), zip?.BrowserDownloadUrl); + if (zip == null) + _log.Error($"Latest release of {owner}/{name} does not contain a zip archive."); + if (!latest.TagName.TryExtractVersion(out Version version)) + _log.Error($"Unable to parse version tag for {owner}/{name}"); + return new Tuple(version, zip?.BrowserDownloadUrl); } catch (Exception e) { _log.Error($"An error occurred getting release information for '{owner}/{name}'"); _log.Error(e); - return new Tuple(new Version(), null); - } - } - - public async Task CheckAndUpdatePlugin(PluginManifest manifest) - { - if (!Torch.Config.GetPluginUpdates) - return; - - try - { - var name = manifest.Repository.Split('/'); - if (name.Length != 2) - { - _log.Error($"'{manifest.Repository}' is not a valid GitHub repository."); - return; - } - - var currentVersion = new Version(manifest.Version); - var releaseInfo = await GetLatestRelease(name[0], name[1]).ConfigureAwait(false); - if (releaseInfo.Item1 > currentVersion) - { - _log.Warn($"Updating {manifest.Repository} from version {currentVersion} to version {releaseInfo.Item1}"); - var updateName = Path.Combine(_fsManager.TempDirectory, $"{name[0]}_{name[1]}.zip"); - var updatePath = Path.Combine(_torchDir, "Plugins"); - await new WebClient().DownloadFileTaskAsync(new Uri(releaseInfo.Item2), updateName).ConfigureAwait(false); - UpdateFromZip(updateName, updatePath); - File.Delete(updateName); - } - else - { - _log.Info($"{manifest.Repository} is up to date. ({currentVersion})"); - } - } - catch (Exception e) - { - _log.Error($"An error occured downloading the plugin update for {manifest.Repository}."); - _log.Error(e); + return default(Tuple); } } @@ -116,7 +77,7 @@ namespace Torch.Managers try { - var releaseInfo = await GetLatestRelease("TorchAPI", "Torch").ConfigureAwait(false); + var releaseInfo = await TryGetLatestArchiveUrl("TorchAPI", "Torch").ConfigureAwait(false); if (releaseInfo.Item1 > Torch.TorchVersion) { _log.Warn($"Updating Torch from version {Torch.TorchVersion} to version {releaseInfo.Item1}"); diff --git a/Torch/PluginManifest.cs b/Torch/PluginManifest.cs deleted file mode 100644 index 6f13cb4..0000000 --- a/Torch/PluginManifest.cs +++ /dev/null @@ -1,34 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using System.Xml.Serialization; - -namespace Torch -{ - public class PluginManifest - { - public string Repository { get; set; } = "Jimmacle/notarealrepo"; - public string Version { get; set; } = "1.0"; - - public void Save(string path) - { - using (var f = File.OpenWrite(path)) - { - var ser = new XmlSerializer(typeof(PluginManifest)); - ser.Serialize(f, this); - } - } - - public static PluginManifest Load(string path) - { - using (var f = File.OpenRead(path)) - { - var ser = new XmlSerializer(typeof(PluginManifest)); - return (PluginManifest)ser.Deserialize(f); - } - } - } -} diff --git a/Torch/Plugins/PluginManager.cs b/Torch/Plugins/PluginManager.cs new file mode 100644 index 0000000..25eb10c --- /dev/null +++ b/Torch/Plugins/PluginManager.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net; +using System.Reflection; +using System.Threading.Tasks; +using System.Xml.Serialization; +using NLog; +using Octokit; +using Torch.API; +using Torch.API.Managers; +using Torch.API.Plugins; +using Torch.Collections; +using Torch.Commands; + +namespace Torch.Managers +{ + /// + public class PluginManager : Manager, IPluginManager + { + private GitHubClient _gitClient = new GitHubClient(new ProductHeaderValue("Torch")); + private static Logger _log = LogManager.GetLogger(nameof(PluginManager)); + private const string MANIFEST_NAME = "manifest.xml"; + public readonly string PluginDir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Plugins"); + private readonly ObservableDictionary _plugins = new ObservableDictionary(); + [Dependency] + private CommandManager _commandManager; + + /// + public IReadOnlyDictionary Plugins => _plugins.AsReadOnly(); + + public event Action> PluginsLoaded; + + public PluginManager(ITorchBase torchInstance) : base(torchInstance) + { + if (!Directory.Exists(PluginDir)) + Directory.CreateDirectory(PluginDir); + } + + /// + /// Updates loaded plugins in parallel. + /// + public void UpdatePlugins() + { + foreach (var plugin in _plugins.Values) + plugin.Update(); + } + + /// + /// Unloads all plugins. + /// + public override void Detach() + { + foreach (var plugin in _plugins.Values) + plugin.Dispose(); + + _plugins.Clear(); + } + + public void LoadPlugins() + { + DownloadPluginUpdates(); + _log.Info("Loading plugins..."); + var pluginItems = Directory.EnumerateFiles(PluginDir, "*.zip").Union(Directory.EnumerateDirectories(PluginDir)); + 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; + } + + if (_plugins.ContainsKey(manifest.Guid)) + { + _log.Error($"The GUID provided by {manifest.Name} ({item}) is already in use by {_plugins[manifest.Guid].Name}"); + continue; + } + + if (isZip) + LoadPluginFromZip(path); + else + LoadPluginFromFolder(path); + } + + _plugins.ForEach(x => x.Value.Init(Torch)); + _log.Info($"Loaded {_plugins.Count} plugins."); + PluginsLoaded?.Invoke(_plugins.Values.AsReadOnly()); + } + + private void DownloadPluginUpdates() + { + _log.Info("Checking for plugin updates..."); + var count = 0; + var pluginItems = Directory.EnumerateFiles(PluginDir, "*.zip").Union(Directory.EnumerateDirectories(PluginDir)); + Parallel.ForEach(pluginItems, async item => + { + PluginManifest manifest = null; + try + { + var path = Path.Combine(PluginDir, item); + var isZip = item.EndsWith(".zip", StringComparison.CurrentCultureIgnoreCase); + manifest = isZip ? GetManifestFromZip(path) : GetManifestFromDirectory(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 GetLatestArchiveAsync(manifest.Repository).ConfigureAwait(false); + + if (currentVersion == null || latest.Item1 == null) + { + _log.Error($"Error parsing version from manifest or GitHub for plugin '{manifest.Name}.'"); + return; + } + + if (latest.Item1 <= currentVersion) + { + _log.Debug($"{manifest.Name} {manifest.Version} is up to date."); + return; + } + + _log.Info($"Updating plugin '{manifest.Name}' from {currentVersion} to {latest.Item1}."); + await UpdatePluginAsync(path, latest.Item2).ConfigureAwait(false); + count++; + } + catch (Exception e) + { + _log.Error($"An error occurred updating the plugin {manifest.Name}."); + _log.Error(e); + } + }); + + _log.Info($"Updated {count} plugins."); + } + + private async Task> GetLatestArchiveAsync(string repository) + { + try + { + var split = repository.Split('/'); + var latest = await _gitClient.Repository.Release.GetLatest(split[0], split[1]).ConfigureAwait(false); + if (!latest.TagName.TryExtractVersion(out Version latestVersion)) + { + _log.Error($"Unable to parse version tag for the latest release of '{repository}.'"); + } + + var zipAsset = latest.Assets.FirstOrDefault(x => x.Name.Contains(".zip", StringComparison.CurrentCultureIgnoreCase)); + if (zipAsset == null) + { + _log.Error($"Unable to find archive for the latest release of '{repository}.'"); + } + + return new Tuple(latestVersion, zipAsset?.BrowserDownloadUrl); + } + catch (Exception e) + { + _log.Error($"Unable to get the latest release of '{repository}.'"); + _log.Error(e); + return default(Tuple); + } + } + + private Task UpdatePluginAsync(string localPath, string downloadUrl) + { + if (File.Exists(localPath)) + File.Delete(localPath); + + if (Directory.Exists(localPath)) + Directory.Delete(localPath, true); + + var fileName = downloadUrl.Split('/').Last(); + var filePath = Path.Combine(PluginDir, fileName); + + return new WebClient().DownloadFileTaskAsync(downloadUrl, filePath); + } + + private void LoadPluginFromFolder(string directory) + { + var assemblies = new List(); + var files = Directory.EnumerateFiles(directory, "*.*", SearchOption.AllDirectories).ToList(); + + var manifest = GetManifestFromDirectory(directory); + if (manifest == null) + { + _log.Warn($"Directory {directory} is missing a manifest, skipping load."); + return; + } + + foreach (var file in files) + { + if (!file.Contains(".dll", StringComparison.CurrentCultureIgnoreCase)) + continue; + + using (var stream = File.OpenRead(file)) + { + var data = new byte[stream.Length]; + stream.Read(data, 0, data.Length); + assemblies.Add(Assembly.Load(data)); + } + } + + 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.Contains(".dll", StringComparison.CurrentCultureIgnoreCase)) + continue; + + using (var stream = entry.Open()) + { + var data = new byte[entry.Length]; + stream.Read(data, 0, data.Length); + assemblies.Add(Assembly.Load(data)); + } + } + } + + InstantiatePlugin(manifest, assemblies); + } + + 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; + foreach (var asm in assemblies) + { + foreach (var type in asm.GetExportedTypes()) + { + if (!type.GetInterfaces().Contains(typeof(ITorchPlugin))) + continue; + + if (pluginType != null) + { + _log.Error($"The plugin '{manifest.Name}' has multiple implementations of {nameof(ITorchPlugin)}, not loading."); + return; + } + + pluginType = type; + } + } + + if (pluginType == null) + { + _log.Error($"The plugin '{manifest.Name}' does not have an implementation of {nameof(ITorchPlugin)}, not loading."); + return; + } + + // Backwards compatibility for PluginAttribute. + var pluginAttr = pluginType.GetCustomAttribute(); + if (pluginAttr != null) + { + _log.Warn($"Plugin '{manifest.Name}' is using the obsolete {nameof(PluginAttribute)}, using info from attribute if necessary."); + manifest.Version = manifest.Version ?? pluginAttr.Version.ToString(); + manifest.Name = manifest.Name ?? pluginAttr.Name; + if (manifest.Guid == default(Guid)) + manifest.Guid = pluginAttr.Guid; + } + + _log.Info($"Loading plugin '{manifest.Name}' ({manifest.Version})"); + var plugin = (TorchPluginBase)Activator.CreateInstance(pluginType); + + plugin.Manifest = manifest; + plugin.StoragePath = Torch.Config.InstancePath; + plugin.Torch = Torch; + _plugins.Add(manifest.Guid, plugin); + _commandManager.RegisterPluginCommands(plugin); + } + + /// + public IEnumerator GetEnumerator() + { + return _plugins.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/Torch/Plugins/PluginManifest.cs b/Torch/Plugins/PluginManifest.cs new file mode 100644 index 0000000..13618a9 --- /dev/null +++ b/Torch/Plugins/PluginManifest.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.Xml.Serialization; + +namespace Torch +{ + public class PluginManifest + { + /// + /// The display name of the plugin. + /// + public string Name { get; set; } + + /// + /// A unique identifier for the plugin. + /// + public Guid Guid { get; set; } + + /// + /// A GitHub repository in the format of Author/Repository to retrieve plugin updates. + /// + public string Repository { get; set; } + + /// + /// The plugin version. This must include a string in the format of #[.#[.#]] for update checking purposes. + /// + public string Version { get; set; } + + /// + /// A list of dependent plugin repositories. This may be updated to include GUIDs in the future. + /// + public List Dependencies { get; } = new List(); + + public void Save(string path) + { + using (var f = File.OpenWrite(path)) + { + var ser = new XmlSerializer(typeof(PluginManifest)); + ser.Serialize(f, this); + } + } + + public static PluginManifest Load(string path) + { + using (var f = File.OpenRead(path)) + { + return Load(f); + } + } + + public static PluginManifest Load(Stream stream) + { + var ser = new XmlSerializer(typeof(PluginManifest)); + return (PluginManifest)ser.Deserialize(stream); + } + + public static PluginManifest Load(TextReader reader) + { + var ser = new XmlSerializer(typeof(PluginManifest)); + return (PluginManifest)ser.Deserialize(reader); + } + } +} diff --git a/Torch/Torch.csproj b/Torch/Torch.csproj index 5a33e03..9d94c77 100644 --- a/Torch/Torch.csproj +++ b/Torch/Torch.csproj @@ -155,9 +155,10 @@ Properties\AssemblyVersion.cs - + + @@ -204,7 +205,7 @@ - + @@ -221,7 +222,7 @@ - + CollectionEditor.xaml diff --git a/Torch/TorchBase.cs b/Torch/TorchBase.cs index cb0f9d1..75d5612 100644 --- a/Torch/TorchBase.cs +++ b/Torch/TorchBase.cs @@ -259,7 +259,7 @@ namespace Torch } catch { - // Running as service + // Running as service } #if DEBUG diff --git a/Torch/TorchPluginBase.cs b/Torch/TorchPluginBase.cs index 0bead82..64859d9 100644 --- a/Torch/TorchPluginBase.cs +++ b/Torch/TorchPluginBase.cs @@ -16,30 +16,11 @@ namespace Torch public abstract class TorchPluginBase : ITorchPlugin { public string StoragePath { get; internal set; } - public Guid Id { get; } - public Version Version { get; } - public string Name { get; } - public ITorchBase Torch { get; private set; } - private static readonly Logger _log = LogManager.GetLogger(nameof(TorchPluginBase)); - - protected TorchPluginBase() - { - var type = GetType(); - var pluginInfo = type.GetCustomAttribute(); - if (pluginInfo == null) - { - _log.Warn($"Plugin {type.FullName} has no PluginAttribute"); - Name = type.FullName; - Version = new Version(0, 0, 0, 0); - Id = default(Guid); - } - else - { - Name = pluginInfo.Name; - Version = pluginInfo.Version; - Id = pluginInfo.Guid; - } - } + public PluginManifest Manifest { get; internal set; } + public Guid Id => Manifest.Guid; + public string Version => Manifest.Version; + public string Name => Manifest.Name; + public ITorchBase Torch { get; internal set; } public virtual void Init(ITorchBase torch) { diff --git a/Torch/ViewModels/ViewModel.cs b/Torch/ViewModels/ViewModel.cs index 010f34c..7233102 100644 --- a/Torch/ViewModels/ViewModel.cs +++ b/Torch/ViewModels/ViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Threading.Tasks; @@ -20,6 +21,16 @@ namespace Torch PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName)); } + protected virtual void SetValue(ref T backingField, T value, [CallerMemberName] string propName = "") + { + if (backingField.Equals(value)) + return; + + backingField = value; + // ReSharper disable once ExplicitCallerInfoArgument + OnPropertyChanged(propName); + } + /// /// Fires PropertyChanged for all properties. ///