diff --git a/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs b/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs index f368e01..478f5b8 100644 --- a/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs +++ b/WirelessTextSyncer.Windows.Tests/SyncMessageTests.cs @@ -59,6 +59,21 @@ public sealed class SyncMessageTests keyboard.Calls); } + [TestMethod] + public void DiscoveryResponseContainsServiceEndpoint() + { + using var discovery = new DiscoveryResponderService(() => "192.168.1.10", () => 8181, "Desktop-WIN11"); + + using var document = JsonDocument.Parse(discovery.BuildResponseJson()); + var root = document.RootElement; + + Assert.AreEqual("wirelessTextSyncer.service", root.GetProperty("type").GetString()); + Assert.AreEqual(1, root.GetProperty("version").GetInt32()); + Assert.AreEqual("Desktop-WIN11", root.GetProperty("name").GetString()); + Assert.AreEqual("192.168.1.10", root.GetProperty("host").GetString()); + Assert.AreEqual(8181, root.GetProperty("port").GetInt32()); + } + private sealed class RecordingKeyboardInjectionService : IKeyboardInjectionService { public List Calls { get; } = []; diff --git a/WirelessTextSyncer.Windows/Services/DiscoveryResponderService.cs b/WirelessTextSyncer.Windows/Services/DiscoveryResponderService.cs new file mode 100644 index 0000000..27c78d5 --- /dev/null +++ b/WirelessTextSyncer.Windows/Services/DiscoveryResponderService.cs @@ -0,0 +1,115 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; + +namespace WirelessTextSyncer.Windows.Services; + +public sealed class DiscoveryResponderService : IDisposable +{ + public const int DiscoveryPort = 8182; + public const string DiscoveryRequestType = "wirelessTextSyncer.discovery"; + public const string ServiceResponseType = "wirelessTextSyncer.service"; + + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly Func hostProvider; + private readonly Func portProvider; + private readonly string deviceName; + private readonly CancellationTokenSource cancellation = new(); + private UdpClient? udpClient; + private Task? listenTask; + + public DiscoveryResponderService(Func hostProvider, Func portProvider, string? deviceName = null) + { + this.hostProvider = hostProvider; + this.portProvider = portProvider; + this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName; + } + + public void Start() + { + if (udpClient is not null) + { + return; + } + + udpClient = new UdpClient(AddressFamily.InterNetwork); + udpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + udpClient.EnableBroadcast = true; + udpClient.Client.Bind(new IPEndPoint(IPAddress.Any, DiscoveryPort)); + listenTask = Task.Run(ListenAsync); + AppLogger.Info($"Discovery responder listening on UDP {DiscoveryPort}."); + } + + public void Dispose() + { + cancellation.Cancel(); + udpClient?.Dispose(); + + try + { + listenTask?.Wait(TimeSpan.FromSeconds(1)); + } + catch (AggregateException) + { + } + + cancellation.Dispose(); + } + + public string BuildResponseJson() + { + return JsonSerializer.Serialize( + new DiscoveryResponse(ServiceResponseType, 1, deviceName, hostProvider(), portProvider()), + JsonOptions); + } + + private async Task ListenAsync() + { + var token = cancellation.Token; + while (!token.IsCancellationRequested && udpClient is not null) + { + try + { + var result = await udpClient.ReceiveAsync(token); + if (!IsDiscoveryRequest(result.Buffer)) + { + continue; + } + + var responseBytes = Encoding.UTF8.GetBytes(BuildResponseJson()); + await udpClient.SendAsync(responseBytes, result.RemoteEndPoint, token); + AppLogger.Info($"Answered discovery request from {result.RemoteEndPoint}."); + } + catch (OperationCanceledException) + { + break; + } + catch (ObjectDisposedException) + { + break; + } + catch (Exception exception) + { + AppLogger.Error("Discovery responder failed to process a packet.", exception); + } + } + } + + private static bool IsDiscoveryRequest(byte[] bytes) + { + try + { + var request = JsonSerializer.Deserialize(bytes, JsonOptions); + return request?.Type == DiscoveryRequestType && request.Version == 1; + } + catch (JsonException) + { + return false; + } + } + + private sealed record DiscoveryRequest(string Type, int Version); + + private sealed record DiscoveryResponse(string Type, int Version, string Name, string Host, int Port); +} diff --git a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs index f15566f..5355c0c 100644 --- a/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs +++ b/WirelessTextSyncer.Windows/Tray/TrayApplicationContext.cs @@ -6,6 +6,7 @@ public sealed class TrayApplicationContext : ApplicationContext { private readonly KeyboardInjectionService keyboard; private readonly WebSocketServerService server; + private readonly DiscoveryResponderService discovery; private readonly NotifyIcon notifyIcon; private ToolStripMenuItem? clipboardPasteMenuItem; private ToolStripMenuItem? sendInputTypingMenuItem; @@ -18,6 +19,7 @@ public sealed class TrayApplicationContext : ApplicationContext server = new WebSocketServerService(handler); server.StatusChanged += (_, _) => UpdateTrayText(); + discovery = new DiscoveryResponderService(() => server.LocalIpAddress, () => server.Port); notifyIcon = new NotifyIcon { @@ -31,6 +33,7 @@ public sealed class TrayApplicationContext : ApplicationContext try { server.Start(8181); + discovery.Start(); UpdateTrayText(); } catch (Exception ex) @@ -47,6 +50,7 @@ public sealed class TrayApplicationContext : ApplicationContext if (disposing) { notifyIcon.Dispose(); + discovery.Dispose(); server.Dispose(); }