Compare commits

..

17 Commits

Author SHA1 Message Date
Clint Rutkas
2c5437577f AltWindowCycle: address PR review feedback
- Re-add VREDRAW to spell-check allowlist; the module uses CS_VREDRAW
  (and it is referenced across the repo), so removing it would reintroduce
  spell-check failures.
- enable(): set m_enabled from InitializeAltWindowCycle() result and only
  emit the enabled telemetry on success, so a failed init no longer reports
  the module as enabled-but-non-functional.
- EnabledModules: note that AltWindowCycle defaults to off, matching the
  convention used by the surrounding fields.
- ShellPage: use the module-specific AltWindowCycle icon in the nav item
  instead of the generic Windowing & Layouts group icon.
- ShowOverlayWindow(): guard the CreateRoundRectRgn/SetWindowRgn pair so a
  null region is skipped and the HRGN is freed if SetWindowRgn fails.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 10:17:34 -07:00
Niels Laute
d920da1f86 AltWindowCycle: use public DWM system-backdrop API for acrylic (#48875)
Replaces the undocumented `SetWindowCompositionAttribute` /
`ACCENT_POLICY` acrylic path with the public, documented
`DWMWA_SYSTEMBACKDROP_TYPE` (`DWMSBT_TRANSIENTWINDOW`) on Windows 11
22H2+.

On older builds (Windows 10, Windows 11 21H2) it falls back to an opaque
tinted fill painted by the thumb host window proc, so no private
composition API is used anywhere.

### Changes
- Remove `ACCENT_POLICY` / `WINDOWCOMPOSITIONATTRIBDATA` /
`SetWindowCompositionAttribute` usage.
- Add `SupportsSystemBackdrop()` OS-version gate (build >= 22621 via
`RtlGetVersion`).
- Add `ApplyBackdrop()` + `ThumbHostProc` for the public acrylic path
and the opaque fallback fill.
- Guard `DWMWA_SYSTEMBACKDROP_TYPE` / `DWMSBT_TRANSIENTWINDOW` for older
SDKs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 20:09:09 +02:00
Niels Laute
44904c1884 AltWindowCycle: refine overlay card visuals
Theme-aware card background (dark mode uses a darker, mostly-opaque tile), 1px CardStroke border, header height 48 with vertically centered 16px icon/title, corner radius 10, preview inset by the card stroke so the border stays visible, robust opaque-backing + rounded-bottom punch so the DWM thumbnail reads as rounded at the bottom, and focus-ring contrast shadow that is white in light mode / black in dark mode. Unit-test layout expectations updated accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 13:40:26 +02:00
Niels Laute
43ad3b3f4b Merge branch 'main' into crutkas/alt-window-cycle 2026-06-25 13:37:06 +02:00
Niels Laute
aca897962c Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-25 13:36:31 +02:00
Niels Laute
0004026137 Merge branch 'main' into crutkas/alt-window-cycle 2026-06-24 10:03:17 +02:00
copilot-swe-agent[bot]
1f64356551 Fix AltWindowCycle spelling warnings 2026-06-06 22:36:01 +00:00
Clint Rutkas
6800dd2abb AltWindowCycle: production cleanup + UWP/icon fixes
Production-cleanup pass on the shipped module:
- Remove POC-only AWC_* diagnostic env-var code paths.
- Remove the translucent debug backdrop host window and RenderBackdrop().
- Remove the magenta debug brush and unused helpers.
- Cache window titles at overlay-show time instead of re-querying every
  selection repaint; single production acrylic + DWM-thumbnail render path.

Code-review fixes:
- UWP/packaged apps all share ApplicationFrameHost.exe as their owning
  process, so they were grouped together. Add RealProcessImagePath() to
  resolve the hosted Windows.UI.Core.CoreWindow child's real process and
  group each packaged app on its own.
- Legacy 1-bit-mask icons carry no per-pixel alpha, so pure-black opaque
  pixels were dropped as transparent. Render such icons over both black and
  white backgrounds and treat pixels identical on both as opaque, preserving
  black icon detail.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 22:34:03 -07:00
Clint Rutkas
fefd70ae84 Clean up AltWindowCycle PowerToys integration
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 18:09:22 -07:00
Clint Rutkas
aae941e7c6 Restore required spelling allow entries
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 17:25:42 -07:00
Clint Rutkas
d133113b86 Fix AltWindowCycle spelling metadata
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 16:45:10 -07:00
Clint Rutkas
a1200a1321 Add AltWindowCycle logic unit tests
Extract pure overlay layout, crop, wrap, and first-hotkey selection logic into a shared helper and cover it with native MSTests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 16:40:55 -07:00
Clint Rutkas
6870ad33f3 Improve AltWindowCycle overlay responsiveness
Show the overlay immediately, avoid stale monitor-size frames, and use an inset live-thumbnail viewport so DWM previews remain fast without exposing bad card corners.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 16:25:50 -07:00
Clint Rutkas
e354ae18fc Use DWM thumbnails for AltWindowCycle overlay
Keep thumbnail previews compositor-owned for visual fidelity and remove the rejected app-callback snapshot path. The overlay still runs on the dedicated module UI thread and preserves the acrylic chrome.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 13:34:34 -07:00
Clint Rutkas
9aa225d009 Add AltWindowCycle acrylic overlay
Port the Alt-Tab-style overlay into the native PowerToys module with a dedicated UI thread, acrylic backdrop, and same-surface preview snapshots for real alpha-masked rounded corners.

Keep the runner hotkey callback lightweight by posting to the module UI thread, and harden snapshot capture with timeout-bounded WM_PRINTCLIENT rendering.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-05 13:04:40 -07:00
copilot-swe-agent[bot]
be4a5c250e Add ROOTOWNER to spell-check allow list 2026-06-03 18:47:48 +00:00
Clint Rutkas
b40fe31f11 New module: AltWindowCycle
Adds AltWindowCycle, an in-proc module that cycles the top-level windows
of the currently-focused application, mimicking macOS Cmd+.

- Native C++ module DLL (modeled on FindMyMouse) loaded in-proc by the runner
- Two configurable hotkeys: Alt+` (next) and Shift+Alt+` (previous)
- C# Settings page under Windowing & Layouts, Dashboard entry, GPO support
- Settings persisted to AltWindowCycle/settings.json

Note: ships with placeholder icon/module images (copied from AlwaysOnTop)
that still need real assets.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-03 10:44:04 -07:00
91 changed files with 3043 additions and 3798 deletions

View File

@@ -330,6 +330,7 @@ xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
ROOTOWNER
# MRU lists
CACHEWRITE

View File

@@ -10,6 +10,7 @@ ACCESSDENIED
ACCESSTOKEN
acfs
ACIE
ACRYLICBLURBEHIND
acrt
ACTIVATEAPP
ACTIVATEOPTIONS
@@ -93,6 +94,7 @@ ASSOCSTR
ASYNCWINDOWPLACEMENT
ASYNCWINDOWPOS
atl
AWC
ATRIOX
aumid
AUO
@@ -460,6 +462,7 @@ dwm
dwmapi
DWMCOLORIZATIONCOLORCHANGED
DWMCOMPOSITIONCHANGED
DWMSBT
DWMNCRENDERINGCHANGED
Dwmp
DWMSENDICONICLIVEPREVIEWBITMAP
@@ -1378,6 +1381,7 @@ pnid
PNMLINK
Poc
Podcasts
PARGB
POINTERID
POINTERUPDATE
Pokedex
@@ -1842,6 +1846,7 @@ SYSLIB
sysmenu
systemai
SYSTEMAPPS
SYSTEMBACKDROP
SYSTEMMODAL
systemroot
SYSTEMTIME
@@ -1916,6 +1921,7 @@ tracerpt
trackbar
trafficmanager
transicc
TRANSIENTWINDOW
transitioning
TRAYMOUSEMESSAGE
triaging
@@ -2056,7 +2062,7 @@ WANTNUKEWARNING
WANTPALM
WASDK
wbem
Wca
wca
WCE
wcex
WCRAPI
@@ -2082,6 +2088,7 @@ winapi
winappsdk
windir
WINDOWCREATED
WINDOWCOMPOSITIONATTRIBDATA
WINDOWDESTROYED
windowedge
WINDOWINFO

View File

@@ -154,6 +154,10 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Folder Name="/modules/AltWindowCycle/">
<Project Path="src/modules/AltWindowCycle/AltWindowCycle.vcxproj" Id="4d1d41c7-e22b-4a8b-8805-660a100e1d79" />
<Project Path="src/modules/AltWindowCycle/UnitTests/AltWindowCycleUnitTests.vcxproj" Id="9ebe5c3a-4a37-477e-9176-07c7cf0eba6e" />
</Folder>
<Folder Name="/modules/AlwaysOnTop/">
<Project Path="src/modules/alwaysontop/AlwaysOnTop/AlwaysOnTop.vcxproj" Id="1dc3be92-ce89-43fb-8110-9c043a2fe7a2" />
<Project Path="src/modules/alwaysontop/AlwaysOnTopModuleInterface/AlwaysOnTopModuleInterface.vcxproj" Id="48a0a19e-a0be-4256-acf8-cc3b80291af9" />
@@ -1147,4 +1151,3 @@
<Project Path="tools/project_template/ModuleTemplate/ModuleTemplateCompileTest.vcxproj" Id="64a80062-4d8b-4229-8a38-dfa1d7497749" />
</Solution>

View File

@@ -8,6 +8,10 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredAlwaysOnTopEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredAltWindowCycleEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredAltWindowCycleEnabledValue());
}
GpoRuleConfigured GPOWrapper::GetConfiguredAwakeEnabledValue()
{
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredAwakeEnabledValue());

View File

@@ -8,6 +8,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
{
GPOWrapper() = default;
static GpoRuleConfigured GetConfiguredAlwaysOnTopEnabledValue();
static GpoRuleConfigured GetConfiguredAltWindowCycleEnabledValue();
static GpoRuleConfigured GetConfiguredAwakeEnabledValue();
static GpoRuleConfigured GetConfiguredCmdNotFoundEnabledValue();
static GpoRuleConfigured GetConfiguredCmdPalEnabledValue();

View File

@@ -12,6 +12,7 @@ namespace PowerToys
};
[default_interface] static runtimeclass GPOWrapper {
static GpoRuleConfigured GetConfiguredAlwaysOnTopEnabledValue();
static GpoRuleConfigured GetConfiguredAltWindowCycleEnabledValue();
static GpoRuleConfigured GetConfiguredAwakeEnabledValue();
static GpoRuleConfigured GetConfiguredCmdNotFoundEnabledValue();
static GpoRuleConfigured GetConfiguredCmdPalEnabledValue();

View File

@@ -7,6 +7,7 @@ namespace ManagedCommon
public enum ModuleType
{
AdvancedPaste,
AltWindowCycle,
AlwaysOnTop,
Awake,
ColorPicker,
@@ -38,7 +39,6 @@ namespace ManagedCommon
Workspaces,
GrabAndMove,
ZoomIt,
PowerScripts,
GeneralSettings,
}
}

View File

