diff --git a/.gitignore b/.gitignore index 1884028..a2e95b6 100644 --- a/.gitignore +++ b/.gitignore @@ -196,8 +196,6 @@ PublishScripts/ *.nupkg # NuGet Symbol Packages *.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* # except build/, which is used as an MSBuild target. !**/[Pp]ackages/build/ # Uncomment if necessary however generally it will be regenerated when needed diff --git a/Examples/DataReceiver/FindServiceDialog.cs b/Examples/DataReceiver/FindServiceDialog.cs index 5b35f61..0230f12 100644 --- a/Examples/DataReceiver/FindServiceDialog.cs +++ b/Examples/DataReceiver/FindServiceDialog.cs @@ -14,7 +14,7 @@ public class FindServiceDialog : Dialog public FindServiceDialog(ILogger logger) { - _service = new OSCQueryService( OSCQueryService.DefaultServerName + "1", OSCQueryService.DefaultPortHttp + 10, OSCQueryService.DefaultPortOsc + 10, logger); + _service = new OSCQueryService( OSCQueryService.DefaultServerName + "1", Extensions.GetAvailableTcpPort(), OSCQueryService.DefaultPortOsc, logger); Width = 45; Height = 10; @@ -47,7 +47,7 @@ public FindServiceDialog(ILogger logger) }; Add(_listView); - _service.OnProfileAdded += _ => + _service.OnOscQueryServiceAdded += _ => { RefreshListings(); }; diff --git a/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Runtime/Plugins/vrc-oscquery-lib.dll b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Runtime/Plugins/vrc-oscquery-lib.dll index 63ada4b..1248353 100644 --- a/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Runtime/Plugins/vrc-oscquery-lib.dll +++ b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Runtime/Plugins/vrc-oscquery-lib.dll @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2f3b2d7d24ea66c86e6b5d2f154c4b18ea14d325fe277e9b03b2581fc525c507 -size 30720 +oid sha256:2c7c612d47a1e44e6c582b0ed50fa6c88afd51c7c012cf5d72fe0941f2d9b703 +size 37888 diff --git a/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Receiver/ReceiverCanvas.cs b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Receiver/ReceiverCanvas.cs index 249c043..62d7e4a 100644 --- a/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Receiver/ReceiverCanvas.cs +++ b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Receiver/ReceiverCanvas.cs @@ -42,20 +42,30 @@ private void StartService() var serverName = $"{w.IngVerb().UpperCaseFirstChar()}-{w2.Word().UpperCaseFirstChar()}-{w.Abbreviation()}"; // Create OSC Server on available port - var port = VRC.OSCQuery.Extensions.GetAvailableTcpPort(); - _receiver = OscServer.GetOrCreate(port); + var port = Extensions.GetAvailableTcpPort(); + var udpPort = Extensions.GetAvailableUdpPort(); + _receiver = OscServer.GetOrCreate(udpPort); // Listen to all incoming messages _receiver.AddMonitorCallback(OnMessageReceived); - _oscQuery = new OSCQueryService( - serverName, - port, - port, - new UnityMSLogger() - ); + + var logger = new UnityMSLogger(); + + _oscQuery = new OSCQueryServiceBuilder() + .WithServiceName(serverName) + .WithTcpPort(port) + .WithOscPort(udpPort) + .WithLogger(logger) + .WithDiscovery(new MeaModDiscovery(logger)) + .StartHttpServer() + .AdvertiseOSC() + .AdvertiseOSCQuery() + .Build(); + + _oscQuery.RefreshServices(); // Show server name and chosen port - HeaderText.text = $"{serverName} listening on {port}"; + HeaderText.text = $"{serverName} running at tcp:{port} osc: {udpPort}"; } // Process incoming messages, add to message queue diff --git a/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Shared/OSCClientPlus.cs b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Shared/OSCClientPlus.cs new file mode 100644 index 0000000..920385f --- /dev/null +++ b/Examples/OSCQueryExplorer-Unity/Packages/com.vrchat.oscquery/Samples/Shared/OSCClientPlus.cs @@ -0,0 +1,24 @@ +using System.Net.Sockets; +using OscCore; + +namespace VRC.OSCQuery.Samples.Shared +{ + public class OscClientPlus : OscClient + { + /// Send a message with a string and a bool + public void Send(string address, string message, bool value) + { + string boolTag = value ? "T" : "F"; + m_Writer.Reset(); + m_Writer.Write(address); + string typeTags = $",s{boolTag}"; + m_Writer.Write(typeTags); + m_Writer.Write(message); + m_Socket.Send(m_Writer.Buffer, m_Writer.Length, SocketFlags.None); + } + + public OscClientPlus(string ipAddress, int port) : base(ipAddress, port) + { + } + } +} \ No newline at end of file diff --git a/Readme.md b/Readme.md index 38a7edf..88233e1 100644 --- a/Readme.md +++ b/Readme.md @@ -26,7 +26,7 @@ This library does not yet return limited attributes based on query strings, like ## ⚡️ Basic Use 1. Build vrc-oscquery-lib into vrc-oscquery-lib.dll and add it to your project (will make this a NuGet package once it's ready for wider use). -2. Construct a new OSCQuery service with `new OSCQueryService()`, optionally passing in the name, TCP port to use for serving HTTP, UDP port that you're using for OSC, and an ILogger if you want logs. +2. Construct a new OSCQuery service with `new OSCQueryServiceBuilder().WithDefaults().Build()`. T optionally passing in the name, TCP port to use for serving HTTP, UDP port that you're using for OSC, and an ILogger if you want logs. 3. You should now be able to visit `http://localhost:tcpPort` in a browser and see raw JSON describing an empty root node. - You can also visit `http://localhost:tcpPort?explorer` to see an OSCQuery Explorer UI for the OSCQuery service, which should be easier to navigate than the raw JSON. 4. You can also visit `http://localhost:tcpPort?HOST_INFO` to get information about the supported attributes of this OSCQuery Server. diff --git a/Tests/vrc-oscquery-tests/SpecTests.cs b/Tests/vrc-oscquery-tests/SpecTests.cs index ae28bdd..a1a1555 100644 --- a/Tests/vrc-oscquery-tests/SpecTests.cs +++ b/Tests/vrc-oscquery-tests/SpecTests.cs @@ -1,50 +1,69 @@ -using System; using System.Net; -using System.Net.Http; -using System.Threading; -using System.Threading.Tasks; using Newtonsoft.Json; using Newtonsoft.Json.Linq; namespace VRC.OSCQuery.Tests { + [TestFixture] public class SpecTests { - [SetUp] - public void Setup() + [Test] + public async Task OSCQueryServiceFluent_FromFluentBuilderWithTcpPort_ReturnsSamePort() { + int port = Extensions.GetAvailableTcpPort(); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .Build(); + + Assert.That(port, Is.EqualTo(service.TcpPort)); + + service.Dispose(); } - + [Test] - public async Task OSCQueryService_OnRandomPort_ReturnsStatusCodeAtRoot() + public async Task OSCQueryServiceFluent_OnRandomPort_ReturnsStatusCodeAtRoot() { - int targetPort = Extensions.GetAvailableTcpPort(); - var service = new OSCQueryService("test-service", targetPort); - var result = await new HttpClient().GetAsync($"http://localhost:{targetPort}"); + int port = Extensions.GetAvailableTcpPort(); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); + + var result = await new HttpClient().GetAsync($"http://localhost:{port}"); Assert.True(result.IsSuccessStatusCode); service.Dispose(); } - + [Test] - public async Task Service_WithRandomOSCPort_ReturnsPortInHostInfo() + public async Task OSCQueryServiceFluent_WithRandomOSCPort_ReturnsPortInHostInfo() { - int tcpPort = Extensions.GetAvailableTcpPort(); + int port = Extensions.GetAvailableTcpPort(); int oscPort = Extensions.GetAvailableUdpPort(); - var service = new OSCQueryService("test-service", tcpPort, oscPort); - // Get HostInfo Json - var hostInfo = await Extensions.GetHostInfo(IPAddress.Loopback, tcpPort); + + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .WithUdpPort(oscPort) + .StartHttpServer() + .Build(); + + // Get HostInfo via HTTP + var hostInfo = await Extensions.GetHostInfo(IPAddress.Loopback, port); Assert.That(hostInfo.oscPort, Is.EqualTo(oscPort)); service.Dispose(); } - + [Test] - public async Task Service_WithAddedIntProperty_ReturnsValueForThatProperty() + public async Task OSCQueryServiceFluent_WithAddedIntProperty_ReturnsValueForThatProperty() { - var random = new Random(); - int tcpPort = random.Next(9000,9999); - var service = new OSCQueryService("TestService", tcpPort); + int port = Extensions.GetAvailableTcpPort(); + + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); + int randomInt = new Random().Next(); string name = Guid.NewGuid().ToString(); @@ -54,7 +73,7 @@ public async Task Service_WithAddedIntProperty_ReturnsValueForThatProperty() Attributes.AccessValues.ReadOnly, randomInt.ToString() ); - var response = await new HttpClient().GetAsync($"http://localhost:{tcpPort}{path}"); + var response = await new HttpClient().GetAsync($"http://localhost:{port}{path}"); var responseString = await response.Content.ReadAsStringAsync(); var responseObject = JObject.Parse(responseString); @@ -65,22 +84,27 @@ public async Task Service_WithAddedIntProperty_ReturnsValueForThatProperty() } [Test] - public async Task Service_WithAddedBoolProperty_ReturnsValueForThatProperty() + public async Task OSCQueryServiceFluent_WithAddedBoolProperty_ReturnsValueForThatProperty() { var random = new Random(); - int tcpPort = random.Next(9000,9999); - var service = new OSCQueryService("TestService", tcpPort); - + int port = Extensions.GetAvailableTcpPort(); + + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); + string name = Guid.NewGuid().ToString(); string path = $"/{name}"; - service.AddEndpoint( + service.AddEndpoint( path, Attributes.AccessValues.ReadOnly, false.ToString() ); service.SetValue(path, "true"); - var response = await new HttpClient().GetAsync($"http://localhost:{tcpPort}{path}"); + var response = await new HttpClient().GetAsync($"http://localhost:{port}{path}"); + var responseString = await response.Content.ReadAsStringAsync(); var responseObject = JObject.Parse(responseString); @@ -88,14 +112,16 @@ public async Task Service_WithAddedBoolProperty_ReturnsValueForThatProperty() service.Dispose(); } - + [Test] - public async Task Service_WithMultiplePaths_ReturnsValuesForAllChildren() + public async Task OSCQueryServiceFluent_WithMultiplePaths_ReturnsValuesForAllChildren() { var r = new Random(); - int tcpPort = Extensions.GetAvailableTcpPort(); - var udpPort = Extensions.GetAvailableUdpPort(); - var service = new OSCQueryService("TestService", tcpPort, udpPort); + int port = Extensions.GetAvailableTcpPort(); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); int randomInt1 = r.Next(); int randomInt2 = r.Next(); @@ -118,7 +144,7 @@ public async Task Service_WithMultiplePaths_ReturnsValuesForAllChildren() randomInt2.ToString() ); - var response = await new HttpClient().GetAsync($"http://localhost:{tcpPort}/"); + var response = await new HttpClient().GetAsync($"http://localhost:{port}/"); Assert.True(response.IsSuccessStatusCode); @@ -132,12 +158,14 @@ public async Task Service_WithMultiplePaths_ReturnsValuesForAllChildren() } [Test] - public void GetOSCTree_ReturnsExpectedValues() + public void OSCQueryServiceFluent_GetOSCTree_ReturnsExpectedValues() { var r = new Random(); int tcpPort = Extensions.GetAvailableTcpPort(); - var udpPort = Extensions.GetAvailableUdpPort(); - var service = new OSCQueryService("TestService", tcpPort, udpPort); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(tcpPort) + .StartHttpServer() + .Build(); int randomInt1 = r.Next(); int randomInt2 = r.Next(); @@ -174,13 +202,16 @@ public void GetOSCTree_ReturnsExpectedValues() service.Dispose(); } - + [Test] - public async Task Service_AfterAddingGrandChildNode_HasNodesForEachAncestor() + public async Task OSCQueryServiceFluent_AfterAddingGrandChildNode_HasNodesForEachAncestor() { var port = Extensions.GetAvailableTcpPort(); - var service = new OSCQueryService(Guid.NewGuid().ToString(), port); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); string fullPath = "/foo/bar/baz"; @@ -194,25 +225,29 @@ public async Task Service_AfterAddingGrandChildNode_HasNodesForEachAncestor() var result = JsonConvert.DeserializeObject(responseString); Assert.NotNull(result.Contents["foo"].Contents["bar"].Contents["baz"]); - - Assert.Pass(); } - + [Test] - public async Task Service_WithRequestForFavicon_ReturnsSuccess() + public async Task OSCQueryServiceFluent_WithRequestForFavicon_ReturnsSuccess() { var port = Extensions.GetAvailableTcpPort(); - var service = new OSCQueryService("TestService", port); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); var response = await new HttpClient().GetAsync($"http://localhost:{port}/favicon.ico"); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK)); } [Test] - public async Task Service_After404_CanReturnParameterValue() + public async Task OSCQueryServiceFluent_After404_CanReturnParameterValue() { var port = Extensions.GetAvailableTcpPort(); - var service = new OSCQueryService("TestService", port); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); var response = await new HttpClient().GetAsync($"http://localhost:{port}/whatever"); Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.NotFound)); @@ -237,27 +272,47 @@ public async Task Service_After404_CanReturnParameterValue() service.Dispose(); } - + [Test] - public void Service_GivenInvalidPathToAdd_ReturnsFalse() + public void OSCQueryServiceFluent_GivenInvalidPathToAdd_ReturnsFalse() { var port = Extensions.GetAvailableTcpPort(); - var service = new OSCQueryService("TestService", port); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); var result = service.AddEndpoint("invalid", Attributes.AccessValues.ReadWrite); Assert.False(result); } [Test] - public void Service_RootNode_HasFullPathWithSlash() + public void OSCQueryServiceFluent_RootNode_HasFullPathWithSlash() { var port = Extensions.GetAvailableTcpPort(); - var udpPort = Extensions.GetAvailableUdpPort(); - var service = new OSCQueryService(Guid.NewGuid().ToString(), port, udpPort); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .StartHttpServer() + .Build(); + var tree = Task.Run(() => Extensions.GetOSCTree(IPAddress.Loopback, port)).GetAwaiter().GetResult(); Assert.NotNull(tree); - - var rootNode = tree.GetNodeWithPath("/"); - Assert.That("/", Is.EqualTo(rootNode.FullPath)); + string rootPath = "/"; + var rootNode = tree.GetNodeWithPath(rootPath); + Assert.That(rootPath, Is.EqualTo(rootNode.FullPath)); + } + + [Test] + public void OSCQueryServiceFluent_WithUdpPort_ReturnsSamePort() + { + var port = Extensions.GetAvailableTcpPort(); + var oscPort = Extensions.GetAvailableUdpPort(); + var service = new OSCQueryServiceBuilder() + .WithTcpPort(port) + .WithUdpPort(oscPort) + .StartHttpServer() + .Build(); + + Assert.That(oscPort, Is.EqualTo(service.OscPort)); } } diff --git a/getting-started.md b/getting-started.md index 822bc39..a553127 100644 --- a/getting-started.md +++ b/getting-started.md @@ -6,30 +6,70 @@ To use OSCQuery in your project, you first need to integrate the library. This c OSCQuery can itself neither receive nor send OSC, its purpose is to allow OSC services to find other services and to communicate to them what they can do. If you are looking to send OSC you can use any OSC library, like **[OscCore](https://github.com/vrchat/OscCore)** for Unity projects and **[Rug.Osc](https://bitbucket.org/rugcode/rug.osc/src/master/)** for .NET. ## Starting the OscQueryService -Before actually starting up the service, you first need two ports: -- **UDP Port:** This is where your application will receive OSC messages. -- **TCP Port:** This port will host all the information about your service, accessible through HTTP requests. -You can specify your own ports or use this Utility method to find free ones: -```csharp -var udpPort = Extensions.GetAvailableUdpPort(); -var tcpPort = Extensions.GetAvailableTcpPort(); -``` +The OSCQueryServiceBuilder has a [Fluent Interface](https://en.wikipedia.org/wiki/Fluent_interface) for creating and configuring the OSCQuery service. To start a service that does "all the things" using the typical settings, you can call: -You can start the OSCQuery Service by passing a unique name along with these ports into its constructor: ```csharp -var queryService = new OSCQueryService("MyService", tcpPort, udpPort); +var oscQuery = new OSCQueryServiceBuilder().Build(); +``` +This creates the Service, starts up the HTTP server on "localhost", starts up the Discovery system using the default library ([MeaMod.DNS](https://github.com/meamod/MeaMod.DNS)), and advertises both the OSCQuery and OSC services on the local network. + +The format is always `new OSCQueryServiceBuilder()`, followed by all the things you want to add, finished with `Build()`, which returns the OSCQueryService you've just defined. + +### Fluent Interface Options +There's a lot of options you _can_ configure if you want more control over what happens. The additional methods are listed below. Note that if you do not add any fluent options, then `WithDefaults()` is called for you automatically. + +* WithDefaults() + * Sets up Discovery, Advertising and HTTP serving using default names and ports. +* WithTcpPort(int port) + * Set the TCP port you want to use for serving the HTTP endpoints. Defaults to any available open TCP port. +* WithUdpPort(int port) + * Set the UDP port on which you're going to run an OSC Server. Defaults to any available open UDP port. +* WithHostIP(IPAddress address) + * Set the address to use for serving HTTP. Defaults to localhost - note that serving to 0.0.0.0 on Windows is not allowed by default without jumping through some security hoops on each installed machine (this works on Android, though that implementation is not yet released). +* WithServiceName(string name) + * Sets the name that your service will use when advertising. Defaults to "OSCQueryService" +* WithLogger(ILogger logger) + * Sets the target logger, which you can implement if you want to specifically log to the Console, or to a Unity textfield, or anything else for which you write an ILogger implementation. +* WithMiddleware(Func middleware) + * Adds Middleware to your HTTP server if you want to serve up additional pages or content. +* WithDiscovery(IDiscovery d) + * Sets the class to be used for advertising and discovering your service. Only for advanced users or other platforms - the Android-compatible implementation uses the native [NsdManager](https://developer.android.com/reference/android/net/nsd/NsdManager) class, for example (still in development). +* StartHttpSever() + * Serves the HTTP endpoints required by the OSCQuery spec. You don't _have_ to call this if you're just using this library to find and receive data from other OSCQuery services, and not serving data yourself, but your app will be out-of-spec and may not play well with others. +* AdvertiseOSC() + * Broadcasts the info of the OSC Service on the local network. +* AdvertiseOSCQuery() + * Broadcasts the info of the OSCQuery Service on the local network. +* AddListenerForServiceType(Action\ listener, OSCQueryServiceProfile.ServiceType type) + * Adds a listener which will be sent OSCQueryServiceProfiles for newly-discovered OSC or OSCQuery services. + +You can can add these onto `.WithDefaults()` if you want _almost_ all the defaults. For example, this code will have all the defaults, but find the first available TCP port instead of 8060, and uses the name "MyService" instead of "OSCQueryService". + +```csharp +var oscQuery = new OSCQueryServiceBuilder() + .WithDefaults() + .WithTcpPort(Extensions.GetAvailableTcpPort()) + .WithServiceName("MyService") + .Build(); ``` -*Creating the Service also allows middleware or a logger as optional parameters if needed.* +## A Simple Example A minimal example for a working OSCQuery Service could look like this: ```csharp -var udpPort = Extensions.GetAvailableUdpPort(); + var tcpPort = Extensions.GetAvailableTcpPort(); -var queryService = new OSCQueryService("MyService", tcpPort, udpPort); +var udpPort = Extensions.GetAvailableUdpPort(); + +var oscQuery = new OSCQueryServiceBuilder() + .WithDefaults() + .WithTcpPort(tcpPort) + .WithUdpPort(udpPort)) + .WithServiceName("MyService") + .Build(); // Manually logging the ports to see them without a logger -Console.WriteLine($"Started QueryService at TCP {tcpPort}, UDP {udpPort}"); +Console.WriteLine($"Started OSCQueryService at TCP {tcpPort}, UDP {udpPort}"); // Stops the program from ending until a key is pressed Console.ReadKey(); diff --git a/vrc-oscquery-lib/HostInfo.cs b/vrc-oscquery-lib/HostInfo.cs index fb9c7f3..b185164 100644 --- a/vrc-oscquery-lib/HostInfo.cs +++ b/vrc-oscquery-lib/HostInfo.cs @@ -21,7 +21,7 @@ public class HostInfo public string oscIP; [JsonProperty(Keys.OSC_PORT)] - public int oscPort; + public int oscPort = OSCQueryService.DefaultPortOsc; [JsonProperty(Keys.OSC_TRANSPORT)] public string oscTransport = Keys.OSC_TRANSPORT_UDP; diff --git a/vrc-oscquery-lib/OSCQueryHttpServer.cs b/vrc-oscquery-lib/OSCQueryHttpServer.cs new file mode 100644 index 0000000..44b1d92 --- /dev/null +++ b/vrc-oscquery-lib/OSCQueryHttpServer.cs @@ -0,0 +1,219 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace VRC.OSCQuery +{ + public class OSCQueryHttpServer : IDisposable + { + private HttpListener _listener; + private bool _shouldProcessHttp; + + // HTTP Middleware + private List> _preMiddleware; + private List> _middleware = new List>(); // constructed here to ensure it exists even if empty + private List> _postMiddleware; + + private ILogger Logger; + + private OSCQueryService _oscQuery; + + public OSCQueryHttpServer(OSCQueryService oscQueryService, ILogger logger) + { + _oscQuery = oscQueryService; + Logger = logger; + + // Create and start HTTPListener + _listener = new HttpListener(); + + string prefix = $"http://{_oscQuery.HostIP}:{_oscQuery.TcpPort}/"; + _listener.Prefixes.Add(prefix); + _preMiddleware = new List> + { + HostInfoMiddleware + }; + _postMiddleware = new List> + { + FaviconMiddleware, + ExplorerMiddleware, + RootNodeMiddleware + }; + _listener.Start(); + _listener.BeginGetContext(HttpListenerLoop, _listener); + _shouldProcessHttp = true; + } + + public void AddMiddleware(Func middleware) + { + _middleware.Add(middleware); + } + + /// + /// Process and responds to incoming HTTP queries + /// + private void HttpListenerLoop(IAsyncResult result) + { + if (!_shouldProcessHttp) return; + + var context = _listener.EndGetContext(result); + _listener.BeginGetContext(HttpListenerLoop, _listener); + Task.Run(async () => + { + // Pre middleware + foreach (var middleware in _preMiddleware) + { + var move = false; + await middleware(context, () => move = true); + if (!move) return; + } + + // User middleware + foreach (var middleware in _middleware) + { + var move = false; + await middleware(context, () => move = true); + if (!move) return; + } + + // Post middleware + foreach (var middleware in _postMiddleware) + { + var move = false; + await middleware(context, () => move = true); + if (!move) return; + } + }).ConfigureAwait(false); + } + + private async Task HostInfoMiddleware(HttpListenerContext context, Action next) + { + if (!context.Request.RawUrl.Contains(Attributes.HOST_INFO)) + { + next(); + return; + } + + try + { + // Serve Host Info for requests with "HOST_INFO" in them + var hostInfoString = _oscQuery.HostInfo.ToString(); + + // Send Response + context.Response.Headers.Add("pragma:no-cache"); + + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = hostInfoString.Length; + using (var sw = new StreamWriter(context.Response.OutputStream)) + { + await sw.WriteAsync(hostInfoString); + await sw.FlushAsync(); + } + } + catch (Exception e) + { + Logger.LogError($"Could not construct and send Host Info: {e.Message}"); + } + } + + private static string _pathToResources; + + private static string PathToResources + { + get + { + if (string.IsNullOrWhiteSpace(_pathToResources)) + { + var dllLocation = Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location); + _pathToResources = Path.Combine(new DirectoryInfo(dllLocation).Parent?.FullName ?? string.Empty, "Resources"); + } + return _pathToResources; + } + } + private async Task ExplorerMiddleware(HttpListenerContext context, Action next) + { + if (!context.Request.Url.Query.Contains(Attributes.EXPLORER)) + { + next(); + return; + } + + var path = Path.Combine(PathToResources, "OSCQueryExplorer.html"); + if (!File.Exists(path)) + { + Logger.LogError($"Cannot find file at {path} to serve."); + next(); + return; + } + await Extensions.ServeStaticFile(path, "text/html", context); + } + + private async Task FaviconMiddleware(HttpListenerContext context, Action next) + { + if (!context.Request.RawUrl.Contains("favicon.ico")) + { + next(); + return; + } + + var path = Path.Combine(PathToResources, "favicon.ico"); + if (!File.Exists(path)) + { + Logger.LogError($"Cannot find file at {path} to serve."); + next(); + return; + } + + await Extensions.ServeStaticFile(path, "image/x-icon", context); + } + + private async Task RootNodeMiddleware(HttpListenerContext context, Action next) + { + var path = context.Request.Url.LocalPath; + var matchedNode = _oscQuery.RootNode.GetNodeWithPath(path); + if (matchedNode == null) + { + const string err = "OSC Path not found"; + + context.Response.StatusCode = (int)HttpStatusCode.NotFound; + context.Response.ContentLength64 = err.Length; + using (var sw = new StreamWriter(context.Response.OutputStream)) + { + await sw.WriteAsync(err); + await sw.FlushAsync(); + } + + return; + } + + var stringResponse = matchedNode.ToString(); + + // Send Response + context.Response.Headers.Add("pragma:no-cache"); + + context.Response.ContentType = "application/json"; + context.Response.ContentLength64 = stringResponse.Length; + using (var sw = new StreamWriter(context.Response.OutputStream)) + { + await sw.WriteAsync(stringResponse); + await sw.FlushAsync(); + } + } + + public void Dispose() + { + _shouldProcessHttp = false; + // HttpListener teardown + if (_listener != null) + { + if (_listener.IsListening) + _listener.Stop(); + + _listener.Close(); + } + + } + } +} \ No newline at end of file diff --git a/vrc-oscquery-lib/OSCQueryService.cs b/vrc-oscquery-lib/OSCQueryService.cs index 5e545bf..7502dec 100644 --- a/vrc-oscquery-lib/OSCQueryService.cs +++ b/vrc-oscquery-lib/OSCQueryService.cs @@ -1,11 +1,8 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Linq; using System.Net; +using System.Net.Sockets; using System.Threading.Tasks; -using MeaMod.DNS.Model; -using MeaMod.DNS.Multicast; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; @@ -13,376 +10,155 @@ namespace VRC.OSCQuery { public class OSCQueryService : IDisposable { - // Constants - public const int DefaultPortHttp = 8080; - public const int DefaultPortOsc = 9000; - public const string DefaultServerName = "OSCQueryService"; - - // Services - private static readonly string _localOscUdpServiceName = $"{Attributes.SERVICE_OSC_UDP}.local"; - private static readonly string _localOscJsonServiceName = $"{Attributes.SERVICE_OSCJSON_TCP}.local"; - - // Zeroconf - private ServiceProfile _zeroconfService; - private ServiceProfile _oscService; - private MulticastService _mdns; - private ServiceDiscovery _discovery; - public event Action OnProfileAdded; - public event Action OnOscServiceAdded; - public event Action OnOscQueryServiceAdded; - - // Store discovered services - private readonly HashSet _oscQueryServices = new HashSet(); - private readonly HashSet _oscServices = new HashSet(); + #region Fluent Pattern Implementation - // HTTP Server - private HttpListener _listener; - private bool _shouldProcessHttp; + public OSCQueryService() {} // Need to have this empty constructor for the Builder - // HTTP Middleware - private List> _preMiddleware; - private List> _middleware; - private List> _postMiddleware; - - // Misc - private OSCQueryRootNode _rootNode; - private HostInfo _hostInfo; - public static ILogger Logger; - private readonly HashSet _matchedNames = new HashSet() { - _localOscUdpServiceName, _localOscJsonServiceName - }; + public int TcpPort { get; set; } = DefaultPortHttp; - /// - /// Creates an OSCQueryService which can track OSC endpoints in the enclosing program as well as find other OSCQuery-compatible services on the link-local network - /// - /// Server name to use, default is "OSCQueryService" - /// TCP port on which to serve OSCQuery info, default is 8080 - /// UDP Port at which the OSC Server can be reached, default is 9000 - /// Optional logger which will be used for logs generated within this class. Will log to Null if not set. - /// Optional set of middleware to be injected into the HTTP server. Middleware will be executed in the order they are passed in. - public OSCQueryService(string serverName = DefaultServerName, int httpPort = DefaultPortHttp, int oscPort = DefaultPortOsc, ILogger logger = null, params Func[] middleware) + public int OscPort { - Logger = logger ?? new NullLogger(); - Initialize(serverName); - StartOSCQueryService(serverName, httpPort, middleware); - if (oscPort > 0) - { - AdvertiseOSCService(serverName, oscPort); - } - RefreshServices(); + get => HostInfo.oscPort; + set => HostInfo.oscPort = value; } - public void Initialize(string serverName = DefaultServerName) - { - // Create HostInfo object - _hostInfo = new HostInfo() - { - name = serverName, - }; - - _mdns = new MulticastService(); - _mdns.UseIpv6 = false; - _mdns.IgnoreDuplicateMessages = true; - - _discovery = new ServiceDiscovery(_mdns); - - // Query for OSC and OSCQuery services on every network interface - _mdns.NetworkInterfaceDiscovered += (s, e) => - { - RefreshServices(); - }; - - // Callback invoked when the above query is answered - _mdns.AnswerReceived += OnRemoteServiceInfo; - _mdns.Start(); - } + public string ServerName { + get => HostInfo.name; + set => HostInfo.name = value; + } + + public IPAddress HostIP { get; set; } = IPAddress.Loopback; + + public static ILogger Logger { get; set; } = new NullLogger(); - public void StartOSCQueryService(string serverName, int httpPort = -1, params Func[] middleware) + public void AddMiddleware(Func middleware) { - BuildRootResponse(); - - // Use the provided port or grab a new one - httpPort = httpPort == -1 ? Extensions.GetAvailableTcpPort() : httpPort; - - // Advertise OSCJSON service - _zeroconfService = new ServiceProfile(serverName, Attributes.SERVICE_OSCJSON_TCP, (ushort)httpPort, new[] { IPAddress.Loopback }); - _discovery.Advertise(_zeroconfService); - Logger.LogInformation($"Advertising TCP Service {serverName} as {Attributes.SERVICE_OSCJSON_TCP} on {httpPort}"); - - // Create and start HTTPListener - _listener = new HttpListener(); - _listener.Prefixes.Add($"http://localhost:{httpPort}/"); - _listener.Prefixes.Add($"http://127.0.0.1:{httpPort}/"); - _preMiddleware = new List> - { - HostInfoMiddleware - }; - if (middleware != null) - { - _middleware = middleware.ToList(); - } - _postMiddleware = new List> - { - FaviconMiddleware, - ExplorerMiddleware, - RootNodeMiddleware - }; - _listener.Start(); - _listener.BeginGetContext(HttpListenerLoop, _listener); - _shouldProcessHttp = true; + _http.AddMiddleware(middleware); } - - public void AdvertiseOSCService(string serverName, int oscPort = -1) + + public void SetDiscovery(IDiscovery discovery) { - _hostInfo.oscPort = oscPort; - _hostInfo.oscIP = IPAddress.Loopback.ToString(); - _oscService = new ServiceProfile(serverName, Attributes.SERVICE_OSC_UDP, (ushort)oscPort, new[] { IPAddress.Loopback }); - _discovery.Advertise(_oscService); - Logger.LogInformation($"Advertising OSC Service {serverName} as {Attributes.SERVICE_OSC_UDP} on {oscPort}"); + _discovery = discovery; + _discovery.OnOscQueryServiceAdded += profile => OnOscQueryServiceAdded?.Invoke(profile); + _discovery.OnOscServiceAdded += profile => OnOscServiceAdded?.Invoke(profile); } - public void RefreshServices() - { - _mdns.SendQuery(_localOscUdpServiceName); - _mdns.SendQuery(_localOscJsonServiceName); - } + #endregion - /// - /// Callback invoked when an mdns Service provides information about itself - /// - /// - /// Event Data with info from queried Service - private void OnRemoteServiceInfo(object sender, MessageEventArgs eventArgs) + private IPAddress _localIp; + private IPAddress LocalIp { - var response = eventArgs.Message; - - try + get { - // Check whether this service matches OSCJSON or OSC services for which we're looking - var hasMatch = response.Answers.Any(record => _matchedNames.Contains(record?.CanonicalName)); - if (!hasMatch) - { - return; - } - - // Get the name and SRV Record of the service - var name = response.Answers.First(r => _matchedNames.Contains(r?.CanonicalName)).CanonicalName; - var srvRecord = response.AdditionalRecords.OfType().FirstOrDefault(); - if (srvRecord == default) - { - Logger.LogWarning($"Found the matching service {name}, but it doesn't have an SRVRecord, can't proceed."); - return; - } - - // Get the rest of the items we need to track this service - var port = srvRecord.Port; - var domainName = srvRecord.Name.Labels; - var instanceName = domainName[0]; - - var serviceName = string.Join(".", domainName.Skip(1).SkipLast(1)); - var ips = response.AdditionalRecords.OfType().Select(r => r.Address); - - var ipAddressList = ips.ToList(); - var profile = new ServiceProfile(instanceName, serviceName, srvRecord.Port, ipAddressList); - - // If this is an OSC service, add it to the OSC collection - if (string.Compare(name, _localOscUdpServiceName, StringComparison.Ordinal) == 0 && profile != _oscService) + if (_localIp == null) { - // Make sure there's not already a service with the same name - if (_oscServices.All(p => p.name != profile.InstanceName)) + using (Socket socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, 0)) { - var p = new OSCQueryServiceProfile(instanceName, ipAddressList.First(), port, OSCQueryServiceProfile.ServiceType.OSC); - _oscServices.Add(p); - OnProfileAdded?.Invoke(profile); - OnOscServiceAdded?.Invoke(p); - Logger.LogInformation($"Found match {name} on port {port}"); + socket.Connect("8.8.8.8", 65530); + IPEndPoint endPoint = socket.LocalEndPoint as IPEndPoint; + _localIp = endPoint.Address; } } - // If this is an OSCQuery service, add it to the OSCQuery collection - else if (string.Compare(name, _localOscJsonServiceName, StringComparison.Ordinal) == 0 && (_zeroconfService != null && profile.FullyQualifiedName != _zeroconfService.FullyQualifiedName)) - { - // Make sure there's not already a service with the same name - if (_oscQueryServices.All(p => p.name != profile.InstanceName)) - { - var p = new OSCQueryServiceProfile(instanceName, ipAddressList.First(), port, OSCQueryServiceProfile.ServiceType.OSCQuery); - _oscQueryServices.Add(p); - OnProfileAdded?.Invoke(profile); - OnOscQueryServiceAdded?.Invoke(p); - Logger.LogInformation($"Found match {name} on port {port}"); - } - } - } - catch (Exception e) - { - // Using a non-error log level because we may have just found a non-matching service - Logger.LogInformation($"Could not parse answer from {eventArgs.RemoteEndPoint}: {e.Message}"); + + return _localIp; } } - public HashSet GetOSCQueryServices() => _oscQueryServices; - public HashSet GetOSCServices() => _oscServices; + // Constants + public const int DefaultPortHttp = 8060; + public const int DefaultPortOsc = 9000; + public const string DefaultServerName = "OSCQueryService"; + + // Services + public static readonly string _localOscUdpServiceName = $"{Attributes.SERVICE_OSC_UDP}.local"; + public static readonly string _localOscJsonServiceName = $"{Attributes.SERVICE_OSCJSON_TCP}.local"; - public void SetValue(string address, string value) - { - var target = _rootNode.GetNodeWithPath(address); - if (target == null) - { - // add this node - target = _rootNode.AddNode(new OSCQueryNode(address)); - } - - target.Value = value; - } + public static readonly HashSet MatchedNames = new HashSet() { + _localOscUdpServiceName, _localOscJsonServiceName + }; - /// - /// Process and responds to incoming HTTP queries - /// - private void HttpListenerLoop(IAsyncResult result) - { - if (!_shouldProcessHttp) return; - - var context = _listener.EndGetContext(result); - _listener.BeginGetContext(HttpListenerLoop, _listener); - Task.Run(async () => - { - // Pre middleware - foreach (var middleware in _preMiddleware) - { - var move = false; - await middleware(context, () => move = true); - if (!move) return; - } - - // User middleware - foreach (var middleware in _middleware) - { - var move = false; - await middleware(context, () => move = true); - if (!move) return; - } - - // Post middleware - foreach (var middleware in _postMiddleware) - { - var move = false; - await middleware(context, () => move = true); - if (!move) return; - } - }).ConfigureAwait(false); - } + private IDiscovery _discovery; + + #region Wrapped Calls for Discovery Service + + public event Action OnOscServiceAdded; + public event Action OnOscQueryServiceAdded; + public HashSet GetOSCQueryServices() => _discovery.GetOSCQueryServices(); + public HashSet GetOSCServices() => _discovery.GetOSCServices(); + + #endregion + + // HTTP Server + OSCQueryHttpServer _http; - private async Task HostInfoMiddleware(HttpListenerContext context, Action next) + // Lazy HostInfo + private HostInfo _hostInfo; + public HostInfo HostInfo { - if (!context.Request.RawUrl.Contains(Attributes.HOST_INFO)) - { - next(); - return; - } - - try + get { - // Serve Host Info for requests with "HOST_INFO" in them - var hostInfoString = _hostInfo.ToString(); - - // Send Response - context.Response.Headers.Add("pragma:no-cache"); - - context.Response.ContentType = "application/json"; - context.Response.ContentLength64 = hostInfoString.Length; - using (var sw = new StreamWriter(context.Response.OutputStream)) + if (_hostInfo == null) { - await sw.WriteAsync(hostInfoString); - await sw.FlushAsync(); + // Create HostInfo object + _hostInfo = new HostInfo() + { + name = DefaultServerName, + oscPort = DefaultPortOsc, + oscIP = IPAddress.Loopback.ToString() + }; } - } - catch (Exception e) - { - Logger.LogError($"Could not construct and send Host Info: {e.Message}"); + return _hostInfo; } } - private static string _pathToResources; - - private static string PathToResources + // Lazy RootNode + private OSCQueryRootNode _rootNode; + public OSCQueryRootNode RootNode { get { - if (string.IsNullOrWhiteSpace(_pathToResources)) + if (_rootNode == null) { - var dllLocation = Path.Combine(System.Reflection.Assembly.GetExecutingAssembly().Location); - _pathToResources = Path.Combine(new DirectoryInfo(dllLocation).Parent?.FullName ?? string.Empty, "Resources"); + BuildRootNode(); } - return _pathToResources; - } - } - private async Task ExplorerMiddleware(HttpListenerContext context, Action next) - { - if (!context.Request.Url.Query.Contains(Attributes.EXPLORER)) - { - next(); - return; - } - var path = Path.Combine(PathToResources, "OSCQueryExplorer.html"); - if (!File.Exists(path)) - { - Logger.LogError($"Cannot find file at {path} to serve."); - next(); - return; + return _rootNode; } - await Extensions.ServeStaticFile(path, "text/html", context); } - private async Task FaviconMiddleware(HttpListenerContext context, Action next) + public void StartHttpServer() { - if (!context.Request.RawUrl.Contains("favicon.ico")) - { - next(); - return; - } - - var path = Path.Combine(PathToResources, "favicon.ico"); - if (!File.Exists(path)) - { - Logger.LogError($"Cannot find file at {path} to serve."); - next(); - return; - } - - await Extensions.ServeStaticFile(path, "image/x-icon", context); + _http = new OSCQueryHttpServer(this, Logger); } - - private async Task RootNodeMiddleware(HttpListenerContext context, Action next) + + public void AdvertiseOSCQueryService(string serviceName, int port = -1) { - var path = context.Request.Url.LocalPath; - var matchedNode = _rootNode.GetNodeWithPath(path); - if (matchedNode == null) - { - const string err = "OSC Path not found"; + // Get random available port if none was specified + port = port < 0 ? Extensions.GetAvailableTcpPort() : port; + _discovery.Advertise(new OSCQueryServiceProfile(serviceName, LocalIp, port, OSCQueryServiceProfile.ServiceType.OSCQuery)); + } - context.Response.StatusCode = (int)HttpStatusCode.NotFound; - context.Response.ContentLength64 = err.Length; - using (var sw = new StreamWriter(context.Response.OutputStream)) - { - await sw.WriteAsync(err); - await sw.FlushAsync(); - } + public void AdvertiseOSCService(string serviceName, int port = -1) + { + // Get random available port if none was specified + port = port < 0 ? Extensions.GetAvailableUdpPort() : port; + _discovery.Advertise(new OSCQueryServiceProfile(serviceName, LocalIp, port, OSCQueryServiceProfile.ServiceType.OSC)); + } - return; - } + public void RefreshServices() + { + _discovery.RefreshServices(); + } - var stringResponse = matchedNode.ToString(); - - // Send Response - context.Response.Headers.Add("pragma:no-cache"); - - context.Response.ContentType = "application/json"; - context.Response.ContentLength64 = stringResponse.Length; - using (var sw = new StreamWriter(context.Response.OutputStream)) + public void SetValue(string address, string value) + { + var target = RootNode.GetNodeWithPath(address); + if (target == null) { - await sw.WriteAsync(stringResponse); - await sw.FlushAsync(); + // add this node + target = RootNode.AddNode(new OSCQueryNode(address)); } + target.Value = value; } /// @@ -404,13 +180,13 @@ public bool AddEndpoint(string path, string oscTypeString, Attributes.AccessValu return false; } - if (_rootNode.GetNodeWithPath(path) != null) + if (RootNode.GetNodeWithPath(path) != null) { Logger.LogWarning($"Path already exists, skipping: {path}"); return false; } - _rootNode.AddNode(new OSCQueryNode(path) + RootNode.AddNode(new OSCQueryNode(path) { Access = accessValues, Description = description, @@ -439,13 +215,13 @@ public bool AddEndpoint(string path, Attributes.AccessValues accessValues, st public bool RemoveEndpoint(string path) { // Exit early if no matching path is found - if (_rootNode?.GetNodeWithPath(path) == null) + if (RootNode.GetNodeWithPath(path) == null) { Logger.LogWarning($"No endpoint found for {path}"); return false; } - _rootNode.RemoveNode(path); + RootNode.RemoveNode(path); return true; } @@ -453,7 +229,7 @@ public bool RemoveEndpoint(string path) /// /// Constructs the response the server will use for HOST_INFO queries /// - private void BuildRootResponse() + private void BuildRootNode() { _rootNode = new OSCQueryRootNode() { @@ -465,21 +241,9 @@ private void BuildRootResponse() public void Dispose() { - _shouldProcessHttp = false; - - // HttpListener teardown - if (_listener != null) - { - if (_listener.IsListening) - _listener.Stop(); - - _listener.Close(); - } - - // Service Teardown - _discovery.Dispose(); - _mdns.Stop(); - + _http?.Dispose(); + _discovery?.Dispose(); + GC.SuppressFinalize(this); } @@ -487,6 +251,63 @@ public void Dispose() { Dispose(); } + + #region Obsolete Functions - Remove before Open Beta + + /// + /// Creates an OSCQueryService which can track OSC endpoints in the enclosing program as well as find other OSCQuery-compatible services on the link-local network + /// + /// Server name to use, default is "OSCQueryService" + /// TCP port on which to serve OSCQuery info, default is 8080 + /// UDP Port at which the OSC Server can be reached, default is 9000 + /// Optional logger which will be used for logs generated within this class. Will log to Null if not set. + /// Optional set of middleware to be injected into the HTTP server. Middleware will be executed in the order they are passed in. + [Obsolete("Use the Fluent Interface so we can remove this constructor", false)] + public OSCQueryService(string serverName = DefaultServerName, int httpPort = DefaultPortHttp, int oscPort = DefaultPortOsc, ILogger logger = null, params Func[] middleware) + { + if (logger != null) Logger = logger; + + OscPort = oscPort; + TcpPort = httpPort; + + Initialize(serverName); + StartOSCQueryService(serverName, httpPort, middleware); + if (oscPort != DefaultPortOsc) + { + AdvertiseOSCService(serverName, oscPort); + } + RefreshServices(); + } + + [Obsolete("Use the Fluent Interface so we can remove this function", false)] + public void Initialize(string serverName = DefaultServerName) + { + ServerName = serverName; + SetDiscovery(new MeaModDiscovery(Logger)); + } + + [Obsolete("Use the Fluent Interface instead of this combo function", false)] + public void StartOSCQueryService(string serverName, int httpPort = -1, params Func[] middleware) + { + ServerName = serverName; + + // Use the provided port or grab a new one + TcpPort = httpPort == -1 ? Extensions.GetAvailableTcpPort() : httpPort; + + // Add all provided middleware + if (middleware != null) + { + foreach (var newMiddleware in middleware) + { + AddMiddleware(newMiddleware); + } + } + + AdvertiseOSCQueryService(serverName, TcpPort); + StartHttpServer(); + } + + #endregion } } \ No newline at end of file diff --git a/vrc-oscquery-lib/OSCQueryServiceBuilder.cs b/vrc-oscquery-lib/OSCQueryServiceBuilder.cs new file mode 100644 index 0000000..653c04d --- /dev/null +++ b/vrc-oscquery-lib/OSCQueryServiceBuilder.cs @@ -0,0 +1,122 @@ +using System; +using System.Net; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace VRC.OSCQuery +{ + public class OSCQueryServiceBuilder + { + private readonly OSCQueryService _service = new OSCQueryService(); + public OSCQueryService Build() + { + if (!_customStartup) + { + WithDefaults(); + } + return _service; + } + + // flag to know whether the user has set something custom + private bool _customStartup = false; + + /// + /// Starts HTTP Server, Advertises OSCQuery & OSC, Uses default library for Network Discovery + /// + /// OSCQueryServiceBuilder for Fluent construction + public OSCQueryServiceBuilder WithDefaults() + { + _customStartup = true; + StartHttpServer(); + AdvertiseOSCQuery(); + AdvertiseOSC(); + WithDiscovery(new MeaModDiscovery()); + return this; + } + + public OSCQueryServiceBuilder WithTcpPort(int port) + { + _customStartup = true; + _service.TcpPort = port; + return this; + } + + public OSCQueryServiceBuilder WithUdpPort(int port) + { + _customStartup = true; + _service.OscPort = port; + return this; + } + + public OSCQueryServiceBuilder WithHostIP(IPAddress address) + { + _customStartup = true; + _service.HostIP = address; + return this; + } + + public OSCQueryServiceBuilder StartHttpServer() + { + _customStartup = true; + _service.StartHttpServer(); + return this; + } + + public OSCQueryServiceBuilder WithServiceName(string name) + { + _customStartup = true; + _service.ServerName = name; + return this; + } + + public OSCQueryServiceBuilder WithLogger(ILogger logger) + { + _customStartup = true; + OSCQueryService.Logger = logger; + return this; + } + + public OSCQueryServiceBuilder WithMiddleware(Func middleware) + { + _customStartup = true; + _service.AddMiddleware(middleware); + return this; + } + + public OSCQueryServiceBuilder WithDiscovery(IDiscovery d) + { + _customStartup = true; + _service.SetDiscovery(d); + return this; + } + + public OSCQueryServiceBuilder AddListenerForServiceType(Action listener, OSCQueryServiceProfile.ServiceType type) + { + _customStartup = true; + switch (type) + { + case OSCQueryServiceProfile.ServiceType.OSC: + _service.OnOscServiceAdded += listener; + break; + case OSCQueryServiceProfile.ServiceType.OSCQuery: + _service.OnOscQueryServiceAdded += listener; + break; + } + return this; + } + + public OSCQueryServiceBuilder AdvertiseOSC() + { + _customStartup = true; + _service.AdvertiseOSCService(_service.ServerName, _service.OscPort); + return this; + } + + public OSCQueryServiceBuilder AdvertiseOSCQuery() + { + _customStartup = true; + _service.AdvertiseOSCQueryService(_service.ServerName, _service.TcpPort); + return this; + } + } +} \ No newline at end of file diff --git a/vrc-oscquery-lib/OSCServiceProfile.cs b/vrc-oscquery-lib/OSCServiceProfile.cs index ed7ed1d..ae3106d 100644 --- a/vrc-oscquery-lib/OSCServiceProfile.cs +++ b/vrc-oscquery-lib/OSCServiceProfile.cs @@ -14,6 +14,19 @@ public enum ServiceType OSCQuery, OSC } + public string GetServiceTypeString() + { + switch (serviceType) + { + case ServiceType.OSC: + return Attributes.SERVICE_OSC_UDP; + case ServiceType.OSCQuery: + return Attributes.SERVICE_OSCJSON_TCP; + default: + return "UNKNOWN"; + } + } + public OSCQueryServiceProfile(string name, IPAddress address, int port, ServiceType serviceType) { this.name = name; diff --git a/vrc-oscquery-lib/Zeroconf/MeaModDiscovery.cs b/vrc-oscquery-lib/Zeroconf/MeaModDiscovery.cs new file mode 100644 index 0000000..a364674 --- /dev/null +++ b/vrc-oscquery-lib/Zeroconf/MeaModDiscovery.cs @@ -0,0 +1,150 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using MeaMod.DNS.Model; +using MeaMod.DNS.Multicast; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace VRC.OSCQuery +{ + public class MeaModDiscovery : IDiscovery + { + private ServiceDiscovery _discovery; + private MulticastService _mdns; + private static ILogger Logger; + + // Store discovered services + private readonly HashSet _oscQueryServices = new HashSet(); + private readonly HashSet _oscServices = new HashSet(); + + public HashSet GetOSCQueryServices() => _oscQueryServices; + public HashSet GetOSCServices() => _oscServices; + + public void Dispose() + { + _discovery?.Dispose(); + _mdns?.Stop(); + } + + public MeaModDiscovery(ILogger logger = null) + { + Logger = logger ?? new NullLogger(); + + _mdns = new MulticastService(); + _mdns.UseIpv6 = false; + _mdns.IgnoreDuplicateMessages = true; + + _discovery = new ServiceDiscovery(_mdns); + + // Query for OSC and OSCQuery services on every network interface + _mdns.NetworkInterfaceDiscovered += (s, e) => + { + RefreshServices(); + }; + + // Callback invoked when the above query is answered + _mdns.AnswerReceived += OnRemoteServiceInfo; + _mdns.Start(); + } + + public void RefreshServices() + { + _mdns.SendQuery(OSCQueryService._localOscUdpServiceName); + _mdns.SendQuery(OSCQueryService._localOscJsonServiceName); + } + + public event Action OnOscServiceAdded; + public event Action OnOscQueryServiceAdded; + + private Dictionary _profiles = new Dictionary(); + public void Advertise(OSCQueryServiceProfile profile) + { + var meaProfile = new ServiceProfile(profile.name, profile.GetServiceTypeString(), (ushort)profile.port, new[] { profile.address }); + _discovery.Advertise(meaProfile); + _profiles.Add(profile, meaProfile); + + Logger.LogInformation($"Advertising Service {profile.name} of type {profile.serviceType} on {profile.port}"); + } + + public void Unadvertise(OSCQueryServiceProfile profile) + { + if (_profiles.ContainsKey(profile)) + { + _discovery.Unadvertise(_profiles[profile]); + _profiles.Remove(profile); + } + Logger.LogInformation($"Unadvertising Service {profile.name} of type {profile.serviceType} on {profile.port}"); + } + + /// + /// Callback invoked when an mdns Service provides information about itself + /// + /// + /// Event Data with info from queried Service + private void OnRemoteServiceInfo(object sender, MessageEventArgs eventArgs) + { + var response = eventArgs.Message; + + try + { + // Check whether this service matches OSCJSON or OSC services for which we're looking + var hasMatch = response.Answers.Any(record => OSCQueryService.MatchedNames.Contains(record?.CanonicalName)); + if (!hasMatch) + { + return; + } + + // Get the name and SRV Record of the service + var name = response.Answers.First(r => OSCQueryService.MatchedNames.Contains(r?.CanonicalName)).CanonicalName; + var srvRecord = response.AdditionalRecords.OfType().FirstOrDefault(); + if (srvRecord == default) + { + Logger.LogWarning($"Found the matching service {name}, but it doesn't have an SRVRecord, can't proceed."); + return; + } + + // Get the rest of the items we need to track this service + var port = srvRecord.Port; + var domainName = srvRecord.Name.Labels; + var instanceName = domainName[0]; + + var serviceName = string.Join(".", domainName.Skip(1).SkipLast(1)); + var ips = response.AdditionalRecords.OfType().Select(r => r.Address); + + var ipAddressList = ips.ToList(); + var profile = new ServiceProfile(instanceName, serviceName, srvRecord.Port, ipAddressList); + + // If this is an OSC service, add it to the OSC collection + if (string.Compare(name, OSCQueryService._localOscUdpServiceName, StringComparison.Ordinal) == 0 && !_profiles.ContainsValue(profile)) + { + // Make sure there's not already a service with the same name + if (_oscServices.All(p => p.name != profile.InstanceName)) + { + var p = new OSCQueryServiceProfile(instanceName, ipAddressList.First(), port, OSCQueryServiceProfile.ServiceType.OSC); + _oscServices.Add(p); + OnOscServiceAdded?.Invoke(p); + Logger.LogInformation($"Found match {name} on port {port}"); + } + } + // If this is an OSCQuery service, add it to the OSCQuery collection + else if (string.Compare(name, OSCQueryService._localOscJsonServiceName, StringComparison.Ordinal) == 0 && !_profiles.ContainsValue(profile)) + { + // Make sure there's not already a service with the same name + if (_oscQueryServices.All(p => p.name != profile.InstanceName)) + { + var p = new OSCQueryServiceProfile(instanceName, ipAddressList.First(), port, OSCQueryServiceProfile.ServiceType.OSCQuery); + _oscQueryServices.Add(p); + OnOscQueryServiceAdded?.Invoke(p); + Logger.LogInformation($"Found match {name} on port {port}"); + } + } + } + catch (Exception e) + { + // Using a non-error log level because we may have just found a non-matching service + Logger.LogInformation($"Could not parse answer from {eventArgs.RemoteEndPoint}: {e.Message}"); + } + } + } +} \ No newline at end of file diff --git a/vrc-oscquery-lib/Zeroconf/ZeroconfTypes.cs b/vrc-oscquery-lib/Zeroconf/ZeroconfTypes.cs new file mode 100644 index 0000000..c8364df --- /dev/null +++ b/vrc-oscquery-lib/Zeroconf/ZeroconfTypes.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; + +namespace VRC.OSCQuery +{ + public interface IDiscovery : IDisposable + { + void RefreshServices(); + event Action OnOscServiceAdded; + event Action OnOscQueryServiceAdded; + HashSet GetOSCQueryServices(); + HashSet GetOSCServices(); + + void Advertise(OSCQueryServiceProfile profile); + void Unadvertise(OSCQueryServiceProfile profile); + } +} \ No newline at end of file