Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>1.3.3</Version>
<Version>1.3.4</Version>
<Company>John Lightfoot</Company>
<Authors>John Lightfoot</Authors>
<Copyright>Copyright © 2026 John Lightfoot. All rights reserved. Proprietary. Free for non-commercial use.</Copyright>
Expand Down
4 changes: 4 additions & 0 deletions INTEROPERABILITY.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/releases/v1.3.4.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 90 additions & 0 deletions src/ToolkitLauncher.Core/JobConfiguratorDiscovery.cs
Original file line number Diff line number Diff line change
@@ -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<NetworkConfigurator> Instances, bool TimedOut, int Checked, int Total, int UnsupportedNetworks);

public sealed class JobConfiguratorDiscovery(HttpClient http,
Func<IEnumerable<(IPAddress Address, int Prefix)>>? networks = null, TimeSpan? scanBudget = null)
{
public static HttpClient CreateClient() => new(new HttpClientHandler { UseProxy = false, AllowAutoRedirect = false, UseCookies = false })
{ Timeout = Timeout.InfiniteTimeSpan };

public async Task<JobDiscoveryResult> DiscoverAsync(Action<NetworkConfigurator>? 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<string, NetworkConfigurator>();
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<NetworkConfigurator?> 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<IPAddress> 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));
}
5 changes: 5 additions & 0 deletions src/ToolkitLauncher/AppCard.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel;
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using System.Windows.Media;
using System.Windows.Media.Imaging;
Expand Down Expand Up @@ -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<NetworkConfigurator> 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;
Expand Down
8 changes: 8 additions & 0 deletions src/ToolkitLauncher/MainWindow.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,14 @@
<ComboBox Width="150" ItemsSource="{Binding Channels}" SelectedItem="{Binding Channel}" IsEnabled="{Binding CanChoose}" HorizontalAlignment="Center" Margin="0,16,0,12"
AutomationProperties.Name="Release channel" ToolTip="Choose the release channel to install."/>
<Button Content="Install" Style="{StaticResource Primary}" Background="{Binding Theme.Primary}" BorderBrush="{Binding Theme.Primary}" Click="InstallClick" IsEnabled="{Binding CanInstall}"/>
<StackPanel Visibility="{Binding ShowNetworkDiscovery, Converter={StaticResource VisibleIf}}" Margin="0,8,0,0">
<ItemsControl ItemsSource="{Binding NetworkConfigurators}">
<ItemsControl.ItemTemplate><DataTemplate>
<Button Content="{Binding LinkText}" ToolTip="{Binding Address}" Style="{StaticResource Link}" Click="OpenNetworkConfiguratorClick" HorizontalAlignment="Center" Margin="0,4,0,0"/>
</DataTemplate></ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock Text="{Binding NetworkDiscoveryStatus}" FontSize="11" Foreground="{StaticResource Muted}" TextAlignment="Center" Margin="0,4,0,0"/>
</StackPanel>
<Grid Visibility="{Binding Busy, Converter={StaticResource VisibleIf}}" Margin="0,12,0,0">
<Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions>
<ProgressBar Height="5" Value="{Binding Progress}" IsIndeterminate="{Binding InstallerRunning}" VerticalAlignment="Center" Foreground="{Binding Theme.Primary}"/>
Expand Down
Loading