@@ -72,6 +72,7 @@ namespace powertoys_gpo
const std::wstring POLICY_CONFIGURE_ENABLED_QOI_THUMBNAILS = L"ConfigureEnabledUtilityFileExplorerQOIThumbnails";
const std::wstring POLICY_CONFIGURE_ENABLED_NEWPLUS = L"ConfigureEnabledUtilityNewPlus";
const std::wstring POLICY_CONFIGURE_ENABLED_WORKSPACES = L"ConfigureEnabledUtilityWorkspaces";
const std::wstring POLICY_CONFIGURE_ENABLED_ALT_WINDOW_CYCLE = L"ConfigureEnabledUtilityAltWindowCycle";
// The registry value names for PowerToys installer and update policies.
const std::wstring POLICY_DISABLE_PER_USER_INSTALLATION = L"PerUserInstallationDisabled";
@@ -288,6 +289,11 @@ namespace powertoys_gpo
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_ALWAYS_ON_TOP);
}
inline gpo_rule_configured_t getConfiguredAltWindowCycleEnabledValue()
{
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_ALT_WINDOW_CYCLE);
}
inline gpo_rule_configured_t getConfiguredAwakeEnabledValue()
{
return getUtilityEnabledValue(POLICY_CONFIGURE_ENABLED_AWAKE);

View File

@@ -91,6 +91,16 @@
<decimal value="0" />
</disabledValue>
</policy>
<policy name="ConfigureEnabledUtilityAltWindowCycle" class="Both" displayName="$(string.ConfigureEnabledUtilityAltWindowCycle)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityAltWindowCycle">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_99_0" />
<enabledValue>
<decimal value="1" />
</enabledValue>
<disabledValue>
<decimal value="0" />
</disabledValue>
</policy>
<policy name="ConfigureEnabledUtilityAwake" class="Both" displayName="$(string.ConfigureEnabledUtilityAwake)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityAwake">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_64_0" />

View File

@@ -244,6 +244,7 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureAllUtilityGlobalEnabledState">Configure global utility enabled state</string>
<string id="ConfigureEnabledUtilityAdvancedPaste">Advanced Paste: Configure enabled state</string>
<string id="ConfigureEnabledUtilityAlwaysOnTop">Always On Top: Configure enabled state</string>
<string id="ConfigureEnabledUtilityAltWindowCycle">AltWindowCycle: Configure enabled state</string>
<string id="ConfigureEnabledUtilityAwake">Awake: Configure enabled state</string>
<string id="ConfigureEnabledUtilityColorPicker">Color Picker: Configure enabled state</string>
<string id="ConfigureEnabledUtilityCmdNotFound">Command Not Found: Configure enabled state</string>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
#pragma once
// Starts the dedicated UI thread that owns all overlay windows and the Switcher
// state machine. Must be called from the PowerToys module enable() path.
// Safe to call multiple times (idempotent).
bool InitializeAltWindowCycle(HINSTANCE hinst);
// Stops the UI thread and destroys all overlay resources. Safe to call when not
// initialized (idempotent). Called from disable() and destroy().
void ShutdownAltWindowCycle();
// Called from on_hotkey() on the runner thread. Does a cheap window-count check
// and posts to the UI thread. `holdModifiers` is an AltWindowCycleLogic modifier
// mask that controls which modifier release commits the visible cycle.
// Returns false (do not swallow) if the focused app has fewer than 2 cycle
// candidates and the overlay is not already active.
bool HandleAltWindowCycleHotkey(bool forward, unsigned int holdModifiers);
// Instant (no-overlay) cycle helper, kept for internal use.
void CycleForegroundAppWindows(bool forward);

View File

@@ -0,0 +1,40 @@
#include <windows.h>
#include "resource.h"
#include "../../common/version/version.h"
#define APSTUDIO_READONLY_SYMBOLS
#include "winres.h"
#undef APSTUDIO_READONLY_SYMBOLS
1 VERSIONINFO
FILEVERSION FILE_VERSION
PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK VS_FFI_FILEFLAGSMASK
#ifdef _DEBUG
FILEFLAGS VS_FF_DEBUG
#else
FILEFLAGS 0x0L
#endif
FILEOS VOS_NT_WINDOWS32
FILETYPE VFT_DLL
FILESUBTYPE VFT2_UNKNOWN
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0" // US English (0x0409), Unicode (0x04B0) charset
BEGIN
VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", FILE_DESCRIPTION
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", INTERNAL_NAME
VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", ORIGINAL_FILENAME
VALUE "ProductName", PRODUCT_NAME
VALUE "ProductVersion", PRODUCT_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200 // US English (0x0409), Unicode (1200) charset
END
END

View File

@@ -0,0 +1,130 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" />
<PropertyGroup Label="Globals">
<VCProjectVersion>15.0</VCProjectVersion>
<ProjectGuid>{4d1d41c7-e22b-4a8b-8805-660a100e1d79}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>AltWindowCycle</RootNamespace>
<ProjectName>AltWindowCycle</ProjectName>
</PropertyGroup>
<Import Project="$(RepoRoot)deps\spdlog.props" />
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir>
<TargetName>PowerToys.AltWindowCycle</TargetName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<LinkIncremental>true</LinkIncremental>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<Optimization>Disabled</Optimization>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
<LanguageStandard>stdcpplatest</LanguageStandard>
<PrecompiledHeader>Use</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile>
<AdditionalDependencies>dwmapi.lib;gdi32.lib;gdiplus.lib;shcore.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<Optimization>MaxSpeed</Optimization>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
<LanguageStandard>stdcpplatest</LanguageStandard>
<PrecompiledHeader>Use</PrecompiledHeader>
<PrecompiledHeaderFile>pch.h</PrecompiledHeaderFile>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile>
<AdditionalDependencies>dwmapi.lib;gdi32.lib;gdiplus.lib;shcore.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>$(RepoRoot)src\;$(RepoRoot)src\modules;$(RepoRoot)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="AltWindowCycle.h" />
<ClInclude Include="AltWindowCycleLogic.h" />
<ClInclude Include="pch.h" />
<ClInclude Include="trace.h" />
<ClInclude Include="resource.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="dllmain.cpp" />
<ClCompile Include="AltWindowCycle.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="trace.cpp" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="AltWindowCycle.rc" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="$(RepoRoot)src\common\logger\logger.vcxproj">
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
</ProjectReference>
<ProjectReference Include="$(RepoRoot)src\common\SettingsAPI\SettingsAPI.vcxproj">
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" />
</ImportGroup>
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project>

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx;hm;inl;inc;ipp;xsd</Extensions>
</Filter>
<Filter Include="Resource Files">
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="dllmain.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="AltWindowCycle.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="pch.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="trace.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="AltWindowCycle.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="AltWindowCycleLogic.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="pch.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="trace.h">
<Filter>Header Files</Filter>
</ClInclude>
<ClInclude Include="resource.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="AltWindowCycle.rc">
<Filter>Resource Files</Filter>
</ResourceCompile>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,181 @@
#pragma once
#ifndef WIN32_LEAN_AND_MEAN
#define WIN32_LEAN_AND_MEAN
#endif
#include <windows.h>
#include <algorithm>
namespace AltWindowCycleLogic
{
constexpr int DefaultMaxColumns = 6;
constexpr unsigned int ModifierAlt = 1u << 0;
constexpr unsigned int ModifierCtrl = 1u << 1;
constexpr unsigned int ModifierShift = 1u << 2;
constexpr unsigned int ModifierWin = 1u << 3;
constexpr unsigned int AllModifiers = ModifierAlt | ModifierCtrl | ModifierShift | ModifierWin;
struct OverlayLayout
{
double scale = 1.0;
int pad = 0;
int gap = 0;
int tileW = 0;
int tileH = 0;
int headerH = 0;
int previewH = 0;
int inner = 0;
int radius = 0;
int cardTrimBottom = 0;
int iconSize = 0;
int cols = 1;
int rows = 0;
int panelX = 0;
int panelY = 0;
int panelW = 0;
int panelH = 0;
};
enum class FirstHotkeyAction
{
Ignore,
ShowOverlay,
};
struct FirstHotkeyResult
{
int selected = 0;
FirstHotkeyAction action = FirstHotkeyAction::Ignore;
};
constexpr int ScaledValue(double scale, int value)
{
return static_cast<int>(value * scale + 0.5);
}
constexpr unsigned int StableHoldModifiers(unsigned int configuredModifiers)
{
const unsigned int sanitized = configuredModifiers & AllModifiers;
const unsigned int nonShiftModifiers = sanitized & ~ModifierShift;
return nonShiftModifiers != 0 ? nonShiftModifiers : sanitized;
}
constexpr bool AreRequiredModifiersDown(unsigned int requiredModifiers, unsigned int downModifiers)
{
return requiredModifiers != 0 && (downModifiers & requiredModifiers) == requiredModifiers;
}
inline int WrapIndex(int index, int count)
{
if (count <= 0)
{
return 0;
}
return ((index % count) + count) % count;
}
inline FirstHotkeyResult BeginCycle(int currentIndex, int windowCount, bool forward)
{
if (windowCount < 2)
{
return {};
}
const int normalizedCurrentIndex = currentIndex < 0 ? 0 : currentIndex;
return {
WrapIndex(forward ? normalizedCurrentIndex + 1 : normalizedCurrentIndex - 1, windowCount),
FirstHotkeyAction::ShowOverlay
};
}
inline OverlayLayout ComputeOverlayLayout(const RECT& work, int windowCount, double scale, int maxColumns = DefaultMaxColumns)
{
OverlayLayout layout;
layout.scale = scale;
layout.pad = ScaledValue(scale, 32);
layout.gap = ScaledValue(scale, 26);
layout.tileW = ScaledValue(scale, 270);
layout.headerH = ScaledValue(scale, 48);
layout.previewH = ScaledValue(scale, 142);
layout.inner = ScaledValue(scale, 6);
layout.radius = ScaledValue(scale, 10);
layout.cardTrimBottom = 0;
layout.iconSize = ScaledValue(scale, 16);
layout.tileH = layout.headerH + layout.inner + layout.previewH + layout.inner;
const int workW = work.right - work.left;
const int workH = work.bottom - work.top;
const int safeWindowCount = (std::max)(0, windowCount);
const int safeMaxColumns = (std::max)(1, maxColumns);
const int columnsFromWork = (std::max)(1, (workW - 2 * layout.pad + layout.gap) / (layout.tileW + layout.gap));
layout.cols = (std::min)(safeWindowCount, (std::min)(safeMaxColumns, columnsFromWork));
if (layout.cols < 1)
{
layout.cols = 1;
}
layout.rows = (safeWindowCount + layout.cols - 1) / layout.cols;
layout.panelW = 2 * layout.pad + layout.cols * layout.tileW + (layout.cols - 1) * layout.gap;
layout.panelH = 2 * layout.pad + layout.rows * layout.tileH + (layout.rows - 1) * layout.gap;
layout.panelX = work.left + (workW - layout.panelW) / 2;
layout.panelY = work.top + (workH - layout.panelH) / 2;
return layout;
}
inline RECT TileRect(const OverlayLayout& layout, int index)
{
const int col = index % layout.cols;
const int row = index / layout.cols;
const int left = layout.pad + col * (layout.tileW + layout.gap);
const int top = layout.pad + row * (layout.tileH + layout.gap);
return { left, top, left + layout.tileW, top + layout.tileH - layout.cardTrimBottom };
}
inline RECT PreviewRect(const OverlayLayout& layout, const RECT& tile)
{
// Sits directly below the header band, inset by the 1px card stroke on the
// left/right/bottom so the card border stays visible around the image.
const int stroke = ScaledValue(layout.scale, 1);
return {
tile.left + stroke,
tile.top + layout.headerH,
tile.right - stroke,
tile.bottom - stroke
};
}
inline RECT HeaderRect(const OverlayLayout& layout, const RECT& tile)
{
const int margin = ScaledValue(layout.scale, 12);
return { tile.left + margin, tile.top, tile.right - margin, tile.top + layout.headerH };
}
inline RECT CoverSource(const RECT& dest, const RECT& avail)
{
const int aw = avail.right - avail.left;
const int ah = avail.bottom - avail.top;
const int dw = dest.right - dest.left;
const int dh = dest.bottom - dest.top;
if (aw <= 0 || ah <= 0 || dw <= 0 || dh <= 0)
{
return avail;
}
const double destA = static_cast<double>(dw) / dh;
const double srcA = static_cast<double>(aw) / ah;
if (srcA > destA)
{
const int cw = (std::max)(1, static_cast<int>(ah * destA + 0.5));
const int x = avail.left + (aw - cw) / 2;
return { x, avail.top, x + cw, avail.bottom };
}
const int ch = (std::max)(1, static_cast<int>(aw / destA + 0.5));
const int y = avail.top + (ah - ch) / 2;
return { avail.left, y, avail.right, y + ch };
}
}

View File

@@ -0,0 +1,168 @@
#pragma warning(push)
#pragma warning(disable : 26466)
#include "CppUnitTest.h"
#pragma warning(pop)
#include "..\AltWindowCycleLogic.h"
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
namespace AltWindowCycleUnitTests
{
namespace
{
void AssertRectEqual(const RECT& expected, const RECT& actual)
{
Assert::AreEqual(expected.left, actual.left, L"left");
Assert::AreEqual(expected.top, actual.top, L"top");
Assert::AreEqual(expected.right, actual.right, L"right");
Assert::AreEqual(expected.bottom, actual.bottom, L"bottom");
}
}
TEST_CLASS(AltWindowCycleLogicTests)
{
public:
TEST_METHOD(ComputeOverlayLayoutUsesExpectedUnscaledGeometry)
{
const RECT work = { 0, 0, 1920, 1080 };
const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.0);
Assert::AreEqual(32, layout.pad);
Assert::AreEqual(26, layout.gap);
Assert::AreEqual(270, layout.tileW);
Assert::AreEqual(48, layout.headerH);
Assert::AreEqual(142, layout.previewH);
Assert::AreEqual(6, layout.inner);
Assert::AreEqual(10, layout.radius);
Assert::AreEqual(16, layout.iconSize);
Assert::AreEqual(202, layout.tileH);
Assert::AreEqual(4, layout.cols);
Assert::AreEqual(1, layout.rows);
Assert::AreEqual(1222, layout.panelW);
Assert::AreEqual(266, layout.panelH);
Assert::AreEqual(349, layout.panelX);
Assert::AreEqual(407, layout.panelY);
}
TEST_METHOD(ComputeOverlayLayoutUsesRoundedScaledGeometry)
{
const RECT work = { 0, 0, 1920, 1080 };
const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 6, 1.5);
Assert::AreEqual(48, layout.pad);
Assert::AreEqual(39, layout.gap);
Assert::AreEqual(405, layout.tileW);
Assert::AreEqual(72, layout.headerH);
Assert::AreEqual(213, layout.previewH);
Assert::AreEqual(9, layout.inner);
Assert::AreEqual(15, layout.radius);
Assert::AreEqual(24, layout.iconSize);
Assert::AreEqual(303, layout.tileH);
Assert::AreEqual(4, layout.cols);
Assert::AreEqual(2, layout.rows);
Assert::AreEqual(1833, layout.panelW);
Assert::AreEqual(741, layout.panelH);
Assert::AreEqual(43, layout.panelX);
Assert::AreEqual(169, layout.panelY);
}
TEST_METHOD(ComputeOverlayLayoutHandlesEmptyWindowCount)
{
const RECT work = { 10, 20, 810, 620 };
const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 0, 1.0);
Assert::AreEqual(1, layout.cols);
Assert::AreEqual(0, layout.rows);
}
TEST_METHOD(TilePreviewAndHeaderRectsUsePanelRelativeInsetViewport)
{
const RECT work = { 0, 0, 1920, 1080 };
const auto layout = AltWindowCycleLogic::ComputeOverlayLayout(work, 4, 1.0);
const RECT tile = AltWindowCycleLogic::TileRect(layout, 0);
AssertRectEqual({ 32, 32, 302, 234 }, tile);
const RECT preview = AltWindowCycleLogic::PreviewRect(layout, tile);
AssertRectEqual({ 33, 80, 301, 233 }, preview);
const RECT header = AltWindowCycleLogic::HeaderRect(layout, tile);
AssertRectEqual({ 44, 32, 290, 80 }, header);
}
TEST_METHOD(CoverSourceCropsWideSourceToDestinationAspectRatio)
{
const RECT dest = { 0, 0, 100, 100 };
const RECT avail = { 0, 0, 400, 200 };
const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
AssertRectEqual({ 100, 0, 300, 200 }, source);
}
TEST_METHOD(CoverSourceCropsTallSourceToDestinationAspectRatio)
{
const RECT dest = { 0, 0, 100, 100 };
const RECT avail = { 0, 0, 200, 400 };
const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
AssertRectEqual({ 0, 100, 200, 300 }, source);
}
TEST_METHOD(CoverSourceReturnsAvailableRegionForInvalidInputs)
{
const RECT dest = { 0, 0, 0, 100 };
const RECT avail = { 1, 2, 3, 4 };
const RECT source = AltWindowCycleLogic::CoverSource(dest, avail);
AssertRectEqual(avail, source);
}
TEST_METHOD(WrapIndexWrapsForwardAndBackward)
{
Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(3, 3));
Assert::AreEqual(2, AltWindowCycleLogic::WrapIndex(-1, 3));
Assert::AreEqual(1, AltWindowCycleLogic::WrapIndex(4, 3));
Assert::AreEqual(0, AltWindowCycleLogic::WrapIndex(4, 0));
}
TEST_METHOD(StableHoldModifiersPreferNonShiftModifiers)
{
Assert::AreEqual(AltWindowCycleLogic::ModifierAlt, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierShift));
Assert::AreEqual(AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierCtrl | AltWindowCycleLogic::ModifierShift));
Assert::AreEqual(AltWindowCycleLogic::ModifierWin, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierWin));
Assert::AreEqual(AltWindowCycleLogic::ModifierShift, AltWindowCycleLogic::StableHoldModifiers(AltWindowCycleLogic::ModifierShift));
Assert::AreEqual(0u, AltWindowCycleLogic::StableHoldModifiers(0));
}
TEST_METHOD(AreRequiredModifiersDownRequiresEveryHeldModifier)
{
Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(0, AltWindowCycleLogic::ModifierAlt));
Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt, 0));
Assert::IsFalse(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::ModifierAlt));
Assert::IsTrue(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt, AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierShift));
Assert::IsTrue(AltWindowCycleLogic::AreRequiredModifiersDown(AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl, AltWindowCycleLogic::ModifierAlt | AltWindowCycleLogic::ModifierCtrl | AltWindowCycleLogic::ModifierShift));
}
TEST_METHOD(BeginCycleSelectsNextOrPreviousAndShowsOverlay)
{
const auto forward = AltWindowCycleLogic::BeginCycle(0, 3, true);
Assert::AreEqual(1, forward.selected);
Assert::IsTrue(forward.action == AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay);
const auto backward = AltWindowCycleLogic::BeginCycle(0, 3, false);
Assert::AreEqual(2, backward.selected);
Assert::IsTrue(backward.action == AltWindowCycleLogic::FirstHotkeyAction::ShowOverlay);
const auto ignored = AltWindowCycleLogic::BeginCycle(0, 1, true);
Assert::AreEqual(0, ignored.selected);
Assert::IsTrue(ignored.action == AltWindowCycleLogic::FirstHotkeyAction::Ignore);
}
};
}

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<ProjectGuid>{9EBE5C3A-4A37-477E-9176-07C7CF0EBA6E}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>AltWindowCycleUnitTests</RootNamespace>
<ProjectSubType>NativeUnitTestProject</ProjectSubType>
<ProjectName>AltWindowCycle.UnitTests</ProjectName>
</PropertyGroup>
<PropertyGroup Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseOfMfc>false</UseOfMfc>
<UsePrecompiledHeaders>false</UsePrecompiledHeaders>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<ImportGroup Label="ExtensionSettings">
</ImportGroup>
<ImportGroup Label="Shared">
</ImportGroup>
<ImportGroup Label="PropertySheets">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\tests\AltWindowCycle\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>..;$(RepoRoot)src\;$(VCInstallDir)UnitTest\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<PreprocessorDefinitions>WIN32;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<UseFullPaths>true</UseFullPaths>
<PrecompiledHeader>NotUsing</PrecompiledHeader>
</ClCompile>
<Link>
<AdditionalLibraryDirectories>$(VCInstallDir)UnitTest\lib;%(AdditionalLibraryDirectories)</AdditionalLibraryDirectories>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClCompile Include="AltWindowCycleLogicTests.cpp" />
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\AltWindowCycleLogic.h" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup>
<Filter Include="Source Files">
<UniqueIdentifier>{1345DADF-2D71-454C-A6D9-B9698E920E1A}</UniqueIdentifier>
<Extensions>cpp;c;cc;cxx</Extensions>
</Filter>
<Filter Include="Header Files">
<UniqueIdentifier>{8439D5F4-E4A2-4A72-A6D7-579E2A8D1260}</UniqueIdentifier>
<Extensions>h;hh;hpp;hxx</Extensions>
</Filter>
</ItemGroup>
<ItemGroup>
<ClCompile Include="AltWindowCycleLogicTests.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\AltWindowCycleLogic.h">
<Filter>Header Files</Filter>
</ClInclude>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,287 @@
// dllmain.cpp : Defines the entry point for the DLL application.
#include "pch.h"
#include <interface/powertoy_module_interface.h>
#include <common/SettingsAPI/settings_objects.h>
#include <common/utils/gpo.h>
#include "AltWindowCycle.h"
#include "AltWindowCycleLogic.h"
#include "trace.h"
extern "C" IMAGE_DOS_HEADER __ImageBase;
BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
Trace::RegisterProvider();
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
break;
case DLL_PROCESS_DETACH:
Trace::UnregisterProvider();
break;
}
return TRUE;
}
namespace
{
const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
const wchar_t JSON_KEY_WIN[] = L"win";
const wchar_t JSON_KEY_ALT[] = L"alt";
const wchar_t JSON_KEY_CTRL[] = L"ctrl";
const wchar_t JSON_KEY_SHIFT[] = L"shift";
const wchar_t JSON_KEY_CODE[] = L"code";
const wchar_t JSON_KEY_NEXT_WINDOW_SHORTCUT[] = L"next_window_shortcut";
const wchar_t JSON_KEY_PREVIOUS_WINDOW_SHORTCUT[] = L"previous_window_shortcut";
// ` ~ key (VK_OEM_3) on US layouts.
const unsigned char DEFAULT_BACKTICK_VK = 0xC0;
// Hotkey ids, matching the order returned by get_hotkeys() (and the order in
// the Settings UI). on_hotkey() receives these indices.
enum HotkeyId : size_t
{
HotkeyNext = 0,
HotkeyPrevious = 1
};
static unsigned int ModifierMaskFromHotkey(const PowertoyModuleIface::Hotkey& hotkey)
{
unsigned int modifiers = 0;
if (hotkey.alt)
{
modifiers |= AltWindowCycleLogic::ModifierAlt;
}
if (hotkey.ctrl)
{
modifiers |= AltWindowCycleLogic::ModifierCtrl;
}
if (hotkey.shift)
{
modifiers |= AltWindowCycleLogic::ModifierShift;
}
if (hotkey.win)
{
modifiers |= AltWindowCycleLogic::ModifierWin;
}
return AltWindowCycleLogic::StableHoldModifiers(modifiers);
}
}
// Implement the PowerToy Module Interface and all the required methods.
class AltWindowCycle : public PowertoyModuleIface
{
private:
bool m_enabled = false;
// Cycle to the next window of the focused app.
PowertoyModuleIface::Hotkey m_nextHotkey;
// Cycle to the previous window of the focused app.
PowertoyModuleIface::Hotkey m_previousHotkey;
void init_settings();
void parse_settings(PowerToysSettings::PowerToyValues& settings);
static void parse_hotkey(const winrt::Windows::Data::Json::JsonObject& properties,
const wchar_t* key,
PowertoyModuleIface::Hotkey& hotkey)
{
try
{
auto jsonHotkeyObject = properties.GetNamedObject(key);
hotkey.win = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_WIN);
hotkey.alt = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_ALT);
hotkey.shift = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_SHIFT);
hotkey.ctrl = jsonHotkeyObject.GetNamedBoolean(JSON_KEY_CTRL);
hotkey.key = static_cast<unsigned char>(jsonHotkeyObject.GetNamedNumber(JSON_KEY_CODE));
}
catch (...)
{
Logger::error("Failed to initialize AltWindowCycle shortcut from settings");
}
}
public:
AltWindowCycle()
{
LoggerHelpers::init_logger(L"AltWindowCycle", L"ModuleInterface", "AltWindowCycle");
init_settings();
}
virtual void destroy() override
{
ShutdownAltWindowCycle(); // idempotent
delete this;
}
virtual const wchar_t* get_name() override
{
return L"AltWindowCycle";
}
virtual const wchar_t* get_key() override
{
return L"AltWindowCycle";
}
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override
{
return powertoys_gpo::getConfiguredAltWindowCycleEnabledValue();
}
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
{
HINSTANCE hinstance = reinterpret_cast<HINSTANCE>(&__ImageBase);
PowerToysSettings::Settings settings(hinstance, get_name());
settings.set_description(L"Cycle between the windows of the currently focused application.");
return settings.serialize_to_buffer(buffer, buffer_size);
}
virtual void call_custom_action(const wchar_t* /*action*/) override
{
}
virtual void set_config(const wchar_t* config) override
{
try
{
PowerToysSettings::PowerToyValues values =
PowerToysSettings::PowerToyValues::from_json_string(config, get_key());
parse_settings(values);
values.save_to_settings_file();
}
catch (std::exception&)
{
// Improper JSON.
}
}
virtual void enable() override
{
m_enabled = InitializeAltWindowCycle(reinterpret_cast<HINSTANCE>(&__ImageBase));
if (m_enabled)
{
Trace::EnableAltWindowCycle(true);
}
}
virtual void disable() override
{
m_enabled = false;
Trace::EnableAltWindowCycle(false);
ShutdownAltWindowCycle();
}
virtual bool is_enabled() override
{
return m_enabled;
}
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
{
if (hotkeys && buffer_size >= 2)
{
hotkeys[HotkeyNext] = m_nextHotkey;
hotkeys[HotkeyPrevious] = m_previousHotkey;
}
return 2;
}
virtual bool on_hotkey(size_t hotkeyId) override
{
if (!m_enabled)
{
return false;
}
if (hotkeyId == HotkeyNext)
{
Logger::trace(L"AltWindowCycle next-window hotkey pressed");
Trace::CycleWindow(true);
return HandleAltWindowCycleHotkey(true, ModifierMaskFromHotkey(m_nextHotkey));
}
if (hotkeyId == HotkeyPrevious)
{
Logger::trace(L"AltWindowCycle previous-window hotkey pressed");
Trace::CycleWindow(false);
return HandleAltWindowCycleHotkey(false, ModifierMaskFromHotkey(m_previousHotkey));
}
return false;
}
};
void AltWindowCycle::init_settings()
{
try
{
PowerToysSettings::PowerToyValues settings =
PowerToysSettings::PowerToyValues::load_from_settings_file(AltWindowCycle::get_key());
parse_settings(settings);
}
catch (std::exception&)
{
// Error while loading from the settings file. Let default values stay as they are.
}
}
void AltWindowCycle::parse_settings(PowerToysSettings::PowerToyValues& settings)
{
// Reset to defaults before parsing so removed/invalid values fall back cleanly.
m_nextHotkey = PowertoyModuleIface::Hotkey{};
m_previousHotkey = PowertoyModuleIface::Hotkey{};
auto settingsObject = settings.get_raw_json();
if (settingsObject.GetView().Size() && settingsObject.HasKey(JSON_KEY_PROPERTIES))
{
auto properties = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
if (properties.HasKey(JSON_KEY_NEXT_WINDOW_SHORTCUT))
{
parse_hotkey(properties, JSON_KEY_NEXT_WINDOW_SHORTCUT, m_nextHotkey);
}
if (properties.HasKey(JSON_KEY_PREVIOUS_WINDOW_SHORTCUT))
{
parse_hotkey(properties, JSON_KEY_PREVIOUS_WINDOW_SHORTCUT, m_previousHotkey);
}
}
else
{
Logger::info("AltWindowCycle settings are empty");
}
// Default: Alt+` cycles to the next window of the focused app.
if (!m_nextHotkey.key)
{
m_nextHotkey.win = false;
m_nextHotkey.ctrl = false;
m_nextHotkey.shift = false;
m_nextHotkey.alt = true;
m_nextHotkey.key = DEFAULT_BACKTICK_VK;
}
// Default: Shift+Alt+` cycles to the previous window of the focused app.
if (!m_previousHotkey.key)
{
m_previousHotkey.win = false;
m_previousHotkey.ctrl = false;
m_previousHotkey.shift = true;
m_previousHotkey.alt = true;
m_previousHotkey.key = DEFAULT_BACKTICK_VK;
}
}
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
{
return new AltWindowCycle();
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.CppWinRT" version="2.0.250303.1" targetFramework="native" />
</packages>

View File

@@ -0,0 +1 @@
#include "pch.h"

View File

@@ -0,0 +1,15 @@
#pragma once
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <dwmapi.h>
#include <algorithm>
#include <string>
#include <vector>
#include <winrt/Windows.Data.Json.h>
#include <common/logger/logger.h>
#include <common/utils/logger_helper.h>
#include <common/SettingsAPI/settings_helpers.h>

View File

@@ -0,0 +1,13 @@
//{{NO_DEPENDENCIES}}
// Microsoft Visual C++ generated include file.
// Used by AltWindowCycle.rc
//////////////////////////////
// Non-localizable
#define FILE_DESCRIPTION "PowerToys AltWindowCycle"
#define INTERNAL_NAME "AltWindowCycle"
#define ORIGINAL_FILENAME "PowerToys.AltWindowCycle.dll"
// Non-localizable
//////////////////////////////

View File

@@ -0,0 +1,33 @@
#include "pch.h"
#include "trace.h"
#include <common/Telemetry/TraceBase.h>
TRACELOGGING_DEFINE_PROVIDER(
g_hProvider,
"Microsoft.PowerToys",
// {38e8889b-9731-53f5-e901-e8a7c1753074}
(0x38e8889b, 0x9731, 0x53f5, 0xe9, 0x01, 0xe8, 0xa7, 0xc1, 0x75, 0x30, 0x74),
TraceLoggingOptionProjectTelemetry());
// Log if the user has AltWindowCycle enabled or disabled
void Trace::EnableAltWindowCycle(const bool enabled) noexcept
{
TraceLoggingWriteWrapper(
g_hProvider,
"AltWindowCycle_EnableAltWindowCycle",
ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
TraceLoggingBoolean(enabled, "Enabled"));
}
// Log that the user invoked the module to cycle a window
void Trace::CycleWindow(const bool forward) noexcept
{
TraceLoggingWriteWrapper(
g_hProvider,
"AltWindowCycle_CycleWindow",
ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
TraceLoggingBoolean(forward, "Forward"));
}

View File

@@ -0,0 +1,13 @@
#pragma once
#include <common/Telemetry/TraceBase.h>
class Trace : public telemetry::TraceBase
{
public:
// Log if the user has AltWindowCycle enabled or disabled
static void EnableAltWindowCycle(const bool enabled) noexcept;
// Log that the user invoked the module to cycle a window
static void CycleWindow(const bool forward) noexcept;
};

View File

@@ -1,17 +0,0 @@
<Project>
<!--
PROTOTYPE-ONLY build props for the PowerScripts module.
Intentionally does NOT import the repo-root Directory.Build.props so the
prototype stays isolated from StyleCop / TreatWarningsAsErrors / Central
Package Management while we iterate. Before promoting PowerScripts out of
prototype status, delete this file so the projects inherit the standard
PowerToys build configuration and analyzers.
-->
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
</PropertyGroup>
</Project>

View File

@@ -1,3 +0,0 @@
<Project>
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
</Project>

View File

@@ -1,11 +0,0 @@
<Project>
<!--
PROTOTYPE-ONLY: stops NuGet from discovering the repo-root Directory.Packages.props and
disables Central Package Management so the prototype projects can pin their own PackageReference
versions in isolation. Remove together with the local Directory.Build.props when promoting the
module to the standard PowerToys build.
-->
<PropertyGroup>
<ManagePackageVersionsCentrally>false</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>

View File

@@ -1,87 +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 Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Tests;
[TestClass]
public class ManifestTests
{
[TestMethod]
public void Serializer_RoundTrips_WithCamelCaseEnums()
{
var manifest = new PowerScriptManifest
{
Id = "demo",
Name = "Demo",
Kind = ScriptKind.File,
Runtime = ScriptRuntime.PowerShell,
Entry = "run.ps1",
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 1, MaxFiles = 0 },
Output = new ScriptOutput { Type = ScriptOutputType.SideEffect },
Surfaces = { "contextMenu" },
};
var json = ManifestSerializer.Serialize(manifest);
StringAssert.Contains(json, "\"kind\": \"file\"");
StringAssert.Contains(json, "\"runtime\": \"powerShell\"");
var back = ManifestSerializer.Deserialize(json);
Assert.IsNotNull(back);
Assert.AreEqual(ScriptKind.File, back!.Kind);
Assert.AreEqual(ScriptOutputType.SideEffect, back.Output!.Type);
Assert.AreEqual(".png", back.Input!.Extensions[0]);
}
[TestMethod]
public void Validator_Allows_IdFolderMismatch()
{
// A script's id is portable and intentionally decoupled from its folder name, so a mismatch
// is no longer an error (a downloaded/shared script keeps its id in any folder).
var manifest = new PowerScriptManifest { Id = "abc", Name = "x", Entry = "run.ps1" };
var errors = ManifestValidator.Validate(manifest, folderName: "different");
Assert.AreEqual(0, errors.Count);
}
[TestMethod]
public void Validator_Flags_MissingId()
{
var manifest = new PowerScriptManifest { Id = string.Empty, Name = "x", Entry = "run.ps1" };
var errors = ManifestValidator.Validate(manifest, folderName: "abc");
Assert.IsTrue(errors.Any(e => e.Contains("'id' is required")));
}
[TestMethod]
public void Validator_Flags_FileKind_WithoutExtensions()
{
var manifest = new PowerScriptManifest
{
Id = "abc",
Name = "x",
Entry = "run.ps1",
Kind = ScriptKind.File,
};
var errors = ManifestValidator.Validate(manifest, "abc");
Assert.IsTrue(errors.Any(e => e.Contains("input.extensions")));
}
[TestMethod]
public void Validator_Flags_MaxFiles_LessThanMin()
{
var manifest = new PowerScriptManifest
{
Id = "abc",
Name = "x",
Entry = "run.ps1",
Kind = ScriptKind.File,
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 3, MaxFiles = 2 },
};
var errors = ManifestValidator.Validate(manifest, "abc");
Assert.IsTrue(errors.Any(e => e.Contains("maxFiles")));
}
}

