diff --git a/Backend/WebWidget/InstanceIndex.html b/Backend/WebWidget/InstanceIndex.html new file mode 100644 index 00000000..20ee33b1 --- /dev/null +++ b/Backend/WebWidget/InstanceIndex.html @@ -0,0 +1,10 @@ + + + Slipstream Actice WebWidget Instances + + +

Slipstream Actice WebWidget Instances

+ + {{CONTENT}} + + \ No newline at end of file diff --git a/Backend/WebWidget/ss.js b/Backend/WebWidget/ss.js new file mode 100644 index 00000000..cac15bfd --- /dev/null +++ b/Backend/WebWidget/ss.js @@ -0,0 +1,52 @@ +var INSTANCE_ID +var WEB_WIDGET_TYPE +var ASSETS + +function connect() { + let socket = a = new WebSocket("ws://" + document.location.host + "/events/" + INSTANCE_ID) + + socket.onopen = function (e) { + console.log("[slipstream ws] [open] Connection established") + + if (typeof(onConnect) === "function") { + onConnect() + } + } + + socket.onmessage = function (event) { + if (typeof (onData) !== "function") { + console.log("[slipstream ws] [onmessage] got data, but no onData() function defined. Ignored", event.data) + } + else { + console.log("[slipstream ws] [onmessage] got data", event.data) + onData(JSON.parse(event.data)) + } + } + + socket.onclose = function (event) { + if (event.wasClean) { + console.log("[slipstream ws] [close] Connection closed cleanly, code=" + event.code + " reason=" + event.reason) + } else { + console.log('[slipstream ws] [close] Connection died') + } + + if (typeof (onDisconnect) === "function") { + onDisconnect() + } + + setTimeout(connect, 1000); + } + + socket.onerror = function (error) { + console.log("[slipstream ws] [error] " + error.message) + socket.close() + } +} + +window.addEventListener('load', function () { + INSTANCE_ID = document.body.getAttribute("data-instance-id") + WEB_WIDGET_TYPE = document.body.getAttribute("data-web-widget-type") + ASSETS = document.body.getAttribute("data-assets") + + connect() +}) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8a9a68..77635b08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,10 @@ And only scripts using that instance will be notified of its events. Meaning, you will not get IRacing events, if you don't have require("api/iracing"):instance(..) - You need a new init.lua, so delete your existing init.lua to get a new one. + - IRacing: Adds pit-stop lua functions: pit_clear_all(), pit_clear_tyres(), + pit_fast_repair(), pit_add_fuel(), pit_change_(left|right)_(front|rear)_tyre() + and pit_clean_windshield() + - New Component: WebWidgets - Merged UI components into WinFormUI. You need to change `require("api/ui")` to `require("api/winformui")` diff --git a/Components/Twitch/Events/TwitchReceivedMessage.cs b/Components/Twitch/Events/TwitchReceivedMessage.cs index e003c47b..33daee9d 100644 --- a/Components/Twitch/Events/TwitchReceivedMessage.cs +++ b/Components/Twitch/Events/TwitchReceivedMessage.cs @@ -8,9 +8,7 @@ namespace Slipstream.Components.Twitch.Events public class TwitchReceivedMessage : IEvent { public string EventType => nameof(TwitchReceivedMessage); - public IEventEnvelope Envelope { get; set; } = new EventEnvelope(); - [Description("User that sent the message")] public string From { get; set; } = string.Empty; diff --git a/Components/WebWidget/EventFactory/WebWidgetEventFactory.cs b/Components/WebWidget/EventFactory/WebWidgetEventFactory.cs new file mode 100644 index 00000000..8cdd71e9 --- /dev/null +++ b/Components/WebWidget/EventFactory/WebWidgetEventFactory.cs @@ -0,0 +1,16 @@ +using Slipstream.Components.WebWidget; +using Slipstream.Components.WebWidget.Events; +using Slipstream.Shared; + +#nullable enable + +namespace Slipstream.Components.Internal.EventFactory +{ + public class WebWidgetEventFactory : IWebWidgetEventFactory + { + public WebWidgetCommandEvent CreateWebWidgetCommandEvent(IEventEnvelope envelope, string data) + { + return new WebWidgetCommandEvent { Envelope = envelope, Data = data }; + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/EventHandler/WebWidgetEventHandler.cs b/Components/WebWidget/EventHandler/WebWidgetEventHandler.cs new file mode 100644 index 00000000..0ac85866 --- /dev/null +++ b/Components/WebWidget/EventHandler/WebWidgetEventHandler.cs @@ -0,0 +1,32 @@ +#nullable enable + +using Slipstream.Components.WebWidget.Events; +using Slipstream.Shared; +using System; + +namespace Slipstream.Components.WebWidget.EventHandler +{ + internal class WebWidgetEventHandler : IEventHandler + { + public event EventHandler? OnWebWidgetCommandEvent; + + public IEventHandler.HandledStatus HandleEvent(IEvent @event) + { + return @event switch + { + WebWidgetCommandEvent tev => OnEvent(OnWebWidgetCommandEvent, tev), + _ => IEventHandler.HandledStatus.NotMine, + }; + } + + private IEventHandler.HandledStatus OnEvent(EventHandler? onEvent, TEvent args) + { + if (onEvent != null) + { + onEvent.Invoke(this, args); + return IEventHandler.HandledStatus.Handled; + } + return IEventHandler.HandledStatus.UseDefault; + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/Events/WebWidgetCommandEvent.cs b/Components/WebWidget/Events/WebWidgetCommandEvent.cs new file mode 100644 index 00000000..a7a20275 --- /dev/null +++ b/Components/WebWidget/Events/WebWidgetCommandEvent.cs @@ -0,0 +1,13 @@ +#nullable enable + +using Slipstream.Shared; + +namespace Slipstream.Components.WebWidget.Events +{ + public class WebWidgetCommandEvent : IEvent + { + public string EventType => typeof(WebWidgetCommandEvent).Name; + public IEventEnvelope Envelope { get; set; } = new EventEnvelope(); + public string Data { get; set; } = string.Empty; + } +} \ No newline at end of file diff --git a/Components/WebWidget/HttpServer.cs b/Components/WebWidget/HttpServer.cs new file mode 100644 index 00000000..a5f2a6c8 --- /dev/null +++ b/Components/WebWidget/HttpServer.cs @@ -0,0 +1,133 @@ +#nullable enable + +using System; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO; +using Serilog; +using Slipstream.Components.WebWidget.EventHandler; +using Slipstream.Shared; + +namespace Slipstream.Components.WebWidget +{ + public class HttpServer : IHttpServer, IHttpServerApi + { + private const string WEB_WIDGET_ROOT_DIRECTORY = "WebWidgets/"; + + private readonly Object Lock = new object(); + private Thread? ServiceThread; + private readonly ILogger Logger; + private readonly IEventBusSubscription Subscription; + private readonly WebSocketsEventsServer EventsServerModule; + private readonly IEventHandlerController EventHandlerController; + private readonly IWebWidgetInstances Instances = new WebWidgetInstances(); + private const string Url = "http://127.0.0.1:1919"; // Must NOT end with slash + + public HttpServer(ILogger logger, IEventBusSubscription subscription, IEventHandlerController eventHandlerController) + { + Logger = logger; + Subscription = subscription; + EventHandlerController = eventHandlerController; + + EventsServerModule = new WebSocketsEventsServer(Logger, Instances); + + System.IO.Directory.CreateDirectory(WEB_WIDGET_ROOT_DIRECTORY); + } + + public void AddInstance(string instanceId, string webWidgetType, string? data) + { + lock (Lock) + { + if (ServiceThread == null) + { + ServiceThread = new Thread(new ThreadStart(ThreadMain)) + { + Name = GetType().Name, + }; + ServiceThread.Start(); + } + } + + // Crude sanity check + var indexFile = WEB_WIDGET_ROOT_DIRECTORY + webWidgetType + "/index.html"; + if (System.IO.File.Exists(indexFile)) + { + Instances.Add(instanceId, webWidgetType, data); + Subscription.AddImpersonate(instanceId); + + Logger.Information($"HttpServer: {Url}/instances/{instanceId} added"); + } + else + { + Logger.Error($"HttpServer: {Url}/instances/{instanceId} not added, as {indexFile} does not exist"); + } + } + + private void ThreadMain() + { + Logger.Information("HttpServer started"); + + using (var server = CreateWebServer(Url)) + { + Task serverTask = server.RunAsync(); + bool stopping = false; + + var internalHandler = EventHandlerController.Get(); + var webWidgetHandler = EventHandlerController.Get(); + + internalHandler.OnInternalCommandShutdown += (_, e) => stopping = true; + webWidgetHandler.OnWebWidgetCommandEvent += (_, e) => + { + if (e.Envelope.Recipients == null) + return; + + foreach (var recipient in e.Envelope.Recipients) + { + EventsServerModule.Broadcast(recipient, e.Data); + } + }; + + while (!stopping) + { + IEvent? @event = Subscription.NextEvent(100); + EventHandlerController.HandleEvent(@event); + + stopping = stopping || serverTask.IsCompleted; + } + } + + Logger.Information("HttpServer stopped"); + } + + public void RemoveInstance(string instanceId) + { + Instances.Remove(instanceId); + Subscription.DeleteImpersonation(instanceId); + Logger.Information($"HttpServer: {Url}/instances/{instanceId} removed"); + } + + private WebServer CreateWebServer(string url) + { + // endpoints: + // / + // /ss.js + // /instances// + // /webwidgets// + // /events// + + var server = new WebServer(o => o + .WithUrlPrefix(url)) + .WithStaticFolder("/webwidgets/", WEB_WIDGET_ROOT_DIRECTORY, false) + .WithModule(EventsServerModule) + .WithModule(new JavascriptWebModule()) + .WithModule(new InstanceWebModule(Instances, WEB_WIDGET_ROOT_DIRECTORY, Logger)) + .WithModule(new InstanceIndexWebModule(Instances, Logger)); + + return server; + } + + public void Dispose() + { + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/IHttpServer.cs b/Components/WebWidget/IHttpServer.cs new file mode 100644 index 00000000..3de14261 --- /dev/null +++ b/Components/WebWidget/IHttpServer.cs @@ -0,0 +1,10 @@ +#nullable enable + +using System; + +namespace Slipstream.Components.WebWidget +{ + public interface IHttpServer : IDisposable + { + } +} \ No newline at end of file diff --git a/Components/WebWidget/IHttpServerApi.cs b/Components/WebWidget/IHttpServerApi.cs new file mode 100644 index 00000000..b6ed54c8 --- /dev/null +++ b/Components/WebWidget/IHttpServerApi.cs @@ -0,0 +1,11 @@ +#nullable enable + +namespace Slipstream.Components.WebWidget +{ + public interface IHttpServerApi + { + // These needs to be threadsafe! + void AddInstance(string instanceId, string instanceType, string? data); + void RemoveInstance(string instanceId); + } +} \ No newline at end of file diff --git a/Components/WebWidget/IWebWidgetEventFactory.cs b/Components/WebWidget/IWebWidgetEventFactory.cs new file mode 100644 index 00000000..f4f374ad --- /dev/null +++ b/Components/WebWidget/IWebWidgetEventFactory.cs @@ -0,0 +1,12 @@ +using Slipstream.Components.WebWidget.Events; +using Slipstream.Shared; + +#nullable enable + +namespace Slipstream.Components.WebWidget +{ + public interface IWebWidgetEventFactory + { + WebWidgetCommandEvent CreateWebWidgetCommandEvent(IEventEnvelope envelope, string data); + } +} \ No newline at end of file diff --git a/Components/WebWidget/IWebWidgetInstances.cs b/Components/WebWidget/IWebWidgetInstances.cs new file mode 100644 index 00000000..6c923822 --- /dev/null +++ b/Components/WebWidget/IWebWidgetInstances.cs @@ -0,0 +1,16 @@ +#nullable enable + + +using System.Collections.Generic; + +namespace Slipstream.Components.WebWidget +{ + public interface IWebWidgetInstances + { + void Add(string id, string type, string? data); + void Remove(string id); + string this[string id] {get;} + ICollection GetIds(); + string? InitData(string id); + } +} \ No newline at end of file diff --git a/Components/WebWidget/InstanceIndexWebModule.cs b/Components/WebWidget/InstanceIndexWebModule.cs new file mode 100644 index 00000000..653a07f2 --- /dev/null +++ b/Components/WebWidget/InstanceIndexWebModule.cs @@ -0,0 +1,87 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.Routing; +using Serilog; + +namespace Slipstream.Components.WebWidget +{ + public class InstanceIndexWebModule : IWebModule + { + private readonly RouteMatcher RouteMatcher; + private readonly ILogger Logger; + private readonly IWebWidgetInstances Instances; + private readonly string Template; + public bool IsFinalHandler => true; + public ExceptionHandlerCallback? OnUnhandledException { get; set; } + public HttpExceptionHandlerCallback? OnHttpException { get; set; } + public string BaseRoute => "/"; + + public InstanceIndexWebModule(IWebWidgetInstances instances, ILogger logger) + { + Instances = instances; + RouteMatcher = RouteMatcher.Parse(BaseRoute, true); + Logger = logger; + + var assembly = GetType().Assembly; + using var s = assembly.GetManifestResourceStream("Slipstream.Backend.WebWidget.InstanceIndex.html"); + using var sr = new StreamReader(s); + Template = sr.ReadToEnd(); + } + + public RouteMatch? MatchUrlPath(string urlPath) + { + return RouteMatcher.Match(urlPath); + } + + public void Start(CancellationToken cancellationToken) + { + } + + public Task HandleRequestAsync(IHttpContext context) + { + if (context.IsHandled) + return Task.CompletedTask; + + try + { + var content = ""; + + if(Instances.GetIds().Count == 0) + { + content = Template.Replace("{{CONTENT}}", "No instances"); + } + else + { + var instancesBlock = ""; + + foreach (var instanceId in Instances.GetIds()) + { + instancesBlock += $"
  • {instanceId}
  • "; + } + + content = Template.Replace("{{CONTENT}}", $"
      {instancesBlock}
    "); + } + + context.Response.Headers.Add(HttpHeaderNames.CacheControl, "no-cache"); + return context.SendStringAsync(content, MimeType.Html, Encoding.UTF8); + } + catch(KeyNotFoundException _) + { + context.Response.StatusCode = 404; + return Task.CompletedTask; + } + catch(Exception e) + { + Logger.Error(e.Message); + throw; + } + } + } +} diff --git a/Components/WebWidget/InstanceWebModule.cs b/Components/WebWidget/InstanceWebModule.cs new file mode 100644 index 00000000..5d3788b4 --- /dev/null +++ b/Components/WebWidget/InstanceWebModule.cs @@ -0,0 +1,74 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.Routing; +using Serilog; + +namespace Slipstream.Components.WebWidget +{ + public class InstanceWebModule : IWebModule + { + private readonly RouteMatcher RouteMatcher; + private readonly ILogger Logger; + private readonly IWebWidgetInstances Instances; + private readonly string WebWidgetDirectory; + public bool IsFinalHandler => true; + public ExceptionHandlerCallback? OnUnhandledException { get; set; } + public HttpExceptionHandlerCallback? OnHttpException { get; set; } + public string BaseRoute => "/instances/{id}"; + + public InstanceWebModule(IWebWidgetInstances instances, string webWidgetDirectory, ILogger logger) + { + Instances = instances; + WebWidgetDirectory = webWidgetDirectory; + RouteMatcher = RouteMatcher.Parse(BaseRoute, true); + Logger = logger; + } + + public RouteMatch? MatchUrlPath(string urlPath) + { + return RouteMatcher.Match(urlPath); + } + + public void Start(CancellationToken cancellationToken) + { + } + + public Task HandleRequestAsync(IHttpContext context) + { + try + { + var instanceId = context.Route["id"]; + var webWidgetType = Instances[instanceId]; + + var template = File.ReadAllText(WebWidgetDirectory + webWidgetType + "/index.html"); + var assets = "/webwidgets/" + webWidgetType; + var rendered = template + .Replace("{{ASSETS}}", assets) + .Replace("{{SLIPSTREAM_BODY_ATTRS}}", $" data-instance-id=\"{instanceId}\" data-web-widget-type=\"{webWidgetType}\" data-assets=\"{assets}\"") + .Replace("{{SLIPSTREAM_HEADERS}}", ""); + + context.SetHandled(); + context.Response.Headers.Add(HttpHeaderNames.CacheControl, "no-cache"); + return context.SendStringAsync(rendered, MimeType.Html, Encoding.UTF8); + } + catch(KeyNotFoundException _) + { + context.Response.StatusCode = 404; + context.SetHandled(); + return Task.CompletedTask; + } + catch(Exception e) + { + Logger.Error(e.Message); + throw; + } + } + } +} diff --git a/Components/WebWidget/JavascriptWebModule.cs b/Components/WebWidget/JavascriptWebModule.cs new file mode 100644 index 00000000..c0e8d764 --- /dev/null +++ b/Components/WebWidget/JavascriptWebModule.cs @@ -0,0 +1,48 @@ +#nullable enable + +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EmbedIO; +using EmbedIO.Routing; + +namespace Slipstream.Components.WebWidget +{ + public class JavascriptWebModule : IWebModule + { + private readonly RouteMatcher RouteMatcher; + private readonly string Content; + public bool IsFinalHandler => true; + public ExceptionHandlerCallback? OnUnhandledException { get; set; } + public HttpExceptionHandlerCallback? OnHttpException { get; set; } + public string BaseRoute => "/ss.js"; + + public JavascriptWebModule() + { + RouteMatcher = RouteMatcher.Parse(BaseRoute, true); + + var assembly = GetType().Assembly; + using var s = assembly.GetManifestResourceStream("Slipstream.Backend.WebWidget.ss.js"); + using var sr = new StreamReader(s); + Content = sr.ReadToEnd(); + } + + public RouteMatch? MatchUrlPath(string urlPath) + { + return RouteMatcher.Match(urlPath); + } + + public void Start(CancellationToken cancellationToken) + { + } + + public Task HandleRequestAsync(IHttpContext context) + { + if (context.IsHandled) + return Task.CompletedTask; + + return context.SendStringAsync(Content, "application/javascript", Encoding.UTF8); + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/Lua/IWebWidgetInstanceThread.cs b/Components/WebWidget/Lua/IWebWidgetInstanceThread.cs new file mode 100644 index 00000000..cc53ff18 --- /dev/null +++ b/Components/WebWidget/Lua/IWebWidgetInstanceThread.cs @@ -0,0 +1,11 @@ +#nullable enable + +using Slipstream.Shared.Lua; +using System; + +namespace Slipstream.Components.WebWidget.Lua +{ + public interface IWebWidgetInstanceThread : ILuaInstanceThread, IDisposable + { + } +} \ No newline at end of file diff --git a/Components/WebWidget/Lua/WebWidgetInstanceThread.cs b/Components/WebWidget/Lua/WebWidgetInstanceThread.cs new file mode 100644 index 00000000..a217d44d --- /dev/null +++ b/Components/WebWidget/Lua/WebWidgetInstanceThread.cs @@ -0,0 +1,36 @@ +#nullable enable + +using Serilog; + +namespace Slipstream.Components.WebWidget.Lua +{ + public class WebWidgetInstanceThread : IWebWidgetInstanceThread + { + private readonly string InstanceId; + private readonly string WebWidgetType; + private readonly IHttpServerApi HttpServer; + private readonly string? Data; + + public WebWidgetInstanceThread(string instanceId, string webWidgetType, string data, IHttpServerApi httpServer) + { + InstanceId = instanceId; + WebWidgetType = webWidgetType; + HttpServer = httpServer; + Data = data; + } + + public void Dispose() + { + } + + public void Start() + { + HttpServer.AddInstance(InstanceId, WebWidgetType, Data); + } + + public void Stop() + { + HttpServer.RemoveInstance(InstanceId); + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/Lua/WebWidgetLuaLibrary.cs b/Components/WebWidget/Lua/WebWidgetLuaLibrary.cs new file mode 100644 index 00000000..9e5a3a37 --- /dev/null +++ b/Components/WebWidget/Lua/WebWidgetLuaLibrary.cs @@ -0,0 +1,53 @@ +#nullable enable + +using Autofac; +using Newtonsoft.Json; +using Slipstream.Shared; +using Slipstream.Shared.Helpers.StrongParameters; +using Slipstream.Shared.Helpers.StrongParameters.Validators; +using Slipstream.Shared.Lua; +using System.Collections.Generic; + +namespace Slipstream.Components.WebWidget.Lua +{ + public class WebWidgetLuaLibrary : BaseLuaLibrary + { + private IHttpServer? HttpServer; + public static DictionaryValidator ConfigurationValidator { get; } + + static WebWidgetLuaLibrary() + { + ConfigurationValidator = new DictionaryValidator() + .RequireString("id") + .PermitDictionary("data", a => a.AllowAnythingElse()) + .RequireString("type"); + } + + public WebWidgetLuaLibrary(ILifetimeScope scope, IEventBus eventBus) : base(ConfigurationValidator, scope, eventBus) + { + } + + protected override IWebWidgetInstanceThread CreateInstance(ILifetimeScope scope, Parameters cfg) + { + var instanceId = cfg.Extract("id"); + var webWidgetType = cfg.Extract("type"); + var data = cfg.ExtractOrDefault>("data", null); + var json = JsonConvert.SerializeObject(data); + + if (HttpServer == null) + { + var subscription = EventBus.RegisterListener(instanceId); + HttpServer = LifetimeScope.Resolve( + new TypedParameter(typeof(IEventBusSubscription), subscription) + ); + } + + return scope.Resolve( + new NamedParameter("instanceId", instanceId), + new NamedParameter("webWidgetType", webWidgetType), + new NamedParameter("data", json), + new NamedParameter("httpServer", HttpServer) + ); + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/Lua/WebWidgetLuaReference.cs b/Components/WebWidget/Lua/WebWidgetLuaReference.cs new file mode 100644 index 00000000..b31a7859 --- /dev/null +++ b/Components/WebWidget/Lua/WebWidgetLuaReference.cs @@ -0,0 +1,29 @@ +#nullable enable + +using NLua; +using Slipstream.Shared; +using Slipstream.Shared.Lua; +using Slipstream.Shared.Helpers.StrongParameters; +using Newtonsoft.Json; + +namespace Slipstream.Components.WebWidget.Lua +{ + public class WebWidgetLuaReference : BaseLuaReference + { + private readonly IWebWidgetEventFactory EventFactory; + private readonly IEventBus EventBus; + + public WebWidgetLuaReference(string instanceId, string luaScriptInstanceId, IWebWidgetEventFactory eventFactory, IEventBus eventBus) : base(instanceId, luaScriptInstanceId) + { + EventFactory = eventFactory; + EventBus = eventBus; + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE1006:Naming Styles", Justification = "This is expose in Lua, so we want to keep that naming style")] + public void send(LuaTable data) + { + var json = JsonConvert.SerializeObject(Parameters.From(data)); + EventBus.PublishEvent(EventFactory.CreateWebWidgetCommandEvent(Envelope, json)); + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/README.md b/Components/WebWidget/README.md new file mode 100644 index 00000000..6bab732e --- /dev/null +++ b/Components/WebWidget/README.md @@ -0,0 +1,95 @@ +# WebWidget + +Enables you to create http endpoints hosted by Slipstream. These endpoints called WebWidgets +instances can serve html and optionally assets. You can send updates to them, if this is needed. + +This can be used with OBS as a browswer source, allowing Slipstream to visually +display data on stream. + +## Lua + +
    Construction
    + +```lua +local webwidget = require("api/webwidget"):instance(config) +``` + +This will return a WebWidget instance and create it if it does not exists. + +`config` is the initial configuration of the instance if one needs to be created. +It is a table with one or more keys as defined below. + +| Parameter | Type | Default | Description | +| :---------- | :-----------: | :--------: | :----------------------------- | +| id | string | | Mandatory: Id of this instance | +| type | string | | Mandatory: WebWidget type | +| data | lua table- | nil | Optional: Initial data | + +When creating an instance, Slipstream will start up an embedded HTTP server, serving +that endpoint. URL is shown in the console, but will be http://127.0.0.1:1919/instances/, +where is replaced with the id provided to the instance. The endpoint is removed +when no longer referenced from Lua. + +In Slipstream data directory, you'll find a WebWidgets directory. The content of this directory +is served via http://127.0.0.1:1919/webwidgets/ allowing you to have you javascript, +css, images and other assets located here. + +The WebWidget directory, will also require an index.html exists in a subdirectory with the same +name as provided as the WebWidgetType. + +For example: +```lua +local webwidget = require("api/webwidget"):instance({ id = "mywidget", type = "test", data = "Hello") +``` + +Will create the endpoint http://127.0.0.1:1919/instances/wmywidget. If you go to this URL with +you browser, it will serve `WebWidget\test\index.html`. If this file does not exist, Slipstream +will not create the endpoint, but show a message in the console saying it is ignoring it. + +The `index.html` file should be based on the following template +```html + + + + {{SLIPSTREAM_HEADERS}} + + + + + + +``` + +For most part, this is regular HTML, except there are a few special strings: + +| Name | Required | Description | +| :-------- | :-----------:| :----------------------------- | +| {{SLIPSTREAM_HEADERS}} | Yes | Needs to be in the -tags for your HTML document. This will include javascript needed for the WebWidget to work | +| {{SLIPSTREAM_BODY_ATTRS}} | Yes | Needs to be a part of your -tag. e.g: `` | +| {{ASSETS}} | No | Will be replaced with the path to the WebWidgets assets directory (a subdirectory under `WebWidget` in your Slipstream data Directory) | + +To get access to the data provided, you need to define a javascript function named `onData`, +taking one argument, `data`, which is whatever data sent to the WebWidget. Additional two other javascript functions can +be implemented. `onConnect` (takes no arguments) and `onDisconnect()`, these are triggered when connecting/disconnecting to +Slipstream backend. It's optinal to implement these, but can be used to implement a "ghosted" view of the widget, +not connected to Slipstream. + +There are also three global variables available: `ASSETS` (for the assets path), `INSTANCE_ID` and finally `WEB_WIDGET_TYPE` + +See `widget:send(data)` for how to send data to your widget. +
    + +
    widget:send(data)
    +Delivers data to the WebWidget. Data itself is unparsed so it can be formatted in whatever +way that is appropiate. The callback in the `index.html` will receive it as-is. So you can +provide a string, json, numbers and so on. You will need to make the callback parse it, and +handle it as needed. + +```lua +local webwidget = require("api/webwidget"):instance(config) +webwidget:send("green") +``` diff --git a/Components/WebWidget/WebSocketsEventsServer.cs b/Components/WebWidget/WebSocketsEventsServer.cs new file mode 100644 index 00000000..4cf630c3 --- /dev/null +++ b/Components/WebWidget/WebSocketsEventsServer.cs @@ -0,0 +1,77 @@ +#nullable enable + +using System.Collections.Generic; +using System.Threading.Tasks; +using EmbedIO.WebSockets; +using Serilog; + +namespace Slipstream.Components.WebWidget +{ + public class WebSocketsEventsServer : WebSocketModule + { + private readonly ILogger Logger; + private readonly IWebWidgetInstances Instances; + private readonly Dictionary> InstanceToContextIdMap = new Dictionary>(); + + public WebSocketsEventsServer(ILogger logger, IWebWidgetInstances instances) : base("/events/{id}", true) + { + Logger = logger; + Instances = instances; + } + + protected override Task OnMessageReceivedAsync(IWebSocketContext context, byte[] rxBuffer, IWebSocketReceiveResult rxResult) + => Task.CompletedTask; + + protected override Task OnClientConnectedAsync(IWebSocketContext context) + { + string instanceId = ParseInstanceId(context); + Logger.Information($"HttpServer - connected {context} - ctxid={context.Id}, instanceID={instanceId}"); + + lock (InstanceToContextIdMap) + { + if (!InstanceToContextIdMap.ContainsKey(instanceId)) + InstanceToContextIdMap.Add(instanceId, new List()); + + InstanceToContextIdMap[instanceId].Add(context.Id); + } + + var initData = Instances.InitData(instanceId); + + // if we encoded null, it will be "null" - no need to send that + if (initData != null && initData != "null") + Broadcast(instanceId, initData); + + return base.OnClientConnectedAsync(context); + } + + private static string ParseInstanceId(IWebSocketContext context) + { + return context.RequestUri.LocalPath.Substring("/events/".Length); + } + + protected override Task OnClientDisconnectedAsync(IWebSocketContext context) + { + string instanceId = ParseInstanceId(context); + Logger.Information($"HttpServer - disconnected {context.RequestUri.LocalPath} - ctxid={context.Id}, instanceId={instanceId}"); + + lock (InstanceToContextIdMap) + { + if (InstanceToContextIdMap.ContainsKey(instanceId)) + InstanceToContextIdMap[instanceId].Remove(context.Id); + } + + return base.OnClientDisconnectedAsync(context); + } + + public void Broadcast(string instanceId, string data) + { + BroadcastAsync(data, s => + { + List ctxIds; + InstanceToContextIdMap.TryGetValue(instanceId, out ctxIds); + + return ctxIds != null && ctxIds.Contains(s.Id); + }); + } + } +} \ No newline at end of file diff --git a/Components/WebWidget/WebWidgetInstances.cs b/Components/WebWidget/WebWidgetInstances.cs new file mode 100644 index 00000000..811ac3e9 --- /dev/null +++ b/Components/WebWidget/WebWidgetInstances.cs @@ -0,0 +1,54 @@ +#nullable enable + +using System.Collections.Generic; + +namespace Slipstream.Components.WebWidget +{ + public class WebWidgetInstances : IWebWidgetInstances + { + private readonly IDictionary InstancesType = new Dictionary(); + private readonly IDictionary InstanceInitData = new Dictionary(); + + public void Add(string id, string type, string? data) + { + lock(InstancesType) + { + InstancesType.Add(id, type); + InstanceInitData.Add(id, data); + } + + } + + public void Remove(string id) + { + lock (InstancesType) + { + InstancesType.Remove(id); + InstanceInitData.Remove(id); + } + } + + public string this[string id] + { + get + { + return InstancesType[id]; + } + } + + public string? InitData(string id) + { + lock(InstanceInitData) + if(InstanceInitData.ContainsKey(id)) + return InstanceInitData[id]; + + return null; + } + + public ICollection GetIds() + { + lock(InstancesType) + return InstancesType.Keys; + } + } +} \ No newline at end of file diff --git a/Components/WinFormUI/Services/EventDataService.cs b/Components/WinFormUI/Services/EventDataService.cs index 91ef3dce..c5c39d16 100644 --- a/Components/WinFormUI/Services/EventDataService.cs +++ b/Components/WinFormUI/Services/EventDataService.cs @@ -40,7 +40,7 @@ private static EventInfoModel BuildEventInfo(Type t) private static string GetDescriptionFromAttribute(PropertyInfo p) { return Attribute.IsDefined(p, typeof(DescriptionAttribute)) ? - (Attribute.GetCustomAttribute(p, typeof(DescriptionAttribute)) as DescriptionAttribute).Description : null; + (Attribute.GetCustomAttribute(p, typeof(DescriptionAttribute)) as DescriptionAttribute).Description : ""; } private static IList BuildEventProperties(Type t) diff --git a/LuaScripts/debug.lua b/LuaScripts/debug.lua deleted file mode 100644 index 37fc186e..00000000 --- a/LuaScripts/debug.lua +++ /dev/null @@ -1,19 +0,0 @@ ---[[ - -This script will display all events seen by it to the console. Only for development / debugging purposes. - -Before using this, edit lib/config.lua - ---]] - -local cfg = require("lib/config") -local util = require("api/util"):instance(cfg.util) -local ui = require("api/winformui"):instance(cfg.winformui) - -function handle(event) - if event.EventType ~= "WinFormUICommandWriteToConsole" then - ui:print(util:event_to_json(event)) - end -end - -print "debug.lua loaded" \ No newline at end of file diff --git a/Program.cs b/Program.cs index 7f0df0d5..03e23dca 100644 --- a/Program.cs +++ b/Program.cs @@ -105,6 +105,8 @@ private static void ConfigureServices(ContainerBuilder builder) builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); builder.RegisterType().As().InstancePerDependency(); + builder.RegisterType().As().InstancePerDependency(); + builder.RegisterType().As().As().SingleInstance(); } private class PopulateSink diff --git a/LuaScripts/lib/common.lua b/Samples/Common/Scripts/common.lua similarity index 100% rename from LuaScripts/lib/common.lua rename to Samples/Common/Scripts/common.lua diff --git a/LuaScripts/lib/config.lua b/Samples/Common/Scripts/config.lua similarity index 100% rename from LuaScripts/lib/config.lua rename to Samples/Common/Scripts/config.lua diff --git a/Samples/IRacing - Audio - Prechecks/README.md b/Samples/IRacing - Audio - Prechecks/README.md new file mode 100644 index 00000000..048baf44 --- /dev/null +++ b/Samples/IRacing - Audio - Prechecks/README.md @@ -0,0 +1,6 @@ +# IRacing/Audio - Prechecks + +Will remind player to check fuel, setup, etc at qualify/race start. + +Before using this, edit lib/config.lua. You'll need `config.lua` and +`common.lua`. Both files can be found in `Samples/Common` folder. \ No newline at end of file diff --git a/LuaScripts/iracing_prechecks.lua b/Samples/IRacing - Audio - Prechecks/Scripts/iracing_prechecks.lua similarity index 100% rename from LuaScripts/iracing_prechecks.lua rename to Samples/IRacing - Audio - Prechecks/Scripts/iracing_prechecks.lua diff --git a/Samples/IRacing - Twitch - Car Command/README.md b/Samples/IRacing - Twitch - Car Command/README.md new file mode 100644 index 00000000..27b74c33 --- /dev/null +++ b/Samples/IRacing - Twitch - Car Command/README.md @@ -0,0 +1,7 @@ +# IRacing/Twitch !car command + +Implements a "!car" command for twitch users to see the current car driven. +This script assumes you + +Before using this, edit lib/config.lua. You'll need `config.lua` and +`common.lua`. Both files can be found in `Samples/Common` folder. \ No newline at end of file diff --git a/LuaScripts/iracing_car.lua b/Samples/IRacing - Twitch - Car Command/Scripts/iracing_car.lua similarity index 91% rename from LuaScripts/iracing_car.lua rename to Samples/IRacing - Twitch - Car Command/Scripts/iracing_car.lua index ba33774c..266395c0 100644 --- a/LuaScripts/iracing_car.lua +++ b/Samples/IRacing - Twitch - Car Command/Scripts/iracing_car.lua @@ -1,6 +1,3 @@ --- Implements a "!car" command for twitch users to see the current car driven --- Before using this, edit lib/config.lua - local cfg = require("lib/config") local common = require("lib/common") local iracing = require("api/iracing"):instance(cfg.iracing) diff --git a/Samples/IRacing - Twitch - IR Command/README.md b/Samples/IRacing - Twitch - IR Command/README.md new file mode 100644 index 00000000..227e274d --- /dev/null +++ b/Samples/IRacing - Twitch - IR Command/README.md @@ -0,0 +1,9 @@ +# IRacing/Twitch !ir command + +Provides "!ir" command, that will shown the latest seen IRating/License info + +- "!ir" - shows IRating/License for current category of the session (road, dirt, dirtoval, dirtroad) +- "!ir " - show of another category than current + +Before using this, edit lib/config.lua. You'll need `config.lua` and +`common.lua`. Both files can be found in `Samples/Common` folder. \ No newline at end of file diff --git a/LuaScripts/iracing_ir.lua b/Samples/IRacing - Twitch - IR Command/Scripts/iracing_ir.lua similarity index 88% rename from LuaScripts/iracing_ir.lua rename to Samples/IRacing - Twitch - IR Command/Scripts/iracing_ir.lua index 6ecf6da7..11236b9c 100644 --- a/LuaScripts/iracing_ir.lua +++ b/Samples/IRacing - Twitch - IR Command/Scripts/iracing_ir.lua @@ -1,13 +1,3 @@ ---[[ - - Provides "!ir" command, that will shown the latest seen IRating/License info - - - "!ir" - shows IRating/License for current category of the session (road, dirt, dirtoval, dirtroad) - - "!ir " - show of another category than current - - Before using this, edit lib/config.lua ---]] - local cfg = require("lib/config") local iracing = require("api/iracing"):instance(cfg.iracing) local twitch = require("api/twitch"):instance(cfg.twitch) diff --git a/Samples/IRacing - Twitch - SoF Command/README.md b/Samples/IRacing - Twitch - SoF Command/README.md new file mode 100644 index 00000000..12a9e5f9 --- /dev/null +++ b/Samples/IRacing - Twitch - SoF Command/README.md @@ -0,0 +1,6 @@ +# IRacing/Twitch !sof command + +Implements a "!sof" command for twitch users to see the current strength-of-field + +Before using this, edit lib/config.lua. You'll need `config.lua` and +`common.lua`. Both files can be found in `Samples/Common` folder. \ No newline at end of file diff --git a/LuaScripts/iracing_sof.lua b/Samples/IRacing - Twitch - SoF Command/Scripts/iracing_sof.lua similarity index 90% rename from LuaScripts/iracing_sof.lua rename to Samples/IRacing - Twitch - SoF Command/Scripts/iracing_sof.lua index 67984b21..3886e794 100644 --- a/LuaScripts/iracing_sof.lua +++ b/Samples/IRacing - Twitch - SoF Command/Scripts/iracing_sof.lua @@ -1,6 +1,3 @@ --- Implements a "!sof" command for twitch users to see the current strength-of-field --- Before using this, edit lib/config.lua - local cfg = require("lib/config") local common = require("lib/common") local iracing = require("api/iracing"):instance(cfg.iracing) diff --git a/Samples/IRacing - Twitch - Track Command/README.md b/Samples/IRacing - Twitch - Track Command/README.md new file mode 100644 index 00000000..5037c8d8 --- /dev/null +++ b/Samples/IRacing - Twitch - Track Command/README.md @@ -0,0 +1,6 @@ +# IRacing/Twitch !track command + +Implements a "!track" command for twitch users to see the current track. + +Before using this, edit lib/config.lua. You'll need `config.lua` and +`common.lua`. Both files can be found in `Samples/Common` folder. diff --git a/LuaScripts/iracing_track.lua b/Samples/IRacing - Twitch - Track Command/Scripts/iracing_track.lua similarity index 91% rename from LuaScripts/iracing_track.lua rename to Samples/IRacing - Twitch - Track Command/Scripts/iracing_track.lua index efe61894..b9d23eee 100644 --- a/LuaScripts/iracing_track.lua +++ b/Samples/IRacing - Twitch - Track Command/Scripts/iracing_track.lua @@ -1,6 +1,3 @@ --- Adds "!track" twitch command that displays current track --- Before using this, edit lib/config.lua - local cfg = require("lib/config") local common = require("lib/common") local iracing = require("api/iracing"):instance(cfg.iracing) diff --git a/Samples/IRacing - WebWidget - ChatOn Command/README.md b/Samples/IRacing - WebWidget - ChatOn Command/README.md new file mode 100644 index 00000000..8f20c4b7 --- /dev/null +++ b/Samples/IRacing - WebWidget - ChatOn Command/README.md @@ -0,0 +1,7 @@ +# IRacing/Twitch/WebWidget ChatOn command + +Implements !chaton and !chatoff commands, that will show/hide a focused-message (on screen) +and in chat. + +In OBS use http://127.0.0.1:1919/instances/focused-mode as browser-source for seeing the +webwidget. diff --git a/Samples/IRacing - WebWidget - ChatOn Command/Scripts/chatstate.lua b/Samples/IRacing - WebWidget - ChatOn Command/Scripts/chatstate.lua new file mode 100644 index 00000000..898ffee7 --- /dev/null +++ b/Samples/IRacing - WebWidget - ChatOn Command/Scripts/chatstate.lua @@ -0,0 +1,25 @@ +local cfg = require("lib/config") + +local widget = require("api/webwidget"):instance({ id = "focused-mode", type = "textbox"}) +local twitch = require("api/twitch"):instance(cfg.twitch) + +local focused_msg = "Focused time! Chat will not monitored to keep focus." +local relaxed_msg = "No more focusing, let's chat!'" + +function handle(event) + if event.EventType == "TwitchReceivedMessage" then + if event.Message == "!chatoff" or event.Message == "!chaton" then + if event.Moderator or event.Vip or event.Broadcaster then + if event.Message == "!chatoff" then + widget:send({ text = focused_msg}) + twitch:send_channel_message(focused_msg) + else + widget:send({ text = ""}) + twitch:send_channel_message(relaxed_msg) + end + else + twitch:send_channel_message(event.From .. " You are not allowed to use !chaton and !chatoff") + end + end + end +end diff --git a/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/controller.js b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/controller.js new file mode 100644 index 00000000..ad7e1895 --- /dev/null +++ b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/controller.js @@ -0,0 +1,63 @@ +let wrapper; + +if (!String.prototype.replaceAll) { + String.prototype.replaceAll = function (search, replace) { + return this.split(search).join(replace); + } +} + +function onConnect() { + document.body.classList.remove("offline") +} + +function onDisconnect() { + document.body.classList.add("offline") +} + +function onData(data) { + if (wrapper === undefined) { + wrapper = document.getElementsByClassName("wrapper")[0] + } + + let visible = wrapper.classList.contains("show") + + if (data.mode) { + if (!visible) { + wrapper.classList.remove("transition") + } + + wrapper.classList.remove("from-right") + wrapper.classList.remove("from-left") + wrapper.classList.remove("from-top") + wrapper.classList.remove("from-bottom") + + if (!wrapper.classList.contains("from-" + data.mode)) { + wrapper.classList.add("from-" + data.mode) + } + data = "" + } + else { + if (!visible) { + wrapper.classList.add("transition") + } + } + + let html = undefined; + + if (data.text) { + html = "

    " + data.text.trim() + "

    " + } + if (data.html) { + // We need to split the "needle" up as to strings, to avoid having it replaced with + // the assets path + html = data.html.replace("{{ASSETS}}", ASSETS) + } + + if (html === undefined || html === "") { + wrapper.classList.remove("show") + } + else { + wrapper.children[0].innerHTML = html + wrapper.classList.add("show") + } +} \ No newline at end of file diff --git a/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/index.html b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/index.html new file mode 100644 index 00000000..3eaa43f2 --- /dev/null +++ b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/index.html @@ -0,0 +1,15 @@ + + + + {{SLIPSTREAM_HEADERS}} + + + + + +
    +
    +
    +
    + + \ No newline at end of file diff --git a/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/style.css b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/style.css new file mode 100644 index 00000000..3230dc71 --- /dev/null +++ b/Samples/IRacing - WebWidget - ChatOn Command/WebWidgets/Textbox/style.css @@ -0,0 +1,64 @@ +html, body { + font-family: 'Open Sans', sans-serif; +} + +.offline { + background-color: #000a; +} + +.online { +} + +.wrapper { + width: 100%; + z-index: 10; + position: absolute; + overflow: hidden; +} + + .wrapper.from-left { + left: 0; + } + +wrapper.from-right { + right: 0; +} + +wrapper.from-top { + top: 0; +} + +wrapper.from-bottom { + bottom: 0; +} + +.wrapper .content { + padding: 5px 20px; + background-color: black; + color: white; + border-left: 1rem solid blue; +} + +.wrapper.transition .content { + transition: transform .5s ease; +} + +.wrapper.from-left .content { + transform: translateX(-100%); +} + +.wrapper.from-right .content { + transform: translateX(100%); +} + +.wrapper.from-top .content { + transform: translateY(-100%); +} + +.wrapper.from-bottom .content { + transform: translateY(100%); +} + +.wrapper.show .content { + transform: translateX(0); +} diff --git a/Shared/Lua/BaseInstanceThread.cs b/Shared/Lua/BaseInstanceThread.cs index 798c9c4d..0bf8aa17 100644 --- a/Shared/Lua/BaseInstanceThread.cs +++ b/Shared/Lua/BaseInstanceThread.cs @@ -104,7 +104,7 @@ public void Join() ServiceThread.Join(); } - public void Dispose() + public virtual void Dispose() { Stopping = true; if (ServiceThread?.IsAlive == true) diff --git a/Shared/Lua/BaseLuaLibrary.cs b/Shared/Lua/BaseLuaLibrary.cs index 20affa7b..4e893e6d 100644 --- a/Shared/Lua/BaseLuaLibrary.cs +++ b/Shared/Lua/BaseLuaLibrary.cs @@ -64,7 +64,7 @@ public void Dispose() protected abstract TInstance CreateInstance(ILifetimeScope scope, Parameters cfg); - protected void HandleInstance(string instanceId, Parameters cfg) + protected virtual void HandleInstance(string instanceId, Parameters cfg) { if (!Instances.ContainsKey(instanceId)) { diff --git a/Shared/Lua/SingletonLuaLibrary.cs b/Shared/Lua/SingletonLuaLibrary.cs index 86ef6f2a..91d6271b 100644 --- a/Shared/Lua/SingletonLuaLibrary.cs +++ b/Shared/Lua/SingletonLuaLibrary.cs @@ -1,11 +1,15 @@ #nullable enable using Autofac; +using NLua; using Slipstream.Shared.Helpers.StrongParameters; using Slipstream.Shared.Helpers.StrongParameters.Validators; +using System.Diagnostics; namespace Slipstream.Shared.Lua { + // This will at most create one instance with id "singleton". Can be used for eg. IRacing, where more than one instance + // doesn't make sense. public abstract class SingletonLuaLibrary : BaseLuaLibrary where TInstance : ILuaInstanceThread where TReference : ILuaReference diff --git a/Slipstream.csproj b/Slipstream.csproj index 0835cdcb..a94f8d27 100644 --- a/Slipstream.csproj +++ b/Slipstream.csproj @@ -64,6 +64,9 @@ packages\DSharpPlus.4.0.1\lib\netstandard2.0\DSharpPlus.dll + + packages\EmbedIO.3.4.3\lib\netstandard2.0\EmbedIO.dll + packages\Emzi0767.Common.2.6.2\lib\net45\Emzi0767.Common.dll @@ -125,6 +128,9 @@ packages\Serilog.Sinks.Console.4.0.0\lib\net45\Serilog.Sinks.Console.dll + + packages\Unosquare.Swan.Lite.3.0.0\lib\net461\Swan.Lite.dll + packages\System.Buffers.4.5.1\lib\net461\System.Buffers.dll @@ -267,6 +273,23 @@ + + + + + + + + + + + + + + + + + @@ -508,18 +531,22 @@ - + - - - - - - - + + + + + + + + + + + - + SettingsSingleFileGenerator @@ -530,6 +557,10 @@ Settings.settings True + + + + @@ -548,6 +579,11 @@ + + + + + diff --git a/packages.config b/packages.config index a4fb43fa..a6e399ea 100644 --- a/packages.config +++ b/packages.config @@ -3,6 +3,7 @@ + @@ -45,4 +46,5 @@ + \ No newline at end of file