diff --git a/NAPS2.Escl.Server/EsclServer.cs b/NAPS2.Escl.Server/EsclServer.cs index 12f522cdf..dec5c381c 100644 --- a/NAPS2.Escl.Server/EsclServer.cs +++ b/NAPS2.Escl.Server/EsclServer.cs @@ -11,16 +11,11 @@ public class EsclServer : IEsclServer public void AddDevice(EsclDeviceConfig deviceConfig) { - if (deviceConfig.Port == 0) - { - deviceConfig.Port = Port++; - } var advertiser = new MdnsAdvertiser(); _devices[deviceConfig] = (advertiser, new CancellationTokenSource()); if (_started) { - StartServer(deviceConfig); - Task.Run(() => advertiser.AdvertiseDevice(deviceConfig)); + Task.Run(() => StartServerAndAdvertise(deviceConfig, advertiser)); } } @@ -36,10 +31,7 @@ public class EsclServer : IEsclServer _devices.Remove(deviceConfig); } - // TODO: Better port handling - public int Port { get; set; } = 9898; - - public void Start() + public Task Start() { if (_started) { @@ -48,24 +40,36 @@ public class EsclServer : IEsclServer _started = true; _cts = new CancellationTokenSource(); + var tasks = new List(); foreach (var device in _devices.Keys) { - StartServer(device); - Task.Run(() => _devices[device].advertiser.AdvertiseDevice(device)); + tasks.Add(Task.Run(() => StartServerAndAdvertise(device, _devices[device].advertiser))); } + return Task.WhenAll(tasks); } - private void StartServer(EsclDeviceConfig deviceConfig) + private async Task StartServerAndAdvertise(EsclDeviceConfig deviceConfig, MdnsAdvertiser advertiser) { - // TODO: Auto free port? - var url = $"http://+:{deviceConfig.Port}/"; + var cancelToken = CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, _devices[deviceConfig].cts.Token).Token; + // Try to run the server with the port specified in the EsclDeviceConfig first. If that fails, try random ports + // instead, and store the actually-used port back in EsclDeviceConfig so it can be advertised correctly. + await PortFinder.RunWithSpecifiedOrRandomPort(deviceConfig.Port, async port => + { + await StartServer(deviceConfig, port, cancelToken); + deviceConfig.Port = port; + }, cancelToken); + advertiser.AdvertiseDevice(deviceConfig); + } + + private async Task StartServer(EsclDeviceConfig deviceConfig, int port, CancellationToken cancelToken) + { + var url = $"http://+:{port}/"; var serverState = new EsclServerState(); var server = new WebServer(o => o .WithMode(HttpListenerMode.EmbedIO) .WithUrlPrefix(url)) .WithWebApi("/eSCL", m => m.WithController(() => new EsclApiController(deviceConfig, serverState))); - server.StateChanged += ServerOnStateChanged; - server.RunAsync(CancellationTokenSource.CreateLinkedTokenSource(_cts!.Token, _devices[deviceConfig].cts.Token).Token); + await server.StartAsync(cancelToken); } public void Stop() @@ -84,10 +88,6 @@ public class EsclServer : IEsclServer _cts = null; } - private void ServerOnStateChanged(object sender, WebServerStateChangedEventArgs e) - { - } - public void Dispose() { if (_started) diff --git a/NAPS2.Escl.Server/PortFinder.cs b/NAPS2.Escl.Server/PortFinder.cs new file mode 100644 index 000000000..59256936e --- /dev/null +++ b/NAPS2.Escl.Server/PortFinder.cs @@ -0,0 +1,43 @@ +namespace NAPS2.Escl.Server; + +internal static class PortFinder +{ + private const int MAX_PORT_TRIES = 5; + private const int RANDOM_PORT_MIN = 10001; + private const int RANDOM_PORT_MAX = 19999; + + public static async Task RunWithSpecifiedOrRandomPort(int defaultPort, Func portTaskFunc, + CancellationToken cancelToken) + { + int port = defaultPort; + int retries = 0; + var random = new Random(); + if (port == 0) + { + port = RandomPort(random); + } + while (true) + { + try + { + await portTaskFunc(port); + break; + } + catch (Exception) + { + if (cancelToken.IsCancellationRequested) + { + break; + } + retries++; + port = RandomPort(random); + if (retries > MAX_PORT_TRIES) + { + throw; + } + } + } + } + + private static int RandomPort(Random random) => random.Next(RANDOM_PORT_MIN, RANDOM_PORT_MAX + 1); +} \ No newline at end of file diff --git a/NAPS2.Escl.Server/WebServerExtensions.cs b/NAPS2.Escl.Server/WebServerExtensions.cs new file mode 100644 index 000000000..fdb300b3e --- /dev/null +++ b/NAPS2.Escl.Server/WebServerExtensions.cs @@ -0,0 +1,30 @@ +using EmbedIO; + +namespace NAPS2.Escl.Server; + +internal static class WebServerExtensions +{ + public static async Task StartAsync(this WebServer server, CancellationToken cancelToken = default) + { + var startedTcs = new TaskCompletionSource(); + server.StateChanged += (_, args) => + { + if (args.NewState == WebServerState.Listening) + { + startedTcs.TrySetResult(true); + } + }; + _ = server.RunAsync(cancelToken).ContinueWith(t => + { + if (t.IsFaulted) + { + startedTcs.TrySetException(t.Exception!); + } + else + { + startedTcs.TrySetCanceled(); + } + }); + await startedTcs.Task; + } +} \ No newline at end of file diff --git a/NAPS2.Escl.Tests/AdvertiseTests.cs b/NAPS2.Escl.Tests/AdvertiseTests.cs index 34b7b3eb3..5883d6e3f 100644 --- a/NAPS2.Escl.Tests/AdvertiseTests.cs +++ b/NAPS2.Escl.Tests/AdvertiseTests.cs @@ -18,11 +18,12 @@ public class AdvertiseTests { Version = "2.6", MakeAndModel = "HP Blah", - SerialNumber = "123abc" + SerialNumber = "123abc", + Uuid = Guid.NewGuid().ToString("D") }, CreateJob = _ => job }); - server.Start(); + await server.Start(); if (Debugger.IsAttached) { for (int i = 0; i < 100; i++) diff --git a/NAPS2.Escl.Tests/ClientServerTests.cs b/NAPS2.Escl.Tests/ClientServerTests.cs index 63de17832..b8663cbc2 100644 --- a/NAPS2.Escl.Tests/ClientServerTests.cs +++ b/NAPS2.Escl.Tests/ClientServerTests.cs @@ -11,32 +11,28 @@ public class ClientServerTests [Fact] public async Task ClientServer() { - // TODO: Any better way to prevent port collisions when running tests? -#if NET6_0_OR_GREATER - int port = 9802; -#else - int port = 9801; -#endif var job = Substitute.For(); - using var server = new EsclServer { Port = port }; - server.AddDevice(new EsclDeviceConfig + using var server = new EsclServer(); + var deviceConfig = new EsclDeviceConfig { Capabilities = new EsclCapabilities { Version = "2.0", MakeAndModel = "HP Blah", - SerialNumber = "123abc" + SerialNumber = "123abc", + Uuid = Guid.NewGuid().ToString("D") }, CreateJob = _ => job - }); - server.Start(); + }; + server.AddDevice(deviceConfig); + await server.Start(); var client = new EsclClient(new EsclService { IpV4 = IPAddress.Loopback, IpV6 = IPAddress.IPv6Loopback, Host = IPAddress.IPv6Loopback.ToString(), RemoteEndpoint = IPAddress.IPv6Loopback, - Port = 9801, + Port = deviceConfig.Port, RootUrl = "eSCL", Tls = false }); diff --git a/NAPS2.Escl/Server/IEsclServer.cs b/NAPS2.Escl/Server/IEsclServer.cs index af6518db3..8ddcc7f4f 100644 --- a/NAPS2.Escl/Server/IEsclServer.cs +++ b/NAPS2.Escl/Server/IEsclServer.cs @@ -4,7 +4,6 @@ public interface IEsclServer : IDisposable { void AddDevice(EsclDeviceConfig deviceConfig); void RemoveDevice(EsclDeviceConfig deviceConfig); - int Port { get; set; } - void Start(); + Task Start(); void Stop(); } \ No newline at end of file diff --git a/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs b/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs index f2cb0d2b1..533e7ca4d 100644 --- a/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs +++ b/NAPS2.Lib/EtoForms/Ui/SharedDeviceForm.cs @@ -10,6 +10,8 @@ namespace NAPS2.EtoForms.Ui; public class SharedDeviceForm : EtoDialogBase { + private const int BASE_PORT = 9801; + private readonly IScanPerformer _scanPerformer; private readonly ErrorOutput _errorOutput; @@ -114,6 +116,8 @@ public class SharedDeviceForm : EtoDialogBase } } + private int Port { get; set; } + private Driver DeviceDriver { get => _twainDriver.Checked ? Driver.Twain @@ -151,6 +155,7 @@ public class SharedDeviceForm : EtoDialogBase _displayName.Text = SharedDevice?.Name ?? ""; CurrentDevice ??= SharedDevice?.Device; + Port = SharedDevice?.Port ?? 0; DeviceDriver = SharedDevice?.Device.Driver ?? ScanOptionsValidator.SystemDefaultDriver; @@ -198,10 +203,22 @@ public class SharedDeviceForm : EtoDialogBase SharedDevice = new SharedDevice { Name = _displayName.Text, - Device = CurrentDevice! + Device = CurrentDevice!, + Port = Port == 0 ? NextPort() : Port }; } + private int NextPort() + { + var devices = Config.Get(c => c.SharedDevices); + int port = BASE_PORT; + while (devices.Any(x => x.Port == port)) + { + port++; + } + return port; + } + private void Ok_Click(object? sender, EventArgs e) { if (_displayName.Text == "") diff --git a/NAPS2.Sdk/Remoting/Server/ScanServer.cs b/NAPS2.Sdk/Remoting/Server/ScanServer.cs index e96b62ce4..df50f7e02 100644 --- a/NAPS2.Sdk/Remoting/Server/ScanServer.cs +++ b/NAPS2.Sdk/Remoting/Server/ScanServer.cs @@ -25,8 +25,8 @@ public class ScanServer : IDisposable public void SetDefaultIcon(byte[] iconPng) => _defaultIconPng = iconPng; - public void RegisterDevice(ScanDevice device, string? displayName = null) => - RegisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name }); + public void RegisterDevice(ScanDevice device, string? displayName = null, int port = 0) => + RegisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name, Port = port }); public void RegisterDevice(SharedDevice sharedDevice) { @@ -35,8 +35,8 @@ public class ScanServer : IDisposable _esclServer.AddDevice(esclDeviceConfig); } - public void UnregisterDevice(ScanDevice device, string? displayName = null) => - UnregisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name }); + public void UnregisterDevice(ScanDevice device, string? displayName = null, int port = 0) => + UnregisterDevice(new SharedDevice { Device = device, Name = displayName ?? device.Name, Port = port }); public void UnregisterDevice(SharedDevice sharedDevice) { @@ -49,6 +49,7 @@ public class ScanServer : IDisposable { return new EsclDeviceConfig { + Port = device.Port, Capabilities = new EsclCapabilities { MakeAndModel = device.Name, diff --git a/NAPS2.Sdk/Remoting/Server/SharedDevice.cs b/NAPS2.Sdk/Remoting/Server/SharedDevice.cs index 36223ec7d..92a466afa 100644 --- a/NAPS2.Sdk/Remoting/Server/SharedDevice.cs +++ b/NAPS2.Sdk/Remoting/Server/SharedDevice.cs @@ -8,6 +8,7 @@ public record SharedDevice { public required string Name { get; init; } public required ScanDevice Device { get; init; } + public required int Port { get; init; } public string Uuid {