View File

@@ -1,19 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerScripts.Core.Tests</RootNamespace>
<AssemblyName>PowerScripts.Core.Tests</AssemblyName>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.13.0" />
<PackageReference Include="MSTest.TestAdapter" Version="3.8.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.8.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,166 +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 Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
namespace PowerScripts.Core.Tests;
[TestClass]
public class ScriptRegistryTests
{
private string _root = string.Empty;
[TestInitialize]
public void Setup()
{
_root = Path.Combine(Path.GetTempPath(), "powerscripts-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private void WriteScript(string id, string manifestJson, string entryFile = "run.ps1")
{
var folder = Path.Combine(_root, id);
Directory.CreateDirectory(folder);
File.WriteAllText(Path.Combine(folder, "manifest.json"), manifestJson);
File.WriteAllText(Path.Combine(folder, entryFile), "# noop");
}
[TestMethod]
public void Load_Skips_Invalid_And_Records_Error()
{
WriteScript("good", """
{ "id": "good", "name": "Good", "kind": "system", "entry": "run.ps1" }
""");
// Missing 'id' -> should be rejected.
WriteScript("bad", """
{ "name": "Bad", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual("good", registry.Scripts[0].Id);
Assert.AreEqual(1, registry.Errors.Count);
}
[TestMethod]
public void Load_Allows_IdDecoupledFromFolder()
{
// The folder name differs from the id; the script is still loaded and keyed by its id.
WriteScript("some-folder", """
{ "id": "portable.id", "name": "Portable", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual("portable.id", registry.Scripts[0].Id);
Assert.AreEqual(0, registry.Errors.Count);
Assert.IsNotNull(registry.Get("portable.id"));
}
[TestMethod]
public void Load_Rejects_DuplicateIds()
{
WriteScript("folder-a", """
{ "id": "dup", "name": "First", "kind": "system", "entry": "run.ps1" }
""");
WriteScript("folder-b", """
{ "id": "dup", "name": "Second", "kind": "system", "entry": "run.ps1" }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
// Only the first wins; the collision is reported.
Assert.AreEqual(1, registry.Scripts.Count);
Assert.AreEqual(1, registry.Errors.Count);
Assert.IsTrue(registry.Errors[0].Message.Contains("duplicate id"));
}
[TestMethod]
public void FileScriptsFor_Matches_Extension_And_Wildcard()
{
WriteScript("png-only", """
{ "id": "png-only", "name": "PNG", "kind": "file", "entry": "run.ps1",
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 0 } }
""");
WriteScript("any-file", """
{ "id": "any-file", "name": "Any", "kind": "file", "entry": "run.ps1",
"input": { "extensions": ["*"], "minFiles": 1, "maxFiles": 0 } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
var forPng = registry.FileScriptsFor(".PNG").Select(s => s.Id).OrderBy(x => x).ToList();
CollectionAssert.AreEqual(new[] { "any-file", "png-only" }, forPng);
var forTxt = registry.FileScriptsFor(".txt").Select(s => s.Id).ToList();
CollectionAssert.AreEqual(new[] { "any-file" }, forTxt);
}
[TestMethod]
public void FileScriptsForSelection_Respects_MinMax_And_MixedExtensions()
{
WriteScript("single-png", """
{ "id": "single-png", "name": "Single PNG", "kind": "file", "entry": "run.ps1",
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 1 } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
// Two files exceeds maxFiles=1.
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.png", "b.png" }).Count());
// One file is fine.
Assert.AreEqual(1, registry.FileScriptsForSelection(new[] { "a.png" }).Count());
// Mixed extensions: not all match .png.
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.txt" }).Count());
}
[TestMethod]
public void SystemScripts_Filters_ByKind()
{
WriteScript("sys", """
{ "id": "sys", "name": "Sys", "kind": "system", "entry": "run.ps1" }
""");
WriteScript("file", """
{ "id": "file", "name": "File", "kind": "file", "entry": "run.ps1",
"input": { "extensions": ["*"] } }
""");
var registry = new ScriptRegistry(_root);
registry.Load();
var system = registry.SystemScripts.Select(s => s.Id).ToList();
CollectionAssert.AreEqual(new[] { "sys" }, system);
}
[TestMethod]
public void Load_EmptyRoot_YieldsNoScripts()
{
var registry = new ScriptRegistry(_root);
registry.Load();
Assert.AreEqual(0, registry.Scripts.Count);
Assert.AreEqual(0, registry.Errors.Count);
}
}

View File

@@ -1,105 +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 Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Security;
namespace PowerScripts.Core.Tests;
[TestClass]
public class SecurityTests
{
private string _folder = string.Empty;
[TestInitialize]
public void Setup()
{
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-sec-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_folder);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_folder))
{
Directory.Delete(_folder, recursive: true);
}
}
private PowerScriptManifest WriteScript(string id, string body, params string[] capabilities)
{
var entry = "run.ps1";
File.WriteAllText(Path.Combine(_folder, entry), body);
return new PowerScriptManifest
{
Id = id,
Name = id,
Kind = ScriptKind.System,
Entry = entry,
FolderPath = _folder,
Capabilities = capabilities.ToList(),
};
}
[TestMethod]
public void Integrity_IsStable_ForSameContent()
{
var a = WriteScript("s", "Write-Host hi");
var first = ScriptIntegrity.ComputeHash(a);
var second = ScriptIntegrity.ComputeHash(a);
Assert.AreEqual(first, second);
Assert.AreNotEqual(string.Empty, first);
}
[TestMethod]
public void Integrity_Changes_WhenBodyChanges()
{
var a = WriteScript("s", "Write-Host hi");
var before = ScriptIntegrity.ComputeHash(a);
File.WriteAllText(Path.Combine(_folder, "run.ps1"), "Remove-Item C:\\ -Recurse");
var after = ScriptIntegrity.ComputeHash(a);
Assert.AreNotEqual(before, after);
}
[TestMethod]
public void Integrity_Changes_WhenCapabilitiesChange()
{
var a = WriteScript("s", "Write-Host hi", "fileRead");
var before = ScriptIntegrity.ComputeHash(a);
var b = WriteScript("s", "Write-Host hi", "fileRead", "process");
var after = ScriptIntegrity.ComputeHash(b);
Assert.AreNotEqual(before, after);
}
[TestMethod]
public void TrustStore_RoundTrips_And_Enforces_Hash()
{
var path = Path.Combine(_folder, "trust.json");
var manifest = WriteScript("s", "Write-Host hi");
var hash = ScriptIntegrity.ComputeHash(manifest);
var store = new TrustStore(path);
Assert.IsFalse(store.IsTrusted("s", hash));
store.Trust(new TrustRecord { Id = "s", Hash = hash, ApprovedUtc = DateTimeOffset.UtcNow });
Assert.IsTrue(store.IsTrusted("s", hash));
// A different content hash for the same id is NOT trusted (edit invalidates approval).
Assert.IsFalse(store.IsTrusted("s", "deadbeef"));
// Persisted across instances.
var reopened = new TrustStore(path);
Assert.IsTrue(reopened.IsTrusted("s", hash));
// Revoke clears it.
Assert.IsTrue(reopened.Revoke("s"));
Assert.IsFalse(new TrustStore(path).IsTrusted("s", hash));
}
}

View File

@@ -1,137 +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.Diagnostics;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Execution;
/// <summary>
/// The outcome of running a PowerScript.
/// </summary>
public sealed class ScriptExecutionResult
{
public int ExitCode { get; init; }
public bool Succeeded => ExitCode == 0;
public string StdOut { get; init; } = string.Empty;
public string StdErr { get; init; } = string.Empty;
}
/// <summary>
/// Runs a PowerScript. This is the single execution path shared by every surface (context menu,
/// Keyboard Manager, Command Palette, agents) so behavior and security posture stay consistent.
///
/// Prototype security posture: always runs non-elevated under the invoking user's token, with the
/// PowerShell profile disabled and a per-run execution policy of Bypass scoped to the launched
/// process only. Signing / capability enforcement is intentionally out of scope for the prototype.
/// </summary>
public sealed class ScriptExecutor
{
/// <summary>Environment variable the script can read to get the newline-separated input files.</summary>
public const string FilesEnvironmentVariable = "POWERSCRIPTS_FILES";
public ScriptExecutionResult Execute(
PowerScriptManifest manifest,
IReadOnlyList<string>? files = null,
IReadOnlyDictionary<string, string?>? parameters = null)
{
if (manifest.Runtime != ScriptRuntime.PowerShell)
{
throw new NotSupportedException($"Runtime '{manifest.Runtime}' is not supported in the prototype.");
}
if (!File.Exists(manifest.EntryFullPath))
{
throw new FileNotFoundException("Script entry file not found.", manifest.EntryFullPath);
}
files ??= Array.Empty<string>();
var psi = new ProcessStartInfo
{
FileName = ResolvePowerShellExecutable(),
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
CreateNoWindow = true,
WorkingDirectory = manifest.FolderPath,
};
psi.ArgumentList.Add("-NoProfile");
psi.ArgumentList.Add("-NonInteractive");
psi.ArgumentList.Add("-ExecutionPolicy");
psi.ArgumentList.Add("Bypass");
psi.ArgumentList.Add("-File");
psi.ArgumentList.Add(manifest.EntryFullPath);
// Files are passed both as a -Files parameter (array binding) and via an environment
// variable so scripts can consume whichever is convenient.
if (files.Count > 0)
{
psi.ArgumentList.Add("-Files");
foreach (var file in files)
{
psi.ArgumentList.Add(file);
}
psi.Environment[FilesEnvironmentVariable] = string.Join('\n', files);
}
if (parameters is not null)
{
foreach (var (name, value) in parameters)
{
psi.ArgumentList.Add("-" + name);
psi.ArgumentList.Add(value ?? string.Empty);
}
}
using var process = new Process { StartInfo = psi };
process.Start();
// Read both streams concurrently to avoid pipe deadlock on large output.
var stdOutTask = process.StandardOutput.ReadToEndAsync();
var stdErrTask = process.StandardError.ReadToEndAsync();
process.WaitForExit();
return new ScriptExecutionResult
{
ExitCode = process.ExitCode,
StdOut = stdOutTask.GetAwaiter().GetResult(),
StdErr = stdErrTask.GetAwaiter().GetResult(),
};
}
/// <summary>
/// Prefers PowerShell 7+ (<c>pwsh</c>); falls back to Windows PowerShell (<c>powershell</c>).
/// </summary>
private static string ResolvePowerShellExecutable()
{
return ExistsOnPath("pwsh.exe") ? "pwsh.exe" : "powershell.exe";
}
private static bool ExistsOnPath(string fileName)
{
var pathVar = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
foreach (var dir in pathVar.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
if (File.Exists(Path.Combine(dir.Trim(), fileName)))
{
return true;
}
}
catch
{
// Ignore malformed PATH entries.
}
}
return false;
}
}

View File

@@ -1,38 +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.Text.Json;
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Manifest;
/// <summary>
/// Centralized JSON options and (de)serialization helpers for PowerScript manifests.
/// </summary>
public static class ManifestSerializer
{
public static JsonSerializerOptions Options { get; } = CreateOptions();
private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
public static PowerScriptManifest? Deserialize(string json) =>
JsonSerializer.Deserialize<PowerScriptManifest>(json, Options);
public static string Serialize(PowerScriptManifest manifest) =>
JsonSerializer.Serialize(manifest, Options);
}

View File

@@ -1,62 +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.
namespace PowerScripts.Core.Manifest;
/// <summary>
/// Validates a parsed manifest. Returns human-readable errors rather than throwing so the registry
/// can skip a single bad script without failing the whole catalogue.
///
/// A script's <c>id</c> is its portable identity and is intentionally decoupled from the folder it
/// happens to live in: this lets a script keep a stable id when it is shared, downloaded from a
/// community catalogue, or dropped into a differently-named folder to avoid a local name clash.
/// Uniqueness of ids across the catalogue is enforced by the registry, not here.
/// </summary>
public static class ManifestValidator
{
public static IReadOnlyList<string> Validate(PowerScriptManifest manifest, string folderName)
{
_ = folderName;
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(manifest.Id))
{
errors.Add("'id' is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Name))
{
errors.Add("'name' is required.");
}
if (string.IsNullOrWhiteSpace(manifest.Entry))
{
errors.Add("'entry' is required.");
}
else if (!string.IsNullOrEmpty(manifest.FolderPath) && !File.Exists(manifest.EntryFullPath))
{
errors.Add($"entry script not found: '{manifest.Entry}'.");
}
if (manifest.Kind == ScriptKind.File)
{
if (manifest.Input is null || manifest.Input.Extensions.Count == 0)
{
errors.Add("file scripts must declare 'input.extensions'.");
}
if (manifest.Input is { MinFiles: < 1 })
{
errors.Add("'input.minFiles' must be at least 1.");
}
if (manifest.Input is { MaxFiles: > 0 } input && input.MaxFiles < input.MinFiles)
{
errors.Add("'input.maxFiles' must be 0 (unbounded) or >= minFiles.");
}
}
return errors;
}
}

View File

@@ -1,151 +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.Text.Json.Serialization;
namespace PowerScripts.Core.Manifest;
/// <summary>
/// What a PowerScript operates on.
/// </summary>
public enum ScriptKind
{
/// <summary>Acts on the PC; no file input. Surfaced via hotkey / Command Palette.</summary>
System,
/// <summary>Acts on one or more input files of a declared type. Surfaced in the right-click menu.</summary>
File,
}
/// <summary>
/// The runtime used to execute a PowerScript. Only PowerShell is supported in the prototype;
/// the field exists so Python / Node can be added without a schema break.
/// </summary>
public enum ScriptRuntime
{
PowerShell,
}
/// <summary>
/// The kind of result a file PowerScript produces.
/// </summary>
public enum ScriptOutputType
{
None,
/// <summary>Produces a converted file (e.g. HEIC -> JPG).</summary>
ConvertedFile,
/// <summary>Performs a side effect (e.g. checksum, OCR, strip metadata).</summary>
SideEffect,
}
/// <summary>
/// Declares the file input contract for a <see cref="ScriptKind.File"/> script.
/// </summary>
public sealed class ScriptInput
{
/// <summary>File extensions this script accepts (e.g. ".heic"). "*" means any extension.</summary>
public List<string> Extensions { get; set; } = new();
/// <summary>Minimum number of files required.</summary>
public int MinFiles { get; set; } = 1;
/// <summary>Maximum number of files; 0 means unbounded.</summary>
public int MaxFiles { get; set; }
}
/// <summary>
/// Declares the output contract for a <see cref="ScriptKind.File"/> script.
/// </summary>
public sealed class ScriptOutput
{
public ScriptOutputType Type { get; set; } = ScriptOutputType.None;
/// <summary>For <see cref="ScriptOutputType.ConvertedFile"/>: the produced extension (e.g. ".jpg").</summary>
public string? Extension { get; set; }
}
/// <summary>
/// A typed, user-editable parameter passed to the script.
/// </summary>
public sealed class ScriptParameter
{
public string Name { get; set; } = string.Empty;
/// <summary>One of: "string", "int", "bool".</summary>
public string Type { get; set; } = "string";
public string? Default { get; set; }
public int? Min { get; set; }
public int? Max { get; set; }
}
/// <summary>
/// The on-disk description of a single PowerScript. One script lives in its own folder containing
/// a <c>manifest.json</c> (this type) plus the script body referenced by <see cref="Entry"/>.
/// </summary>
public sealed class PowerScriptManifest
{
public int SchemaVersion { get; set; } = 1;
/// <summary>Stable identifier; must match the containing folder name.</summary>
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
/// <summary>Optional icon file name, relative to the script folder.</summary>
public string? Icon { get; set; }
/// <summary>Optional author/publisher, shown in the trust prompt (e.g. "contoso" or a GitHub user).</summary>
public string? Publisher { get; set; }
/// <summary>Optional semantic version of the script (e.g. "1.2.0").</summary>
public string? Version { get; set; }
/// <summary>Optional provenance, e.g. the catalogue URL the script was adopted from.</summary>
public string? Source { get; set; }
public ScriptKind Kind { get; set; }
public ScriptRuntime Runtime { get; set; } = ScriptRuntime.PowerShell;
/// <summary>Script body file name, relative to the script folder (e.g. "run.ps1").</summary>
public string Entry { get; set; } = string.Empty;
/// <summary>File input contract; required for <see cref="ScriptKind.File"/>.</summary>
public ScriptInput? Input { get; set; }
public ScriptOutput? Output { get; set; }
public List<ScriptParameter> Parameters { get; set; } = new();
/// <summary>Where the script appears, e.g. "contextMenu", "keyboardManager", "commandPalette".</summary>
public List<string> Surfaces { get; set; } = new();
/// <summary>
/// Declared capabilities (e.g. "fileRead", "fileWrite", "process"). Doubles as the user-consent
/// string and the permission contract an agent / MCP server must respect.
/// </summary>
public List<string> Capabilities { get; set; } = new();
/// <summary>Prototype always runs "asInvoker" (non-elevated).</summary>
public string Elevation { get; set; } = "asInvoker";
/// <summary>Absolute path to the folder that contains this manifest. Populated by the registry.</summary>
[JsonIgnore]
public string FolderPath { get; set; } = string.Empty;
/// <summary>Absolute path to the script body file.</summary>
[JsonIgnore]
public string EntryFullPath => string.IsNullOrEmpty(FolderPath) ? Entry : Path.Combine(FolderPath, Entry);
/// <summary>True if this script declares the given surface (case-insensitive).</summary>
public bool HasSurface(string surface) =>
Surfaces.Any(s => string.Equals(s, surface, StringComparison.OrdinalIgnoreCase));
}

View File

@@ -1,7 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>PowerScripts.Core</RootNamespace>
<AssemblyName>PowerScripts.Core</AssemblyName>
</PropertyGroup>
</Project>

View File

@@ -1,114 +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.Text.Json;
namespace PowerScripts.Core;
/// <summary>
/// Well-known filesystem locations for the PowerScripts module. The scripts root can be overridden
/// (explicit path, environment variable, or a persisted user setting) which keeps tests and ad-hoc
/// runs hermetic and lets the user point PowerScripts at their own folder from Settings.
/// </summary>
public static class PowerScriptsPaths
{
/// <summary>Environment variable that overrides the default scripts root.</summary>
public const string RootEnvironmentVariable = "POWERSCRIPTS_ROOT";
/// <summary>The folder a single script lives in must contain a file with this name.</summary>
public const string ManifestFileName = "manifest.json";
/// <summary>The user-settings file name persisted next to the module data.</summary>
public const string ConfigFileName = "config.json";
/// <summary>
/// The module's data directory: <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts</c>.
/// </summary>
public static string ModuleDirectory
{
get
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "PowerToys", "PowerScripts");
}
}
/// <summary>The user-settings file that persists the chosen scripts root.</summary>
public static string ConfigFilePath => Path.Combine(ModuleDirectory, ConfigFileName);
/// <summary>The trust store file name (records which script contents the user has approved).</summary>
public const string TrustFileName = "trust.json";
/// <summary>The trust store: which (script id, content hash) pairs the user has approved to run.</summary>
public static string TrustFilePath => Path.Combine(ModuleDirectory, TrustFileName);
/// <summary>
/// Default scripts root:
/// <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts</c>.
/// </summary>
public static string DefaultScriptsRoot => Path.Combine(ModuleDirectory, "scripts");
/// <summary>
/// Resolves the scripts root, honoring (in order): an explicit path, the environment override,
/// the persisted user setting, then the default.
/// </summary>
public static string ResolveScriptsRoot(string? explicitRoot = null)
{
if (!string.IsNullOrWhiteSpace(explicitRoot))
{
return explicitRoot;
}
var fromEnv = Environment.GetEnvironmentVariable(RootEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv;
}
var fromConfig = ReadConfiguredScriptsRoot();
return string.IsNullOrWhiteSpace(fromConfig) ? DefaultScriptsRoot : fromConfig;
}
/// <summary>
/// Reads the user-chosen scripts root from <see cref="ConfigFilePath"/>, or <c>null</c> if it is
/// missing, empty, or unreadable.
/// </summary>
public static string? ReadConfiguredScriptsRoot()
{
try
{
if (!File.Exists(ConfigFilePath))
{
return null;
}
using var stream = File.OpenRead(ConfigFilePath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
value.ValueKind == JsonValueKind.String)
{
var root = value.GetString();
return string.IsNullOrWhiteSpace(root) ? null : root;
}
}
catch (Exception)
{
// A corrupt or unreadable config simply falls back to the default.
}
return null;
}
/// <summary>
/// Persists the user-chosen scripts root to <see cref="ConfigFilePath"/>. Passing <c>null</c> or
/// whitespace clears the override so the default is used again.
/// </summary>
public static void SaveConfiguredScriptsRoot(string? root)
{
Directory.CreateDirectory(ModuleDirectory);
var normalized = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigFilePath, json);
}
}

View File

@@ -1,156 +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 PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Registry;
/// <summary>
/// A manifest that failed to load or validate, kept so the UI can surface problems.
/// </summary>
public sealed record ScriptLoadError(string FolderPath, string Message);
/// <summary>
/// The single source of truth for installed PowerScripts. Every surface (context menu, Keyboard
/// Manager editor, Command Palette, agents) reads from this registry rather than defining scripts
/// of its own. The registry only reads the filesystem; it never executes anything.
/// </summary>
public sealed class ScriptRegistry
{
private readonly List<PowerScriptManifest> _scripts = new();
private readonly List<ScriptLoadError> _errors = new();
public ScriptRegistry(string? root = null)
{
Root = PowerScriptsPaths.ResolveScriptsRoot(root);
}
/// <summary>Absolute path to the scanned scripts root.</summary>
public string Root { get; }
public IReadOnlyList<PowerScriptManifest> Scripts => _scripts;
public IReadOnlyList<ScriptLoadError> Errors => _errors;
/// <summary>
/// Scans <see cref="Root"/> for <c>&lt;id&gt;/manifest.json</c> folders, parses and validates each,
/// and rebuilds the in-memory catalogue. Bad scripts are recorded in <see cref="Errors"/> and skipped.
/// </summary>
public void Load()
{
_scripts.Clear();
_errors.Clear();
if (!Directory.Exists(Root))
{
return;
}
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var folder in Directory.EnumerateDirectories(Root))
{
var manifestPath = Path.Combine(folder, PowerScriptsPaths.ManifestFileName);
if (!File.Exists(manifestPath))
{
continue;
}
PowerScriptManifest? manifest;
try
{
manifest = ManifestSerializer.Deserialize(File.ReadAllText(manifestPath));
}
catch (Exception ex)
{
_errors.Add(new ScriptLoadError(folder, $"failed to parse manifest.json: {ex.Message}"));
continue;
}
if (manifest is null)
{
_errors.Add(new ScriptLoadError(folder, "manifest.json deserialized to null."));
continue;
}
manifest.FolderPath = folder;
var folderName = new DirectoryInfo(folder).Name;
var validationErrors = ManifestValidator.Validate(manifest, folderName);
if (validationErrors.Count > 0)
{
_errors.Add(new ScriptLoadError(folder, string.Join(" ", validationErrors)));
continue;
}
// Ids are the portable identity and must be unique across the catalogue, since every
// surface resolves a script by id. A collision (e.g. two adopted scripts sharing an id)
// is reported and the duplicate skipped rather than silently shadowed.
if (!seenIds.Add(manifest.Id))
{
_errors.Add(new ScriptLoadError(folder, $"duplicate id '{manifest.Id}' - already defined by another script; skipped."));
continue;
}
_scripts.Add(manifest);
}
_scripts.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
}
public PowerScriptManifest? Get(string id) =>
_scripts.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
/// <summary>System scripts (no file input) — candidates for Keyboard Manager / Command Palette.</summary>
public IEnumerable<PowerScriptManifest> SystemScripts =>
_scripts.Where(s => s.Kind == ScriptKind.System);
/// <summary>
/// File scripts whose declared input extensions match the given file extension (e.g. ".png").
/// A declared extension of "*" matches anything. Used to build the right-click submenu.
/// </summary>
public IEnumerable<PowerScriptManifest> FileScriptsFor(string extension)
{
var ext = NormalizeExtension(extension);
return _scripts.Where(s =>
s.Kind == ScriptKind.File &&
s.Input is not null &&
s.Input.Extensions.Any(e => MatchesExtension(e, ext)));
}
/// <summary>
/// File scripts that accept <em>all</em> of the given files (every extension matches and the
/// count is within the declared min/max). Used when a multi-file selection is right-clicked.
/// </summary>
public IEnumerable<PowerScriptManifest> FileScriptsForSelection(IReadOnlyCollection<string> files)
{
var extensions = files.Select(f => NormalizeExtension(Path.GetExtension(f))).Distinct().ToList();
return _scripts.Where(s =>
s.Kind == ScriptKind.File &&
s.Input is not null &&
extensions.All(ext => s.Input.Extensions.Any(e => MatchesExtension(e, ext))) &&
files.Count >= s.Input.MinFiles &&
(s.Input.MaxFiles == 0 || files.Count <= s.Input.MaxFiles));
}
private static string NormalizeExtension(string extension)
{
if (string.IsNullOrEmpty(extension))
{
return string.Empty;
}
return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant();
}
private static bool MatchesExtension(string declared, string normalizedTarget)
{
if (declared == "*")
{
return true;
}
return string.Equals(NormalizeExtension(declared), normalizedTarget, StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -1,50 +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.Security.Cryptography;
using System.Text;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Core.Security;
/// <summary>
/// Computes a stable content fingerprint for a script. The fingerprint covers both the executable
/// body and the parts of the manifest that define what the script is allowed to do, so that editing
/// the script <em>or</em> escalating its declared capabilities invalidates any prior user trust and
/// forces a fresh consent prompt (trust-on-first-use).
/// </summary>
public static class ScriptIntegrity
{
/// <summary>
/// Returns the lowercase hex SHA-256 of the script's entry-file bytes combined with its declared
/// <c>kind</c> and (sorted) <c>capabilities</c>. Returns an empty string if the entry file is
/// missing (an untrusted state that will never match a stored trust record).
/// </summary>
public static string ComputeHash(PowerScriptManifest manifest)
{
ArgumentNullException.ThrowIfNull(manifest);
var entryPath = manifest.EntryFullPath;
if (string.IsNullOrEmpty(entryPath) || !File.Exists(entryPath))
{
return string.Empty;
}
var body = File.ReadAllBytes(entryPath);
var capabilities = manifest.Capabilities
.Select(c => c.Trim().ToLowerInvariant())
.Where(c => c.Length > 0)
.OrderBy(c => c, StringComparer.Ordinal);
var declaration = $"\nkind={manifest.Kind}\ncapabilities={string.Join(',', capabilities)}\n";
using var sha = SHA256.Create();
sha.TransformBlock(body, 0, body.Length, null, 0);
var declarationBytes = Encoding.UTF8.GetBytes(declaration);
sha.TransformFinalBlock(declarationBytes, 0, declarationBytes.Length);
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
}
}

View File

@@ -1,125 +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.Text.Json;
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Security;
/// <summary>
/// A single trust-on-first-use record: the user approved a script id whose content matched
/// <see cref="Hash"/>. If the script's content or declared capabilities later change, the recomputed
/// hash no longer matches and the user is asked to approve again.
/// </summary>
public sealed class TrustRecord
{
public string Id { get; set; } = string.Empty;
public string Hash { get; set; } = string.Empty;
public IReadOnlyList<string> Capabilities { get; set; } = [];
public string? Source { get; set; }
public string? Publisher { get; set; }
public DateTimeOffset ApprovedUtc { get; set; }
}
/// <summary>
/// Persists which script contents the user has explicitly allowed to run. This is the enforcement
/// point behind the manifest's declared <c>capabilities</c>: a script only runs once the user has
/// approved its exact current content, and re-approves whenever that content changes.
/// </summary>
public sealed class TrustStore
{
private static readonly JsonSerializerOptions Options = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly string _path;
private readonly Dictionary<string, TrustRecord> _records;
public TrustStore(string path)
{
_path = path ?? throw new ArgumentNullException(nameof(path));
_records = Load(path);
}
/// <summary>All current trust records.</summary>
public IReadOnlyCollection<TrustRecord> Records => _records.Values;
/// <summary>Returns true if the user has approved this id with exactly this content hash.</summary>
public bool IsTrusted(string id, string hash)
{
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(hash))
{
return false;
}
return _records.TryGetValue(id, out var record)
&& string.Equals(record.Hash, hash, StringComparison.OrdinalIgnoreCase);
}
/// <summary>Records (or updates) approval for an id at the given content hash and persists it.</summary>
public void Trust(TrustRecord record)
{
ArgumentNullException.ThrowIfNull(record);
_records[record.Id] = record;
Save();
}
/// <summary>Removes approval for an id. Returns true if a record was removed.</summary>
public bool Revoke(string id)
{
if (string.IsNullOrEmpty(id) || !_records.Remove(id))
{
return false;
}
Save();
return true;
}
private static Dictionary<string, TrustRecord> Load(string path)
{
var result = new Dictionary<string, TrustRecord>(StringComparer.OrdinalIgnoreCase);
try
{
if (File.Exists(path))
{
var records = JsonSerializer.Deserialize<List<TrustRecord>>(File.ReadAllText(path), Options);
if (records is not null)
{
foreach (var record in records.Where(r => !string.IsNullOrEmpty(r.Id)))
{
result[record.Id] = record;
}
}
}
}
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
{
// A corrupt or unreadable trust file is treated as "nothing trusted" so the user is
// simply re-prompted, rather than crashing every surface that runs a script.
}
return result;
}
private void Save()
{
var directory = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_path, JsonSerializer.Serialize(_records.Values.ToList(), Options));
}
}

View File

@@ -1,61 +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.Runtime.InteropServices;
using PowerScripts.Core.Manifest;
namespace PowerScripts.Host;
/// <summary>
/// Shows the trust-on-first-use consent dialog. Because every surface (context menu, Keyboard
/// Manager, agents) funnels through <c>Host run &lt;id&gt;</c>, this single prompt is the one place a
/// user sees, in plain language, exactly what a script is and what it declares it can do before it
/// ever executes. A native top-most MessageBox is used so the prompt is visible even when the Host
/// was launched hidden by a surface.
/// </summary>
internal static class ConsentPrompt
{
private const uint MB_YESNO = 0x00000004;
private const uint MB_ICONWARNING = 0x00000030;
private const uint MB_DEFBUTTON2 = 0x00000100;
private const uint MB_TOPMOST = 0x00040000;
private const uint MB_SETFOREGROUND = 0x00010000;
private const int IDYES = 6;
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
/// <summary>
/// Returns true if the user approves running this script. Presents the script's identity,
/// provenance and declared capabilities so the decision is informed.
/// </summary>
public static bool Confirm(PowerScriptManifest manifest)
{
var capabilities = manifest.Capabilities.Count > 0
? string.Join(", ", manifest.Capabilities)
: "(none declared)";
var publisher = string.IsNullOrWhiteSpace(manifest.Publisher) ? "(unknown)" : manifest.Publisher;
var source = string.IsNullOrWhiteSpace(manifest.Source) ? "(local)" : manifest.Source;
var text =
$"A PowerScript is about to run for the first time (or its contents changed).\n\n" +
$"Name: {manifest.Name}\n" +
$"Id: {manifest.Id}\n" +
$"Publisher: {publisher}\n" +
$"Source: {source}\n" +
$"Runtime: {manifest.Runtime}\n" +
$"Declares: {capabilities}\n" +
$"Script file: {manifest.EntryFullPath}\n\n" +
"Only allow scripts you trust. Allow this script to run?";
var result = MessageBoxW(
IntPtr.Zero,
text,
"PowerScripts — allow this script to run?",
MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2 | MB_TOPMOST | MB_SETFOREGROUND);
return result == IDYES;
}
}

View File

@@ -1,13 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<RootNamespace>PowerScripts.Host</RootNamespace>
<AssemblyName>PowerScripts.Host</AssemblyName>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\PowerScripts.Core\PowerScripts.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,481 +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.Text.Json;
using PowerScripts.Core;
using PowerScripts.Core.Execution;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
using PowerScripts.Core.Security;
namespace PowerScripts.Host;
/// <summary>
/// The shared PowerScripts executor / catalogue CLI.
///
/// This is the single invocation entry point every surface points at:
/// - Keyboard Manager maps a hotkey to: PowerScripts.Host.exe run &lt;id&gt;
/// - The Explorer context menu invokes: PowerScripts.Host.exe run &lt;id&gt; --files &lt;paths&gt;
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
///
/// Usage:
/// PowerScripts.Host list [--json] [--root &lt;dir&gt;]
/// PowerScripts.Host run &lt;id&gt; [--files &lt;f1&gt; &lt;f2&gt; ...] [--set name=value ...] [--root &lt;dir&gt;]
/// </summary>
internal static class Program
{
private static int Main(string[] args)
{
try
{
if (args.Length == 0)
{
PrintUsage();
return 1;
}
var (positional, options) = ParseArgs(args.Skip(1).ToArray());
var root = options.TryGetValue("root", out var r) ? r.FirstOrDefault() : null;
var registry = new ScriptRegistry(root);
registry.Load();
return args[0].ToLowerInvariant() switch
{
"list" => RunList(registry, options.ContainsKey("json")),
"run" => RunScript(registry, positional, options),
"trust" => RunTrust(registry, positional),
"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(),
_ => Unknown(args[0]),
};
}
catch (Exception ex)
{
Console.Error.WriteLine($"PowerScripts error: {ex.Message}");
return 2;
}
}
private static int RunList(ScriptRegistry registry, bool asJson)
{
if (asJson)
{
// Structured, permissioned capability list — also the shape the KBM editor picker and
// future agents/MCP servers consume.
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
var projection = registry.Scripts.Select(s => new
{
s.Id,
s.Name,
s.Description,
kind = s.Kind.ToString(),
runtime = s.Runtime.ToString(),
s.Publisher,
s.Version,
s.Source,
s.Surfaces,
s.Capabilities,
trusted = trustStore.IsTrusted(s.Id, ScriptIntegrity.ComputeHash(s)),
input = s.Input,
parameters = s.Parameters,
});
Console.WriteLine(JsonSerializer.Serialize(
projection,
new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
}));
return 0;
}
Console.WriteLine($"Scripts root: {registry.Root}");
if (registry.Scripts.Count == 0)
{
Console.WriteLine("(no scripts found)");
}
foreach (var s in registry.Scripts)
{
Console.WriteLine($" {s.Id,-24} [{s.Kind,-6}] {s.Name}");
}
foreach (var e in registry.Errors)
{
Console.Error.WriteLine($" ! {e.FolderPath}: {e.Message}");
}
return 0;
}
private static int RunScript(
ScriptRegistry registry,
IReadOnlyList<string> positional,
IReadOnlyDictionary<string, List<string>> options)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("run: missing <id>.");
return 1;
}
var id = positional[0];
var manifest = registry.Get(id);
if (manifest is null)
{
Console.Error.WriteLine($"run: no script with id '{id}'. Try 'list'.");
return 1;
}
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
// Trust-on-first-use gate. This is the single enforcement point for the manifest's declared
// capabilities: a script only runs once the user has approved its exact current content, and
// is re-prompted whenever the script body or its declared capabilities change (the content
// hash then no longer matches the stored approval).
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
var contentHash = ScriptIntegrity.ComputeHash(manifest);
if (!trustStore.IsTrusted(id, contentHash))
{
var nonInteractive = options.ContainsKey("no-consent")
|| string.Equals(Environment.GetEnvironmentVariable("POWERSCRIPTS_NO_CONSENT"), "1", StringComparison.Ordinal);
if (nonInteractive)
{
Console.Error.WriteLine($"run: script '{id}' is not trusted and consent is disabled; refusing to run. Approve it with 'trust approve {id}'.");
return 3;
}
if (!ConsentPrompt.Confirm(manifest))
{
Console.Error.WriteLine($"run: user declined to trust script '{id}'.");
return 3;
}
trustStore.Trust(new TrustRecord
{
Id = manifest.Id,
Hash = contentHash,
Capabilities = manifest.Capabilities,
Source = manifest.Source,
Publisher = manifest.Publisher,
ApprovedUtc = DateTimeOffset.UtcNow,
});
}
var parameters = new Dictionary<string, string?>();
if (options.TryGetValue("set", out var sets))
{
foreach (var kv in sets)
{
var idx = kv.IndexOf('=');
if (idx <= 0)
{
Console.Error.WriteLine($"run: --set expects name=value, got '{kv}'.");
return 1;
}
parameters[kv[..idx]] = kv[(idx + 1)..];
}
}
var executor = new ScriptExecutor();
var result = executor.Execute(manifest, files, parameters);
if (!string.IsNullOrEmpty(result.StdOut))
{
Console.Out.Write(result.StdOut);
}
if (!string.IsNullOrEmpty(result.StdErr))
{
Console.Error.Write(result.StdErr);
}
return result.ExitCode;
}
/// <summary>
/// Manages the trust store — the record of which script contents the user has approved to run.
/// trust list show every approved script id + the content hash approved
/// trust approve &lt;id&gt; approve the script's current content without running it
/// trust revoke &lt;id&gt; forget approval, so the next run re-prompts
/// </summary>
private static int RunTrust(ScriptRegistry registry, IReadOnlyList<string> positional)
{
var sub = positional.Count > 0 ? positional[0].ToLowerInvariant() : "list";
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
switch (sub)
{
case "list":
if (trustStore.Records.Count == 0)
{
Console.WriteLine("(no scripts trusted yet)");
return 0;
}
foreach (var record in trustStore.Records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
{
Console.WriteLine($" {record.Id,-24} {record.Hash[..Math.Min(12, record.Hash.Length)]} approved {record.ApprovedUtc:u}");
}
return 0;
case "approve":
{
if (positional.Count < 2)
{
Console.Error.WriteLine("trust approve: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[1]);
if (manifest is null)
{
Console.Error.WriteLine($"trust approve: no script with id '{positional[1]}'. Try 'list'.");
return 1;
}
trustStore.Trust(new TrustRecord
{
Id = manifest.Id,
Hash = ScriptIntegrity.ComputeHash(manifest),
Capabilities = manifest.Capabilities,
Source = manifest.Source,
Publisher = manifest.Publisher,
ApprovedUtc = DateTimeOffset.UtcNow,
});
Console.WriteLine($"trust approve: '{manifest.Id}' approved.");
return 0;
}
case "revoke":
if (positional.Count < 2)
{
Console.Error.WriteLine("trust revoke: missing <id>.");
return 1;
}
if (trustStore.Revoke(positional[1]))
{
Console.WriteLine($"trust revoke: '{positional[1]}' will be re-prompted on next run.");
return 0;
}
Console.Error.WriteLine($"trust revoke: '{positional[1]}' was not trusted.");
return 1;
default:
Console.Error.WriteLine($"trust: unknown subcommand '{sub}'. Use list | approve <id> | revoke <id>.");
return 1;
}
}
/// <summary>
/// Emits the Keyboard Manager "Run Program" mapping for a system PowerScript so a user (or the
/// future KBM editor picker) can bind a hotkey to it. KBM's existing RunProgram action already
/// supports this — no KBM engine change is needed. The app path + args go straight into the
/// editor's "Run Program" fields; <c>--json</c> emits the on-disk mapping shape (the user still
/// chooses the trigger keys, so <c>originalKeys</c> is left as a placeholder).
/// </summary>
private static int RunKbm(ScriptRegistry registry, IReadOnlyList<string> positional, bool asJson)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("kbm: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[0]);
if (manifest is null)
{
Console.Error.WriteLine($"kbm: no script with id '{positional[0]}'. Try 'list'.");
return 1;
}
var hostPath = Environment.ProcessPath ?? "PowerScripts.Host.exe";
var programArgs = $"run {manifest.Id}";
if (asJson)
{
// Field names match the KBM engine (see common/KeyboardManagerConstants.h /
// MappingConfiguration.cpp). Append this to remapShortcutsToRunProgram and set
// originalKeys to your chosen trigger (e.g. "162;91;83" for Ctrl+Win+S).
var mapping = new Dictionary<string, object>
{
["originalKeys"] = "<set-your-trigger-keys>",
["operationType"] = 1,
["runProgramFilePath"] = hostPath,
["runProgramArgs"] = programArgs,
["runProgramStartInDir"] = string.Empty,
["runProgramElevationLevel"] = 0,
["runProgramAlreadyRunningAction"] = 0,
["runProgramStartWindowType"] = 0,
["unicodeText"] = "*Unsupported*",
};
Console.WriteLine(JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }));
return 0;
}
Console.WriteLine($"PowerScript '{manifest.Id}' ({manifest.Name}) — Keyboard Manager 'Run Program' action:");
Console.WriteLine($" Program: {hostPath}");
Console.WriteLine($" Arguments: {programArgs}");
Console.WriteLine();
Console.WriteLine("In Keyboard Manager: Remap a shortcut -> action 'Run Program', paste the values above,");
Console.WriteLine("then pick the trigger shortcut. (Use 'kbm <id> --json' for the raw mapping object.)");
return 0;
}
/// <summary>
/// Emits the file scripts that match a right-clicked selection as tab-separated
/// <c>&lt;id&gt;\t&lt;name&gt;</c> 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.
/// </summary>
private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary<string, List<string>> options)
{
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
if (files.Count == 0)
{
return 0;
}
foreach (var script in registry.FileScriptsForSelection(files))
{
Console.WriteLine($"{script.Id}\t{script.Name}");
}
return 0;
}
/// <summary>
/// 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
/// every surface (context menu, selection matching) then reflects them. System scripts have no
/// file input, so they are rejected.
/// </summary>
private static int RunSetExtensions(
ScriptRegistry registry,
IReadOnlyList<string> positional,
IReadOnlyDictionary<string, List<string>> options)
{
if (positional.Count == 0)
{
Console.Error.WriteLine("set-extensions: missing <id>.");
return 1;
}
var manifest = registry.Get(positional[0]);
if (manifest is null)
{
Console.Error.WriteLine($"set-extensions: no script with id '{positional[0]}'. Try 'list'.");
return 1;
}
if (manifest.Kind != ScriptKind.File)
{
Console.Error.WriteLine($"set-extensions: '{manifest.Id}' is a {manifest.Kind} script; extensions only apply to File scripts.");
return 1;
}
var raw = options.TryGetValue("ext", out var values) ? values : new List<string>();
var normalized = raw
.SelectMany(v => v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
.Select(NormalizeExtension)
.Where(e => !string.IsNullOrEmpty(e))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalized.Count == 0)
{
Console.Error.WriteLine("set-extensions: at least one extension is required (e.g. --ext .md .txt).");
return 1;
}
manifest.Input ??= new ScriptInput();
manifest.Input.Extensions = normalized;
var manifestPath = Path.Combine(manifest.FolderPath, PowerScriptsPaths.ManifestFileName);
File.WriteAllText(manifestPath, ManifestSerializer.Serialize(manifest));
Console.WriteLine($"set-extensions: {manifest.Id} -> [{string.Join(", ", normalized)}]");
return 0;
}
/// <summary>Normalizes a user-typed extension to lower-case with a leading dot ("md" -> ".md").</summary>
private static string NormalizeExtension(string raw)
{
var e = raw.Trim().ToLowerInvariant();
if (string.IsNullOrEmpty(e) || e == "*")
{
return e;
}
return e.StartsWith('.') ? e : "." + e;
}
/// <summary>
/// Minimal parser. Recognizes <c>--name value [value ...]</c> (multi-value, e.g. --files) and
/// <c>--flag</c> (no value, e.g. --json). Everything else is positional.
/// </summary>
private static (List<string> Positional, Dictionary<string, List<string>> Options) ParseArgs(string[] args)
{
var positional = new List<string>();
var options = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
string? current = null;
foreach (var arg in args)
{
if (arg.StartsWith("--", StringComparison.Ordinal))
{
current = arg[2..];
if (!options.ContainsKey(current))
{
options[current] = new List<string>();
}
}
else if (current is not null)
{
options[current].Add(arg);
}
else
{
positional.Add(arg);
}
}
return (positional, options);
}
private static int Unknown(string command)
{
Console.Error.WriteLine($"Unknown command '{command}'.");
PrintUsage();
return 1;
}
private static int PrintUsage()
{
Console.WriteLine("PowerScripts.Host — run and enumerate PowerScripts.");
Console.WriteLine();
Console.WriteLine(" list [--json] [--root <dir>]");
Console.WriteLine(" run <id> [--files <f1> <f2> ...] [--set name=value ...] [--no-consent] [--root <dir>]");
Console.WriteLine(" trust list | approve <id> | revoke <id> (manage which scripts are allowed to run)");
Console.WriteLine(" kbm <id> [--json] [--root <dir>] (Keyboard Manager 'Run Program' mapping)");
Console.WriteLine(" set-extensions <id> --ext <.md .txt ...> (set a file script's trigger extensions)");
Console.WriteLine(" shell-menu --files <f1> <f2> ... (tab-separated id/name of matching file scripts)");
Console.WriteLine(" shell-install [--root <dir>] (register the Explorer right-click submenu)");
Console.WriteLine(" shell-uninstall [--root <dir>] (remove the Explorer right-click submenu)");
return 0;
}
}

View File

@@ -1,134 +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 Microsoft.Win32;
using PowerScripts.Core.Manifest;
using PowerScripts.Core.Registry;
namespace PowerScripts.Host;
/// <summary>
/// Registers / unregisters the Explorer right-click "PowerScript" cascading submenu for file
/// PowerScripts. For each file extension declared by a script, it writes a per-user shell verb under
/// <c>HKCU\Software\Classes\SystemFileAssociations\&lt;ext&gt;\shell\PowerScripts</c> whose nested
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run &lt;id&gt; --files "%1"</c>.
///
/// This is the prototype's context-menu surface: it needs no COM DLL and is driven entirely by the
/// script registry, so right-click works immediately and reflects the installed scripts. The
/// PowerScripts module (runner) calls <c>shell-install</c> on enable and <c>shell-uninstall</c> on
/// disable.
/// </summary>
internal static class ShellRegistration
{
private const string RootVerb = "PowerScripts";
private const string MenuLabel = "PowerScript";
private const string ClassesRoot = @"Software\Classes\SystemFileAssociations";
/// <summary>Marker value so uninstall only removes keys this tool created.</summary>
private const string OwnerMarkerName = "PowerScriptsOwned";
public static int Install(ScriptRegistry registry, string hostExePath)
{
// Group file scripts by each declared extension (skip the "*" wildcard for the static menu).
var byExtension = new Dictionary<string, List<PowerScriptManifest>>(StringComparer.OrdinalIgnoreCase);
foreach (var script in registry.Scripts.Where(s => s.Kind == ScriptKind.File && s.Input is not null))
{
foreach (var rawExt in script.Input!.Extensions)
{
if (rawExt == "*")
{
continue;
}
var ext = rawExt.StartsWith('.') ? rawExt : "." + rawExt;
if (!byExtension.TryGetValue(ext, out var list))
{
list = new List<PowerScriptManifest>();
byExtension[ext] = list;
}
list.Add(script);
}
}
if (byExtension.Count == 0)
{
Console.WriteLine("shell-install: no file scripts with concrete extensions to register.");
return 0;
}
foreach (var (ext, scripts) in byExtension)
{
RemoveVerbForExtension(ext);
var verbPath = $@"{ClassesRoot}\{ext}\shell\{RootVerb}";
using var verbKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(verbPath)!;
verbKey.SetValue("MUIVerb", MenuLabel);
verbKey.SetValue(OwnerMarkerName, 1, RegistryValueKind.DWord);
// Presence of "SubCommands" makes Explorer render the nested \shell verbs as a submenu.
verbKey.SetValue("SubCommands", string.Empty);
using var subShell = verbKey.CreateSubKey("shell")!;
foreach (var script in scripts)
{
using var item = subShell.CreateSubKey(script.Id)!;
item.SetValue("MUIVerb", script.Name);
using var command = item.CreateSubKey("command")!;
command.SetValue(null, $"\"{hostExePath}\" run {script.Id} --files \"%1\"");
}
Console.WriteLine($" registered {scripts.Count} script(s) for {ext}");
}
Console.WriteLine($"shell-install: done ({byExtension.Count} extension(s)).");
return 0;
}
public static int Uninstall(ScriptRegistry registry)
{
// Remove for every extension currently declared, plus best-effort sweep is unnecessary since
// we only ever create owned keys.
var extensions = registry.Scripts
.Where(s => s.Kind == ScriptKind.File && s.Input is not null)
.SelectMany(s => s.Input!.Extensions)
.Where(e => e != "*")
.Select(e => e.StartsWith('.') ? e : "." + e)
.Distinct(StringComparer.OrdinalIgnoreCase);
foreach (var ext in extensions)
{
RemoveVerbForExtension(ext);
}
Console.WriteLine("shell-uninstall: done.");
return 0;
}
private static void RemoveVerbForExtension(string ext)
{
var verbParent = $@"{ClassesRoot}\{ext}\shell";
using var shellKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(verbParent, writable: true);
if (shellKey is null)
{
return;
}
// Only delete the verb if we own it.
using (var verbKey = shellKey.OpenSubKey(RootVerb))
{
if (verbKey is null)
{
return;
}
if (verbKey.GetValue(OwnerMarkerName) is null)
{
return;
}
}
shellKey.DeleteSubKeyTree(RootVerb, throwOnMissingSubKey: false);
}
}

View File

@@ -1,9 +0,0 @@
# Native handler build artifacts
*.dll
*.lib
*.exp
*.obj
*.pdb
*.ilk
# Host publish output used by register.ps1
hostpublish/

View File

@@ -1,51 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
xmlns:desktop4="http://schemas.microsoft.com/appx/manifest/desktop/windows10/4"
xmlns:desktop5="http://schemas.microsoft.com/appx/manifest/desktop/windows10/5"
xmlns:uap10="http://schemas.microsoft.com/appx/manifest/uap/windows10/10"
xmlns:com="http://schemas.microsoft.com/appx/manifest/com/windows10"
IgnorableNamespaces="uap rescap desktop4 desktop5 uap10 com">
<Identity Name="Microsoft.PowerToys.PowerScriptsContextMenu" ProcessorArchitecture="neutral" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" Version="1.0.0.0" />
<Properties>
<DisplayName>PowerToys PowerScripts Context Menu</DisplayName>
<PublisherDisplayName>Microsoft</PublisherDisplayName>
<Logo>Assets\storelogo.png</Logo>
</Properties>
<Resources>
<Resource Language="en-us" />
</Resources>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.18950.0" MaxVersionTested="10.0.19000.0" />
</Dependencies>
<Capabilities>
<rescap:Capability Name="runFullTrust" />
<rescap:Capability Name="unvirtualizedResources" />
</Capabilities>
<Applications>
<Application Id="PowerScriptsContextMenu" Executable="PowerScripts.Host.exe" uap10:TrustLevel="mediumIL" uap10:RuntimeBehavior="win32App">
<uap:VisualElements AppListEntry="none" DisplayName="PowerToys PowerScripts Context Menu" Description="PowerScripts context menu handler" BackgroundColor="transparent" Square150x150Logo="Assets\Square150x150Logo.png" Square44x44Logo="Assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" Square310x310Logo="Assets\LargeTile.png" Square71x71Logo="Assets\SmallTile.png"></uap:DefaultTile>
<uap:SplashScreen Image="Assets\SplashScreen.png" />
</uap:VisualElements>
<Extensions>
<desktop4:Extension Category="windows.fileExplorerContextMenus">
<desktop4:FileExplorerContextMenus>
<desktop5:ItemType Type="*">
<desktop5:Verb Id="PowerScriptsCommand" Clsid="9FF7C126-9562-4F16-A6FB-9622B26E0D62" />
</desktop5:ItemType>
</desktop4:FileExplorerContextMenus>
</desktop4:Extension>
<com:Extension Category="windows.comServer" uap10:RuntimeBehavior="packagedClassicApp">
<com:ComServer>
<com:SurrogateServer DisplayName="PowerScripts context menu verb handler">
<com:Class Id="9FF7C126-9562-4F16-A6FB-9622B26E0D62" Path="PowerToys.PowerScriptsContextMenu.dll" ThreadingModel="STA" />
</com:SurrogateServer>
</com:ComServer>
</com:Extension>
</Extensions>
</Application>
</Applications>
</Package>

View File

@@ -1,15 +0,0 @@
@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

View File

@@ -1,4 +0,0 @@
EXPORTS
DllCanUnloadNow PRIVATE
DllGetClassObject PRIVATE
DllGetActivationFactory PRIVATE

View File

@@ -1,388 +0,0 @@
// 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 <paths>", caches the id/name lines, hides itself
// when nothing matches.
// * EnumSubCommands -> turns each cached line into a submenu item.
// * Invoke (item) -> runs "Host run <id> --files <paths>".
#include <windows.h>
#include <shobjidl_core.h>
#include <shlwapi.h>
#include <wrl/module.h>
#include <wrl/implements.h>
#include <wrl/client.h>
#include <string>
#include <vector>
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<std::wstring> ExtractPaths(IShellItemArray* selection)
{
std::vector<std::wstring> result;
if (selection == nullptr)
{
return result;
}
DWORD count = 0;
if (FAILED(selection->GetCount(&count)))
{
return result;
}
for (DWORD i = 0; i < count; ++i)
{
ComPtr<IShellItem> 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<std::wstring>& 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<wchar_t> 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<int>(raw.size()), nullptr, 0);
if (needed > 0)
{
output.resize(needed);
MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(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<wchar_t> 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<ScriptEntry> ParseMenu(const std::wstring& text)
{
std::vector<ScriptEntry> 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<RuntimeClassFlags<ClassicCom>, IExplorerCommand>
{
public:
PowerScriptSubCommand(std::wstring id, std::wstring name, std::vector<std::wstring> 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<std::wstring> 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<std::wstring> m_files;
};
// IEnumExplorerCommand over the submenu items.
class PowerScriptEnum : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IEnumExplorerCommand>
{
public:
explicit PowerScriptEnum(std::vector<ComPtr<IExplorerCommand>> 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<ComPtr<IExplorerCommand>> 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<RuntimeClassFlags<ClassicCom>, 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<ComPtr<IExplorerCommand>> commands;
for (const auto& entry : m_entries)
{
commands.push_back(Make<PowerScriptSubCommand>(entry.Id, entry.Name, m_files));
}
auto enumObject = Make<PowerScriptEnum>(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<IUnknown> m_site;
std::vector<std::wstring> m_files;
std::vector<ScriptEntry> m_entries;
};
CoCreatableClass(PowerScriptCommand);
STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
{
return Module<ModuleType::InProc>::GetModule().GetActivationFactory(activatableClassId, factory);
}
STDAPI DllCanUnloadNow()
{
return (Module<InProc>::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
}
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
{
return Module<InProc>::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;
}

View File

@@ -1,75 +0,0 @@
<#
.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).'

View File

@@ -1,165 +0,0 @@
# PowerScripts (prototype)
> **Status: prototype.** Write a small script once and surface it across PowerToys.
> This folder contains the **working core** (manifest schema, registry, shared executor
> `PowerScripts.Host.exe`) plus sample scripts, and three **implemented surfaces**:
> a Settings module page, the Explorer right-click menu, and the Keyboard Manager editor.
## Implemented surfaces (prototype)
| Surface | What it does | How |
| --- | --- | --- |
| **Settings module** | New "PowerScripts" page in the Settings app that lists installed scripts and has an enable toggle. Enabling/disabling installs/removes the Explorer context-menu entries. | `src/settings-ui/.../Views/PowerScriptsPage.xaml(.cs)` + `PowerScriptsViewModel`; reads `Host.exe list --json`; toggle runs `Host.exe shell-install`/`shell-uninstall`. |
| **Explorer right-click** | Right-click a file → "PowerScript" submenu lists scripts whose manifest declares that extension; clicking runs the script on the file. | `Host.exe shell-install` writes `HKCU\Software\Classes\SystemFileAssociations\<ext>\shell\PowerScripts` cascading verbs → `Host.exe run <id> --files "%1"`. |
| **Keyboard Manager** | A new "PowerScript" action in the KBM editor; pick a system script and assign it to a hotkey. | `KeyboardManagerEditorUI` action picker saves an ordinary `RunProgram` mapping → `Host.exe run <id>`. |
### End-to-end demo
1. **Settings**: open Settings → PowerScripts → see `convert_md_to_txt`, `volume_up`, etc.; toggle on.
2. **Context menu**: right-click a `.md` file → PowerScript → "Convert Markdown to Text" → a `.txt` is written next to it.
3. **Keyboard Manager**: KBM editor → add mapping → action "PowerScript" → pick "Volume Up" → assign a shortcut.
## The idea
A **PowerScript** is a script plus a manifest, living in its own folder. Two flavours:
- **System** (`kind: "system"`) — "do something on my PC". No file input. Triggered by a Keyboard
Manager hotkey (and later the Command Palette).
- **File** (`kind: "file"`) — "do something with this file". Input is one or more files of declared
types. Surfaced in the Explorer right-click menu.
Every surface is a thin consumer of one **registry** and invokes one **executor** — so a script is
authored once and appears everywhere it's declared.
## Architecture
```
Registry (PowerScripts.Core) ──read──► surfaces:
scans <root>/<id>/manifest.json • Explorer context menu (file actions)
• Keyboard Manager editor (system actions)
• Command Palette / Advanced Paste (later)
▲ │ invoke
└──────────── all surfaces ────────────────┘
PowerScripts.Host.exe (executor)
list [--json] | run <id> [--files ...] [--set k=v ...]
```
- **`PowerScripts.Core`** — manifest model + JSON (`Manifest/`), validation, registry (`Registry/`),
executor (`Execution/`).
- **`PowerScripts.Host`** — the CLI every surface points at. `list --json` is the structured catalogue
the KBM editor picker and future agents/MCP consume; `run <id>` executes.
- **`samples/`** — `system-snapshot` & `volume_up` (system), `sha256-checksum` & `convert_md_to_txt` (file).
### Scripts root
`%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts\<id>\manifest.json`
(override with the `POWERSCRIPTS_ROOT` env var or `--root`).
## Manifest schema (v1)
```jsonc
{
"schemaVersion": 1,
"id": "heic-to-jpg", // must match the folder name
"name": "Convert HEIC to JPG",
"description": "…",
"kind": "file", // "system" | "file"
"runtime": "powershell", // prototype: powershell only
"entry": "run.ps1",
"input": { "extensions": [".heic"], "minFiles": 1, "maxFiles": 0 }, // file kind
"output": { "type": "convertedFile", "extension": ".jpg" },
"parameters": [ { "name": "quality", "type": "int", "default": "90", "min": 1, "max": 100 } ],
"surfaces": ["contextMenu", "keyboardManager"],
"capabilities": ["fileWrite"], // consent string + agent permission contract
"elevation": "asInvoker" // prototype always runs non-elevated
}
```
## Build & run
```powershell
cd src\modules\PowerScripts
dotnet build PowerScripts.Host\PowerScripts.Host.csproj -c Debug
$env:POWERSCRIPTS_ROOT = "$PWD\samples"
$exe = "PowerScripts.Host\bin\Debug\net10.0\PowerScripts.Host.exe"
& $exe list
& $exe run system-snapshot
& $exe run sha256-checksum --files C:\some\file.png
```
> The prototype projects are isolated from the repo build via local `Directory.Build.props`,
> `Directory.Packages.props` and `nuget.config` (no StyleCop / warnings-as-errors / central package
> management; restores from public nuget.org). Delete these three files when promoting the module to
> follow standard PowerToys build rules.
## Tests
```powershell
cd src\modules\PowerScripts
dotnet test PowerScripts.Core.Tests\PowerScripts.Core.Tests.csproj
```
`PowerScripts.Core.Tests` (MSTest) covers manifest serialization/validation and the registry
(extension + wildcard matching, multi-file selection min/max, kind filtering, invalid-script
skipping). 9 tests, all passing.
## Surface integration plans
### 1. Keyboard Manager (system actions) — first priority
KBM already has a `RunProgram` action, so a hotkey → PowerScript works **today**. Get the exact
mapping for a system script:
```powershell
& $exe kbm system-snapshot # prints Program path + Arguments for the editor
& $exe kbm system-snapshot --json # prints the raw remapShortcutsToRunProgram object
```
Then in Keyboard Manager → *Remap a shortcut* → action **Run Program**, paste the Program path and
`run <id>` arguments and choose the trigger keys. The mapping persists as the existing engine shape
(verified against `common/KeyboardManagerConstants.h`):
```json
{ "operationType": 1, "runProgramFilePath": "…\\PowerScripts.Host.exe", "runProgramArgs": "run system-snapshot", "unicodeText": "*Unsupported*" }
```
**Prototype goal — pick a PowerScript inside the editor** (instead of typing a path). The editor is
**C# WinUI 3** (`PowerToys.KeyboardManagerEditorUI.exe`), a separate process that already reads JSON
at runtime, so it can call `Host.exe list --json` to populate a script dropdown. Additive change-list
(verified against the current source):
- `Controls/UnifiedMappingControl.xaml.cs` — the nested `enum ActionType` (KeyOrShortcut, Text,
OpenUrl, OpenApp, MouseClick, Disable): add a `PowerScript` value; extend `CurrentActionType`,
`SetActionType`, `IsInputComplete`.
- `Controls/UnifiedMappingControl.xaml` — add a `ComboBoxItem` (Tag `PowerScript`) to
`ActionTypeComboBox` and a `SwitchPresenter` `Case` hosting a script-picker ComboBox.
- `Pages/MainPage.xaml.cs` — add a `UnifiedMappingControl.ActionType.PowerScript` arm to the save
`switch` (~line 390) that reuses the `SaveProgramMapping` path with
`ProgramPath = <PowerScripts.Host.exe>` and `ProgramArgs = "run <id>"`.
- A small helper in `KeyboardManagerEditorUI` to load the script list (shell out to `Host.exe
list --json`, like `Settings/SettingsManager.cs` reads its JSON).
- **No KBM engine change** — it stays a `RunProgram` mapping.
> The editor-picker edits live in the shared KBM WinUI project, which needs the full PowerToys build
> (VS + internal NuGet feeds) to compile — do them in that environment. The `kbm` command above is
> the verifiable, build-free path that already delivers hotkey → PowerScript.
### 2. Explorer right-click (file actions)
A single compiled `IExplorerCommand` COM handler (pattern: `src/modules/NewPlus/NewShellExtensionContextMenu`)
reads the registry, filters `kind:"file"` scripts whose `input.extensions` match the selection, and
shows a dynamic submenu. Invoking an item runs `Host.exe run <id> --files <paths>`.
### Deferred (kept easy by the registry design)
Command Palette (one `ICommandProvider` extension enumerating system scripts) and Advanced Paste —
both become additional registry-reading adapters. No core changes expected.
## Agent / AI tie-in (designed-for)
`Host.exe list --json` already yields a structured, permissioned capability list and `run <id>` is
the invoke — so an MCP server can expose installed PowerScripts as user-consented tools. AI authoring
("generate a PowerScript that…") emits a manifest + script folder the user reviews once.

View File

@@ -1,97 +0,0 @@
<#
.SYNOPSIS
End-to-end test helper for invoking a PowerScript from Keyboard Manager (new editor).
.DESCRIPTION
Self-contained KBM e2e that doesn't require the full PowerToys runner:
1. Forces the *new* Keyboard Manager editor (useNewEditor = true).
2. Launches PowerToys.KeyboardManagerEditorUI.exe so you can add a shortcut whose
action is "PowerScript" -> pick a system script (e.g. "Volume Up") -> Save.
3. Starts PowerToys.KeyboardManagerEngine.exe standalone, which reads the saved
default.json and installs the keyboard hook. Press your shortcut and the engine
runs PowerScripts.Host.exe run <id>.
Defaults assume a Debug build under <repo>\x64\Debug. Use -Configuration Release for a
release layout.
.EXAMPLE
# Configure a hotkey, then start the engine and test:
pwsh -File kbm-e2e.ps1
.EXAMPLE
# Skip the editor; just (re)start the engine to apply the current mappings:
pwsh -File kbm-e2e.ps1 -EngineOnly
#>
[CmdletBinding()]
param(
[switch]$EngineOnly,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Debug'
)
$ErrorActionPreference = 'Stop'
# Repo root = four levels up from src\modules\PowerScripts.
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..')).Path
$binRoot = Join-Path $repoRoot "x64\$Configuration"
$editorExe = Join-Path $binRoot 'WinUI3Apps\PowerToys.KeyboardManagerEditorUI.exe'
$engineExe = Join-Path $binRoot 'KeyboardManagerEngine\PowerToys.KeyboardManagerEngine.exe'
$kbmDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\Keyboard Manager'
$settings = Join-Path $kbmDir 'settings.json'
function Stop-ProcessesByName([string[]]$names)
{
$ids = Get-Process -ErrorAction SilentlyContinue | Where-Object { $names -contains $_.Name } | Select-Object -ExpandProperty Id
foreach ($id in $ids) { try { Stop-Process -Id $id -Force } catch { } }
}
if (-not (Test-Path $engineExe)) { throw "Engine not found: $engineExe. Build KeyboardManagerEngine first." }
# 1. Force the new editor.
if (Test-Path $settings)
{
$json = Get-Content $settings -Raw | ConvertFrom-Json
if ($json.properties.PSObject.Properties.Name -contains 'useNewEditor')
{
$json.properties.useNewEditor = $true
}
($json | ConvertTo-Json -Depth 10) | Set-Content $settings -Encoding UTF8
Write-Host 'Set useNewEditor = true.'
}
# 2. Launch the new editor (unless engine-only) and wait for the user to finish.
if (-not $EngineOnly)
{
if (-not (Test-Path $editorExe)) { throw "Editor not found: $editorExe. Build KeyboardManagerEditorUI first." }
Write-Host ''
Write-Host 'Opening the NEW Keyboard Manager editor.' -ForegroundColor Cyan
Write-Host ' - Click "Add shortcut", set a trigger (e.g. Ctrl+Alt+U).'
Write-Host ' - Action type -> PowerScript -> pick a System script (e.g. Volume Up).'
Write-Host ' - Save, then CLOSE the editor window to continue.'
Write-Host ''
# Pass this process id as the parent so the editor stays open until you close it.
$editor = Start-Process -FilePath $editorExe -ArgumentList "$PID" -PassThru
$editor.WaitForExit()
Write-Host 'Editor closed.'
}
# 3. (Re)start the engine standalone so it applies the saved mappings.
Stop-ProcessesByName @('PowerToys.KeyboardManagerEngine')
Start-Sleep -Milliseconds 500
$engine = Start-Process -FilePath $engineExe -PassThru
Start-Sleep -Seconds 1
if (Get-Process -Id $engine.Id -ErrorAction SilentlyContinue)
{
Write-Host ''
Write-Host "KBM engine running (pid $($engine.Id))." -ForegroundColor Green
Write-Host 'Press your configured shortcut now — the PowerScript should run.'
Write-Host "Stop the engine when done: Stop-Process -Id $($engine.Id)"
}
else
{
throw 'Engine exited immediately. Check the KBM logs under the Keyboard Manager\Logs folder.'
}

View File

@@ -1,11 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
PROTOTYPE-ONLY: restore the isolated PowerScripts prototype projects from public nuget.org instead
of the repo's auth-gated internal feed. Remove when promoting the module to the standard build.
-->
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
</packageSources>
</configuration>

View File

@@ -1,21 +0,0 @@
{
"schemaVersion": 1,
"id": "convert_md_to_txt",
"name": "Convert Markdown to Text",
"description": "Convert the selected Markdown file(s) to a plain .txt file next to the original.",
"kind": "file",
"runtime": "powershell",
"entry": "run.ps1",
"input": {
"extensions": [".md"],
"minFiles": 1,
"maxFiles": 0
},
"output": {
"type": "convertedFile",
"extension": ".txt"
},
"surfaces": ["contextMenu"],
"capabilities": ["fileRead", "fileWrite"],
"elevation": "asInvoker"
}

View File

@@ -1,35 +0,0 @@
# Convert Markdown to Text — a "file" PowerScript surfaced on .md right-click.
# Writes a plain .txt next to each selected .md file (light Markdown stripping).
param(
[string[]]$Files
)
if (-not $Files -or $Files.Count -eq 0) {
if ($env:POWERSCRIPTS_FILES) {
$Files = $env:POWERSCRIPTS_FILES -split "`n"
}
}
if (-not $Files -or $Files.Count -eq 0) {
Write-Error 'No files provided.'
exit 1
}
foreach ($f in $Files) {
$path = $f.Trim()
if (-not $path) { continue }
if (-not (Test-Path -LiteralPath $path)) {
Write-Warning "Not found: $path"
continue
}
$text = Get-Content -LiteralPath $path -Raw
# Light Markdown stripping: headings, emphasis markers, inline code backticks.
$text = $text -replace '(?m)^\s{0,3}#{1,6}\s*', ''
$text = $text -replace '(\*\*|__|\*|_|`)', ''
$out = [System.IO.Path]::ChangeExtension($path, '.txt')
Set-Content -LiteralPath $out -Value $text -Encoding UTF8
"Converted: $out"
}

View File

@@ -1,20 +0,0 @@
{
"schemaVersion": 1,
"id": "sha256-checksum",
"name": "Compute SHA-256",
"description": "Compute the SHA-256 checksum of the selected file(s).",
"kind": "file",
"runtime": "powershell",
"entry": "run.ps1",
"input": {
"extensions": ["*"],
"minFiles": 1,
"maxFiles": 0
},
"output": {
"type": "sideEffect"
},
"surfaces": ["contextMenu"],
"capabilities": ["fileRead"],
"elevation": "asInvoker"
}

View File

@@ -1,30 +0,0 @@
# Compute SHA-256 — a "file" PowerScript.
# Surfaced in the Explorer right-click menu for the selected file(s).
# Files arrive both as -Files and via the POWERSCRIPTS_FILES environment variable.
param(
[string[]]$Files
)
if (-not $Files -or $Files.Count -eq 0) {
if ($env:POWERSCRIPTS_FILES) {
$Files = $env:POWERSCRIPTS_FILES -split "`n"
}
}
if (-not $Files -or $Files.Count -eq 0) {
Write-Error 'No files provided.'
exit 1
}
foreach ($f in $Files) {
$path = $f.Trim()
if (-not $path) { continue }
if (-not (Test-Path -LiteralPath $path)) {
Write-Warning "Not found: $path"
continue
}
$hash = Get-FileHash -LiteralPath $path -Algorithm SHA256
'{0} {1}' -f $hash.Hash, $path
}

View File

@@ -1,12 +0,0 @@
{
"schemaVersion": 1,
"id": "system-snapshot",
"name": "System Snapshot",
"description": "Show computer name, OS and uptime.",
"kind": "system",
"runtime": "powershell",
"entry": "run.ps1",
"surfaces": ["keyboardManager", "commandPalette"],
"capabilities": ["systemInfo"],
"elevation": "asInvoker"
}

View File

@@ -1,12 +0,0 @@
# System Snapshot — a "system" PowerScript (no file input).
# Surfaced via a Keyboard Manager hotkey or the Command Palette.
$os = Get-CimInstance Win32_OperatingSystem -ErrorAction SilentlyContinue
[pscustomobject]@{
Computer = $env:COMPUTERNAME
User = $env:USERNAME
OS = if ($os) { $os.Caption } else { [System.Environment]::OSVersion.VersionString }
Uptime = if ($os) { (Get-Date) - $os.LastBootUpTime } else { 'n/a' }
Time = (Get-Date).ToString('s')
} | Format-List

View File

@@ -1,12 +0,0 @@
{
"schemaVersion": 1,
"id": "volume_up",
"name": "Volume Up",
"description": "Raise the system volume a few steps.",
"kind": "system",
"runtime": "powershell",
"entry": "run.ps1",
"surfaces": ["keyboardManager", "commandPalette"],
"capabilities": ["systemControl"],
"elevation": "asInvoker"
}

View File

@@ -1,11 +0,0 @@
# Volume Up — a "system" PowerScript (no file input).
# Assign it to a hotkey in Keyboard Manager. Sends the system "Volume Up" media key a few times.
$wsh = New-Object -ComObject WScript.Shell
for ($i = 0; $i -lt 4; $i++) {
# 0xAF (175) is the Volume Up virtual key.
$wsh.SendKeys([char]175)
Start-Sleep -Milliseconds 40
}
'Volume raised.'

View File

@@ -212,12 +212,6 @@
<TextBlock x:Uid="ActionType_Disable_Text" />
</StackPanel>
</ComboBoxItem>
<ComboBoxItem Tag="PowerScript">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="14" Glyph="&#xE756;" />
<TextBlock Text="PowerScript" />
</StackPanel>
</ComboBoxItem>
<!--
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
<StackPanel Orientation="Horizontal" Spacing="8">
@@ -404,27 +398,6 @@
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
</tkcontrols:Case>
<!-- PowerScript Action -->
<tkcontrols:Case Value="PowerScript">
<StackPanel Orientation="Vertical" Spacing="8">
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="Run a PowerScript when this shortcut is pressed."
TextWrapping="Wrap" />
<ComboBox
x:Name="PowerScriptComboBox"
HorizontalAlignment="Stretch"
DisplayMemberPath="Name"
PlaceholderText="Select a PowerScript"
SelectionChanged="PowerScriptComboBox_SelectionChanged" />
<TextBlock
x:Name="PowerScriptEmptyHint"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="No PowerScripts found. Enable the PowerScripts module and add a system script."
TextWrapping="Wrap"
Visibility="Collapsed" />
</StackPanel>
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</StackPanel>
<!-- Validation InfoBar spanning all columns -->

View File

@@ -34,8 +34,6 @@ namespace KeyboardManagerEditorUI.Controls
private readonly ObservableCollection<string> _triggerKeys = new();
private readonly ObservableCollection<string> _actionKeys = new();
private readonly ObservableCollection<Helpers.PowerScriptInfo> _powerScripts = new();
private bool _disposed;
private bool _internalUpdate;
@@ -81,7 +79,6 @@ namespace KeyboardManagerEditorUI.Controls
OpenApp,
MouseClick,
Disable,
PowerScript,
}
/// <summary>
@@ -135,7 +132,6 @@ namespace KeyboardManagerEditorUI.Controls
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
"PowerScript" => ActionType.PowerScript,
_ => ActionType.KeyOrShortcut,
};
}
@@ -155,14 +151,6 @@ namespace KeyboardManagerEditorUI.Controls
TriggerKeys.ItemsSource = _triggerKeys;
ActionKeys.ItemsSource = _actionKeys;
// Populate the PowerScripts picker (system scripts). Empty when PowerScripts isn't installed.
foreach (var script in Helpers.PowerScriptsCatalog.GetSystemScripts())
{
_powerScripts.Add(script);
}
PowerScriptComboBox.ItemsSource = _powerScripts;
_triggerKeys.CollectionChanged += (_, _) =>
{
UpdatePlaceholderVisibility();
@@ -279,18 +267,6 @@ namespace KeyboardManagerEditorUI.Controls
RaiseValidationStateChanged();
}
private void PowerScriptComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (PowerScriptEmptyHint != null)
{
PowerScriptEmptyHint.Visibility = _powerScripts.Count == 0
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
}
RaiseValidationStateChanged();
}
private void ActionKeyToggleBtn_Checked(object sender, RoutedEventArgs e)
{
if (ActionKeyToggleBtn.IsChecked == true)
@@ -776,26 +752,6 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public string GetUrl() => UrlPathInput?.Text ?? string.Empty;
/// <summary>
/// Gets the selected PowerScript (for the PowerScript action type), or null if none selected.
/// </summary>
public Helpers.PowerScriptInfo? GetSelectedPowerScript() => PowerScriptComboBox?.SelectedItem as Helpers.PowerScriptInfo;
/// <summary>
/// Selects the PowerScript with the given id in the picker, if present.
/// </summary>
public void SelectPowerScript(string id)
{
foreach (var script in _powerScripts)
{
if (string.Equals(script.Id, id, StringComparison.OrdinalIgnoreCase))
{
PowerScriptComboBox.SelectedItem = script;
return;
}
}
}
/// <summary>
/// Gets the program path (for OpenApp action type).
/// </summary>

View File

@@ -1,22 +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.
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// A single PowerScript entry as surfaced to the Keyboard Manager editor's "PowerScript" action picker.
/// </summary>
public sealed class PowerScriptInfo
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public override string ToString() => Name;
}
}

View File

@@ -1,147 +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.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// Bridges the Keyboard Manager editor to the PowerScripts module.
///
/// PowerScripts are surfaced through the shared executor <c>PowerScripts.Host.exe</c>. To keep the
/// editor decoupled from the PowerScripts assemblies, we shell out to <c>Host.exe list --json</c>
/// and parse the result. Selecting a "system" PowerScript in the editor then saves an ordinary
/// Keyboard Manager "Run Program" mapping whose target is <c>Host.exe run &lt;id&gt;</c>.
/// </summary>
public static class PowerScriptsCatalog
{
private const string HostExeName = "PowerScripts.Host.exe";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Resolves the full path to <c>PowerScripts.Host.exe</c>, or null if it can't be found.
/// Search order: explicit override env var, next to the editor, then the default install root.
/// </summary>
public static string? ResolveHostPath()
{
var overridePath = Environment.GetEnvironmentVariable("POWERSCRIPTS_HOST");
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
{
return overridePath;
}
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, HostExeName),
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"PowerScripts",
HostExeName),
};
// Prototype dev fallback: in an in-repo build the Host isn't copied next to the editor,
// so walk up from the base directory and probe the Host project's bin output. This keeps
// the PowerScript action usable for end-to-end testing from a Debug build.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
foreach (var config in new[] { "Debug", "Release" })
{
var hostBin = Path.Combine(
dir.FullName,
"src",
"modules",
"PowerScripts",
"PowerScripts.Host",
"bin",
config);
if (Directory.Exists(hostBin))
{
var found = Directory
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrEmpty(found))
{
candidates.Add(found);
}
}
}
dir = dir.Parent;
}
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Returns the list of system PowerScripts available for hotkey assignment, or an empty list
/// when PowerScripts isn't installed or no system scripts exist.
/// </summary>
public static IReadOnlyList<PowerScriptInfo> GetSystemScripts()
{
var hostPath = ResolveHostPath();
if (hostPath is null)
{
return Array.Empty<PowerScriptInfo>();
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = "list --json",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process is null)
{
return Array.Empty<PowerScriptInfo>();
}
string json = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
var all = JsonSerializer.Deserialize<List<PowerScriptInfo>>(json, JsonOptions) ?? new List<PowerScriptInfo>();
var systemScripts = new List<PowerScriptInfo>();
foreach (var script in all)
{
if (string.Equals(script.Kind, "system", StringComparison.OrdinalIgnoreCase))
{
systemScripts.Add(script);
}
}
return systemScripts;
}
catch (Exception)
{
// Prototype: a missing/failed PowerScripts host simply yields no scripts to pick.
return Array.Empty<PowerScriptInfo>();
}
}
}
}

View File

@@ -394,7 +394,6 @@ namespace KeyboardManagerEditorUI.Pages
UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys),
UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys),
UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys),
UnifiedMappingControl.ActionType.PowerScript => SavePowerScriptMapping(triggerKeys),
UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."),
_ => false,
};
@@ -440,10 +439,6 @@ namespace KeyboardManagerEditorUI.Pages
triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode),
UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping(
triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping),
UnifiedMappingControl.ActionType.PowerScript => UnifiedMappingControl.GetSelectedPowerScript() is null
? ValidationErrorType.EmptyProgramPath
: ValidationHelper.ValidateAppMapping(
triggerKeys, PowerScriptsCatalog.ResolveHostPath() ?? string.Empty, isAppSpecific, appName, _mappingService!, _isEditMode),
_ => ValidationErrorType.NoError,
};
}
@@ -688,47 +683,6 @@ namespace KeyboardManagerEditorUI.Pages
return saved;
}
private bool SavePowerScriptMapping(List<string> triggerKeys)
{
var script = UnifiedMappingControl.GetSelectedPowerScript();
if (script is null)
{
return false;
}
string hostPath = PowerScriptsCatalog.ResolveHostPath() ?? string.Empty;
if (string.IsNullOrEmpty(hostPath))
{
return false;
}
string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
// A PowerScript hotkey is an ordinary "Run Program" mapping that invokes the shared executor.
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RunProgram,
OriginalKeys = originalKeysString,
TargetKeys = originalKeysString,
ProgramPath = hostPath,
ProgramArgs = $"run {script.Id}",
StartInDirectory = string.Empty,
IfRunningAction = ProgramAlreadyRunningAction.StartAnother,
Visibility = StartWindowType.Hidden,
Elevation = ElevationLevel.NonElevated,
TargetApp = string.Empty,
};
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
if (saved)
{
_mappingService.SaveSettings();
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
}
return saved;
}
#endregion
#region Delete Handlers

View File

@@ -288,6 +288,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
L"PowerToys.LightSwitchModuleInterface.dll",
L"PowerToys.PowerDisplayModuleInterface.dll",
L"PowerToys.GrabAndMoveModuleInterface.dll",
L"PowerToys.AltWindowCycle.dll",
};
for (auto moduleSubdir : knownModules)

View File

@@ -25,6 +25,7 @@ internal static class ModuleGpoHelper
ModuleType.FancyZones => GPOWrapper.GetConfiguredFancyZonesEnabledValue(),
ModuleType.FileLocksmith => GPOWrapper.GetConfiguredFileLocksmithEnabledValue(),
ModuleType.FindMyMouse => GPOWrapper.GetConfiguredFindMyMouseEnabledValue(),
ModuleType.AltWindowCycle => GPOWrapper.GetConfiguredAltWindowCycleEnabledValue(),
ModuleType.Hosts => GPOWrapper.GetConfiguredHostsFileEditorEnabledValue(),
ModuleType.ImageResizer => GPOWrapper.GetConfiguredImageResizerEnabledValue(),
ModuleType.KeyboardManager => GPOWrapper.GetConfiguredKeyboardManagerEnabledValue(),

View File

@@ -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.Text.Json.Serialization;
using Settings.UI.Library.Attributes;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class AltWindowCycleProperties
{
// Alt+` cycles to the next window of the focused app.
[JsonIgnore]
[CmdConfigureIgnore]
public HotkeySettings DefaultNextWindowShortcut => new HotkeySettings(false, false, true, false, 0xC0);
// Shift+Alt+` cycles to the previous window of the focused app.
[JsonIgnore]
[CmdConfigureIgnore]
public HotkeySettings DefaultPreviousWindowShortcut => new HotkeySettings(false, false, true, true, 0xC0);
[JsonPropertyName("next_window_shortcut")]
public HotkeySettings NextWindowShortcut { get; set; }
[JsonPropertyName("previous_window_shortcut")]
public HotkeySettings PreviousWindowShortcut { get; set; }
public AltWindowCycleProperties()
{
NextWindowShortcut = DefaultNextWindowShortcut;
PreviousWindowShortcut = DefaultPreviousWindowShortcut;
}
}
}

View File

@@ -0,0 +1,57 @@
// 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.Text.Json.Serialization;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class AltWindowCycleSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig
{
public const string ModuleName = "AltWindowCycle";
[JsonPropertyName("properties")]
public AltWindowCycleProperties Properties { get; set; }
public AltWindowCycleSettings()
{
Name = ModuleName;
Properties = new AltWindowCycleProperties();
Version = "1.0";
}
public string GetModuleName()
{
return Name;
}
public ModuleType GetModuleType() => ModuleType.AltWindowCycle;
public HotkeyAccessor[] GetAllHotkeyAccessors()
{
var hotkeyAccessors = new List<HotkeyAccessor>
{
new HotkeyAccessor(
() => Properties.NextWindowShortcut,
value => Properties.NextWindowShortcut = value ?? Properties.DefaultNextWindowShortcut,
"AltWindowCycle_NextWindowShortcut"),
new HotkeyAccessor(
() => Properties.PreviousWindowShortcut,
value => Properties.PreviousWindowShortcut = value ?? Properties.DefaultPreviousWindowShortcut,
"AltWindowCycle_PreviousWindowShortcut"),
};
return hotkeyAccessors.ToArray();
}
// This can be utilized in the future if the settings.json file is to be modified/deleted.
public bool UpgradeSettingsConfiguration()
{
return false;
}
}
}

View File

@@ -218,18 +218,18 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
private bool powerScripts; // defaulting to off
private bool altWindowCycle; // defaulting to off
[JsonPropertyName("PowerScripts")]
public bool PowerScripts
[JsonPropertyName("AltWindowCycle")]
public bool AltWindowCycle
{
get => powerScripts;
get => altWindowCycle;
set
{
if (powerScripts != value)
if (altWindowCycle != value)
{
LogTelemetryEvent(value);
powerScripts = value;
altWindowCycle = value;
}
}
}

View File

@@ -37,7 +37,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.MeasureTool => "ms-appx:///Assets/Settings/Icons/ScreenRuler.png",
ModuleType.PowerLauncher => "ms-appx:///Assets/Settings/Icons/PowerToysRun.png",
ModuleType.GeneralSettings => "ms-appx:///Assets/Settings/Icons/PowerToys.png",
ModuleType.PowerScripts => "ms-appx:///Assets/Settings/Icons/PowerToys.png",
_ => $"ms-appx:///Assets/Settings/Icons/{moduleType}.png",
};
}
@@ -57,6 +56,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.FancyZones => generalSettingsConfig.Enabled.FancyZones,
ModuleType.FileLocksmith => generalSettingsConfig.Enabled.FileLocksmith,
ModuleType.FindMyMouse => generalSettingsConfig.Enabled.FindMyMouse,
ModuleType.AltWindowCycle => generalSettingsConfig.Enabled.AltWindowCycle,
ModuleType.Hosts => generalSettingsConfig.Enabled.Hosts,
ModuleType.ImageResizer => generalSettingsConfig.Enabled.ImageResizer,
ModuleType.KeyboardManager => generalSettingsConfig.Enabled.KeyboardManager,
@@ -78,7 +78,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.Workspaces => generalSettingsConfig.Enabled.Workspaces,
ModuleType.GrabAndMove => generalSettingsConfig.Enabled.GrabAndMove,
ModuleType.ZoomIt => generalSettingsConfig.Enabled.ZoomIt,
ModuleType.PowerScripts => generalSettingsConfig.Enabled.PowerScripts,
ModuleType.GeneralSettings => generalSettingsConfig.EnableQuickAccess,
_ => false,
};
@@ -99,6 +98,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
case ModuleType.FancyZones: generalSettingsConfig.Enabled.FancyZones = isEnabled; break;
case ModuleType.FileLocksmith: generalSettingsConfig.Enabled.FileLocksmith = isEnabled; break;
case ModuleType.FindMyMouse: generalSettingsConfig.Enabled.FindMyMouse = isEnabled; break;
case ModuleType.AltWindowCycle: generalSettingsConfig.Enabled.AltWindowCycle = isEnabled; break;
case ModuleType.Hosts: generalSettingsConfig.Enabled.Hosts = isEnabled; break;
case ModuleType.ImageResizer: generalSettingsConfig.Enabled.ImageResizer = isEnabled; break;
case ModuleType.KeyboardManager: generalSettingsConfig.Enabled.KeyboardManager = isEnabled; break;
@@ -120,7 +120,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
case ModuleType.Workspaces: generalSettingsConfig.Enabled.Workspaces = isEnabled; break;
case ModuleType.GrabAndMove: generalSettingsConfig.Enabled.GrabAndMove = isEnabled; break;
case ModuleType.ZoomIt: generalSettingsConfig.Enabled.ZoomIt = isEnabled; break;
case ModuleType.PowerScripts: generalSettingsConfig.Enabled.PowerScripts = isEnabled; break;
case ModuleType.GeneralSettings: generalSettingsConfig.EnableQuickAccess = isEnabled; break;
}
}
@@ -144,6 +143,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.FancyZones => FancyZonesSettings.ModuleName,
ModuleType.FileLocksmith => FileLocksmithSettings.ModuleName,
ModuleType.FindMyMouse => FindMyMouseSettings.ModuleName,
ModuleType.AltWindowCycle => AltWindowCycleSettings.ModuleName,
ModuleType.Hosts => HostsSettings.ModuleName,
ModuleType.ImageResizer => ImageResizerSettings.ModuleName,
ModuleType.KeyboardManager => KeyboardManagerSettings.ModuleName,
@@ -165,7 +165,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.Workspaces => WorkspacesSettings.ModuleName,
ModuleType.GrabAndMove => GrabAndMoveSettings.ModuleName,
ModuleType.ZoomIt => ZoomItSettings.ModuleName,
ModuleType.PowerScripts => "PowerScripts", // Prototype: no dedicated settings class
_ => moduleType.ToString(),
};
}

View File

@@ -44,6 +44,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonSerializable(typeof(GeneralSettings))]
[JsonSerializable(typeof(OutGoingGeneralSettings))]
[JsonSerializable(typeof(AdvancedPasteSettings))]
[JsonSerializable(typeof(AltWindowCycleSettings))]
[JsonSerializable(typeof(AlwaysOnTopSettings))]
[JsonSerializable(typeof(AwakeSettings))]
[JsonSerializable(typeof(CmdNotFoundSettings))]

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -27,6 +27,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
case ModuleType.FancyZones: return GPOWrapper.GetConfiguredFancyZonesEnabledValue();
case ModuleType.FileLocksmith: return GPOWrapper.GetConfiguredFileLocksmithEnabledValue();
case ModuleType.FindMyMouse: return GPOWrapper.GetConfiguredFindMyMouseEnabledValue();
case ModuleType.AltWindowCycle: return GPOWrapper.GetConfiguredAltWindowCycleEnabledValue();
case ModuleType.Hosts: return GPOWrapper.GetConfiguredHostsFileEditorEnabledValue();
case ModuleType.ImageResizer: return GPOWrapper.GetConfiguredImageResizerEnabledValue();
case ModuleType.KeyboardManager: return GPOWrapper.GetConfiguredKeyboardManagerEnabledValue();
@@ -59,7 +60,6 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
ModuleType.AdvancedPaste => typeof(AdvancedPastePage),
ModuleType.AlwaysOnTop => typeof(AlwaysOnTopPage),
ModuleType.Awake => typeof(AwakePage),
ModuleType.PowerScripts => typeof(PowerScriptsPage),
ModuleType.CmdPal => typeof(CmdPalPage),
ModuleType.ColorPicker => typeof(ColorPickerPage),
ModuleType.CropAndLock => typeof(CropAndLockPage),
@@ -69,6 +69,7 @@ namespace Microsoft.PowerToys.Settings.UI.Helpers
ModuleType.FancyZones => typeof(FancyZonesPage),
ModuleType.FileLocksmith => typeof(FileLocksmithPage),
ModuleType.FindMyMouse => typeof(MouseUtilsPage),
ModuleType.AltWindowCycle => typeof(AltWindowCyclePage),
ModuleType.GeneralSettings => typeof(GeneralPage),
ModuleType.Hosts => typeof(HostsPage),
ModuleType.ImageResizer => typeof(ImageResizerPage),

View File

@@ -22,6 +22,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
[JsonSerializable(typeof(Dictionary<string, List<string>>))]
[JsonSerializable(typeof(FileLocksmithSettings))]
[JsonSerializable(typeof(FindMyMouseSettings))]
[JsonSerializable(typeof(AltWindowCycleSettings))]
[JsonSerializable(typeof(IList<PowerToysReleaseInfo>))]
[JsonSerializable(typeof(KeyboardManagerSettings))]
[JsonSerializable(typeof(LightSwitchSettings))]

View File

@@ -416,9 +416,9 @@ namespace Microsoft.PowerToys.Settings.UI
case "Dashboard": return typeof(DashboardPage);
case "Overview": return typeof(GeneralPage);
case "AdvancedPaste": return typeof(AdvancedPastePage);
case "AltWindowCycle": return typeof(AltWindowCyclePage);
case "AlwaysOnTop": return typeof(AlwaysOnTopPage);
case "Awake": return typeof(AwakePage);
case "PowerScripts": return typeof(PowerScriptsPage);
case "CmdNotFound": return typeof(CmdNotFoundPage);
case "ColorPicker": return typeof(ColorPickerPage);
case "LightSwitch": return typeof(LightSwitchPage);

View File

@@ -0,0 +1,39 @@
<local:NavigablePage
x:Class="Microsoft.PowerToys.Settings.UI.Views.AltWindowCyclePage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
AutomationProperties.LandmarkType="Main"
mc:Ignorable="d">
<local:NavigablePage.Resources />
<controls:SettingsPageControl
x:Uid="AltWindowCycle"
IsTabStop="False"
ModuleImageSource="ms-appx:///Assets/Settings/Modules/AltWindowCycle.png">
<controls:SettingsPageControl.ModuleContent>
<StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical">
<controls:GPOInfoControl ShowWarning="{x:Bind ViewModel.IsEnabledGpoConfigured, Mode=OneWay}">
<tkcontrols:SettingsCard
Name="AltWindowCycleEnableToggleControlHeaderText"
x:Uid="AltWindowCycle_EnableToggleControl_HeaderText"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AltWindowCycle.png}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:GPOInfoControl>
<controls:SettingsGroup x:Uid="AltWindowCycle_Activation_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="AltWindowCycle_NextWindowShortcut">
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.NextWindowShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="AltWindowCycle_PreviousWindowShortcut">
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.PreviousWindowShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
</controls:SettingsPageControl>
</local:NavigablePage>

View File

@@ -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.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Settings.UI.Views
{
public sealed partial class AltWindowCyclePage : NavigablePage, IRefreshablePage
{
private AltWindowCycleViewModel ViewModel { get; set; }
public AltWindowCyclePage()
{
var settingsUtils = SettingsUtils.Default;
ViewModel = new AltWindowCycleViewModel(settingsUtils, SettingsRepository<GeneralSettings>.GetInstance(settingsUtils), SettingsRepository<AltWindowCycleSettings>.GetInstance(settingsUtils), ShellPage.SendDefaultIPCMessage);
DataContext = ViewModel;
InitializeComponent();
Loaded += (s, e) => ViewModel.OnPageLoaded();
}
public void RefreshEnabledState()
{
ViewModel.RefreshEnabledState();
}
}
}

View File

@@ -1,119 +0,0 @@
<local:NavigablePage
x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerScriptsPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:ui="using:CommunityToolkit.WinUI"
xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels"
d:DataContext="{d:DesignInstance Type=viewModels:PowerScriptsViewModel}"
AutomationProperties.LandmarkType="Main"
mc:Ignorable="d">
<controls:SettingsPageControl
x:Name="PowerScriptsSettingsPage"
ModuleDescription="Write a small script once and surface it everywhere — the Explorer right-click menu and Keyboard Manager. This is an experimental prototype."
ModuleTitle="PowerScripts"
IsTabStop="False">
<controls:SettingsPageControl.ModuleContent>
<StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical">
<tkcontrols:SettingsCard
Name="PowerScriptsEnableSettingsCard"
Description="Enable PowerScripts"
Header="PowerScripts"
HeaderIcon="{ui:FontIcon Glyph=&#xE756;}">
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PowerScriptsFolderSettingsCard"
Description="{x:Bind ViewModel.ScriptsFolder, Mode=OneWay}"
Header="Scripts folder"
HeaderIcon="{ui:FontIcon Glyph=&#xE8B7;}"
IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button
x:Name="BrowseScriptsFolderButton"
Click="BrowseScriptsFolderButton_Click"
Content="Browse..." />
<Button
x:Name="ResetScriptsFolderButton"
Click="ResetScriptsFolderButton_Click"
Content="Reset"
IsEnabled="{x:Bind ViewModel.IsCustomFolder, Mode=OneWay}" />
</StackPanel>
</tkcontrols:SettingsCard>
<controls:SettingsGroup Header="Installed scripts" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<InfoBar
IsClosable="False"
IsOpen="{x:Bind ViewModel.HasScripts, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
Message="No PowerScripts found. Pick a scripts folder above (or use the default) that contains script subfolders, each with a manifest.json."
Severity="Informational" />
<ItemsControl ItemsSource="{x:Bind ViewModel.Scripts, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="viewModels:PowerScriptListItem">
<tkcontrols:SettingsExpander
Margin="0,2,0,2"
Description="{x:Bind Description}"
Header="{x:Bind Name}"
IsExpanded="False">
<tkcontrols:SettingsExpander.HeaderIcon>
<FontIcon Glyph="{x:Bind KindGlyph}" />
</tkcontrols:SettingsExpander.HeaderIcon>
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Kind}" />
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard
ContentAlignment="Right"
Header="Trigger on file types"
Visibility="{x:Bind IsFileScript, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ExtensionsDisplay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Runtime">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind RuntimeDisplay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Surfaces">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind SurfacesDisplay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Capabilities">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind CapabilitiesDisplay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Trust">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind TrustDisplay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>
<controls:PageLink Link="https://aka.ms/PowerToysOverview" Text="Documentation" />
</controls:SettingsPageControl.PrimaryLinks>
</controls:SettingsPageControl>
</local:NavigablePage>

View File

@@ -1,61 +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.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.UI.Xaml;
namespace Microsoft.PowerToys.Settings.UI.Views
{
public sealed partial class PowerScriptsPage : NavigablePage, IRefreshablePage
{
private readonly SettingsUtils _settingsUtils;
private readonly SettingsRepository<GeneralSettings> _generalSettingsRepository;
private PowerScriptsViewModel ViewModel { get; set; }
public PowerScriptsPage()
{
_settingsUtils = SettingsUtils.Default;
_generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils);
ViewModel = new PowerScriptsViewModel(_generalSettingsRepository, ShellPage.SendDefaultIPCMessage);
DataContext = ViewModel;
InitializeComponent();
}
public void RefreshEnabledState()
{
ViewModel.ReloadScripts();
}
private async void BrowseScriptsFolderButton_Click(object sender, RoutedEventArgs e)
{
var folder = await PickSingleFolderDialog();
if (!string.IsNullOrWhiteSpace(folder))
{
ViewModel.SetScriptsFolder(folder);
}
}
private void ResetScriptsFolderButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.ResetScriptsFolder();
}
private async Task<string> PickSingleFolderDialog()
{
// Use the shell32 folder dialog (works even when Settings runs elevated), matching GeneralPage.
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
return await Task.FromResult(ShellGetFolder.GetFolderDialog(hwnd));
}
}
}

View File

@@ -198,12 +198,6 @@
helpers:NavHelper.NavigateTo="views:AwakePage"
AutomationProperties.AutomationId="AwakeNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" />
<NavigationViewItem
x:Name="PowerScriptsNavigationItem"
Content="PowerScripts"
helpers:NavHelper.NavigateTo="views:PowerScriptsPage"
AutomationProperties.AutomationId="PowerScriptsNavItem"
Icon="{ui:FontIcon Glyph=&#xE756;}" />
<NavigationViewItem
x:Name="CmdPalNavigationItem"
x:Uid="Shell_CmdPal"
@@ -270,6 +264,12 @@
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/WindowingAndLayouts.png}"
SelectsOnInvoked="False">
<NavigationViewItem.MenuItems>
<NavigationViewItem
x:Name="AltWindowCycleNavigationItem"
x:Uid="Shell_AltWindowCycle"
helpers:NavHelper.NavigateTo="views:AltWindowCyclePage"
AutomationProperties.AutomationId="AltWindowCycleNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/AltWindowCycle.png}" />
<NavigationViewItem
x:Name="AlwaysOnTopNavigationItem"
x:Uid="Shell_AlwaysOnTop"

View File

@@ -137,14 +137,6 @@
<value>Screen Ruler</value>
<comment>"Screen Ruler" is the name of the utility</comment>
</data>
<data name="PowerScripts.ModuleTitle" xml:space="preserve">
<value>PowerScripts</value>
<comment>"PowerScripts" is the name of the utility</comment>
</data>
<data name="PowerScripts.ModuleDescription" xml:space="preserve">
<value>Write a small script once and surface it across PowerToys and the Windows shell.</value>
<comment>Description of the PowerScripts utility</comment>
</data>
<data name="MeasureTool_ActivationSettings.Header" xml:space="preserve">
<value>Activation</value>
</data>
@@ -3012,6 +3004,34 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Oobe_CropAndLock_HowToUse_Screenshot.Text" xml:space="preserve">
<value>to crop an application's window into a screenshot window. The screenshot won't update with the original window's content.</value>
</data>
<data name="AltWindowCycle.ModuleDescription" xml:space="preserve">
<value>Cycle between the windows of the currently focused application.</value>
</data>
<data name="AltWindowCycle.ModuleTitle" xml:space="preserve">
<value>Alt Window Cycle</value>
</data>
<data name="AltWindowCycle_Activation_GroupSettings.Header" xml:space="preserve">
<value>Activation</value>
</data>
<data name="AltWindowCycle_EnableToggleControl_HeaderText.Header" xml:space="preserve">
<value>Enable Alt Window Cycle</value>
</data>
<data name="AltWindowCycle_NextWindowShortcut.Description" xml:space="preserve">
<value>Cycle to the next window of the focused app</value>
</data>
<data name="AltWindowCycle_NextWindowShortcut.Header" xml:space="preserve">
<value>Next window</value>
</data>
<data name="AltWindowCycle_PreviousWindowShortcut.Description" xml:space="preserve">
<value>Cycle to the previous window of the focused app</value>
</data>
<data name="AltWindowCycle_PreviousWindowShortcut.Header" xml:space="preserve">
<value>Previous window</value>
</data>
<data name="Shell_AltWindowCycle.Content" xml:space="preserve">
<value>Alt Window Cycle</value>
<comment>{Locked}</comment>
</data>
<data name="AlwaysOnTop.ModuleDescription" xml:space="preserve">
<value>Always On Top is a quick and easy way to pin windows on top.</value>
</data>

View File

@@ -0,0 +1,174 @@
// 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.Globalization;
using System.Runtime.CompilerServices;
using System.Text.Json;
using global::PowerToys.GPOWrapper;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.Library.Utilities;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public partial class AltWindowCycleViewModel : PageViewModelBase
{
protected override string ModuleName => AltWindowCycleSettings.ModuleName;
private SettingsUtils SettingsUtils { get; set; }
private GeneralSettings GeneralSettingsConfig { get; set; }
private AltWindowCycleSettings Settings { get; set; }
private Func<string, int> SendConfigMSG { get; }
public AltWindowCycleViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<AltWindowCycleSettings> moduleSettingsRepository, Func<string, int> ipcMSGCallBackFunc)
{
ArgumentNullException.ThrowIfNull(settingsUtils);
SettingsUtils = settingsUtils;
// To obtain the general settings configurations of PowerToys Settings.
ArgumentNullException.ThrowIfNull(settingsRepository);
GeneralSettingsConfig = settingsRepository.SettingsConfig;
InitializeEnabledValue();
// To obtain the settings configurations of AltWindowCycle.
ArgumentNullException.ThrowIfNull(moduleSettingsRepository);
Settings = moduleSettingsRepository.SettingsConfig;
_nextWindowShortcut = Settings.Properties.NextWindowShortcut;
_previousWindowShortcut = Settings.Properties.PreviousWindowShortcut;
// set the callback functions value to handle outgoing IPC message.
SendConfigMSG = ipcMSGCallBackFunc;
}
private void InitializeEnabledValue()
{
_enabledGpoRuleConfiguration = GPOWrapper.GetConfiguredAltWindowCycleEnabledValue();
if (_enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled || _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled)
{
// Get the enabled state from GPO.
_enabledStateIsGPOConfigured = true;
_isEnabled = _enabledGpoRuleConfiguration == GpoRuleConfigured.Enabled;
}
else
{
_isEnabled = GeneralSettingsConfig.Enabled.AltWindowCycle;
}
}
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
{
var hotkeysDict = new Dictionary<string, HotkeySettings[]>
{
[ModuleName] = [NextWindowShortcut, PreviousWindowShortcut],
};
return hotkeysDict;
}
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_enabledStateIsGPOConfigured)
{
// If it's GPO configured, shouldn't be able to change this state.
return;
}
if (value != _isEnabled)
{
_isEnabled = value;
// Set the status in the general settings configuration
GeneralSettingsConfig.Enabled.AltWindowCycle = value;
OutGoingGeneralSettings snd = new OutGoingGeneralSettings(GeneralSettingsConfig);
SendConfigMSG(snd.ToString());
OnPropertyChanged(nameof(IsEnabled));
}
}
}
public bool IsEnabledGpoConfigured
{
get => _enabledStateIsGPOConfigured;
}
public HotkeySettings NextWindowShortcut
{
get => _nextWindowShortcut;
set
{
if (value != _nextWindowShortcut)
{
_nextWindowShortcut = value ?? Settings.Properties.DefaultNextWindowShortcut;
Settings.Properties.NextWindowShortcut = _nextWindowShortcut;
NotifyPropertyChanged();
SendSettingsConfigMessage();
}
}
}
public HotkeySettings PreviousWindowShortcut
{
get => _previousWindowShortcut;
set
{
if (value != _previousWindowShortcut)
{
_previousWindowShortcut = value ?? Settings.Properties.DefaultPreviousWindowShortcut;
Settings.Properties.PreviousWindowShortcut = _previousWindowShortcut;
NotifyPropertyChanged();
SendSettingsConfigMessage();
}
}
}
public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
{
OnPropertyChanged(propertyName);
SettingsUtils.SaveSettings(Settings.ToJsonString(), AltWindowCycleSettings.ModuleName);
}
public void RefreshEnabledState()
{
InitializeEnabledValue();
OnPropertyChanged(nameof(IsEnabled));
}
private void SendSettingsConfigMessage()
{
SendConfigMSG(
string.Format(
CultureInfo.InvariantCulture,
"{{ \"powertoys\": {{ \"{0}\": {1} }} }}",
AltWindowCycleSettings.ModuleName,
JsonSerializer.Serialize(Settings, SourceGenerationContextContext.Default.AltWindowCycleSettings)));
}
private GpoRuleConfigured _enabledGpoRuleConfiguration;
private bool _enabledStateIsGPOConfigured;
private bool _isEnabled;
private HotkeySettings _nextWindowShortcut;
private HotkeySettings _previousWindowShortcut;
}
}

View File

@@ -493,6 +493,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
ModuleType.EnvironmentVariables => GetModuleItemsEnvironmentVariables(),
ModuleType.FancyZones => GetModuleItemsFancyZones(),
ModuleType.FindMyMouse => GetModuleItemsFindMyMouse(),
ModuleType.AltWindowCycle => GetModuleItemsAltWindowCycle(),
ModuleType.Hosts => GetModuleItemsHosts(),
ModuleType.KeyboardManager => GetModuleItemsKeyboardManager(),
ModuleType.LightSwitch => GetModuleItemsLightSwitch(),
@@ -626,6 +627,18 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
return new ObservableCollection<DashboardModuleItem>(list);
}
private ObservableCollection<DashboardModuleItem> GetModuleItemsAltWindowCycle()
{
ISettingsRepository<AltWindowCycleSettings> moduleSettingsRepository = SettingsRepository<AltWindowCycleSettings>.GetInstance(SettingsUtils.Default);
var settings = moduleSettingsRepository.SettingsConfig;
var list = new List<DashboardModuleItem>
{
new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AltWindowCycle_NextWindowShortcut/Header"), Shortcut = settings.Properties.NextWindowShortcut.GetKeysList() },
new DashboardModuleShortcutItem() { Label = resourceLoader.GetString("AltWindowCycle_PreviousWindowShortcut/Header"), Shortcut = settings.Properties.PreviousWindowShortcut.GetKeysList() },
};
return new ObservableCollection<DashboardModuleItem>(list);
}
private ObservableCollection<DashboardModuleItem> GetModuleItemsHosts()
{
var list = new List<DashboardModuleItem>

View File

@@ -1,17 +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.Collections.Generic;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
/// <summary>
/// The declared file-input contract for a script. Mirrors the <c>input</c> object emitted by
/// <c>PowerScripts.Host.exe list --json</c>.
/// </summary>
public sealed class PowerScriptInput
{
public List<string> Extensions { get; set; } = new();
}
}

View File

@@ -1,71 +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.Collections.Generic;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
/// <summary>
/// A single PowerScript shown in the Settings list. This is a read-only projection of the
/// script's <c>manifest.json</c> (the source of truth), as emitted by
/// <c>PowerScripts.Host.exe list --json</c>. The Settings page only displays this information;
/// authors change it by editing the manifest.
/// </summary>
public sealed class PowerScriptListItem
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Description { get; set; } = string.Empty;
public string Kind { get; set; } = string.Empty;
public string Runtime { get; set; } = string.Empty;
public PowerScriptInput Input { get; set; }
public List<string> Surfaces { get; set; } = new();
public List<string> Capabilities { get; set; } = new();
/// <summary>
/// True once the user has approved this script's current content to run (trust-on-first-use).
/// Emitted by the Host as <c>trusted</c>; recomputed from the script's content hash, so it
/// flips back to false if the script body or its declared capabilities change.
/// </summary>
public bool Trusted { get; set; }
public string KindGlyph => string.Equals(Kind, "file", StringComparison.OrdinalIgnoreCase)
? "\uE8A5" // file action
: "\uE756"; // system action
/// <summary>True for file scripts, which can be triggered from the Explorer right-click menu.</summary>
public bool IsFileScript => string.Equals(Kind, "file", StringComparison.OrdinalIgnoreCase);
/// <summary>Comma-separated trigger extensions declared in the manifest (file scripts only).</summary>
public string ExtensionsDisplay => Input?.Extensions is { Count: > 0 } exts
? string.Join(", ", exts)
: "—";
/// <summary>Comma-separated list of the surfaces this script appears on.</summary>
public string SurfacesDisplay => Surfaces is { Count: > 0 }
? string.Join(", ", Surfaces)
: "—";
/// <summary>Comma-separated list of the capabilities the script declares.</summary>
public string CapabilitiesDisplay => Capabilities is { Count: > 0 }
? string.Join(", ", Capabilities)
: "—";
/// <summary>Friendly runtime label (e.g. "PowerShell").</summary>
public string RuntimeDisplay => string.IsNullOrEmpty(Runtime) ? "—" : Runtime;
/// <summary>Human-readable trust state shown in the Settings list.</summary>
public string TrustDisplay => Trusted
? "Trusted"
: "Not yet trusted — you'll be asked to allow it the first time it runs";
}
}

View File

@@ -1,310 +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.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
public partial class PowerScriptsViewModel : Observable
{
private const string HostExeName = "PowerScripts.Host.exe";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
private static readonly JsonSerializerOptions WriteJsonOptions = new() { WriteIndented = true };
private readonly ISettingsRepository<GeneralSettings> _generalSettingsRepository;
private readonly Func<string, int> _sendConfigMsg;
private bool _isEnabled;
private string _scriptsFolder;
public PowerScriptsViewModel(ISettingsRepository<GeneralSettings> generalSettingsRepository, Func<string, int> sendConfigMsg)
{
ArgumentNullException.ThrowIfNull(generalSettingsRepository);
_generalSettingsRepository = generalSettingsRepository;
_sendConfigMsg = sendConfigMsg;
_isEnabled = generalSettingsRepository.SettingsConfig.Enabled.PowerScripts;
_scriptsFolder = ResolveScriptsFolder();
Scripts = new ObservableCollection<PowerScriptListItem>();
ReloadScripts();
}
public ObservableCollection<PowerScriptListItem> Scripts { get; }
public bool HasScripts => Scripts.Count > 0;
/// <summary>
/// The folder PowerScripts scans for <c>&lt;id&gt;\manifest.json</c> script folders. Persisted to
/// the shared <c>config.json</c> so every surface (Settings, the Explorer context menu, and the
/// Keyboard Manager mapping) resolves the same folder.
/// </summary>
public string ScriptsFolder
{
get => _scriptsFolder;
private set
{
if (_scriptsFolder != value)
{
_scriptsFolder = value;
OnPropertyChanged();
OnPropertyChanged(nameof(IsCustomFolder));
}
}
}
public bool IsCustomFolder =>
!string.Equals(ScriptsFolder, DefaultScriptsFolder, StringComparison.OrdinalIgnoreCase);
public bool IsEnabled
{
get => _isEnabled;
set
{
if (_isEnabled != value)
{
_isEnabled = value;
GeneralSettings generalSettings = _generalSettingsRepository.SettingsConfig;
generalSettings.Enabled.PowerScripts = value;
if (_sendConfigMsg != null)
{
var outgoing = new OutGoingGeneralSettings(generalSettings);
_sendConfigMsg(outgoing.ToString());
}
// Prototype: wire the Explorer right-click submenu directly from Settings, so
// enabling/disabling PowerScripts installs/removes the context-menu entries even
// without a dedicated runner module.
RunHostShellCommand(value ? "shell-install" : "shell-uninstall");
OnPropertyChanged();
}
}
}
public void ReloadScripts()
{
Scripts.Clear();
foreach (var script in LoadScriptsFromHost())
{
Scripts.Add(script);
}
OnPropertyChanged(nameof(HasScripts));
}
/// <summary>Persists a user-chosen scripts folder and refreshes every surface that reads it.</summary>
public void SetScriptsFolder(string folder)
{
if (string.IsNullOrWhiteSpace(folder))
{
return;
}
SaveConfiguredScriptsRoot(folder.Trim());
ScriptsFolder = ResolveScriptsFolder();
ReloadScripts();
// Re-register the Explorer submenu so right-click entries reflect the new folder's scripts.
if (_isEnabled)
{
RunHostShellCommand("shell-install");
}
}
/// <summary>Clears the override so the default folder under %LOCALAPPDATA% is used again.</summary>
public void ResetScriptsFolder()
{
SaveConfiguredScriptsRoot(null);
ScriptsFolder = ResolveScriptsFolder();
ReloadScripts();
if (_isEnabled)
{
RunHostShellCommand("shell-install");
}
}
private static string ModuleDirectory => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"PowerScripts");
private static string ConfigFilePath => Path.Combine(ModuleDirectory, "config.json");
private static string DefaultScriptsFolder => Path.Combine(ModuleDirectory, "scripts");
private static string ResolveScriptsFolder()
{
var fromEnv = Environment.GetEnvironmentVariable("POWERSCRIPTS_ROOT");
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv;
}
try
{
if (File.Exists(ConfigFilePath))
{
using var stream = File.OpenRead(ConfigFilePath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
value.ValueKind == JsonValueKind.String)
{
var root = value.GetString();
if (!string.IsNullOrWhiteSpace(root))
{
return root;
}
}
}
}
catch (Exception)
{
// A corrupt or unreadable config falls back to the default.
}
return DefaultScriptsFolder;
}
private static void SaveConfiguredScriptsRoot(string folder)
{
Directory.CreateDirectory(ModuleDirectory);
var normalized = string.IsNullOrWhiteSpace(folder) ? string.Empty : folder.Trim();
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, WriteJsonOptions);
File.WriteAllText(ConfigFilePath, json);
}
private static string ResolveHostPath()
{
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, HostExeName),
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
Path.Combine(ModuleDirectory, HostExeName),
};
// Prototype dev fallback: when running an in-repo build, the Host isn't copied next to
// Settings, so walk up from the base directory and probe the Host project's bin output.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
foreach (var config in new[] { "Debug", "Release" })
{
var hostBin = Path.Combine(
dir.FullName,
"src",
"modules",
"PowerScripts",
"PowerScripts.Host",
"bin",
config);
if (Directory.Exists(hostBin))
{
var found = Directory
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrEmpty(found))
{
candidates.Add(found);
}
}
}
dir = dir.Parent;
}
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return string.Empty;
}
private static void RunHostShellCommand(string command)
{
string hostPath = ResolveHostPath();
if (string.IsNullOrEmpty(hostPath))
{
return;
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = command,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
process?.WaitForExit(5000);
}
catch (Exception)
{
// Prototype: best-effort context-menu (un)registration.
}
}
private static IReadOnlyList<PowerScriptListItem> LoadScriptsFromHost()
{
string hostPath = ResolveHostPath();
if (string.IsNullOrEmpty(hostPath))
{
return Array.Empty<PowerScriptListItem>();
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = "list --json",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process is null)
{
return Array.Empty<PowerScriptListItem>();
}
string json = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
return JsonSerializer.Deserialize<List<PowerScriptListItem>>(json, JsonOptions)
?? new List<PowerScriptListItem>();
}
catch (Exception)
{
// Prototype: a missing/failed host simply yields an empty list.
return Array.Empty<PowerScriptListItem>();
}
}
}
}