From 1b72c0b969935092022bc1dd066db7abcb51e534 Mon Sep 17 00:00:00 2001 From: Kai Tao <69313318+vanzue@users.noreply.github.com> Date: Thu, 27 Nov 2025 17:22:59 +0800 Subject: [PATCH 01/10] Update check-spelling expect list (#43925) ## Summary of the Pull Request ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed Spell no complain --- .github/actions/spell-check/expect.txt | 107 ++++++++++--------------- 1 file changed, 44 insertions(+), 63 deletions(-) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index e2abef0344..1ce6f88899 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -2,8 +2,8 @@ AAAAs abcdefghjkmnpqrstuvxyz abgr ABlocked -ABOUTBOX ABORTIFHUNG +ABOUTBOX Abug Acceleratorkeys ACCEPTFILES @@ -97,8 +97,8 @@ ASSOCSTR ASYNCWINDOWPLACEMENT ASYNCWINDOWPOS atl -ATX ATRIOX +ATX aumid authenticode AUTOBUDDY @@ -117,10 +117,10 @@ azman azureaiinference azureinference azureopenai +backticks bbwe BCIE bck -backticks BESTEFFORT bezelled bhid @@ -148,8 +148,8 @@ bmi BNumber BODGY BOklab -Bootstrappers BOOTSTRAPPERINSTALLFOLDER +Bootstrappers BOTTOMALIGN boxmodel BPBF @@ -176,17 +176,16 @@ BYPOSITION CALCRECT CALG callbackptr -cabstr calpwstr -caub Cangjie CANRENAME -Carlseibert Canvascustomlayout CAPTUREBLT CAPTURECHANGED CARETBLINKING +Carlseibert CAtl +caub CBN cch CCHDEVICENAME @@ -206,11 +205,9 @@ changecursor CHILDACTIVATE CHILDWINDOW CHOOSEFONT -CIBUILD cidl CIELCh cim -claude CImage cla CLASSDC @@ -264,7 +261,6 @@ CONFIGW CONFLICTINGMODIFIERKEY CONFLICTINGMODIFIERSHORTCUT CONOUT -coreclr constexpr contentdialog contentfiles @@ -276,6 +272,7 @@ copiedcolorrepresentation coppied copyable COPYPEN +coreclr COREWINDOW Corpor cotaskmem @@ -284,18 +281,18 @@ countof covrun cpcontrols cph -cppcoreguidelines cplusplus CPower +cppcoreguidelines cpptools cppvsdbg cppwinrt createdump -creativecommons CREATEPROCESS CREATESCHEDULEDTASK CREATESTRUCT CREATEWINDOWFAILED +creativecommons CRECT CRH critsec @@ -331,7 +328,6 @@ CYSCREEN CYSMICON CYVIRTUALSCREEN Czechia -cziplib Dac dacl DAffine @@ -355,9 +351,7 @@ Deact debugbreak decryptor Dedup -dfx Deduplicator -Deeplink DEFAULTBOOTSTRAPPERINSTALLFOLDER DEFAULTCOLOR DEFAULTFLAGS @@ -404,7 +398,6 @@ DISPLAYFREQUENCY displayname DISPLAYORIENTATION divyan -djwsxzxb Dlg DLGFRAME DLGMODALFRAME @@ -417,7 +410,6 @@ DONTVALIDATEPATH dotnet downsampled downsampling -Downsampled downscale DPICHANGED DPIs @@ -531,7 +523,6 @@ EXTRINSICPROPERTIES eyetracker FANCYZONESDRAWLAYOUTTEST FANCYZONESEDITOR -FNumber FARPROC fdx fesf @@ -563,8 +554,8 @@ FIXEDSYS flac flyouts FMask -foundrylocal fmtid +FNumber FOF FOFX FOLDERID @@ -575,6 +566,7 @@ FORCEMINIMIZE FORMATDLGORD formatetc FORPARSING +foundrylocal FRAMECHANGED frm FROMTOUCH @@ -593,13 +585,13 @@ gdi gdiplus GDIPVER GDISCALED +geolocator GETCLIENTAREAANIMATION GETCURSEL GETDESKWALLPAPER GETDLGCODE GETDPISCALEDSIZE getfilesiginforedist -geolocator GETHOTKEY GETICON GETLBTEXT @@ -610,11 +602,12 @@ GETSCREENSAVERRUNNING GETSECKEY GETSTICKYKEYS GETTEXTLENGTH -GIFs -gitmodules GHND +gitmodules GMEM GNumber +googleai +googlegemini gpedit gpo GPOCA @@ -631,8 +624,6 @@ GValue gwl GWLP GWLSTYLE -googleai -googlegemini hangeul Hanzi Hardlines @@ -743,9 +734,7 @@ IDCANCEL IDD idk idl -IIM idlist -ifd IDOK IDOn IDR @@ -754,15 +743,16 @@ ietf IEXPLORE IFACEMETHOD IFACEMETHODIMP +ifd IGNOREUNKNOWN IGo iid +IIM Iindex Ijwhost ILD IMAGEHLP IMAGERESIZERCONTEXTMENU -IPTC IMAGERESIZEREXT imageresizerinput imageresizersettings @@ -798,7 +788,6 @@ INSTALLFOLDERTOPREVIOUSINSTALLFOLDER INSTALLLOCATION INSTALLMESSAGE INSTALLPROPERTY -installscopeperuser INSTALLSTARTMENUSHORTCUT INSTALLSTATE Inste @@ -811,6 +800,7 @@ invokecommand ipcmanager IPREVIEW ipreviewhandlervisualssetfont +IPTC irow irprops isbi @@ -854,15 +844,14 @@ keyvault KILLFOCUS killrunner kmph -ksa kvp Kybd LARGEICON lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL -LCh lbl +LCh lcid LCIDTo lcl @@ -878,10 +867,10 @@ LExit lhwnd LIBFUZZER LIBID +lightswitch LIMITSIZE LIMITTEXT lindex -lightswitch linkid LINKOVERLAY LINQTo @@ -892,6 +881,7 @@ LLKH llkhf LMEM LMENU +lng LOADFROMFILE LOBYTE localappdata @@ -901,17 +891,14 @@ LOCATIONCHANGE LOCKTYPE LOGFONT LOGFONTW -logon -lon LOGMSG +logon LOGPIXELSX LOGPIXELSY -lng lon longdate LONGNAMES lowlevel -lquadrant LOWORD lparam LPBITMAPINFOHEADER @@ -945,6 +932,7 @@ lpv LPW lpwcx lpwndpl +lquadrant LReader LRESULT LSTATUS @@ -971,6 +959,7 @@ MAKELONG MAKELPARAM makepri MAKEWPARAM +Malware manifestdependency MAPPEDTOSAMEKEY MAPTOSAMESHORTCUT @@ -993,8 +982,8 @@ MENUITEMINFO MENUITEMINFOW MERGECOPY MERGEPAINT -Metadatas metadatamatters +Metadatas metafile mfc Mgmt @@ -1040,9 +1029,6 @@ mousepointer mouseutils MOVESIZEEND MOVESIZESTART -muxx -muxxc -muxxh MRM MRT mru @@ -1075,6 +1061,9 @@ MTND MULTIPLEUSE multizone muxc +muxx +muxxc +muxxh MVPs mvvm MVVMTK @@ -1157,7 +1146,6 @@ nonstd NOOWNERZORDER NOPARENTNOTIFY NOPREFIX -NPU NOREDIRECTIONBITMAP NOREDRAW NOREMOVE @@ -1186,6 +1174,7 @@ nowarn NOZORDER NPH npmjs +NPU NResize NTAPI ntdll @@ -1210,16 +1199,15 @@ oldpath oldtheme oleaut OLECHAR +ollama onebranch +onnx OOBEUI openas opencode OPENFILENAME opensource openxmlformats -ollama -Olllama -onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1292,6 +1280,7 @@ pguid phbm phbmp phicon +Photoshop phwnd pici pidl @@ -1314,7 +1303,6 @@ pnid PNMLINK Poc Podcasts -Photoshop POINTERID POINTERUPDATE Pokedex @@ -1409,10 +1397,9 @@ pwsz pwtd QDC qit -QNN -Qualcomm QITAB QITABENT +QNN qoi Quarternary QUERYENDSESSION @@ -1422,8 +1409,8 @@ quickaccent QUNS RAII RAlt -RAquadrant randi +RAquadrant rasterization Rasterize RAWINPUTDEVICE @@ -1450,9 +1437,7 @@ regfile REGISTERCLASSFAILED REGISTRYHEADER REGISTRYPREVIEWEXT -registryroot regkey -regroot regsvr REINSTALLMODE releaseblog @@ -1505,7 +1490,6 @@ rstringalpha rstringdigit rtb RTLREADING -rtm runas rundll rungameid @@ -1562,8 +1546,8 @@ SETRULES SETSCREENSAVEACTIVE SETSTICKYKEYS SETTEXT -settingscard SETTINGCHANGE +settingscard SETTINGSCHANGED settingsheader settingshotkeycontrol @@ -1719,7 +1703,6 @@ sublang SUBMODULEUPDATE subresource Superbar -suntimes sut svchost SVGIn @@ -1753,7 +1736,6 @@ SYSTEMMODAL SYSTEMTIME TARG TARGETAPPHEADER -TARGETDIR targetentrypoint TARGETHEADER targetver @@ -1783,10 +1765,10 @@ textextractor TEXTINCLUDE tfopen tgz +THEMECHANGED themeresources THH THICKFRAME -THEMECHANGED THISCOMPONENT throughs TILEDWINDOW @@ -1883,7 +1865,6 @@ USEINSTALLERFORTEST USESHOWWINDOW USESTDHANDLES USRDLL -utm UType uuidv uwp @@ -1956,11 +1937,11 @@ Wca WCE wcex WClass +WCRAPI wcsicmp wcsncpy wcsnicmp WCT -WCRAPI WDA wdm wdp @@ -1988,6 +1969,7 @@ WINDOWPLACEMENT WINDOWPOSCHANGED WINDOWPOSCHANGING WINDOWSBUILDNUMBER +windowsml windowssearch windowssettings WINDOWSTYLES @@ -2003,9 +1985,8 @@ Winhook WINL winlogon winmd -WINNT -windowsml winml +WINNT winres winrt winsdk @@ -2067,20 +2048,21 @@ WTSAT Wubi WUX Wwanpp +xap XAxis XButton xclip xcopy -xap XDeployment -XDimension xdf +XDimension XDocument XElement xfd XFile XIncrement XLoc +xmp XNamespace Xoshiro XPels @@ -2091,23 +2073,22 @@ xsi XSpeed XStr xstyler -xmp XTimer XUP XVIRTUALSCREEN xxxxxx YAxis ycombinator -YIncrement YDimension +YIncrement yinle yinyue YPels YPos YResolution YSpeed -YTimer YStr +YTimer YVIRTUALSCREEN ZEROINIT zonability From 47d4a65223c58f2e98f58ea3a51305c1206f8dd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 27 Nov 2025 16:24:47 +0100 Subject: [PATCH 02/10] CmdPal: Add option to return to home automatically after a delay (#43551) ## Summary of the Pull Request This PR replaces the Go home when activated setting with a new Automatically return home option. This allows users to specify how long the Command Palette should wait after being dismissed before automatically returning to the home page. It also introduces migration logic to transition from the old setting to the new one. ## Pictures? Pictures! image ## PR Checklist - [x] Closes: #43355 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../SettingsModel.cs | 79 +++++++++++++++++-- .../SettingsViewModel.cs | 42 +++++++--- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 38 +++++++++ .../Pages/ShellPage.xaml.cs | 2 +- .../Settings/GeneralPage.xaml | 14 +++- .../Strings/en-us/Resources.resw | 39 +++++++-- 6 files changed, 190 insertions(+), 24 deletions(-) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs index a06ec8adf7..aee23ef0ca 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsModel.cs @@ -6,7 +6,9 @@ using System.Diagnostics; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; using CommunityToolkit.Mvvm.ComponentModel; +using ManagedCommon; using Microsoft.CmdPal.UI.ViewModels.Settings; using Microsoft.CommandPalette.Extensions.Toolkit; using Windows.Foundation; @@ -15,6 +17,8 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsModel : ObservableObject { + private const string DeprecatedHotkeyGoesHomeKey = "HotkeyGoesHome"; + [JsonIgnore] public static readonly string FilePath; @@ -30,8 +34,6 @@ public partial class SettingsModel : ObservableObject public bool ShowAppDetails { get; set; } - public bool HotkeyGoesHome { get; set; } - public bool BackspaceGoesBack { get; set; } public bool SingleClickActivates { get; set; } @@ -56,6 +58,8 @@ public partial class SettingsModel : ObservableObject public WindowPosition? LastWindowPosition { get; set; } + public TimeSpan AutoGoHomeInterval { get; set; } = Timeout.InfiniteTimeSpan; + // END SETTINGS /////////////////////////////////////////////////////////////////////////// @@ -98,12 +102,29 @@ public partial class SettingsModel : ObservableObject { // Read the JSON content from the file var jsonContent = File.ReadAllText(FilePath); + var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel) ?? new(); - var loaded = JsonSerializer.Deserialize(jsonContent, JsonSerializationContext.Default.SettingsModel); + var migratedAny = false; + try + { + if (JsonNode.Parse(jsonContent) is JsonObject root) + { + migratedAny |= ApplyMigrations(root, loaded); + } + } + catch (Exception ex) + { + Debug.WriteLine($"Migration check failed: {ex}"); + } - Debug.WriteLine(loaded is not null ? "Loaded settings file" : "Failed to parse"); + Debug.WriteLine("Loaded settings file"); - return loaded ?? new(); + if (migratedAny) + { + SaveSettings(loaded); + } + + return loaded; } catch (Exception ex) { @@ -113,6 +134,51 @@ public partial class SettingsModel : ObservableObject return new(); } + private static bool ApplyMigrations(JsonObject root, SettingsModel model) + { + var migrated = false; + + // Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan) + // The old 'HotkeyGoesHome' boolean indicated whether the "go home" action should happen immediately (true) or never (false). + // The new 'AutoGoHomeInterval' uses a TimeSpan: 'TimeSpan.Zero' means immediate, 'Timeout.InfiniteTimeSpan' means never. + migrated |= TryMigrate( + "Migration #1: HotkeyGoesHome (bool) -> AutoGoHomeInterval (TimeSpan)", + root, + model, + nameof(AutoGoHomeInterval), + DeprecatedHotkeyGoesHomeKey, + (settingsModel, goesHome) => settingsModel.AutoGoHomeInterval = goesHome ? TimeSpan.Zero : Timeout.InfiniteTimeSpan, + JsonSerializationContext.Default.Boolean); + + return migrated; + } + + private static bool TryMigrate(string migrationName, JsonObject root, SettingsModel model, string newKey, string oldKey, Action apply, JsonTypeInfo jsonTypeInfo) + { + try + { + // If new key already present, skip migration + if (root.ContainsKey(newKey) && root[newKey] is not null) + { + return false; + } + + // If old key present, try to deserialize and apply + if (root.TryGetPropertyValue(oldKey, out var oldNode) && oldNode is not null) + { + var value = oldNode.Deserialize(jsonTypeInfo); + apply(model, value!); + return true; + } + } + catch (Exception ex) + { + Logger.LogError($"Error during migration {migrationName}.", ex); + } + + return false; + } + public static void SaveSettings(SettingsModel model) { if (string.IsNullOrEmpty(FilePath)) @@ -139,6 +205,9 @@ public partial class SettingsModel : ObservableObject savedSettings[item.Key] = item.Value?.DeepClone(); } + // Remove deprecated keys + savedSettings.Remove(DeprecatedHotkeyGoesHomeKey); + var serialized = savedSettings.ToJsonString(JsonSerializationContext.Default.Options); File.WriteAllText(FilePath, serialized); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs index 380f2340ba..4d44db7d8a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/SettingsViewModel.cs @@ -11,6 +11,19 @@ namespace Microsoft.CmdPal.UI.ViewModels; public partial class SettingsViewModel : INotifyPropertyChanged { + private static readonly List AutoGoHomeIntervals = + [ + Timeout.InfiniteTimeSpan, + TimeSpan.Zero, + TimeSpan.FromSeconds(10), + TimeSpan.FromSeconds(20), + TimeSpan.FromSeconds(30), + TimeSpan.FromSeconds(60), + TimeSpan.FromSeconds(90), + TimeSpan.FromSeconds(120), + TimeSpan.FromSeconds(180), + ]; + private readonly SettingsModel _settings; private readonly IServiceProvider _serviceProvider; @@ -58,16 +71,6 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } - public bool HotkeyGoesHome - { - get => _settings.HotkeyGoesHome; - set - { - _settings.HotkeyGoesHome = value; - Save(); - } - } - public bool BackspaceGoesBack { get => _settings.BackspaceGoesBack; @@ -138,6 +141,25 @@ public partial class SettingsViewModel : INotifyPropertyChanged } } + public int AutoGoBackIntervalIndex + { + get + { + var index = AutoGoHomeIntervals.IndexOf(_settings.AutoGoHomeInterval); + return index >= 0 ? index : 0; + } + + set + { + if (value >= 0 && value < AutoGoHomeIntervals.Count) + { + _settings.AutoGoHomeInterval = AutoGoHomeIntervals[value]; + } + + Save(); + } + } + public ObservableCollection CommandProviders { get; } = []; public SettingsExtensionsViewModel Extensions { get; } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index b80ea69b86..34f746facf 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -58,6 +58,7 @@ public sealed partial class MainWindow : WindowEx, [System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")] private readonly uint WM_TASKBAR_RESTART; private readonly HWND _hwnd; + private readonly DispatcherTimer _autoGoHomeTimer; private readonly WNDPROC? _hotkeyWndProc; private readonly WNDPROC? _originalWndProc; private readonly List _hotkeys = []; @@ -68,6 +69,7 @@ public sealed partial class MainWindow : WindowEx, private DesktopAcrylicController? _acrylicController; private SystemBackdropConfiguration? _configurationSource; + private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan; private WindowPosition _currentWindowPosition = new(); @@ -75,6 +77,9 @@ public sealed partial class MainWindow : WindowEx, { InitializeComponent(); + _autoGoHomeTimer = new DispatcherTimer(); + _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); unsafe @@ -141,6 +146,15 @@ public sealed partial class MainWindow : WindowEx, HideWindow(); } + private void OnAutoGoHomeTimerOnTick(object? s, object e) + { + _autoGoHomeTimer.Stop(); + + // BEAR LOADING: Focus Search must be suppressed here; otherwise it may steal focus (for example, from the system tray icon) + // and prevent the user from opening its context menu. + WeakReferenceMessenger.Default.Send(new GoHomeMessage(WithAnimation: false, FocusSearch: false)); + } + private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e) { if (e.Key == VirtualKey.GoBack) @@ -220,6 +234,9 @@ public sealed partial class MainWindow : WindowEx, App.Current.Services.GetService()!.SetupTrayIcon(settings.ShowSystemTrayIcon); _ignoreHotKeyWhenFullScreen = settings.IgnoreShortcutWhenFullscreen; + + _autoGoHomeInterval = settings.AutoGoHomeInterval; + _autoGoHomeTimer.Interval = _autoGoHomeInterval; } // We want to use DesktopAcrylicKind.Thin and custom colors as this is the default material @@ -279,6 +296,8 @@ public sealed partial class MainWindow : WindowEx, private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target) { + StopAutoGoHome(); + var hwnd = new HWND(hwndValue != 0 ? hwndValue : _hwnd); // Remember, IsIconic == "minimized", which is entirely different state @@ -533,6 +552,25 @@ public sealed partial class MainWindow : WindowEx, // If the window was not cloaked, then leave it hidden. // Sure, it's not ideal, but at least it's not visible. } + + // Start auto-go-home timer + RestartAutoGoHome(); + } + + private void StopAutoGoHome() + { + _autoGoHomeTimer.Stop(); + } + + private void RestartAutoGoHome() + { + if (_autoGoHomeInterval == Timeout.InfiniteTimeSpan) + { + return; + } + + _autoGoHomeTimer.Stop(); + _autoGoHomeTimer.Start(); } private bool Cloak() diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs index e51597d268..dc12cf142b 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Pages/ShellPage.xaml.cs @@ -345,7 +345,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page, // Depending on the settings, either // * Go home, or // * Select the search text (if we should remain open on this page) - if (settings.HotkeyGoesHome) + if (settings.AutoGoHomeInterval == TimeSpan.Zero) { GoHome(false); } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml index 97aa0e4768..5f3ea2a55e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Settings/GeneralPage.xaml @@ -51,8 +51,18 @@ - - + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw index 9c66be3773..89f7b5f10d 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Strings/en-us/Resources.resw @@ -344,12 +344,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Preventing disruption of the program running in fullscreen by unintentional activation of shortcut - - Go home when activated - - - Automatically opens the home page upon activation - Highlight search on activate @@ -523,4 +517,37 @@ Right-click to remove the key combination, thereby deactivating the shortcut. Command Palette - Fatal error + + Never + + + Immediately + + + 10 seconds + + + 20 seconds + + + 30 seconds + + + 60 seconds + + + 90 seconds + + + 2 minutes + + + 3 minutes + + + Automatically return home + + + Automatically returns to home page after a period of inactivity when Command Palette is closed + \ No newline at end of file From 0de60445ea99aa97b4c640d192c5f55fdbda7629 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Thu, 27 Nov 2025 16:31:10 +0100 Subject: [PATCH 03/10] CmdPal: Use Shell API to determine the default browser in WebSearch (#43339) ## Summary of the Pull Request This PR introduces a new method for determining the default browser using the Windows Shell API. The new provider selects the browser associated with the HTTPS protocol (falling back to HTTP if necessary). The original implementation is retained as a fallback for now, and the codebase is prepared for future extensions (e.g., manual default-browser selection). As a flyby, it also fixes an issue where commands continued showing the previous browser name if the user changed their default browser while the Command Palette was running. ## One-liner for change log Fixed default browser selection in the Web Search built-in extension. ## PR Checklist - [x] Closes: #42343 - [ ] **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 - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + .../MockBrowserInfoService.cs | 12 + .../QueryTests.cs | 12 +- .../SettingsManagerTests.cs | 4 +- .../Commands/OpenURLCommand.cs | 18 +- .../Commands/SearchWebCommand.cs | 14 +- .../FallbackExecuteSearchItem.cs | 25 +- .../FallbackOpenURLItem.cs | 23 +- .../Helpers/Browser/BrowserInfo.cs | 14 ++ .../Browser/BrowserInfoServiceExtensions.cs | 32 +++ .../Browser/DefaultBrowserInfoService.cs | 99 ++++++++ .../Helpers/Browser/IBrowserInfoService.cs | 17 ++ .../Browser/Providers/AssociatedApp.cs | 7 + .../Providers/AssociationProviderBase.cs | 154 +++++++++++++ .../FallbackMsEdgeBrowserProvider.cs | 31 +++ .../Providers/IDefaultBrowserProvider.cs | 13 ++ .../LegacyRegistryAssociationProvider.cs | 46 ++++ .../Providers/ShellAssociationProvider.cs | 64 ++++++ .../Helpers/DefaultBrowserInfo.cs | 215 ------------------ .../Helpers/NativeMethods.cs | 54 +++++ .../Pages/WebSearchListPage.cs | 20 +- .../Properties/Resources.Designer.cs | 11 +- .../Properties/Resources.resx | 3 + .../WebSearchCommandsProvider.cs | 8 +- .../WebSearchTopLevelCommandItem.cs | 11 +- 25 files changed, 637 insertions(+), 271 deletions(-) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs delete mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 1ce6f88899..9f18bbb300 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -1692,6 +1692,7 @@ stringtable stringval Strm strret +STRSAFE stscanf sttngs Stubless diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs new file mode 100644 index 0000000000..ee27aa737e --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockBrowserInfoService.cs @@ -0,0 +1,12 @@ +// 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.CmdPal.Ext.WebSearch.Helpers.Browser; + +namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests; + +public class MockBrowserInfoService : IBrowserInfoService +{ + public BrowserInfo GetDefaultBrowser() => new() { Name = "mocked browser", Path = "C:\\mockery\\mock.exe" }; +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs index 00f1235c0e..63e35314cd 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/QueryTests.cs @@ -26,8 +26,9 @@ public class QueryTests : CommandPaletteUnitTestBase { // Setup var settings = new MockSettingsInterface(); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText(string.Empty, query); @@ -55,8 +56,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText("abcdef", string.Empty); @@ -90,8 +92,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 5); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); mockHistoryItems.Add(new HistoryItem("another search5", DateTime.Parse("2024-01-06 13:00:00", CultureInfo.CurrentCulture))); @@ -123,8 +126,9 @@ public class QueryTests : CommandPaletteUnitTestBase }; var settings = new MockSettingsInterface(mockHistory: mockHistoryItems, historyItemCount: 0); + var browserInfoService = new MockBrowserInfoService(); - var page = new WebSearchListPage(settings); + var page = new WebSearchListPage(settings, browserInfoService); // Act page.UpdateSearchText("abcdef", string.Empty); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs index fd19427ca1..2ec5546daa 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/SettingsManagerTests.cs @@ -20,7 +20,9 @@ public class SettingsManagerTests : CommandPaletteUnitTestBase { // Setup var settings = new MockSettingsInterface(historyItemCount: 5); - var page = new WebSearchListPage(settings); + var browserInfoService = new MockBrowserInfoService(); + + var page = new WebSearchListPage(settings, browserInfoService); var eventRaised = false; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs index 08d0a114f5..937be16ac2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/OpenURLCommand.cs @@ -2,32 +2,28 @@ // 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.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class OpenURLCommand : InvokableCommand { + private readonly IBrowserInfoService _browserInfoService; + public string Url { get; internal set; } = string.Empty; - internal OpenURLCommand(string url) + internal OpenURLCommand(string url, IBrowserInfoService browserInfoService) { + _browserInfoService = browserInfoService; Url = url; - BrowserInfo.UpdateIfTimePassed(); Icon = Icons.WebSearch; Name = string.Empty; } public override CommandResult Invoke() { - if (!ShellHelpers.OpenCommandInShell(BrowserInfo.Path, BrowserInfo.ArgumentsPattern, $"{Url}")) - { - // TODO GH# 138 --> actually display feedback from the extension somewhere. - return CommandResult.KeepOpen(); - } - - return CommandResult.Dismiss(); + // TODO GH# 138 --> actually display feedback from the extension somewhere. + return _browserInfoService.Open(Url) ? CommandResult.Dismiss() : CommandResult.KeepOpen(); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs index 2cc8953048..98921aa8ae 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -4,31 +4,31 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; - namespace Microsoft.CmdPal.Ext.WebSearch.Commands; internal sealed partial class SearchWebCommand : InvokableCommand { private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; - public string Arguments { get; internal set; } = string.Empty; + public string Arguments { get; internal set; } - internal SearchWebCommand(string arguments, ISettingsInterface settingsManager) + internal SearchWebCommand(string arguments, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { Arguments = arguments; - BrowserInfo.UpdateIfTimePassed(); Icon = Icons.WebSearch; - Name = Properties.Resources.open_in_default_browser; + Name = Resources.open_in_default_browser; _settingsManager = settingsManager; + _browserInfoService = browserInfoService; } public override CommandResult Invoke() { - if (!ShellHelpers.OpenCommandInShell(BrowserInfo.Path, BrowserInfo.ArgumentsPattern, $"? {Arguments}")) + if (!_browserInfoService.Open($"? {Arguments}")) { // TODO GH# 138 --> actually display feedback from the extension somewhere. return CommandResult.KeepOpen(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs index c942e668d3..61557d996a 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackExecuteSearchItem.cs @@ -5,9 +5,9 @@ using System.Globalization; using System.Text; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch.Commands; @@ -16,25 +16,34 @@ internal sealed partial class FallbackExecuteSearchItem : FallbackCommandItem private readonly SearchWebCommand _executeItem; private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private static readonly CompositeFormat SubtitleText = System.Text.CompositeFormat.Parse(Properties.Resources.web_search_fallback_subtitle); - private string _title; - public FallbackExecuteSearchItem(SettingsManager settings) - : base(new SearchWebCommand(string.Empty, settings) { Id = "com.microsoft.websearch.fallback" }, Resources.command_item_title) + private readonly IBrowserInfoService _browserInfoService; + + public FallbackExecuteSearchItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new SearchWebCommand(string.Empty, settings, browserInfoService) { Id = "com.microsoft.websearch.fallback" }, Resources.command_item_title) { - _executeItem = (SearchWebCommand)this.Command!; + _executeItem = (SearchWebCommand)Command!; + _browserInfoService = browserInfoService; Title = string.Empty; Subtitle = string.Empty; _executeItem.Name = string.Empty; - _title = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); Icon = Icons.WebSearch; } + private static string UpdateBrowserName(IBrowserInfoService browserInfoService) + { + var browserName = browserInfoService.GetDefaultBrowser()?.Name; + return string.IsNullOrWhiteSpace(browserName) + ? Resources.open_in_default_browser + : string.Format(CultureInfo.CurrentCulture, PluginOpen, browserName); + } + public override void UpdateQuery(string query) { _executeItem.Arguments = query; var isEmpty = string.IsNullOrEmpty(query); - _executeItem.Name = isEmpty ? string.Empty : Properties.Resources.open_in_default_browser; - Title = isEmpty ? string.Empty : _title; + _executeItem.Name = isEmpty ? string.Empty : Resources.open_in_default_browser; + Title = isEmpty ? string.Empty : UpdateBrowserName(_browserInfoService); Subtitle = string.Format(CultureInfo.CurrentCulture, SubtitleText, query); } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs index 9f5d9d86ca..7feb53b1de 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/FallbackOpenURLItem.cs @@ -7,21 +7,26 @@ using System.Globalization; using System.Text; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; +using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch; internal sealed partial class FallbackOpenURLItem : FallbackCommandItem { + private readonly IBrowserInfoService _browserInfoService; private readonly OpenURLCommand _executeItem; private static readonly CompositeFormat PluginOpenURL = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url); private static readonly CompositeFormat PluginOpenUrlInBrowser = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open_url_in_browser); - public FallbackOpenURLItem(SettingsManager settings) - : base(new OpenURLCommand(string.Empty), Properties.Resources.open_url_fallback_title) + public FallbackOpenURLItem(ISettingsInterface settings, IBrowserInfoService browserInfoService) + : base(new OpenURLCommand(string.Empty, browserInfoService), Resources.open_url_fallback_title) { - _executeItem = (OpenURLCommand)this.Command!; + ArgumentNullException.ThrowIfNull(browserInfoService); + + _browserInfoService = browserInfoService; + _executeItem = (OpenURLCommand)Command!; Title = string.Empty; _executeItem.Name = string.Empty; Subtitle = string.Empty; @@ -39,7 +44,7 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem return; } - var success = Uri.TryCreate(query, UriKind.Absolute, out var uri); + var success = Uri.TryCreate(query, UriKind.Absolute, out _); // if url not contain schema, add http:// by default. if (!success) @@ -48,13 +53,15 @@ internal sealed partial class FallbackOpenURLItem : FallbackCommandItem } _executeItem.Url = query; - _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Properties.Resources.open_in_default_browser; + _executeItem.Name = string.IsNullOrEmpty(query) ? string.Empty : Resources.open_in_default_browser; Title = string.Format(CultureInfo.CurrentCulture, PluginOpenURL, query); - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, BrowserInfo.Name ?? BrowserInfo.MSEdgeName); + + var browserName = _browserInfoService.GetDefaultBrowser()?.Name; + Subtitle = string.IsNullOrWhiteSpace(browserName) ? Resources.open_in_default_browser : string.Format(CultureInfo.CurrentCulture, PluginOpenUrlInBrowser, browserName); } - public static bool IsValidUrl(string url) + private static bool IsValidUrl(string url) { if (string.IsNullOrWhiteSpace(url)) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs new file mode 100644 index 0000000000..9da978f481 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfo.cs @@ -0,0 +1,14 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +public record BrowserInfo +{ + public required string Path { get; init; } + + public required string Name { get; init; } + + public string? ArgumentsPattern { get; init; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs new file mode 100644 index 0000000000..1614273d83 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/BrowserInfoServiceExtensions.cs @@ -0,0 +1,32 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Extension methods for . +/// +/// +internal static class BrowserInfoServiceExtensions +{ + /// + /// Opens the specified URL in the system's default web browser. + /// + /// The browser information service used to resolve the system's default browser. + /// The URL to open. + /// + /// if a default browser is found and the URL launch command is issued successfully; + /// otherwise, . + /// + /// + /// Returns if the default browser cannot be determined. + /// + public static bool Open(this IBrowserInfoService browserInfoService, string url) + { + var defaultBrowser = browserInfoService.GetDefaultBrowser(); + return defaultBrowser != null && ShellHelpers.OpenCommandInShell(defaultBrowser.Path, defaultBrowser.ArgumentsPattern, url); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs new file mode 100644 index 0000000000..51312fe4c0 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/DefaultBrowserInfoService.cs @@ -0,0 +1,99 @@ +// 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.Threading; +using ManagedCommon; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Service to get information about the default browser. +/// +internal class DefaultBrowserInfoService : IBrowserInfoService +{ + private static readonly IDefaultBrowserProvider[] Providers = + [ + new ShellAssociationProvider(), + new LegacyRegistryAssociationProvider(), + new FallbackMsEdgeBrowserProvider(), + ]; + + private readonly Lock _updateLock = new(); + + private readonly Dictionary _lastLoggedErrors = []; + + private const long UpdateTimeout = 3000; + private long _lastUpdateTickCount = -UpdateTimeout; + + private BrowserInfo? _defaultBrowser; + + public BrowserInfo? GetDefaultBrowser() + { + try + { + UpdateIfTimePassed(); + } + catch (Exception) + { + // exception is already logged at this point + } + + return _defaultBrowser; + } + + /// + /// Updates only if at least more than 3000ms has passed since the last update, to avoid multiple calls to . + /// (because of multiple plugins calling update at the same time.) + /// + private void UpdateIfTimePassed() + { + lock (_updateLock) + { + var curTickCount = Environment.TickCount64; + if (curTickCount - _lastUpdateTickCount < UpdateTimeout && _defaultBrowser != null) + { + return; + } + + var newDefaultBrowser = UpdateCore(); + _defaultBrowser = newDefaultBrowser; + _lastUpdateTickCount = curTickCount; + } + } + + /// + /// Consider using to avoid updating multiple times. + /// (because of multiple plugins calling update at the same time.) + /// + private BrowserInfo UpdateCore() + { + foreach (var provider in Providers) + { + try + { + var result = provider.GetDefaultBrowserInfo(); +#if DEBUG + result = result with { Name = result.Name + " (" + provider.GetType().Name + ")" }; +#endif + return result; + } + catch (Exception ex) + { + // since we run this fairly often, avoid logging the same error multiple times + var lastLoggedError = _lastLoggedErrors.GetValueOrDefault(provider.GetType()); + var error = ex.ToString(); + if (error != lastLoggedError) + { + _lastLoggedErrors[provider.GetType()] = error; + Logger.LogError($"Exception when retrieving browser using provider {provider.GetType()}", ex); + } + } + } + + throw new InvalidOperationException("Unable to determine default browser"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs new file mode 100644 index 0000000000..5d82193e5d --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/IBrowserInfoService.cs @@ -0,0 +1,17 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; + +/// +/// Provides functionality to retrieve information about the system's default web browser. +/// +public interface IBrowserInfoService +{ + /// + /// Gets information about the system's default web browser. + /// + /// + BrowserInfo? GetDefaultBrowser(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs new file mode 100644 index 0000000000..3c6ba74d67 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociatedApp.cs @@ -0,0 +1,7 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +internal record AssociatedApp(string? Command, string? FriendlyName); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs new file mode 100644 index 0000000000..43ed130401 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/AssociationProviderBase.cs @@ -0,0 +1,154 @@ +// 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.IO; +using Windows.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Base class for providers that determine the default browser via application associations. +/// +internal abstract class AssociationProviderBase : IDefaultBrowserProvider +{ + protected abstract AssociatedApp? FindAssociation(); + + public BrowserInfo GetDefaultBrowserInfo() + { + var appAssociation = FindAssociation(); + if (appAssociation is null) + { + throw new ArgumentNullException(nameof(appAssociation), "Could not determine default browser application."); + } + + var commandPattern = appAssociation.Command; + var appAndArgs = SplitAppAndArgs(commandPattern); + + if (string.IsNullOrEmpty(appAndArgs.Path)) + { + throw new ArgumentOutOfRangeException(nameof(appAndArgs.Path), "Default browser program path could not be determined."); + } + + // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App + if (!Path.Exists(appAndArgs.Path) && !Uri.TryCreate(appAndArgs.Path, UriKind.Absolute, out _)) + { + throw new ArgumentException($"Command validation failed: {commandPattern}", nameof(commandPattern)); + } + + return new BrowserInfo + { + Path = appAndArgs.Path, + Name = appAssociation.FriendlyName ?? Path.GetFileNameWithoutExtension(appAndArgs.Path), + ArgumentsPattern = appAndArgs.Arguments, + }; + } + + private static (string? Path, string? Arguments) SplitAppAndArgs(string? commandPattern) + { + if (string.IsNullOrEmpty(commandPattern)) + { + throw new ArgumentOutOfRangeException(nameof(commandPattern), "Default browser program command is not specified."); + } + + commandPattern = GetIndirectString(commandPattern); + + // HACK: for firefox installed through Microsoft store + // When installed through Microsoft Firefox the commandPattern does not have + // quotes for the path. As the Program Files does have a space + // the extracted path would be invalid, here we add the quotes to fix it + const string FirefoxExecutableName = "firefox.exe"; + if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && + !commandPattern.StartsWith('\"')) + { + var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + + FirefoxExecutableName.Length; + commandPattern = commandPattern.Insert(pathEndIndex, "\""); + commandPattern = commandPattern.Insert(0, "\""); + } + + if (commandPattern.StartsWith('\"')) + { + var endQuoteIndex = commandPattern.IndexOf('\"', 1); + if (endQuoteIndex != -1) + { + return (commandPattern[1..endQuoteIndex], commandPattern[(endQuoteIndex + 1)..].Trim()); + } + } + else + { + var spaceIndex = commandPattern.IndexOf(' '); + if (spaceIndex != -1) + { + return (commandPattern[..spaceIndex], commandPattern[(spaceIndex + 1)..].Trim()); + } + } + + return (null, null); + } + + protected static string GetIndirectString(string str) + { + if (string.IsNullOrEmpty(str) || str[0] != '@') + { + return str; + } + + const int initialCapacity = 128; + const int maxCapacity = 8192; // Reasonable upper limit + int hresult; + + unsafe + { + // Try with stack allocation first for common cases + var stackBuffer = stackalloc char[initialCapacity]; + + fixed (char* pszSource = str) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + stackBuffer, + initialCapacity, + null); + + // S_OK (0) means success + if (hresult == 0) + { + return new string(stackBuffer); + } + + // STRSAFE_E_INSUFFICIENT_BUFFER (0x8007007A) means buffer too small + // Try with progressively larger heap buffers + if (unchecked((uint)hresult) == 0x8007007A) + { + for (var capacity = initialCapacity * 2; capacity <= maxCapacity; capacity *= 2) + { + var heapBuffer = new char[capacity]; + fixed (char* pBuffer = heapBuffer) + { + hresult = PInvoke.SHLoadIndirectString( + pszSource, + pBuffer, + (uint)capacity, + null); + + if (hresult == 0) + { + return new string(pBuffer); + } + + if (unchecked((uint)hresult) != 0x8007007A) + { + break; // Different error, stop retrying + } + } + } + } + } + } + + throw new InvalidOperationException( + $"Could not load indirect string. HRESULT: 0x{unchecked((uint)hresult):X8}"); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs new file mode 100644 index 0000000000..8489362004 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/FallbackMsEdgeBrowserProvider.cs @@ -0,0 +1,31 @@ +// 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.IO; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Provides a fallback implementation of the default browser provider that returns information for Microsoft Edge. +/// +/// This class is used when no other default browser provider is available. It supplies the path, +/// arguments pattern, and name for Microsoft Edge as the default browser information. +internal sealed class FallbackMsEdgeBrowserProvider : IDefaultBrowserProvider +{ + private const string MsEdgeArgumentsPattern = "--single-argument %1"; + + private const string MsEdgeName = "Microsoft Edge"; + + private static string MsEdgePath => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), + @"Microsoft\Edge\Application\msedge.exe"); + + public BrowserInfo GetDefaultBrowserInfo() => new() + { + Path = MsEdgePath, + ArgumentsPattern = MsEdgeArgumentsPattern, + Name = MsEdgeName, + }; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs new file mode 100644 index 0000000000..82a0b679fb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/IDefaultBrowserProvider.cs @@ -0,0 +1,13 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Retrieves information about the default browser. +/// +internal interface IDefaultBrowserProvider +{ + BrowserInfo GetDefaultBrowserInfo(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs new file mode 100644 index 0000000000..28fe40f995 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/LegacyRegistryAssociationProvider.cs @@ -0,0 +1,46 @@ +// 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.Win32; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Provides the default web browser by reading registry keys. This is a legacy method and may not work on all systems. +/// +internal sealed class LegacyRegistryAssociationProvider : AssociationProviderBase +{ + protected override AssociatedApp? FindAssociation() + { + var progId = GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", + "ProgId") + ?? GetRegistryValue( + @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", + "ProgId"); + var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") + ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); + + if (appName is not null) + { + appName = GetIndirectString(appName); + appName = appName + .Replace("URL", null, StringComparison.OrdinalIgnoreCase) + .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) + .Replace("Document", null, StringComparison.OrdinalIgnoreCase) + .Replace("Web", null, StringComparison.OrdinalIgnoreCase) + .TrimEnd(); + } + + var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); + + return commandPattern is null ? null : new AssociatedApp(commandPattern, appName); + + static string? GetRegistryValue(string registryLocation, string? valueName) + { + return Registry.GetValue(registryLocation, valueName, null) as string; + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs new file mode 100644 index 0000000000..a70c3476d4 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/Browser/Providers/ShellAssociationProvider.cs @@ -0,0 +1,64 @@ +// 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. + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser.Providers; + +/// +/// Retrieves the default web browser using the system shell functions. +/// +internal sealed class ShellAssociationProvider : AssociationProviderBase +{ + private static readonly string[] Protocols = ["https", "http"]; + + protected override AssociatedApp FindAssociation() + { + foreach (var protocol in Protocols) + { + var command = AssocQueryStringSafe(NativeMethods.AssocStr.Command, protocol); + if (string.IsNullOrWhiteSpace(command)) + { + continue; + } + + var appName = AssocQueryStringSafe(NativeMethods.AssocStr.FriendlyAppName, protocol); + + return new AssociatedApp(command, appName); + } + + return new AssociatedApp(null, null); + } + + private static unsafe string? AssocQueryStringSafe(NativeMethods.AssocStr what, string protocol) + { + uint cch = 0; + + // First call: get required length (incl. null) + _ = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, null, ref cch); + if (cch == 0) + { + return null; + } + + // Small buffers on stack; large on heap + var span = cch <= 512 ? stackalloc char[(int)cch] : new char[(int)cch]; + + fixed (char* p = span) + { + var hr = NativeMethods.AssocQueryStringW(NativeMethods.AssocF.IsProtocol, what, protocol, null, p, ref cch); + if (hr != 0 || cch == 0) + { + return null; + } + + // cch includes the null terminator; slice it off + var len = (int)cch - 1; + if (len < 0) + { + len = 0; + } + + return new string(span[..len]); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs deleted file mode 100644 index f6b82ecfbb..0000000000 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/DefaultBrowserInfo.cs +++ /dev/null @@ -1,215 +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.Text; -using System.Threading; -using ManagedCommon; - -namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; - -/// -/// Contains information (e.g. path to executable, name...) about the default browser. -/// -public static class DefaultBrowserInfo -{ - private static readonly Lock _updateLock = new(); - - /// Gets the path to the MS Edge browser executable. - public static string MSEdgePath => System.IO.Path.Combine( - Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), - @"Microsoft\Edge\Application\msedge.exe"); - - /// Gets the command line pattern of the MS Edge. - public const string MSEdgeArgumentsPattern = "--single-argument %1"; - - public const string MSEdgeName = "Microsoft Edge"; - - /// Gets the path to default browser's executable. - public static string? Path { get; private set; } - - /// Gets since the icon is embedded in the executable. - public static string? IconPath => Path; - - /// Gets the user-friendly name of the default browser. - public static string? Name { get; private set; } - - /// Gets the command line pattern of the default browser. - public static string? ArgumentsPattern { get; private set; } - - public static bool IsDefaultBrowserSet => !string.IsNullOrEmpty(Path); - - public const long UpdateTimeout = 300; - - private static long _lastUpdateTickCount = -UpdateTimeout; - - private static bool _updatedOnce; - private static bool _errorLogged; - - /// - /// Updates only if at least more than 300ms has passed since the last update, to avoid multiple calls to . - /// (because of multiple plugins calling update at the same time.) - /// - public static void UpdateIfTimePassed() - { - var curTickCount = Environment.TickCount64; - if (curTickCount - _lastUpdateTickCount >= UpdateTimeout) - { - _lastUpdateTickCount = curTickCount; - Update(); - } - } - - /// - /// Consider using to avoid updating multiple times. - /// (because of multiple plugins calling update at the same time.) - /// - public static void Update() - { - lock (_updateLock) - { - if (!_updatedOnce) - { - // Log.Info("I've tried updating the chosen Web Browser info at least once.", typeof(DefaultBrowserInfo)); - _updatedOnce = true; - } - - try - { - var progId = GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoiceLatest\ProgId", - "ProgId") - ?? GetRegistryValue( - @"HKEY_CURRENT_USER\Software\Microsoft\Windows\Shell\Associations\UrlAssociations\http\UserChoice", - "ProgId"); - var appName = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\Application", "ApplicationName") - ?? GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}", "FriendlyTypeName"); - - if (appName is not null) - { - // Handle indirect strings: - if (appName.StartsWith('@')) - { - appName = GetIndirectString(appName); - } - - appName = appName - .Replace("URL", null, StringComparison.OrdinalIgnoreCase) - .Replace("HTML", null, StringComparison.OrdinalIgnoreCase) - .Replace("Document", null, StringComparison.OrdinalIgnoreCase) - .Replace("Web", null, StringComparison.OrdinalIgnoreCase) - .TrimEnd(); - } - - Name = appName; - - var commandPattern = GetRegistryValue($@"HKEY_CLASSES_ROOT\{progId}\shell\open\command", null); - - if (string.IsNullOrEmpty(commandPattern)) - { - throw new ArgumentOutOfRangeException( - nameof(commandPattern), - "Default browser program command is not specified."); - } - - if (commandPattern.StartsWith('@')) - { - commandPattern = GetIndirectString(commandPattern); - } - - // HACK: for firefox installed through Microsoft store - // When installed through Microsoft Firefox the commandPattern does not have - // quotes for the path. As the Program Files does have a space - // the extracted path would be invalid, here we add the quotes to fix it - const string FirefoxExecutableName = "firefox.exe"; - if (commandPattern.Contains(FirefoxExecutableName) && commandPattern.Contains(@"\WindowsApps\") && (!commandPattern.StartsWith('\"'))) - { - var pathEndIndex = commandPattern.IndexOf(FirefoxExecutableName, StringComparison.Ordinal) + FirefoxExecutableName.Length; - commandPattern = commandPattern.Insert(pathEndIndex, "\""); - commandPattern = commandPattern.Insert(0, "\""); - } - - if (commandPattern.StartsWith('\"')) - { - var endQuoteIndex = commandPattern.IndexOf('\"', 1); - if (endQuoteIndex != -1) - { - Path = commandPattern.Substring(1, endQuoteIndex - 1); - ArgumentsPattern = commandPattern.Substring(endQuoteIndex + 1).Trim(); - } - } - else - { - var spaceIndex = commandPattern.IndexOf(' '); - if (spaceIndex != -1) - { - Path = commandPattern.Substring(0, spaceIndex); - ArgumentsPattern = commandPattern.Substring(spaceIndex + 1).Trim(); - } - } - - // Packaged applications could be an URI. Example: shell:AppsFolder\Microsoft.MicrosoftEdge.Stable_8wekyb3d8bbwe!App - if (!System.IO.Path.Exists(Path) && !Uri.TryCreate(Path, UriKind.Absolute, out _)) - { - throw new ArgumentException( - $"Command validation failed: {commandPattern}", - nameof(commandPattern)); - } - - if (string.IsNullOrEmpty(Path)) - { - throw new ArgumentOutOfRangeException( - nameof(Path), - "Default browser program path could not be determined."); - } - } - catch (Exception) - { - // Fallback to MS Edge - Path = MSEdgePath; - Name = MSEdgeName; - ArgumentsPattern = MSEdgeArgumentsPattern; - - if (!_errorLogged) - { - // Log.Exception("Exception when retrieving browser path/name. Path and Name are set to use Microsoft Edge.", e, typeof(DefaultBrowserInfo)); - Logger.LogError("Exception when retrieving browser path/name. Path and Name are set to use Microsoft Edge."); - _errorLogged = true; - } - } - - string? GetRegistryValue(string registryLocation, string? valueName) - { - return Microsoft.Win32.Registry.GetValue(registryLocation, valueName, null) as string; - } - - string GetIndirectString(string str) - { - var stringBuilder = new StringBuilder(128); - unsafe - { - var buffer = stackalloc char[128]; - var capacity = 128; - var firstChar = str[0]; - var strPtr = &firstChar; - - // S_OK == 0 - fixed (char* pszSourceLocal = str) - { - if (global::Windows.Win32.PInvoke.SHLoadIndirectString( - pszSourceLocal, - buffer, - (uint)capacity, - default) == 0) - { - return new string(buffer); - } - } - } - - throw new ArgumentNullException(nameof(str), "Could not load indirect string."); - } - } - } -} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs new file mode 100644 index 0000000000..dee5b33fc5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/NativeMethods.cs @@ -0,0 +1,54 @@ +// 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.Runtime.InteropServices; + +namespace Microsoft.CmdPal.Ext.WebSearch.Helpers; + +internal static partial class NativeMethods +{ + [LibraryImport("shlwapi.dll", StringMarshalling = StringMarshalling.Utf16, SetLastError = false)] + internal static unsafe partial int AssocQueryStringW( + AssocF flags, + AssocStr str, + string pszAssoc, + string? pszExtra, + char* pszOut, + ref uint pcchOut); + + [Flags] + public enum AssocF : uint + { + None = 0, + IsProtocol = 0x00001000, + } + + public enum AssocStr + { + Command = 1, + Executable, + FriendlyDocName, + FriendlyAppName, + NoOpen, + ShellNewValue, + DDECommand, + DDEIfExec, + DDEApplication, + DDETopic, + InfoTip, + QuickTip, + TileInfo, + ContentType, + DefaultIcon, + ShellExtension, + DropTarget, + DelegateExecute, + SupportedUriProtocols, + ProgId, + AppId, + AppPublisher, + AppIconReference, // sometimes present, but DefaultIcon is most common + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs index 641d5f6135..bf21f7c912 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Pages/WebSearchListPage.cs @@ -9,23 +9,24 @@ using System.Text; using System.Threading; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; -using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo; namespace Microsoft.CmdPal.Ext.WebSearch.Pages; internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable { private readonly ISettingsInterface _settingsManager; + private readonly IBrowserInfoService _browserInfoService; private readonly Lock _sync = new(); private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name); private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open); private IListItem[] _allItems = []; private List _historyItems = []; - public WebSearchListPage(ISettingsInterface settingsManager) + public WebSearchListPage(ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { ArgumentNullException.ThrowIfNull(settingsManager); @@ -35,6 +36,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable Id = "com.microsoft.cmdpal.websearch"; _settingsManager = settingsManager; + _browserInfoService = browserInfoService; _settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged; // It just looks viewer to have string twice on the page, and default placeholder is good enough @@ -43,8 +45,8 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable EmptyContent = new CommandItem(new NoOpCommand()) { Icon = Icon, - Title = Properties.Resources.plugin_description, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), + Title = Resources.plugin_description, + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), }; UpdateHistory(); @@ -67,7 +69,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable for (var index = items.Count - 1; index >= 0; index--) { var historyItem = items[index]; - history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager)) + history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager, _browserInfoService)) { Icon = Icons.History, Title = historyItem.SearchString, @@ -82,7 +84,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable } } - private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager) + private static IListItem[] Query(string query, List historySnapshot, ISettingsInterface settingsManager, IBrowserInfoService browserInfoService) { ArgumentNullException.ThrowIfNull(query); @@ -95,10 +97,10 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable if (!string.IsNullOrEmpty(query)) { var searchTerm = query; - var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager)) + var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager, browserInfoService)) { Title = searchTerm, - Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName), + Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, browserInfoService.GetDefaultBrowser()?.Name ?? Resources.default_browser), Icon = Icons.Search, }; results.Add(result); @@ -117,7 +119,7 @@ internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable historySnapshot = _historyItems; } - var items = Query(search ?? string.Empty, historySnapshot, _settingsManager); + var items = Query(search ?? string.Empty, historySnapshot, _settingsManager, _browserInfoService); lock (_sync) { diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs index 39ebd6bf2b..090b54375d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs @@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { // class via a tool like ResGen or Visual Studio. // To add or remove a member, edit your .ResX file then rerun ResGen // with the /str option, or rebuild your VS project. - [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] public class Resources { @@ -69,6 +69,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// + /// Looks up a localized string similar to default browser. + /// + public static string default_browser { + get { + return ResourceManager.GetString("default_browser", resourceCulture); + } + } + /// /// Looks up a localized string similar to Web Search. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx index 5a406eca60..032f89e7a5 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx @@ -184,4 +184,7 @@ Open URL + + default browser + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs index 1a15991120..89cfe5a183 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchCommandsProvider.cs @@ -5,6 +5,7 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; @@ -19,6 +20,7 @@ public sealed partial class WebSearchCommandsProvider : CommandProvider private readonly WebSearchTopLevelCommandItem _webSearchTopLevelItem; private readonly ICommandItem[] _topLevelItems; private readonly IFallbackCommandItem[] _fallbackCommands; + private readonly IBrowserInfoService _browserInfoService = new DefaultBrowserInfoService(); public WebSearchCommandsProvider() { @@ -27,10 +29,10 @@ public sealed partial class WebSearchCommandsProvider : CommandProvider Icon = Icons.WebSearch; Settings = _settingsManager.Settings; - _fallbackItem = new FallbackExecuteSearchItem(_settingsManager); - _openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager); + _fallbackItem = new FallbackExecuteSearchItem(_settingsManager, _browserInfoService); + _openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager, _browserInfoService); - _webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager) + _webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager, _browserInfoService) { MoreCommands = [ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs index bc161991ca..b2aaa95f5e 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/WebSearchTopLevelCommandItem.cs @@ -5,6 +5,7 @@ using System; using Microsoft.CmdPal.Ext.WebSearch.Commands; using Microsoft.CmdPal.Ext.WebSearch.Helpers; +using Microsoft.CmdPal.Ext.WebSearch.Helpers.Browser; using Microsoft.CmdPal.Ext.WebSearch.Pages; using Microsoft.CmdPal.Ext.WebSearch.Properties; using Microsoft.CommandPalette.Extensions; @@ -15,13 +16,15 @@ namespace Microsoft.CmdPal.Ext.WebSearch; public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler, IDisposable { private readonly SettingsManager _settingsManager; + private readonly IBrowserInfoService _browserInfoService; - public WebSearchTopLevelCommandItem(SettingsManager settingsManager) - : base(new WebSearchListPage(settingsManager)) + public WebSearchTopLevelCommandItem(SettingsManager settingsManager, IBrowserInfoService browserInfoService) + : base(new WebSearchListPage(settingsManager, browserInfoService)) { Icon = Icons.WebSearch; SetDefaultTitle(); _settingsManager = settingsManager; + _browserInfoService = browserInfoService; } private void SetDefaultTitle() => Title = Resources.command_item_title; @@ -37,12 +40,12 @@ public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandle if (string.IsNullOrEmpty(query)) { SetDefaultTitle(); - ReplaceCommand(new WebSearchListPage(_settingsManager)); + ReplaceCommand(new WebSearchListPage(_settingsManager, _browserInfoService)); } else { Title = query; - ReplaceCommand(new SearchWebCommand(query, _settingsManager)); + ReplaceCommand(new SearchWebCommand(query, _settingsManager, _browserInfoService)); } } From 06afe099734d3be7ed723666d829a3ce07354be0 Mon Sep 17 00:00:00 2001 From: Michael Jolley Date: Sat, 29 Nov 2025 13:07:19 -0600 Subject: [PATCH 04/10] CmdPal: New Remote Desktop built-in extension (#43090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR introduces a new built-in extension for Remote Desktop users. It allows you to view past RDP connections, save predefined connections, and connect to any of them. Or start a new RDP connection. https://github.com/user-attachments/assets/6a5041a6-5741-4df0-a305-da7166f962e1 ### GitHub issue maintenance stuff Closes #38305 --------- Co-authored-by: Niels Laute Co-authored-by: Jiří Polášek --- .github/actions/spell-check/expect.txt | 8 +- Directory.Packages.props | 2 +- PowerToys.sln | 22 +++ .../Commands/MainListPage.cs | 1 + .../cmdpal/Microsoft.CmdPal.UI/App.xaml.cs | 2 + .../Microsoft.CmdPal.UI.csproj | 1 + .../FallbackRemoteDesktopItemTests.cs | 125 ++++++++++++++ ....CmdPal.Ext.RemoteDesktop.UnitTests.csproj | 24 +++ .../MockRDPConnectionsManager.cs | 23 +++ .../MockSettingsManager.cs | 23 +++ .../RDPConnectionsManagerTests.cs | 52 ++++++ .../RemoteDesktopCommandProviderTests.cs | 101 ++++++++++++ .../Assets/RemoteDesktop.png | Bin 0 -> 3504 bytes .../Assets/RemoteDesktop.svg | 21 +++ .../Commands/ConnectionListItem.cs | 35 ++++ .../Commands/FallbackRemoteDesktopItem.cs | 74 +++++++++ .../Commands/OpenRemoteDesktopCommand.cs | 82 ++++++++++ .../Helper/ConnectionHelpers.cs | 30 ++++ .../Helper/IRDPConnectionManager.cs | 13 ++ .../Helper/RDPConnectionsManager.cs | 89 ++++++++++ .../Icons.cs | 12 ++ .../Microsoft.CmdPal.Ext.RemoteDesktop.csproj | 44 +++++ .../Pages/RemoteDesktopListPage.cs | 27 ++++ .../Properties/AssemblyInfo.cs | 7 + .../Properties/Resources.Designer.cs | 153 ++++++++++++++++++ .../Properties/Resources.resx | 150 +++++++++++++++++ .../RemoteDesktopCommandProvider.cs | 45 ++++++ .../Settings/ISettingsInterface.cs | 15 ++ .../Settings/SettingsManager.cs | 53 ++++++ 29 files changed, 1232 insertions(+), 2 deletions(-) create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs create mode 100644 src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs create mode 100644 src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 9f18bbb300..c77154b466 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -727,7 +727,6 @@ HWNDPARENT HWNDPREV hyjiacan IAI -icf ICONERROR ICONLOCATION IDCANCEL @@ -800,6 +799,7 @@ invokecommand ipcmanager IPREVIEW ipreviewhandlervisualssetfont +irdp IPTC irow irprops @@ -1057,6 +1057,7 @@ msrc msstore msvcp MT +mstsc MTND MULTIPLEUSE multizone @@ -1206,8 +1207,11 @@ OOBEUI openas opencode OPENFILENAME +openrdp opensource openxmlformats +ollama +onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE ORSCANS @@ -1420,6 +1424,8 @@ RAWPATH rbhid rclsid RCZOOMIT +remotedesktop +rdp RDW READMODE READOBJECTS diff --git a/Directory.Packages.props b/Directory.Packages.props index 75b2399c8a..6744b991aa 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -69,7 +69,7 @@ This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail. --> - + diff --git a/PowerToys.sln b/PowerToys.sln index e34779c5bb..4e42cc9e30 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -834,6 +834,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LanguageModelProvider", "sr EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.UI.ViewModels.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.UI.ViewModels.UnitTests\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj", "{A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.RemoteDesktop", "src\modules\cmdpal\ext\Microsoft.CmdPal.Ext.RemoteDesktop\Microsoft.CmdPal.Ext.RemoteDesktop.csproj", "{2B3FB837-23DE-629F-82C6-42304E7083C9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj", "{DB34808A-FF91-D06E-A426-AFB5A8BD583B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|ARM64 = Debug|ARM64 @@ -3036,6 +3040,22 @@ Global {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|ARM64.Build.0 = Release|ARM64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.ActiveCfg = Release|x64 {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C}.Release|x64.Build.0 = Release|x64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Debug|ARM64.Build.0 = Debug|ARM64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Debug|x64.ActiveCfg = Debug|x64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Debug|x64.Build.0 = Debug|x64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Release|ARM64.ActiveCfg = Release|ARM64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Release|ARM64.Build.0 = Release|ARM64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Release|x64.ActiveCfg = Release|x64 + {2B3FB837-23DE-629F-82C6-42304E7083C9}.Release|x64.Build.0 = Release|x64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Debug|ARM64.ActiveCfg = Debug|ARM64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Debug|ARM64.Build.0 = Debug|ARM64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Debug|x64.ActiveCfg = Debug|x64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Debug|x64.Build.0 = Debug|x64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Release|ARM64.ActiveCfg = Release|ARM64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Release|ARM64.Build.0 = Release|ARM64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Release|x64.ActiveCfg = Release|x64 + {DB34808A-FF91-D06E-A426-AFB5A8BD583B}.Release|x64.Build.0 = Release|x64 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -3367,6 +3387,8 @@ Global {4E0FCF69-B06B-D272-76BF-ED3A559B4EDA} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} {45354F4F-1414-45CE-B600-51CD1209FD19} = {1AFB6476-670D-4E80-A464-657E01DFF482} {A66E9270-5D93-EC9C-F06E-CE7295BB9A6C} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} + {2B3FB837-23DE-629F-82C6-42304E7083C9} = {ECB8E0D1-7603-4E5C-AB10-D1E545E6F8E2} + {DB34808A-FF91-D06E-A426-AFB5A8BD583B} = {8EF25507-2575-4ADE-BF7E-D23376903AB8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs index 996475d559..b13a72d276 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Commands/MainListPage.cs @@ -36,6 +36,7 @@ public partial class MainListPage : DynamicListPage, "com.microsoft.cmdpal.builtin.websearch", "com.microsoft.cmdpal.builtin.windowssettings", "com.microsoft.cmdpal.builtin.datetime", + "com.microsoft.cmdpal.builtin.remotedesktop", ]; private readonly IServiceProvider _serviceProvider; diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs index 917716be19..f91b9e304a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/App.xaml.cs @@ -13,6 +13,7 @@ using Microsoft.CmdPal.Ext.Calc; using Microsoft.CmdPal.Ext.ClipboardHistory; using Microsoft.CmdPal.Ext.Indexer; using Microsoft.CmdPal.Ext.Registry; +using Microsoft.CmdPal.Ext.RemoteDesktop; using Microsoft.CmdPal.Ext.Shell; using Microsoft.CmdPal.Ext.System; using Microsoft.CmdPal.Ext.TimeDate; @@ -151,6 +152,7 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Models services.AddSingleton(); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index bd7402e4fd..f1702c302c 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -118,6 +118,7 @@ + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs new file mode 100644 index 0000000000..837246b731 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs @@ -0,0 +1,125 @@ +// 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.Globalization; +using System.Reflection; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class FallbackRemoteDesktopItemTests +{ + private static readonly CompositeFormat OpenHostCompositeFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + + [TestMethod] + public void UpdateQuery_WhenMatchingConnectionExists_UsesConnectionName() + { + var connectionName = "my-rdp-server"; + + // Arrange + var setup = CreateFallback(connectionName); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery("my-rdp-server"); + + // Assert + Assert.AreEqual(connectionName, fallback.Title); + var expectedSubtitle = string.Format(CultureInfo.CurrentCulture, OpenHostCompositeFormat, connectionName); + Assert.AreEqual(expectedSubtitle, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_connect, command.Name); + Assert.AreEqual(connectionName, GetCommandHost(command)); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsValidHostWithoutExistingConnection_UsesQuery() + { + // Arrange + var setup = CreateFallback(); + var fallback = setup.Fallback; + const string hostname = "test.corp"; + + // Act + fallback.UpdateQuery(hostname); + + // Assert + var expectedTitle = string.Format(CultureInfo.CurrentCulture, OpenHostCompositeFormat, hostname); + Assert.AreEqual(expectedTitle, fallback.Title); + Assert.AreEqual(Resources.remotedesktop_title, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_connect, command.Name); + Assert.AreEqual(hostname, GetCommandHost(command)); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsWhitespace_ResetsCommand() + { + // Arrange + var setup = CreateFallback("rdp-server-two"); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery(" "); + + // Assert + Assert.AreEqual(Resources.remotedesktop_command_open, fallback.Title); + Assert.AreEqual(string.Empty, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_open, command.Name); + Assert.AreEqual(string.Empty, GetCommandHost(command)); + } + + [TestMethod] + public void UpdateQuery_WhenQueryIsInvalidHost_ClearsCommand() + { + // Arrange + var setup = CreateFallback("rdp-server-three"); + var fallback = setup.Fallback; + + // Act + fallback.UpdateQuery("not a valid host"); + + // Assert + Assert.AreEqual(Resources.remotedesktop_command_open, fallback.Title); + Assert.AreEqual(string.Empty, fallback.Subtitle); + + var command = fallback.Command as OpenRemoteDesktopCommand; + Assert.IsNotNull(command); + Assert.AreEqual(Resources.remotedesktop_command_open, command.Name); + Assert.AreEqual(string.Empty, GetCommandHost(command)); + } + + private static string GetCommandHost(OpenRemoteDesktopCommand command) + { + var field = typeof(OpenRemoteDesktopCommand).GetField("_rdpHost", BindingFlags.NonPublic | BindingFlags.Instance); + if (field is null) + { + return string.Empty; + } + + return field.GetValue(command) as string ?? string.Empty; + } + + private static (FallbackRemoteDesktopItem Fallback, IRdpConnectionManager Manager) CreateFallback(params string[] connectionNames) + { + var settingsManager = new MockSettingsManager(connectionNames); + var connectionsManager = new MockRDPConnectionsManager(settingsManager); + + var fallback = new FallbackRemoteDesktopItem(connectionsManager); + + return (fallback, connectionsManager); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj new file mode 100644 index 0000000000..0b998ec4ad --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + + + false + true + Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\ + false + false + enable + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs new file mode 100644 index 0000000000..36c3151c6f --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs @@ -0,0 +1,23 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +internal sealed class MockRDPConnectionsManager : IRdpConnectionManager +{ + private readonly List _connections = new(); + + public IReadOnlyCollection Connections => _connections.AsReadOnly(); + + public MockRDPConnectionsManager(ISettingsInterface settingsManager) + { + _connections.AddRange(settingsManager.PredefinedConnections.Select(ConnectionHelpers.MapToResult)); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs new file mode 100644 index 0000000000..1a81dcc7ee --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockSettingsManager.cs @@ -0,0 +1,23 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using ToolkitSettings = Microsoft.CommandPalette.Extensions.Toolkit.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +internal sealed class MockSettingsManager : ISettingsInterface +{ + private readonly List _connections; + + public IReadOnlyCollection PredefinedConnections => _connections; + + public ToolkitSettings Settings { get; } = new(); + + public MockSettingsManager(params string[] predefinedConnections) + { + _connections = new(predefinedConnections); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs new file mode 100644 index 0000000000..dabea49a63 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs @@ -0,0 +1,52 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class RDPConnectionsManagerTests +{ + [TestMethod] + public void Constructor_AddsOpenCommandItem() + { + // Act + var manager = new RDPConnectionsManager(new MockSettingsManager(["test.local"])); + + // Assert + Assert.IsTrue(manager.Connections.Any(item => string.IsNullOrEmpty(item.ConnectionName))); + } + + [TestMethod] + public void FindConnection_ReturnsExactMatch() + { + // Arrange + var connectionName = "rdp-test"; + var connection = new ConnectionListItem(connectionName); + + // Act + var result = ConnectionHelpers.FindConnection(connectionName, new[] { connection }); + + // Assert + Assert.IsNotNull(result); + Assert.AreEqual(connectionName, result.ConnectionName); + } + + [TestMethod] + public void FindConnection_ReturnsNullForWhitespaceQuery() + { + // Arrange + var connection = new ConnectionListItem("rdp-test"); + + // Act + var result = ConnectionHelpers.FindConnection(" ", new[] { connection }); + + // Assert + Assert.IsNull(result); + } +} diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs new file mode 100644 index 0000000000..54698997ee --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RemoteDesktopCommandProviderTests.cs @@ -0,0 +1,101 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Pages; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; + +[TestClass] +public class RemoteDesktopCommandProviderTests +{ + [TestMethod] + public void ProviderHasCorrectId() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.AreEqual("com.microsoft.cmdpal.builtin.remotedesktop", provider.Id); + } + + [TestMethod] + public void ProviderHasDisplayName() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.IsNotNull(provider.DisplayName); + Assert.IsTrue(provider.DisplayName.Length > 0); + } + + [TestMethod] + public void ProviderHasIcon() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Assert + Assert.IsNotNull(provider.Icon); + } + + [TestMethod] + public void TopLevelCommandsNotEmpty() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void FallbackCommandsNotEmpty() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.FallbackCommands(); + + // Assert + Assert.IsNotNull(commands); + Assert.IsTrue(commands.Length > 0); + } + + [TestMethod] + public void TopLevelCommandsContainListPageCommand() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.TopLevelCommands(); + + // Assert + Assert.AreEqual(1, commands.Length); + Assert.IsInstanceOfType(commands.Single().Command, typeof(RemoteDesktopListPage)); + } + + [TestMethod] + public void FallbackCommandsContainFallbackItem() + { + // Setup + var provider = new RemoteDesktopCommandProvider(); + + // Act + var commands = provider.FallbackCommands(); + + // Assert + Assert.AreEqual(1, commands.Length); + Assert.IsInstanceOfType(commands.Single(), typeof(FallbackRemoteDesktopItem)); + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png new file mode 100644 index 0000000000000000000000000000000000000000..52d97dbfe9d119f992174ae8eeabdf5a7a11fc37 GIT binary patch literal 3504 zcmV;h4NvlkP)@~0drDELIAGL9O(c600d`2O+f$vv5yPI9Qni+*kZB2lh6qQz>jW9FDGN3YZZhLj#*4}IX=L~R2nvnEt_&NV&|9h|R zTi;sy@&j~Ar*ulEbV{dm%KvLo#Ty&r-g=Hl5&V7@xNIigalv43VG@Kh{nSz=@1Vb`1lq5(1gSZdMLLpO; zilB+@;a@3z1n0!i44rEcvgV(8XqNzQ00LNZluF@TrT8M@Ggn{b9<tX+aX@o(2j@l8`BeNdTqX z;GSs+GLpx&Bvkx{x}?Jm3T&_fVg=b)dj$9vToTOgr7)10I-Uz45Q$FKiETjS2Kf$B zyU4tpLM4Qp0+O?=LqL)+f+V>;0-|(^UCVP)uZ@Ugp6t^yy4-G8B*!v=|AZGJ|az z7n}ss(8kH7X}Ag!qfE-GV5|g$Je({Y@qG~nKpaN-UOt(EAV&EFEmbH;KQHuX83H*7 zwn_^HAatSwm}n@!r}LO}M3O?T&g89(piCWJH>qs}5>AORuc0DGPa_x$jqVrUfE&Xb@pgcA5Xu~ELJ2+w*=@?O1wuxbHtX}5CW zf{;VFPRZPx-$Ra=n=T{Vb2;I2Gxh(=Ny2N#l_Tat6=3zG;+QW%P)IJ$1tAWjQQTWl z1Sg|N1U2V})FF7?anAycRr3jRy5WGU(57fUNb5&B}O4zlK`69 zic8g`Ec55e9*rL_s^hBO@ODH?nXeovK@D!@t@ugENyuOOQV-Cju>bIpV^86{3Gm8o z8eWRCEh^N}qGMhYqy$J}wyLrOb)|{pC$FhuPM7veClzvx3ZPpU?Z67Q3HfhxQJdfU zXKNB#%>zQ%@pP1BB@{4p;;loE(;@kxI_>oDA!6OnM{#0dJ>=fX^Lx&HqoRI*M=f1~m$CZiBm5^q8ZW9IYQ(PGj!uUJp z*71dIE#t$T$Dve!VJg%5MEji|k}A;ge9U2YJjOqg=aC4uSL0(RGVnvo zR+wWsg312ZPpDSx-@m`#UeB-?3I@B`T-t^XPFK1hJ01lP0BZS6Q`+7#e-`!DF|Ui_ zbDzDvdj5Uv2wvZ@)jeCbVwF2uxF`0Lu8ikT6u=J~@W%U;q$m}mdS;iOFsIM941pN8 zge>-h9I`Nov9EXyuB4gBi3M(+Q;WF)tgzouc|98isI}(@ zmn}~!$pCs#Mzasv%hn;l7~|V2i%XLANT^1tM-T`>6$#z|qcI&MZGOj29LJV|=ZB5#9=`c#t$Q0w@GqH&$SpCVkktG z#Y|E>>+jMTQDCw}(ku7%;bB8UIgqgT%~3{62!e=8?CV94M3! z?Ct66>ua$q?8jmG6(;*289WZ*VO>akyt&~+yYS_QX^BcppyD#u{Ov3E;gLrlb7U|H zd-sf{`sTR)hOb7u|M9{5ICW|=g(y)PT_D1j_{>btE0skp6rcrggds1<$g7XCFT;G}82ZpV3BEck6(UdE}($pn-r*5x+g z)T5ojC9Nl0hX9Zz9AS3^2H|PM`sp{qk52U=@t?Yi9mw3^LtJOFwG*B`8xG$|O8C>B zH}TG2-v+M>28WknaA;X1Vf)rsA_*}IxO0Uxr|?ErtJR~ezYUTE7>QAGKFNpt90rkg ziebS-eNt1ASVX`SSz`srYYTq$H`hcH-~HP`L@ef?f#G2c3=IboPGQHkEshKdKnc=| z(J_;Y7cXvBfU)T$dokPDiEWTbl3{xr5C%yF6qI3(=`V^XU>2iCol;1S5|olAs0nzgOZceTg{g*$ZMr|&PcidJlmS06 z=HzVG^$dRL&=9g=O$W-u9ARK!2ulYBJqZT<&K=v+=n&p(G#Z%teEQb`UVG*0$9_ps zs;<-A07rp#9wdZpTP#nmy9M7Kyw)#$dBc;!c39XPnV6U`#&3<_t!FI(L}4#b#2JCY zdcyWfZZCP?TYK^LpZB@v_uh9uuD$L$SG8WRt6HtT|L!{%f5YDBMw^91pv&P5A}<8r z&q(gheIFVE7I`qvY)^v6WWkT)_1Cru@Po#yLq6uiQj{S2bUnJ9;_H4 ztDQR{_$5ZpOO|@ss{k~OPSj)JD$Nj=*tOR6+-C+l(3mYSZfU}|dGjUtswb1wu5czHg6 zm}n@KvrxRYW&G3>PE4M`UDwWYVCKx3jrj`};Fepz?&MogcqVTY$fc0ogVa^E>OEhp z#beXjZ>`x_e79`^=H9ZdVtQNaJC4B%G4Rd!uB~4xB_=E>AC7;DPp2xl^_t6EqAHjB zap|RVk@oq7mvufzuz>Ah6=V+&zD@?XOLiW(BV^OMwQD!FvZ?K1(3UG6er|)&;Y#hN zREoUrlvb1o2|H;eWj9ro*^*aEa~vp__Bo8~K0K|t?F#&G^}>~^R9aXvpTJ5bf5>lx z()lSQ{E%yiP>qJq1@(Hvm}802lSK7R_uqHlGc#eiAOikZWn^SzVYNmZRf(SP;7jf^ zIL{A63E{5Y|K+obCaWH|N_D{Pri&h>M(OGG>+d~qwmvT^0=Vqny?dqkUSz-JJzz=j z^Ch|CsX`6_0000 + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs new file mode 100644 index 0000000000..888a1d2f71 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/ConnectionListItem.cs @@ -0,0 +1,35 @@ +// 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.Globalization; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class ConnectionListItem : ListItem +{ + public ConnectionListItem(string connectionName) + { + ConnectionName = connectionName; + + if (string.IsNullOrEmpty(connectionName)) + { + Title = Resources.remotedesktop_open_rdp; + Subtitle = Resources.remotedesktop_subtitle; + } + else + { + Title = connectionName; + CompositeFormat remoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + Subtitle = string.Format(CultureInfo.CurrentCulture, remoteDesktopOpenHostFormat, connectionName); + } + + Icon = Icons.RDPIcon; + Command = new OpenRemoteDesktopCommand(connectionName); + } + + public string ConnectionName { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs new file mode 100644 index 0000000000..415746670f --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs @@ -0,0 +1,74 @@ +// 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.Globalization; +using System.Linq; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem +{ + private const string _id = "com.microsoft.cmdpal.builtin.remotedesktop.fallback"; + + private static readonly UriHostNameType[] ValidUriHostNameTypes = [ + UriHostNameType.IPv6, + UriHostNameType.IPv4, + UriHostNameType.Dns + ]; + + private static readonly CompositeFormat RemoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); + private readonly IRdpConnectionManager _rdpConnectionsManager; + + public FallbackRemoteDesktopItem(IRdpConnectionManager rdpConnectionsManager) + : base(new OpenRemoteDesktopCommand(string.Empty), Resources.remotedesktop_title) + { + _rdpConnectionsManager = rdpConnectionsManager; + + Title = string.Empty; + Subtitle = string.Empty; + Icon = Icons.RDPIcon; + } + + public override void UpdateQuery(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + Title = string.Empty; + Subtitle = string.Empty; + Command = new OpenRemoteDesktopCommand(string.Empty); + return; + } + + var connections = _rdpConnectionsManager.Connections.Where(w => !string.IsNullOrWhiteSpace(w.ConnectionName)); + + var queryConnection = ConnectionHelpers.FindConnection(query, connections); + + if (queryConnection is not null && !string.IsNullOrWhiteSpace(queryConnection.ConnectionName)) + { + var connectionName = queryConnection.ConnectionName; + + Command = new OpenRemoteDesktopCommand(connectionName); + Title = connectionName; + Subtitle = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName); + } + else if (ValidUriHostNameTypes.Contains(Uri.CheckHostName(query))) + { + var connectionName = query.Trim(); + Command = new OpenRemoteDesktopCommand(connectionName); + Title = string.Format(CultureInfo.CurrentCulture, RemoteDesktopOpenHostFormat, connectionName); + Subtitle = Resources.remotedesktop_title; + } + else + { + Title = string.Empty; + Subtitle = string.Empty; + Command = new OpenRemoteDesktopCommand(string.Empty); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs new file mode 100644 index 0000000000..679b015a1e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/OpenRemoteDesktopCommand.cs @@ -0,0 +1,82 @@ +// 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.Diagnostics; +using System.Text; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +internal sealed partial class OpenRemoteDesktopCommand : BaseObservable, IInvokableCommand +{ + private static readonly CompositeFormat ProcessErrorFormat = + CompositeFormat.Parse(Resources.remotedesktop_log_mstsc_error); + + private static readonly CompositeFormat InvalidHostnameFormat = + CompositeFormat.Parse(Resources.remotedesktop_log_invalid_hostname); + + public string Name { get; } + + public string Id { get; } = "com.microsoft.cmdpal.builtin.remotedesktop.openrdp"; + + public IIconInfo Icon => Icons.RDPIcon; + + private readonly string _rdpHost; + + public OpenRemoteDesktopCommand(string rdpHost) + { + _rdpHost = rdpHost; + + Name = string.IsNullOrWhiteSpace(_rdpHost) ? + Resources.remotedesktop_command_open : + Resources.remotedesktop_command_connect; + } + + public ICommandResult Invoke(object sender) + { + using var process = new Process(); + process.StartInfo.UseShellExecute = false; + process.StartInfo.WorkingDirectory = Environment.SpecialFolder.MyDocuments.ToString(); + process.StartInfo.FileName = "mstsc"; + + if (!string.IsNullOrWhiteSpace(_rdpHost)) + { + // validate that _rdpHost is a proper hostname or IP address + if (Uri.CheckHostName(_rdpHost) == UriHostNameType.Unknown) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + InvalidHostnameFormat, + _rdpHost), + Result = CommandResult.KeepOpen(), + }); + } + + process.StartInfo.Arguments = $"/v:{_rdpHost}"; + } + + try + { + process.Start(); + + return CommandResult.Dismiss(); + } + catch (Exception ex) + { + return CommandResult.ShowToast(new ToastArgs() + { + Message = string.Format( + System.Globalization.CultureInfo.CurrentCulture, + ProcessErrorFormat, + ex.Message), + Result = CommandResult.KeepOpen(), + }); + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs new file mode 100644 index 0000000000..5fac986169 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/ConnectionHelpers.cs @@ -0,0 +1,30 @@ +// 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.Collections.Generic; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal static class ConnectionHelpers +{ + public static ConnectionListItem MapToResult(string item) => new(item); + + public static ConnectionListItem? FindConnection(string query, IEnumerable connections) + { + if (string.IsNullOrWhiteSpace(query)) + { + return null; + } + + var matchedConnection = ListHelpers.FilterList( + connections, + query, + (s, i) => ListHelpers.ScoreListItem(s, i)) + .FirstOrDefault(); + return matchedConnection; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs new file mode 100644 index 0000000000..4d04126dfb --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs @@ -0,0 +1,13 @@ +// 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.Collections.Generic; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal interface IRdpConnectionManager +{ + IReadOnlyCollection Connections { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs new file mode 100644 index 0000000000..b9b7d62e1a --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs @@ -0,0 +1,89 @@ +// 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.Collections.ObjectModel; +using System.Linq; +using System.Threading; +using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using Microsoft.Win32; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; + +internal class RDPConnectionsManager : IRdpConnectionManager +{ + private readonly ISettingsInterface _settingsManager; + private readonly ConnectionListItem _openRdpCommandListItem = new(string.Empty); + + private ReadOnlyCollection _connections = new(Array.Empty()); + + private const int MinutesToCache = 1; + private DateTime? _connectionsLastLoaded; + + public RDPConnectionsManager(ISettingsInterface settingsManager) + { + _settingsManager = settingsManager; + _settingsManager.Settings.SettingsChanged += (s, e) => + { + _connectionsLastLoaded = null; + }; + } + + public IReadOnlyCollection Connections + { + get + { + if (!_connectionsLastLoaded.HasValue || + (DateTime.Now - _connectionsLastLoaded.Value).TotalMinutes >= MinutesToCache) + { + var registryConnections = GetRdpConnectionsFromRegistry(); + var predefinedConnections = GetPredefinedConnectionsFromSettings(); + _connectionsLastLoaded = DateTime.Now; + + var newConnections = new List(registryConnections.Count + predefinedConnections.Count + 1); + newConnections.AddRange(registryConnections); + newConnections.AddRange(predefinedConnections); + newConnections.Insert(0, _openRdpCommandListItem); + + Interlocked.Exchange(ref _connections, new ReadOnlyCollection(newConnections)); + } + + return _connections; + } + } + + private List GetRdpConnectionsFromRegistry() + { + using var key = Registry.CurrentUser.OpenSubKey(@"SOFTWARE\Microsoft\Terminal Server Client\Default"); + + var validConnections = new List(); + + if (key is not null) + { + validConnections = key.GetValueNames() + .Select(name => key.GetValue(name)) + .OfType() // Keep only string values + .Select(v => v.Trim()) // Normalize + .Where(v => !string.IsNullOrWhiteSpace(v)) + .Distinct() // Remove dupes if any + .Select(ConnectionHelpers.MapToResult) + .ToList(); + } + + return validConnections; + } + + private List GetPredefinedConnectionsFromSettings() + { + var validConnections = _settingsManager.PredefinedConnections + .Select(s => s.Trim()) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .Select(ConnectionHelpers.MapToResult) + .ToList(); + + return validConnections; + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs new file mode 100644 index 0000000000..eec9e48e24 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Icons.cs @@ -0,0 +1,12 @@ +// 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.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop; + +internal static class Icons +{ + internal static IconInfo RDPIcon { get; } = IconHelpers.FromRelativePath("Assets\\RemoteDesktop.svg"); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj new file mode 100644 index 0000000000..2a561b9b9e --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Microsoft.CmdPal.Ext.RemoteDesktop.csproj @@ -0,0 +1,44 @@ + + + + + + Microsoft.CmdPal.Ext.RemoteDesktop + $(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal + false + false + + Microsoft.CmdPal.Ext.RemoteDesktop.pri + enable + + + + + + + + + Resources.resx + True + True + + + + + Resources.Designer.cs + PublicResXFileCodeGenerator + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs new file mode 100644 index 0000000000..42a0165277 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs @@ -0,0 +1,27 @@ +// 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.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Pages; + +internal sealed partial class RemoteDesktopListPage : ListPage +{ + private readonly IRdpConnectionManager _rdpConnectionManager; + + public RemoteDesktopListPage(IRdpConnectionManager rdpConnectionManager) + { + Icon = Icons.RDPIcon; + Name = Resources.remotedesktop_title; + Id = "com.microsoft.cmdpal.builtin.remotedesktop"; + + _rdpConnectionManager = rdpConnectionManager; + } + + public override IListItem[] GetItems() => _rdpConnectionManager.Connections.ToArray(); +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..4a6c84ddea --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/AssemblyInfo.cs @@ -0,0 +1,7 @@ +// 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.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests")] diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs new file mode 100644 index 0000000000..de0b924c33 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.Designer.cs @@ -0,0 +1,153 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + public class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.CmdPal.Ext.RemoteDesktop.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + public static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to Connect. + /// + public static string remotedesktop_command_connect { + get { + return ResourceManager.GetString("remotedesktop_command_connect", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open. + /// + public static string remotedesktop_command_open { + get { + return ResourceManager.GetString("remotedesktop_command_open", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The hostname '{0}' was invalid. Ensure you're using a valid hostname or IP address.. + /// + public static string remotedesktop_log_invalid_hostname { + get { + return ResourceManager.GetString("remotedesktop_log_invalid_hostname", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Unable to initialize Microsoft Terminal Service Client. Ensure it is enabled in Windows Settings.\r{0}. + /// + public static string remotedesktop_log_mstsc_error { + get { + return ResourceManager.GetString("remotedesktop_log_mstsc_error", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Connect to {0}. + /// + public static string remotedesktop_open_host { + get { + return ResourceManager.GetString("remotedesktop_open_host", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Open Remote Desktop Client. + /// + public static string remotedesktop_open_rdp { + get { + return ResourceManager.GetString("remotedesktop_open_rdp", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to A list of connections to include in the query results by default. + /// + public static string remotedesktop_settings_predefined_connections_description { + get { + return ResourceManager.GetString("remotedesktop_settings_predefined_connections_description", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Predefined connections. + /// + public static string remotedesktop_settings_predefined_connections_title { + get { + return ResourceManager.GetString("remotedesktop_settings_predefined_connections_title", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Establish Remote Desktop connections. + /// + public static string remotedesktop_subtitle { + get { + return ResourceManager.GetString("remotedesktop_subtitle", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Remote Desktop. + /// + public static string remotedesktop_title { + get { + return ResourceManager.GetString("remotedesktop_title", resourceCulture); + } + } + } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx new file mode 100644 index 0000000000..bfbf1d3ac5 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Properties/Resources.resx @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Remote Desktop + + + Establish Remote Desktop connections + + + Open + + + Connect to {0} + + + Connect + + + Open Remote Desktop Client + + + Predefined connections + + + A list of connections to include in the query results by default + + + Unable to initialize Microsoft Terminal Service Client. Ensure it is enabled in Windows Settings.\r{0} + + + The hostname '{0}' was invalid. Ensure you're using a valid hostname or IP address. + + \ No newline at end of file diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs new file mode 100644 index 0000000000..eefd467d5b --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs @@ -0,0 +1,45 @@ +// 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.CmdPal.Ext.RemoteDesktop.Commands; +using Microsoft.CmdPal.Ext.RemoteDesktop.Helper; +using Microsoft.CmdPal.Ext.RemoteDesktop.Pages; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop; + +public partial class RemoteDesktopCommandProvider : CommandProvider +{ + private readonly CommandItem listPageCommand; + private readonly FallbackRemoteDesktopItem fallback; + + public RemoteDesktopCommandProvider() + { + Id = "com.microsoft.cmdpal.builtin.remotedesktop"; + DisplayName = Resources.remotedesktop_title; + Icon = Icons.RDPIcon; + + var settingsManager = new SettingsManager(); + var rdpConnectionsManager = new RDPConnectionsManager(settingsManager); + var listPage = new RemoteDesktopListPage(rdpConnectionsManager); + + fallback = new FallbackRemoteDesktopItem(rdpConnectionsManager); + + listPageCommand = new CommandItem(listPage) + { + Subtitle = Resources.remotedesktop_subtitle, + Icon = Icons.RDPIcon, + MoreCommands = [ + new CommandContextItem(settingsManager.Settings.SettingsPage), + ], + }; + } + + public override ICommandItem[] TopLevelCommands() => [listPageCommand]; + + public override IFallbackCommandItem[] FallbackCommands() => [fallback]; +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs new file mode 100644 index 0000000000..dbca0d3833 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/ISettingsInterface.cs @@ -0,0 +1,15 @@ +// 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.Collections.Generic; +using ToolkitSettings = Microsoft.CommandPalette.Extensions.Toolkit.Settings; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +internal interface ISettingsInterface +{ + public IReadOnlyCollection PredefinedConnections { get; } + + public ToolkitSettings Settings { get; } +} diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs new file mode 100644 index 0000000000..1469e448d7 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Settings/SettingsManager.cs @@ -0,0 +1,53 @@ +// 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.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.CmdPal.Ext.RemoteDesktop.Properties; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace Microsoft.CmdPal.Ext.RemoteDesktop.Settings; + +internal class SettingsManager : JsonSettingsManager, ISettingsInterface +{ + // Line break character used in WinUI3 TextBox and TextBlock. + private const char TEXTBOXNEWLINE = '\r'; + + private static readonly string _namespace = "com.microsoft.cmdpal.builtin.remotedesktop"; + + private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}"; + + private readonly TextSetting _predefinedConnections = new( + Namespaced(nameof(PredefinedConnections)), + Resources.remotedesktop_settings_predefined_connections_title, + Resources.remotedesktop_settings_predefined_connections_description, + string.Empty) + { + Multiline = true, + Placeholder = $"server1.domain.com{TEXTBOXNEWLINE}server2.domain.com{TEXTBOXNEWLINE}192.168.1.1", + }; + + public IReadOnlyCollection PredefinedConnections => _predefinedConnections.Value?.Split(TEXTBOXNEWLINE).ToList() ?? []; + + internal static string SettingsJsonPath() + { + var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal"); + Directory.CreateDirectory(directory); + + return Path.Combine(directory, "settings.json"); + } + + public SettingsManager() + { + FilePath = SettingsJsonPath(); + + Settings.Add(_predefinedConnections); + + // Load settings from file upon initialization + LoadSettings(); + + Settings.SettingsChanged += (s, a) => this.SaveSettings(); + } +} From bc0a760aff6ecc9376bd1fc1f7ff8bfb22915638 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sat, 29 Nov 2025 23:23:24 +0100 Subject: [PATCH 05/10] CmdPal: Add mini dev center (#43939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary of the Pull Request This PR introduces a small ribbon to the CmdPal for app developers. The dev ribbon is dynamically added to the main window in local (non-CI) builds. It shows the number of logged errors and warnings, the current build configuration (Debug or Release), and whether it’s built with AOT. The flyout shows the latest errors and warnings and lets you quickly access the logs. ## Pictures? Pictures! image ## PR Checklist - [x] Closes: #43318 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 1 + src/common/ManagedCommon/Logger.cs | 9 +- .../Controls/DevRibbon.xaml | 257 ++++++++++++++++++ .../Controls/DevRibbon.xaml.cs | 40 +++ .../Microsoft.CmdPal.UI/Helpers/BuildInfo.cs | 36 +++ .../Microsoft.CmdPal.UI/MainWindow.xaml | 4 +- .../Microsoft.CmdPal.UI/MainWindow.xaml.cs | 28 +- .../Microsoft.CmdPal.UI.csproj | 33 ++- .../ViewModels/DevRibbonViewModel.cs | 190 +++++++++++++ .../ViewModels/LogEntryViewModel.cs | 77 ++++++ 10 files changed, 659 insertions(+), 16 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index c77154b466..096bd8e394 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -56,6 +56,7 @@ ANull AOC aocfnapldcnfbofgmbbllojgocaelgdd AOklab +aot APARTMENTTHREADED APeriod apicontract diff --git a/src/common/ManagedCommon/Logger.cs b/src/common/ManagedCommon/Logger.cs index 1173920340..7f72cdd78b 100644 --- a/src/common/ManagedCommon/Logger.cs +++ b/src/common/ManagedCommon/Logger.cs @@ -31,6 +31,11 @@ namespace ManagedCommon /// public static string CurrentVersionLogDirectoryPath { get; private set; } + /// + /// Gets the path to the current log file. + /// + public static string CurrentLogFile { get; private set; } + /// /// Gets the path to the log directory for the app. /// @@ -55,7 +60,9 @@ namespace ManagedCommon AppLogDirectoryPath = basePath; CurrentVersionLogDirectoryPath = versionedPath; - var logFilePath = Path.Combine(versionedPath, "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"); + var logFile = "Log_" + DateTime.Now.ToString(@"yyyy-MM-dd", CultureInfo.InvariantCulture) + ".log"; + var logFilePath = Path.Combine(versionedPath, logFile); + CurrentLogFile = logFilePath; Trace.Listeners.Add(new TextWriterTraceListener(logFilePath)); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml new file mode 100644 index 0000000000..e354f0519f --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml @@ -0,0 +1,257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs new file mode 100644 index 0000000000..1659f32d32 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Controls/DevRibbon.xaml.cs @@ -0,0 +1,40 @@ +// 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.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; + +namespace Microsoft.CmdPal.UI.Controls; + +internal sealed partial class DevRibbon : UserControl +{ + public ViewModels.DevRibbonViewModel ViewModel { get; } + + public DevRibbon() + { + InitializeComponent(); + ViewModel = new ViewModels.DevRibbonViewModel(); + + if (FlyoutContent != null) + { + FlyoutContent.DataContext = ViewModel; + } + } + + private void DevRibbonButton_PointerEntered(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "PointerOver", true); + } + + private void DevRibbonButton_PointerExited(object sender, PointerRoutedEventArgs e) + { + VisualStateManager.GoToState(this, "Normal", true); + } + + private Visibility VisibleIfGreaterThanZero(int value) + { + return value > 0 ? Visibility.Visible : Visibility.Collapsed; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.cs new file mode 100644 index 0000000000..7f129d8b06 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BuildInfo.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 System.Reflection; +using System.Runtime.CompilerServices; + +namespace Microsoft.CmdPal.UI.Helpers; + +internal static class BuildInfo +{ +#if DEBUG + public const string Configuration = "Debug"; +#else + public const string Configuration = "Release"; +#endif + + // Runtime AOT detection + public static bool IsNativeAot => !RuntimeFeature.IsDynamicCodeSupported; + + // From assembly metadata (build-time values) + public static bool PublishTrimmed => GetBoolMetadata("PublishTrimmed", false); + + // From assembly metadata (build-time values) + public static bool PublishAot => GetBoolMetadata("PublishAot", false); + + public static bool IsCiBuild => GetBoolMetadata("CIBuild", false); + + private static string? GetMetadata(string key) => + Assembly.GetExecutingAssembly() + .GetCustomAttributes() + .FirstOrDefault(a => a.Key == key)?.Value; + + private static bool GetBoolMetadata(string key, bool defaultValue) => + bool.TryParse(GetMetadata(key), out var result) ? result : defaultValue; +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml index d06932fd59..c0c0ab811f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml @@ -14,5 +14,7 @@ Activated="MainWindow_Activated" Closed="MainWindow_Closed" mc:Ignorable="d"> - + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs index 34f746facf..2936f8447e 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/MainWindow.xaml.cs @@ -11,6 +11,7 @@ using Microsoft.CmdPal.Core.Common.Helpers; using Microsoft.CmdPal.Core.Common.Services; using Microsoft.CmdPal.Core.ViewModels.Messages; using Microsoft.CmdPal.Ext.ClipboardHistory.Messages; +using Microsoft.CmdPal.UI.Controls; using Microsoft.CmdPal.UI.Events; using Microsoft.CmdPal.UI.Helpers; using Microsoft.CmdPal.UI.Messages; @@ -113,7 +114,7 @@ public sealed partial class MainWindow : WindowEx, ExtendsContentIntoTitleBar = true; AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed; SizeChanged += WindowSizeChanged; - RootShellPage.Loaded += RootShellPage_Loaded; + RootElement.Loaded += RootElementLoaded; WM_TASKBAR_RESTART = PInvoke.RegisterWindowMessage("TaskbarCreated"); @@ -130,7 +131,7 @@ public sealed partial class MainWindow : WindowEx, App.Current.Services.GetService()!.SettingsChanged += SettingsChangedHandler; // Make sure that we update the acrylic theme when the OS theme changes - RootShellPage.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateAcrylic); + RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateAcrylic); // Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () => @@ -165,11 +166,18 @@ public sealed partial class MainWindow : WindowEx, private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(); - private void RootShellPage_Loaded(object sender, RoutedEventArgs e) => - + private void RootElementLoaded(object sender, RoutedEventArgs e) + { // Now that our content has loaded, we can update our draggable regions UpdateRegionsForCustomTitleBar(); + // Add dev ribbon if enabled + if (!BuildInfo.IsCiBuild) + { + RootElement.Children.Add(new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) }); + } + } + private void WindowSizeChanged(object sender, WindowSizeChangedEventArgs args) => UpdateRegionsForCustomTitleBar(); private void PositionCentered() @@ -658,28 +666,28 @@ public sealed partial class MainWindow : WindowEx, private void UpdateRegionsForCustomTitleBar() { // Specify the interactive regions of the title bar. - var scaleAdjustment = RootShellPage.XamlRoot.RasterizationScale; + var scaleAdjustment = RootElement.XamlRoot.RasterizationScale; // Get the rectangle around our XAML content. We're going to mark this // rectangle as "Passthrough", so that the normal window operations // (resizing, dragging) don't apply in this space. - var transform = RootShellPage.TransformToVisual(null); + var transform = RootElement.TransformToVisual(null); // Reserve 16px of space at the top for dragging. var topHeight = 16; var bounds = transform.TransformBounds(new Rect( 0, topHeight, - RootShellPage.ActualWidth, - RootShellPage.ActualHeight)); + RootElement.ActualWidth, + RootElement.ActualHeight)); var contentRect = GetRect(bounds, scaleAdjustment); var rectArray = new RectInt32[] { contentRect }; var nonClientInputSrc = InputNonClientPointerSource.GetForWindowId(this.AppWindow.Id); nonClientInputSrc.SetRegionRects(NonClientRegionKind.Passthrough, rectArray); // Add a drag-able region on top - var w = RootShellPage.ActualWidth; - _ = RootShellPage.ActualHeight; + var w = RootElement.ActualWidth; + _ = RootElement.ActualHeight; var dragSides = new RectInt32[] { GetRect(new Rect(0, 0, w, topHeight), scaleAdjustment), // the top, {topHeight=16} tall diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj index f1702c302c..eac3643847 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Microsoft.CmdPal.UI.csproj @@ -15,6 +15,7 @@ enable enable true + preview $(CmdPalVersion) @@ -25,10 +26,10 @@ - + true + --> true @@ -37,7 +38,7 @@ true - + true Never $(OutputPath)\AppPackages\Microsoft.CmdPal.UI_$(Version)_Test\ @@ -66,6 +67,7 @@ + @@ -168,6 +170,9 @@ MSBuild:Compile + + MSBuild:Compile + $(DefaultXamlRuntime) @@ -235,4 +240,24 @@ + + + + <_Parameter1>PublishTrimmed + <_Parameter2>$(PublishTrimmed) + + + <_Parameter1>PublishAot + <_Parameter2>$(PublishAot) + + + <_Parameter1>CIBuild + <_Parameter2>$(CIBuild) + + + <_Parameter1>CommandPaletteBranding + <_Parameter2>$(CommandPaletteBranding) + + + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs new file mode 100644 index 0000000000..1876d3f82a --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/DevRibbonViewModel.cs @@ -0,0 +1,190 @@ +// 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.Collections.ObjectModel; +using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ManagedCommon; +using Microsoft.CmdPal.UI.Helpers; +using Microsoft.UI; +using Windows.System; +using Windows.UI; +using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue; + +namespace Microsoft.CmdPal.UI.ViewModels; + +internal sealed partial class DevRibbonViewModel : ObservableObject +{ + private const int MaxLogEntries = 2; + private const string Release = "Release"; + private const string Debug = "Debug"; + + private static readonly Color ReleaseAotColor = ColorHelper.FromArgb(255, 124, 58, 237); + private static readonly Color ReleaseColor = ColorHelper.FromArgb(255, 51, 65, 85); + private static readonly Color DebugAotColor = ColorHelper.FromArgb(255, 99, 102, 241); + private static readonly Color DebugColor = ColorHelper.FromArgb(255, 107, 114, 128); + + private readonly DispatcherQueue _dispatcherQueue; + + public DevRibbonViewModel() + { + _dispatcherQueue = DispatcherQueue.GetForCurrentThread(); + Trace.Listeners.Add(new DevRibbonTraceListener(this)); + + var configLabel = BuildConfiguration == Release ? "RLS" : "DBG"; /* #no-spell-check-line */ + var aotLabel = BuildInfo.IsNativeAot ? "⚡AOT" : "NO AOT"; + Tag = $"{configLabel} | {aotLabel}"; + + TagColor = (BuildConfiguration, BuildInfo.IsNativeAot) switch + { + (Release, true) => ReleaseAotColor, + (Release, false) => ReleaseColor, + (Debug, true) => DebugAotColor, + (Debug, false) => DebugColor, + _ => Colors.Fuchsia, + }; + } + + public string BuildConfiguration => BuildInfo.Configuration; + + public bool IsAotReleaseConfiguration => BuildConfiguration == Release && BuildInfo.IsNativeAot; + + public bool IsAot => BuildInfo.IsNativeAot; + + public bool IsPublishTrimmed => BuildInfo.PublishTrimmed; + + public ObservableCollection LatestLogs { get; } = []; + + [ObservableProperty] + public partial int WarningCount { get; private set; } + + [ObservableProperty] + public partial int ErrorCount { get; private set; } + + [ObservableProperty] + public partial string Tag { get; private set; } + + [ObservableProperty] + public partial Color TagColor { get; private set; } + + [RelayCommand] + private async Task OpenLogFileAsync() + { + var logPath = Logger.CurrentLogFile; + if (File.Exists(logPath)) + { + await Launcher.LaunchUriAsync(new Uri(logPath)); + } + } + + [RelayCommand] + private async Task OpenLogFolderAsync() + { + var logFolderPath = Logger.CurrentVersionLogDirectoryPath; + if (Directory.Exists(logFolderPath)) + { + await Launcher.LaunchFolderPathAsync(logFolderPath); + } + } + + [RelayCommand] + private void ResetErrorCounters() + { + WarningCount = 0; + ErrorCount = 0; + LatestLogs.Clear(); + } + + private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener + { + private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff"; + + [GeneratedRegex(@"^\[(?.*?)\] \[(?.*?)\] (?.*)")] + private static partial Regex LogRegex(); + + private readonly Lock _lock = new(); + private LogEntryViewModel? _latestLogEntry; + + public override void Write(string? message) + { + // Not required for this scenario. + } + + public override void WriteLine(string? message) + { + if (message is null) + { + return; + } + + lock (_lock) + { + var match = LogRegex().Match(message); + if (match.Success) + { + var severity = match.Groups["severity"].Value; + var isWarning = severity.Equals("Warning", StringComparison.OrdinalIgnoreCase); + var isError = severity.Equals("Error", StringComparison.OrdinalIgnoreCase); + + if (isWarning || isError) + { + var timestampStr = match.Groups["timestamp"].Value; + var timestamp = DateTimeOffset.TryParseExact( + timestampStr, + TimestampFormat, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeLocal, + out var parsed) + ? parsed + : DateTimeOffset.Now; + + var logEntry = new LogEntryViewModel( + timestamp, + severity, + match.Groups["message"].Value, + string.Empty); + + _latestLogEntry = logEntry; + + viewModel._dispatcherQueue.TryEnqueue(() => + { + if (isWarning) + { + viewModel.WarningCount++; + } + else + { + viewModel.ErrorCount++; + } + + viewModel.LatestLogs.Insert(0, logEntry); + + while (viewModel.LatestLogs.Count > MaxLogEntries) + { + viewModel.LatestLogs.RemoveAt(viewModel.LatestLogs.Count - 1); + } + }); + } + else + { + _latestLogEntry = null; + } + + return; + } + + if (IndentLevel > 0 && _latestLogEntry is { } latest) + { + viewModel._dispatcherQueue.TryEnqueue(() => + { + latest.AppendDetails(message); + }); + } + } + } + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs new file mode 100644 index 0000000000..5f9ed8db68 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ViewModels/LogEntryViewModel.cs @@ -0,0 +1,77 @@ +// 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.Globalization; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace Microsoft.CmdPal.UI.ViewModels; + +internal sealed partial class LogEntryViewModel : ObservableObject +{ + private const int HeaderMaxLength = 80; + private const string WarningGlyph = "\uE7BA"; + private const string ErrorGlyph = "\uEA39"; + private const string TimestampFormat = "HH:mm:ss"; + + private DateTimeOffset Timestamp { get; } + + private string Severity { get; } + + private string Message { get; } + + private string FormattedTimestamp { get; } + + public string SeverityGlyph { get; } + + [ObservableProperty] + public partial string Header { get; private set; } + + [ObservableProperty] + public partial string Description { get; private set; } + + [ObservableProperty] + public partial string Details { get; private set; } + + public LogEntryViewModel(DateTimeOffset timestamp, string severity, string message, string details) + { + Timestamp = timestamp; + Severity = severity; + Message = message; + Details = details; + + SeverityGlyph = severity.ToUpperInvariant() switch + { + "WARNING" => WarningGlyph, + "ERROR" => ErrorGlyph, + _ => string.Empty, + }; + + FormattedTimestamp = timestamp.ToString(TimestampFormat, CultureInfo.CurrentCulture); + Description = $"{FormattedTimestamp} • {Message}"; + Header = Message; + } + + public void AppendDetails(string? message) + { + if (string.IsNullOrEmpty(message)) + { + return; + } + + Details += Environment.NewLine + message; + + // Make header the second line of details (because that's actually the message itself): + var detailsLines = Details.Split([Environment.NewLine], StringSplitOptions.None); + if (detailsLines.Length < 2) + { + return; + } + + Header = detailsLines[1].Trim(); + if (Header.Length > HeaderMaxLength) + { + Header = Header[..(HeaderMaxLength - 1)] + "…"; + } + } +} From afd9d4cc3c27aec7d6f1ec051b0547d20b4eacbc Mon Sep 17 00:00:00 2001 From: Clint Rutkas Date: Sat, 29 Nov 2025 15:11:17 -0800 Subject: [PATCH 06/10] Update PowerToys download links to version 0.96.1 (#43965) ## Summary of the Pull Request ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 624d95501b..dd3abefe1c 100644 --- a/README.md +++ b/README.md @@ -53,17 +53,17 @@ Go to the [PowerToys GitHub releases][github-release-link], click Assets to reve [github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22 [github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.96%22 -[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-x64.exe -[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysUserSetup-0.96.0-arm64.exe -[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-x64.exe -[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.0/PowerToysSetup-0.96.0-arm64.exe +[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.1/PowerToysUserSetup-0.96.1-x64.exe +[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.1/PowerToysUserSetup-0.96.1-arm64.exe +[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.1/PowerToysSetup-0.96.1-x64.exe +[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.96.1/PowerToysSetup-0.96.1-arm64.exe | Description | Filename | |----------------|----------| -| Per user - x64 | [PowerToysUserSetup-0.96.0-x64.exe][ptUserX64] | -| Per user - ARM64 | [PowerToysUserSetup-0.96.0-arm64.exe][ptUserArm64] | -| Machine wide - x64 | [PowerToysSetup-0.96.0-x64.exe][ptMachineX64] | -| Machine wide - ARM64 | [PowerToysSetup-0.96.0-arm64.exe][ptMachineArm64] | +| Per user - x64 | [PowerToysUserSetup-0.96.1-x64.exe][ptUserX64] | +| Per user - ARM64 | [PowerToysUserSetup-0.96.1-arm64.exe][ptUserArm64] | +| Machine wide - x64 | [PowerToysSetup-0.96.1-x64.exe][ptMachineX64] | +| Machine wide - ARM64 | [PowerToysSetup-0.96.1-arm64.exe][ptMachineArm64] | From 8aea589b0158e613f437359ef786f884124520bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sun, 30 Nov 2025 00:36:55 +0100 Subject: [PATCH 07/10] CmdPal: Align spellchecker and naming to .NET guidelines (#43974) ## Summary of the Pull Request - Add command-line parameter value (icf) - Unify file and class name casing to match .NET naming conventions (RDP -> Rdp as Url, Dns, Xml) -- fixes IRDP spellchecking error - Rename IRdpConnectionManager to IRdpConnectionsManager (*s) to match the class name ## PR Checklist - [ ] Closes: #xxx - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .github/actions/spell-check/expect.txt | 2 +- .../FallbackRemoteDesktopItemTests.cs | 4 ++-- ...ConnectionsManager.cs => MockRdpConnectionsManager.cs} | 4 ++-- ...tionsManagerTests.cs => RdpConnectionsManagerTests.cs} | 4 ++-- .../Commands/FallbackRemoteDesktopItem.cs | 4 ++-- ...IRDPConnectionManager.cs => IRdpConnectionsManager.cs} | 2 +- ...{RDPConnectionsManager.cs => RdpConnectionsManager.cs} | 4 ++-- .../Pages/RemoteDesktopListPage.cs | 8 ++++---- .../RemoteDesktopCommandProvider.cs | 2 +- 9 files changed, 17 insertions(+), 17 deletions(-) rename src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/{MockRDPConnectionsManager.cs => MockRdpConnectionsManager.cs} (84%) rename src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/{RDPConnectionsManagerTests.cs => RdpConnectionsManagerTests.cs} (93%) rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/{IRDPConnectionManager.cs => IRdpConnectionsManager.cs} (90%) rename src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/{RDPConnectionsManager.cs => RdpConnectionsManager.cs} (96%) diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index 096bd8e394..f080d44d6a 100644 --- a/.github/actions/spell-check/expect.txt +++ b/.github/actions/spell-check/expect.txt @@ -728,6 +728,7 @@ HWNDPARENT HWNDPREV hyjiacan IAI +icf ICONERROR ICONLOCATION IDCANCEL @@ -800,7 +801,6 @@ invokecommand ipcmanager IPREVIEW ipreviewhandlervisualssetfont -irdp IPTC irow irprops diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs index 837246b731..75c63c5ae0 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/FallbackRemoteDesktopItemTests.cs @@ -113,10 +113,10 @@ public class FallbackRemoteDesktopItemTests return field.GetValue(command) as string ?? string.Empty; } - private static (FallbackRemoteDesktopItem Fallback, IRdpConnectionManager Manager) CreateFallback(params string[] connectionNames) + private static (FallbackRemoteDesktopItem Fallback, IRdpConnectionsManager Manager) CreateFallback(params string[] connectionNames) { var settingsManager = new MockSettingsManager(connectionNames); - var connectionsManager = new MockRDPConnectionsManager(settingsManager); + var connectionsManager = new MockRdpConnectionsManager(settingsManager); var fallback = new FallbackRemoteDesktopItem(connectionsManager); diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs similarity index 84% rename from src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs rename to src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs index 36c3151c6f..be1c961523 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRDPConnectionsManager.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/MockRdpConnectionsManager.cs @@ -10,13 +10,13 @@ using Microsoft.CmdPal.Ext.RemoteDesktop.Settings; namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; -internal sealed class MockRDPConnectionsManager : IRdpConnectionManager +internal sealed class MockRdpConnectionsManager : IRdpConnectionsManager { private readonly List _connections = new(); public IReadOnlyCollection Connections => _connections.AsReadOnly(); - public MockRDPConnectionsManager(ISettingsInterface settingsManager) + public MockRdpConnectionsManager(ISettingsInterface settingsManager) { _connections.AddRange(settingsManager.PredefinedConnections.Select(ConnectionHelpers.MapToResult)); } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs similarity index 93% rename from src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs rename to src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs index dabea49a63..a8a48ba79c 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RDPConnectionsManagerTests.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests/RdpConnectionsManagerTests.cs @@ -10,13 +10,13 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; namespace Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests; [TestClass] -public class RDPConnectionsManagerTests +public class RdpConnectionsManagerTests { [TestMethod] public void Constructor_AddsOpenCommandItem() { // Act - var manager = new RDPConnectionsManager(new MockSettingsManager(["test.local"])); + var manager = new RdpConnectionsManager(new MockSettingsManager(["test.local"])); // Assert Assert.IsTrue(manager.Connections.Any(item => string.IsNullOrEmpty(item.ConnectionName))); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs index 415746670f..8579566b77 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Commands/FallbackRemoteDesktopItem.cs @@ -23,9 +23,9 @@ internal sealed partial class FallbackRemoteDesktopItem : FallbackCommandItem ]; private static readonly CompositeFormat RemoteDesktopOpenHostFormat = CompositeFormat.Parse(Resources.remotedesktop_open_host); - private readonly IRdpConnectionManager _rdpConnectionsManager; + private readonly IRdpConnectionsManager _rdpConnectionsManager; - public FallbackRemoteDesktopItem(IRdpConnectionManager rdpConnectionsManager) + public FallbackRemoteDesktopItem(IRdpConnectionsManager rdpConnectionsManager) : base(new OpenRemoteDesktopCommand(string.Empty), Resources.remotedesktop_title) { _rdpConnectionsManager = rdpConnectionsManager; diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs similarity index 90% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs index 4d04126dfb..2968e15c9c 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRDPConnectionManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs @@ -7,7 +7,7 @@ using Microsoft.CmdPal.Ext.RemoteDesktop.Commands; namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; -internal interface IRdpConnectionManager +internal interface IRdpConnectionsManager { IReadOnlyCollection Connections { get; } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs similarity index 96% rename from src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs rename to src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs index b9b7d62e1a..6e357e27d9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RDPConnectionsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/RdpConnectionsManager.cs @@ -13,7 +13,7 @@ using Microsoft.Win32; namespace Microsoft.CmdPal.Ext.RemoteDesktop.Helper; -internal class RDPConnectionsManager : IRdpConnectionManager +internal class RdpConnectionsManager : IRdpConnectionsManager { private readonly ISettingsInterface _settingsManager; private readonly ConnectionListItem _openRdpCommandListItem = new(string.Empty); @@ -23,7 +23,7 @@ internal class RDPConnectionsManager : IRdpConnectionManager private const int MinutesToCache = 1; private DateTime? _connectionsLastLoaded; - public RDPConnectionsManager(ISettingsInterface settingsManager) + public RdpConnectionsManager(ISettingsInterface settingsManager) { _settingsManager = settingsManager; _settingsManager.Settings.SettingsChanged += (s, e) => diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs index 42a0165277..c6ba2b3187 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Pages/RemoteDesktopListPage.cs @@ -12,16 +12,16 @@ namespace Microsoft.CmdPal.Ext.RemoteDesktop.Pages; internal sealed partial class RemoteDesktopListPage : ListPage { - private readonly IRdpConnectionManager _rdpConnectionManager; + private readonly IRdpConnectionsManager _rdpConnectionsManager; - public RemoteDesktopListPage(IRdpConnectionManager rdpConnectionManager) + public RemoteDesktopListPage(IRdpConnectionsManager rdpConnectionsManager) { Icon = Icons.RDPIcon; Name = Resources.remotedesktop_title; Id = "com.microsoft.cmdpal.builtin.remotedesktop"; - _rdpConnectionManager = rdpConnectionManager; + _rdpConnectionsManager = rdpConnectionsManager; } - public override IListItem[] GetItems() => _rdpConnectionManager.Connections.ToArray(); + public override IListItem[] GetItems() => _rdpConnectionsManager.Connections.ToArray(); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs index eefd467d5b..1ce307b301 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/RemoteDesktopCommandProvider.cs @@ -24,7 +24,7 @@ public partial class RemoteDesktopCommandProvider : CommandProvider Icon = Icons.RDPIcon; var settingsManager = new SettingsManager(); - var rdpConnectionsManager = new RDPConnectionsManager(settingsManager); + var rdpConnectionsManager = new RdpConnectionsManager(settingsManager); var listPage = new RemoteDesktopListPage(rdpConnectionsManager); fallback = new FallbackRemoteDesktopItem(rdpConnectionsManager); From 1ba5a258e93247a30a78519cceb5311cbf1e2441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Sun, 30 Nov 2025 01:59:58 +0100 Subject: [PATCH 08/10] CmdPal: Add custom search engine option to Web Search extension (#43941) ## Summary of the Pull Request This PR allows user to customize a search query in Command Palette's Web Search built-in extension. This will also solve a problem with some browser that doesn't handle argument in form "? " as it will allow user to specify the complete URI. - Introduces a new text box in Web Search extension settings for specifying a custom search engine URI - If the text box is non-empty, the provided URI is used for queries - If left empty, the extension defaults to previous behavior, sending queries in the format "? query" ## Pictures? Pictures! image ## PR Checklist - [x] Closes: #43940 - [x] Closes: #42867 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../MockSettingsInterface.cs | 2 ++ .../Commands/SearchWebCommand.cs | 29 ++++++++++++++++++- .../Helpers/ISettingsInterface.cs | 2 ++ .../Helpers/SettingsManager.cs | 12 ++++++++ .../Properties/Resources.Designer.cs | 18 ++++++++++++ .../Properties/Resources.resx | 6 ++++ 6 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs index a51db9165d..1e5f0533c7 100644 --- a/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.WebSearch.UnitTests/MockSettingsInterface.cs @@ -18,6 +18,8 @@ public class MockSettingsInterface : ISettingsInterface public int HistoryItemCount { get; set; } + public string CustomSearchUri { get; } + public IReadOnlyList HistoryItems => _historyItems; public MockSettingsInterface(int historyItemCount = 0, bool globalIfUri = true, List mockHistory = null) diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs index 98921aa8ae..1f5fdb8598 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Commands/SearchWebCommand.cs @@ -28,12 +28,15 @@ internal sealed partial class SearchWebCommand : InvokableCommand public override CommandResult Invoke() { - if (!_browserInfoService.Open($"? {Arguments}")) + var uri = BuildUri(); + + if (!_browserInfoService.Open(uri)) { // TODO GH# 138 --> actually display feedback from the extension somewhere. return CommandResult.KeepOpen(); } + // remember only the query, not the full URI if (_settingsManager.HistoryItemCount != 0) { _settingsManager.AddHistoryItem(new HistoryItem(Arguments, DateTime.Now)); @@ -41,4 +44,28 @@ internal sealed partial class SearchWebCommand : InvokableCommand return CommandResult.Dismiss(); } + + private string BuildUri() + { + if (string.IsNullOrWhiteSpace(_settingsManager.CustomSearchUri)) + { + return $"? " + Arguments; + } + + // if the custom search URI contains query placeholder, replace it with the actual query + // otherwise append the query to the end of the URI + // support {query}, %query% or %s as placeholder + var placeholderVariants = new[] { "{query}", "%query%", "%s" }; + foreach (var placeholder in placeholderVariants) + { + if (_settingsManager.CustomSearchUri.Contains(placeholder, StringComparison.OrdinalIgnoreCase)) + { + return _settingsManager.CustomSearchUri.Replace(placeholder, Uri.EscapeDataString(Arguments), StringComparison.OrdinalIgnoreCase); + } + } + + // is this too smart? + var separator = _settingsManager.CustomSearchUri.Contains('?') ? '&' : '?'; + return $"{_settingsManager.CustomSearchUri}{separator}q={Uri.EscapeDataString(Arguments)}"; + } } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs index cbbb86bbd2..cff6f8919d 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/ISettingsInterface.cs @@ -18,5 +18,7 @@ public interface ISettingsInterface public IReadOnlyList HistoryItems { get; } + string CustomSearchUri { get; } + public void AddHistoryItem(HistoryItem historyItem); } diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs index 8cc7734368..0af19e14c2 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Helpers/SettingsManager.cs @@ -41,6 +41,15 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface Resources.plugin_global_if_uri, false); + private readonly TextSetting _customSearchUri = new( + Namespaced(nameof(CustomSearchUri)), + Resources.plugin_custom_search_uri, + Resources.plugin_custom_search_uri, + string.Empty) + { + Placeholder = Resources.plugin_custom_search_uri_placeholder, + }; + private readonly ChoiceSetSetting _historyItemCount = new( Namespaced(HistoryItemCountLegacySettingsKey), Resources.plugin_history_item_count, @@ -51,6 +60,8 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface public int HistoryItemCount => int.TryParse(_historyItemCount.Value, out var value) && value >= 0 ? value : 0; + public string CustomSearchUri => _customSearchUri.Value ?? string.Empty; + public IReadOnlyList HistoryItems => _history.HistoryItems; public SettingsManager() @@ -59,6 +70,7 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface Settings.Add(_globalIfURI); Settings.Add(_historyItemCount); + Settings.Add(_customSearchUri); LoadSettings(); diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs index 090b54375d..9db0a40cac 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.Designer.cs @@ -159,6 +159,24 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Properties { } } + /// + /// Looks up a localized string similar to Custom search engine URL. + /// + public static string plugin_custom_search_uri { + get { + return ResourceManager.GetString("plugin_custom_search_uri", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Use {query} or %s as the search query placeholder; e.g. https://www.bing.com/search?q={query}. + /// + public static string plugin_custom_search_uri_placeholder { + get { + return ResourceManager.GetString("plugin_custom_search_uri_placeholder", resourceCulture); + } + } + /// /// Looks up a localized string similar to Searches the web with your default search engine. /// diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx index 032f89e7a5..c7f424c6f9 100644 --- a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.WebSearch/Properties/Resources.resx @@ -187,4 +187,10 @@ default browser + + Custom search engine URL + + + Use {query} or %s as the search query placeholder; e.g. https://www.bing.com/search?q={query} + \ No newline at end of file From 4d3c223402bb037851b76f77dc143793eda88981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Pol=C3=A1=C5=A1ek?= Date: Mon, 1 Dec 2025 02:32:30 +0100 Subject: [PATCH 09/10] CmdPal: Fix grid views (#43991) ## Summary of the Pull Request This PR fixes the crash due to binding to a trimmed property. For this it converts runtime bindings on GridView to use `{x:Bind}` so this issue can't happen in the future. - Fixes a crash related to the `Visibility` property in gallery/grid views when trimmed during AOT builds. - Fixes ShowTitle and ShowSubtitle properties, they are now taken into account in a view. - Improves UI layout, removes some margins and maches the corner radius of the item contaienr with the item content in the gallery view. - Refactores gallery and grid views to move logic from the view to the view model so we can x:Bind to them. - Replaces `{Binding}` with `{x:Bind}` to improve performance and enable compile-time binding validation. - Properties related to grids are splatted on to the common `IGridPropertiesViewModel` interface. Subclassing would add extra overhead without substential benefit. - Adds new samples to showcase various grid view configurations. ## Pictures? Pictures! A) Gallery view (with title and subtitle) image B) Gallery view (only title) image C) Gallery view (no title or subtitle) image D) Small icons image E) Medium icons (with labels) image F) Medium icons (no labels) image ## PR Checklist - [x] Closes: #43973 - [ ] **Communication:** I've discussed this with core contributors already. If the work hasn't been agreed, this work might be rejected - [ ] **Tests:** Added/updated and all pass - [ ] **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: #xxx ## Detailed Description of the Pull Request / Additional comments ## Validation Steps Performed --- .../GalleryGridPropertiesViewModel.cs | 8 +- .../IGridPropertiesViewModel.cs | 4 + .../ListItemViewModel.cs | 96 ++++++-- .../ListViewModel.cs | 45 ++-- .../MediumGridPropertiesViewModel.cs | 6 +- .../SmallGridPropertiesViewModel.cs | 4 + .../GridItemContainerStyleSelector.cs | 31 +++ .../Converters/GridItemTemplateSelector.cs | 21 +- .../ExtViews/ListPage.xaml | 231 ++++++++++++++---- .../Helpers/BindTransformers.cs | 3 + .../Pages/SampleGalleryListPage.cs | 7 - .../Pages/SampleGridsListPage.cs | 59 +++++ .../SamplePagesExtension/SamplesListPage.cs | 4 +- 13 files changed, 404 insertions(+), 115 deletions(-) create mode 100644 src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs create mode 100644 src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs index 9d360109dc..85d85838ac 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/GalleryGridPropertiesViewModel.cs @@ -11,15 +11,15 @@ public class GalleryGridPropertiesViewModel : IGridPropertiesViewModel { private readonly ExtensionObject _model; + public bool ShowTitle { get; private set; } + + public bool ShowSubtitle { get; private set; } + public GalleryGridPropertiesViewModel(IGalleryGridLayout galleryGridLayout) { _model = new(galleryGridLayout); } - public bool ShowTitle { get; set; } - - public bool ShowSubtitle { get; set; } - public void InitializeProperties() { var model = _model.Unsafe; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs index ea3d6027d3..ec14bbdde3 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/IGridPropertiesViewModel.cs @@ -6,5 +6,9 @@ namespace Microsoft.CmdPal.Core.ViewModels; public interface IGridPropertiesViewModel { + bool ShowTitle { get; } + + bool ShowSubtitle { get; } + void InitializeProperties(); } diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs index 8850d5778b..a400374e3c 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/ListItemViewModel.cs @@ -10,10 +10,9 @@ using Microsoft.CommandPalette.Extensions.Toolkit; namespace Microsoft.CmdPal.Core.ViewModels; -public partial class ListItemViewModel(IListItem model, WeakReference context) - : CommandItemViewModel(new(model), context) +public partial class ListItemViewModel : CommandItemViewModel { - public new ExtensionObject Model { get; } = new(model); + public new ExtensionObject Model { get; } public List? Tags { get; set; } @@ -32,6 +31,40 @@ public partial class ListItemViewModel(IListItem model, WeakReference context) + : base(new(model), context) + { + Model = new ExtensionObject(model); + } + public override void InitializeProperties() { if (IsInitialized) @@ -93,16 +126,18 @@ public partial class ListItemViewModel(IListItem model, WeakReference FilteredItems { get; set; } = []; + public ObservableCollection FilteredItems { get; } = []; public FiltersViewModel? Filters { get; set; } @@ -224,6 +223,8 @@ public partial class ListViewModel : PageViewModel, IDisposable // TODO we can probably further optimize this by also keeping a // HashSet of every ExtensionObject we currently have, and only // building new viewmodels for the ones we haven't already built. + var showsTitle = GridProperties?.ShowTitle ?? true; + var showsSubtitle = GridProperties?.ShowSubtitle ?? true; foreach (var item in newItems) { // Check for cancellation during item processing @@ -237,6 +238,8 @@ public partial class ListViewModel : PageViewModel, IDisposable // If an item fails to load, silently ignore it. if (viewModel.SafeFastInit()) { + viewModel.LayoutShowsTitle = showsTitle; + viewModel.LayoutShowsSubtitle = showsSubtitle; newViewModels.Add(viewModel); } } @@ -583,6 +586,7 @@ public partial class ListViewModel : PageViewModel, IDisposable GridProperties = LoadGridPropertiesViewModel(model.GridProperties); GridProperties?.InitializeProperties(); UpdateProperty(nameof(GridProperties)); + ApplyLayoutToItems(); ShowDetails = model.ShowDetails; UpdateProperty(nameof(ShowDetails)); @@ -608,22 +612,15 @@ public partial class ListViewModel : PageViewModel, IDisposable model.ItemsChanged += Model_ItemsChanged; } - private IGridPropertiesViewModel? LoadGridPropertiesViewModel(IGridProperties? gridProperties) + private static IGridPropertiesViewModel? LoadGridPropertiesViewModel(IGridProperties? gridProperties) { - if (gridProperties is IMediumGridLayout mediumGridLayout) + return gridProperties switch { - return new MediumGridPropertiesViewModel(mediumGridLayout); - } - else if (gridProperties is IGalleryGridLayout galleryGridLayout) - { - return new GalleryGridPropertiesViewModel(galleryGridLayout); - } - else if (gridProperties is ISmallGridLayout smallGridLayout) - { - return new SmallGridPropertiesViewModel(smallGridLayout); - } - - return null; + IMediumGridLayout mediumGridLayout => new MediumGridPropertiesViewModel(mediumGridLayout), + IGalleryGridLayout galleryGridLayout => new GalleryGridPropertiesViewModel(galleryGridLayout), + ISmallGridLayout smallGridLayout => new SmallGridPropertiesViewModel(smallGridLayout), + _ => null, + }; } public void LoadMoreIfNeeded() @@ -685,6 +682,7 @@ public partial class ListViewModel : PageViewModel, IDisposable GridProperties = LoadGridPropertiesViewModel(model.GridProperties); GridProperties?.InitializeProperties(); UpdateProperty(nameof(IsGridView)); + ApplyLayoutToItems(); break; case nameof(ShowDetails): ShowDetails = model.ShowDetails; @@ -730,6 +728,21 @@ public partial class ListViewModel : PageViewModel, IDisposable }); } + private void ApplyLayoutToItems() + { + lock (_listLock) + { + var showsTitle = GridProperties?.ShowTitle ?? true; + var showsSubtitle = GridProperties?.ShowSubtitle ?? true; + + foreach (var item in Items) + { + item.LayoutShowsTitle = showsTitle; + item.LayoutShowsSubtitle = showsSubtitle; + } + } + } + public void Dispose() { GC.SuppressFinalize(this); diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs index 57150bbd0d..2059e1547b 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/MediumGridPropertiesViewModel.cs @@ -11,13 +11,15 @@ public class MediumGridPropertiesViewModel : IGridPropertiesViewModel { private readonly ExtensionObject _model; + public bool ShowTitle { get; private set; } + + public bool ShowSubtitle => false; + public MediumGridPropertiesViewModel(IMediumGridLayout mediumGridLayout) { _model = new(mediumGridLayout); } - public bool ShowTitle { get; set; } - public void InitializeProperties() { var model = _model.Unsafe; diff --git a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs index 03f43fe8e5..3cc51d780e 100644 --- a/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs +++ b/src/modules/cmdpal/Core/Microsoft.CmdPal.Core.ViewModels/SmallGridPropertiesViewModel.cs @@ -11,6 +11,10 @@ public class SmallGridPropertiesViewModel : IGridPropertiesViewModel { private readonly ExtensionObject _model; + public bool ShowTitle => false; + + public bool ShowSubtitle => false; + public SmallGridPropertiesViewModel(ISmallGridLayout smallGridLayout) { _model = new(smallGridLayout); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs new file mode 100644 index 0000000000..5d45592ef1 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemContainerStyleSelector.cs @@ -0,0 +1,31 @@ +// 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.CmdPal.Core.ViewModels; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; + +namespace Microsoft.CmdPal.UI; + +internal sealed partial class GridItemContainerStyleSelector : StyleSelector +{ + public IGridPropertiesViewModel? GridProperties { get; set; } + + public Style? Small { get; set; } + + public Style? Medium { get; set; } + + public Style? Gallery { get; set; } + + protected override Style? SelectStyleCore(object item, DependencyObject container) + { + return GridProperties switch + { + SmallGridPropertiesViewModel => Small, + MediumGridPropertiesViewModel => Medium, + GalleryGridPropertiesViewModel => Gallery, + _ => Medium, + }; + } +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs index df20e70b02..c93470e3e3 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Converters/GridItemTemplateSelector.cs @@ -20,21 +20,12 @@ internal sealed partial class GridItemTemplateSelector : DataTemplateSelector protected override DataTemplate? SelectTemplateCore(object item, DependencyObject dependencyObject) { - DataTemplate? dataTemplate = Medium; - - if (GridProperties is SmallGridPropertiesViewModel) + return GridProperties switch { - dataTemplate = Small; - } - else if (GridProperties is MediumGridPropertiesViewModel) - { - dataTemplate = Medium; - } - else if (GridProperties is GalleryGridPropertiesViewModel) - { - dataTemplate = Gallery; - } - - return dataTemplate; + SmallGridPropertiesViewModel => Small, + MediumGridPropertiesViewModel => Medium, + GalleryGridPropertiesViewModel => Gallery, + _ => Medium, + }; } } diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml index 676a676f95..7cf720198a 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/ExtViews/ListPage.xaml @@ -5,33 +5,151 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cmdpalUI="using:Microsoft.CmdPal.UI" xmlns:controls="using:CommunityToolkit.WinUI.Controls" - xmlns:converters="using:CommunityToolkit.WinUI.Converters" xmlns:coreViewModels="using:Microsoft.CmdPal.Core.ViewModels" xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:help="using:Microsoft.CmdPal.UI.Helpers" - xmlns:local="using:Microsoft.CmdPal.UI" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - xmlns:ui="using:CommunityToolkit.WinUI" - xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels" x:Name="PageRoot" Background="Transparent" DataContext="{x:Bind ViewModel, Mode=OneWay}" mc:Ignorable="d"> - - - - - + + 6 + 4 + 4 + 8 + 8 + + + + + + + Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" /> + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> - - + Padding="8" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" + CornerRadius="{StaticResource MediumGridViewItemCornerRadius}" + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> + + + + - - + Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" /> + @@ -193,11 +316,11 @@ Padding="0" HorizontalAlignment="Center" VerticalAlignment="Center" - AutomationProperties.Name="{x:Bind Title}" + AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" BorderThickness="0" - CornerRadius="4" + CornerRadius="{StaticResource GalleryGridViewItemRadius}" Orientation="Vertical" - ToolTipService.ToolTip="{x:Bind Title}"> + ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}"> - - - + CornerRadius="{StaticResource GalleryGridViewItemRadius}"> @@ -222,35 +341,39 @@ - + + TextWrapping="NoWrap" + Visibility="{x:Bind ShowTitle, Mode=OneWay}" /> + TextWrapping="NoWrap" + Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" /> @@ -295,6 +418,7 @@ IsDoubleTapEnabled="True" IsItemClickEnabled="True" ItemClick="Items_ItemClick" + ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}" ItemTemplateSelector="{StaticResource GridItemTemplateSelector}" ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}" RightTapped="Items_RightTapped" @@ -302,6 +426,7 @@ + diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs index 24d2ef47a6..012e8dc789 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI/Helpers/BindTransformers.cs @@ -15,4 +15,7 @@ internal static class BindTransformers public static Visibility EmptyOrWhitespaceToCollapsed(string? input) => string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible; + + public static Visibility VisibleWhenAny(bool value1, bool value2) + => (value1 || value2) ? Visibility.Visible : Visibility.Collapsed; } diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs index 3a80d180e0..2f6fba7089 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGalleryListPage.cs @@ -9,13 +9,6 @@ namespace SamplePagesExtension; internal sealed partial class SampleGalleryListPage : ListPage { - public SampleGalleryListPage() - { - Icon = new IconInfo("\uE7C5"); - Name = "Sample Gallery List Page"; - GridProperties = new GalleryGridLayout(); - } - public override IListItem[] GetItems() { return [ diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs new file mode 100644 index 0000000000..05b604c912 --- /dev/null +++ b/src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleGridsListPage.cs @@ -0,0 +1,59 @@ +// 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.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; + +namespace SamplePagesExtension; + +internal sealed partial class SampleGridsListPage : ListPage +{ + private readonly IListItem[] _items = + [ + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = true } }) + { + Title = "Gallery list page (title and subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false } }) + { + Title = "Gallery list page (title, no subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new GalleryGridLayout { ShowTitle = false, ShowSubtitle = false } }) + { + Title = "Gallery list page (no title, no subtitle)", + Subtitle = "A sample gallery list page with images", + Icon = IconHelpers.FromRelativePath("Assets/Images/Swirls.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new SmallGridLayout() }) + { + Title = "Small grid list page", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new MediumGridLayout { ShowTitle = true } }) + { + Title = "Medium grid (with title)", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + }, + new ListItem(new SampleGalleryListPage { GridProperties = new MediumGridLayout { ShowTitle = false } }) + { + Title = "Medium grid (hidden title)", + Subtitle = "A sample grid list page with text items", + Icon = IconHelpers.FromRelativePath("Assets/Images/Win-Digital.png"), + } + ]; + + public SampleGridsListPage() + { + Icon = new IconInfo("\uE7C5"); + Name = "Grid and gallery lists"; + } + + public override IListItem[] GetItems() => _items; +} diff --git a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs index 254dbf3eb9..73ef1815d4 100644 --- a/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs +++ b/src/modules/cmdpal/ext/SamplePagesExtension/SamplesListPage.cs @@ -34,9 +34,9 @@ public partial class SamplesListPage : ListPage Title = "Dynamic List Page Command", Subtitle = "Changes the list of items in response to the typed query", }, - new ListItem(new SampleGalleryListPage()) + new ListItem(new SampleGridsListPage()) { - Title = "Gallery List Page Command", + Title = "Grid views and galleries", Subtitle = "Displays items as a gallery", }, new ListItem(new OnLoadPage()) From f510be4c5384662fe0e15e6563d93e9ab7e420dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 22:29:21 +0800 Subject: [PATCH 10/10] Build(deps): Bump actions/checkout from 3 to 6 (#43838) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 6.
Release notes

Sourced from actions/checkout's releases.

v6.0.0

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5.0.0...v6.0.0

v6-beta

What's Changed

Updated persist-credentials to store the credentials under $RUNNER_TEMP instead of directly in the local git config.

This requires a minimum Actions Runner version of v2.329.0 to access the persisted credentials for Docker container action scenarios.

v5.0.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v5...v5.0.1

v5.0.0

What's Changed

⚠️ Minimum Compatible Runner Version

v2.327.1
Release Notes

Make sure your runner is updated to this version or newer to use this release.

Full Changelog: https://github.com/actions/checkout/compare/v4...v5.0.0

v4.3.1

What's Changed

Full Changelog: https://github.com/actions/checkout/compare/v4...v4.3.1

v4.3.0

What's Changed

... (truncated)

Changelog

Sourced from actions/checkout's changelog.

Changelog

V6.0.0

V5.0.1

V5.0.0

V4.3.1

V4.3.0

v4.2.2

v4.2.1

v4.2.0

v4.1.7

v4.1.6

v4.1.5

... (truncated)

Commits

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=actions/checkout&package-manager=github_actions&previous-version=3&new-version=6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. [//]: # (dependabot-automerge-start) [//]: # (dependabot-automerge-end) ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/dependency-review.yml | 2 +- .github/workflows/manual-batch-issue-deduplication.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 0db3dc6595..be07e7facc 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -21,6 +21,6 @@ jobs: runs-on: ubuntu-latest steps: - name: 'Checkout Repository' - uses: actions/checkout@v4 + uses: actions/checkout@v6 - name: 'Dependency Review' uses: actions/dependency-review-action@v4 \ No newline at end of file diff --git a/.github/workflows/manual-batch-issue-deduplication.yml b/.github/workflows/manual-batch-issue-deduplication.yml index d02dc2e282..616e2244f0 100644 --- a/.github/workflows/manual-batch-issue-deduplication.yml +++ b/.github/workflows/manual-batch-issue-deduplication.yml @@ -27,7 +27,7 @@ jobs: issue: ${{ fromJson(github.event.inputs.issue_numbers) }} steps: - name: Checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: Run GenAI Issue Deduplicator uses: pelikhan/action-genai-issue-dedup@v0