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())