mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 16:39:14 +02:00
Compare commits
17 Commits
powerscrip
...
crutkas/al
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c5437577f | ||
|
|
d920da1f86 | ||
|
|
44904c1884 | ||
|
|
43ad3b3f4b | ||
|
|
aca897962c | ||
|
|
0004026137 | ||
|
|
1f64356551 | ||
|
|
6800dd2abb | ||
|
|
fefd70ae84 | ||
|
|
aae941e7c6 | ||
|
|
d133113b86 | ||
|
|
a1200a1321 | ||
|
|
6870ad33f3 | ||
|
|
e354ae18fc | ||
|
|
9aa225d009 | ||
|
|
be4a5c250e | ||
|
|
b40fe31f11 |
1
.github/actions/spell-check/allow/code.txt
vendored
1
.github/actions/spell-check/allow/code.txt
vendored
@@ -330,6 +330,7 @@ xes
|
||||
PACKAGEVERSIONNUMBER
|
||||
APPXMANIFESTVERSION
|
||||
PROGMAN
|
||||
ROOTOWNER
|
||||
|
||||
# MRU lists
|
||||
CACHEWRITE
|
||||
|
||||
9
.github/actions/spell-check/expect.txt
vendored
9
.github/actions/spell-check/expect.txt
vendored
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
1574
src/modules/AltWindowCycle/AltWindowCycle.cpp
Normal file
1574
src/modules/AltWindowCycle/AltWindowCycle.cpp
Normal file
File diff suppressed because it is too large
Load Diff
20
src/modules/AltWindowCycle/AltWindowCycle.h
Normal file
20
src/modules/AltWindowCycle/AltWindowCycle.h
Normal 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);
|
||||
40
src/modules/AltWindowCycle/AltWindowCycle.rc
Normal file
40
src/modules/AltWindowCycle/AltWindowCycle.rc
Normal 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
|
||||
130
src/modules/AltWindowCycle/AltWindowCycle.vcxproj
Normal file
130
src/modules/AltWindowCycle/AltWindowCycle.vcxproj
Normal 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>
|
||||
56
src/modules/AltWindowCycle/AltWindowCycle.vcxproj.filters
Normal file
56
src/modules/AltWindowCycle/AltWindowCycle.vcxproj.filters
Normal 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>
|
||||
181
src/modules/AltWindowCycle/AltWindowCycleLogic.h
Normal file
181
src/modules/AltWindowCycle/AltWindowCycleLogic.h
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
287
src/modules/AltWindowCycle/dllmain.cpp
Normal file
287
src/modules/AltWindowCycle/dllmain.cpp
Normal 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();
|
||||
}
|
||||
4
src/modules/AltWindowCycle/packages.config
Normal file
4
src/modules/AltWindowCycle/packages.config
Normal 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>
|
||||
1
src/modules/AltWindowCycle/pch.cpp
Normal file
1
src/modules/AltWindowCycle/pch.cpp
Normal file
@@ -0,0 +1 @@
|
||||
#include "pch.h"
|
||||
15
src/modules/AltWindowCycle/pch.h
Normal file
15
src/modules/AltWindowCycle/pch.h
Normal 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>
|
||||
13
src/modules/AltWindowCycle/resource.h
Normal file
13
src/modules/AltWindowCycle/resource.h
Normal 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
|
||||
//////////////////////////////
|
||||
33
src/modules/AltWindowCycle/trace.cpp
Normal file
33
src/modules/AltWindowCycle/trace.cpp
Normal 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"));
|
||||
}
|
||||
13
src/modules/AltWindowCycle/trace.h
Normal file
13
src/modules/AltWindowCycle/trace.h
Normal 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;
|
||||
};
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
<Project>
|
||||
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
|
||||
</Project>
|
||||
@@ -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>
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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><id>/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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 <id></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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 <id>
|
||||
/// - The Explorer context menu invokes: PowerScripts.Host.exe run <id> --files <paths>
|
||||
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
|
||||
///
|
||||
/// Usage:
|
||||
/// PowerScripts.Host list [--json] [--root <dir>]
|
||||
/// PowerScripts.Host run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]
|
||||
/// </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 <id> approve the script's current content without running it
|
||||
/// trust revoke <id> 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><id>\t<name></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;
|
||||
}
|
||||
}
|
||||
@@ -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\<ext>\shell\PowerScripts</c> whose nested
|
||||
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run <id> --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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
# Native handler build artifacts
|
||||
*.dll
|
||||
*.lib
|
||||
*.exp
|
||||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
# Host publish output used by register.ps1
|
||||
hostpublish/
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -1,4 +0,0 @@
|
||||
EXPORTS
|
||||
DllCanUnloadNow PRIVATE
|
||||
DllGetClassObject PRIVATE
|
||||
DllGetActivationFactory PRIVATE
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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).'
|
||||
@@ -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.
|
||||
@@ -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.'
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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.'
|
||||
@@ -212,12 +212,6 @@
|
||||
<TextBlock x:Uid="ActionType_Disable_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem Tag="PowerScript">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <id></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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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),
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=}">
|
||||
<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=}"
|
||||
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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=}" />
|
||||
<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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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><id>\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>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user