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).'