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 @@