From 97978f3696fb11ab6dd33a19cf270308e3d1d9b3 Mon Sep 17 00:00:00 2001
From: John Lightfoot <259939203+JohnDevAc@users.noreply.github.com>
Date: Mon, 7 Sep 2026 08:13:30 +0100
Subject: [PATCH] Discover network Job Configurator instances in Toolkit 1.3.4
---
Directory.Build.props | 2 +-
INTEROPERABILITY.md | 4 +
README.md | 4 +-
docs/releases/v1.3.4.md | 7 ++
.../JobConfiguratorDiscovery.cs | 90 +++++++++++++++++++
src/ToolkitLauncher/AppCard.cs | 5 ++
src/ToolkitLauncher/MainWindow.xaml | 8 ++
src/ToolkitLauncher/MainWindow.xaml.cs | 83 ++++++++++++++++-
.../JobDiscoveryTests.cs | 88 ++++++++++++++++++
tests/ToolkitLauncher.Tests/Program.cs | 19 +++-
10 files changed, 304 insertions(+), 6 deletions(-)
create mode 100644 docs/releases/v1.3.4.md
create mode 100644 src/ToolkitLauncher.Core/JobConfiguratorDiscovery.cs
create mode 100644 tests/ToolkitLauncher.Tests/JobDiscoveryTests.cs
diff --git a/Directory.Build.props b/Directory.Build.props
index 8392c8f..0672245 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -3,7 +3,7 @@
enable
enable
true
- 1.3.3
+ 1.3.4
John Lightfoot
John Lightfoot
Copyright © 2026 John Lightfoot. All rights reserved. Proprietary. Free for non-commercial use.
diff --git a/INTEROPERABILITY.md b/INTEROPERABILITY.md
index ffb9fb9..e93a3e7 100644
--- a/INTEROPERABILITY.md
+++ b/INTEROPERABILITY.md
@@ -1,5 +1,9 @@
# Interoperability and setup readiness
+## Network Job Configurator links — 7 September 2026
+
+When local Job Configurator detection is empty, Toolkit starts independent LAN discovery during startup, explicit update checks and post-setup refresh. It verifies the current or legacy Job Configurator product on `/api/health`, then exposes each discovered TCP 8091 web UI below Install. Probes bypass internet proxies, refuse redirects, limit response size and time, cover complete supported /20–/30 subnets, and report partial/unsupported coverage. Remote discovery does not mark the application locally installed. Closing Toolkit or replacing a scan cancels pending probes; local installation clears the links. No server or companion integration contract changed.
+
## PC Agent setup recovery
The PC Agent card now checks per-user configuration independently of executable version. Missing, unreadable or invalid schema-1 identity/network/membership state requires **Complete setup**. It follows the agent's current/legacy state precedence and does not treat a disconnected adapter as missing setup. A matching installed Agent/Setup pair can complete configuration offline; missing or mismatched Setup requires the complete release package. Recovery retains newer installed versions. Local checks run again before actions and after setup exits, including unsuccessful or incomplete setup. Toolkit never writes Agent configuration or accepts its licence; the independently installed Setup owns those operations. The server process contract and remote consent flow are unchanged.
diff --git a/README.md b/README.md
index 30c63fc..80beb2b 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,7 @@ A native Windows application for installing, updating and launching four product
## Run
-Download **Production-Toolkit-1.3.3-win-x64-Setup.exe** from the [latest release](https://github.com/JohnDevAc/Production-Toolkit/releases/latest) and run it. Setup installs Production Toolkit for your Windows account, creates desktop and Start menu shortcuts, and adds an entry to **Settings → Apps → Installed apps** with an uninstaller. The default location is `%LOCALAPPDATA%\Programs\Production Toolkit`; a custom location can be selected and will be reused by updates.
+Download **Production-Toolkit-1.3.4-win-x64-Setup.exe** from the [latest release](https://github.com/JohnDevAc/Production-Toolkit/releases/latest) and run it. Setup installs Production Toolkit for your Windows account, creates desktop and Start menu shortcuts, and adds an entry to **Settings → Apps → Installed apps** with an uninstaller. The default location is `%LOCALAPPDATA%\Programs\Production Toolkit`; a custom location can be selected and will be reused by updates.
The Windows x64 application is self-contained: no separate .NET installation, browser runtime, Python or Node.js is required. Production Toolkit installs and updates without administrator elevation; installers for the managed applications may request it. Users of the earlier portable releases should run this installer once and then use the installed shortcuts. Local builds are in `artifacts/release`; portable executable and ZIP downloads remain available as secondary options.
@@ -30,6 +30,8 @@ Uninstalled applications show a large app icon, their name, a Stable / Developme
- **Launch** for installed applications; **Open setup** for Environment Setup.
- Matching layouts with colours derived from each application's icon.
+When NDI Job Configurator is not installed locally, startup also searches connected IPv4 networks for a running instance. Each verified server gets an **Open web UI · address** link below **Install**. Links appear as servers respond; release checks and the other cards remain usable. **Check for updates** repeats discovery and clears stale results, and each link is checked again before opening in your default browser. The read-only search probes TCP 8091 on complete /20–/30 subnets, with bounded concurrency, short request deadlines and a four-minute overall limit. Unsupported networks and partial scans are reported. It does not need internet or save remote results between launches.
+
The app reads installed versions, icons and release information on startup and during **Check for updates**. When an installer or **Open setup** window closes, all cards automatically refresh their local installed versions, icons, actions and environment status. This also detects components installed by another app's setup and changes made during repair, uninstall or incomplete setup. It uses the saved release information without another GitHub check, so API cooldowns do not block the refresh. There is no timer or background polling. Finish one installer before opening another; downloads remain cancellable. The app never automatically accepts an application's licence or runs an unattended installation. Routine launch, cancellation and completion confirmations stay out of the cards; errors and active installation/download progress remain visible.
## Supported applications
diff --git a/docs/releases/v1.3.4.md b/docs/releases/v1.3.4.md
new file mode 100644
index 0000000..2bd8af0
--- /dev/null
+++ b/docs/releases/v1.3.4.md
@@ -0,0 +1,7 @@
+# Production Toolkit 1.3.4
+
+When NDI Job Configurator is not installed on this PC, Toolkit now searches connected IPv4 networks at startup and adds each verified server's **Open web UI** link below **Install**. Discovery starts independently of GitHub checks, shows results as they arrive, and leaves the dashboard usable. **Check for updates** searches again and removes stale links. Links are rechecked before opening in the default browser.
+
+The read-only search verifies Job Configurator's identity on TCP 8091, covers complete /20–/30 subnets, and reports unsupported networks or incomplete scans. Local installation cancels the search and restores the normal local-app card. Remote discovery does not change NDI settings or install a local server.
+
+Validation: 333 regression and WPF UI assertions pass, including full-subnet coverage, malformed/unrelated responses, bounded cancellation, stale-result removal and link placement at 100%, 200% and 250%. The release workflow also verifies installation, running-app upgrade, shortcuts and uninstall on its Windows runner.
diff --git a/src/ToolkitLauncher.Core/JobConfiguratorDiscovery.cs b/src/ToolkitLauncher.Core/JobConfiguratorDiscovery.cs
new file mode 100644
index 0000000..2228272
--- /dev/null
+++ b/src/ToolkitLauncher.Core/JobConfiguratorDiscovery.cs
@@ -0,0 +1,90 @@
+using System.Collections.Concurrent;
+using System.Net;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
+using System.Text.Json;
+
+namespace ToolkitLauncher.Core;
+
+public sealed record NetworkConfigurator(Uri Address)
+{
+ public string LinkText => $"Open web UI · {Address.Host}";
+}
+
+public sealed record JobDiscoveryResult(IReadOnlyList Instances, bool TimedOut, int Checked, int Total, int UnsupportedNetworks);
+
+public sealed class JobConfiguratorDiscovery(HttpClient http,
+ Func>? networks = null, TimeSpan? scanBudget = null)
+{
+ public static HttpClient CreateClient() => new(new HttpClientHandler { UseProxy = false, AllowAutoRedirect = false, UseCookies = false })
+ { Timeout = Timeout.InfiniteTimeSpan };
+
+ public async Task DiscoverAsync(Action? found = null, CancellationToken ct = default)
+ {
+ ct.ThrowIfCancellationRequested();
+ var adapters = (networks ?? ActiveNetworks)().Distinct().ToArray();
+ var unsupported = adapters.Count(n => n.Prefix is < 20 or > 30);
+ var supported = adapters.Where(n => n.Prefix is >= 20 and <= 30).ToArray();
+ var local = adapters.Select(n => n.Address).ToHashSet();
+ // Try the nearby /24 on every interface first, then finish every supported subnet.
+ var candidates = supported.SelectMany(n => SubnetHosts(n.Address, Math.Max(24, n.Prefix)))
+ .Concat(supported.SelectMany(n => SubnetHosts(n.Address, n.Prefix)))
+ .Where(address => !local.Contains(address)).Distinct().ToArray();
+ var instances = new ConcurrentDictionary();
+ var completed = 0;
+ var timedOut = false;
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ deadline.CancelAfter(scanBudget ?? TimeSpan.FromMinutes(4));
+ try
+ {
+ await Parallel.ForEachAsync(candidates, new ParallelOptions { MaxDegreeOfParallelism = 64, CancellationToken = deadline.Token }, async (address, token) =>
+ {
+ var instance = await ProbeAsync(new Uri($"http://{address}:8091/"), token).ConfigureAwait(false);
+ Interlocked.Increment(ref completed);
+ if (instance is not null && instances.TryAdd(instance.Address.AbsoluteUri, instance)) found?.Invoke(instance);
+ }).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (!ct.IsCancellationRequested) { timedOut = true; }
+ ct.ThrowIfCancellationRequested();
+ return new(instances.Values.OrderBy(i => i.Address.Host, StringComparer.Ordinal).ToArray(), timedOut, completed, candidates.Length, unsupported);
+ }
+
+ public async Task ProbeAsync(Uri address, CancellationToken ct = default)
+ {
+ using var deadline = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ deadline.CancelAfter(TimeSpan.FromMilliseconds(850));
+ try
+ {
+ using var response = await http.GetAsync(new Uri(address, "api/health"), HttpCompletionOption.ResponseHeadersRead, deadline.Token).ConfigureAwait(false);
+ if (!response.IsSuccessStatusCode) return null;
+ // A port listener is insufficient; require this application's public identity.
+ await response.Content.LoadIntoBufferAsync(16 * 1024).WaitAsync(deadline.Token).ConfigureAwait(false);
+ using var json = JsonDocument.Parse(await response.Content.ReadAsStringAsync(deadline.Token).ConfigureAwait(false));
+ if (json.RootElement.ValueKind != JsonValueKind.Object ||
+ !json.RootElement.TryGetProperty("product", out var product) || product.ValueKind != JsonValueKind.String ||
+ product.GetString() is not ("NDI Job Configurator" or "Kiloview Job Configurator")) return null;
+ return new(address);
+ }
+ catch (Exception ex) when (!ct.IsCancellationRequested && ex is HttpRequestException or OperationCanceledException or JsonException or IOException) { return null; }
+ }
+
+ public static IEnumerable SubnetHosts(IPAddress address, int prefix)
+ {
+ if (address.AddressFamily != AddressFamily.InterNetwork || prefix is < 20 or > 30)
+ throw new ArgumentOutOfRangeException(nameof(prefix), "Automatic discovery supports IPv4 /20 through /30.");
+ var bytes = address.GetAddressBytes();
+ var value = ((uint)bytes[0] << 24) | ((uint)bytes[1] << 16) | ((uint)bytes[2] << 8) | bytes[3];
+ var mask = uint.MaxValue << (32 - prefix);
+ var network = value & mask;
+ var broadcast = network | ~mask;
+ for (var host = network + 1; host < broadcast; host++)
+ yield return new IPAddress(new[] { (byte)(host >> 24), (byte)(host >> 16), (byte)(host >> 8), (byte)host });
+ }
+
+ private static IEnumerable<(IPAddress Address, int Prefix)> ActiveNetworks() => NetworkInterface.GetAllNetworkInterfaces()
+ .Where(n => n.OperationalStatus == OperationalStatus.Up && n.NetworkInterfaceType != NetworkInterfaceType.Loopback)
+ .SelectMany(n => n.GetIPProperties().UnicastAddresses)
+ .Where(n => n.Address.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(n.Address)
+ && n.Address.GetAddressBytes() is var b && b[0] is > 0 and < 224 && !(b[0] == 169 && b[1] == 254))
+ .Select(n => (n.Address, n.PrefixLength));
+}
diff --git a/src/ToolkitLauncher/AppCard.cs b/src/ToolkitLauncher/AppCard.cs
index ffaf598..148a42b 100644
--- a/src/ToolkitLauncher/AppCard.cs
+++ b/src/ToolkitLauncher/AppCard.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using System.Windows.Media.Imaging;
@@ -36,6 +37,10 @@ public void UpdateIcons(BitmapSource? installed, BitmapSource? remote)
public ReleaseSnapshot? Snapshot { get; set; }
public bool Offline { get; set; }
public Installation? Installed { get; set; }
+ public ObservableCollection NetworkConfigurators { get; } = [];
+ public bool ShowNetworkDiscovery => Definition.IsJob && Installed is null;
+ private string networkDiscoveryStatus = "";
+ public string NetworkDiscoveryStatus { get => networkDiscoveryStatus; set { networkDiscoveryStatus = value; Notify(); } }
public EnvironmentSnapshot? EnvironmentStatus { get; set; }
public bool ShowEnvironment => Definition.IsEnvironment;
public bool ShowChannelNote => !Definition.IsEnvironment && !NeedsPcAgentSetup;
diff --git a/src/ToolkitLauncher/MainWindow.xaml b/src/ToolkitLauncher/MainWindow.xaml
index e0c5ca6..7d670ea 100644
--- a/src/ToolkitLauncher/MainWindow.xaml
+++ b/src/ToolkitLauncher/MainWindow.xaml
@@ -124,6 +124,14 @@
+
+
+
+
+
+
+
+
diff --git a/src/ToolkitLauncher/MainWindow.xaml.cs b/src/ToolkitLauncher/MainWindow.xaml.cs
index 7d20145..5ae97db 100644
--- a/src/ToolkitLauncher/MainWindow.xaml.cs
+++ b/src/ToolkitLauncher/MainWindow.xaml.cs
@@ -14,6 +14,9 @@ public partial class MainWindow : Window, INotifyPropertyChanged
private readonly HttpClient apiHttp = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly HttpClient iconHttp = new() { Timeout = TimeSpan.FromSeconds(15) };
private readonly HttpClient downloadHttp = new() { Timeout = TimeSpan.FromMinutes(30) };
+ private readonly HttpClient discoveryHttp = JobConfiguratorDiscovery.CreateClient();
+ private CancellationTokenSource? discoveryCancellation;
+ private int discoveryGeneration;
private readonly GitHubClient github;
private readonly PackageService packages;
private readonly IconService icons;
@@ -93,7 +96,7 @@ public MainWindow(string? dataRoot, bool preview, bool checkOnStartup = true)
}
if (!e.Cancel) lifetime.Cancel();
};
- Closed += (_, _) => { apiHttp.Dispose(); iconHttp.Dispose(); downloadHttp.Dispose(); lifetime.Dispose(); };
+ Closed += (_, _) => { apiHttp.Dispose(); iconHttp.Dispose(); downloadHttp.Dispose(); discoveryHttp.Dispose(); lifetime.Dispose(); };
if (local.Warning is not null) Footer = local.Warning;
}
@@ -149,6 +152,22 @@ await Task.WhenAll(Cards.Select(async card =>
var environment = card.Definition.IsEnvironment ? await EnvironmentStatusService.ReadAsync(shutdown) : null;
return (Found: found, Icon: icon, Environment: environment);
}, shutdown);
+ // LAN discovery starts as soon as local detection finishes, even
+ // when GitHub is offline or its release request is still pending.
+ if (card.Definition.IsJob) _ = DiscoverAfterLocalDetectionAsync();
+ async Task DiscoverAfterLocalDetectionAsync()
+ {
+ try
+ {
+ var detected = await installedTask;
+ if (shutdown.IsCancellationRequested) return;
+ card.Installed = detected.Found;
+ card.Recompute();
+ await RefreshJobDiscoveryAsync(card);
+ }
+ catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
+ catch (Exception error) { local.Log("Job Configurator detection: " + error.Message); }
+ }
try
{
try
@@ -185,6 +204,67 @@ await Task.WhenAll(Cards.Select(async card =>
finally { refreshing = false; Notify(nameof(CanRefresh)); Notify(nameof(RefreshText)); Notify(nameof(Summary)); }
}
+ public async Task RefreshJobDiscoveryAsync(AppCard card, JobConfiguratorDiscovery? discovery = null)
+ {
+ if (!card.Definition.IsJob || shutdown.IsCancellationRequested) return;
+ var generation = ++discoveryGeneration;
+ discoveryCancellation?.Cancel();
+ card.NetworkConfigurators.Clear();
+ card.NetworkDiscoveryStatus = "";
+ if (card.Installed is not null) return;
+ using var cancellation = CancellationTokenSource.CreateLinkedTokenSource(shutdown);
+ discoveryCancellation = cancellation;
+ var acceptingResults = true;
+ card.NetworkDiscoveryStatus = "Looking for NDI Job Configurator on your network…";
+ try
+ {
+ var result = await Task.Run(() => (discovery ?? new JobConfiguratorDiscovery(discoveryHttp)).DiscoverAsync(instance =>
+ {
+ Dispatcher.BeginInvoke(() =>
+ {
+ if (acceptingResults && generation == discoveryGeneration && ReferenceEquals(discoveryCancellation, cancellation)
+ && !shutdown.IsCancellationRequested && card.Installed is null && !card.NetworkConfigurators.Contains(instance))
+ card.NetworkConfigurators.Add(instance);
+ });
+ }, cancellation.Token), cancellation.Token);
+ acceptingResults = false;
+ if (generation != discoveryGeneration || card.Installed is not null) return;
+ card.NetworkConfigurators.Clear();
+ foreach (var instance in result.Instances) card.NetworkConfigurators.Add(instance);
+ card.NetworkDiscoveryStatus = result.TimedOut ? $"Network check incomplete ({result.Checked} of {result.Total} addresses). Use Check for updates to retry."
+ : result.Instances.Count > 0 ? "" : result.Total == 0 ? "No supported local network is connected." : "No NDI Job Configurator found on your network.";
+ if (result.UnsupportedNetworks > 0) card.NetworkDiscoveryStatus += " Automatic discovery supports IPv4 /20–/30 networks.";
+ }
+ catch (OperationCanceledException) when (cancellation.IsCancellationRequested) { }
+ catch (Exception error)
+ {
+ if (generation == discoveryGeneration && !shutdown.IsCancellationRequested)
+ {
+ card.NetworkDiscoveryStatus = "Could not check the local network. Use Check for updates to retry.";
+ local.Log("Job Configurator discovery: " + error.Message);
+ }
+ }
+ finally { acceptingResults = false; if (ReferenceEquals(discoveryCancellation, cancellation)) discoveryCancellation = null; }
+ }
+
+ private async void OpenNetworkConfiguratorClick(object sender, RoutedEventArgs e)
+ {
+ if ((sender as FrameworkElement)?.DataContext is not NetworkConfigurator instance) return;
+ var card = Cards.Single(c => c.Definition.IsJob);
+ try
+ {
+ if (await new JobConfiguratorDiscovery(discoveryHttp).ProbeAsync(instance.Address, shutdown) is null)
+ {
+ card.NetworkConfigurators.Remove(instance);
+ card.NetworkDiscoveryStatus = "That server is no longer reachable. Use Check for updates to search again.";
+ return;
+ }
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(instance.Address.AbsoluteUri) { UseShellExecute = true });
+ }
+ catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
+ catch (Exception error) { Report(card, "Could not open the web UI. " + Friendly(error)); }
+ }
+
private async void RefreshClick(object sender, RoutedEventArgs e)
{
if (!CanRefresh) return;
@@ -212,6 +292,7 @@ await Task.WhenAll(Cards.Select(async card =>
return (Found: found, Icon: icon, Environment: environment);
}, shutdown);
card.Installed = result.Found;
+ if (card.Definition.IsJob && !PreviewMode) _ = RefreshJobDiscoveryAsync(card);
card.EnvironmentStatus = result.Environment;
card.UpdateInstalledIcon(result.Icon);
}
diff --git a/tests/ToolkitLauncher.Tests/JobDiscoveryTests.cs b/tests/ToolkitLauncher.Tests/JobDiscoveryTests.cs
new file mode 100644
index 0000000..74cdb18
--- /dev/null
+++ b/tests/ToolkitLauncher.Tests/JobDiscoveryTests.cs
@@ -0,0 +1,88 @@
+using System.IO;
+using System.Net;
+using System.Net.Http;
+using System.Windows.Threading;
+using ToolkitLauncher;
+using ToolkitLauncher.Core;
+
+internal static partial class Program
+{
+ private static HttpResponseMessage Health(string body, HttpStatusCode status = HttpStatusCode.OK) => new(status) { Content = new StringContent(body) };
+ private static async Task JobDiscoveryTestsAsync()
+ {
+ var requests = new List();
+ using var http = new HttpClient(new FakeHandler(request =>
+ {
+ lock (requests) requests.Add(request.RequestUri!);
+ return request.RequestUri!.Host switch
+ {
+ "192.0.2.2" => Health("{\"product\":\"NDI Job Configurator\"}"),
+ "192.0.3.2" => Health("{\"product\":\"Kiloview Job Configurator\"}"),
+ "192.0.2.3" => Health("{\"product\":\"NDI Configurator PC Agent\"}"),
+ "192.0.2.4" => Health("{\"product\":123}"),
+ "192.0.2.5" => Health("unrelated web server"),
+ "192.0.2.6" => Health(new string('x', 17000)),
+ "192.0.2.7" => Health("{\"product\":\"NDI Job Configurator\"}", HttpStatusCode.Redirect),
+ _ => Health("{}")
+ };
+ }));
+ var discovery = new JobConfiguratorDiscovery(http, () => [(IPAddress.Parse("192.0.2.1"), 23), (IPAddress.Parse("192.0.2.1"), 23)]);
+ var found = new System.Collections.Concurrent.ConcurrentBag();
+ var result = await discovery.DiscoverAsync(found.Add);
+ Check(result.Instances.Count == 2 && found.Count == 2, "Discovery accepts current/legacy Job Configurator identity and rejects other listeners, malformed, oversized and redirect responses");
+ Check(result.Total == 509 && result.Checked == 509 && requests.Select(r => r.Host).Distinct().Count() == 509,
+ "Discovery covers the complete /23 once, including hosts beyond /24, and skips this PC");
+ Check(requests.All(r => r.Scheme == "http" && r.Port == 8091 && r.AbsolutePath == "/api/health"), "Discovery sends only read-only health probes on the contracted port");
+ Check(JobConfiguratorDiscovery.SubnetHosts(IPAddress.Parse("192.0.2.1"), 20).Count() == 4094
+ && JobConfiguratorDiscovery.SubnetHosts(IPAddress.Parse("192.0.2.1"), 30).Count() == 2, "Supported subnet boundaries exclude network and broadcast addresses");
+ var unsupported = await new JobConfiguratorDiscovery(http, () => [(IPAddress.Parse("10.0.0.1"), 16)]).DiscoverAsync();
+ Check(unsupported.Total == 0 && unsupported.UnsupportedNetworks == 1, "Unsupported large networks are reported without truncating or scanning them");
+ var empty = await new JobConfiguratorDiscovery(http, () => []).DiscoverAsync();
+ Check(empty.Total == 0 && empty.Instances.Count == 0, "Disconnected hosts finish discovery cleanly");
+ using var stalled = new HttpClient(new AsyncHandler(async (request, token) =>
+ {
+ if (request.RequestUri!.Host == "192.0.2.2") return Health("{\"product\":\"NDI Job Configurator\"}");
+ await Task.Delay(Timeout.Infinite, token);
+ return Health("{}");
+ }));
+ var bounded = new JobConfiguratorDiscovery(stalled, () => [(IPAddress.Parse("192.0.2.1"), 24)], TimeSpan.FromMilliseconds(150));
+ var partial = await bounded.DiscoverAsync();
+ Check(partial.TimedOut && partial.Checked < partial.Total && partial.Instances.Count == 1, "Timed-out scans retain discoveries and report partial coverage");
+ using var cancel = new CancellationTokenSource(75);
+ await ThrowsAsync(() => bounded.DiscoverAsync(ct: cancel.Token), "Caller cancellation stops outstanding probes");
+ using var offline = new HttpClient(new AsyncHandler((_, _) => throw new HttpRequestException("Network unavailable")));
+ var offlineResult = await new JobConfiguratorDiscovery(offline, () => [(IPAddress.Parse("192.0.2.1"), 30)]).DiscoverAsync();
+ Check(offlineResult.Checked == 1 && offlineResult.Instances.Count == 0, "A failed peer does not fail the network scan");
+ }
+
+ private static void JobDiscoveryFlowTests()
+ {
+ var window = new MainWindow(Path.Combine(Temporary, "network-flow"), true);
+ var card = window.Cards.Single(c => c.Definition.IsJob);
+ using var http = new HttpClient(new FakeHandler(_ => Health("{\"product\":\"NDI Job Configurator\"}")));
+ var discovery = new JobConfiguratorDiscovery(http, () => [(IPAddress.Parse("192.0.2.1"), 30)]);
+ void Complete(Task task)
+ {
+ var frame = new DispatcherFrame();
+ task.ContinueWith(_ => window.Dispatcher.BeginInvoke(() => frame.Continue = false));
+ Dispatcher.PushFrame(frame); task.GetAwaiter().GetResult();
+ Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.ApplicationIdle);
+ }
+ try
+ {
+ Complete(window.RefreshJobDiscoveryAsync(card, discovery));
+ Check(card.ShowCompact && card.ShowNetworkDiscovery && card.NetworkConfigurators.Count == 1 && card.NetworkDiscoveryStatus == "",
+ $"An uninstalled Job Configurator card gains a web UI link without becoming locally installed (compact={card.ShowCompact}, discovery={card.ShowNetworkDiscovery}, links={card.NetworkConfigurators.Count}, status={card.NetworkDiscoveryStatus})");
+ Complete(window.RefreshJobDiscoveryAsync(card, new JobConfiguratorDiscovery(http, () => [])));
+ Check(card.NetworkConfigurators.Count == 0, "A later check clears stale remote links");
+ using var stalled = new HttpClient(new AsyncHandler(async (_, token) => { await Task.Delay(Timeout.Infinite, token); return Health("{}"); }));
+ var pending = window.RefreshJobDiscoveryAsync(card, new JobConfiguratorDiscovery(stalled, () => [(IPAddress.Parse("192.0.2.1"), 30)]));
+ card.Installed = new("C:\\Fixture\\job.exe", "1.0.0");
+ Complete(window.RefreshJobDiscoveryAsync(card, discovery));
+ Complete(pending);
+ Check(!card.ShowNetworkDiscovery && card.NetworkConfigurators.Count == 0 && card.NetworkDiscoveryStatus == "",
+ "Installing Job Configurator cancels the old scan and hides network discovery");
+ }
+ finally { window.Close(); }
+ }
+}
diff --git a/tests/ToolkitLauncher.Tests/Program.cs b/tests/ToolkitLauncher.Tests/Program.cs
index ddeb9ad..3ae8e3f 100644
--- a/tests/ToolkitLauncher.Tests/Program.cs
+++ b/tests/ToolkitLauncher.Tests/Program.cs
@@ -14,7 +14,7 @@
using ToolkitLauncher;
using ToolkitLauncher.Core;
-internal static class Program
+internal static partial class Program
{
private static int count;
private static readonly Dictionary LiveIcons = [];
@@ -40,6 +40,7 @@ private static int Main(string[] args)
ToolkitUpdateTestsAsync().GetAwaiter().GetResult();
EnvironmentTests();
PcAgentSetupTests();
+ JobDiscoveryTestsAsync().GetAwaiter().GetResult();
if (args.Contains("--environment"))
{
LiveEnvironment = EnvironmentStatusService.ReadAsync(default).GetAwaiter().GetResult();
@@ -624,6 +625,7 @@ private static void ReviewUi(string output)
var app = new App { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.InitializeComponent();
InstallerRefreshTests();
PcAgentSetupFlowTests();
+ JobDiscoveryFlowTests();
ReviewUpdatePrompt(output);
var bindingErrors = new StringWriter();
PresentationTraceSources.DataBindingSource.Listeners.Add(new TextWriterTraceListener(bindingErrors));
@@ -749,7 +751,7 @@ private static void ReviewUi(string output)
scroll.ScrollToTop(); root.UpdateLayout();
}
}
- foreach (var scenario in new[] { "partial-environment", "unverified-environment", "nothing-installed", "setup-download-only", "client-only", "combined-host", "incomplete-agent" })
+ foreach (var scenario in new[] { "partial-environment", "unverified-environment", "nothing-installed", "setup-download-only", "client-only", "combined-host", "incomplete-agent", "network-configurators" })
{
foreach (var card in window.Cards) { card.Busy = false; card.Activity = ""; card.Offline = false; }
var environment = window.Cards[0];
@@ -775,6 +777,11 @@ private static void ReviewUi(string output)
if (scenario == "setup-download-only") environment.Installed = new("C:\\Downloads\\setup.exe", "1.3.2", true);
}
environment.EnvironmentStatus = EnvironmentSnapshot.From(evidence, DateTimeOffset.Now);
+ if (scenario == "network-configurators")
+ {
+ window.Cards[1].NetworkConfigurators.Add(new(new Uri("http://192.0.2.10:8091/")));
+ window.Cards[1].NetworkConfigurators.Add(new(new Uri("http://192.0.2.11:8091/")));
+ }
foreach (var card in window.Cards) card.Recompute();
foreach (var label in Descendants(root)) label.GetBindingExpression(TextBlock.TextProperty)?.UpdateTarget();
foreach (var scale in new[] { 1d, 2d, 2.5d })
@@ -799,8 +806,14 @@ private static void ReviewUi(string output)
{
var view = (AppCard)card.DataContext;
var buttons = Descendants