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 @@
-
+
diff --git a/Torch.Server/Views/PluginsControl.xaml.cs b/Torch.Server/Views/PluginsControl.xaml.cs
index 2f53d43..6b54f26 100644
--- a/Torch.Server/Views/PluginsControl.xaml.cs
+++ b/Torch.Server/Views/PluginsControl.xaml.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
@@ -15,6 +16,8 @@ using System.Windows.Navigation;
using System.Windows.Shapes;
using NLog;
using Torch.API;
+using Torch.API.Managers;
+using Torch.Managers;
using Torch.Server.ViewModels;
namespace Torch.Server.Views
@@ -24,6 +27,9 @@ namespace Torch.Server.Views
///
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.
///