From becc96f59cf18f3128fedbd6856a5248104216dd Mon Sep 17 00:00:00 2001 From: Niels Laute Date: Fri, 14 Aug 2026 02:56:38 +0200 Subject: [PATCH] [Settings] Improve update-notification UX (#49872) ## Summary of the Pull Request Refreshes the Settings update experience with a shared update coordinator, consistent state badges, and a compact floating status surface available across pages. The surface supports checking, update available, downloading, ready to install, network failure, and download failure states; it can be dismissed and reopens when users return to General while attention is still needed. https://github.com/user-attachments/assets/7b1a7266-efc3-4b6f-9bea-1d25a032ead6 ## PR Checklist - [ ] Closes: N/A - [x] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [x] **Tests:** Added/updated and all pass - [x] **Localization:** All end-user-facing strings can be localized - [ ] **Dev docs:** Added/updated - [ ] **New binaries:** Added on the required places - [ ] [JSON for signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json) for new binaries - [ ] [WXS for installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs) for new binaries and localization folder - [ ] [YML for CI pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml) for new test projects - [ ] [YML for signed pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml) - [ ] **Documentation updated:** If checked, please file a pull request on [our docs repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys) and link it here: N/A ## Detailed Description of the Pull Request / Additional comments - Centralizes persisted and transient update state in a shared `UpdateViewModel` used by Dashboard, General, navigation, and the floating surface. - Adds reusable status, activity, and badge controls with stable layout, dismissal/reopen behavior, in-app What's New navigation, and retry-safe single-flight update actions. - Keeps the existing General update glyph while adding an adjacent state badge. - Adds Debug-only controls for previewing every updater state and running the complete state flow without affecting production screenshots. - Adds focused coverage for state mapping, activity visibility, IPC/launch failures, transient operation recovery, and duplicate installer-launch prevention. ## Validation Steps Performed - Built `Settings.UI` for x64 Debug. - Built `Settings.UI.UnitTests` for x64 Debug. - Ran focused `ViewModelTests.Update` and `ViewModelTests.General` tests with `vstest.console.exe`: 39 passed. - Exercised the Debug updater flow across all states and verified that a dismissed notification reopens after navigating back to General. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 120899e5-fc77-425e-918b-644929b65739 Copilot-Session: 7da8be2f-1f73-48e9-8ae9-aa2448f2a5e3 --- .../ViewModelTests/General.cs | 45 +- .../ViewModelTests/Update.cs | 363 +++++++++++ .../Converters/UpdateStateToBoolConverter.cs | 37 -- .../Settings.UI/SettingsXAML/App.xaml | 1 - .../Settings.UI/SettingsXAML/App.xaml.cs | 8 + .../Dashboard/CheckUpdateControl.xaml | 86 +-- .../Dashboard/CheckUpdateControl.xaml.cs | 18 +- .../Controls/UpdateActivityControl.xaml | 32 + .../Controls/UpdateActivityControl.xaml.cs | 36 ++ .../Controls/UpdateStateBadgeControl.xaml | 99 +++ .../Controls/UpdateStateBadgeControl.xaml.cs | 65 ++ .../Controls/UpdateStatusControl.xaml | 106 ++++ .../Controls/UpdateStatusControl.xaml.cs | 44 ++ .../SettingsXAML/MainWindow.xaml.cs | 6 + .../SettingsXAML/Views/GeneralPage.xaml | 189 +----- .../SettingsXAML/Views/GeneralPage.xaml.cs | 29 +- .../SettingsXAML/Views/ShellPage.xaml | 13 +- .../SettingsXAML/Views/ShellPage.xaml.cs | 48 +- .../Settings.UI/Strings/en-us/Resources.resw | 4 +- .../ViewModels/GeneralViewModel.cs | 267 +------- .../Settings.UI/ViewModels/UpdateViewModel.cs | 578 ++++++++++++++++++ 21 files changed, 1455 insertions(+), 619 deletions(-) create mode 100644 src/settings-ui/Settings.UI.UnitTests/ViewModelTests/Update.cs delete mode 100644 src/settings-ui/Settings.UI/Converters/UpdateStateToBoolConverter.cs create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml.cs create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml.cs create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStatusControl.xaml create mode 100644 src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStatusControl.xaml.cs create mode 100644 src/settings-ui/Settings.UI/ViewModels/UpdateViewModel.cs diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs index d71d9e4610..a0d4b75b0f 100644 --- a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/General.cs @@ -38,9 +38,9 @@ namespace ViewModelTests bool isAdmin, Func ipcMSGCallBackFunc, Func ipcMSGRestartAsAdminMSGCallBackFunc, - Func ipcMSGCheckForUpdatesCallBackFunc, + Action checkForUpdatesAction, string configFileSubfolder = "") - : base(settingsRepository, runAsAdminText, runAsUserText, isElevated, isAdmin, ipcMSGCallBackFunc, ipcMSGRestartAsAdminMSGCallBackFunc, ipcMSGCheckForUpdatesCallBackFunc, configFileSubfolder) + : base(settingsRepository, runAsAdminText, runAsUserText, isElevated, isAdmin, ipcMSGCallBackFunc, ipcMSGRestartAsAdminMSGCallBackFunc, checkForUpdatesAction, configFileSubfolder) { } @@ -70,7 +70,7 @@ namespace ViewModelTests // Arrange Func sendMockIPCConfigMSG = msg => 0; Func sendRestartAdminIPCMessage = msg => 0; - Func sendCheckForUpdatesIPCMessage = msg => 0; + Action checkForUpdates = () => { }; var viewModel = new TestGeneralViewModel( settingsRepository: generalSettingsRepository, runAsAdminText: "GeneralSettings_RunningAsAdminText", @@ -79,7 +79,7 @@ namespace ViewModelTests isAdmin: false, ipcMSGCallBackFunc: sendMockIPCConfigMSG, ipcMSGRestartAsAdminMSGCallBackFunc: sendRestartAdminIPCMessage, - ipcMSGCheckForUpdatesCallBackFunc: sendCheckForUpdatesIPCMessage, + checkForUpdatesAction: checkForUpdates, configFileSubfolder: string.Empty); // Verify that the old settings persisted @@ -98,7 +98,7 @@ namespace ViewModelTests public void IncludePrereleaseUpdatesShouldSendUpdatedSettingWhenSuccessful() { bool sawExpectedIpcPayload = false; - bool sawExpectedUpdateCheckPayload = false; + bool updateCheckRequested = false; Func sendMockIPCConfigMSG = msg => { if (string.IsNullOrWhiteSpace(msg)) @@ -118,24 +118,7 @@ namespace ViewModelTests }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => - { - if (string.IsNullOrWhiteSpace(msg)) - { - return 0; - } - - GeneralSettingsCustomAction action = JsonSerializer.Deserialize(msg); - if (action?.GeneralSettingsAction?.GeneralSettings is null) - { - return 0; - } - - Assert.IsTrue(action.GeneralSettingsAction.GeneralSettings.IncludePrereleaseUpdates); - Assert.AreEqual("check_for_updates", action.GeneralSettingsAction.GeneralSettings.CustomActionName); - sawExpectedUpdateCheckPayload = true; - return 0; - }; + Action checkForUpdates = () => updateCheckRequested = true; GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", @@ -144,7 +127,7 @@ namespace ViewModelTests false, sendMockIPCConfigMSG, sendRestartAdminIPCMessage, - sendCheckForUpdatesIPCMessage, + checkForUpdates, GeneralSettingsFileName); Assert.IsFalse(viewModel.IncludePrereleaseUpdates); @@ -152,7 +135,7 @@ namespace ViewModelTests viewModel.IncludePrereleaseUpdates = true; Assert.IsTrue(sawExpectedIpcPayload); - Assert.IsTrue(sawExpectedUpdateCheckPayload); + Assert.IsTrue(updateCheckRequested); } [TestMethod] @@ -161,7 +144,7 @@ namespace ViewModelTests // Arrange Func sendMockIPCConfigMSG = msg => { return 0; }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", @@ -209,7 +192,7 @@ namespace ViewModelTests // Arrange Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", @@ -251,7 +234,7 @@ namespace ViewModelTests }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; // Arrange GeneralViewModel viewModel = new TestGeneralViewModel( @@ -299,7 +282,7 @@ namespace ViewModelTests }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", @@ -341,7 +324,7 @@ namespace ViewModelTests }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", @@ -383,7 +366,7 @@ namespace ViewModelTests }; Func sendRestartAdminIPCMessage = msg => { return 0; }; - Func sendCheckForUpdatesIPCMessage = msg => { return 0; }; + Action sendCheckForUpdatesIPCMessage = () => { }; GeneralViewModel viewModel = new TestGeneralViewModel( settingsRepository: SettingsRepository.GetInstance(mockGeneralSettingsUtils.Object), "GeneralSettings_RunningAsAdminText", diff --git a/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/Update.cs b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/Update.cs new file mode 100644 index 0000000000..1f34dce8f3 --- /dev/null +++ b/src/settings-ui/Settings.UI.UnitTests/ViewModelTests/Update.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Text.Json; +using Microsoft.PowerToys.Settings.UI.Library; +using Microsoft.PowerToys.Settings.UI.Library.Interfaces; +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace ViewModelTests +{ + [TestClass] + public class Update + { + private sealed class TestSettingsRepository : ISettingsRepository + { + public TestSettingsRepository(GeneralSettings settings) + { + SettingsConfig = settings; + } + + public GeneralSettings SettingsConfig { get; set; } + + public event Action SettingsChanged; + + public bool ReloadSettings() + { + SettingsChanged?.Invoke(SettingsConfig); + return true; + } + } + + [DataTestMethod] + [DataRow(UpdatingSettings.UpdatingState.UpToDate, (int)UpdateViewModel.TransientUpdateOperation.None, UpdateViewModel.UpdateUIState.UpToDate)] + [DataRow(UpdatingSettings.UpdatingState.NetworkError, (int)UpdateViewModel.TransientUpdateOperation.None, UpdateViewModel.UpdateUIState.NetworkError)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToDownload, (int)UpdateViewModel.TransientUpdateOperation.None, UpdateViewModel.UpdateUIState.ReadyToDownload)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToInstall, (int)UpdateViewModel.TransientUpdateOperation.None, UpdateViewModel.UpdateUIState.ReadyToInstall)] + [DataRow(UpdatingSettings.UpdatingState.ErrorDownloading, (int)UpdateViewModel.TransientUpdateOperation.None, UpdateViewModel.UpdateUIState.ErrorDownloading)] + [DataRow(UpdatingSettings.UpdatingState.UpToDate, (int)UpdateViewModel.TransientUpdateOperation.Checking, UpdateViewModel.UpdateUIState.Checking)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToDownload, (int)UpdateViewModel.TransientUpdateOperation.Checking, UpdateViewModel.UpdateUIState.Checking)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToInstall, (int)UpdateViewModel.TransientUpdateOperation.Checking, UpdateViewModel.UpdateUIState.Checking)] + [DataRow(UpdatingSettings.UpdatingState.UpToDate, (int)UpdateViewModel.TransientUpdateOperation.Downloading, UpdateViewModel.UpdateUIState.Downloading)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToDownload, (int)UpdateViewModel.TransientUpdateOperation.Downloading, UpdateViewModel.UpdateUIState.Downloading)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToInstall, (int)UpdateViewModel.TransientUpdateOperation.Downloading, UpdateViewModel.UpdateUIState.Downloading)] + [DataRow(UpdatingSettings.UpdatingState.ReadyToInstall, (int)UpdateViewModel.TransientUpdateOperation.Installing, UpdateViewModel.UpdateUIState.ReadyToInstall)] + public void GetUpdateUIStateShouldMapPersistentAndTransientStates( + UpdatingSettings.UpdatingState updatingState, + int activeUpdateOperation, + UpdateViewModel.UpdateUIState expected) + { + Assert.AreEqual( + expected, + UpdateViewModel.GetUpdateUIState( + updatingState, + (UpdateViewModel.TransientUpdateOperation)activeUpdateOperation)); + } + + [TestMethod] + public void CheckForUpdatesShouldShowProgressAndSendCurrentSettings() + { + string sentMessage = null; + var generalSettings = new GeneralSettings + { + IncludePrereleaseUpdates = true, + }; + var viewModel = CreateViewModel( + new TestSettingsRepository(generalSettings), + new UpdatingSettings(), + message => + { + sentMessage = message; + return 0; + }); + + viewModel.CheckForUpdates(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.Checking, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.IsActivityVisible); + Assert.IsFalse(viewModel.CanStartAction); + + var action = JsonSerializer.Deserialize(sentMessage); + Assert.IsTrue(action.GeneralSettingsAction.GeneralSettings.IncludePrereleaseUpdates); + Assert.AreEqual("check_for_updates", action.GeneralSettingsAction.GeneralSettings.CustomActionName); + } + + [TestMethod] + public void CheckForUpdatesShouldUseCheckingStateForAnExistingUpdate() + { + var updatingSettings = new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToInstall, + DownloadedInstallerFilename = "PowerToysSetup.exe", + }; + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + updatingSettings, + message => 0); + + viewModel.CheckForUpdates(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.Checking, viewModel.CurrentUpdateUIState); + Assert.IsFalse(viewModel.CanStartAction); + } + + [TestMethod] + public void CheckForUpdatesShouldRecoverWhenIpcDeliveryFails() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings(), + message => 1); + + viewModel.CheckForUpdates(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.NetworkError, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.CanStartAction); + Assert.IsTrue(viewModel.IsActivityVisible); + } + + [TestMethod] + public void CheckForUpdatesShouldRecoverWhenIpcDeliveryThrows() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings(), + message => throw new InvalidOperationException()); + + viewModel.CheckForUpdates(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.NetworkError, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.CanStartAction); + } + + [TestMethod] + public void RefreshUpdatingStateShouldCompleteTransientOperation() + { + var currentSettings = new UpdatingSettings(); + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + currentSettings, + message => 0, + () => currentSettings); + + viewModel.CheckForUpdates(); + currentSettings = new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.NetworkError, + }; + + viewModel.RefreshUpdatingState(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.NetworkError, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.CanStartAction); + } + + [TestMethod] + public void RefreshUpdatingStateShouldRecoverWhenStateCannotBeLoaded() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings(), + message => 0, + () => null); + + viewModel.CheckForUpdates(); + viewModel.RefreshUpdatingState(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.NetworkError, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.CanStartAction); + } + + [TestMethod] + public void UpdateNowShouldShowDownloadingUntilUpdaterStateChanges() + { + bool updateStarted = false; + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToDownload, + }, + message => 0, + startUpdate: () => updateStarted = true); + + viewModel.UpdateNow(); + + Assert.IsTrue(updateStarted); + Assert.AreEqual(UpdateViewModel.UpdateUIState.Downloading, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.IsActivityVisible); + } + + [TestMethod] + public void UpdateNowShouldPreventStartingDownloadedInstallerTwice() + { + int updateStartCount = 0; + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToInstall, + DownloadedInstallerFilename = "PowerToysSetup.exe", + }, + message => 0, + startUpdate: () => updateStartCount++); + + viewModel.UpdateNow(); + viewModel.UpdateNow(); + + Assert.AreEqual(1, updateStartCount); + Assert.AreEqual(UpdateViewModel.UpdateUIState.ReadyToInstall, viewModel.CurrentUpdateUIState); + Assert.IsFalse(viewModel.CanStartAction); + } + + [TestMethod] + public void UpdateNowShouldRecoverWhenStartingUpdaterFails() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToDownload, + }, + message => 0, + startUpdate: () => throw new InvalidOperationException()); + + viewModel.UpdateNow(); + + Assert.AreEqual(UpdateViewModel.UpdateUIState.ErrorDownloading, viewModel.CurrentUpdateUIState); + Assert.IsTrue(viewModel.CanStartAction); + Assert.IsTrue(viewModel.IsActivityVisible); + } + + [TestMethod] + public void UpdateNowShouldClearFailureWhenRetryingDownloadedInstaller() + { + int updateStartCount = 0; + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToInstall, + DownloadedInstallerFilename = "PowerToysSetup.exe", + }, + message => 0, + startUpdate: () => + { + updateStartCount++; + if (updateStartCount == 1) + { + throw new InvalidOperationException(); + } + }); + + viewModel.UpdateNow(); + Assert.AreEqual(UpdateViewModel.UpdateUIState.ErrorDownloading, viewModel.CurrentUpdateUIState); + + viewModel.UpdateNow(); + + Assert.AreEqual(2, updateStartCount); + Assert.AreEqual(UpdateViewModel.UpdateUIState.ReadyToInstall, viewModel.CurrentUpdateUIState); + Assert.IsFalse(viewModel.CanStartAction); + } + +#if DEBUG + [TestMethod] + public void UpdateNowShouldNotStartUpdaterWhilePreviewing() + { + bool updateStarted = false; + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings(), + message => 0, + startUpdate: () => updateStarted = true); + viewModel.SetDebugPreviewState(UpdateViewModel.UpdateUIState.ReadyToDownload); + + viewModel.UpdateNow(); + + Assert.IsFalse(updateStarted); + Assert.AreEqual(UpdateViewModel.UpdateUIState.Downloading, viewModel.CurrentUpdateUIState); + } +#endif + + [TestMethod] + public void DismissingActivityShouldHideSurfaceButKeepUpdateBadge() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToDownload, + }, + message => 0); + + viewModel.RequestActivity(); + viewModel.DismissActivity(); + + Assert.IsFalse(viewModel.IsActivityVisible); + Assert.IsTrue(viewModel.ShowUpdateBadge); + } + + [TestMethod] + public void UpToDateActivityShouldRemainHiddenAtStartOfWindowSession() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings(), + message => 0); + + viewModel.RequestActivity(); + Assert.IsTrue(viewModel.IsActivityVisible); + + viewModel.DismissActivity(); + + Assert.IsFalse(viewModel.IsActivityVisible); + + viewModel.BeginWindowSession(); + + Assert.IsFalse(viewModel.IsActivityVisible); + } + + [TestMethod] + public void AvailableUpdateShouldShowActivityAtStartOfWindowSession() + { + var viewModel = CreateViewModel( + new TestSettingsRepository(new GeneralSettings()), + new UpdatingSettings + { + State = UpdatingSettings.UpdatingState.ReadyToDownload, + }, + message => 0); + + Assert.IsTrue(viewModel.IsActivityVisible); + + viewModel.DismissActivity(); + Assert.IsFalse(viewModel.IsActivityVisible); + + viewModel.BeginWindowSession(); + Assert.IsTrue(viewModel.IsActivityVisible); + } + + private static UpdateViewModel CreateViewModel( + ISettingsRepository settingsRepository, + UpdatingSettings initialSettings, + Func sendMessage, + Func loadSettings = null, + Action startUpdate = null) + { + loadSettings ??= () => initialSettings; + startUpdate ??= () => { }; + + return new UpdateViewModel( + settingsRepository, + sendMessage, + loadSettings, + startUpdate, + false, + false, + null); + } + } +} diff --git a/src/settings-ui/Settings.UI/Converters/UpdateStateToBoolConverter.cs b/src/settings-ui/Settings.UI/Converters/UpdateStateToBoolConverter.cs deleted file mode 100644 index 15d874a430..0000000000 --- a/src/settings-ui/Settings.UI/Converters/UpdateStateToBoolConverter.cs +++ /dev/null @@ -1,37 +0,0 @@ -// Copyright (c) Microsoft Corporation -// The Microsoft Corporation licenses this file to you under the MIT license. -// See the LICENSE file in the project root for more information. - -using System; - -using Microsoft.UI.Xaml.Data; - -namespace Microsoft.PowerToys.Settings.UI.Converters -{ - public sealed partial class UpdateStateToBoolConverter : IValueConverter - { - public object Convert(object value, Type targetType, object parameter, string language) - { - if (value == null || parameter == null) - { - return false; - } - else - { - if (value.ToString() == (string)parameter) - { - return true; - } - else - { - return false; - } - } - } - - public object ConvertBack(object value, Type targetType, object parameter, string language) - { - return value; - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml index 00797056a2..864e5a644d 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml @@ -58,7 +58,6 @@ x:Key="EmptyObjectToObjectConverter" EmptyValue="Collapsed" NotEmptyValue="Visible" /> - 2 diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index e23eb1dbde..0666906a1f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -110,11 +110,19 @@ namespace Microsoft.PowerToys.Settings.UI public static void OpenSettingsWindow(Type type = null, bool ensurePageIsSelected = false) { + bool isNewWindowSession = settingsWindow == null || + !NativeMethods.IsWindowVisible(settingsWindow.GetWindowHandle()); + if (settingsWindow == null) { settingsWindow = new MainWindow(); } + if (isNewWindowSession) + { + settingsWindow.BeginWindowSession(); + } + settingsWindow.Activate(); if (type != null) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml index df8a477612..1dd8680922 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml @@ -8,73 +8,39 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" mc:Ignorable="d"> - - - + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs index 8ad9646215..2e85e49755 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/Dashboard/CheckUpdateControl.xaml.cs @@ -2,33 +2,25 @@ // The Microsoft Corporation licenses this file to you under the MIT license. // See the LICENSE file in the project root for more information. -using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Services; +using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.PowerToys.Settings.UI.Views; +using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; namespace Microsoft.PowerToys.Settings.UI.Controls { public sealed partial class CheckUpdateControl : UserControl { - public bool UpdateAvailable { get; set; } - - public UpdatingSettings UpdateSettingsConfig { get; set; } - - public string LastCheckedDateFriendly { get; set; } + public UpdateViewModel ViewModel => ShellPage.ShellHandler?.UpdateViewModel; public CheckUpdateControl() { InitializeComponent(); - UpdateSettingsConfig = UpdatingSettings.LoadSettings(); - UpdateAvailable = UpdateSettingsConfig != null && (UpdateSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToInstall || UpdateSettingsConfig.State == UpdatingSettings.UpdatingState.ReadyToDownload); - LastCheckedDateFriendly = FriendlyDateHelper.Format(UpdateSettingsConfig?.LastCheckedDateTime); } - private void SWVersionButtonClicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) + private void UpdateButton_Click(object sender, RoutedEventArgs e) { - NavigationService.Navigate(typeof(GeneralPage)); + ShellPage.ShellHandler?.OpenUpdateActivity(); } } } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml new file mode 100644 index 0000000000..1907156cc9 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml.cs new file mode 100644 index 0000000000..45d8b76d4a --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateActivityControl.xaml.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class UpdateActivityControl : UserControl + { + public static readonly DependencyProperty ViewModelProperty = + DependencyProperty.Register( + nameof(ViewModel), + typeof(UpdateViewModel), + typeof(UpdateActivityControl), + new PropertyMetadata(null)); + + public UpdateViewModel ViewModel + { + get => (UpdateViewModel)GetValue(ViewModelProperty); + set => SetValue(ViewModelProperty, value); + } + + public UpdateActivityControl() + { + InitializeComponent(); + } + + public void Open() + { + ViewModel?.RequestActivity(); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml new file mode 100644 index 0000000000..7ffb81a057 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml.cs new file mode 100644 index 0000000000..dc81bf428e --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStateBadgeControl.xaml.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using Microsoft.PowerToys.Settings.UI.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.Controls +{ + public sealed partial class UpdateStateBadgeControl : UserControl + { + public static readonly DependencyProperty StateProperty = + DependencyProperty.Register( + nameof(State), + typeof(UpdateViewModel.UpdateUIState), + typeof(UpdateStateBadgeControl), + new PropertyMetadata(UpdateViewModel.UpdateUIState.UpToDate, OnStateChanged)); + + private bool _isLoaded; + + public UpdateViewModel.UpdateUIState State + { + get => (UpdateViewModel.UpdateUIState)GetValue(StateProperty); + set => SetValue(StateProperty, value); + } + + public UpdateStateBadgeControl() + { + InitializeComponent(); + Loaded += UpdateStateBadgeControl_Loaded; + } + + private static void OnStateChanged(DependencyObject dependencyObject, DependencyPropertyChangedEventArgs args) + { + var control = (UpdateStateBadgeControl)dependencyObject; + if (control._isLoaded) + { + control.UpdateVisualState(); + } + } + + private void UpdateStateBadgeControl_Loaded(object sender, RoutedEventArgs e) + { + _isLoaded = true; + UpdateVisualState(); + } + + private void UpdateVisualState() + { + string stateName = State switch + { + UpdateViewModel.UpdateUIState.Checking => "CheckingState", + UpdateViewModel.UpdateUIState.ReadyToDownload or + UpdateViewModel.UpdateUIState.ReadyToInstall => "AttentionState", + UpdateViewModel.UpdateUIState.Downloading => "DownloadingState", + UpdateViewModel.UpdateUIState.NetworkError or + UpdateViewModel.UpdateUIState.ErrorDownloading => "ErrorState", + _ => "SuccessState", + }; + + VisualStateManager.GoToState(this, stateName, false); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStatusControl.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStatusControl.xaml new file mode 100644 index 0000000000..090f4ca5d4 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/Controls/UpdateStatusControl.xaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +