From 8ccc8eadb4de981a3f6b54b22072270cf8cd4c6b Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 1 Oct 2026 13:22:25 -0400 Subject: [PATCH 1/3] Track packages that cannot finish until the machine reboots --- docs/IPC.md | 26 ++- src/Languages/lang_en.json | 17 +- .../Infrastructure/AvaloniaBootstrapper.cs | 3 + .../AvaloniaOperationRegistry.cs | 11 +- .../Infrastructure/MacOsNotificationBridge.cs | 8 +- .../OperationNotificationText.cs | 30 +++ .../Infrastructure/SystemRestartService.cs | 144 ++++++++++++ .../Infrastructure/TrayService.cs | 46 +++- .../WindowsAppNotificationBridge.cs | 9 +- .../LogPages/OperationHistoryRowViewModel.cs | 21 +- .../SettingsPages/SettingsBasePage.axaml.cs | 2 +- .../HeadlessIpcHost.cs | 3 + src/UniGetUI.Interface.IpcApi/IpcAppApi.cs | 2 + src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs | 3 + .../IpcOperationApi.cs | 3 + .../IpcPackageApi.cs | 3 + src/UniGetUI.PackageEngine.Enums/Enums.cs | 3 +- .../Helpers/ChocolateyPkgOperationHelper.cs | 7 +- .../Helpers/WinGetPkgOperationHelper.cs | 6 +- .../AbstractOperation.cs | 15 +- .../History/OperationHistoryRecord.cs | 2 + .../PackageOperations.cs | 20 +- .../Reboot/BootSession.cs | 115 ++++++++++ .../Reboot/PendingRebootEntry.cs | 14 ++ .../Reboot/PendingRebootJsonContext.cs | 8 + .../Reboot/PendingRebootStore.cs | 182 +++++++++++++++ .../ChocolateyManagerTests.cs | 20 +- .../PendingRebootStoreTests.cs | 216 ++++++++++++++++++ .../WinGetManagerTests.cs | 10 +- 29 files changed, 900 insertions(+), 49 deletions(-) create mode 100644 src/UniGetUI.Avalonia/Infrastructure/OperationNotificationText.cs create mode 100644 src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs create mode 100644 src/UniGetUI.PackageEngine.Operations/Reboot/BootSession.cs create mode 100644 src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootEntry.cs create mode 100644 src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootJsonContext.cs create mode 100644 src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs create mode 100644 src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs diff --git a/docs/IPC.md b/docs/IPC.md index 6487393fd8..f49d448956 100644 --- a/docs/IPC.md +++ b/docs/IPC.md @@ -248,13 +248,17 @@ These keys are used by package-related endpoints such as install, update, uninst | Method | Path | Auth | Parameters/body | CLI equivalent | Notes | | --- | --- | --- | --- | --- | --- | | `GET` | `/uniget/v1/status` | No | None | `status`, `version` | Returns `running`, `transport`, `tcpPort`, `namedPipeName`, `namedPipePath`, `baseAddress`, `version`, and `buildNumber`. | -| `GET` | `/uniget/v1/app` | Yes | None | `app status` | Returns app/headless/window state. | +| `GET` | `/uniget/v1/app` | Yes | None | `app status` | Returns app/headless/window state, plus `systemRestartPending` and `systemRestartPendingPackages` (see [Pending machine restarts](#pending-machine-restarts)). | | `POST` | `/uniget/v1/app/show` | Yes | None | `app show` | UI-only in practice. | | `POST` | `/uniget/v1/app/navigate` | Yes | Query: `page`, optional `manager`, optional `helpAttachment` | `app navigate` | UI-only in practice. | | `POST` | `/uniget/v1/app/quit` | Yes | None | `app quit` | Shuts down the selected session. | ### Operations +Every operation payload carries `systemRestartRequired`: `true` when the operation succeeded but +the machine must be restarted before the change takes effect. See +[Pending machine restarts](#pending-machine-restarts). + | Method | Path | Auth | Parameters/body | CLI equivalent | | --- | --- | --- | --- | --- | | `GET` | `/uniget/v1/operations` | Yes | None | `operation list` | @@ -380,6 +384,10 @@ them first. ### Packages +Every package payload carries `systemRestartPending`: `true` when that package is one of the +packages this machine is waiting on a restart for. See +[Pending machine restarts](#pending-machine-restarts). + | Method | Path | Auth | Parameters/body | CLI equivalent | Notes | | --- | --- | --- | --- | --- | --- | | `GET` | `/uniget/v1/packages/search` | Yes | Query `query`, optional `manager`, optional `maxResults` | `package search` | Search endpoint. | @@ -400,6 +408,22 @@ them first. | `POST` | `/uniget/v1/packages/update-all` | Yes | None | `package update-all` | Requires `OnUpgradeAll` handler to be wired. | | `POST` | `/uniget/v1/packages/update-manager` | Yes | Query `manager` | `package update-manager` | Requires `OnUpgradeAllForManager` handler to be wired. | +## Pending machine restarts + +Some installers finish successfully but only take effect after the machine reboots (WinGet +`0x8A150109`/`0x8A15010B`, Chocolatey `3010`/`1641`). UniGetUI records those packages and keeps the +record until the machine has actually rebooted — closing and reopening UniGetUI does not clear it. + +These fields expose it, so a fleet tool can tell which machines are waiting on a restart and why: + +| Payload | Field | Meaning | +| --- | --- | --- | +| `GET /uniget/v1/app` | `systemRestartPending` | The machine is waiting on a restart. | +| `GET /uniget/v1/app` | `systemRestartPendingPackages` | How many packages are waiting on it. | +| Operation payloads | `systemRestartRequired` | This operation succeeded but needs a restart. | +| Operation-history entries | `systemRestartRequired` | Same, for a finished operation. | +| Package payloads | `systemRestartPending` | This package is waiting on a restart. | + ## Headless-specific limitations In headless sessions: diff --git a/src/Languages/lang_en.json b/src/Languages/lang_en.json index d5796aa281..9f7e6d6655 100644 --- a/src/Languages/lang_en.json +++ b/src/Languages/lang_en.json @@ -1728,5 +1728,20 @@ "Runs elevated": "Runs elevated", "Processes terminated before the operation": "Processes terminated before the operation", "Unknown package source": "Unknown package source", - "Non-default package source": "Non-default package source" + "Non-default package source": "Non-default package source", + "This operation finished, but the computer must be restarted before the changes take effect": "This operation finished, but the computer must be restarted before the changes take effect", + "Restart computer": "Restart computer", + "Restart computer (1 package pending)": "Restart computer (1 package pending)", + "Restart computer ({0} packages pending)": "Restart computer ({0} packages pending)", + "1 package is waiting for your computer to restart": "1 package is waiting for your computer to restart", + "{0} packages are waiting for your computer to restart": "{0} packages are waiting for your computer to restart", + "UniGetUI and your computer both need to be restarted": "UniGetUI and your computer both need to be restarted", + "Restart your computer?": "Restart your computer?", + "Restart now": "Restart now", + "Not now": "Not now", + "1 package cannot finish until your computer restarts.": "1 package cannot finish until your computer restarts.", + "{0} packages cannot finish until your computer restarts.": "{0} packages cannot finish until your computer restarts.", + "Your computer will restart now. Save your work and close any open applications first.": "Your computer will restart now. Save your work and close any open applications first.", + "The computer could not be restarted": "The computer could not be restarted", + "UniGetUI could not start the restart. Please restart your computer manually.": "UniGetUI could not start the restart. Please restart your computer manually." } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs index fab337800a..83687461b6 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaBootstrapper.cs @@ -16,6 +16,7 @@ using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.Operations.Reboot; using UniGetUI.PackageOperations; namespace UniGetUI.Avalonia.Infrastructure; @@ -255,6 +256,8 @@ private static IpcAppInfo GetAppInfo() CanQuit = true, CurrentPage = window is null ? "" : IpcAppPages.ToPageName(window.CurrentPage.ToString()), SupportedPages = IpcAppPages.SupportedPages, + SystemRestartPending = PendingRebootStore.HasPending, + SystemRestartPendingPackages = PendingRebootStore.PendingCount, }; } diff --git a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs index d0ab2e941e..8259398667 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/AvaloniaOperationRegistry.cs @@ -34,7 +34,7 @@ public static class AvaloniaOperationRegistry private static readonly ConcurrentDictionary _errorCounts = new(); private static int _errorsOccurred; public static int ErrorsOccurred => _errorsOccurred; - public static bool RestartRequired { get; set; } + public static bool AppRestartRequired { get; set; } private static bool _shortcutDialogOpen; @@ -229,13 +229,8 @@ private static void ShowOperationSuccessNotification(AbstractOperation op) if (Settings.AreSuccessNotificationsDisabled()) return; - string title = op.Metadata.SuccessTitle.Length > 0 - ? op.Metadata.SuccessTitle - : CoreTools.Translate("Success!"); - - string message = op.Metadata.SuccessMessage.Length > 0 - ? op.Metadata.SuccessMessage - : CoreTools.Translate("Success!"); + string title = OperationNotificationText.SuccessTitle(op); + string message = OperationNotificationText.SuccessMessage(op); AccessibilityAnnouncementService.Announce( $"{title}. {message}", diff --git a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs index 3c36618fcf..e380c3ad76 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/MacOsNotificationBridge.cs @@ -52,12 +52,8 @@ public static bool ShowSuccess(AbstractOperation operation) if (Settings.AreSuccessNotificationsDisabled()) return false; try { - string title = operation.Metadata.SuccessTitle.Length > 0 - ? operation.Metadata.SuccessTitle - : CoreTools.Translate("Success!"); - string message = operation.Metadata.SuccessMessage.Length > 0 - ? operation.Metadata.SuccessMessage - : CoreTools.Translate("Success!"); + string title = OperationNotificationText.SuccessTitle(operation); + string message = OperationNotificationText.SuccessMessage(operation); DeliverNotification(title, message, MainWindow.RuntimeNotificationLevel.Success, allowInAppFallback: false); return true; } diff --git a/src/UniGetUI.Avalonia/Infrastructure/OperationNotificationText.cs b/src/UniGetUI.Avalonia/Infrastructure/OperationNotificationText.cs new file mode 100644 index 0000000000..e857356e0a --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/OperationNotificationText.cs @@ -0,0 +1,30 @@ +using UniGetUI.Core.Tools; +using UniGetUI.PackageOperations; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class OperationNotificationText +{ + public static string SuccessTitle(AbstractOperation operation) + => operation.Metadata.SuccessTitle.Length > 0 + ? operation.Metadata.SuccessTitle + : CoreTools.Translate("Success!"); + + public static string SuccessMessage(AbstractOperation operation) + { + string message = operation.Metadata.SuccessMessage.Length > 0 + ? operation.Metadata.SuccessMessage + : CoreTools.Translate("Success!"); + + if (!operation.SystemRestartRequired) return message; + + message = message.TrimEnd(); + if (message.Length > 0 && !".!?:;。!?".Contains(message[^1])) + message += "."; + + return message + + " " + + CoreTools.Translate( + "This operation finished, but the computer must be restarted before the changes take effect"); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs new file mode 100644 index 0000000000..3f860dcd37 --- /dev/null +++ b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs @@ -0,0 +1,144 @@ +using System.Diagnostics; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Threading; +using UniGetUI.Avalonia.Views.DialogPages; +using UniGetUI.Core.Logging; +using UniGetUI.Core.Tools; +using UniGetUI.PackageEngine.Operations.Reboot; + +namespace UniGetUI.Avalonia.Infrastructure; + +internal static class SystemRestartService +{ + private const int ShutdownRequestTimeoutMs = 10000; + + public static async Task ConfirmAndRestartAsync(Window owner) + { + try + { + if (!await ConfirmAsync(owner)) return; + + if (Restart()) + PendingRebootStore.ClearAll(); + else + ReportRestartFailure(); + } + catch (Exception ex) + { + Logger.Error("Failed to prompt for a system restart:"); + Logger.Error(ex); + } + } + + private static async Task ConfirmAsync(Window owner) + { + var pending = PendingRebootStore.GetPending(); + if (pending.Count == 0) return false; + + var body = new StackPanel { Spacing = 12 }; + body.Children.Add(new TextBlock + { + Text = pending.Count == 1 + ? CoreTools.Translate("1 package cannot finish until your computer restarts.") + : CoreTools.Translate( + "{0} packages cannot finish until your computer restarts.", + pending.Count), + Opacity = 0.82, + TextWrapping = TextWrapping.Wrap, + }); + + body.Children.Add(new ScrollViewer + { + MaxHeight = 180, + Content = new TextBlock + { + Text = string.Join( + Environment.NewLine, + pending + .Select(entry => entry.PackageName.Length > 0 ? entry.PackageName : entry.PackageId) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .Select(name => "* " + name)), + FontFamily = new FontFamily("Consolas"), + TextWrapping = TextWrapping.Wrap, + }, + }); + + body.Children.Add(new TextBlock + { + Text = CoreTools.Translate( + "Your computer will restart now. Save your work and close any open applications first."), + TextWrapping = TextWrapping.Wrap, + }); + + var dialog = new ImmersiveConfirmationDialog( + CoreTools.Translate("Restart your computer?"), + body, + CoreTools.Translate("Restart now"), + CoreTools.Translate("Not now")) + { + MaxWidth = 560, + MaxHeight = 420, + FocusPrimaryButton = false, + }; + + await dialog.ShowDialog(owner); + return dialog.Result is true; + } + + private static bool Restart() + { + if (!OperatingSystem.IsWindows()) + { + Logger.Warn("A system restart was requested on a platform where UniGetUI cannot trigger one"); + return false; + } + + try + { + using var process = Process.Start(new ProcessStartInfo + { + FileName = Path.Combine(Environment.SystemDirectory, "shutdown.exe"), + Arguments = "/r /t 0", + UseShellExecute = false, + CreateNoWindow = true, + }); + + if (process is null) + { + Logger.Error("Could not start shutdown.exe to request a system restart"); + return false; + } + + if (!process.WaitForExit(ShutdownRequestTimeoutMs)) + { + Logger.Warn("shutdown.exe did not return in time; assuming the restart was requested"); + return true; + } + + if (process.ExitCode != 0) + { + Logger.Error($"shutdown.exe refused the restart request with exit code {process.ExitCode}"); + return false; + } + + return true; + } + catch (Exception ex) + { + Logger.Error("Failed to request a system restart:"); + Logger.Error(ex); + return false; + } + } + + private static void ReportRestartFailure() + { + Dispatcher.UIThread.Post(() => + Views.MainWindow.Instance?.ShowRuntimeNotification( + CoreTools.Translate("The computer could not be restarted"), + CoreTools.Translate( + "UniGetUI could not start the restart. Please restart your computer manually."), + Views.MainWindow.RuntimeNotificationLevel.Error)); + } +} diff --git a/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs b/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs index c1311c2f12..f19d76fbb3 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/TrayService.cs @@ -9,6 +9,7 @@ using UniGetUI.Core.SettingsEngine; using UniGetUI.Core.Tools; using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Operations.Reboot; using UniGetUI.PackageEngine.PackageLoader; namespace UniGetUI.Avalonia.Infrastructure; @@ -16,16 +17,29 @@ namespace UniGetUI.Avalonia.Infrastructure; internal sealed class TrayService : IDisposable { private readonly TrayIcon _trayIcon; + private readonly NativeMenuItem _restartComputerItem; private string _lastIconUri = ""; public TrayService(MainWindow owner) { + _restartComputerItem = new NativeMenuItem(CoreTools.Translate("Restart computer")) + { + IsVisible = false, + }; + _restartComputerItem.Click += (_, _) => Dispatcher.UIThread.Post(() => + { + owner.ShowFromTray(); + _ = SystemRestartService.ConfirmAndRestartAsync(owner); + }); + _trayIcon = new TrayIcon { ToolTipText = "UniGetUI", - Menu = BuildMenu(owner), + Menu = BuildMenu(owner, _restartComputerItem), }; + PendingRebootStore.Changed += OnPendingRebootStoreChanged; + _trayIcon.Clicked += (_, _) => Dispatcher.UIThread.Post(() => owner.ShowFromTray()); var app = Application.Current!; @@ -48,6 +62,17 @@ public void UpdateStatus() int updatesCount = UpgradablePackagesLoader.Instance?.Count() ?? 0; + int rebootPendingCount = PendingRebootStore.PendingCount; + bool appRestartRequired = AvaloniaOperationRegistry.AppRestartRequired; + + _restartComputerItem.IsVisible = rebootPendingCount > 0; + _restartComputerItem.Header = rebootPendingCount switch + { + 0 => CoreTools.Translate("Restart computer"), + 1 => CoreTools.Translate("Restart computer (1 package pending)"), + _ => CoreTools.Translate("Restart computer ({0} packages pending)", rebootPendingCount), + }; + if (anyRunning) { status = "blue"; @@ -58,10 +83,18 @@ public void UpdateStatus() status = "orange"; tooltip = CoreTools.Translate("Attention required"); } - else if (AvaloniaOperationRegistry.RestartRequired) + else if (rebootPendingCount > 0 || appRestartRequired) { status = "turquoise"; - tooltip = CoreTools.Translate("Restart required"); + tooltip = (rebootPendingCount, appRestartRequired) switch + { + (0, _) => CoreTools.Translate("Restart UniGetUI to fully apply changes"), + (1, false) => CoreTools.Translate( + "1 package is waiting for your computer to restart"), + (_, false) => CoreTools.Translate( + "{0} packages are waiting for your computer to restart", rebootPendingCount), + _ => CoreTools.Translate("UniGetUI and your computer both need to be restarted"), + }; } else if (updatesCount > 0) { @@ -117,7 +150,10 @@ private static bool IsTaskbarLight() #endif } - private static NativeMenu BuildMenu(MainWindow owner) + private void OnPendingRebootStoreChanged(object? sender, EventArgs e) + => Dispatcher.UIThread.Post(UpdateStatus); + + private static NativeMenu BuildMenu(MainWindow owner, NativeMenuItem restartComputerItem) { var menu = new NativeMenu(); @@ -133,6 +169,7 @@ private static NativeMenu BuildMenu(MainWindow owner) menu.Add(discover); menu.Add(updates); menu.Add(installed); + menu.Add(restartComputerItem); menu.Add(new NativeMenuItemSeparator()); menu.Add(new NativeMenuItem( @@ -155,6 +192,7 @@ private static NativeMenu BuildMenu(MainWindow owner) public void Dispose() { + PendingRebootStore.Changed -= OnPendingRebootStoreChanged; var app = Application.Current; if (app is not null) TrayIcon.GetIcons(app)?.Remove(_trayIcon); diff --git a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs index cf970449fc..d6b07d5402 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/WindowsAppNotificationBridge.cs @@ -72,13 +72,8 @@ public static bool ShowProgress(AbstractOperation operation) public static bool ShowSuccess(AbstractOperation operation) { - string title = operation.Metadata.SuccessTitle.Length > 0 - ? operation.Metadata.SuccessTitle - : CoreTools.Translate("Success!"); - - string message = operation.Metadata.SuccessMessage.Length > 0 - ? operation.Metadata.SuccessMessage - : CoreTools.Translate("Success!"); + string title = OperationNotificationText.SuccessTitle(operation); + string message = OperationNotificationText.SuccessMessage(operation); return Show(title, message, MainWindow.RuntimeNotificationLevel.Success, launchAction: NotificationArguments.Show, allowInAppFallback: false); } diff --git a/src/UniGetUI.Avalonia/ViewModels/Pages/LogPages/OperationHistoryRowViewModel.cs b/src/UniGetUI.Avalonia/ViewModels/Pages/LogPages/OperationHistoryRowViewModel.cs index 6f94d28474..da7130ce9e 100644 --- a/src/UniGetUI.Avalonia/ViewModels/Pages/LogPages/OperationHistoryRowViewModel.cs +++ b/src/UniGetUI.Avalonia/ViewModels/Pages/LogPages/OperationHistoryRowViewModel.cs @@ -64,6 +64,8 @@ public string SourceLabel public string StatusLabel => Record.Status switch { + OperationHistoryRecord.StatusSucceeded when Record.SystemRestartRequired + => CoreTools.Translate("Restart required"), OperationHistoryRecord.StatusSucceeded => CoreTools.Translate("Succeeded"), OperationHistoryRecord.StatusFailed => CoreTools.Translate("Failed"), OperationHistoryRecord.StatusCanceled => CoreTools.Translate("Canceled"), @@ -72,14 +74,26 @@ public string SourceLabel public StatusBadgeSeverity StatusSeverity => Record.Status switch { + OperationHistoryRecord.StatusSucceeded when Record.SystemRestartRequired + => StatusBadgeSeverity.Warning, OperationHistoryRecord.StatusSucceeded => StatusBadgeSeverity.Success, OperationHistoryRecord.StatusFailed => StatusBadgeSeverity.Error, _ => StatusBadgeSeverity.Info, }; - public string StatusTooltip => Record.ExitCode is { } code - ? CoreTools.Translate("Exit code: {0}", code) - : StatusLabel; + public string StatusTooltip + { + get + { + string status = Record.SystemRestartRequired + ? CoreTools.Translate( + "This operation finished, but the computer must be restarted before the changes take effect") + : StatusLabel; + return Record.ExitCode is { } code + ? $"{status} ({CoreTools.Translate("Exit code: {0}", code)})" + : status; + } + } public DateTime Timestamp => DateTime.TryParse(Record.TimestampUtc, null, @@ -116,6 +130,7 @@ public string DetailsSummary parts.Add(PackageId.Length > 0 && PackageId != TargetName ? $"{TargetName} ({PackageId})" : TargetName); if (VersionChange.Length > 0) parts.Add(VersionChange); parts.Add(Record.Status); + if (Record.SystemRestartRequired) parts.Add("restart-required"); if (Record.ExitCode is { } code) parts.Add(CoreTools.Translate("exit {0}", code)); if (Record.FailureSummary.Length > 0) parts.Add(Record.FailureSummary); return string.Join(" · ", parts); diff --git a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs index 1dd54a264f..6622731675 100644 --- a/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs +++ b/src/UniGetUI.Avalonia/Views/Pages/SettingsPages/SettingsBasePage.axaml.cs @@ -120,7 +120,7 @@ private async void Page_NavigationRequested(object? sender, Type e) private void Page_RestartRequired(object? sender, EventArgs e) { VM.IsRestartBannerVisible = true; - AvaloniaOperationRegistry.RestartRequired = true; + AvaloniaOperationRegistry.AppRestartRequired = true; MainWindow.Instance?.UpdateSystemTrayStatus(); } diff --git a/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs b/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs index 212169cda9..60f7c008e5 100644 --- a/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs +++ b/src/UniGetUI.Interface.IpcApi/HeadlessIpcHost.cs @@ -1,4 +1,5 @@ using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Operations.Reboot; namespace UniGetUI.Interface; @@ -76,6 +77,8 @@ private static IpcServer CreateIpcServer(Action requestShutdown) CanNavigate = false, CanQuit = true, SupportedPages = IpcAppPages.SupportedPages, + SystemRestartPending = PendingRebootStore.HasPending, + SystemRestartPendingPackages = PendingRebootStore.PendingCount, }; backgroundApi.ShowAppHandler = () => throw new InvalidOperationException( diff --git a/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs index 05ea7b5734..ee19952921 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcAppApi.cs @@ -10,6 +10,8 @@ public sealed class IpcAppInfo public bool CanQuit { get; set; } public string CurrentPage { get; set; } = ""; public IReadOnlyList SupportedPages { get; set; } = IpcAppPages.SupportedPages; + public bool SystemRestartPending { get; set; } + public int SystemRestartPendingPackages { get; set; } } public sealed class IpcAppNavigateRequest diff --git a/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs b/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs index 98f27f0849..e7092d2442 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcLogsApi.cs @@ -31,6 +31,7 @@ public class IpcOperationHistoryEntry public int OutputLineCount { get; set; } public int? ExitCode { get; set; } public string FailureSummary { get; set; } = ""; + public bool SystemRestartRequired { get; set; } public string Content { get; set; } = ""; } @@ -114,6 +115,7 @@ private static void CopyEntryFields(OperationHistoryRecord record, IpcOperationH entry.OutputLineCount = record.Output.Count; entry.ExitCode = record.ExitCode; entry.FailureSummary = record.FailureSummary; + entry.SystemRestartRequired = record.SystemRestartRequired; entry.Content = BuildSummary(record); } @@ -128,6 +130,7 @@ private static string BuildSummary(OperationHistoryRecord record) if (target.Length > 0) parts.Add(target); if (version.Length > 0) parts.Add($"({version})"); parts.Add($"[{record.Status}]"); + if (record.SystemRestartRequired) parts.Add("[restart-required]"); return string.Join(' ', parts); } diff --git a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs index ce46175051..4f14159ca3 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcOperationApi.cs @@ -30,6 +30,7 @@ public class IpcOperationInfo public IpcPackageInfo? Package { get; set; } public string ManagerName { get; set; } = ""; public string SourceName { get; set; } = ""; + public bool SystemRestartRequired { get; set; } } public sealed class IpcOperationDetails : IpcOperationInfo @@ -170,6 +171,7 @@ public static IpcOperationDetails GetOperation(string operationId) Package = info.Package, ManagerName = info.ManagerName, SourceName = info.SourceName, + SystemRestartRequired = info.SystemRestartRequired, Output = tracked.GetOutputSnapshot(), }; } @@ -311,6 +313,7 @@ private static IpcOperationInfo CreateOperationInfo(TrackedOperation tracked) Package = GetOperationPackage(operation), ManagerName = GetManagerName(operation), SourceName = GetSourceName(operation), + SystemRestartRequired = operation.SystemRestartRequired, }; } diff --git a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs index 841e50c352..07242c3b12 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs @@ -5,6 +5,7 @@ using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; using UniGetUI.PackageEngine.Operations; +using UniGetUI.PackageEngine.Operations.Reboot; using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.PackageLoader; using UniGetUI.PackageEngine.Serializable; @@ -21,6 +22,7 @@ public sealed class IpcPackageInfo public string Source { get; set; } = ""; public string Manager { get; set; } = ""; public bool IsUpgradable { get; set; } + public bool SystemRestartPending { get; set; } } public sealed class IpcPackageActionRequest @@ -810,6 +812,7 @@ private static IpcPackageInfo ToIpcPackageInfo(IPackage package) Source = package.Source.AsString_DisplayName, Manager = IpcManagerSettingsApi.GetPublicManagerId(package.Manager), IsUpgradable = package.IsUpgradable, + SystemRestartPending = PendingRebootStore.IsPending(package.Manager.Id, package.Id), }; } } diff --git a/src/UniGetUI.PackageEngine.Enums/Enums.cs b/src/UniGetUI.PackageEngine.Enums/Enums.cs index 2061d7b832..04994c4532 100644 --- a/src/UniGetUI.PackageEngine.Enums/Enums.cs +++ b/src/UniGetUI.PackageEngine.Enums/Enums.cs @@ -43,8 +43,7 @@ public enum OperationVeredict Success, Failure, Canceled, - - // RestartRequired, + RestartRequired, AutoRetry, } diff --git a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyPkgOperationHelper.cs index e3f16a0a2c..2a56a1947f 100644 --- a/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.Chocolatey/Helpers/ChocolateyPkgOperationHelper.cs @@ -76,13 +76,12 @@ protected override OperationVeredict _getOperationResult( int returnCode ) { - if (returnCode is 3010) + if (returnCode is 3010 or 1641) { - return OperationVeredict.Success; - // return OperationVeredict.RestartRequired; + return OperationVeredict.RestartRequired; } - if (returnCode is 1641 or 1614 or 1605 or 0) + if (returnCode is 1614 or 1605 or 0) { return OperationVeredict.Success; } diff --git a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs index 460b111310..4c89179d60 100644 --- a/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs +++ b/src/UniGetUI.PackageEngine.Managers.WinGet/Helpers/WinGetPkgOperationHelper.cs @@ -294,12 +294,12 @@ int returnCode // See https://github.com/microsoft/winget-cli/blob/master/doc/windows/package-manager/winget/returnCodes.md for reference uint uintCode = (uint)returnCode; - if (uintCode is 0x8A150109) - { // TODO: Restart required to finish installation + if (uintCode is 0x8A150109 or 0x8A15010B) + { if (operation is OperationType.Update or OperationType.Install) // Pending-restart sticks after reboot; don't count it as a phantom no-op (#5042). MarkUpgradeAsDone(package, countTowardStuckLoop: false); - return OperationVeredict.Success; + return OperationVeredict.RestartRequired; } if (uintCode is 0x8A150077 or 0x8A15010C or 0x8A150005) diff --git a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs index bb42bd06cf..b7ceb92701 100644 --- a/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs +++ b/src/UniGetUI.PackageEngine.Operations/AbstractOperation.cs @@ -45,6 +45,8 @@ public OperationStatus Status } } + public bool SystemRestartRequired { get; private set; } + public void ApplyCapabilities(bool admin, bool interactive, bool skiphash, string? scope) { BadgesChanged?.Invoke(this, new BadgeCollection(admin, interactive, skiphash, scope)); @@ -348,12 +350,21 @@ private async Task MainThreadCore(CancellationTokenSource runCancellation) while (OperationQueue.Remove(this)) ; - if (result == OperationVeredict.Success) + SystemRestartRequired = result is OperationVeredict.RestartRequired; + + if (result is OperationVeredict.Success or OperationVeredict.RestartRequired) { Status = OperationStatus.Succeeded; OperationSucceeded?.Invoke(this, EventArgs.Empty); OperationFinished?.Invoke(this, EventArgs.Empty); Line(Metadata.SuccessMessage, LineType.Information); + if (SystemRestartRequired) + Line( + CoreTools.Translate( + "This operation finished, but the computer must be restarted before the changes take effect" + ), + LineType.Information + ); } else if (result == OperationVeredict.Failure) { @@ -541,7 +552,7 @@ private async Task _runOperation() } } while (result is OperationVeredict.AutoRetry); - if (result is not OperationVeredict.Success) + if (result is not (OperationVeredict.Success or OperationVeredict.RestartRequired)) return result; // Process postoperations diff --git a/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs b/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs index 7d69fb478c..9cdd99d1e7 100644 --- a/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs +++ b/src/UniGetUI.PackageEngine.Operations/History/OperationHistoryRecord.cs @@ -43,6 +43,7 @@ public sealed class OperationHistoryRecord /// written before this was tracked, and for operations that never ran a process. /// public bool? RanElevated { get; set; } + public bool SystemRestartRequired { get; set; } /// Short human-readable reason, derived from the last error line (mainly for failures). public string FailureSummary { get; set; } = ""; public List Output { get; set; } = []; @@ -74,6 +75,7 @@ public static OperationHistoryRecord FromOperation(AbstractOperation op, string Role = (int)OperationType.None, Status = status, TimestampUtc = DateTime.UtcNow.ToString("O"), + SystemRestartRequired = op.SystemRestartRequired, }; try diff --git a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs index f5f900d14d..cb3be98a8d 100644 --- a/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs +++ b/src/UniGetUI.PackageEngine.Operations/PackageOperations.cs @@ -10,6 +10,7 @@ using UniGetUI.PackageEngine.Classes.Packages.Classes; using UniGetUI.PackageEngine.Enums; using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations.Reboot; using UniGetUI.PackageEngine.PackageClasses; using UniGetUI.PackageEngine.PackageLoader; using UniGetUI.PackageEngine.Serializable; @@ -132,10 +133,27 @@ public PackageOperation( if (status is OperationStatus.Canceled) Package.SetTag(PackageTag.Default); }; + OperationSucceeded += (_, _) => RecordPendingRebootState(); OperationSucceeded += (_, _) => HandleSuccess(); OperationFailed += (_, _) => HandleFailure(); } + private void RecordPendingRebootState() + { + try + { + if (SystemRestartRequired) + PendingRebootStore.Record(Package, Role); + else if (Role is OperationType.Uninstall) + PendingRebootStore.Clear(Package.Manager.Id, Package.Id); + } + catch (Exception ex) + { + Logger.Warn("Failed to update the pending-reboot store"); + Logger.Warn(ex); + } + } + public static bool HasPendingOperation(IPackage package, OperationType role) { if (package.Tag is not (PackageTag.OnQueue or PackageTag.BeingProcessed)) @@ -789,7 +807,7 @@ private async Task InterpretBrokerTerminalStatus(BrokerStatus // the manager's result parser, like the local process path does. Only // real process output is passed; internal informational lines are not. var veredict = await GetProcessVeredict(status.ExitCode ?? -1, _brokerStreamedOutput ?? []); - if (veredict is OperationVeredict.Success) + if (veredict is OperationVeredict.Success or OperationVeredict.RestartRequired) { Line("Operation completed successfully via agent broker.", LineType.Information); } diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/BootSession.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/BootSession.cs new file mode 100644 index 0000000000..57fee018b1 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/BootSession.cs @@ -0,0 +1,115 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using UniGetUI.Core.Logging; + +namespace UniGetUI.PackageEngine.Operations.Reboot; + +public static class BootSession +{ + public static string? TestIdOverride { get; set; } + public static long? TestUptimeOverride { get; set; } + + private const int SystemBootEnvironmentInformation = 90; + + [StructLayout(LayoutKind.Sequential)] + private struct BootEnvironmentInformation + { + public Guid BootIdentifier; + public int FirmwareType; + public ulong BootFlags; + } + + [SupportedOSPlatform("windows")] + [DllImport("ntdll.dll")] + private static extern int NtQuerySystemInformation( + int systemInformationClass, + out BootEnvironmentInformation systemInformation, + int systemInformationLength, + IntPtr returnLength + ); + + [SupportedOSPlatform("windows")] + [DllImport("kernelbase.dll", SetLastError = false)] + private static extern void QueryInterruptTime(out ulong lpInterruptTime); + + public static string GetId() + { + if (TestIdOverride is { } overriden) return overriden; + + if (OperatingSystem.IsWindows()) + { + try + { + int status = NtQuerySystemInformation( + SystemBootEnvironmentInformation, + out BootEnvironmentInformation info, + Marshal.SizeOf(), + IntPtr.Zero); + + if (status == 0 && info.BootIdentifier != Guid.Empty) + return info.BootIdentifier.ToString("N"); + + Logger.Warn($"Could not read the Windows boot identifier (NTSTATUS 0x{status:X8})"); + } + catch (Exception ex) + { + Logger.Warn("Could not read the Windows boot identifier"); + Logger.Warn(ex); + } + } + else if (OperatingSystem.IsLinux()) + { + try + { + string id = File.ReadAllText("/proc/sys/kernel/random/boot_id").Trim(); + if (id.Length > 0) return id; + } + catch (Exception ex) + { + Logger.Warn("Could not read /proc/sys/kernel/random/boot_id"); + Logger.Warn(ex); + } + } + + return ""; + } + + public static long GetUptimeTicks() + { + if (TestUptimeOverride is { } overriden) return overriden; + + if (OperatingSystem.IsWindows()) + { + try + { + QueryInterruptTime(out ulong interruptTime); + if (interruptTime > 0) return (long)interruptTime; + } + catch (Exception ex) + { + Logger.Warn("QueryInterruptTime is unavailable; falling back to the monotonic tick count"); + Logger.Warn(ex); + } + } + else if (OperatingSystem.IsLinux()) + { + try + { + string uptime = File.ReadAllText("/proc/uptime").Split(' ')[0]; + if (double.TryParse(uptime, System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out double seconds) + && seconds > 0) + { + return (long)(seconds * TimeSpan.TicksPerSecond); + } + } + catch (Exception ex) + { + Logger.Warn("Could not read the uptime from /proc/uptime"); + Logger.Warn(ex); + } + } + + return Environment.TickCount64 * TimeSpan.TicksPerMillisecond; + } +} diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootEntry.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootEntry.cs new file mode 100644 index 0000000000..7b28f2f1a5 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootEntry.cs @@ -0,0 +1,14 @@ +namespace UniGetUI.PackageEngine.Operations.Reboot; + +public sealed class PendingRebootEntry +{ + public string PackageId { get; set; } = ""; + public string PackageName { get; set; } = ""; + public string ManagerName { get; set; } = ""; + public string SourceName { get; set; } = ""; + public string Version { get; set; } = ""; + public string Kind { get; set; } = ""; + public string RecordedAtUtc { get; set; } = ""; + public string BootId { get; set; } = ""; + public long UptimeTicks { get; set; } +} diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootJsonContext.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootJsonContext.cs new file mode 100644 index 0000000000..ab92c24644 --- /dev/null +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootJsonContext.cs @@ -0,0 +1,8 @@ +using System.Text.Json.Serialization; + +namespace UniGetUI.PackageEngine.Operations.Reboot; + +[JsonSourceGenerationOptions(WriteIndented = true)] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(PendingRebootEntry))] +internal sealed partial class PendingRebootJsonContext : JsonSerializerContext; diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs new file mode 100644 index 0000000000..1e1a9d055a --- /dev/null +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs @@ -0,0 +1,182 @@ +using System.Text.Json; +using UniGetUI.Core.Data; +using UniGetUI.Core.Logging; +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; + +namespace UniGetUI.PackageEngine.Operations.Reboot; + +public static class PendingRebootStore +{ + private const int MaxEntries = 500; + private static readonly object _lock = new(); + private static List? _cache; + + public static event EventHandler? Changed; + + public static string? TestFilePathOverride { get; set; } + + private static string FilePath + => TestFilePathOverride ?? Path.Join(CoreData.UniGetUIUserConfigurationDirectory, "PendingReboots.json"); + + public static void InvalidateCache() + { + lock (_lock) _cache = null; + } + + public static IReadOnlyList GetPending() + { + lock (_lock) return LoadUnlocked().ToArray(); + } + + public static int PendingCount + { + get { lock (_lock) return LoadUnlocked().Count; } + } + + public static bool HasPending => PendingCount > 0; + + public static bool IsPending(string managerName, string packageId) + { + if (string.IsNullOrEmpty(packageId)) return false; + lock (_lock) + { + return LoadUnlocked().Any(entry => Matches(entry, managerName, packageId)); + } + } + + public static void Record(IPackage package, OperationType role) + { + var entry = new PendingRebootEntry + { + PackageId = package.Id, + PackageName = package.Name, + ManagerName = package.Manager.Id, + SourceName = package.Source.Name, + Version = role is OperationType.Update ? package.NewVersionString : package.VersionString, + Kind = KindFor(role), + RecordedAtUtc = DateTime.UtcNow.ToString("O"), + BootId = BootSession.GetId(), + UptimeTicks = BootSession.GetUptimeTicks(), + }; + + lock (_lock) + { + var list = LoadUnlocked(); + list.RemoveAll(existing => Matches(existing, entry.ManagerName, entry.PackageId)); + list.Insert(0, entry); + if (list.Count > MaxEntries) + list.RemoveRange(MaxEntries, list.Count - MaxEntries); + SaveUnlocked(); + } + + Changed?.Invoke(null, EventArgs.Empty); + } + + public static void Clear(string managerName, string packageId) + { + bool changed; + lock (_lock) + { + var list = LoadUnlocked(); + changed = list.RemoveAll(entry => Matches(entry, managerName, packageId)) > 0; + if (changed) SaveUnlocked(); + } + + if (changed) Changed?.Invoke(null, EventArgs.Empty); + } + + public static void ClearAll() + { + bool changed; + lock (_lock) + { + var list = LoadUnlocked(); + changed = list.Count > 0; + list.Clear(); + if (changed) SaveUnlocked(); + } + + if (changed) Changed?.Invoke(null, EventArgs.Empty); + } + + private static bool Matches(PendingRebootEntry entry, string managerName, string packageId) + => entry.PackageId.Equals(packageId, StringComparison.OrdinalIgnoreCase) + && entry.ManagerName.Equals(managerName, StringComparison.OrdinalIgnoreCase); + + private static string KindFor(OperationType role) => role switch + { + OperationType.Install => "install-package", + OperationType.Update => "update-package", + OperationType.Uninstall => "uninstall-package", + _ => "", + }; + + private static List LoadUnlocked() + { + if (_cache is not null) return _cache; + + var loaded = new List(); + try + { + if (File.Exists(FilePath)) + { + string json = File.ReadAllText(FilePath); + var typeInfo = PendingRebootJsonContext.Default.ListPendingRebootEntry; + loaded = JsonSerializer.Deserialize(json, typeInfo) ?? []; + } + } + catch (Exception ex) + { + Logger.Warn("Failed to read the pending-reboot store; starting empty"); + Logger.Warn(ex); + loaded = []; + } + + _cache = loaded; + if (DropEntriesFromPreviousBootsUnlocked()) + SaveUnlocked(); + + return _cache; + } + + private static bool DropEntriesFromPreviousBootsUnlocked() + { + if (_cache is null || _cache.Count == 0) return false; + + string currentBootId = BootSession.GetId(); + long currentUptime = BootSession.GetUptimeTicks(); + int removed = _cache.RemoveAll( + entry => RecordedBeforeCurrentBoot(entry, currentBootId, currentUptime)); + if (removed > 0) + Logger.Info($"Discarded {removed} pending-reboot entries recorded before the current boot"); + return removed > 0; + } + + private static bool RecordedBeforeCurrentBoot( + PendingRebootEntry entry, + string currentBootId, + long currentUptimeTicks) + { + if (currentBootId.Length > 0 && entry.BootId.Length > 0) + return !string.Equals(entry.BootId, currentBootId, StringComparison.OrdinalIgnoreCase); + + if (entry.UptimeTicks <= 0) return true; + return currentUptimeTicks < entry.UptimeTicks; + } + + private static void SaveUnlocked() + { + try + { + var typeInfo = PendingRebootJsonContext.Default.ListPendingRebootEntry; + string json = JsonSerializer.Serialize(_cache ?? [], typeInfo); + File.WriteAllText(FilePath, json); + } + catch (Exception ex) + { + Logger.Warn("Failed to persist the pending-reboot store"); + Logger.Warn(ex); + } + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs index e3439fc6a5..b32a5e7c56 100644 --- a/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/ChocolateyManagerTests.cs @@ -233,8 +233,6 @@ public void UninstallParametersUseUninstallVerbAndOnlyUninstallCustomParameters( [Theory] [InlineData(0)] - [InlineData(3010)] - [InlineData(1641)] [InlineData(1614)] [InlineData(1605)] public void OperationResultTreatsChocolateySuccessCodesAsSuccess(int returnCode) @@ -252,6 +250,24 @@ public void OperationResultTreatsChocolateySuccessCodesAsSuccess(int returnCode) OperationAssert.HasVeredict(veredict, OperationVeredict.Success); } + [Theory] + [InlineData(3010)] + [InlineData(1641)] + public void OperationResultTreatsChocolateyRebootCodesAsRestartRequired(int returnCode) + { + var manager = new Chocolatey(); + var package = new PackageBuilder().WithManager(manager).Build(); + + var veredict = manager.OperationHelper.GetResult( + package, + OperationType.Install, + ["completed"], + returnCode + ); + + OperationAssert.HasVeredict(veredict, OperationVeredict.RestartRequired); + } + [Fact] public void OperationResultPromotesElevationFailuresToAutoRetry() { diff --git a/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs b/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs new file mode 100644 index 0000000000..cc7722841f --- /dev/null +++ b/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs @@ -0,0 +1,216 @@ +using UniGetUI.PackageEngine.Enums; +using UniGetUI.PackageEngine.Interfaces; +using UniGetUI.PackageEngine.Operations.Reboot; +using UniGetUI.PackageEngine.Tests.Infrastructure.Builders; + +namespace UniGetUI.PackageEngine.Tests; + +[Collection(nameof(OperationOrchestrationTestCollection))] +public sealed class PendingRebootStoreTests : IDisposable +{ + private static readonly long TwoHours = TimeSpan.FromHours(2).Ticks; + private const string BootA = "11111111111111111111111111111111"; + private const string BootB = "22222222222222222222222222222222"; + + private readonly string _tempFile; + + public PendingRebootStoreTests() + { + _tempFile = Path.Combine( + Path.GetTempPath(), + $"unigetui-reboottest-{Guid.NewGuid():N}.json"); + PendingRebootStore.TestFilePathOverride = _tempFile; + PendingRebootStore.InvalidateCache(); + PendingRebootStore.ClearAll(); + BootSession.TestUptimeOverride = TwoHours; + BootSession.TestIdOverride = BootA; + } + + public void Dispose() + { + BootSession.TestUptimeOverride = null; + BootSession.TestIdOverride = null; + PendingRebootStore.TestFilePathOverride = null; + PendingRebootStore.InvalidateCache(); + try { if (File.Exists(_tempFile)) File.Delete(_tempFile); } + catch { /* best-effort cleanup */ } + } + + private static IPackage Package(string id = "Contoso.Test") + => new PackageBuilder().WithId(id).WithName(id).Build(); + + [Fact] + public void RecordedPackageIsReportedAsPending() + { + var package = Package(); + + PendingRebootStore.Record(package, OperationType.Install); + + Assert.Equal(1, PendingRebootStore.PendingCount); + Assert.True(PendingRebootStore.IsPending(package.Manager.Id, package.Id)); + Assert.Equal("install-package", PendingRebootStore.GetPending()[0].Kind); + } + + [Fact] + public void RecordingTheSamePackageTwiceKeepsASingleEntry() + { + var package = Package(); + + PendingRebootStore.Record(package, OperationType.Install); + PendingRebootStore.Record(package, OperationType.Update); + + Assert.Equal(1, PendingRebootStore.PendingCount); + Assert.Equal("update-package", PendingRebootStore.GetPending()[0].Kind); + } + + [Fact] + public void ClearRemovesOnlyTheGivenPackage() + { + var kept = Package("Contoso.Kept"); + var dropped = Package("Contoso.Dropped"); + + PendingRebootStore.Record(kept, OperationType.Install); + PendingRebootStore.Record(dropped, OperationType.Install); + PendingRebootStore.Clear(dropped.Manager.Id, dropped.Id); + + Assert.Equal(1, PendingRebootStore.PendingCount); + Assert.True(PendingRebootStore.IsPending(kept.Manager.Id, kept.Id)); + } + + [Fact] + public void ReopeningTheAppWithinTheSameBootKeepsPendingPackages() + { + PendingRebootStore.Record(Package(), OperationType.Install); + + PendingRebootStore.InvalidateCache(); + + Assert.Equal(1, PendingRebootStore.PendingCount); + } + + [Fact] + public void UptimeAdvancingWithinTheSameBootKeepsPendingPackages() + { + PendingRebootStore.Record(Package(), OperationType.Install); + + BootSession.TestUptimeOverride = TwoHours + TimeSpan.FromHours(30).Ticks; + PendingRebootStore.InvalidateCache(); + + Assert.Equal(1, PendingRebootStore.PendingCount); + } + + [Fact] + public void RebootingTheMachineClearsPendingPackages() + { + PendingRebootStore.Record(Package(), OperationType.Install); + + BootSession.TestIdOverride = BootB; + BootSession.TestUptimeOverride = TimeSpan.FromSeconds(45).Ticks; + PendingRebootStore.InvalidateCache(); + + Assert.Equal(0, PendingRebootStore.PendingCount); + Assert.False(PendingRebootStore.HasPending); + } + + [Fact] + public void RebootIsDetectedEvenWhenTheEntryWasRecordedEarlyInThePreviousBoot() + { + BootSession.TestUptimeOverride = TimeSpan.FromMinutes(2).Ticks; + PendingRebootStore.Record(Package(), OperationType.Install); + + BootSession.TestIdOverride = BootB; + BootSession.TestUptimeOverride = TimeSpan.FromHours(1).Ticks; + PendingRebootStore.InvalidateCache(); + + Assert.Equal(0, PendingRebootStore.PendingCount); + } + + [Fact] + public void WithoutABootIdTheUptimeCounterStillDetectsAReboot() + { + BootSession.TestIdOverride = ""; + PendingRebootStore.Record(Package(), OperationType.Install); + + BootSession.TestUptimeOverride = TimeSpan.FromSeconds(45).Ticks; + PendingRebootStore.InvalidateCache(); + + Assert.Equal(0, PendingRebootStore.PendingCount); + } + + [Fact] + public void ClearAllRemovesEveryEntry() + { + PendingRebootStore.Record(Package("Contoso.One"), OperationType.Install); + PendingRebootStore.Record(Package("Contoso.Two"), OperationType.Install); + + PendingRebootStore.ClearAll(); + + Assert.Equal(0, PendingRebootStore.PendingCount); + } + + [Fact] + public void RealUptimeSourceIsPositiveAndMonotonic() + { + BootSession.TestUptimeOverride = null; + + long first = BootSession.GetUptimeTicks(); + long second = BootSession.GetUptimeTicks(); + + Assert.True(first > 0); + Assert.True(second >= first); + } + + [Fact] + public void RealBootIdIsStableWithinTheSession() + { + BootSession.TestIdOverride = null; + + string first = BootSession.GetId(); + string second = BootSession.GetId(); + + Assert.Equal(first, second); + if (OperatingSystem.IsWindows()) Assert.NotEqual("", first); + } + + [Fact] + public void SurvivesAReloadUsingTheRealUptimeSource() + { + BootSession.TestUptimeOverride = null; + BootSession.TestIdOverride = null; + PendingRebootStore.Record(Package(), OperationType.Install); + + PendingRebootStore.InvalidateCache(); + + Assert.Equal(1, PendingRebootStore.PendingCount); + } + + [Fact] + public void EntriesWithNeitherBootIdNorUptimeStampAreDiscarded() + { + PendingRebootStore.Record(Package(), OperationType.Install); + var entry = PendingRebootStore.GetPending()[0]; + entry.BootId = ""; + entry.UptimeTicks = 0; + File.WriteAllText( + _tempFile, + System.Text.Json.JsonSerializer.Serialize(new[] { entry })); + + PendingRebootStore.InvalidateCache(); + + Assert.Equal(0, PendingRebootStore.PendingCount); + } + + [Fact] + public void AMatchingBootIdOutweighsAMissingUptimeStamp() + { + PendingRebootStore.Record(Package(), OperationType.Install); + var entry = PendingRebootStore.GetPending()[0]; + entry.UptimeTicks = 0; + File.WriteAllText( + _tempFile, + System.Text.Json.JsonSerializer.Serialize(new[] { entry })); + + PendingRebootStore.InvalidateCache(); + + Assert.Equal(1, PendingRebootStore.PendingCount); + } +} diff --git a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs index ac6690cb60..60815660c0 100644 --- a/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/WinGetManagerTests.cs @@ -2037,8 +2037,10 @@ public void IsStuckUpgradeLoop_DoesNotSuppressANewerVersion() Assert.False(WinGetPkgOperationHelper.ConsumeAlreadyUpgradedSuppression(newerUpdate)); } - [Fact] - public void RestartRequiredUpgradesNeverTripTheLoopBreaker() + [Theory] + [InlineData(0x8A150109u)] + [InlineData(0x8A15010Bu)] + public void RestartRequiredUpgradesNeverTripTheLoopBreaker(uint returnCode) { var manager = new WinGet(); var package = new PackageBuilder() @@ -2056,9 +2058,9 @@ public void RestartRequiredUpgradesNeverTripTheLoopBreaker() package, OperationType.Update, [], - unchecked((int)0x8A150109u) + unchecked((int)returnCode) ), - OperationVeredict.Success + OperationVeredict.RestartRequired ); } From 562dafa833144644fe85a53667b330800952ba10 Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 1 Oct 2026 15:25:19 -0400 Subject: [PATCH 2/3] Harden the pending-reboot store and stop clearing it on a restart request --- .../Infrastructure/SystemRestartService.cs | 5 +- .../Reboot/PendingRebootStore.cs | 61 +++++++++++++++++-- .../PendingRebootStoreTests.cs | 29 +++++++++ 3 files changed, 87 insertions(+), 8 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs index 3f860dcd37..92c2c8fa71 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs @@ -19,10 +19,7 @@ public static async Task ConfirmAndRestartAsync(Window owner) { if (!await ConfirmAsync(owner)) return; - if (Restart()) - PendingRebootStore.ClearAll(); - else - ReportRestartFailure(); + if (!Restart()) ReportRestartFailure(); } catch (Exception ex) { diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs index 1e1a9d055a..6d9ea88f1b 100644 --- a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs @@ -11,6 +11,7 @@ public static class PendingRebootStore private const int MaxEntries = 500; private static readonly object _lock = new(); private static List? _cache; + private static (DateTime Time, long Length) _cacheStamp = (default, -2L); public static event EventHandler? Changed; @@ -21,7 +22,11 @@ private static string FilePath public static void InvalidateCache() { - lock (_lock) _cache = null; + lock (_lock) + { + _cache = null; + _cacheStamp = (default, -2L); + } } public static IReadOnlyList GetPending() @@ -114,7 +119,7 @@ private static bool Matches(PendingRebootEntry entry, string managerName, string private static List LoadUnlocked() { - if (_cache is not null) return _cache; + if (_cache is not null && ReadFileStamp() == _cacheStamp) return _cache; var loaded = new List(); try @@ -133,13 +138,56 @@ private static List LoadUnlocked() loaded = []; } - _cache = loaded; + _cache = Sanitize(loaded); + _cacheStamp = ReadFileStamp(); + if (DropEntriesFromPreviousBootsUnlocked()) SaveUnlocked(); return _cache; } + private static List Sanitize(List entries) + { + var result = new List(entries.Count); + foreach (var entry in entries) + { + if (entry is null) continue; + + entry.PackageId = OrEmpty(entry.PackageId); + entry.PackageName = OrEmpty(entry.PackageName); + entry.ManagerName = OrEmpty(entry.ManagerName); + entry.SourceName = OrEmpty(entry.SourceName); + entry.Version = OrEmpty(entry.Version); + entry.Kind = OrEmpty(entry.Kind); + entry.RecordedAtUtc = OrEmpty(entry.RecordedAtUtc); + entry.BootId = OrEmpty(entry.BootId); + + if (entry.PackageId.Length == 0 || entry.ManagerName.Length == 0) continue; + result.Add(entry); + } + + if (result.Count != entries.Count) + Logger.Warn($"Discarded {entries.Count - result.Count} malformed pending-reboot entries"); + + return result; + } + + private static string OrEmpty(string? value) => value ?? ""; + + private static (DateTime Time, long Length) ReadFileStamp() + { + try + { + var info = new FileInfo(FilePath); + return info.Exists ? (info.LastWriteTimeUtc, info.Length) : (default, -1L); + } + catch + { + return (default, -1L); + } + } + private static bool DropEntriesFromPreviousBootsUnlocked() { if (_cache is null || _cache.Count == 0) return false; @@ -171,7 +219,12 @@ private static void SaveUnlocked() { var typeInfo = PendingRebootJsonContext.Default.ListPendingRebootEntry; string json = JsonSerializer.Serialize(_cache ?? [], typeInfo); - File.WriteAllText(FilePath, json); + + string temporaryPath = FilePath + ".tmp"; + File.WriteAllText(temporaryPath, json); + File.Move(temporaryPath, FilePath, overwrite: true); + + _cacheStamp = ReadFileStamp(); } catch (Exception ex) { diff --git a/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs b/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs index cc7722841f..b830cead06 100644 --- a/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs +++ b/src/UniGetUI.PackageEngine.Tests/PendingRebootStoreTests.cs @@ -147,6 +147,35 @@ public void ClearAllRemovesEveryEntry() Assert.Equal(0, PendingRebootStore.PendingCount); } + [Fact] + public void MalformedEntriesAreDiscardedInsteadOfCrashingTheStore() + { + File.WriteAllText(_tempFile, + "[null,{\"PackageId\":null,\"ManagerName\":\"choco\",\"BootId\":\"" + BootA + "\"}," + + "{\"PackageId\":\"Contoso.Good\",\"ManagerName\":\"choco\",\"PackageName\":null," + + "\"BootId\":\"" + BootA + "\",\"UptimeTicks\":1}]"); + + PendingRebootStore.InvalidateCache(); + + Assert.Equal(1, PendingRebootStore.PendingCount); + Assert.True(PendingRebootStore.IsPending("choco", "Contoso.Good")); + Assert.Equal("", PendingRebootStore.GetPending()[0].PackageName); + } + + [Fact] + public void RecordsWrittenByAnotherSessionAreObserved() + { + PendingRebootStore.Record(Package("Contoso.Mine"), OperationType.Install); + Assert.Equal(1, PendingRebootStore.PendingCount); + + File.WriteAllText(_tempFile, + "[{\"PackageId\":\"Contoso.Mine\",\"ManagerName\":\"Test Manager\",\"BootId\":\"" + BootA + "\",\"UptimeTicks\":1}," + + "{\"PackageId\":\"Contoso.Theirs\",\"ManagerName\":\"Test Manager\",\"BootId\":\"" + BootA + "\",\"UptimeTicks\":1}]"); + + Assert.Equal(2, PendingRebootStore.PendingCount); + Assert.True(PendingRebootStore.IsPending("Test Manager", "Contoso.Theirs")); + } + [Fact] public void RealUptimeSourceIsPositiveAndMonotonic() { From 20bcbb4ffb8c0bfbe95819751cb3022807b486ec Mon Sep 17 00:00:00 2001 From: GabrielDuf Date: Thu, 1 Oct 2026 15:43:14 -0400 Subject: [PATCH 3/3] Keep the pending-reboot lookups cheap and off the UI thread --- .../Infrastructure/SystemRestartService.cs | 11 +++++++--- .../IpcPackageApi.cs | 20 ++++++++++++++---- .../Reboot/PendingRebootStore.cs | 21 +++++++++++++++++-- 3 files changed, 43 insertions(+), 9 deletions(-) diff --git a/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs index 92c2c8fa71..639a614b83 100644 --- a/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs +++ b/src/UniGetUI.Avalonia/Infrastructure/SystemRestartService.cs @@ -19,7 +19,7 @@ public static async Task ConfirmAndRestartAsync(Window owner) { if (!await ConfirmAsync(owner)) return; - if (!Restart()) ReportRestartFailure(); + if (!await RestartAsync()) ReportRestartFailure(); } catch (Exception ex) { @@ -83,7 +83,7 @@ private static async Task ConfirmAsync(Window owner) return dialog.Result is true; } - private static bool Restart() + private static async Task RestartAsync() { if (!OperatingSystem.IsWindows()) { @@ -107,7 +107,12 @@ private static bool Restart() return false; } - if (!process.WaitForExit(ShutdownRequestTimeoutMs)) + using var timeout = new CancellationTokenSource(ShutdownRequestTimeoutMs); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) { Logger.Warn("shutdown.exe did not return in time; assuming the restart was requested"); return true; diff --git a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs index 07242c3b12..2c26359ed8 100644 --- a/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs +++ b/src/UniGetUI.Interface.IpcApi/IpcPackageApi.cs @@ -130,10 +130,11 @@ public static IReadOnlyList SearchPackages( if (safeQuery.Length is 0) return []; + var pendingRestartKeys = PendingRebootStore.GetPendingKeys(); return GetManagers(managerName) .SelectMany(manager => manager.FindPackages(safeQuery)) .DistinctBy(GetPackageIdentity) - .Select(ToIpcPackageInfo) + .Select(package => ToIpcPackageInfo(package, pendingRestartKeys)) .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) .Take(maxResults) @@ -142,9 +143,10 @@ public static IReadOnlyList SearchPackages( public static IReadOnlyList ListInstalledPackages(string? managerName = null) { + var pendingRestartKeys = PendingRebootStore.GetPendingKeys(); return GetInstalledPackagesSnapshot(managerName) .DistinctBy(GetPackageIdentity) - .Select(ToIpcPackageInfo) + .Select(package => ToIpcPackageInfo(package, pendingRestartKeys)) .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -152,9 +154,10 @@ public static IReadOnlyList ListInstalledPackages(string? manage public static IReadOnlyList ListUpgradablePackages(string? managerName = null) { + var pendingRestartKeys = PendingRebootStore.GetPendingKeys(); return GetUpgradablePackagesSnapshot(managerName) .DistinctBy(GetPackageIdentity) - .Select(ToIpcPackageInfo) + .Select(package => ToIpcPackageInfo(package, pendingRestartKeys)) .OrderBy(package => package.Name, StringComparer.OrdinalIgnoreCase) .ThenBy(package => package.Id, StringComparer.OrdinalIgnoreCase) .ToArray(); @@ -802,6 +805,12 @@ private static string GetPackageIdentity(IPackage package) } private static IpcPackageInfo ToIpcPackageInfo(IPackage package) + => ToIpcPackageInfo(package, null); + + private static IpcPackageInfo ToIpcPackageInfo( + IPackage package, + IReadOnlySet? pendingRestartKeys + ) { return new IpcPackageInfo { @@ -812,7 +821,10 @@ private static IpcPackageInfo ToIpcPackageInfo(IPackage package) Source = package.Source.AsString_DisplayName, Manager = IpcManagerSettingsApi.GetPublicManagerId(package.Manager), IsUpgradable = package.IsUpgradable, - SystemRestartPending = PendingRebootStore.IsPending(package.Manager.Id, package.Id), + SystemRestartPending = pendingRestartKeys is null + ? PendingRebootStore.IsPending(package.Manager.Id, package.Id) + : pendingRestartKeys.Contains( + PendingRebootStore.KeyFor(package.Manager.Id, package.Id)), }; } } diff --git a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs index 6d9ea88f1b..ec65d34274 100644 --- a/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs +++ b/src/UniGetUI.PackageEngine.Operations/Reboot/PendingRebootStore.cs @@ -50,6 +50,19 @@ public static bool IsPending(string managerName, string packageId) } } + public static IReadOnlySet GetPendingKeys() + { + lock (_lock) + { + return LoadUnlocked() + .Select(entry => KeyFor(entry.ManagerName, entry.PackageId)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + } + + public static string KeyFor(string managerName, string packageId) + => managerName + "|" + packageId; + public static void Record(IPackage package, OperationType role) { var entry = new PendingRebootEntry @@ -121,6 +134,7 @@ private static List LoadUnlocked() { if (_cache is not null && ReadFileStamp() == _cacheStamp) return _cache; + var stampBeforeRead = ReadFileStamp(); var loaded = new List(); try { @@ -138,10 +152,13 @@ private static List LoadUnlocked() loaded = []; } + int rawCount = loaded.Count; _cache = Sanitize(loaded); - _cacheStamp = ReadFileStamp(); + _cacheStamp = stampBeforeRead; - if (DropEntriesFromPreviousBootsUnlocked()) + bool repaired = _cache.Count != rawCount; + bool dropped = DropEntriesFromPreviousBootsUnlocked(); + if (repaired || dropped) SaveUnlocked(); return _cache;