diff --git a/.github/actions/spell-check/expect.txt b/.github/actions/spell-check/expect.txt index e2abef0344..f080d44d6a 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 @@ -56,6 +56,7 @@ ANull AOC aocfnapldcnfbofgmbbllojgocaelgdd AOklab +aot APARTMENTTHREADED APeriod apicontract @@ -97,8 +98,8 @@ ASSOCSTR ASYNCWINDOWPLACEMENT ASYNCWINDOWPOS atl -ATX ATRIOX +ATX aumid authenticode AUTOBUDDY @@ -117,10 +118,10 @@ azman azureaiinference azureinference azureopenai +backticks bbwe BCIE bck -backticks BESTEFFORT bezelled bhid @@ -148,8 +149,8 @@ bmi BNumber BODGY BOklab -Bootstrappers BOOTSTRAPPERINSTALLFOLDER +Bootstrappers BOTTOMALIGN boxmodel BPBF @@ -176,17 +177,16 @@ BYPOSITION CALCRECT CALG callbackptr -cabstr calpwstr -caub Cangjie CANRENAME -Carlseibert Canvascustomlayout CAPTUREBLT CAPTURECHANGED CARETBLINKING +Carlseibert CAtl +caub CBN cch CCHDEVICENAME @@ -206,11 +206,9 @@ changecursor CHILDACTIVATE CHILDWINDOW CHOOSEFONT -CIBUILD cidl CIELCh cim -claude CImage cla CLASSDC @@ -264,7 +262,6 @@ CONFIGW CONFLICTINGMODIFIERKEY CONFLICTINGMODIFIERSHORTCUT CONOUT -coreclr constexpr contentdialog contentfiles @@ -276,6 +273,7 @@ copiedcolorrepresentation coppied copyable COPYPEN +coreclr COREWINDOW Corpor cotaskmem @@ -284,18 +282,18 @@ countof covrun cpcontrols cph -cppcoreguidelines cplusplus CPower +cppcoreguidelines cpptools cppvsdbg cppwinrt createdump -creativecommons CREATEPROCESS CREATESCHEDULEDTASK CREATESTRUCT CREATEWINDOWFAILED +creativecommons CRECT CRH critsec @@ -331,7 +329,6 @@ CYSCREEN CYSMICON CYVIRTUALSCREEN Czechia -cziplib Dac dacl DAffine @@ -355,9 +352,7 @@ Deact debugbreak decryptor Dedup -dfx Deduplicator -Deeplink DEFAULTBOOTSTRAPPERINSTALLFOLDER DEFAULTCOLOR DEFAULTFLAGS @@ -404,7 +399,6 @@ DISPLAYFREQUENCY displayname DISPLAYORIENTATION divyan -djwsxzxb Dlg DLGFRAME DLGMODALFRAME @@ -417,7 +411,6 @@ DONTVALIDATEPATH dotnet downsampled downsampling -Downsampled downscale DPICHANGED DPIs @@ -531,7 +524,6 @@ EXTRINSICPROPERTIES eyetracker FANCYZONESDRAWLAYOUTTEST FANCYZONESEDITOR -FNumber FARPROC fdx fesf @@ -563,8 +555,8 @@ FIXEDSYS flac flyouts FMask -foundrylocal fmtid +FNumber FOF FOFX FOLDERID @@ -575,6 +567,7 @@ FORCEMINIMIZE FORMATDLGORD formatetc FORPARSING +foundrylocal FRAMECHANGED frm FROMTOUCH @@ -593,13 +586,13 @@ gdi gdiplus GDIPVER GDISCALED +geolocator GETCLIENTAREAANIMATION GETCURSEL GETDESKWALLPAPER GETDLGCODE GETDPISCALEDSIZE getfilesiginforedist -geolocator GETHOTKEY GETICON GETLBTEXT @@ -610,11 +603,12 @@ GETSCREENSAVERRUNNING GETSECKEY GETSTICKYKEYS GETTEXTLENGTH -GIFs -gitmodules GHND +gitmodules GMEM GNumber +googleai +googlegemini gpedit gpo GPOCA @@ -631,8 +625,6 @@ GValue gwl GWLP GWLSTYLE -googleai -googlegemini hangeul Hanzi Hardlines @@ -743,9 +735,7 @@ IDCANCEL IDD idk idl -IIM idlist -ifd IDOK IDOn IDR @@ -754,15 +744,16 @@ ietf IEXPLORE IFACEMETHOD IFACEMETHODIMP +ifd IGNOREUNKNOWN IGo iid +IIM Iindex Ijwhost ILD IMAGEHLP IMAGERESIZERCONTEXTMENU -IPTC IMAGERESIZEREXT imageresizerinput imageresizersettings @@ -798,7 +789,6 @@ INSTALLFOLDERTOPREVIOUSINSTALLFOLDER INSTALLLOCATION INSTALLMESSAGE INSTALLPROPERTY -installscopeperuser INSTALLSTARTMENUSHORTCUT INSTALLSTATE Inste @@ -811,6 +801,7 @@ invokecommand ipcmanager IPREVIEW ipreviewhandlervisualssetfont +IPTC irow irprops isbi @@ -854,15 +845,14 @@ keyvault KILLFOCUS killrunner kmph -ksa kvp Kybd LARGEICON lastcodeanalysissucceeded LASTEXITCODE LAYOUTRTL -LCh lbl +LCh lcid LCIDTo lcl @@ -878,10 +868,10 @@ LExit lhwnd LIBFUZZER LIBID +lightswitch LIMITSIZE LIMITTEXT lindex -lightswitch linkid LINKOVERLAY LINQTo @@ -892,6 +882,7 @@ LLKH llkhf LMEM LMENU +lng LOADFROMFILE LOBYTE localappdata @@ -901,17 +892,14 @@ LOCATIONCHANGE LOCKTYPE LOGFONT LOGFONTW -logon -lon LOGMSG +logon LOGPIXELSX LOGPIXELSY -lng lon longdate LONGNAMES lowlevel -lquadrant LOWORD lparam LPBITMAPINFOHEADER @@ -945,6 +933,7 @@ lpv LPW lpwcx lpwndpl +lquadrant LReader LRESULT LSTATUS @@ -971,6 +960,7 @@ MAKELONG MAKELPARAM makepri MAKEWPARAM +Malware manifestdependency MAPPEDTOSAMEKEY MAPTOSAMESHORTCUT @@ -993,8 +983,8 @@ MENUITEMINFO MENUITEMINFOW MERGECOPY MERGEPAINT -Metadatas metadatamatters +Metadatas metafile mfc Mgmt @@ -1040,9 +1030,6 @@ mousepointer mouseutils MOVESIZEEND MOVESIZESTART -muxx -muxxc -muxxh MRM MRT mru @@ -1071,10 +1058,14 @@ msrc msstore msvcp MT +mstsc MTND MULTIPLEUSE multizone muxc +muxx +muxxc +muxxh MVPs mvvm MVVMTK @@ -1157,7 +1148,6 @@ nonstd NOOWNERZORDER NOPARENTNOTIFY NOPREFIX -NPU NOREDIRECTIONBITMAP NOREDRAW NOREMOVE @@ -1186,6 +1176,7 @@ nowarn NOZORDER NPH npmjs +NPU NResize NTAPI ntdll @@ -1210,15 +1201,17 @@ oldpath oldtheme oleaut OLECHAR +ollama onebranch +onnx OOBEUI openas opencode OPENFILENAME +openrdp opensource openxmlformats ollama -Olllama onnx OPTIMIZEFORINVOKE ORPHANEDDIALOGTITLE @@ -1292,6 +1285,7 @@ pguid phbm phbmp phicon +Photoshop phwnd pici pidl @@ -1314,7 +1308,6 @@ pnid PNMLINK Poc Podcasts -Photoshop POINTERID POINTERUPDATE Pokedex @@ -1409,10 +1402,9 @@ pwsz pwtd QDC qit -QNN -Qualcomm QITAB QITABENT +QNN qoi Quarternary QUERYENDSESSION @@ -1422,8 +1414,8 @@ quickaccent QUNS RAII RAlt -RAquadrant randi +RAquadrant rasterization Rasterize RAWINPUTDEVICE @@ -1433,6 +1425,8 @@ RAWPATH rbhid rclsid RCZOOMIT +remotedesktop +rdp RDW READMODE READOBJECTS @@ -1450,9 +1444,7 @@ regfile REGISTERCLASSFAILED REGISTRYHEADER REGISTRYPREVIEWEXT -registryroot regkey -regroot regsvr REINSTALLMODE releaseblog @@ -1505,7 +1497,6 @@ rstringalpha rstringdigit rtb RTLREADING -rtm runas rundll rungameid @@ -1562,8 +1553,8 @@ SETRULES SETSCREENSAVEACTIVE SETSTICKYKEYS SETTEXT -settingscard SETTINGCHANGE +settingscard SETTINGSCHANGED settingsheader settingshotkeycontrol @@ -1708,6 +1699,7 @@ stringtable stringval Strm strret +STRSAFE stscanf sttngs Stubless @@ -1719,7 +1711,6 @@ sublang SUBMODULEUPDATE subresource Superbar -suntimes sut svchost SVGIn @@ -1753,7 +1744,6 @@ SYSTEMMODAL SYSTEMTIME TARG TARGETAPPHEADER -TARGETDIR targetentrypoint TARGETHEADER targetver @@ -1783,10 +1773,10 @@ textextractor TEXTINCLUDE tfopen tgz +THEMECHANGED themeresources THH THICKFRAME -THEMECHANGED THISCOMPONENT throughs TILEDWINDOW @@ -1883,7 +1873,6 @@ USEINSTALLERFORTEST USESHOWWINDOW USESTDHANDLES USRDLL -utm UType uuidv uwp @@ -1956,11 +1945,11 @@ Wca WCE wcex WClass +WCRAPI wcsicmp wcsncpy wcsnicmp WCT -WCRAPI WDA wdm wdp @@ -1988,6 +1977,7 @@ WINDOWPLACEMENT WINDOWPOSCHANGED WINDOWPOSCHANGING WINDOWSBUILDNUMBER +windowsml windowssearch windowssettings WINDOWSTYLES @@ -2003,9 +1993,8 @@ Winhook WINL winlogon winmd -WINNT -windowsml winml +WINNT winres winrt winsdk @@ -2067,20 +2056,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 +2081,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 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 diff --git a/Directory.Packages.props b/Directory.Packages.props index d66d35968a..428977262b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,4 +1,4 @@ - + true true @@ -71,74 +71,74 @@ TODO: in Common.Dotnet.CsWinRT.props, on upgrade, verify RemoveCsWinRTPackageAnalyzer is no longer needed. 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 0447aadd42..fd9c91e259 100644 --- a/PowerToys.sln +++ b/PowerToys.sln @@ -832,6 +832,9 @@ EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Awake.ModuleServices", "src\modules\awake\Awake.ModuleServices\Awake.ModuleServices.csproj", "{2141FF78-5F51-ED6B-E11B-C7079CCA1456}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ColorPicker.ModuleServices", "src\modules\colorPicker\ColorPicker.ModuleServices\ColorPicker.ModuleServices.csproj", "{C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}" +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 @@ -5291,6 +5294,22 @@ Global {C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x64.Build.0 = Release|x64 {C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x86.ActiveCfg = Release|x64 {C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52}.Release|x86.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 @@ -5626,6 +5645,8 @@ Global {094E65D5-585E-4898-B465-97A47CD44380} = {1AFB6476-670D-4E80-A464-657E01DFF482} {2141FF78-5F51-ED6B-E11B-C7079CCA1456} = {127F38E0-40AA-4594-B955-5616BF206882} {C9F3F4D1-A457-2A6E-DE4E-ED0DDB8DDA52} = {1D78B84B-CA39-406C-98F4-71F7EC266CC0} + {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/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] | 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/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.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.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/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/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/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/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 b80ea69b86..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; @@ -58,6 +59,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 +70,7 @@ public sealed partial class MainWindow : WindowEx, private DesktopAcrylicController? _acrylicController; private SystemBackdropConfiguration? _configurationSource; + private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan; private WindowPosition _currentWindowPosition = new(); @@ -75,6 +78,9 @@ public sealed partial class MainWindow : WindowEx, { InitializeComponent(); + _autoGoHomeTimer = new DispatcherTimer(); + _autoGoHomeTimer.Tick += OnAutoGoHomeTimerOnTick; + _hwnd = new HWND(WinRT.Interop.WindowNative.GetWindowHandle(this).ToInt32()); unsafe @@ -108,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"); @@ -125,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", () => @@ -141,6 +147,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) @@ -151,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() @@ -220,6 +242,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 +304,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 +560,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() @@ -620,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 02c93fd135..2be530a1e8 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) @@ -26,10 +27,10 @@ - + true + --> true @@ -38,7 +39,7 @@ true - + true Never $(OutputPath)\AppPackages\Microsoft.CmdPal.UI_$(Version)_Test\ @@ -67,6 +68,7 @@ + @@ -119,6 +121,7 @@ + @@ -168,6 +171,9 @@ MSBuild:Compile + + MSBuild:Compile + $(DefaultXamlRuntime) @@ -235,4 +241,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/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 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)] + "…"; + } + } +} 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..75c63c5ae0 --- /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, IRdpConnectionsManager 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..be1c961523 --- /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 : IRdpConnectionsManager +{ + 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..a8a48ba79c --- /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/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/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/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.RemoteDesktop/Assets/RemoteDesktop.png b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png new file mode 100644 index 0000000000..52d97dbfe9 Binary files /dev/null and b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.png differ diff --git a/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg new file mode 100644 index 0000000000..e683f4d040 --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Assets/RemoteDesktop.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + 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..8579566b77 --- /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 IRdpConnectionsManager _rdpConnectionsManager; + + public FallbackRemoteDesktopItem(IRdpConnectionsManager 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/IRdpConnectionsManager.cs b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.cs new file mode 100644 index 0000000000..2968e15c9c --- /dev/null +++ b/src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.RemoteDesktop/Helper/IRdpConnectionsManager.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 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 new file mode 100644 index 0000000000..6e357e27d9 --- /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 : IRdpConnectionsManager +{ + 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..c6ba2b3187 --- /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 IRdpConnectionsManager _rdpConnectionsManager; + + public RemoteDesktopListPage(IRdpConnectionsManager rdpConnectionsManager) + { + Icon = Icons.RDPIcon; + Name = Resources.remotedesktop_title; + Id = "com.microsoft.cmdpal.builtin.remotedesktop"; + + _rdpConnectionsManager = rdpConnectionsManager; + } + + public override IListItem[] GetItems() => _rdpConnectionsManager.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..1ce307b301 --- /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(); + } +} 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..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 @@ -4,36 +4,39 @@ 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}")) + 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/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/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/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/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/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..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 @@ -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. /// @@ -150,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 5a406eca60..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 @@ -184,4 +184,13 @@ Open URL + + 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 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)); } } 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())