diff --git a/Directory.Packages.props b/Directory.Packages.props index 5dedc8bfb2be..1e23b1821c70 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -26,7 +26,7 @@ - + diff --git a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs index fd684168b02a..d14c95fcad65 100644 --- a/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs +++ b/src/settings-ui/Settings.UI/OOBE/Enums/PowerToysModules.cs @@ -32,7 +32,6 @@ public enum PowerToysModules MeasureTool, Hosts, Workspaces, - WhatsNew, RegistryPreview, NewPlus, ZoomIt, diff --git a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs index 417bdeef361c..7f14f1809ed3 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/App.xaml.cs @@ -9,7 +9,6 @@ using System.Linq; using System.Text.Json; using System.Threading.Tasks; - using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; @@ -227,7 +226,6 @@ private void OnLaunchedFromRunner(string[] cmdArgs) { settingsWindow = new MainWindow(); settingsWindow.Activate(); - settingsWindow.ExtendsContentIntoTitleBar = true; settingsWindow.NavigateToSection(StartupPage); // https://github.com/microsoft/microsoft-ui-xaml/issues/7595 - Activate doesn't bring window to the foreground @@ -257,11 +255,10 @@ private void OnLaunchedFromRunner(string[] cmdArgs) else if (ShowScoobe) { PowerToysTelemetry.Log.WriteEvent(new ScoobeStartedEvent()); - OobeWindow scoobeWindow = new OobeWindow(OOBE.Enums.PowerToysModules.WhatsNew); - scoobeWindow.Activate(); - scoobeWindow.ExtendsContentIntoTitleBar = true; + ScoobeWindow newScoobeWindow = new ScoobeWindow(); + newScoobeWindow.Activate(); WindowHelpers.ForceTopBorder1PixelInsetOnWindows10(WindowNative.GetWindowHandle(settingsWindow)); - SetOobeWindow(scoobeWindow); + SetScoobeWindow(newScoobeWindow); } } } @@ -339,6 +336,7 @@ public static int UpdateUIThemeMethod(string themeName) private static MainWindow settingsWindow; private static OobeWindow oobeWindow; + private static ScoobeWindow scoobeWindow; public static void ClearSettingsWindow() { @@ -365,6 +363,21 @@ public static void ClearOobeWindow() oobeWindow = null; } + public static ScoobeWindow GetScoobeWindow() + { + return scoobeWindow; + } + + public static void SetScoobeWindow(ScoobeWindow window) + { + scoobeWindow = window; + } + + public static void ClearScoobeWindow() + { + scoobeWindow = null; + } + public static Type GetPage(string settingWindow) { switch (settingWindow) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs index e67b0efc0a85..e85633f9e8f0 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/MainWindow.xaml.cs @@ -20,9 +20,6 @@ namespace Microsoft.PowerToys.Settings.UI { - /// - /// An empty window that can be used on its own or navigated to within a Frame. - /// public sealed partial class MainWindow : WindowEx { public MainWindow(bool createHidden = false) @@ -35,10 +32,12 @@ public MainWindow(bool createHidden = false) App.ThemeService.ThemeChanged += OnThemeChanged; App.ThemeService.ApplyTheme(); + this.ExtendsContentIntoTitleBar = true; + ShellPage.SetElevationStatus(App.IsElevated); ShellPage.SetIsUserAnAdmin(App.IsUserAnAdmin); - var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + var hWnd = WindowNative.GetWindowHandle(this); var placement = WindowHelper.DeserializePlacementOrDefault(hWnd); if (createHidden) { @@ -121,16 +120,12 @@ public MainWindow(bool createHidden = false) // open whats new window ShellPage.SetOpenWhatIsNewCallback(() => { - if (App.GetOobeWindow() == null) + if (App.GetScoobeWindow() == null) { - App.SetOobeWindow(new OobeWindow(OOBE.Enums.PowerToysModules.WhatsNew)); - } - else - { - App.GetOobeWindow().SetAppWindow(OOBE.Enums.PowerToysModules.WhatsNew); + App.SetScoobeWindow(new ScoobeWindow()); } - App.GetOobeWindow().Activate(); + App.GetScoobeWindow().Activate(); }); this.InitializeComponent(); @@ -187,7 +182,7 @@ private void Window_Closed(object sender, WindowEventArgs args) var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); WindowHelper.SerializePlacement(hWnd); - if (App.GetOobeWindow() == null) + if (App.GetOobeWindow() == null && App.GetScoobeWindow() == null) { App.ClearSettingsWindow(); } diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml index ed03a477e40c..78fee7fde321 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeShellPage.xaml @@ -1,60 +1,44 @@ - - - + - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs deleted file mode 100644 index 9d7f0e78f337..000000000000 --- a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/OobeWhatsNew.xaml.cs +++ /dev/null @@ -1,359 +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 System.Collections.Generic; -using System.ComponentModel; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Text; -using System.Text.Json; -using System.Text.RegularExpressions; -using System.Threading.Tasks; -using CommunityToolkit.WinUI.Controls; -using global::PowerToys.GPOWrapper; -using ManagedCommon; -using Microsoft.PowerToys.Settings.UI.Helpers; -using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts; -using Microsoft.PowerToys.Settings.UI.Library.Interfaces; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; -using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel; -using Microsoft.PowerToys.Settings.UI.SerializationContext; -using Microsoft.PowerToys.Settings.UI.Services; -using Microsoft.PowerToys.Settings.UI.Views; -using Microsoft.PowerToys.Telemetry; -using Microsoft.UI.Text; -using Microsoft.UI.Xaml; -using Microsoft.UI.Xaml.Controls; -using Microsoft.UI.Xaml.Media; -using Microsoft.UI.Xaml.Navigation; - -namespace Microsoft.PowerToys.Settings.UI.OOBE.Views -{ - public sealed partial class OobeWhatsNew : Page, INotifyPropertyChanged - { - public OobePowerToysModule ViewModel { get; set; } - - private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData(); - - public bool ShowDataDiagnosticsInfoBar => GetShowDataDiagnosticsInfoBar(); - - private int _conflictCount; - - public AllHotkeyConflictsData AllHotkeyConflictsData - { - get => _allHotkeyConflictsData; - set - { - if (_allHotkeyConflictsData != value) - { - _allHotkeyConflictsData = value; - - UpdateConflictCount(); - - OnPropertyChanged(nameof(AllHotkeyConflictsData)); - OnPropertyChanged(nameof(HasConflicts)); - } - } - } - - public bool HasConflicts => _conflictCount > 0; - - private void UpdateConflictCount() - { - int count = 0; - if (AllHotkeyConflictsData == null) - { - _conflictCount = count; - } - - if (AllHotkeyConflictsData.InAppConflicts != null) - { - foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts) - { - if (!inAppConflict.ConflictIgnored) - { - count++; - } - } - } - - if (AllHotkeyConflictsData.SystemConflicts != null) - { - foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts) - { - if (!systemConflict.ConflictIgnored) - { - count++; - } - } - } - - _conflictCount = count; - } - - public event PropertyChangedEventHandler PropertyChanged; - - /// - /// Initializes a new instance of the class. - /// - public OobeWhatsNew() - { - this.InitializeComponent(); - ViewModel = new OobePowerToysModule(OobeShellPage.OobeShellHandler.Modules[(int)PowerToysModules.WhatsNew]); - DataContext = this; - - // Subscribe to hotkey conflict updates - if (GlobalHotkeyConflictManager.Instance != null) - { - GlobalHotkeyConflictManager.Instance.ConflictsUpdated += OnConflictsUpdated; - GlobalHotkeyConflictManager.Instance.RequestAllConflicts(); - } - } - - private void OnConflictsUpdated(object sender, AllHotkeyConflictsEventArgs e) - { - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - var allConflictData = e.Conflicts; - foreach (var inAppConflict in allConflictData.InAppConflicts) - { - var hotkey = inAppConflict.Hotkey; - var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); - inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); - } - - foreach (var systemConflict in allConflictData.SystemConflicts) - { - var hotkey = systemConflict.Hotkey; - var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key); - systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting); - } - - AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData(); - }); - } - - private void OnPropertyChanged(string propertyName) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } - - private bool GetShowDataDiagnosticsInfoBar() - { - var isDataDiagnosticsGpoDisallowed = GPOWrapper.GetAllowDataDiagnosticsValue() == GpoRuleConfigured.Disabled; - - if (isDataDiagnosticsGpoDisallowed) - { - return false; - } - - bool userActed = DataDiagnosticsSettings.GetUserActionValue(); - - if (userActed) - { - return false; - } - - bool registryValue = DataDiagnosticsSettings.GetEnabledValue(); - - bool isFirstRunAfterUpdate = (App.Current as Microsoft.PowerToys.Settings.UI.App).ShowScoobe; - if (isFirstRunAfterUpdate && registryValue == false) - { - return true; - } - - return false; - } - - /// - /// Regex to remove installer hash sections from the release notes. - /// - private const string RemoveInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+## Highlights"; - private const string RemoveHotFixInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+$"; - private const RegexOptions RemoveInstallerHashesRegexOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; - private bool _loadingReleaseNotes; - - private static async Task GetReleaseNotesMarkdown() - { - string releaseNotesJSON = string.Empty; - - // Let's use system proxy - using var proxyClientHandler = new HttpClientHandler - { - DefaultProxyCredentials = CredentialCache.DefaultCredentials, - Proxy = WebRequest.GetSystemWebProxy(), - PreAuthenticate = true, - }; - - using var getReleaseInfoClient = new HttpClient(proxyClientHandler); - - // GitHub APIs require sending an user agent - // https://docs.github.com/rest/overview/resources-in-the-rest-api#user-agent-required - getReleaseInfoClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); - releaseNotesJSON = await getReleaseInfoClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases"); - IList releases = JsonSerializer.Deserialize>(releaseNotesJSON, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo); - - // Get the latest releases - var latestReleases = releases.OrderByDescending(release => release.PublishedDate).Take(5); - - StringBuilder releaseNotesHtmlBuilder = new StringBuilder(string.Empty); - - // Regex to remove installer hash sections from the release notes. - Regex removeHashRegex = new Regex(RemoveInstallerHashesRegex, RemoveInstallerHashesRegexOptions); - - // Regex to remove installer hash sections from the release notes, since there'll be no Highlights section for hotfix releases. - Regex removeHotfixHashRegex = new Regex(RemoveHotFixInstallerHashesRegex, RemoveInstallerHashesRegexOptions); - int counter = 0; - foreach (var release in latestReleases) - { - releaseNotesHtmlBuilder.AppendLine("# " + release.Name); - var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n### Highlights"); - - // Add a unique counter to [github-current-release-work] to distinguish each release, - // since this variable is used for all latest releases when they are merged. - notes = notes.Replace("[github-current-release-work]", $"[github-current-release-work{++counter}]"); - notes = removeHotfixHashRegex.Replace(notes, string.Empty); - releaseNotesHtmlBuilder.AppendLine(notes); - releaseNotesHtmlBuilder.AppendLine(" "); - } - - return releaseNotesHtmlBuilder.ToString(); - } - - private async Task Reload() - { - if (_loadingReleaseNotes) - { - return; - } - - try - { - _loadingReleaseNotes = true; - ReleaseNotesMarkdown.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - string releaseNotesMarkdown = await GetReleaseNotesMarkdown(); - ProxyWarningInfoBar.IsOpen = false; - ErrorInfoBar.IsOpen = false; - - ReleaseNotesMarkdown.Text = releaseNotesMarkdown; - ReleaseNotesMarkdown.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - } - catch (HttpRequestException httpEx) - { - Logger.LogError("Exception when loading the release notes", httpEx); - if (httpEx.Message.Contains("407", StringComparison.CurrentCulture)) - { - ProxyWarningInfoBar.IsOpen = true; - } - else - { - ErrorInfoBar.IsOpen = true; - } - } - catch (Exception ex) - { - Logger.LogError("Exception when loading the release notes", ex); - ErrorInfoBar.IsOpen = true; - } - finally - { - LoadingProgressRing.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - _loadingReleaseNotes = false; - } - } - - private async void Page_Loaded(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - await Reload(); - } - - /// - protected override void OnNavigatedTo(NavigationEventArgs e) - { - ViewModel.LogOpeningModuleEvent(); - } - - /// - protected override void OnNavigatedFrom(NavigationEventArgs e) - { - ViewModel.LogClosingModuleEvent(); - - // Unsubscribe from conflict updates when leaving the page - if (GlobalHotkeyConflictManager.Instance != null) - { - GlobalHotkeyConflictManager.Instance.ConflictsUpdated -= OnConflictsUpdated; - } - } - - private void DataDiagnostics_InfoBar_YesNo_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - string commandArg = string.Empty; - if (sender is Button senderBtn) - { - commandArg = senderBtn.CommandParameter.ToString(); - } - else if (sender is HyperlinkButton senderLink) - { - commandArg = senderLink.CommandParameter.ToString(); - } - - if (string.IsNullOrEmpty(commandArg)) - { - return; - } - - // Update UI - if (commandArg == "Yes") - { - WhatsNewDataDiagnosticsInfoBar.Header = ResourceLoaderInstance.ResourceLoader.GetString("Oobe_WhatsNew_DataDiagnostics_Yes_Click_InfoBar_Title"); - } - else - { - WhatsNewDataDiagnosticsInfoBar.Header = ResourceLoaderInstance.ResourceLoader.GetString("Oobe_WhatsNew_DataDiagnostics_No_Click_InfoBar_Title"); - } - - WhatsNewDataDiagnosticsInfoBarDescText.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - WhatsNewDataDiagnosticsInfoBarDescTextYesClicked.Visibility = Microsoft.UI.Xaml.Visibility.Visible; - DataDiagnosticsButtonYes.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - DataDiagnosticsButtonNo.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - - // Set Data Diagnostics registry values - if (commandArg == "Yes") - { - DataDiagnosticsSettings.SetEnabledValue(true); - } - else - { - DataDiagnosticsSettings.SetEnabledValue(false); - } - - DataDiagnosticsSettings.SetUserActionValue(true); - - this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () => - { - ShellPage.ShellHandler?.SignalGeneralDataUpdate(); - }); - } - - private void DataDiagnostics_InfoBar_Close_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - WhatsNewDataDiagnosticsInfoBar.Visibility = Microsoft.UI.Xaml.Visibility.Collapsed; - } - - private void DataDiagnostics_OpenSettings_Click(Microsoft.UI.Xaml.Documents.Hyperlink sender, Microsoft.UI.Xaml.Documents.HyperlinkClickEventArgs args) - { - Common.UI.SettingsDeepLink.OpenSettings(Common.UI.SettingsDeepLink.SettingsWindow.Overview); - } - - private async void LoadReleaseNotes_Click(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) - { - await Reload(); - } - } -} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs new file mode 100644 index 000000000000..4689d179b274 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseGroupViewModel.cs @@ -0,0 +1,62 @@ +// 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.Collections.Generic; +using System.Globalization; + +using Microsoft.PowerToys.Settings.UI.Helpers; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + /// + /// View model for a group of releases (grouped by major.minor version). + /// + public class ScoobeReleaseGroupViewModel + { + /// + /// Gets the list of releases in this group. + /// + public IList Releases { get; } + + /// + /// Gets the version text to display (e.g., "0.96.0"). + /// + public string VersionText { get; } + + /// + /// Gets the date text to display (e.g., "December 2025"). + /// + public string DateText { get; } + + public ScoobeReleaseGroupViewModel(IList releases) + { + Releases = releases ?? throw new ArgumentNullException(nameof(releases)); + + if (releases.Count > 0) + { + var latestRelease = releases[0]; + VersionText = GetVersionFromRelease(latestRelease); + DateText = latestRelease.PublishedDate.ToString("MMMM yyyy", CultureInfo.CurrentCulture); + } + else + { + VersionText = "Unknown"; + DateText = string.Empty; + } + } + + private static string GetVersionFromRelease(PowerToysReleaseInfo release) + { + // TagName is typically like "v0.96.0", Name might be "Release v0.96.0" + string version = release.TagName ?? release.Name ?? "Unknown"; + if (version.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + version = version.Substring(1); + } + + return version; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml new file mode 100644 index 000000000000..5bd267fee570 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs new file mode 100644 index 000000000000..9371fcbaed30 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeReleaseNotesPage.xaml.cs @@ -0,0 +1,165 @@ +// 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.Collections.Generic; +using System.Globalization; +using System.Text; +using System.Text.RegularExpressions; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media.Imaging; +using Microsoft.UI.Xaml.Navigation; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class ScoobeReleaseNotesPage : Page + { + private IList _currentReleases; + + /// + /// Initializes a new instance of the class. + /// + public ScoobeReleaseNotesPage() + { + this.InitializeComponent(); + } + + /// + /// Regex to remove installer hash sections from the release notes. + /// + private const string RemoveInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+## Highlights"; + private const string RemoveHotFixInstallerHashesRegex = @"(\r\n)+## Installer Hashes(\r\n.*)+$"; + private const RegexOptions RemoveInstallerHashesRegexOptions = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.CultureInvariant; + + /// + /// Regex to match markdown images with 'Hero' in the alt text. + /// Matches: ![...Hero...](url) + /// + private static readonly Regex HeroImageRegex = new Regex( + @"!\[([^\]]*Hero[^\]]*)\]\(([^)]+)\)", + RegexOptions.Compiled | RegexOptions.IgnoreCase); + + /// + /// Regex to match GitHub PR/Issue references (e.g., #41029). + /// Only matches # followed by digits that are not already part of a markdown link. + /// + private static readonly Regex GitHubPrReferenceRegex = new Regex( + @"(? releases) + { + if (releases == null || releases.Count == 0) + { + return (string.Empty, null); + } + + StringBuilder releaseNotesHtmlBuilder = new StringBuilder(string.Empty); + + // Regex to remove installer hash sections from the release notes. + Regex removeHashRegex = new Regex(RemoveInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + + // Regex to remove installer hash sections from the release notes, since there'll be no Highlights section for hotfix releases. + Regex removeHotfixHashRegex = new Regex(RemoveHotFixInstallerHashesRegex, RemoveInstallerHashesRegexOptions); + + string lastHeroImageUrl = null; + + int counter = 0; + bool isFirst = true; + foreach (var release in releases) + { + // Add separator between releases + if (!isFirst) + { + releaseNotesHtmlBuilder.AppendLine("---"); + releaseNotesHtmlBuilder.AppendLine(); + } + + isFirst = false; + + var releaseUrl = string.Format(CultureInfo.InvariantCulture, GitHubReleaseLinkTemplate, release.TagName); + releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"# {release.Name}"); + releaseNotesHtmlBuilder.AppendLine(CultureInfo.InvariantCulture, $"{release.PublishedDate.ToString("MMMM d, yyyy", CultureInfo.CurrentCulture)} � [View on GitHub]({releaseUrl})"); + releaseNotesHtmlBuilder.AppendLine(); + releaseNotesHtmlBuilder.AppendLine(" "); + releaseNotesHtmlBuilder.AppendLine(); + var notes = removeHashRegex.Replace(release.ReleaseNotes, "\r\n## Highlights"); + notes = notes.Replace("[github-current-release-work]", $"[github-current-release-work{++counter}]"); + notes = removeHotfixHashRegex.Replace(notes, string.Empty); + + // Find all Hero images and keep track of the last one + var heroMatches = HeroImageRegex.Matches(notes); + foreach (Match match in heroMatches) + { + lastHeroImageUrl = match.Groups[2].Value; + } + + // Remove Hero images from the markdown + notes = HeroImageRegex.Replace(notes, string.Empty); + + // Convert GitHub PR/Issue references to hyperlinks + notes = GitHubPrReferenceRegex.Replace(notes, match => + string.Format(CultureInfo.InvariantCulture, GitHubPrLinkTemplate, match.Groups[1].Value)); + + releaseNotesHtmlBuilder.AppendLine(notes); + releaseNotesHtmlBuilder.AppendLine(" "); + } + + return (releaseNotesHtmlBuilder.ToString(), lastHeroImageUrl); + } + + private void DisplayReleaseNotes() + { + if (_currentReleases == null || _currentReleases.Count == 0) + { + ReleaseNotesMarkdown.Visibility = Visibility.Collapsed; + return; + } + + try + { + LoadingProgressRing.Visibility = Visibility.Collapsed; + + var (releaseNotesMarkdown, heroImageUrl) = ProcessReleaseNotesMarkdown(_currentReleases); + + // Set the Hero image if found + if (!string.IsNullOrEmpty(heroImageUrl)) + { + HeroImageHolder.Source = new BitmapImage(new Uri(heroImageUrl)); + HeroImageHolder.Visibility = Visibility.Visible; + } + else + { + HeroImageHolder.Visibility = Visibility.Collapsed; + } + + ReleaseNotesMarkdown.Text = releaseNotesMarkdown; + ReleaseNotesMarkdown.Visibility = Visibility.Visible; + } + catch (Exception ex) + { + Logger.LogError("Exception when displaying the release notes", ex); + } + } + + private void Page_Loaded(object sender, RoutedEventArgs e) + { + DisplayReleaseNotes(); + } + + protected override void OnNavigatedTo(NavigationEventArgs e) + { + if (e.Parameter is IList releases) + { + _currentReleases = releases; + } + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml new file mode 100644 index 000000000000..4ea411a57420 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml.cs new file mode 100644 index 000000000000..a18ea16a226e --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/OOBE/Views/ScoobeShellPage.xaml.cs @@ -0,0 +1,194 @@ +// 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.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using ManagedCommon; +using Microsoft.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.SerializationContext; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.PowerToys.Settings.UI.OOBE.Views +{ + public sealed partial class ScoobeShellPage : Page + { + public static Action OpenMainWindowCallback { get; set; } + + public static void SetOpenMainWindowCallback(Action implementation) + { + OpenMainWindowCallback = implementation; + } + + /// + /// Gets or sets a shell handler to be used to update contents of the shell dynamically from page within the frame. + /// + public static ScoobeShellPage ScoobeShellHandler { get; set; } + + /// + /// Gets the list of release groups loaded from GitHub (grouped by major.minor version). + /// + public IList> ReleaseGroups { get; private set; } + + private bool _isLoading; + + public ScoobeShellPage() + { + InitializeComponent(); + ScoobeShellHandler = this; + } + + private async void ShellPage_Loaded(object sender, RoutedEventArgs e) + { + SetTitleBar(); + await LoadReleasesAsync(); + } + + private async Task LoadReleasesAsync() + { + if (_isLoading) + { + return; + } + + _isLoading = true; + LoadingProgressRing.Visibility = Visibility.Visible; + ErrorInfoBar.IsOpen = false; + navigationView.MenuItems.Clear(); + + try + { + var releases = await FetchReleasesFromGitHubAsync(); + ReleaseGroups = GroupReleasesByMajorMinor(releases); + PopulateNavigationItems(); + } + catch (Exception ex) + { + Logger.LogError("Failed to load releases", ex); + ErrorInfoBar.IsOpen = true; + } + finally + { + LoadingProgressRing.Visibility = Visibility.Collapsed; + _isLoading = false; + } + } + + private static async Task> FetchReleasesFromGitHubAsync() + { + using var proxyClientHandler = new HttpClientHandler + { + DefaultProxyCredentials = CredentialCache.DefaultCredentials, + Proxy = WebRequest.GetSystemWebProxy(), + PreAuthenticate = true, + }; + + using var httpClient = new HttpClient(proxyClientHandler); + httpClient.DefaultRequestHeaders.TryAddWithoutValidation("User-Agent", "PowerToys"); + + string json = await httpClient.GetStringAsync("https://api.github.com/repos/microsoft/PowerToys/releases?per_page=20"); + var allReleases = JsonSerializer.Deserialize>(json, SourceGenerationContextContext.Default.IListPowerToysReleaseInfo); + + return allReleases + .OrderByDescending(r => r.PublishedDate) + .ToList(); + } + + private static IList> GroupReleasesByMajorMinor(IList releases) + { + return releases + .GroupBy(r => GetMajorMinorVersion(r)) + .Select(g => g.OrderByDescending(r => r.PublishedDate).ToList() as IList) + .ToList(); + } + + private static string GetMajorMinorVersion(PowerToysReleaseInfo release) + { + string version = GetVersionFromRelease(release); + var parts = version.Split('.'); + if (parts.Length >= 2) + { + return $"{parts[0]}.{parts[1]}"; + } + + return version; + } + + private static string GetVersionFromRelease(PowerToysReleaseInfo release) + { + // TagName is typically like "v0.96.0", Name might be "Release v0.96.0" + string version = release.TagName ?? release.Name ?? "Unknown"; + if (version.StartsWith("v", StringComparison.OrdinalIgnoreCase)) + { + version = version.Substring(1); + } + + return version; + } + + private void PopulateNavigationItems() + { + if (ReleaseGroups == null || ReleaseGroups.Count == 0) + { + return; + } + + foreach (var releaseGroup in ReleaseGroups) + { + var viewModel = new ScoobeReleaseGroupViewModel(releaseGroup); + navigationView.MenuItems.Add(viewModel); + } + + // Select the first item to trigger navigation + navigationView.SelectedItem = navigationView.MenuItems[0]; + } + + private void NavigationView_SelectionChanged(NavigationView sender, NavigationViewSelectionChangedEventArgs args) + { + if (args.SelectedItem is ScoobeReleaseGroupViewModel viewModel) + { + NavigationFrame.Navigate(typeof(ScoobeReleaseNotesPage), viewModel.Releases); + } + } + + private async void RetryButton_Click(object sender, RoutedEventArgs e) + { + await LoadReleasesAsync(); + } + + private void SetTitleBar() + { + var window = App.GetScoobeWindow(); + if (window != null) + { + window.ExtendsContentIntoTitleBar = true; + window.SetTitleBar(AppTitleBar); + } + } + + private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args) + { + if (args.DisplayMode == NavigationViewDisplayMode.Compact || args.DisplayMode == NavigationViewDisplayMode.Minimal) + { + TitleBarIcon.Margin = new Thickness(0, 0, 8, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = true; + } + else + { + TitleBarIcon.Margin = new Thickness(16, 0, 0, 0); // Workaround, see XAML comment + AppTitleBar.IsPaneToggleButtonVisible = false; + } + } + + private void TitleBar_PaneButtonClick(TitleBar sender, object args) + { + navigationView.IsPaneOpen = !navigationView.IsPaneOpen; + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs index 56262fea6aae..6a630547736f 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/OobeWindow.xaml.cs @@ -44,6 +44,7 @@ public OobeWindow(PowerToysModules initialModule) _windowId = Win32Interop.GetWindowIdFromWindow(_hWnd); _appWindow = AppWindow.GetFromWindowId(_windowId); this.Activated += Window_Activated_SetIcon; + this.ExtendsContentIntoTitleBar = true; var dpi = NativeMethods.GetDpiForWindow(_hWnd); _currentDPI = dpi; @@ -60,7 +61,7 @@ public OobeWindow(PowerToysModules initialModule) this.SizeChanged += OobeWindow_SizeChanged; - var loader = Helpers.ResourceLoaderInstance.ResourceLoader; + var loader = ResourceLoaderInstance.ResourceLoader; Title = loader.GetString("OobeWindow_Title"); if (shellPage != null) diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml new file mode 100644 index 000000000000..934229cea159 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml @@ -0,0 +1,17 @@ + + + + + + diff --git a/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs new file mode 100644 index 000000000000..446d0e2d0d48 --- /dev/null +++ b/src/settings-ui/Settings.UI/SettingsXAML/ScoobeWindow.xaml.cs @@ -0,0 +1,121 @@ +// 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.PowerToys.Settings.UI.Helpers; +using Microsoft.PowerToys.Settings.UI.OOBE.Views; +using Microsoft.UI; +using Microsoft.UI.Windowing; +using Microsoft.UI.Xaml; +using PowerToys.Interop; +using Windows.Graphics; +using WinUIEx; +using WinUIEx.Messaging; + +namespace Microsoft.PowerToys.Settings.UI +{ + public sealed partial class ScoobeWindow : WindowEx, IDisposable + { + private const int ExpectedWidth = 1100; + private const int ExpectedHeight = 700; + private const int DefaultDPI = 96; + private int _currentDPI; + private WindowId _windowId; + private IntPtr _hWnd; + private AppWindow _appWindow; + private bool disposedValue; + + public ScoobeWindow() + { + App.ThemeService.ThemeChanged += OnThemeChanged; + App.ThemeService.ApplyTheme(); + + this.InitializeComponent(); + + _hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this); + _windowId = Win32Interop.GetWindowIdFromWindow(_hWnd); + _appWindow = AppWindow.GetFromWindowId(_windowId); + this.Activated += Window_Activated_SetIcon; + this.ExtendsContentIntoTitleBar = true; + + var dpi = NativeMethods.GetDpiForWindow(_hWnd); + _currentDPI = dpi; + float scalingFactor = (float)dpi / DefaultDPI; + int width = (int)(ExpectedWidth * scalingFactor); + int height = (int)(ExpectedHeight * scalingFactor); + + SizeInt32 size; + size.Width = width; + size.Height = height; + _appWindow.Resize(size); + + this.SizeChanged += ScoobeWindow_SizeChanged; + + var loader = Helpers.ResourceLoaderInstance.ResourceLoader; + Title = loader.GetString("ScoobeWindow_Title"); + + ScoobeShellPage.SetOpenMainWindowCallback((Type type) => + { + App.OpenSettingsWindow(type); + }); + } + + private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args) + { + // Set window icon + _appWindow.SetIcon("Assets\\Settings\\icon.ico"); + } + + private void ScoobeWindow_SizeChanged(object sender, WindowSizeChangedEventArgs args) + { + var dpi = NativeMethods.GetDpiForWindow(_hWnd); + if (_currentDPI != dpi) + { + // Reacting to a DPI change. Should not cause a resize -> sizeChanged loop. + _currentDPI = dpi; + float scalingFactor = (float)dpi / DefaultDPI; + int width = (int)(ExpectedWidth * scalingFactor); + int height = (int)(ExpectedHeight * scalingFactor); + SizeInt32 size; + size.Width = width; + size.Height = height; + _appWindow.Resize(size); + } + } + + private void Window_Closed(object sender, WindowEventArgs args) + { + App.ClearScoobeWindow(); + + var mainWindow = App.GetSettingsWindow(); + if (mainWindow != null) + { + mainWindow.CloseHiddenWindow(); + } + + App.ThemeService.ThemeChanged -= OnThemeChanged; + } + + private void OnThemeChanged(object sender, ElementTheme theme) + { + WindowHelper.SetTheme(this, theme); + } + + private void Dispose(bool disposing) + { + if (!disposedValue) + { + disposedValue = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs index 639eb5f747d6..752cc695edd5 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/DashboardPage.xaml.cs @@ -8,8 +8,6 @@ using ManagedCommon; using Microsoft.PowerToys.Settings.UI.Helpers; using Microsoft.PowerToys.Settings.UI.Library; -using Microsoft.PowerToys.Settings.UI.OOBE.Enums; -using Microsoft.PowerToys.Settings.UI.OOBE.Views; using Microsoft.PowerToys.Settings.UI.ViewModels; using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; @@ -50,16 +48,12 @@ public void RefreshEnabledState() private void WhatsNewButton_Click(object sender, RoutedEventArgs e) { - if (App.GetOobeWindow() == null) + if (App.GetScoobeWindow() == null) { - App.SetOobeWindow(new OobeWindow(PowerToysModules.WhatsNew)); - } - else - { - App.GetOobeWindow().SetAppWindow(PowerToysModules.WhatsNew); + App.SetScoobeWindow(new ScoobeWindow()); } - App.GetOobeWindow().Activate(); + App.GetScoobeWindow().Activate(); } private void SortAlphabetical_Click(object sender, RoutedEventArgs e) diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index 679910a082e9..9a54159984c4 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -2419,9 +2419,15 @@ From there, simply click on one of the supported files in the File Explorer and Welcome to PowerToys - + Welcome to PowerToys + + What's new in PowerToys + + + What's new in PowerToys + PowerToys Settings Title of the settings window when running as user