diff --git a/src/modules/PowerScripts/PowerScripts.Host/Program.cs b/src/modules/PowerScripts/PowerScripts.Host/Program.cs
index e2c13a7032..76ac0437e8 100644
--- a/src/modules/PowerScripts/PowerScripts.Host/Program.cs
+++ b/src/modules/PowerScripts/PowerScripts.Host/Program.cs
@@ -46,6 +46,7 @@ internal static class Program
"run" => RunScript(registry, positional, options),
"kbm" => RunKbm(registry, positional, options.ContainsKey("json")),
"set-extensions" => RunSetExtensions(registry, positional, options),
+ "shell-menu" => RunShellMenu(registry, options),
"shell-install" => ShellRegistration.Install(registry, Environment.ProcessPath ?? "PowerScripts.Host.exe"),
"shell-uninstall" => ShellRegistration.Uninstall(registry),
"-h" or "--help" or "help" => PrintUsage(),
@@ -216,6 +217,28 @@ internal static class Program
return 0;
}
+ ///
+ /// Emits the file scripts that match a right-clicked selection as tab-separated
+ /// <id>\t<name> lines (one per script). This is the machine-readable feed the
+ /// Windows 11 modern context-menu handler (IExplorerCommand) consumes to build its submenu; a
+ /// line-based format keeps the native handler free of a JSON parser.
+ ///
+ private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary> options)
+ {
+ var files = options.TryGetValue("files", out var f) ? f : new List();
+ if (files.Count == 0)
+ {
+ return 0;
+ }
+
+ foreach (var script in registry.FileScriptsForSelection(files))
+ {
+ Console.WriteLine($"{script.Id}\t{script.Name}");
+ }
+
+ return 0;
+ }
+
///
/// Rewrites a file script's declared input extensions in its manifest.json. This is the write
/// side of the Settings "trigger on these file types" editor; the user picks the extensions and
@@ -330,6 +353,7 @@ internal static class Program
Console.WriteLine(" run [--files ...] [--set name=value ...] [--root ]");
Console.WriteLine(" kbm [--json] [--root ] (Keyboard Manager 'Run Program' mapping)");
Console.WriteLine(" set-extensions --ext <.md .txt ...> (set a file script's trigger extensions)");
+ Console.WriteLine(" shell-menu --files ... (tab-separated id/name of matching file scripts)");
Console.WriteLine(" shell-install [--root ] (register the Explorer right-click submenu)");
Console.WriteLine(" shell-uninstall [--root ] (remove the Explorer right-click submenu)");
return 0;
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore b/src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
new file mode 100644
index 0000000000..4f124d5c4b
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
@@ -0,0 +1,9 @@
+# Native handler build artifacts
+*.dll
+*.lib
+*.exp
+*.obj
+*.pdb
+*.ilk
+# Host publish output used by register.ps1
+hostpublish/
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/AppxManifest.xml b/src/modules/PowerScripts/PowerScriptsContextMenu/AppxManifest.xml
new file mode 100644
index 0000000000..4d0ed210ed
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/AppxManifest.xml
@@ -0,0 +1,51 @@
+
+
+
+
+ PowerToys PowerScripts Context Menu
+ Microsoft
+ Assets\storelogo.png
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd b/src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
new file mode 100644
index 0000000000..8da2ccc2b2
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
@@ -0,0 +1,15 @@
+@echo off
+rem Builds the PowerScripts Windows 11 context-menu handler DLL (self-contained, no PowerToys deps).
+setlocal
+set "VCVARS=C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
+if not exist "%VCVARS%" (
+ echo Could not find vcvars64.bat at "%VCVARS%". Edit build.cmd to point at your VS install.
+ exit /b 1
+)
+call "%VCVARS%" >nul || exit /b 1
+cd /d "%~dp0"
+cl /nologo /std:c++17 /EHsc /O2 /MT /DUNICODE /D_UNICODE /LD dllmain.cpp ^
+ /Fe:PowerToys.PowerScriptsContextMenu.dll ^
+ /link /DEF:dll.def shlwapi.lib runtimeobject.lib ole32.lib || exit /b 1
+echo Built PowerToys.PowerScriptsContextMenu.dll
+endlocal
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/dll.def b/src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
new file mode 100644
index 0000000000..1a1567a913
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
@@ -0,0 +1,4 @@
+EXPORTS
+ DllCanUnloadNow PRIVATE
+ DllGetClassObject PRIVATE
+ DllGetActivationFactory PRIVATE
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp b/src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
new file mode 100644
index 0000000000..c420146560
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
@@ -0,0 +1,388 @@
+// PowerScripts Windows 11 modern context-menu handler.
+//
+// A self-contained IExplorerCommand COM server (no PowerToys common dependencies). It surfaces a
+// top-level "PowerScript" entry with a dynamic submenu of the file scripts that match the current
+// selection. The actual matching/running logic lives in PowerScripts.Host.exe (deployed next to
+// this DLL); the handler is a thin shell that:
+// * GetState -> runs "Host shell-menu --files ", caches the id/name lines, hides itself
+// when nothing matches.
+// * EnumSubCommands -> turns each cached line into a submenu item.
+// * Invoke (item) -> runs "Host run --files ".
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+
+using namespace Microsoft::WRL;
+
+namespace
+{
+ HMODULE g_hModule = nullptr;
+ long g_refModule = 0;
+
+ // Full path to PowerScripts.Host.exe, assumed to sit next to this DLL.
+ std::wstring FindHostExe()
+ {
+ wchar_t path[MAX_PATH] = {};
+ GetModuleFileNameW(g_hModule, path, ARRAYSIZE(path));
+ std::wstring dir(path);
+ const size_t slash = dir.find_last_of(L"\\/");
+ if (slash != std::wstring::npos)
+ {
+ dir.erase(slash + 1);
+ }
+ return dir + L"PowerScripts.Host.exe";
+ }
+
+ // Extracts the filesystem paths from a shell selection.
+ std::vector ExtractPaths(IShellItemArray* selection)
+ {
+ std::vector result;
+ if (selection == nullptr)
+ {
+ return result;
+ }
+
+ DWORD count = 0;
+ if (FAILED(selection->GetCount(&count)))
+ {
+ return result;
+ }
+
+ for (DWORD i = 0; i < count; ++i)
+ {
+ ComPtr item;
+ if (FAILED(selection->GetItemAt(i, &item)))
+ {
+ continue;
+ }
+
+ PWSTR pszPath = nullptr;
+ if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &pszPath)) && pszPath != nullptr)
+ {
+ result.emplace_back(pszPath);
+ CoTaskMemFree(pszPath);
+ }
+ }
+
+ return result;
+ }
+
+ // Quotes a single command-line argument.
+ std::wstring Quote(const std::wstring& value)
+ {
+ return L"\"" + value + L"\"";
+ }
+
+ std::wstring BuildFilesArguments(const std::vector& files)
+ {
+ std::wstring args;
+ for (const auto& file : files)
+ {
+ args += L" " + Quote(file);
+ }
+ return args;
+ }
+
+ // Runs a Host command and returns its stdout. Used only for the (small) shell-menu listing.
+ std::wstring RunHostCapture(const std::wstring& arguments)
+ {
+ std::wstring output;
+
+ SECURITY_ATTRIBUTES sa = {};
+ sa.nLength = sizeof(sa);
+ sa.bInheritHandle = TRUE;
+
+ HANDLE readPipe = nullptr;
+ HANDLE writePipe = nullptr;
+ if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
+ {
+ return output;
+ }
+ SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
+
+ std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
+ si.wShowWindow = SW_HIDE;
+ si.hStdOutput = writePipe;
+ si.hStdError = writePipe;
+
+ PROCESS_INFORMATION pi = {};
+ std::vector mutableCmd(commandLine.begin(), commandLine.end());
+ mutableCmd.push_back(L'\0');
+
+ if (!CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
+ {
+ CloseHandle(readPipe);
+ CloseHandle(writePipe);
+ return output;
+ }
+
+ CloseHandle(writePipe);
+
+ char buffer[4096];
+ DWORD read = 0;
+ std::string raw;
+ while (ReadFile(readPipe, buffer, sizeof(buffer), &read, nullptr) && read > 0)
+ {
+ raw.append(buffer, read);
+ }
+
+ CloseHandle(readPipe);
+ WaitForSingleObject(pi.hProcess, 15000);
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+
+ if (!raw.empty())
+ {
+ const int needed = MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast(raw.size()), nullptr, 0);
+ if (needed > 0)
+ {
+ output.resize(needed);
+ MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast(raw.size()), output.data(), needed);
+ }
+ }
+
+ return output;
+ }
+
+ // Runs a Host command fire-and-forget (used to actually execute a script).
+ void RunHostDetached(const std::wstring& arguments)
+ {
+ std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
+
+ STARTUPINFOW si = {};
+ si.cb = sizeof(si);
+ si.dwFlags = STARTF_USESHOWWINDOW;
+ si.wShowWindow = SW_HIDE;
+
+ PROCESS_INFORMATION pi = {};
+ std::vector mutableCmd(commandLine.begin(), commandLine.end());
+ mutableCmd.push_back(L'\0');
+
+ if (CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
+ {
+ CloseHandle(pi.hProcess);
+ CloseHandle(pi.hThread);
+ }
+ }
+
+ struct ScriptEntry
+ {
+ std::wstring Id;
+ std::wstring Name;
+ };
+
+ // Parses "id\tname" lines into entries.
+ std::vector ParseMenu(const std::wstring& text)
+ {
+ std::vector entries;
+ size_t start = 0;
+ while (start < text.size())
+ {
+ size_t end = text.find(L'\n', start);
+ std::wstring line = (end == std::wstring::npos) ? text.substr(start) : text.substr(start, end - start);
+ start = (end == std::wstring::npos) ? text.size() : end + 1;
+
+ if (!line.empty() && line.back() == L'\r')
+ {
+ line.pop_back();
+ }
+ if (line.empty())
+ {
+ continue;
+ }
+
+ const size_t tab = line.find(L'\t');
+ if (tab == std::wstring::npos)
+ {
+ continue;
+ }
+
+ ScriptEntry entry;
+ entry.Id = line.substr(0, tab);
+ entry.Name = line.substr(tab + 1);
+ if (!entry.Id.empty())
+ {
+ entries.push_back(std::move(entry));
+ }
+ }
+ return entries;
+ }
+}
+
+// A single submenu item: "Convert Markdown to Text", etc.
+class PowerScriptSubCommand : public RuntimeClass, IExplorerCommand>
+{
+public:
+ PowerScriptSubCommand(std::wstring id, std::wstring name, std::vector files) :
+ m_id(std::move(id)), m_name(std::move(name)), m_files(std::move(files))
+ {
+ }
+
+ IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(m_name.c_str(), name); }
+ IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
+ IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
+ IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
+ IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* state) override { *state = ECS_ENABLED; return S_OK; }
+ IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_DEFAULT; return S_OK; }
+ IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override { *enumerator = nullptr; return E_NOTIMPL; }
+
+ IFACEMETHODIMP Invoke(IShellItemArray* selection, IBindCtx*) override
+ {
+ std::vector files = m_files;
+ if (files.empty())
+ {
+ files = ExtractPaths(selection);
+ }
+
+ RunHostDetached(L"run " + m_id + L" --files" + BuildFilesArguments(files));
+ return S_OK;
+ }
+
+private:
+ std::wstring m_id;
+ std::wstring m_name;
+ std::vector m_files;
+};
+
+// IEnumExplorerCommand over the submenu items.
+class PowerScriptEnum : public RuntimeClass, IEnumExplorerCommand>
+{
+public:
+ explicit PowerScriptEnum(std::vector> commands) :
+ m_commands(std::move(commands))
+ {
+ }
+
+ IFACEMETHODIMP Next(ULONG count, IExplorerCommand** commands, ULONG* fetched) override
+ {
+ ULONG produced = 0;
+ for (; produced < count && m_index < m_commands.size(); ++produced, ++m_index)
+ {
+ m_commands[m_index].CopyTo(&commands[produced]);
+ }
+ if (fetched != nullptr)
+ {
+ *fetched = produced;
+ }
+ return (produced == count) ? S_OK : S_FALSE;
+ }
+
+ IFACEMETHODIMP Skip(ULONG count) override
+ {
+ m_index += count;
+ return (m_index <= m_commands.size()) ? S_OK : S_FALSE;
+ }
+
+ IFACEMETHODIMP Reset() override
+ {
+ m_index = 0;
+ return S_OK;
+ }
+
+ IFACEMETHODIMP Clone(IEnumExplorerCommand** out) override
+ {
+ *out = nullptr;
+ return E_NOTIMPL;
+ }
+
+private:
+ std::vector> m_commands;
+ size_t m_index = 0;
+};
+
+// Top-level "PowerScript" command with a dynamic submenu.
+class __declspec(uuid("9FF7C126-9562-4F16-A6FB-9622B26E0D62")) PowerScriptCommand :
+ public RuntimeClass, IExplorerCommand, IObjectWithSite>
+{
+public:
+ IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(L"PowerScript", name); }
+ IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
+ IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
+ IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
+
+ // Called before EnumSubCommands on the same instance; we use it to compute (and cache) the
+ // matching scripts and to hide the entry when nothing matches.
+ IFACEMETHODIMP GetState(IShellItemArray* selection, BOOL, EXPCMDSTATE* state) override
+ {
+ m_files = ExtractPaths(selection);
+ m_entries.clear();
+
+ if (!m_files.empty())
+ {
+ const std::wstring output = RunHostCapture(L"shell-menu --files" + BuildFilesArguments(m_files));
+ m_entries = ParseMenu(output);
+ }
+
+ *state = m_entries.empty() ? ECS_HIDDEN : ECS_ENABLED;
+ return S_OK;
+ }
+
+ IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_HASSUBCOMMANDS; return S_OK; }
+
+ IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override
+ {
+ *enumerator = nullptr;
+
+ std::vector> commands;
+ for (const auto& entry : m_entries)
+ {
+ commands.push_back(Make(entry.Id, entry.Name, m_files));
+ }
+
+ auto enumObject = Make(std::move(commands));
+ return enumObject.CopyTo(enumerator);
+ }
+
+ IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override { return S_OK; }
+
+ // IObjectWithSite
+ IFACEMETHODIMP SetSite(IUnknown* site) override { m_site = site; return S_OK; }
+ IFACEMETHODIMP GetSite(REFIID riid, void** ppv) override { return m_site.CopyTo(riid, ppv); }
+
+private:
+ ComPtr m_site;
+ std::vector m_files;
+ std::vector m_entries;
+};
+
+CoCreatableClass(PowerScriptCommand);
+
+STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
+{
+ return Module::GetModule().GetActivationFactory(activatableClassId, factory);
+}
+
+STDAPI DllCanUnloadNow()
+{
+ return (Module::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
+}
+
+STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
+{
+ return Module::GetModule().GetClassObject(rclsid, riid, ppv);
+}
+
+BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID)
+{
+ switch (reason)
+ {
+ case DLL_PROCESS_ATTACH:
+ g_hModule = hModule;
+ DisableThreadLibraryCalls(hModule);
+ break;
+ default:
+ break;
+ }
+ return TRUE;
+}
diff --git a/src/modules/PowerScripts/PowerScriptsContextMenu/register.ps1 b/src/modules/PowerScripts/PowerScriptsContextMenu/register.ps1
new file mode 100644
index 0000000000..271dd814a1
--- /dev/null
+++ b/src/modules/PowerScripts/PowerScriptsContextMenu/register.ps1
@@ -0,0 +1,75 @@
+<#
+.SYNOPSIS
+ Builds and registers the PowerScripts Windows 11 modern context-menu handler as an
+ unsigned sparse (loose-file) MSIX package. Requires Developer Mode.
+
+.DESCRIPTION
+ 1. Builds the native handler DLL (build.cmd).
+ 2. Publishes PowerScripts.Host.exe (framework-dependent) next to the DLL.
+ 3. Copies the manifest + logo assets into a deploy folder.
+ 4. Registers the package in place via Add-AppxPackage -Register.
+
+ Run register.ps1 -Unregister to remove it.
+#>
+[CmdletBinding()]
+param(
+ [switch]$Unregister,
+ [ValidateSet('Debug', 'Release')]
+ [string]$Configuration = 'Debug'
+)
+
+$ErrorActionPreference = 'Stop'
+$PackageName = 'Microsoft.PowerToys.PowerScriptsContextMenu'
+$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
+$deployDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\PowerScriptsContextMenu'
+
+if ($Unregister)
+{
+ $pkg = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
+ if ($pkg)
+ {
+ Remove-AppxPackage -Package $pkg.PackageFullName
+ Write-Host "Unregistered $($pkg.PackageFullName)"
+ }
+ else
+ {
+ Write-Host "Package $PackageName is not registered."
+ }
+ return
+}
+
+Write-Host '== Building handler DLL =='
+& cmd /c "`"$here\build.cmd`""
+if ($LASTEXITCODE -ne 0) { throw 'DLL build failed.' }
+
+Write-Host '== Publishing PowerScripts.Host =='
+$hostProj = Join-Path $here '..\PowerScripts.Host\PowerScripts.Host.csproj'
+$hostPublish = Join-Path $here 'hostpublish'
+& dotnet publish $hostProj -c $Configuration -o $hostPublish --nologo | Out-Null
+if ($LASTEXITCODE -ne 0) { throw 'Host publish failed.' }
+
+Write-Host '== Staging deploy folder =='
+# Re-register cleanly: remove any prior registration before overwriting files.
+$existing = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
+if ($existing) { Remove-AppxPackage -Package $existing.PackageFullName }
+
+if (Test-Path $deployDir) { Remove-Item $deployDir -Recurse -Force }
+New-Item -ItemType Directory -Force -Path $deployDir | Out-Null
+New-Item -ItemType Directory -Force -Path (Join-Path $deployDir 'Assets') | Out-Null
+
+Copy-Item (Join-Path $here 'PowerToys.PowerScriptsContextMenu.dll') $deployDir -Force
+Copy-Item (Join-Path $here 'AppxManifest.xml') $deployDir -Force
+Copy-Item (Join-Path $hostPublish '*') $deployDir -Recurse -Force
+
+# Reuse the ImageResizer context-menu logo assets for the required tile slots.
+$assetSrc = Join-Path $here '..\..\..\modules\imageresizer\ImageResizerContextMenu\Assets\ImageResizer'
+foreach ($asset in 'storelogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png', 'Wide310x150Logo.png', 'LargeTile.png', 'SmallTile.png', 'SplashScreen.png')
+{
+ Copy-Item (Join-Path $assetSrc $asset) (Join-Path $deployDir 'Assets') -Force
+}
+
+Write-Host '== Registering package =='
+Add-AppxPackage -Register (Join-Path $deployDir 'AppxManifest.xml')
+
+Write-Host "Registered. Deploy folder: $deployDir"
+Write-Host 'Right-click a matching file (e.g. a .md) to see the PowerScript submenu (restart Explorer if needed).'