From 37c8753a66ec319e2818b4f4b3330361ee1000cb Mon Sep 17 00:00:00 2001 From: John Lightfoot <259939203+JohnDevAc@users.noreply.github.com> Date: Sun, 6 Sep 2026 17:29:59 +0100 Subject: [PATCH] Refresh local app cards after installer and maintenance windows close --- Directory.Build.props | 2 +- README.md | 4 +- docs/releases/v1.3.1.md | 7 ++ src/ToolkitLauncher.Core/EnvironmentStatus.cs | 2 +- src/ToolkitLauncher/AppCard.cs | 3 +- src/ToolkitLauncher/MainWindow.xaml.cs | 71 ++++++++++++++++--- tests/ToolkitLauncher.Tests/Program.cs | 51 +++++++++++++ 7 files changed, 125 insertions(+), 15 deletions(-) create mode 100644 docs/releases/v1.3.1.md diff --git a/Directory.Build.props b/Directory.Build.props index 05169f8..48d545f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -3,7 +3,7 @@ enable enable true - 1.3.0 + 1.3.1 John Lightfoot John Lightfoot Copyright © 2026 John Lightfoot. All rights reserved. Proprietary. Free for non-commercial use. diff --git a/README.md b/README.md index 99bdc43..fb0d109 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 diff --git a/docs/releases/v1.3.1.md b/docs/releases/v1.3.1.md new file mode 100644 index 0000000..d9cdd15 --- /dev/null +++ b/docs/releases/v1.3.1.md @@ -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. diff --git a/src/ToolkitLauncher.Core/EnvironmentStatus.cs b/src/ToolkitLauncher.Core/EnvironmentStatus.cs index ab7b364..9470fa0 100644 --- a/src/ToolkitLauncher.Core/EnvironmentStatus.cs +++ b/src/ToolkitLauncher.Core/EnvironmentStatus.cs @@ -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, diff --git a/src/ToolkitLauncher/AppCard.cs b/src/ToolkitLauncher/AppCard.cs index e53060b..1a62e3b 100644 --- a/src/ToolkitLauncher/AppCard.cs +++ b/src/ToolkitLauncher/AppCard.cs @@ -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); } } @@ -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? EnvironmentComponents => EnvironmentStatus?.Components; public bool ShowCompact => Definition.IsEnvironment ? EnvironmentStatus?.State == EnvironmentInstallationState.NotInstalled : Installed is null; public bool ShowDetails => !ShowCompact; diff --git a/src/ToolkitLauncher/MainWindow.xaml.cs b/src/ToolkitLauncher/MainWindow.xaml.cs index 87fc58e..6a1c647 100644 --- a/src/ToolkitLauncher/MainWindow.xaml.cs +++ b/src/ToolkitLauncher/MainWindow.xaml.cs @@ -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; @@ -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."); } @@ -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; diff --git a/tests/ToolkitLauncher.Tests/Program.cs b/tests/ToolkitLauncher.Tests/Program.cs index e63ffc0..e454780 100644 --- a/tests/ToolkitLauncher.Tests/Program.cs +++ b/tests/ToolkitLauncher.Tests/Program.cs @@ -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)); @@ -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();