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.0</Version>
<Version>1.3.1</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: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ A native Windows application for installing, updating and launching four product

## Run

Download **Production-Toolkit-1.3.0-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.1-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 @@ -28,7 +28,7 @@ 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.

The app reads installed versions, icons and release information only on startup and during **Check for updates**. Completing an installation does not trigger another check; click **Check for updates** or reopen the toolkit to refresh the cards afterward. There is no timer, background polling or recheck when focus returns, a channel changes or an app launches. 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.
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.1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Production Toolkit v1.3.1

App cards now refresh automatically when an installer closes. Installed versions, icons, available actions and local environment status update immediately using the saved release information, without another GitHub API request.

The refresh also runs after **Open setup**, repair, uninstall, and setup that exits with an error. All local cards are checked because Environment Setup can install the PC Agent as well. Installer errors and restart requirements remain visible.

Verified with 233 automated assertions, including harmless install/removal fixtures that exercise the real installer-exit path and the existing UI layout checks. The release workflow also validates the Toolkit install, upgrade and uninstall lifecycle.
2 changes: 1 addition & 1 deletion src/ToolkitLauncher.Core/EnvironmentStatus.cs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ public static EnvironmentSnapshot From(EnvironmentEvidence evidence, DateTimeOff
else if (evidence.WatchdogRunning != true) notes.Add(evidence.WatchdogRunning == false ? "KiloLink watchdog is stopped or disabled." : "KiloLink watchdog state is unknown.");
if (evidence.RestartPending) notes.Add("Setup is waiting for a Windows restart or continuation.");
if (!string.IsNullOrWhiteSpace(evidence.Note)) notes.Add(evidence.Note);
notes.Add("Local state checked " + checkedAt.ToLocalTime().ToString("dd MMM, HH:mm") + ". Refresh on startup or Check for updates.");
notes.Add("Local state checked " + checkedAt.ToLocalTime().ToString("dd MMM, HH:mm") + ". Refreshes after setup, on startup or Check for updates.");
return new(state,
[
new("KiloLink Server Pro", kilo,
Expand Down
3 changes: 2 additions & 1 deletion src/ToolkitLauncher/AppCard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public void UpdateIcons(BitmapSource? installed, BitmapSource? remote)
theme = null;
Notify(nameof(Icon)); Notify(nameof(Theme));
}
public void UpdateInstalledIcon(BitmapSource? installed) => UpdateIcons(installed, remoteIcon);
public ReleaseChannel[] Channels { get; } = [ReleaseChannel.Stable, ReleaseChannel.Development];
private ReleaseChannel channel;
public ReleaseChannel Channel { get => channel; set { channel = value; Recompute(); ChannelChanged?.Invoke(this); } }
Expand All @@ -40,7 +41,7 @@ public void UpdateIcons(BitmapSource? installed, BitmapSource? remote)
public bool ShowChannelNote => !Definition.IsEnvironment;
public string EnvironmentSummary => "Environment · " + (EnvironmentStatus?.Status ?? "Not checked");
public string EnvironmentColour => EnvironmentStatus?.Colour ?? "#617082";
public string EnvironmentDetail => EnvironmentStatus?.Detail ?? "Local components are checked on startup or Check for updates.";
public string EnvironmentDetail => EnvironmentStatus?.Detail ?? "Local components are checked on startup, after setup closes or Check for updates.";
public List<EnvironmentComponent>? EnvironmentComponents => EnvironmentStatus?.Components;
public bool ShowCompact => Definition.IsEnvironment ? EnvironmentStatus?.State == EnvironmentInstallationState.NotInstalled : Installed is null;
public bool ShowDetails => !ShowCompact;
Expand Down
71 changes: 61 additions & 10 deletions src/ToolkitLauncher/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,55 @@ private async void RefreshClick(object sender, RoutedEventArgs e)
await CheckToolkitUpdateAsync(userRequested: true);
}

// Recheck all local cards because Environment Setup can also install the PC
// Agent. Keep release snapshots and their API cooldown untouched.
public async Task RefreshInstalledAsync()
{
if (shutdown.IsCancellationRequested) return;
await Task.WhenAll(Cards.Select(async card =>
{
card.Checking = true;
var custom = local.Preferences.LaunchPaths.GetValueOrDefault(card.Definition.Id);
var setup = local.Preferences.Setups.GetValueOrDefault(card.Definition.Id);
try
{
var result = await Task.Run(async () =>
{
var found = InstallationService.Find(card.Definition, custom, setup);
var icon = found is null ? null : IconService.ReadInstalled(found.Path);
var environment = card.Definition.IsEnvironment ? await EnvironmentStatusService.ReadAsync(shutdown) : null;
return (Found: found, Icon: icon, Environment: environment);
}, shutdown);
card.Installed = result.Found;
card.EnvironmentStatus = result.Environment;
card.UpdateInstalledIcon(result.Icon);
}
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
catch (Exception error) { Report(card, "Could not refresh the installed application. " + Friendly(error)); }
finally { card.Checking = false; }
}));
Notify(nameof(Summary));
}

private async Task RunSetupAsync(AppCard card, string path)
{
card.InstallerRunning = true;
card.Activity = "Installer open · complete the steps in its window.";
try
{
using var process = InstallationService.StartSetup(path);
await process.WaitForExitAsync(shutdown);
card.Activity = "";
local.Log($"{card.Name}: installer exited with code {process.ExitCode}.");
// Failed or cancelled setup may still have installed a component, or
// removed one during maintenance. Detection is authoritative, not exit 0.
await RefreshInstalledAsync();
if (process.ExitCode is 3010 or 1641) Report(card, "Installer reports that Windows must restart to finish applying changes.");
else if (process.ExitCode != 0) Report(card, $"Installer exited with code {process.ExitCode}. Check its result before retrying.");
}
finally { card.InstallerRunning = false; }
}

private async Task CheckToolkitUpdateAsync(bool userRequested = false)
{
if (PreviewMode || selfUpdating || shutdown.IsCancellationRequested || Cards.Any(c => c.Busy)) return;
Expand Down Expand Up @@ -272,15 +321,7 @@ private async Task PrepareAsync(AppCard card)
// Several downloads may finish together; serialize external installation wizards.
if (Cards.Any(c => c != card && c.InstallerRunning))
{ Report(card, "Download ready. Finish the other installer, then select " + card.InstallText + "."); return; }
card.InstallerRunning = true;
card.Activity = "Installer open · complete the steps in its window.";
using var process = InstallationService.StartSetup(setupPath!);
await process.WaitForExitAsync(shutdown);
card.Activity = "";
local.Log($"{card.Name}: installer exited with code {process.ExitCode}.");
// Installed versions, icons and releases refresh only at startup or on an explicit check.
if (process.ExitCode is 3010 or 1641) Report(card, "Installer reports that Windows must restart. Reopen the toolkit afterward to refresh version status.");
else if (process.ExitCode != 0) Report(card, $"Installer exited with code {process.ExitCode}. Check its result before retrying.");
await RunSetupAsync(card, setupPath!);
}
catch (OperationCanceledException) { card.Activity = ""; local.Log(card.Name + ": download cancelled."); }
catch (Win32Exception e) when (e.NativeErrorCode == 1223) { card.Activity = ""; local.Log(card.Name + ": administrator prompt cancelled."); }
Expand All @@ -291,16 +332,26 @@ private async Task PrepareAsync(AppCard card)
private static string Friendly(Exception e) => e is TaskCanceledException ? "The connection timed out. Try again when your connection is available." : e.Message;
private void Report(AppCard card, string message) { card.Activity = message; local.Log(card.Name + ": " + message); }
private void CancelClick(object sender, RoutedEventArgs e) => Card(sender).Cancellation?.Cancel();
private void LaunchClick(object sender, RoutedEventArgs e)
private async void LaunchClick(object sender, RoutedEventArgs e)
{
var card = Card(sender);
try
{
if (card.Installed is null) return;
if (card.Definition.IsEnvironment)
{
if (Cards.Any(c => c.InstallerRunning)) { Report(card, "Finish the open installer before starting another installation."); return; }
card.Busy = true; Notify(nameof(CanRefresh));
try { await RunSetupAsync(card, card.Installed.Path); }
finally { card.Busy = false; Notify(nameof(CanRefresh)); }
return;
}
InstallationService.Launch(card.Definition, card.Installed);
card.Activity = "";
local.Log(card.Name + ": launched.");
}
catch (OperationCanceledException) when (shutdown.IsCancellationRequested) { }
catch (Win32Exception error) when (error.NativeErrorCode == 1223) { card.Activity = ""; }
catch (Exception error) { Report(card, Friendly(error)); }
}
public event PropertyChangedEventHandler? PropertyChanged;
Expand Down
51 changes: 51 additions & 0 deletions tests/ToolkitLauncher.Tests/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -498,6 +498,7 @@ private static void ReviewUi(string output)
{
Directory.CreateDirectory(output);
var app = new App { ShutdownMode = ShutdownMode.OnExplicitShutdown }; app.InitializeComponent();
InstallerRefreshTests();
ReviewUpdatePrompt(output);
var bindingErrors = new StringWriter();
PresentationTraceSources.DataBindingSource.Listeners.Add(new TextWriterTraceListener(bindingErrors));
Expand Down Expand Up @@ -674,6 +675,56 @@ private static void ReviewUi(string output)
window.Close();
}

private static void InstallerRefreshTests()
{
var window = new MainWindow(Path.Combine(Temporary, "installer-refresh"), true);
window.Cards.Clear();
var paths = new[] { Path.Combine(Temporary, "installed-job.exe"), Path.Combine(Temporary, "installed-agent.exe") };
for (var i = 0; i < paths.Length; i++)
{
var definition = Catalog.Apps[i == 0 ? 1 : 3] with { KnownPaths = [paths[i]], RegistryNames = [] };
var release = Release("v" + InstallationService.ReadVersion(typeof(MainWindow).Assembly.Location));
release.Assets = [new() { Name = i == 0 ? "NDI-Job-Configurator.exe" : "NDI-Configurator-PC-Agent-win-x64.zip", Size = 100 }];
window.Cards.Add(new AppCard(definition) { Snapshot = new([release], DateTimeOffset.UtcNow), Offline = true });
}
var snapshot = window.Cards[0].Snapshot;
var run = typeof(MainWindow).GetMethod("RunSetupAsync", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!;
void RunFixture(bool remove)
{
var source = Path.Combine(Temporary, remove ? "remove-fixture.cs" : "install-fixture.cs");
var executable = Path.ChangeExtension(source, ".exe");
var code = "using System.IO; class Fixture { static int Main() { " + string.Join(" ", paths.Select(path => remove
? "File.Delete(" + JsonSerializer.Serialize(path) + ");"
: "File.Copy(" + JsonSerializer.Serialize(typeof(MainWindow).Assembly.Location) + ", " + JsonSerializer.Serialize(path) + ", true);"))
+ " return " + (remove ? "1" : "0") + "; } }";
File.WriteAllText(source, code);
var compiler = new ProcessStartInfo(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Windows), @"Microsoft.NET\Framework64\v4.0.30319\csc.exe"))
{ UseShellExecute = false, CreateNoWindow = true };
foreach (var argument in new[] { "/nologo", "/target:winexe", "/out:" + executable, source }) compiler.ArgumentList.Add(argument);
using (var build = Process.Start(compiler)!) { build.WaitForExit(); Check(build.ExitCode == 0, "Harmless installer fixture compiles"); }
var task = (Task)run.Invoke(window, [window.Cards[0], executable])!;
var frame = new DispatcherFrame();
task.ContinueWith(_ => window.Dispatcher.BeginInvoke(() => frame.Continue = false));
Dispatcher.PushFrame(frame);
task.GetAwaiter().GetResult();
}
try
{
Check(window.Cards.All(card => card.Installed is null && card.CanInstall), "Missing local apps initially offer installation");
RunFixture(false);
Check(window.Cards.All(card => card.Installed is not null && card.State == UpdateState.Current && card.CanLaunch && !card.CanInstall),
"Installer exit refreshes every affected card, version and action automatically");
Check(ReferenceEquals(snapshot, window.Cards[0].Snapshot) && window.Cards.All(card => card.Offline),
"Post-install detection retains release cache and online-check state");
RunFixture(true);
Check(window.Cards.All(card => card.Installed is null && card.CanInstall && !card.CanLaunch),
"Maintenance removal refreshes cards even when setup exits with an error");
Check(window.Cards[0].Activity.Contains("code 1") && !window.Cards[0].InstallerRunning,
"Refresh retains the installer failure and clears its running state");
}
finally { window.Close(); }
}

private static void ReviewUpdatePrompt(string output)
{
using var http = new HttpClient();
Expand Down