diff --git a/src/Artemis.Core/Artemis.Core.csproj b/src/Artemis.Core/Artemis.Core.csproj index c00b5950c..bd0e5eb30 100644 --- a/src/Artemis.Core/Artemis.Core.csproj +++ b/src/Artemis.Core/Artemis.Core.csproj @@ -39,6 +39,7 @@ + diff --git a/src/Artemis.Core/Artemis.Core.csproj.DotSettings b/src/Artemis.Core/Artemis.Core.csproj.DotSettings index 0ff3e797c..b1ea041c1 100644 --- a/src/Artemis.Core/Artemis.Core.csproj.DotSettings +++ b/src/Artemis.Core/Artemis.Core.csproj.DotSettings @@ -64,6 +64,11 @@ True True True + True + True + True + True + True True True True \ No newline at end of file diff --git a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs index d726d4b2e..33153f4ef 100644 --- a/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs +++ b/src/Artemis.Core/Models/Profile/LayerProperties/ILayerProperty.cs @@ -78,7 +78,7 @@ namespace Artemis.Core public event EventHandler? CurrentValueSet; /// - /// Occurs when the value of the layer property was updated + /// Occurs when the visibility value of the layer property was updated /// public event EventHandler? VisibilityChanged; diff --git a/src/Artemis.Core/Plugins/DataModelExpansions/DataModel.cs b/src/Artemis.Core/Plugins/DataModelExpansions/DataModel.cs index 27418f2d1..e207c18eb 100644 --- a/src/Artemis.Core/Plugins/DataModelExpansions/DataModel.cs +++ b/src/Artemis.Core/Plugins/DataModelExpansions/DataModel.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Reflection; using Artemis.Core.Modules; using Humanizer; +using Newtonsoft.Json; namespace Artemis.Core.DataModelExpansions { @@ -28,12 +29,14 @@ namespace Artemis.Core.DataModelExpansions /// /// Gets the plugin feature this data model belongs to /// + [JsonIgnore] [DataModelIgnore] public DataModelPluginFeature Feature { get; internal set; } /// /// Gets the describing this data model /// + [JsonIgnore] [DataModelIgnore] public DataModelPropertyAttribute DataModelDescription { get; internal set; } diff --git a/src/Artemis.Core/Plugins/Plugin.cs b/src/Artemis.Core/Plugins/Plugin.cs index 853649b0b..0575a80ac 100644 --- a/src/Artemis.Core/Plugins/Plugin.cs +++ b/src/Artemis.Core/Plugins/Plugin.cs @@ -44,7 +44,6 @@ namespace Artemis.Core /// public DirectoryInfo Directory { get; } - /// /// Gets or sets a configuration dialog for this plugin that is accessible in the UI under Settings > Plugins /// diff --git a/src/Artemis.Core/Plugins/PluginFeature.cs b/src/Artemis.Core/Plugins/PluginFeature.cs index 733c3ee9b..b0e08554f 100644 --- a/src/Artemis.Core/Plugins/PluginFeature.cs +++ b/src/Artemis.Core/Plugins/PluginFeature.cs @@ -13,6 +13,11 @@ namespace Artemis.Core private bool _isEnabled; private Exception? _loadException; + /// + /// Gets the plugin feature info related to this feature + /// + public PluginFeatureInfo Info { get; internal set; } = null!; // Will be set right after construction + /// /// Gets the plugin that provides this feature /// diff --git a/src/Artemis.Core/Plugins/PluginFeatureAttribute.cs b/src/Artemis.Core/Plugins/PluginFeatureAttribute.cs new file mode 100644 index 000000000..0bb78fb75 --- /dev/null +++ b/src/Artemis.Core/Plugins/PluginFeatureAttribute.cs @@ -0,0 +1,27 @@ +using System; + +namespace Artemis.Core +{ + /// + /// Represents an attribute that describes a plugin feature + /// + [AttributeUsage(AttributeTargets.Class)] + public class PluginFeatureAttribute : Attribute + { + /// + /// Gets or sets the user-friendly name for this property, shown in the UI. + /// + public string? Name { get; set; } + + /// + /// Gets or sets the user-friendly description for this property, shown in the UI. + /// + public string? Description { get; set; } + + /// + /// The plugins display icon that's shown in the settings see for + /// available icons + /// + public string? Icon { get; set; } + } +} diff --git a/src/Artemis.Core/Plugins/PluginFeatureInfo.cs b/src/Artemis.Core/Plugins/PluginFeatureInfo.cs new file mode 100644 index 000000000..7fe5a20de --- /dev/null +++ b/src/Artemis.Core/Plugins/PluginFeatureInfo.cs @@ -0,0 +1,92 @@ +using Artemis.Core.DataModelExpansions; +using Artemis.Core.DeviceProviders; +using Artemis.Core.LayerBrushes; +using Artemis.Core.LayerEffects; +using Artemis.Core.Modules; +using Humanizer; +using Newtonsoft.Json; + +namespace Artemis.Core +{ + /// + /// Represents basic info about a plugin feature and contains a reference to the instance of said feature + /// + [JsonObject(MemberSerialization.OptIn)] + public class PluginFeatureInfo : CorePropertyChanged + { + private string? _description; + private string? _icon; + private string _name = null!; + private PluginFeature _pluginFeature = null!; + + internal PluginFeatureInfo() + { + } + + internal PluginFeatureInfo(PluginFeature instance, PluginFeatureAttribute? attribute) + { + Name = attribute?.Name ?? instance.GetType().Name.Humanize(LetterCasing.Title); + Description = attribute?.Description; + Icon = attribute?.Icon; + PluginFeature = instance; + + if (Icon != null) return; + Icon = PluginFeature switch + { + BaseDataModelExpansion => "TableAdd", + DeviceProvider => "Devices", + ProfileModule => "VectorRectangle", + Module => "GearBox", + LayerBrushProvider => "Brush", + LayerEffectProvider => "AutoAwesome", + _ => "Plugin" + }; + } + + /// + /// The name of the plugin + /// + [JsonProperty(Required = Required.Always)] + public string Name + { + get => _name; + internal set => SetAndNotify(ref _name, value); + } + + /// + /// A short description of the plugin + /// + [JsonProperty] + public string? Description + { + get => _description; + set => SetAndNotify(ref _description, value); + } + + /// + /// The plugins display icon that's shown in the settings see for + /// available icons + /// + [JsonProperty] + public string? Icon + { + get => _icon; + set => SetAndNotify(ref _icon, value); + } + + /// + /// Gets the plugin this info is associated with + /// + public PluginFeature PluginFeature + { + get => _pluginFeature; + internal set => SetAndNotify(ref _pluginFeature, value); + } + + /// + public override string ToString() + { + return PluginFeature.Id; + } + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/PluginManagementService.cs b/src/Artemis.Core/Services/PluginManagementService.cs index 39ef62b89..c15122d1d 100644 --- a/src/Artemis.Core/Services/PluginManagementService.cs +++ b/src/Artemis.Core/Services/PluginManagementService.cs @@ -9,6 +9,7 @@ using Artemis.Core.DeviceProviders; using Artemis.Core.Ninject; using Artemis.Storage.Entities.Plugins; using Artemis.Storage.Repositories.Interfaces; +using Humanizer; using McMaster.NETCore.Plugins; using Ninject; using Ninject.Extensions.ChildKernel; @@ -342,6 +343,10 @@ namespace Artemis.Core.Services // Include Plugin as a parameter for the PluginSettingsProvider IParameter[] parameters = {new Parameter("Plugin", plugin, false)}; PluginFeature instance = (PluginFeature) plugin.Kernel.Get(featureType, parameters); + + // Get the PluginFeature attribute which contains extra info on the feature + PluginFeatureAttribute? pluginFeatureAttribute = (PluginFeatureAttribute?) Attribute.GetCustomAttribute(featureType, typeof(PluginFeatureAttribute)); + instance.Info = new PluginFeatureInfo(instance, pluginFeatureAttribute); plugin.AddFeature(instance); // Load the enabled state and if not found, default to true diff --git a/src/Artemis.Core/Services/WebServer/EndPoints/JsonPluginEndPoint.cs b/src/Artemis.Core/Services/WebServer/EndPoints/JsonPluginEndPoint.cs new file mode 100644 index 000000000..061bd984e --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/EndPoints/JsonPluginEndPoint.cs @@ -0,0 +1,80 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using EmbedIO; +using Newtonsoft.Json; + +namespace Artemis.Core.Services +{ + /// + /// Represents a plugin web endpoint receiving an object of type and returning any + /// or . + /// Note: Both will be deserialized and serialized respectively using JSON. + /// + public class JsonPluginEndPoint : PluginEndPoint + { + private readonly Action? _requestHandler; + private readonly Func? _responseRequestHandler; + + internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action requestHandler) : base(pluginFeature, name, pluginsModule) + { + _requestHandler = requestHandler; + ThrowOnFail = true; + Accepts = MimeType.Json; + } + + internal JsonPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func responseRequestHandler) : base(pluginFeature, name, pluginsModule) + { + _responseRequestHandler = responseRequestHandler; + ThrowOnFail = true; + Accepts = MimeType.Json; + Returns = MimeType.Json; + } + + /// + /// Whether or not the end point should throw an exception if deserializing the received JSON fails. + /// If set to malformed JSON is silently ignored; if set to malformed + /// JSON throws a . + /// + public bool ThrowOnFail { get; set; } + + #region Overrides of PluginEndPoint + + /// + protected override async Task ProcessRequest(IHttpContext context) + { + if (context.Request.HttpVerb != HttpVerbs.Post) + throw HttpException.MethodNotAllowed("This end point only accepts POST calls"); + + context.Response.ContentType = MimeType.Json; + + using TextReader reader = context.OpenRequestText(); + object? response = null; + try + { + T deserialized = JsonConvert.DeserializeObject(await reader.ReadToEndAsync()); + + if (_requestHandler != null) + { + _requestHandler(deserialized); + return; + } + + if (_responseRequestHandler != null) + response = _responseRequestHandler(deserialized); + else + throw new ArtemisCoreException("JSON plugin end point has no request handler"); + } + catch (JsonException) + { + if (ThrowOnFail) + throw; + } + + await using TextWriter writer = context.OpenResponseText(); + await writer.WriteAsync(JsonConvert.SerializeObject(response)); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/EndPoints/PluginEndPoint.cs b/src/Artemis.Core/Services/WebServer/EndPoints/PluginEndPoint.cs new file mode 100644 index 000000000..c81c772ee --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/EndPoints/PluginEndPoint.cs @@ -0,0 +1,72 @@ +using System; +using System.Threading.Tasks; +using EmbedIO; +using Newtonsoft.Json; + +namespace Artemis.Core.Services +{ + /// + /// Represents a base type for plugin end points to be targeted by the + /// + public abstract class PluginEndPoint + { + private readonly PluginsModule _pluginsModule; + + internal PluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule) + { + _pluginsModule = pluginsModule; + PluginFeature = pluginFeature; + Name = name; + + PluginFeature.Disabled += OnDisabled; + } + + /// + /// Gets the name of the end point + /// + public string Name { get; } + + /// + /// Gets the full URL of the end point + /// + public string Url => $"{_pluginsModule.ServerUrl.TrimEnd('/')}{_pluginsModule.BaseRoute}{PluginFeature.Plugin.Guid}/{Name}"; + + /// + /// Gets the plugin the end point is associated with + /// + [JsonIgnore] + public PluginFeature PluginFeature { get; } + + /// + /// Gets the plugin info of the plugin the end point is associated with + /// + public PluginInfo PluginInfo => PluginFeature.Plugin.Info; + + /// + /// Gets the mime type of the input this end point accepts + /// + public string Accepts { get; protected set; } + + /// + /// Gets the mime type of the output this end point returns + /// + public string Returns { get; protected set; } + + /// + /// Called whenever the end point has to process a request + /// + /// The HTTP context of the request + protected abstract Task ProcessRequest(IHttpContext context); + + internal async Task InternalProcessRequest(IHttpContext context) + { + await ProcessRequest(context); + } + + private void OnDisabled(object? sender, EventArgs e) + { + PluginFeature.Disabled -= OnDisabled; + _pluginsModule.RemovePluginEndPoint(this); + } + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/EndPoints/RawPluginEndPoint.cs b/src/Artemis.Core/Services/WebServer/EndPoints/RawPluginEndPoint.cs new file mode 100644 index 000000000..1aaca2ac3 --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/EndPoints/RawPluginEndPoint.cs @@ -0,0 +1,53 @@ +using System; +using System.Threading.Tasks; +using EmbedIO; + +namespace Artemis.Core.Services +{ + /// + /// Represents a plugin web endpoint that handles a raw . + /// + /// Note: This requires that you reference the EmbedIO + /// Nuget package. + /// + /// + public class RawPluginEndPoint : PluginEndPoint + { + /// + internal RawPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func requestHandler) : base(pluginFeature, name, pluginsModule) + { + RequestHandler = requestHandler; + } + + /// + /// Gets or sets the handler used to handle incoming requests to this endpoint + /// + public Func RequestHandler { get; } + + /// + /// Sets the mime type this plugin end point accepts + /// + public void SetAcceptType(string type) + { + Accepts = type; + } + + /// + /// Sets the mime type this plugin end point returns + /// + public void SetReturnType(string type) + { + Returns = type; + } + + #region Overrides of PluginEndPoint + + /// + protected override async Task ProcessRequest(IHttpContext context) + { + await RequestHandler(context); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/EndPoints/StringPluginEndPoint.cs b/src/Artemis.Core/Services/WebServer/EndPoints/StringPluginEndPoint.cs new file mode 100644 index 000000000..b5f65bb2d --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/EndPoints/StringPluginEndPoint.cs @@ -0,0 +1,59 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using EmbedIO; + +namespace Artemis.Core.Services +{ + /// + /// Represents a plugin web endpoint receiving an a and returning a or + /// . + /// + public class StringPluginEndPoint : PluginEndPoint + { + private readonly Action? _requestHandler; + private readonly Func? _responseRequestHandler; + + internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Action requestHandler) : base(pluginFeature, name, pluginsModule) + { + _requestHandler = requestHandler; + Accepts = MimeType.PlainText; + } + + internal StringPluginEndPoint(PluginFeature pluginFeature, string name, PluginsModule pluginsModule, Func requestHandler) : base(pluginFeature, name, pluginsModule) + { + _responseRequestHandler = requestHandler; + Accepts = MimeType.PlainText; + Returns = MimeType.PlainText; + } + + #region Overrides of PluginEndPoint + + /// + protected override async Task ProcessRequest(IHttpContext context) + { + if (context.Request.HttpVerb != HttpVerbs.Post) + throw HttpException.MethodNotAllowed("This end point only accepts POST calls"); + + context.Response.ContentType = MimeType.PlainText; + + using TextReader reader = context.OpenRequestText(); + string? response; + if (_requestHandler != null) + { + _requestHandler(await reader.ReadToEndAsync()); + return; + } + + if (_responseRequestHandler != null) + response = _responseRequestHandler(await reader.ReadToEndAsync()); + else + throw new ArtemisCoreException("String plugin end point has no request handler"); + + await using TextWriter writer = context.OpenResponseText(); + await writer.WriteAsync(response); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/Interfaces/IWebServerService.cs b/src/Artemis.Core/Services/WebServer/Interfaces/IWebServerService.cs new file mode 100644 index 000000000..704a4a19c --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/Interfaces/IWebServerService.cs @@ -0,0 +1,101 @@ +using System; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.WebApi; + +namespace Artemis.Core.Services +{ + /// + /// A service that provides access to the local Artemis web server + /// + public interface IWebServerService : IArtemisService + { + /// + /// Gets the current instance of the web server, replaced when occurs. + /// + WebServer? Server { get; } + + /// + /// Gets the plugins module containing all plugin end points + /// + PluginsModule PluginsModule { get; } + + /// + /// Adds a new endpoint for the given plugin feature receiving an object of type + /// Note: Object will be deserialized using JSON. + /// + /// The type of object to be received + /// The plugin feature the end point is associated with + /// The name of the end point, must be unique + /// + /// The resulting end point + JsonPluginEndPoint AddJsonEndPoint(PluginFeature feature, string endPointName, Action requestHandler); + + /// + /// Adds a new endpoint for the given plugin feature receiving an object of type and + /// returning any . + /// Note: Both will be deserialized and serialized respectively using JSON. + /// + /// The type of object to be received + /// The plugin feature the end point is associated with + /// The name of the end point, must be unique + /// + /// The resulting end point + JsonPluginEndPoint AddResponsiveJsonEndPoint(PluginFeature feature, string endPointName, Func requestHandler); + + /// + /// Adds a new endpoint for the given plugin feature receiving an a . + /// + /// The plugin feature the end point is associated with + /// The name of the end point, must be unique + /// + /// The resulting end point + StringPluginEndPoint AddStringEndPoint(PluginFeature feature, string endPointName, Action requestHandler); + + /// + /// Adds a new endpoint for the given plugin feature receiving an a and returning a + /// or . + /// + /// The plugin feature the end point is associated with + /// The name of the end point, must be unique + /// + /// The resulting end point + StringPluginEndPoint AddResponsiveStringEndPoint(PluginFeature feature, string endPointName, Func requestHandler); + + /// + /// Adds a new endpoint for the given plugin feature that handles a raw . + /// + /// Note: This requires that you reference the EmbedIO + /// Nuget package. + /// + /// + /// The plugin feature the end point is associated with + /// The name of the end point, must be unique + /// + /// The resulting end point + RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func requestHandler); + + /// + /// Removes an existing endpoint + /// + /// The end point to remove + void RemovePluginEndPoint(PluginEndPoint endPoint); + + /// + /// Adds a new Web API controller and restarts the web server + /// + /// The type of Web API controller to remove + void AddController() where T : WebApiController; + + /// + /// Removes an existing Web API controller and restarts the web server + /// + /// The type of Web API controller to remove + void RemoveController() where T : WebApiController; + + /// + /// Occurs when the web server has been created and is about to start. This is the ideal place to add your own modules. + /// + event EventHandler? WebServerStarting; + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/PluginsModule.cs b/src/Artemis.Core/Services/WebServer/PluginsModule.cs new file mode 100644 index 000000000..78de97e66 --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/PluginsModule.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using EmbedIO; +using Newtonsoft.Json; + +namespace Artemis.Core.Services +{ + /// + /// Represents an EmbedIO web module used to process web requests and forward them to the right + /// . + /// + public class PluginsModule : WebModuleBase + { + private readonly Dictionary> _pluginEndPoints; + + internal PluginsModule(string baseRoute) : base(baseRoute) + { + _pluginEndPoints = new Dictionary>(); + } + + internal void AddPluginEndPoint(PluginEndPoint registration) + { + string id = registration.PluginFeature.Plugin.Guid.ToString(); + if (!_pluginEndPoints.TryGetValue(id, out Dictionary? registrations)) + { + registrations = new Dictionary(); + _pluginEndPoints.Add(id, registrations); + } + + if (registrations.ContainsKey(registration.Name)) + throw new ArtemisPluginException(registration.PluginFeature.Plugin, $"Plugin already registered an endpoint at {registration.Name}."); + registrations.Add(registration.Name, registration); + } + + internal void RemovePluginEndPoint(PluginEndPoint registration) + { + string id = registration.PluginFeature.Plugin.Guid.ToString(); + if (!_pluginEndPoints.TryGetValue(id, out Dictionary? registrations)) + return; + if (!registrations.ContainsKey(registration.Name)) + return; + registrations.Remove(registration.Name); + } + + #region Overrides of WebModuleBase + + /// + protected override async Task OnRequestAsync(IHttpContext context) + { + if (context.Route.SubPath == null) + throw HttpException.NotFound(); + + // Split the sub path + string[] pathParts = context.Route.SubPath.Substring(1).Split('/'); + // Expect a plugin ID and an endpoint + if (pathParts == null || pathParts.Length != 2) + throw HttpException.BadRequest("Path must contain a plugin ID and endpoint and nothing else."); + + // Find a matching plugin + if (!_pluginEndPoints.TryGetValue(pathParts[0], out Dictionary? endPoints)) + throw HttpException.NotFound($"Found no plugin with ID {pathParts[0]}."); + + // Find a matching endpoint + if (!endPoints.TryGetValue(pathParts[1], out PluginEndPoint? endPoint)) + throw HttpException.NotFound($"Found no endpoint called {pathParts[1]} for plugin with ID {pathParts[0]}."); + + // It is up to the registration how the request is eventually handled, it might even set a response here + await endPoint.InternalProcessRequest(context); + + // No need to return ourselves, assume the request is fully handled by the end point + context.SetHandled(); + } + + /// + public override bool IsFinalHandler => true; + + internal string? ServerUrl { get; set; } + + /// + /// Gets a read only collection containing all current plugin end points + /// + public IReadOnlyCollection PluginEndPoints => new List(_pluginEndPoints.SelectMany(p => p.Value.Values)); + + #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/WebApiControllerRegistration.cs b/src/Artemis.Core/Services/WebServer/WebApiControllerRegistration.cs new file mode 100644 index 000000000..ad5fb30dc --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/WebApiControllerRegistration.cs @@ -0,0 +1,28 @@ +using System; +using EmbedIO.WebApi; +using Ninject; + +namespace Artemis.Core.Services +{ + internal class WebApiControllerRegistration : WebApiControllerRegistration where T : WebApiController + { + public WebApiControllerRegistration(IKernel kernel) : base(typeof(T)) + { + Factory = () => kernel.Get(); + } + + public Func Factory { get; set; } + public override object UntypedFactory => Factory; + } + + internal abstract class WebApiControllerRegistration + { + protected WebApiControllerRegistration(Type controllerType) + { + ControllerType = controllerType; + } + + public abstract object UntypedFactory { get; } + public Type ControllerType { get; set; } + } +} \ No newline at end of file diff --git a/src/Artemis.Core/Services/WebServer/WebServerService.cs b/src/Artemis.Core/Services/WebServer/WebServerService.cs new file mode 100644 index 000000000..41b3911de --- /dev/null +++ b/src/Artemis.Core/Services/WebServer/WebServerService.cs @@ -0,0 +1,220 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.WebApi; +using Newtonsoft.Json; +using Ninject; +using Serilog; + +namespace Artemis.Core.Services +{ + internal class WebServerService : IWebServerService, IDisposable + { + private readonly List _controllers; + private readonly IKernel _kernel; + private readonly ILogger _logger; + private readonly PluginSetting _webServerPortSetting; + + public WebServerService(IKernel kernel, ILogger logger, ISettingsService settingsService) + { + _kernel = kernel; + _logger = logger; + _controllers = new List(); + + _webServerPortSetting = settingsService.GetSetting("WebServer.Port", 9696); + _webServerPortSetting.SettingChanged += WebServerPortSettingOnSettingChanged; + + PluginsModule = new PluginsModule("/plugins"); + + StartWebServer(); + } + + public WebServer? Server { get; private set; } + public PluginsModule PluginsModule { get; } + + #region Web server managament + + private WebServer CreateWebServer() + { + Server?.Dispose(); + Server = null; + + string url = $"http://localhost:{_webServerPortSetting.Value}/"; + WebApiModule apiModule = new("/api/", JsonNetSerializer); + PluginsModule.ServerUrl = url; + WebServer server = new WebServer(o => o.WithUrlPrefix(url).WithMode(HttpListenerMode.EmbedIO)) + .WithLocalSessionManager() + .WithModule(apiModule) + .WithModule(PluginsModule) + .HandleHttpException((context, exception) => HandleHttpExceptionJson(context, exception)) + .HandleUnhandledException(JsonExceptionHandlerCallback); + + // Add registered controllers to the API module + foreach (WebApiControllerRegistration registration in _controllers) + apiModule.RegisterController(registration.ControllerType, (Func) registration.UntypedFactory); + + // Listen for state changes. + server.StateChanged += (s, e) => _logger.Verbose("WebServer new state - {state}", e.NewState); + + // Store the URL in a webserver.txt file so that remote applications can find it + File.WriteAllText(Path.Combine(Constants.DataFolder, "webserver.txt"), url); + + return server; + } + + private void StartWebServer() + { + Server = CreateWebServer(); + OnWebServerStarting(); + Server.Start(); + } + + #endregion + + #region Plugin endpoint management + + public JsonPluginEndPoint AddJsonEndPoint(PluginFeature feature, string endPointName, Action requestHandler) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + if (endPointName == null) throw new ArgumentNullException(nameof(endPointName)); + if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler)); + JsonPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler); + PluginsModule.AddPluginEndPoint(endPoint); + return endPoint; + } + + public JsonPluginEndPoint AddResponsiveJsonEndPoint(PluginFeature feature, string endPointName, Func requestHandler) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + if (endPointName == null) throw new ArgumentNullException(nameof(endPointName)); + if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler)); + JsonPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler); + PluginsModule.AddPluginEndPoint(endPoint); + return endPoint; + } + + public StringPluginEndPoint AddStringEndPoint(PluginFeature feature, string endPointName, Action requestHandler) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + if (endPointName == null) throw new ArgumentNullException(nameof(endPointName)); + if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler)); + StringPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler); + PluginsModule.AddPluginEndPoint(endPoint); + return endPoint; + } + + public StringPluginEndPoint AddResponsiveStringEndPoint(PluginFeature feature, string endPointName, Func requestHandler) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + if (endPointName == null) throw new ArgumentNullException(nameof(endPointName)); + if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler)); + StringPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler); + PluginsModule.AddPluginEndPoint(endPoint); + return endPoint; + } + + public RawPluginEndPoint AddRawEndPoint(PluginFeature feature, string endPointName, Func requestHandler) + { + if (feature == null) throw new ArgumentNullException(nameof(feature)); + if (endPointName == null) throw new ArgumentNullException(nameof(endPointName)); + if (requestHandler == null) throw new ArgumentNullException(nameof(requestHandler)); + RawPluginEndPoint endPoint = new(feature, endPointName, PluginsModule, requestHandler); + PluginsModule.AddPluginEndPoint(endPoint); + return endPoint; + } + + public void RemovePluginEndPoint(PluginEndPoint endPoint) + { + PluginsModule.RemovePluginEndPoint(endPoint); + } + + #endregion + + #region Controller management + + public void AddController() where T : WebApiController + { + _controllers.Add(new WebApiControllerRegistration(_kernel)); + StartWebServer(); + } + + public void RemoveController() where T : WebApiController + { + _controllers.RemoveAll(r => r.ControllerType == typeof(T)); + StartWebServer(); + } + + #endregion + + #region Handlers + + private async Task JsonExceptionHandlerCallback(IHttpContext context, Exception exception) + { + context.Response.ContentType = MimeType.Json; + await using TextWriter writer = context.OpenResponseText(); + + string response = JsonConvert.SerializeObject(new Dictionary() + { + {"StatusCode", context.Response.StatusCode}, + {"StackTrace", exception.StackTrace}, + {"Type", exception.GetType().FullName}, + {"Message", exception.Message}, + {"Data", exception.Data}, + {"InnerException", exception.InnerException}, + {"HelpLink", exception.HelpLink}, + {"Source", exception.Source}, + {"HResult", exception.HResult} + }); + await writer.WriteAsync(response); + } + + private async Task JsonNetSerializer(IHttpContext context, object? data) + { + context.Response.ContentType = MimeType.Json; + await using TextWriter writer = context.OpenResponseText(); + string json = JsonConvert.SerializeObject(data, new JsonSerializerSettings {PreserveReferencesHandling = PreserveReferencesHandling.Objects}); + await writer.WriteAsync(json); + } + + private async Task HandleHttpExceptionJson(IHttpContext context, IHttpException httpException) + { + await context.SendStringAsync(JsonConvert.SerializeObject(httpException, Formatting.Indented), MimeType.Json, Encoding.UTF8); + } + + #endregion + + #region Events + + public event EventHandler? WebServerStarting; + + protected virtual void OnWebServerStarting() + { + WebServerStarting?.Invoke(this, EventArgs.Empty); + } + + #endregion + + #region Event handlers + + private void WebServerPortSettingOnSettingChanged(object? sender, EventArgs e) + { + StartWebServer(); + } + + #endregion + + #region IDisposable + + /// + public void Dispose() + { + Server?.Dispose(); + _webServerPortSetting.SettingChanged -= WebServerPortSettingOnSettingChanged; + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Artemis.Core/packages.lock.json b/src/Artemis.Core/packages.lock.json index 03a69fcd6..c422d273d 100644 --- a/src/Artemis.Core/packages.lock.json +++ b/src/Artemis.Core/packages.lock.json @@ -12,6 +12,15 @@ "System.Threading.Tasks.Extensions": "4.5.3" } }, + "EmbedIO": { + "type": "Direct", + "requested": "[3.4.3, )", + "resolved": "3.4.3", + "contentHash": "YM6hpZNAfvbbixfG9T4lWDGfF0D/TqutbTROL4ogVcHKwPF1hp+xS3ABwd3cxxTxvDFkj/zZl57QgWuFA8Igxw==", + "dependencies": { + "Unosquare.Swan.Lite": "3.0.0" + } + }, "HidSharp": { "type": "Direct", "requested": "[2.1.0, )", @@ -1226,6 +1235,11 @@ "System.Xml.ReaderWriter": "4.3.0" } }, + "Unosquare.Swan.Lite": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "noPwJJl1Q9uparXy1ogtkmyAPGNfSGb0BLT1292nFH1jdMKje6o2kvvrQUvF9Xklj+IoiAI0UzF6Aqxlvo10lw==" + }, "artemis.storage": { "type": "Project", "dependencies": { diff --git a/src/Artemis.UI.Shared/Services/Message/MessageService.cs b/src/Artemis.UI.Shared/Services/Message/MessageService.cs index aef6ffc7e..45bd0e447 100644 --- a/src/Artemis.UI.Shared/Services/Message/MessageService.cs +++ b/src/Artemis.UI.Shared/Services/Message/MessageService.cs @@ -5,7 +5,7 @@ namespace Artemis.UI.Shared.Services { internal class MessageService : IMessageService { - private INotificationProvider _notificationProvider; + private INotificationProvider? _notificationProvider; public ISnackbarMessageQueue MainMessageQueue { get; } public MessageService(ISnackbarMessageQueue mainMessageQueue) @@ -74,20 +74,20 @@ namespace Artemis.UI.Shared.Services /// public void ShowNotification(string title, string message) { - _notificationProvider.ShowNotification(title, message, PackIconKind.None); + _notificationProvider?.ShowNotification(title, message, PackIconKind.None); } /// public void ShowNotification(string title, string message, PackIconKind icon) { - _notificationProvider.ShowNotification(title, message, icon); + _notificationProvider?.ShowNotification(title, message, icon); } /// public void ShowNotification(string title, string message, string icon) { Enum.TryParse(typeof(PackIconKind), icon, true, out object? iconKind); - _notificationProvider.ShowNotification(title, message, (PackIconKind) (iconKind ?? PackIconKind.None)); + _notificationProvider?.ShowNotification(title, message, (PackIconKind) (iconKind ?? PackIconKind.None)); } } } \ No newline at end of file diff --git a/src/Artemis.UI.Shared/Services/Window/WindowService.cs b/src/Artemis.UI.Shared/Services/Window/WindowService.cs index c8b2a0ced..e0ce8bd1b 100644 --- a/src/Artemis.UI.Shared/Services/Window/WindowService.cs +++ b/src/Artemis.UI.Shared/Services/Window/WindowService.cs @@ -66,14 +66,12 @@ namespace Artemis.UI.Shared.Services public void OpenMainWindow() { - IsMainWindowOpen = true; - OnMainWindowOpened(); + _mainWindowManager?.OpenMainWindow(); } public void CloseMainWindow() { - IsMainWindowOpen = false; - OnMainWindowClosed(); + _mainWindowManager?.CloseMainWindow(); } public event EventHandler? MainWindowOpened; diff --git a/src/Artemis.UI.Shared/packages.lock.json b/src/Artemis.UI.Shared/packages.lock.json index db871839a..7c48f91d0 100644 --- a/src/Artemis.UI.Shared/packages.lock.json +++ b/src/Artemis.UI.Shared/packages.lock.json @@ -142,6 +142,14 @@ "System.Xml.XmlDocument": "4.3.0" } }, + "EmbedIO": { + "type": "Transitive", + "resolved": "3.4.3", + "contentHash": "YM6hpZNAfvbbixfG9T4lWDGfF0D/TqutbTROL4ogVcHKwPF1hp+xS3ABwd3cxxTxvDFkj/zZl57QgWuFA8Igxw==", + "dependencies": { + "Unosquare.Swan.Lite": "3.0.0" + } + }, "HidSharp": { "type": "Transitive", "resolved": "2.1.0", @@ -1305,11 +1313,17 @@ "System.Xml.ReaderWriter": "4.3.0" } }, + "Unosquare.Swan.Lite": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "noPwJJl1Q9uparXy1ogtkmyAPGNfSGb0BLT1292nFH1jdMKje6o2kvvrQUvF9Xklj+IoiAI0UzF6Aqxlvo10lw==" + }, "artemis.core": { "type": "Project", "dependencies": { "Artemis.Storage": "1.0.0", "Ben.Demystifier": "0.1.6", + "EmbedIO": "3.4.3", "HidSharp": "2.1.0", "Humanizer.Core": "2.8.26", "LiteDB": "5.0.9", diff --git a/src/Artemis.UI/ApplicationStateManager.cs b/src/Artemis.UI/ApplicationStateManager.cs new file mode 100644 index 000000000..d07a0aa17 --- /dev/null +++ b/src/Artemis.UI/ApplicationStateManager.cs @@ -0,0 +1,127 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using Artemis.Core; +using Artemis.UI.Utilities; +using Stylet; + +namespace Artemis.UI +{ + public class ApplicationStateManager + { + // ReSharper disable once NotAccessedField.Local - Kept in scope to ensure it does not get released + private Mutex _artemisMutex; + + public ApplicationStateManager(string[] startupArguments) + { + StartupArguments = startupArguments; + IsElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + + Core.Utilities.ShutdownRequested += UtilitiesOnShutdownRequested; + Core.Utilities.RestartRequested += UtilitiesOnRestartRequested; + } + + public string[] StartupArguments { get; } + public bool IsElevated { get; } + + public bool FocusExistingInstance() + { + _artemisMutex = new Mutex(true, "Artemis-3c24b502-64e6-4587-84bf-9072970e535d", out bool createdNew); + if (createdNew) + return false; + + try + { + // Blocking is required here otherwise Artemis shuts down before the remote call gets a chance to finish + RemoteFocus().GetAwaiter().GetResult(); + } + catch (Exception) + { + // Not much could go wrong here but this code runs so early it'll crash if something does go wrong + return true; + } + + return true; + } + + private async Task RemoteFocus() + { + // At this point we cannot read the database yet to retrieve the web server port. + // Instead use the method external applications should use as well. + if (!File.Exists(Path.Combine(Constants.DataFolder, "webserver.txt"))) + return; + + string url = await File.ReadAllTextAsync(Path.Combine(Constants.DataFolder, "webserver.txt")); + using HttpClient client = new(); + await client.PostAsync(url + "api/remote/bring-to-foreground", null!); + } + + private void UtilitiesOnRestartRequested(object sender, RestartEventArgs e) + { + List argsList = new(); + argsList.AddRange(StartupArguments); + if (e.ExtraArgs != null) + argsList.AddRange(e.ExtraArgs.Except(argsList)); + string args = argsList.Any() ? "-ArgumentList " + string.Join(',', argsList) : ""; + string command = + $"-Command \"& {{Start-Sleep -Milliseconds {(int) e.Delay.TotalMilliseconds}; " + + "(Get-Process 'Artemis.UI').kill(); " + + $"Start-Process -FilePath '{Constants.ExecutablePath}' -WorkingDirectory '{Constants.ApplicationFolder}' {args}}}\""; + // Elevated always runs with RunAs + if (e.Elevate) + { + ProcessStartInfo info = new() + { + Arguments = command.Replace("}\"", " -Verb RunAs}\""), + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + FileName = "PowerShell.exe" + }; + Process.Start(info); + } + // Non-elevated runs regularly if currently not elevated + else if (!IsElevated) + { + ProcessStartInfo info = new() + { + Arguments = command, + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + FileName = "PowerShell.exe" + }; + Process.Start(info); + } + // Non-elevated runs via a utility method is currently elevated (de-elevating is hacky) + else + { + string powerShell = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); + ProcessUtilities.RunAsDesktopUser(powerShell, command, true); + } + + // Lets try a graceful shutdown, PowerShell will kill if needed + Execute.OnUIThread(() => Application.Current.Shutdown()); + } + + private void UtilitiesOnShutdownRequested(object sender, EventArgs e) + { + // Use PowerShell to kill the process after 2 sec just in case + ProcessStartInfo info = new() + { + Arguments = "-Command \"& {Start-Sleep -s 2; (Get-Process 'Artemis.UI').kill()}", + WindowStyle = ProcessWindowStyle.Hidden, + CreateNoWindow = true, + FileName = "PowerShell.exe" + }; + Process.Start(info); + + Execute.OnUIThread(() => Application.Current.Shutdown()); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Artemis.UI.csproj.DotSettings b/src/Artemis.UI/Artemis.UI.csproj.DotSettings index 7c2d2de36..8bd53f68f 100644 --- a/src/Artemis.UI/Artemis.UI.csproj.DotSettings +++ b/src/Artemis.UI/Artemis.UI.csproj.DotSettings @@ -1,2 +1,5 @@  - True \ No newline at end of file + True + True + True + True \ No newline at end of file diff --git a/src/Artemis.UI/Bootstrapper.cs b/src/Artemis.UI/Bootstrapper.cs index c9c24459a..5544c2307 100644 --- a/src/Artemis.UI/Bootstrapper.cs +++ b/src/Artemis.UI/Bootstrapper.cs @@ -1,16 +1,11 @@ using System; using System.Collections.Generic; -using System.Diagnostics; using System.Globalization; -using System.IO; using System.Linq; -using System.Runtime.InteropServices; -using System.Security.Principal; using System.Threading.Tasks; using System.Windows; using System.Windows.Markup; using System.Windows.Threading; -using Artemis.Core; using Artemis.Core.Ninject; using Artemis.Core.Services; using Artemis.UI.Ninject; @@ -19,7 +14,6 @@ using Artemis.UI.Services; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using Artemis.UI.Stylet; -using Artemis.UI.Utilities; using Ninject; using Serilog; using Stylet; @@ -28,6 +22,7 @@ namespace Artemis.UI { public class Bootstrapper : NinjectBootstrapper { + private ApplicationStateManager _applicationStateManager; private ICoreService _core; public static List StartupArguments { get; private set; } @@ -41,14 +36,18 @@ namespace Artemis.UI protected override void Launch() { - // TODO: Move shutdown code out of bootstrapper - Core.Utilities.ShutdownRequested += UtilitiesOnShutdownRequested; - Core.Utilities.RestartRequested += UtilitiesOnRestartRequested; + _applicationStateManager = new ApplicationStateManager(Args); Core.Utilities.PrepareFirstLaunch(); ILogger logger = Kernel.Get(); - IViewManager viewManager = Kernel.Get(); + if (_applicationStateManager.FocusExistingInstance()) + { + logger.Information("Shutting down because a different instance is already running."); + Application.Current.Shutdown(1); + return; + } + IViewManager viewManager = Kernel.Get(); StartupArguments = Args.ToList(); // Create the Artemis core @@ -78,7 +77,7 @@ namespace Artemis.UI try { _core.StartupArguments = StartupArguments; - _core.IsElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator); + _core.IsElevated = _applicationStateManager.IsElevated; _core.Initialize(); } catch (Exception e) @@ -88,7 +87,9 @@ namespace Artemis.UI } }); - Kernel.Get().RegisterInputProvider(); + IRegistrationService registrationService = Kernel.Get(); + registrationService.RegisterInputProvider(); + registrationService.RegisterControllers(); } protected override void ConfigureIoC(IKernel kernel) @@ -124,67 +125,6 @@ namespace Artemis.UI e.Handled = true; } - private void UtilitiesOnShutdownRequested(object sender, EventArgs e) - { - // Use PowerShell to kill the process after 2 sec just in case - ProcessStartInfo info = new() - { - Arguments = "-Command \"& {Start-Sleep -s 2; (Get-Process 'Artemis.UI').kill()}", - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - FileName = "PowerShell.exe" - }; - Process.Start(info); - - Execute.OnUIThread(() => Application.Current.Shutdown()); - } - - private void UtilitiesOnRestartRequested(object sender, RestartEventArgs e) - { - List argsList = new(); - argsList.AddRange(Args); - if (e.ExtraArgs != null) - argsList.AddRange(e.ExtraArgs.Except(argsList)); - string args = argsList.Any() ? "-ArgumentList " + string.Join(',', argsList) : ""; - string command = - $"-Command \"& {{Start-Sleep -Milliseconds {(int) e.Delay.TotalMilliseconds}; " + - $"(Get-Process 'Artemis.UI').kill(); " + - $"Start-Process -FilePath '{Constants.ExecutablePath}' -WorkingDirectory '{Constants.ApplicationFolder}' {args}}}\""; - // Elevated always runs with RunAs - if (e.Elevate) - { - ProcessStartInfo info = new() - { - Arguments = command.Replace("}\"", " -Verb RunAs}\""), - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - FileName = "PowerShell.exe" - }; - Process.Start(info); - } - // Non-elevated runs regularly if currently not elevated - else if (!_core.IsElevated) - { - ProcessStartInfo info = new() - { - Arguments = command, - WindowStyle = ProcessWindowStyle.Hidden, - CreateNoWindow = true, - FileName = "PowerShell.exe" - }; - Process.Start(info); - } - // Non-elevated runs via a utility method is currently elevated (de-elevating is hacky) - else - { - string powerShell = Path.Combine(Environment.SystemDirectory, "WindowsPowerShell", "v1.0", "powershell.exe"); - ProcessUtilities.RunAsDesktopUser(powerShell, command, true); - } - - // Lets try a graceful shutdown, PowerShell will kill if needed - Execute.OnUIThread(() => Application.Current.Shutdown()); - } - private void HandleFatalException(Exception e, ILogger logger) { logger.Fatal(e, "Fatal exception during initialization, shutting down."); @@ -200,8 +140,5 @@ namespace Artemis.UI Environment.Exit(1); }); } - - [DllImport("user32.dll")] - private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); } } \ No newline at end of file diff --git a/src/Artemis.UI/Controllers/RemoteController.cs b/src/Artemis.UI/Controllers/RemoteController.cs new file mode 100644 index 000000000..2c8c793d5 --- /dev/null +++ b/src/Artemis.UI/Controllers/RemoteController.cs @@ -0,0 +1,39 @@ +using System; +using Artemis.Core.Services; +using Artemis.UI.Shared.Services; +using EmbedIO; +using EmbedIO.Routing; +using EmbedIO.WebApi; + +namespace Artemis.UI.Controllers +{ + public class RemoteController : WebApiController + { + private readonly ICoreService _coreService; + private readonly IWindowService _windowService; + + public RemoteController(ICoreService coreService, IWindowService windowService) + { + _coreService = coreService; + _windowService = windowService; + } + + [Route(HttpVerbs.Post, "/remote/bring-to-foreground")] + public void PostBringToForeground() + { + _windowService.OpenMainWindow(); + } + + [Route(HttpVerbs.Post, "/remote/restart")] + public void PostRestart() + { + Core.Utilities.Restart(_coreService.IsElevated, TimeSpan.FromMilliseconds(500)); + } + + [Route(HttpVerbs.Post, "/remote/shutdown")] + public void PostShutdown() + { + Core.Utilities.Shutdown(); + } + } +} \ No newline at end of file diff --git a/src/Artemis.UI/Ninject/UiModule.cs b/src/Artemis.UI/Ninject/UiModule.cs index 13ead7e67..627334b0a 100644 --- a/src/Artemis.UI/Ninject/UiModule.cs +++ b/src/Artemis.UI/Ninject/UiModule.cs @@ -4,7 +4,7 @@ using Artemis.UI.Ninject.InstanceProviders; using Artemis.UI.Screens; using Artemis.UI.Screens.ProfileEditor; using Artemis.UI.Screens.Splash; -using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services; using Artemis.UI.Shared.Services; using Artemis.UI.Stylet; using FluentValidation; diff --git a/src/Artemis.UI/Screens/ProfileEditor/Dialogs/ProfileImportViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Dialogs/ProfileImportViewModel.cs index ef89cef72..92c454c3c 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Dialogs/ProfileImportViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Dialogs/ProfileImportViewModel.cs @@ -3,13 +3,11 @@ using Artemis.Core.Modules; using Artemis.Core.Services; using Artemis.UI.Shared.Services; using ICSharpCode.AvalonEdit.Document; -using MaterialDesignThemes.Wpf; namespace Artemis.UI.Screens.ProfileEditor.Dialogs { public class ProfileImportViewModel : DialogViewModelBase { - private readonly ISnackbarMessageQueue _mainMessageQueue; private readonly IProfileService _profileService; private readonly IMessageService _messageService; private string _profileJson; diff --git a/src/Artemis.UI/Screens/ProfileEditor/Visualization/ProfileLayerViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Visualization/ProfileLayerViewModel.cs index 7aea3591f..afc9c6c54 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Visualization/ProfileLayerViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Visualization/ProfileLayerViewModel.cs @@ -6,7 +6,7 @@ using System.Windows.Media; using Artemis.Core; using Artemis.UI.Extensions; using Artemis.UI.Screens.Shared; -using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services; using Artemis.UI.Shared.Services; namespace Artemis.UI.Screens.ProfileEditor.Visualization diff --git a/src/Artemis.UI/Screens/ProfileEditor/Visualization/Tools/EditToolViewModel.cs b/src/Artemis.UI/Screens/ProfileEditor/Visualization/Tools/EditToolViewModel.cs index 8c49f86c4..40adf63e4 100644 --- a/src/Artemis.UI/Screens/ProfileEditor/Visualization/Tools/EditToolViewModel.cs +++ b/src/Artemis.UI/Screens/ProfileEditor/Visualization/Tools/EditToolViewModel.cs @@ -5,7 +5,7 @@ using System.Windows.Media; using Artemis.Core; using Artemis.UI.Events; using Artemis.UI.Screens.Shared; -using Artemis.UI.Services.Interfaces; +using Artemis.UI.Services; using Artemis.UI.Shared.Services; using SkiaSharp; using SkiaSharp.Views.WPF; diff --git a/src/Artemis.UI/Screens/RootViewModel.cs b/src/Artemis.UI/Screens/RootViewModel.cs index 35c55db05..e99e5127e 100644 --- a/src/Artemis.UI/Screens/RootViewModel.cs +++ b/src/Artemis.UI/Screens/RootViewModel.cs @@ -14,7 +14,6 @@ using Artemis.UI.Screens.Settings.Tabs.General; using Artemis.UI.Screens.Sidebar; using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Services; -using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.Services; using Artemis.UI.Utilities; using MaterialDesignExtensions.Controls; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabView.xaml b/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabView.xaml index ac964893d..9f0564084 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabView.xaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabView.xaml @@ -59,6 +59,28 @@ + + + + + + + + + + + Startup delay + + Set the amount of seconds to wait before running Artemis with Windows. + If some devices don't work because Artemis starts before the manufacturer's software, try increasing this value. + + + + + + + + @@ -89,62 +111,14 @@ - - Setup wizard - - Opens the startup wizard usually shown when Artemis first starts. + + Log level + + Sets the logging level, a higher logging level will result in more log files. - - - - - - - - - - - - - - - - Debugger - - Use the debugger to see the raw image Artemis is rendering on the surface. - - - - - - - - - - - - - - - - - - - Application files - - Opens the directory where application files like plugins and settings are stored. - - - - + @@ -170,8 +144,13 @@ - - + + + + + Web server + + @@ -182,16 +161,16 @@ - Log level + Web server port - Sets the logging level, a higher logging level will result in more log files. + Artemis runs a local web server that can be used to externally interact with the application. + This web server can only be accessed by applications running on your own computer, e.g. supported games. - + - @@ -391,6 +370,82 @@ + + + + Tools + + + + + + + + + + + + + Setup wizard + + Opens the startup wizard usually shown when Artemis first starts. + + + + + + + + + + + + + + + + + + + Debugger + + Use the debugger to see the raw image Artemis is rendering on the surface. + + + + + + + + + + + + + + + + + + + Application files + + Opens the directory where application files like plugins and settings are stored. + + + + + + + + + \ No newline at end of file diff --git a/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabViewModel.cs b/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabViewModel.cs index 3a41acea1..21a54084c 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabViewModel.cs +++ b/src/Artemis.UI/Screens/Settings/Tabs/General/GeneralSettingsTabViewModel.cs @@ -14,7 +14,6 @@ using Artemis.Core.Services; using Artemis.UI.Properties; using Artemis.UI.Screens.StartupWizard; using Artemis.UI.Services; -using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared; using Artemis.UI.Shared.Services; using MaterialDesignThemes.Wpf; @@ -80,6 +79,9 @@ namespace Artemis.UI.Screens.Settings.Tabs.General LayerBrushProviderId = "Artemis.Plugins.LayerBrushes.Color.ColorBrushProvider-92a9d6ba", BrushType = "ColorBrush" }); + + WebServerPortSetting = _settingsService.GetSetting("WebServer.Port", 9696); + WebServerPortSetting.AutoSave = true; } public BindableCollection LayerBrushDescriptors { get; } @@ -123,7 +125,19 @@ namespace Artemis.UI.Screens.Settings.Tabs.General _settingsService.GetSetting("UI.AutoRun", false).Value = value; _settingsService.GetSetting("UI.AutoRun", false).Save(); NotifyOfPropertyChange(nameof(StartWithWindows)); - Task.Run(ApplyAutorun); + Task.Run(() => ApplyAutorun(false)); + } + } + + public int AutoRunDelay + { + get => _settingsService.GetSetting("UI.AutoRunDelay", 15).Value; + set + { + _settingsService.GetSetting("UI.AutoRunDelay", 15).Value = value; + _settingsService.GetSetting("UI.AutoRunDelay", 15).Save(); + NotifyOfPropertyChange(nameof(AutoRunDelay)); + Task.Run(() => ApplyAutorun(true)); } } @@ -235,6 +249,8 @@ namespace Artemis.UI.Screens.Settings.Tabs.General } } + public PluginSetting WebServerPortSetting { get; } + public bool CanOfferUpdatesIfFound { get => _canOfferUpdatesIfFound; @@ -289,11 +305,11 @@ namespace Artemis.UI.Screens.Settings.Tabs.General protected override void OnInitialActivate() { - Task.Run(ApplyAutorun); + Task.Run(() => ApplyAutorun(false)); base.OnInitialActivate(); } - private void ApplyAutorun() + private void ApplyAutorun(bool recreate) { if (!StartWithWindows) StartMinimized = false; @@ -303,23 +319,29 @@ namespace Artemis.UI.Screens.Settings.Tabs.General if (File.Exists(autoRunFile)) File.Delete(autoRunFile); + // TODO: Don't do anything if running a development build, only auto-run release builds + // Create or remove the task if necessary try { - Process schtasks = new() + bool taskCreated = false; + if (!recreate) { - StartInfo = + Process schtasks = new() { - WindowStyle = ProcessWindowStyle.Hidden, - UseShellExecute = true, - FileName = Path.Combine(Environment.SystemDirectory, "schtasks.exe"), - Arguments = "/TN \"Artemis 2 autorun\"" - } - }; + StartInfo = + { + WindowStyle = ProcessWindowStyle.Hidden, + UseShellExecute = true, + FileName = Path.Combine(Environment.SystemDirectory, "schtasks.exe"), + Arguments = "/TN \"Artemis 2 autorun\"" + } + }; - schtasks.Start(); - schtasks.WaitForExit(); - bool taskCreated = schtasks.ExitCode == 0; + schtasks.Start(); + schtasks.WaitForExit(); + taskCreated = schtasks.ExitCode == 0; + } if (StartWithWindows && !taskCreated) CreateAutoRunTask(); @@ -343,6 +365,9 @@ namespace Artemis.UI.Screens.Settings.Tabs.General task.Descendants().First(d => d.Name.LocalName == "RegistrationInfo").Descendants().First(d => d.Name.LocalName == "Author") .SetValue(System.Security.Principal.WindowsIdentity.GetCurrent().Name); + task.Descendants().First(d => d.Name.LocalName == "Triggers").Descendants().First(d => d.Name.LocalName == "LogonTrigger").Descendants().First(d => d.Name.LocalName == "Delay") + .SetValue(TimeSpan.FromSeconds(AutoRunDelay)); + task.Descendants().First(d => d.Name.LocalName == "Principals").Descendants().First(d => d.Name.LocalName == "Principal").Descendants().First(d => d.Name.LocalName == "UserId") .SetValue(System.Security.Principal.WindowsIdentity.GetCurrent().User.Value); @@ -365,7 +390,7 @@ namespace Artemis.UI.Screens.Settings.Tabs.General UseShellExecute = true, Verb = "runas", FileName = Path.Combine(Environment.SystemDirectory, "schtasks.exe"), - Arguments = $"/Create /XML \"{xmlPath}\" /tn \"Artemis 2 autorun\"" + Arguments = $"/Create /XML \"{xmlPath}\" /tn \"Artemis 2 autorun\" /F" } }; diff --git a/src/Artemis.UI/Screens/Settings/Tabs/Plugins/PluginFeatureView.xaml b/src/Artemis.UI/Screens/Settings/Tabs/Plugins/PluginFeatureView.xaml index 9e88a2346..eb8f06d5f 100644 --- a/src/Artemis.UI/Screens/Settings/Tabs/Plugins/PluginFeatureView.xaml +++ b/src/Artemis.UI/Screens/Settings/Tabs/Plugins/PluginFeatureView.xaml @@ -7,6 +7,7 @@ xmlns:materialDesign="http://materialdesigninxaml.net/winfx/xaml/themes" xmlns:s="https://github.com/canton7/Stylet" xmlns:converters="clr-namespace:Artemis.UI.Converters" + xmlns:shared="clr-namespace:Artemis.UI.Shared;assembly=Artemis.UI.Shared" mc:Ignorable="d" d:DesignHeight="450" d:DesignWidth="800" d:DataContext="{d:DesignInstance local:PluginFeatureViewModel}"> @@ -21,8 +22,9 @@ - @@ -40,7 +42,7 @@ - + Feature.GetType().Name.Humanize(); - public Exception LoadException => Feature.LoadException; public bool Enabling @@ -109,7 +97,7 @@ namespace Artemis.UI.Screens.Settings.Tabs.Plugins } catch (Exception e) { - _messageService.ShowMessage($"Failed to enable {Name}\r\n{e.Message}", "VIEW LOGS", ShowLogsFolder); + _messageService.ShowMessage($"Failed to enable {Feature.Info.Name}\r\n{e.Message}", "VIEW LOGS", ShowLogsFolder); } finally { @@ -123,20 +111,6 @@ namespace Artemis.UI.Screens.Settings.Tabs.Plugins } } - private PackIconKind GetIconKind() - { - return Feature switch - { - BaseDataModelExpansion => PackIconKind.TableAdd, - DeviceProvider => PackIconKind.Devices, - ProfileModule => PackIconKind.VectorRectangle, - Module => PackIconKind.GearBox, - LayerBrushProvider => PackIconKind.Brush, - LayerEffectProvider => PackIconKind.AutoAwesome, - _ => PackIconKind.Plugin - }; - } - #region Event handlers private void OnFeatureEnabling(object sender, PluginFeatureEventArgs e) diff --git a/src/Artemis.UI/Screens/TrayViewModel.cs b/src/Artemis.UI/Screens/TrayViewModel.cs index 666c8cd59..c17ab70ae 100644 --- a/src/Artemis.UI/Screens/TrayViewModel.cs +++ b/src/Artemis.UI/Screens/TrayViewModel.cs @@ -9,7 +9,6 @@ using Artemis.Core.Services; using Artemis.UI.Events; using Artemis.UI.Screens.Splash; using Artemis.UI.Services; -using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.Services; using Hardcodet.Wpf.TaskbarNotification; using MaterialDesignThemes.Wpf; @@ -25,7 +24,6 @@ namespace Artemis.UI.Screens private readonly IEventAggregator _eventAggregator; private readonly IKernel _kernel; private readonly IWindowManager _windowManager; - private bool _canShowRootViewModel; private RootViewModel _rootViewModel; private SplashViewModel _splashViewModel; private TaskbarIcon _taskBarIcon; @@ -44,7 +42,6 @@ namespace Artemis.UI.Screens _windowManager = windowManager; _eventAggregator = eventAggregator; _debugService = debugService; - CanShowRootViewModel = true; Core.Utilities.ShutdownRequested += UtilitiesOnShutdownRequested; Core.Utilities.RestartRequested += UtilitiesOnShutdownRequested; @@ -64,23 +61,19 @@ namespace Artemis.UI.Screens } } - public bool CanShowRootViewModel - { - get => _canShowRootViewModel; - set => SetAndNotify(ref _canShowRootViewModel, value); - } - public void TrayBringToForeground() { - if (!CanShowRootViewModel) + if (IsMainWindowOpen) + { + Execute.PostToUIThread(FocusMainWindow); return; + } // Initialize the shared UI when first showing the window if (!UI.Shared.Bootstrapper.Initialized) UI.Shared.Bootstrapper.Initialize(_kernel); - CanShowRootViewModel = false; - Execute.OnUIThread(() => + Execute.OnUIThreadSync(() => { _splashViewModel?.RequestClose(); _splashViewModel = null; @@ -115,24 +108,25 @@ namespace Artemis.UI.Screens public void OnTrayBalloonTipClicked(object sender, EventArgs e) { - if (CanShowRootViewModel) - { + if (!IsMainWindowOpen) TrayBringToForeground(); - } else - { - // Wrestle the main window to the front - Window mainWindow = (Window) _rootViewModel.View; - if (mainWindow.WindowState == WindowState.Minimized) - mainWindow.WindowState = WindowState.Normal; - mainWindow.Activate(); - mainWindow.Topmost = true; - mainWindow.Topmost = false; - mainWindow.Focus(); - } + FocusMainWindow(); } - private void UtilitiesOnShutdownRequested(object? sender, EventArgs e) + private void FocusMainWindow() + { + // Wrestle the main window to the front + Window mainWindow = (Window) _rootViewModel.View; + if (mainWindow.WindowState == WindowState.Minimized) + mainWindow.WindowState = WindowState.Normal; + mainWindow.Activate(); + mainWindow.Topmost = true; + mainWindow.Topmost = false; + mainWindow.Focus(); + } + + private void UtilitiesOnShutdownRequested(object sender, EventArgs e) { Execute.OnUIThread(() => _taskBarIcon?.Dispose()); } @@ -150,8 +144,6 @@ namespace Artemis.UI.Screens { _rootViewModel.Closed -= RootViewModelOnClosed; _rootViewModel = null; - - CanShowRootViewModel = true; OnMainWindowClosed(); } @@ -198,16 +190,16 @@ namespace Artemis.UI.Screens public bool OpenMainWindow() { - if (CanShowRootViewModel) - return false; - - TrayBringToForeground(); - return true; + if (IsMainWindowOpen) + Execute.OnUIThread(FocusMainWindow); + else + TrayBringToForeground(); + return _rootViewModel.ScreenState == ScreenState.Active; } public bool CloseMainWindow() { - _rootViewModel.RequestClose(); + Execute.OnUIThread(() => _rootViewModel.RequestClose()); return _rootViewModel.ScreenState == ScreenState.Closed; } diff --git a/src/Artemis.UI/Services/DebugService.cs b/src/Artemis.UI/Services/DebugService.cs index 158457079..060790479 100644 --- a/src/Artemis.UI/Services/DebugService.cs +++ b/src/Artemis.UI/Services/DebugService.cs @@ -1,6 +1,5 @@ using System.Windows; using Artemis.UI.Screens.Settings.Debug; -using Artemis.UI.Services.Interfaces; using MaterialDesignExtensions.Controls; using Ninject; using Stylet; diff --git a/src/Artemis.UI/Services/Interfaces/IArtemisUIService.cs b/src/Artemis.UI/Services/Interfaces/IArtemisUIService.cs index d29486dce..3e2b425b8 100644 --- a/src/Artemis.UI/Services/Interfaces/IArtemisUIService.cs +++ b/src/Artemis.UI/Services/Interfaces/IArtemisUIService.cs @@ -1,4 +1,4 @@ -namespace Artemis.UI.Services.Interfaces +namespace Artemis.UI.Services { // ReSharper disable once InconsistentNaming public interface IArtemisUIService diff --git a/src/Artemis.UI/Services/Interfaces/IDebugService.cs b/src/Artemis.UI/Services/Interfaces/IDebugService.cs index fd349ebba..331687677 100644 --- a/src/Artemis.UI/Services/Interfaces/IDebugService.cs +++ b/src/Artemis.UI/Services/Interfaces/IDebugService.cs @@ -1,4 +1,4 @@ -namespace Artemis.UI.Services.Interfaces +namespace Artemis.UI.Services { public interface IDebugService : IArtemisUIService { diff --git a/src/Artemis.UI/Services/Interfaces/ILayerEditorService.cs b/src/Artemis.UI/Services/Interfaces/ILayerEditorService.cs index 79799d183..1b2c687c7 100644 --- a/src/Artemis.UI/Services/Interfaces/ILayerEditorService.cs +++ b/src/Artemis.UI/Services/Interfaces/ILayerEditorService.cs @@ -3,7 +3,7 @@ using System.Windows.Media; using Artemis.Core; using SkiaSharp; -namespace Artemis.UI.Services.Interfaces +namespace Artemis.UI.Services { public interface ILayerEditorService : IArtemisUIService { diff --git a/src/Artemis.UI/Services/LayerEditorService.cs b/src/Artemis.UI/Services/LayerEditorService.cs index fcaa1143d..4ecde9cd8 100644 --- a/src/Artemis.UI/Services/LayerEditorService.cs +++ b/src/Artemis.UI/Services/LayerEditorService.cs @@ -2,7 +2,6 @@ using System.Windows; using System.Windows.Media; using Artemis.Core; -using Artemis.UI.Services.Interfaces; using SkiaSharp; using SkiaSharp.Views.WPF; diff --git a/src/Artemis.UI/Services/RegistrationService.cs b/src/Artemis.UI/Services/RegistrationService.cs index f64218a08..c6c80e900 100644 --- a/src/Artemis.UI/Services/RegistrationService.cs +++ b/src/Artemis.UI/Services/RegistrationService.cs @@ -1,12 +1,12 @@ using System.Linq; using Artemis.Core; using Artemis.Core.Services; +using Artemis.UI.Controllers; using Artemis.UI.DefaultTypes.DataModel.Display; using Artemis.UI.DefaultTypes.DataModel.Input; using Artemis.UI.InputProviders; using Artemis.UI.Ninject; using Artemis.UI.PropertyInput; -using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.Services; using Serilog; @@ -20,6 +20,7 @@ namespace Artemis.UI.Services private readonly IPluginManagementService _pluginManagementService; private readonly ISurfaceService _surfaceService; private readonly IInputService _inputService; + private readonly IWebServerService _webServerService; private bool _registeredBuiltInDataModelDisplays; private bool _registeredBuiltInDataModelInputs; private bool _registeredBuiltInPropertyEditors; @@ -29,7 +30,8 @@ namespace Artemis.UI.Services IProfileEditorService profileEditorService, IPluginManagementService pluginManagementService, ISurfaceService surfaceService, - IInputService inputService) + IInputService inputService, + IWebServerService webServerService) { _logger = logger; _dataModelUIService = dataModelUIService; @@ -37,6 +39,7 @@ namespace Artemis.UI.Services _pluginManagementService = pluginManagementService; _surfaceService = surfaceService; _inputService = inputService; + _webServerService = webServerService; LoadPluginModules(); pluginManagementService.PluginEnabling += PluginServiceOnPluginEnabling; @@ -92,6 +95,11 @@ namespace Artemis.UI.Services _inputService.AddInputProvider(new NativeWindowInputProvider(_logger, _inputService)); } + public void RegisterControllers() + { + _webServerService.AddController(); + } + private void PluginServiceOnPluginEnabling(object sender, PluginEventArgs e) { e.Plugin.Kernel.Load(new[] {new PluginUIModule(e.Plugin)}); @@ -110,5 +118,6 @@ namespace Artemis.UI.Services void RegisterBuiltInDataModelInputs(); void RegisterBuiltInPropertyEditors(); void RegisterInputProvider(); + void RegisterControllers(); } } \ No newline at end of file diff --git a/src/Artemis.UI/Services/UpdateService.cs b/src/Artemis.UI/Services/UpdateService.cs index 21af544b1..69704201a 100644 --- a/src/Artemis.UI/Services/UpdateService.cs +++ b/src/Artemis.UI/Services/UpdateService.cs @@ -10,7 +10,6 @@ using Artemis.Core; using Artemis.Core.Services; using Artemis.UI.Exceptions; using Artemis.UI.Screens.Settings.Dialogs; -using Artemis.UI.Services.Interfaces; using Artemis.UI.Shared.Services; using MaterialDesignThemes.Wpf; using Newtonsoft.Json.Linq; @@ -209,7 +208,7 @@ namespace Artemis.UI.Services AutoUpdate(); } - private void WindowServiceOnMainWindowOpened(object? sender, EventArgs e) + private void WindowServiceOnMainWindowOpened(object sender, EventArgs e) { _logger.Information("Main window opened!"); } diff --git a/src/Artemis.UI/packages.lock.json b/src/Artemis.UI/packages.lock.json index 83f215a37..8a022ea4a 100644 --- a/src/Artemis.UI/packages.lock.json +++ b/src/Artemis.UI/packages.lock.json @@ -195,6 +195,14 @@ "System.Xml.XmlDocument": "4.3.0" } }, + "EmbedIO": { + "type": "Transitive", + "resolved": "3.4.3", + "contentHash": "YM6hpZNAfvbbixfG9T4lWDGfF0D/TqutbTROL4ogVcHKwPF1hp+xS3ABwd3cxxTxvDFkj/zZl57QgWuFA8Igxw==", + "dependencies": { + "Unosquare.Swan.Lite": "3.0.0" + } + }, "HidSharp": { "type": "Transitive", "resolved": "2.1.0", @@ -1357,6 +1365,11 @@ "System.Xml.ReaderWriter": "4.3.0" } }, + "Unosquare.Swan.Lite": { + "type": "Transitive", + "resolved": "3.0.0", + "contentHash": "noPwJJl1Q9uparXy1ogtkmyAPGNfSGb0BLT1292nFH1jdMKje6o2kvvrQUvF9Xklj+IoiAI0UzF6Aqxlvo10lw==" + }, "WriteableBitmapEx": { "type": "Transitive", "resolved": "1.6.7", @@ -1367,6 +1380,7 @@ "dependencies": { "Artemis.Storage": "1.0.0", "Ben.Demystifier": "0.1.6", + "EmbedIO": "3.4.3", "HidSharp": "2.1.0", "Humanizer.Core": "2.8.26", "LiteDB": "5.0.9",