Compare commits

..

5 Commits

Author SHA1 Message Date
Michael Jolley
0af71f19e9 CmdPal: make extension reordering WYSIWYG across load batches
Sort the entire top-level command list by ExtensionOrder after every add path (initial load, dynamic provider update, late append) instead of only within each registration batch. Built-ins and external extensions load in separate batches, so the previous batch-scoped sort could not honor a reorder that crossed that boundary; the dialog let users arrange an order that silently would not stick.

Also make ExtensionOrderHelper.SortByExtensionOrder a stable sort so a single provider's commands keep their relative order, remove the now-unused FindInsertIndex helper and the fragile startIndex>=Count heuristic, and update tests (drop FindInsertIndex cases, add stability and cross-batch coverage).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:47:23 -05:00
Michael Jolley
a73a3ad8dd Merge branch 'main' into michaeljolley-cmdpal-reorder-extensions 2026-06-24 18:14:25 -05:00
Michael Jolley
4a77658c79 CmdPal: Add sort logic tests and differentiate menu icon
- Extract SortByExtensionOrder and FindInsertIndex into a generic
  ExtensionOrderHelper class so the logic can be unit tested without
  constructing heavyweight TopLevelViewModel instances.
- Add 13 unit tests covering edge cases: empty lists, all/none/mixed
  ordering, duplicate provider IDs, and insertion index calculations.
- Change the Reorder Extensions menu icon glyph from E8CB (same as
  Manage Fallback Order) to E174 to visually differentiate the two
  menu items.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-24 16:18:07 -05:00
Michael Jolley
9f71b5d254 Merge remote-tracking branch 'origin/main' into michaeljolley-cmdpal-reorder-extensions
# Conflicts:
#	src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/TopLevelCommandManager.cs
2026-06-24 15:14:04 -05:00
Michael Jolley
26e4159567 CmdPal: Add extension reordering support (#38595)
Add ability for users to reorder Command Palette extensions via a
drag-and-drop dialog in the Extensions settings page.

Changes:
- Add ExtensionOrder string[] to SettingsModel for persisting user's
  preferred extension order
- Sort extensions by configured order in TopLevelCommandManager when
  loading (ordered providers first, then remaining in load order)
- Add ExtensionRanker and ExtensionRankerDialog controls (matching
  existing FallbackRanker pattern) with drag-and-drop reorder support
- Add 'Manage extension order' menu item to Extensions page More flyout
- Add ApplyExtensionOrder to SettingsViewModel to persist and reload
- Add unit tests for ExtensionOrder serialization roundtrip

Fixes #38595

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-20 22:05:32 -05:00
81 changed files with 539 additions and 4708 deletions

View File

@@ -38,7 +38,6 @@ namespace ManagedCommon
Workspaces,
GrabAndMove,
ZoomIt,
PowerScripts,
GeneralSettings,
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,72 +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;
namespace PowerScripts.Core.Tests;
[TestClass]
public class ModuleStateTests
{
private string _folder = string.Empty;
[TestInitialize]
public void Setup()
{
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-state-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_folder);
}
[TestCleanup]
public void Cleanup()
{
if (Directory.Exists(_folder))
{
Directory.Delete(_folder, recursive: true);
}
}
private string WriteSettings(string json)
{
var path = Path.Combine(_folder, "settings.json");
File.WriteAllText(path, json);
return path;
}
[TestMethod]
public void Missing_SettingsFile_Allows()
{
// No PowerToys governance (standalone/dev/test) -> allow.
var path = Path.Combine(_folder, "does-not-exist.json");
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
}
[TestMethod]
public void ExplicitlyEnabled_Allows()
{
var path = WriteSettings("{ \"enabled\": { \"PowerScripts\": true } }");
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
}
[TestMethod]
public void ExplicitlyDisabled_Refuses()
{
var path = WriteSettings("{ \"enabled\": { \"PowerScripts\": false } }");
Assert.IsFalse(ModuleState.IsPowerScriptsEnabled(path));
}
[TestMethod]
public void KeyAbsent_Refuses_BecauseModuleDefaultsOff()
{
var path = WriteSettings("{ \"enabled\": { \"Keyboard Manager\": true } }");
Assert.IsFalse(ModuleState.IsPowerScriptsEnabled(path));
}
[TestMethod]
public void Corrupt_SettingsFile_Allows_ToNotBreakBindings()
{
var path = WriteSettings("{ this is not valid json");
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json;
using System.Text.Json.Serialization;
namespace PowerScripts.Core.Manifest;
/// <summary>
/// Centralized JSON options and (de)serialization helpers for PowerScript manifests.
/// </summary>
public static class ManifestSerializer
{
public static JsonSerializerOptions Options { get; } = CreateOptions();
private static JsonSerializerOptions CreateOptions()
{
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.CamelCase));
return options;
}
public static PowerScriptManifest? Deserialize(string json) =>
JsonSerializer.Deserialize<PowerScriptManifest>(json, Options);
public static string Serialize(PowerScriptManifest manifest) =>
JsonSerializer.Serialize(manifest, Options);
}

View File

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

View File

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

View File

@@ -1,82 +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>
/// The single runtime gate for whether the PowerScripts module is enabled in PowerToys.
///
/// Every surface — the Keyboard Manager hotkey, the Explorer context menu, and any future module —
/// runs a script through <c>PowerScripts.Host.exe run</c>. Enforcing the enabled state here (rather
/// than in each caller) means turning PowerScripts off makes *all* existing bindings inert without
/// having to find, delete, or rewrite them, and re-enabling restores them exactly as they were. Any
/// module that later integrates with PowerScripts inherits this behavior for free.
/// </summary>
public static class ModuleState
{
/// <summary>
/// Environment escape hatch (set to <c>1</c>) that bypasses the enabled gate, for hermetic tests
/// and standalone/dev runs of the host outside a PowerToys installation.
/// </summary>
public const string IgnoreEnabledEnvironmentVariable = "POWERSCRIPTS_IGNORE_ENABLED";
/// <summary>
/// True when scripts are allowed to run.
///
/// Resolution order:
/// - The <see cref="IgnoreEnabledEnvironmentVariable"/> bypass wins if set.
/// - If the PowerToys settings file is absent (no PowerToys governance — standalone/dev/test),
/// execution is allowed.
/// - If the settings file exists, the module must be explicitly enabled
/// (<c>enabled.PowerScripts == true</c>); an absent key means off, mirroring PowerToys'
/// "default off" for this module.
/// - If the settings file is unreadable, we fail open (allow) so a transient/corrupt file does
/// not silently break a user's existing hotkeys; the context-menu install/remove still tracks
/// the toggle in that edge case.
/// </summary>
public static bool IsPowerScriptsEnabled()
{
if (string.Equals(Environment.GetEnvironmentVariable(IgnoreEnabledEnvironmentVariable), "1", StringComparison.Ordinal))
{
return true;
}
return IsPowerScriptsEnabled(PowerScriptsPaths.PowerToysSettingsFilePath);
}
/// <summary>
/// Core resolution against an explicit settings file (the production overload passes the
/// well-known PowerToys path). Split out so the gate can be unit-tested hermetically, mirroring
/// how <c>TrustStore</c> takes its file path.
/// </summary>
public static bool IsPowerScriptsEnabled(string settingsPath)
{
try
{
if (!File.Exists(settingsPath))
{
return true;
}
using var stream = File.OpenRead(settingsPath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("enabled", out var enabled) &&
enabled.ValueKind == JsonValueKind.Object &&
enabled.TryGetProperty("PowerScripts", out var flag))
{
return flag.ValueKind == JsonValueKind.True;
}
// Settings file present but no PowerScripts entry: the module is off by default.
return false;
}
catch (Exception)
{
// Unreadable settings: cannot govern, so don't break existing bindings.
return true;
}
}
}

View File

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

View File

@@ -1,127 +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 PowerToys settings file (<c>%LOCALAPPDATA%\Microsoft\PowerToys\settings.json</c>) that
/// records which modules are enabled. Read by <see cref="ModuleState"/> to gate execution.
/// </summary>
public static string PowerToysSettingsFilePath
{
get
{
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
return Path.Combine(localAppData, "Microsoft", "PowerToys", "settings.json");
}
}
/// <summary>The user-settings file that persists the chosen scripts root.</summary>
public static string ConfigFilePath => Path.Combine(ModuleDirectory, ConfigFileName);
/// <summary>The trust store file name (records which script contents the user has approved).</summary>
public const string TrustFileName = "trust.json";
/// <summary>The trust store: which (script id, content hash) pairs the user has approved to run.</summary>
public static string TrustFilePath => Path.Combine(ModuleDirectory, TrustFileName);
/// <summary>
/// Default scripts root:
/// <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts</c>.
/// </summary>
public static string DefaultScriptsRoot => Path.Combine(ModuleDirectory, "scripts");
/// <summary>
/// Resolves the scripts root, honoring (in order): an explicit path, the environment override,
/// the persisted user setting, then the default.
/// </summary>
public static string ResolveScriptsRoot(string? explicitRoot = null)
{
if (!string.IsNullOrWhiteSpace(explicitRoot))
{
return explicitRoot;
}
var fromEnv = Environment.GetEnvironmentVariable(RootEnvironmentVariable);
if (!string.IsNullOrWhiteSpace(fromEnv))
{
return fromEnv;
}
var fromConfig = ReadConfiguredScriptsRoot();
return string.IsNullOrWhiteSpace(fromConfig) ? DefaultScriptsRoot : fromConfig;
}
/// <summary>
/// Reads the user-chosen scripts root from <see cref="ConfigFilePath"/>, or <c>null</c> if it is
/// missing, empty, or unreadable.
/// </summary>
public static string? ReadConfiguredScriptsRoot()
{
try
{
if (!File.Exists(ConfigFilePath))
{
return null;
}
using var stream = File.OpenRead(ConfigFilePath);
using var document = JsonDocument.Parse(stream);
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
value.ValueKind == JsonValueKind.String)
{
var root = value.GetString();
return string.IsNullOrWhiteSpace(root) ? null : root;
}
}
catch (Exception)
{
// A corrupt or unreadable config simply falls back to the default.
}
return null;
}
/// <summary>
/// Persists the user-chosen scripts root to <see cref="ConfigFilePath"/>. Passing <c>null</c> or
/// whitespace clears the override so the default is used again.
/// </summary>
public static void SaveConfiguredScriptsRoot(string? root)
{
Directory.CreateDirectory(ModuleDirectory);
var normalized = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(ConfigFilePath, json);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +0,0 @@
@echo off
rem Builds the PowerScripts Windows 11 context-menu handler DLL (self-contained, no PowerToys deps).
setlocal
set "VCVARS=C:\Program Files\Microsoft Visual Studio\18\Enterprise\VC\Auxiliary\Build\vcvars64.bat"
if not exist "%VCVARS%" (
echo Could not find vcvars64.bat at "%VCVARS%". Edit build.cmd to point at your VS install.
exit /b 1
)
call "%VCVARS%" >nul || exit /b 1
cd /d "%~dp0"
cl /nologo /std:c++17 /EHsc /O2 /MT /DUNICODE /D_UNICODE /LD dllmain.cpp ^
/Fe:PowerToys.PowerScriptsContextMenu.dll ^
/link /DEF:dll.def shlwapi.lib runtimeobject.lib ole32.lib || exit /b 1
echo Built PowerToys.PowerScriptsContextMenu.dll
endlocal

View File

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

View File

@@ -1,388 +0,0 @@
// PowerScripts Windows 11 modern context-menu handler.
//
// A self-contained IExplorerCommand COM server (no PowerToys common dependencies). It surfaces a
// top-level "PowerScript" entry with a dynamic submenu of the file scripts that match the current
// selection. The actual matching/running logic lives in PowerScripts.Host.exe (deployed next to
// this DLL); the handler is a thin shell that:
// * GetState -> runs "Host shell-menu --files <paths>", caches the id/name lines, hides itself
// when nothing matches.
// * EnumSubCommands -> turns each cached line into a submenu item.
// * Invoke (item) -> runs "Host run <id> --files <paths>".
#include <windows.h>
#include <shobjidl_core.h>
#include <shlwapi.h>
#include <wrl/module.h>
#include <wrl/implements.h>
#include <wrl/client.h>
#include <string>
#include <vector>
using namespace Microsoft::WRL;
namespace
{
HMODULE g_hModule = nullptr;
long g_refModule = 0;
// Full path to PowerScripts.Host.exe, assumed to sit next to this DLL.
std::wstring FindHostExe()
{
wchar_t path[MAX_PATH] = {};
GetModuleFileNameW(g_hModule, path, ARRAYSIZE(path));
std::wstring dir(path);
const size_t slash = dir.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
dir.erase(slash + 1);
}
return dir + L"PowerScripts.Host.exe";
}
// Extracts the filesystem paths from a shell selection.
std::vector<std::wstring> ExtractPaths(IShellItemArray* selection)
{
std::vector<std::wstring> result;
if (selection == nullptr)
{
return result;
}
DWORD count = 0;
if (FAILED(selection->GetCount(&count)))
{
return result;
}
for (DWORD i = 0; i < count; ++i)
{
ComPtr<IShellItem> item;
if (FAILED(selection->GetItemAt(i, &item)))
{
continue;
}
PWSTR pszPath = nullptr;
if (SUCCEEDED(item->GetDisplayName(SIGDN_FILESYSPATH, &pszPath)) && pszPath != nullptr)
{
result.emplace_back(pszPath);
CoTaskMemFree(pszPath);
}
}
return result;
}
// Quotes a single command-line argument.
std::wstring Quote(const std::wstring& value)
{
return L"\"" + value + L"\"";
}
std::wstring BuildFilesArguments(const std::vector<std::wstring>& files)
{
std::wstring args;
for (const auto& file : files)
{
args += L" " + Quote(file);
}
return args;
}
// Runs a Host command and returns its stdout. Used only for the (small) shell-menu listing.
std::wstring RunHostCapture(const std::wstring& arguments)
{
std::wstring output;
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
HANDLE readPipe = nullptr;
HANDLE writePipe = nullptr;
if (!CreatePipe(&readPipe, &writePipe, &sa, 0))
{
return output;
}
SetHandleInformation(readPipe, HANDLE_FLAG_INHERIT, 0);
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
STARTUPINFOW si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
si.hStdOutput = writePipe;
si.hStdError = writePipe;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
mutableCmd.push_back(L'\0');
if (!CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
{
CloseHandle(readPipe);
CloseHandle(writePipe);
return output;
}
CloseHandle(writePipe);
char buffer[4096];
DWORD read = 0;
std::string raw;
while (ReadFile(readPipe, buffer, sizeof(buffer), &read, nullptr) && read > 0)
{
raw.append(buffer, read);
}
CloseHandle(readPipe);
WaitForSingleObject(pi.hProcess, 15000);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
if (!raw.empty())
{
const int needed = MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), nullptr, 0);
if (needed > 0)
{
output.resize(needed);
MultiByteToWideChar(CP_UTF8, 0, raw.c_str(), static_cast<int>(raw.size()), output.data(), needed);
}
}
return output;
}
// Runs a Host command fire-and-forget (used to actually execute a script).
void RunHostDetached(const std::wstring& arguments)
{
std::wstring commandLine = Quote(FindHostExe()) + L" " + arguments;
STARTUPINFOW si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESHOWWINDOW;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi = {};
std::vector<wchar_t> mutableCmd(commandLine.begin(), commandLine.end());
mutableCmd.push_back(L'\0');
if (CreateProcessW(nullptr, mutableCmd.data(), nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi))
{
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
}
struct ScriptEntry
{
std::wstring Id;
std::wstring Name;
};
// Parses "id\tname" lines into entries.
std::vector<ScriptEntry> ParseMenu(const std::wstring& text)
{
std::vector<ScriptEntry> entries;
size_t start = 0;
while (start < text.size())
{
size_t end = text.find(L'\n', start);
std::wstring line = (end == std::wstring::npos) ? text.substr(start) : text.substr(start, end - start);
start = (end == std::wstring::npos) ? text.size() : end + 1;
if (!line.empty() && line.back() == L'\r')
{
line.pop_back();
}
if (line.empty())
{
continue;
}
const size_t tab = line.find(L'\t');
if (tab == std::wstring::npos)
{
continue;
}
ScriptEntry entry;
entry.Id = line.substr(0, tab);
entry.Name = line.substr(tab + 1);
if (!entry.Id.empty())
{
entries.push_back(std::move(entry));
}
}
return entries;
}
}
// A single submenu item: "Convert Markdown to Text", etc.
class PowerScriptSubCommand : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand>
{
public:
PowerScriptSubCommand(std::wstring id, std::wstring name, std::vector<std::wstring> files) :
m_id(std::move(id)), m_name(std::move(name)), m_files(std::move(files))
{
}
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(m_name.c_str(), name); }
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
IFACEMETHODIMP GetState(IShellItemArray*, BOOL, EXPCMDSTATE* state) override { *state = ECS_ENABLED; return S_OK; }
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_DEFAULT; return S_OK; }
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override { *enumerator = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP Invoke(IShellItemArray* selection, IBindCtx*) override
{
std::vector<std::wstring> files = m_files;
if (files.empty())
{
files = ExtractPaths(selection);
}
RunHostDetached(L"run " + m_id + L" --files" + BuildFilesArguments(files));
return S_OK;
}
private:
std::wstring m_id;
std::wstring m_name;
std::vector<std::wstring> m_files;
};
// IEnumExplorerCommand over the submenu items.
class PowerScriptEnum : public RuntimeClass<RuntimeClassFlags<ClassicCom>, IEnumExplorerCommand>
{
public:
explicit PowerScriptEnum(std::vector<ComPtr<IExplorerCommand>> commands) :
m_commands(std::move(commands))
{
}
IFACEMETHODIMP Next(ULONG count, IExplorerCommand** commands, ULONG* fetched) override
{
ULONG produced = 0;
for (; produced < count && m_index < m_commands.size(); ++produced, ++m_index)
{
m_commands[m_index].CopyTo(&commands[produced]);
}
if (fetched != nullptr)
{
*fetched = produced;
}
return (produced == count) ? S_OK : S_FALSE;
}
IFACEMETHODIMP Skip(ULONG count) override
{
m_index += count;
return (m_index <= m_commands.size()) ? S_OK : S_FALSE;
}
IFACEMETHODIMP Reset() override
{
m_index = 0;
return S_OK;
}
IFACEMETHODIMP Clone(IEnumExplorerCommand** out) override
{
*out = nullptr;
return E_NOTIMPL;
}
private:
std::vector<ComPtr<IExplorerCommand>> m_commands;
size_t m_index = 0;
};
// Top-level "PowerScript" command with a dynamic submenu.
class __declspec(uuid("9FF7C126-9562-4F16-A6FB-9622B26E0D62")) PowerScriptCommand :
public RuntimeClass<RuntimeClassFlags<ClassicCom>, IExplorerCommand, IObjectWithSite>
{
public:
IFACEMETHODIMP GetTitle(IShellItemArray*, PWSTR* name) override { return SHStrDupW(L"PowerScripts", name); }
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
// Called before EnumSubCommands on the same instance; we use it to compute (and cache) the
// matching scripts and to hide the entry when nothing matches.
IFACEMETHODIMP GetState(IShellItemArray* selection, BOOL, EXPCMDSTATE* state) override
{
m_files = ExtractPaths(selection);
m_entries.clear();
if (!m_files.empty())
{
const std::wstring output = RunHostCapture(L"shell-menu --files" + BuildFilesArguments(m_files));
m_entries = ParseMenu(output);
}
*state = m_entries.empty() ? ECS_HIDDEN : ECS_ENABLED;
return S_OK;
}
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_HASSUBCOMMANDS; return S_OK; }
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override
{
*enumerator = nullptr;
std::vector<ComPtr<IExplorerCommand>> commands;
for (const auto& entry : m_entries)
{
commands.push_back(Make<PowerScriptSubCommand>(entry.Id, entry.Name, m_files));
}
auto enumObject = Make<PowerScriptEnum>(std::move(commands));
return enumObject.CopyTo(enumerator);
}
IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override { return S_OK; }
// IObjectWithSite
IFACEMETHODIMP SetSite(IUnknown* site) override { m_site = site; return S_OK; }
IFACEMETHODIMP GetSite(REFIID riid, void** ppv) override { return m_site.CopyTo(riid, ppv); }
private:
ComPtr<IUnknown> m_site;
std::vector<std::wstring> m_files;
std::vector<ScriptEntry> m_entries;
};
CoCreatableClass(PowerScriptCommand);
STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
{
return Module<ModuleType::InProc>::GetModule().GetActivationFactory(activatableClassId, factory);
}
STDAPI DllCanUnloadNow()
{
return (Module<InProc>::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
}
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
{
return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID)
{
switch (reason)
{
case DLL_PROCESS_ATTACH:
g_hModule = hModule;
DisableThreadLibraryCalls(hModule);
break;
default:
break;
}
return TRUE;
}

View File

@@ -1,75 +0,0 @@
<#
.SYNOPSIS
Builds and registers the PowerScripts Windows 11 modern context-menu handler as an
unsigned sparse (loose-file) MSIX package. Requires Developer Mode.
.DESCRIPTION
1. Builds the native handler DLL (build.cmd).
2. Publishes PowerScripts.Host.exe (framework-dependent) next to the DLL.
3. Copies the manifest + logo assets into a deploy folder.
4. Registers the package in place via Add-AppxPackage -Register.
Run register.ps1 -Unregister to remove it.
#>
[CmdletBinding()]
param(
[switch]$Unregister,
[ValidateSet('Debug', 'Release')]
[string]$Configuration = 'Debug'
)
$ErrorActionPreference = 'Stop'
$PackageName = 'Microsoft.PowerToys.PowerScriptsContextMenu'
$here = Split-Path -Parent $MyInvocation.MyCommand.Definition
$deployDir = Join-Path $env:LOCALAPPDATA 'Microsoft\PowerToys\PowerScriptsContextMenu'
if ($Unregister)
{
$pkg = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
if ($pkg)
{
Remove-AppxPackage -Package $pkg.PackageFullName
Write-Host "Unregistered $($pkg.PackageFullName)"
}
else
{
Write-Host "Package $PackageName is not registered."
}
return
}
Write-Host '== Building handler DLL =='
& cmd /c "`"$here\build.cmd`""
if ($LASTEXITCODE -ne 0) { throw 'DLL build failed.' }
Write-Host '== Publishing PowerScripts.Host =='
$hostProj = Join-Path $here '..\PowerScripts.Host\PowerScripts.Host.csproj'
$hostPublish = Join-Path $here 'hostpublish'
& dotnet publish $hostProj -c $Configuration -o $hostPublish --nologo | Out-Null
if ($LASTEXITCODE -ne 0) { throw 'Host publish failed.' }
Write-Host '== Staging deploy folder =='
# Re-register cleanly: remove any prior registration before overwriting files.
$existing = Get-AppxPackage -Name $PackageName -ErrorAction SilentlyContinue
if ($existing) { Remove-AppxPackage -Package $existing.PackageFullName }
if (Test-Path $deployDir) { Remove-Item $deployDir -Recurse -Force }
New-Item -ItemType Directory -Force -Path $deployDir | Out-Null
New-Item -ItemType Directory -Force -Path (Join-Path $deployDir 'Assets') | Out-Null
Copy-Item (Join-Path $here 'PowerToys.PowerScriptsContextMenu.dll') $deployDir -Force
Copy-Item (Join-Path $here 'AppxManifest.xml') $deployDir -Force
Copy-Item (Join-Path $hostPublish '*') $deployDir -Recurse -Force
# Reuse the ImageResizer context-menu logo assets for the required tile slots.
$assetSrc = Join-Path $here '..\..\..\modules\imageresizer\ImageResizerContextMenu\Assets\ImageResizer'
foreach ($asset in 'storelogo.png', 'Square150x150Logo.png', 'Square44x44Logo.png', 'Wide310x150Logo.png', 'LargeTile.png', 'SmallTile.png', 'SplashScreen.png')
{
Copy-Item (Join-Path $assetSrc $asset) (Join-Path $deployDir 'Assets') -Force
}
Write-Host '== Registering package =='
Add-AppxPackage -Register (Join-Path $deployDir 'AppxManifest.xml')
Write-Host "Registered. Deploy folder: $deployDir"
Write-Host 'Right-click a matching file (e.g. a .md) to see the PowerScript submenu (restart Explorer if needed).'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,50 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Pure helper methods for sorting items according to a user-defined extension
/// order. Operates on provider ID strings so the logic is easily testable without
/// constructing heavyweight view-model instances.
/// </summary>
internal static class ExtensionOrderHelper
{
/// <summary>
/// Returns a new list with items sorted so that those whose provider is in
/// <paramref name="extensionOrder"/> appear first (in that order), followed by
/// the remaining items in their original order. The sort is stable: items that
/// share a provider (and therefore the same order index) keep their original
/// relative order, so a single provider's commands are never shuffled.
/// </summary>
internal static List<T> SortByExtensionOrder<T>(List<T> items, string[] extensionOrder, Func<T, string> providerIdSelector)
{
var orderLookup = new Dictionary<string, int>(extensionOrder.Length, StringComparer.Ordinal);
for (var i = 0; i < extensionOrder.Length; i++)
{
orderLookup.TryAdd(extensionOrder[i], i);
}
var ordered = new List<T>(items.Count);
var unordered = new List<T>(items.Count);
foreach (var item in items)
{
if (orderLookup.ContainsKey(providerIdSelector(item)))
{
ordered.Add(item);
}
else
{
unordered.Add(item);
}
}
// OrderBy is a stable sort, so commands from the same provider keep the
// relative order in which they were loaded.
var result = ordered.OrderBy(item => orderLookup[providerIdSelector(item)]).ToList();
result.AddRange(unordered);
return result;
}
}

View File

@@ -67,6 +67,14 @@ public record SettingsModel
init => _fallbackRanks = value;
}
private string[]? _extensionOrder = [];
public string[] ExtensionOrder
{
get => _extensionOrder ?? [];
init => _extensionOrder = value;
}
private ImmutableDictionary<string, CommandAlias>? _aliases
= ImmutableDictionary<string, CommandAlias>.Empty;
@@ -165,12 +173,14 @@ public record SettingsModel
ImmutableList<PinnedCommandSettings>? pinnedCommands = null,
ImmutableDictionary<string, ProviderSettings>? providerSettings = null,
string[]? fallbackRanks = null,
string[]? extensionOrder = null,
ImmutableDictionary<string, CommandAlias>? aliases = null,
ImmutableList<TopLevelHotkey>? commandHotkeys = null)
{
PinnedCommands = pinnedCommands ?? ImmutableList<PinnedCommandSettings>.Empty;
ProviderSettings = providerSettings ?? ImmutableDictionary<string, ProviderSettings>.Empty;
FallbackRanks = fallbackRanks ?? [];
ExtensionOrder = extensionOrder ?? [];
Aliases = aliases ?? ImmutableDictionary<string, CommandAlias>.Empty;
CommandHotkeys = commandHotkeys ?? ImmutableList<TopLevelHotkey>.Empty;
}

View File

@@ -297,6 +297,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged,
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
public ObservableCollection<ProviderSettingsViewModel> ExtensionOrderRankings { get; set; } = new();
public ObservableCollection<DockMonitorConfigViewModel> MonitorConfigs { get; } = new();
public SettingsExtensionsViewModel Extensions { get; }
@@ -366,6 +368,24 @@ public partial class SettingsViewModel : INotifyPropertyChanged,
}
FallbackRankings = new ObservableCollection<FallbackSettingsViewModel>(fallbackRankings.OrderBy(o => o.Score).Select(fr => fr.Item));
// Build extension order rankings: providers in ExtensionOrder first (in order), then the rest
var currentExtensionOrder = _settingsService.Settings.ExtensionOrder;
var extensionOrderLookup = new Dictionary<string, int>(currentExtensionOrder.Length, StringComparer.Ordinal);
for (var i = 0; i < currentExtensionOrder.Length; i++)
{
extensionOrderLookup.TryAdd(currentExtensionOrder[i], i);
}
var orderedProviders = new List<Scored<ProviderSettingsViewModel>>(CommandProviders.Count);
foreach (var provider in CommandProviders)
{
var score = extensionOrderLookup.TryGetValue(provider.Id, out var idx) ? idx : CommandProviders.Count + orderedProviders.Count;
orderedProviders.Add(new Scored<ProviderSettingsViewModel>() { Item = provider, Score = score });
}
ExtensionOrderRankings = new ObservableCollection<ProviderSettingsViewModel>(orderedProviders.OrderBy(o => o.Score).Select(o => o.Item));
Extensions = new SettingsExtensionsViewModel(CommandProviders, scheduler);
if (needsSave)
@@ -393,6 +413,13 @@ public partial class SettingsViewModel : INotifyPropertyChanged,
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FallbackRankings)));
}
public void ApplyExtensionOrder()
{
_settingsService.UpdateSettings(s => s with { ExtensionOrder = ExtensionOrderRankings.Select(p => p.Id).ToArray() });
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ExtensionOrderRankings)));
WeakReferenceMessenger.Default.Send<ReloadCommandsMessage>();
}
/// <summary>
/// Builds or refreshes the <see cref="MonitorConfigs"/> collection by reconciling
/// connected monitors with persisted per-monitor settings.

View File

@@ -191,9 +191,13 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
var startIndex = FindIndexForFirstProviderItem(clone, sender.ProviderId);
clone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
clone.InsertRange(startIndex, newItems);
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
// Insert the refreshed items where this provider previously sat (or at the
// end if it's brand new). RebuildTopLevelCommands then re-sorts the whole
// list so the provider lands in its user-configured position.
clone.InsertRange(Math.Min(startIndex, clone.Count), newItems);
RebuildTopLevelCommands(clone);
}
lock (_dockBandsLock)
@@ -469,10 +473,14 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
lock (TopLevelCommands)
{
foreach (var c in commandsToAdd)
{
TopLevelCommands.Add(c);
}
// Append the freshly loaded batch, then re-sort the entire top-level list
// so the user's configured extension order is honored across batches
// (built-ins and external extensions load in separate batches).
var clone = new List<TopLevelViewModel>(TopLevelCommands.Count + commandsToAdd.Count);
clone.AddRange(TopLevelCommands);
clone.AddRange(commandsToAdd);
RebuildTopLevelCommands(clone);
}
lock (_dockBandsLock)
@@ -495,6 +503,23 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
return new RegisterAndLoadSummary(totalCommands, totalDockBands);
}
/// <summary>
/// Replaces the contents of <see cref="TopLevelCommands"/> with <paramref name="newCommands"/>,
/// first applying the user-configured extension order when one is set. Callers must hold the
/// <see cref="TopLevelCommands"/> lock. Built-in and external providers load in separate
/// batches, so re-sorting the whole list here is what lets the configured order span them.
/// </summary>
private void RebuildTopLevelCommands(List<TopLevelViewModel> newCommands)
{
var extensionOrder = _serviceProvider.GetRequiredService<ISettingsService>().Settings.ExtensionOrder;
if (extensionOrder.Length > 0)
{
newCommands = ExtensionOrderHelper.SortByExtensionOrder(newCommands, extensionOrder, c => c.CommandProviderId);
}
ListHelpers.InPlaceUpdateList(TopLevelCommands, newCommands);
}
private async Task<CommandLoadResult> TryLoadCommandsAsync(CommandProviderWrapper wrapper, CancellationToken ct)
{
var sw = Stopwatch.StartNew();
@@ -539,10 +564,11 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
lock (TopLevelCommands)
{
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
var clone = new List<TopLevelViewModel>(TopLevelCommands.Count + commands.Count);
clone.AddRange(TopLevelCommands);
clone.AddRange(commands);
RebuildTopLevelCommands(clone);
}
}

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ExtensionRanker"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
mc:Ignorable="d">
<Grid>
<ListView
Padding="12,0,24,0"
AllowDrop="True"
CanDragItems="True"
CanReorderItems="True"
DragItemsCompleted="ListView_DragItemsCompleted"
ItemsSource="{x:Bind viewModel.ExtensionOrderRankings, Mode=OneWay}"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="viewModels:ProviderSettingsViewModel">
<Grid Padding="4,0,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Viewbox Grid.Column="1" Height="18">
<PathIcon Data="M15.5 17C16.3284 17 17 17.6716 17 18.5C17 19.3284 16.3284 20 15.5 20C14.6716 20 14 19.3284 14 18.5C14 17.6716 14.6716 17 15.5 17ZM8.5 17C9.32843 17 10 17.6716 10 18.5C10 19.3284 9.32843 20 8.5 20C7.67157 20 7 19.3284 7 18.5C7 17.6716 7.67157 17 8.5 17ZM15.5 10C16.3284 10 17 10.6716 17 11.5C17 12.3284 16.3284 13 15.5 13C14.6716 13 14 12.3284 14 11.5C14 10.6716 14.6716 10 15.5 10ZM8.5 10C9.32843 10 10 10.6716 10 11.5C10 12.3284 9.32843 13 8.5 13C7.67157 13 7 12.3284 7 11.5C7 10.6716 7.67157 10 8.5 10ZM15.5 3C16.3284 3 17 3.67157 17 4.5C17 5.32843 16.3284 6 15.5 6C14.6716 6 14 5.32843 14 4.5C14 3.67157 14.6716 3 15.5 3ZM8.5 3C9.32843 3 10 3.67157 10 4.5C10 5.32843 9.32843 6 8.5 6C7.67157 6 7 5.32843 7 4.5C7 3.67157 7.67157 3 8.5 3Z" Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</Viewbox>
<controls:SettingsCard
Width="560"
MinHeight="0"
Padding="8"
Background="Transparent"
BorderThickness="0"
Header="{x:Bind DisplayName}"
ToolTipService.ToolTip="{x:Bind Id}">
<controls:SettingsCard.HeaderIcon>
<cpcontrols:ContentIcon>
<cpcontrols:ContentIcon.Content>
<cpcontrols:IconBox
Width="16"
Height="16"
AutomationProperties.AccessibilityView="Raw"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind helpers:IconProvider.SourceRequested20}" />
</cpcontrols:ContentIcon.Content>
</cpcontrols:ContentIcon>
</controls:SettingsCard.HeaderIcon>
</controls:SettingsCard>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Vertical" Spacing="0" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="4" />
</Style>
</ListView.ItemContainerStyle>
</ListView>
</Grid>
</UserControl>

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ExtensionRanker : UserControl
{
private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
private SettingsViewModel? viewModel;
public ExtensionRanker()
{
this.InitializeComponent();
var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
var themeService = App.Current.Services.GetService<IThemeService>()!;
var settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
viewModel = new SettingsViewModel(topLevelCommandManager, _mainTaskScheduler, themeService, settingsService);
}
private void ListView_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args)
{
viewModel?.ApplyExtensionOrder();
}
}

View File

@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.Controls.ExtensionRankerDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<ContentDialog
x:Name="ExtensionRankerContentDialog"
Width="420"
MinWidth="420"
PrimaryButtonText="OK">
<ContentDialog.Title>
<TextBlock x:Uid="ManageExtensionOrder" />
</ContentDialog.Title>
<ContentDialog.Resources>
<x:Double x:Key="ContentDialogMaxWidth">800</x:Double>
</ContentDialog.Resources>
<Grid Width="560" MinWidth="420">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock
x:Uid="ManageExtensionOrderDialogDescription"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<Rectangle
Grid.Row="1"
Height="1"
Margin="0,16,0,16"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
<cpcontrols:ExtensionRanker
x:Name="ExtensionRanker"
Grid.Row="2"
Margin="-24,0,-24,0" />
</Grid>
</ContentDialog>
</UserControl>

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Controls;
using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class ExtensionRankerDialog : UserControl
{
public ExtensionRankerDialog()
{
InitializeComponent();
}
public IAsyncOperation<ContentDialogResult> ShowAsync()
{
return ExtensionRankerContentDialog!.ShowAsync()!;
}
}

View File

@@ -178,6 +178,11 @@
<FontIcon Glyph="&#xE8CB;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem x:Uid="Settings_ExtensionsPage_More_ReorderExtensions_MenuFlyoutItem" Click="ReorderExtensions_OnClick">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE174;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</Button.Flyout>
<FontIcon
@@ -264,6 +269,7 @@
</Grid>
</ScrollViewer>
<cpcontrols:FallbackRankerDialog x:Name="FallbackRankerDialog" />
<cpcontrols:ExtensionRankerDialog x:Name="ExtensionRankerDialog" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="LayoutVisualStates">
<VisualState x:Name="WideLayout">

View File

@@ -93,4 +93,16 @@ public sealed partial class ExtensionsPage : Page
Logger.LogError("Error when showing FallbackRankerDialog", ex);
}
}
private async void ReorderExtensions_OnClick(object sender, RoutedEventArgs e)
{
try
{
await ExtensionRankerDialog!.ShowAsync();
}
catch (Exception ex)
{
Logger.LogError("Error when showing ExtensionRankerDialog", ex);
}
}
}

View File

@@ -823,6 +823,15 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_ExtensionsPage_More_ReorderFallbacks_MenuFlyoutItem.Text" xml:space="preserve">
<value>Manage fallback order</value>
</data>
<data name="Settings_ExtensionsPage_More_ReorderExtensions_MenuFlyoutItem.Text" xml:space="preserve">
<value>Manage extension order</value>
</data>
<data name="ManageExtensionOrder.Text" xml:space="preserve">
<value>Manage extension order</value>
</data>
<data name="ManageExtensionOrderDialogDescription.Text" xml:space="preserve">
<value>Drag items to set which extensions appear first in the top-level list; extensions at the top take priority.</value>
</data>
<data name="Settings_GeneralPage_VersionNo" xml:space="preserve">
<value>Version {0}</value>
</data>

View File

@@ -0,0 +1,198 @@
// 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;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
[TestClass]
public class ExtensionOrderTests
{
private static readonly string[] OrderAB = ["a", "b"];
private static readonly string[] OrderABC = ["a", "b", "c"];
private static readonly string[] ExpectedXYZ = ["x", "y", "z"];
private static readonly string[] ExpectedABC = ["a", "b", "c"];
private static readonly string[] ExpectedABXY = ["a", "b", "x", "y"];
private static readonly string[] OrderExternalThenBuiltIns = ["external.foo", "builtin.apps", "builtin.calc"];
private static readonly string[] OrderExternalThenApps = ["external.foo", "builtin.apps"];
private static readonly string[] ExpectedExternalFirst = ["external.foo", "builtin.apps", "builtin.calc"];
private static readonly string[] ExpectedNewProviderLast = ["external.foo", "builtin.apps", "newly.installed"];
[TestMethod]
public void ExtensionOrder_DefaultIsEmpty()
{
var settings = DeserializeSettings("{}");
Assert.IsNotNull(settings.ExtensionOrder);
Assert.AreEqual(0, settings.ExtensionOrder.Length);
}
[TestMethod]
public void ExtensionOrder_RoundTrips()
{
var order = new[] { "provider.b", "provider.a", "provider.c" };
var settings = DeserializeSettings("{}") with { ExtensionOrder = order };
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.SettingsModel);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!;
CollectionAssert.AreEqual(order, deserialized.ExtensionOrder);
}
[TestMethod]
public void ExtensionOrder_NullDeserializesToEmpty()
{
var json = """{"ExtensionOrder": null}""";
var settings = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!;
Assert.IsNotNull(settings.ExtensionOrder);
Assert.AreEqual(0, settings.ExtensionOrder.Length);
}
[TestMethod]
public void ExtensionOrder_PreservesOrderInJson()
{
var order = new[] { "z.ext", "a.ext", "m.ext" };
var settings = DeserializeSettings("{}") with { ExtensionOrder = order };
var json = JsonSerializer.Serialize(settings, JsonSerializationContext.Default.SettingsModel);
var deserialized = JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel)!;
Assert.AreEqual("z.ext", deserialized.ExtensionOrder[0]);
Assert.AreEqual("a.ext", deserialized.ExtensionOrder[1]);
Assert.AreEqual("m.ext", deserialized.ExtensionOrder[2]);
}
[TestMethod]
public void SortByExtensionOrder_EmptyList_ReturnsEmpty()
{
var result = ExtensionOrderHelper.SortByExtensionOrder(
new List<string>(),
OrderAB,
s => s);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void SortByExtensionOrder_EmptyOrder_PreservesOriginalOrder()
{
var items = new List<string> { "x", "y", "z" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, [], s => s);
CollectionAssert.AreEqual(ExpectedXYZ, result);
}
[TestMethod]
public void SortByExtensionOrder_AllInOrder_SortsAccordingly()
{
var items = new List<string> { "c", "a", "b" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderABC, s => s);
CollectionAssert.AreEqual(ExpectedABC, result);
}
[TestMethod]
public void SortByExtensionOrder_NoneInOrder_PreservesOriginalOrder()
{
var items = new List<string> { "x", "y", "z" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderABC, s => s);
CollectionAssert.AreEqual(ExpectedXYZ, result);
}
[TestMethod]
public void SortByExtensionOrder_Mixed_OrderedFirstThenUnordered()
{
var items = new List<string> { "x", "b", "y", "a" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s);
CollectionAssert.AreEqual(ExpectedABXY, result);
}
[TestMethod]
public void SortByExtensionOrder_DuplicateProviderIds_AllGroupedInOrder()
{
var items = new List<string> { "b", "a", "b", "c", "a" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s);
Assert.AreEqual("a", result[0]);
Assert.AreEqual("a", result[1]);
Assert.AreEqual("b", result[2]);
Assert.AreEqual("b", result[3]);
Assert.AreEqual("c", result[4]);
}
[TestMethod]
public void SortByExtensionOrder_SingleElement_ReturnsIt()
{
var items = new List<string> { "a" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, s => s);
Assert.AreEqual(1, result.Count);
Assert.AreEqual("a", result[0]);
}
[TestMethod]
public void SortByExtensionOrder_SameProviderItems_KeepRelativeOrder()
{
// Two providers ("a" and "b"), each contributing several commands. The sort
// must be stable so a single provider's commands are never shuffled.
var items = new List<(string Provider, int Command)>
{
("b", 0),
("a", 0),
("b", 1),
("a", 1),
("a", 2),
};
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderAB, x => x.Provider);
var expected = new List<(string, int)>
{
("a", 0),
("a", 1),
("a", 2),
("b", 0),
("b", 1),
};
CollectionAssert.AreEqual(expected, result);
}
[TestMethod]
public void SortByExtensionOrder_ExternalCanOutrankBuiltIn_WhenBothInOrder()
{
// Built-ins load before external extensions, but the configured order lists the
// external provider first. Sorting the full list lets the external outrank the
// built-in so the result matches what the reorder dialog showed (WYSIWYG).
var items = new List<string> { "builtin.apps", "builtin.calc", "external.foo" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderExternalThenBuiltIns, s => s);
CollectionAssert.AreEqual(ExpectedExternalFirst, result);
}
[TestMethod]
public void SortByExtensionOrder_NewProviderNotInOrder_GoesToEnd()
{
// A provider installed after the last reorder isn't in the saved order, so it
// keeps its natural load position at the end rather than jumping to the front.
var items = new List<string> { "external.foo", "builtin.apps", "newly.installed" };
var result = ExtensionOrderHelper.SortByExtensionOrder(items, OrderExternalThenApps, s => s);
CollectionAssert.AreEqual(ExpectedNewProviderLast, result);
}
private static SettingsModel DeserializeSettings(string json)
{
return JsonSerializer.Deserialize(json, JsonSerializationContext.Default.SettingsModel) ?? new SettingsModel();
}
}

View File

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

View File

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

View File

@@ -1,22 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// A single PowerScript entry as surfaced to the Keyboard Manager editor's "PowerScript" action picker.
/// </summary>
public sealed class PowerScriptInfo
{
public string Id { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string Description { get; init; } = string.Empty;
public string Kind { get; init; } = string.Empty;
public override string ToString() => Name;
}
}

View File

@@ -1,31 +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 KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// A saved Keyboard Manager hotkey that runs a PowerScript. Although it is persisted as a
/// "Run Program" mapping (the engine's execution primitive), it is presented in the editor as a
/// first-class PowerScript action so the user sees "PowerScript: &lt;name&gt;" rather than a raw
/// program-path card.
/// </summary>
public class PowerScriptShortcut : IToggleableShortcut
{
public List<string> Shortcut { get; set; } = new List<string>();
/// <summary>The PowerScript id this hotkey runs.</summary>
public string ScriptId { get; set; } = string.Empty;
/// <summary>The PowerScript's friendly name, for display.</summary>
public string ScriptName { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public string Id { get; set; } = string.Empty;
public string AppName { get; set; } = string.Empty;
}
}

View File

@@ -1,224 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.Json;
namespace KeyboardManagerEditorUI.Helpers
{
/// <summary>
/// Bridges the Keyboard Manager editor to the PowerScripts module.
///
/// PowerScripts are surfaced through the shared executor <c>PowerScripts.Host.exe</c>. To keep the
/// editor decoupled from the PowerScripts assemblies, we shell out to <c>Host.exe list --json</c>
/// and parse the result. Selecting a "system" PowerScript in the editor then saves an ordinary
/// Keyboard Manager "Run Program" mapping whose target is <c>Host.exe run &lt;id&gt;</c>.
/// </summary>
public static class PowerScriptsCatalog
{
private const string HostExeName = "PowerScripts.Host.exe";
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
/// <summary>
/// Resolves the full path to <c>PowerScripts.Host.exe</c>, or null if it can't be found.
/// Search order: explicit override env var, next to the editor, then the default install root.
/// </summary>
public static string? ResolveHostPath()
{
var overridePath = Environment.GetEnvironmentVariable("POWERSCRIPTS_HOST");
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
{
return overridePath;
}
var candidates = new List<string>
{
Path.Combine(AppContext.BaseDirectory, HostExeName),
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"PowerScripts",
HostExeName),
};
// Prototype dev fallback: in an in-repo build the Host isn't copied next to the editor,
// so walk up from the base directory and probe the Host project's bin output. This keeps
// the PowerScript action usable for end-to-end testing from a Debug build.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
foreach (var config in new[] { "Debug", "Release" })
{
var hostBin = Path.Combine(
dir.FullName,
"src",
"modules",
"PowerScripts",
"PowerScripts.Host",
"bin",
config);
if (Directory.Exists(hostBin))
{
var found = Directory
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
.FirstOrDefault();
if (!string.IsNullOrEmpty(found))
{
candidates.Add(found);
}
}
}
dir = dir.Parent;
}
foreach (var candidate in candidates)
{
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Returns the list of system PowerScripts available for hotkey assignment, or an empty list
/// when PowerScripts isn't installed or no system scripts exist.
/// </summary>
public static IReadOnlyList<PowerScriptInfo> GetSystemScripts()
{
var hostPath = ResolveHostPath();
if (hostPath is null)
{
return Array.Empty<PowerScriptInfo>();
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = "list --json",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
if (process is null)
{
return Array.Empty<PowerScriptInfo>();
}
string json = process.StandardOutput.ReadToEnd();
process.WaitForExit(5000);
var all = JsonSerializer.Deserialize<List<PowerScriptInfo>>(json, JsonOptions) ?? new List<PowerScriptInfo>();
var systemScripts = new List<PowerScriptInfo>();
foreach (var script in all)
{
if (string.Equals(script.Kind, "system", StringComparison.OrdinalIgnoreCase))
{
systemScripts.Add(script);
}
}
return systemScripts;
}
catch (Exception)
{
// Prototype: a missing/failed PowerScripts host simply yields no scripts to pick.
return Array.Empty<PowerScriptInfo>();
}
}
/// <summary>
/// Records the user's trust for a script's current content, so the engine can run it silently
/// via <c>Host.exe run &lt;id&gt; --no-consent</c>. Assigning a script to a hotkey is itself an
/// explicit consent, so we approve it here rather than popping a dialog from the (hidden)
/// engine-launched process. If the script's contents later change, the non-interactive run
/// simply no-ops until the user re-assigns it.
/// </summary>
public static void ApproveTrust(string id)
{
var hostPath = ResolveHostPath();
if (hostPath is null || string.IsNullOrEmpty(id))
{
return;
}
try
{
var psi = new ProcessStartInfo
{
FileName = hostPath,
Arguments = $"trust approve {id}",
UseShellExecute = false,
CreateNoWindow = true,
};
using var process = Process.Start(psi);
process?.WaitForExit(5000);
}
catch (Exception)
{
// Prototype: if trust can't be recorded the hotkey just won't run; not fatal.
}
}
/// <summary>
/// True when a Keyboard Manager "Run Program" mapping actually launches a PowerScript, i.e. its
/// program path is <c>PowerScripts.Host.exe</c>. Used to present these mappings as first-class
/// PowerScript actions instead of raw run-program cards.
/// </summary>
public static bool IsPowerScriptProgramPath(string? programPath) =>
!string.IsNullOrEmpty(programPath) &&
string.Equals(Path.GetFileName(programPath), HostExeName, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Extracts the script id from a PowerScript mapping's arguments (<c>run &lt;id&gt; ...</c>),
/// or null if the arguments aren't a recognizable PowerScript run command.
/// </summary>
public static string? ParseScriptId(string? programArgs)
{
if (string.IsNullOrWhiteSpace(programArgs))
{
return null;
}
var tokens = programArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries);
if (tokens.Length >= 2 && string.Equals(tokens[0], "run", StringComparison.OrdinalIgnoreCase))
{
return tokens[1];
}
return null;
}
/// <summary>Resolves a script's display name from its id, falling back to the id itself.</summary>
public static string GetScriptName(string id)
{
foreach (var script in GetSystemScripts())
{
if (string.Equals(script.Id, id, StringComparison.OrdinalIgnoreCase))
{
return script.Name;
}
}
return id;
}
}
}

View File

@@ -452,89 +452,6 @@
</ListView>
</StackPanel>
<!-- PowerScripts Section -->
<StackPanel Orientation="Vertical" Visibility="{x:Bind PowerScriptShortcuts.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
<TextBlock
Margin="16,16,0,8"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="PowerScripts" />
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource CardStrokeColorDefaultBrush}" />
<ListView
IsItemClickEnabled="True"
ItemClick="PowerScriptShortcutsList_ItemClick"
ItemsSource="{x:Bind PowerScriptShortcuts}"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None">
<ListView.ItemTemplate>
<DataTemplate x:DataType="helper:PowerScriptShortcut">
<Grid MinHeight="48" Padding="0,8,0,8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Rectangle Style="{StaticResource ItemDividerStyle}" />
<StackPanel Orientation="Horizontal" Spacing="8">
<ItemsControl VerticalAlignment="Center" ItemsSource="{x:Bind Shortcut}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<commoncontrols:KeyVisual Content="{Binding}" Style="{StaticResource OriginalKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="runs PowerScript" />
<FontIcon
VerticalAlignment="Center"
FontSize="16"
Glyph="&#xE756;" />
<TextBlock
VerticalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ScriptName}" />
</StackPanel>
<StackPanel
Grid.Column="1"
VerticalAlignment="Center"
Orientation="Horizontal"
Spacing="8">
<ToggleSwitch
IsOn="{x:Bind IsActive}"
Style="{StaticResource RightAlignedCompactToggleSwitchStyle}"
Toggled="ToggleSwitch_Toggled" />
<Button
VerticalAlignment="Center"
Content="{ui:FontIcon Glyph=&#xE712;,
FontSize=14}"
Style="{StaticResource SubtleButtonStyle}">
<Button.Flyout>
<MenuFlyout>
<MenuFlyoutItem
x:Uid="DeleteMenuItem"
Click="DeleteMapping_Click"
Icon="{ui:FontIcon Glyph=&#xE74D;,
FontSize=14}"
Tag="{x:Bind}" />
</MenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
<!-- URLs Section -->
<StackPanel Orientation="Vertical" Visibility="{x:Bind UrlShortcuts.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
<TextBlock

View File

@@ -79,8 +79,6 @@ namespace KeyboardManagerEditorUI.Pages
public ObservableCollection<ProgramShortcut> ProgramShortcuts { get; } = new();
public ObservableCollection<PowerScriptShortcut> PowerScriptShortcuts { get; } = new();
public ObservableCollection<URLShortcut> UrlShortcuts { get; } = new();
[DllImport("PowerToys.KeyboardManagerEditorLibraryWrapper.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
@@ -94,7 +92,6 @@ namespace KeyboardManagerEditorUI.Pages
TextMapping,
ProgramShortcut,
UrlShortcut,
PowerScriptShortcut,
}
public ItemType Type { get; set; }
@@ -308,31 +305,6 @@ namespace KeyboardManagerEditorUI.Pages
await ShowRemappingDialog();
}
private async void PowerScriptShortcutsList_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is not PowerScriptShortcut powerScriptShortcut)
{
return;
}
_isEditMode = true;
_editingItem = new EditingItem
{
Type = EditingItem.ItemType.PowerScriptShortcut,
Item = powerScriptShortcut,
OriginalTriggerKeys = powerScriptShortcut.Shortcut.ToList(),
AppName = powerScriptShortcut.AppName,
IsAllApps = true,
};
UnifiedMappingControl.Reset();
UnifiedMappingControl.SetTriggerKeys(powerScriptShortcut.Shortcut.ToList());
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.PowerScript);
UnifiedMappingControl.SelectPowerScript(powerScriptShortcut.ScriptId);
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
await ShowRemappingDialog();
}
private async System.Threading.Tasks.Task ShowRemappingDialog()
{
RemappingDialog.PrimaryButtonClick += RemappingDialog_PrimaryButtonClick;
@@ -422,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,
};
@@ -468,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,
};
}
@@ -716,52 +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)));
// Persisted as a "Run Program" mapping (the engine's execution primitive) but presented in
// the editor as a dedicated PowerScript action. Assigning the hotkey is explicit consent,
// so we record trust below and run non-interactively (--no-consent) — the engine launches
// the Host hidden, where a consent dialog could not be reliably shown.
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RunProgram,
OriginalKeys = originalKeysString,
TargetKeys = originalKeysString,
ProgramPath = hostPath,
ProgramArgs = $"run {script.Id} --no-consent",
StartInDirectory = string.Empty,
IfRunningAction = ProgramAlreadyRunningAction.StartAnother,
Visibility = StartWindowType.Hidden,
Elevation = ElevationLevel.NonElevated,
TargetApp = string.Empty,
};
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
if (saved)
{
// Record trust for the script's current content so the engine can run it silently.
PowerScriptsCatalog.ApproveTrust(script.Id);
_mappingService.SaveSettings();
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
}
return saved;
}
#endregion
#region Delete Handlers
@@ -960,14 +881,13 @@ namespace KeyboardManagerEditorUI.Pages
LoadRemappings();
LoadTextMappings();
LoadProgramShortcuts();
LoadPowerScriptShortcuts();
LoadUrlShortcuts();
UpdateHasAnyMappings();
}
private void UpdateHasAnyMappings()
{
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || PowerScriptShortcuts.Count > 0 || UrlShortcuts.Count > 0;
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
MappingState = hasAny ? "HasMappings" : "Empty";
}
@@ -1056,14 +976,6 @@ namespace KeyboardManagerEditorUI.Pages
{
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
// PowerScript hotkeys are stored as Run-Program mappings but shown in their own
// dedicated section, so skip them here.
if (PowerScriptsCatalog.IsPowerScriptProgramPath(mapping.ProgramPath))
{
continue;
}
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
ProgramShortcuts.Add(new ProgramShortcut
@@ -1083,47 +995,6 @@ namespace KeyboardManagerEditorUI.Pages
}
}
private void LoadPowerScriptShortcuts()
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RunProgram, out var remapShortcutIds);
PowerScriptShortcuts.Clear();
if (remapShortcutIds == null)
{
return;
}
foreach (var id in remapShortcutIds)
{
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
if (!PowerScriptsCatalog.IsPowerScriptProgramPath(mapping.ProgramPath))
{
continue;
}
var scriptId = PowerScriptsCatalog.ParseScriptId(mapping.ProgramArgs);
if (string.IsNullOrEmpty(scriptId))
{
continue;
}
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
PowerScriptShortcuts.Add(new PowerScriptShortcut
{
Shortcut = originalKeyNames,
ScriptId = scriptId,
ScriptName = PowerScriptsCatalog.GetScriptName(scriptId),
IsActive = shortcutSettings.IsActive,
Id = shortcutSettings.Id,
AppName = mapping.TargetApp ?? string.Empty,
});
}
}
private void LoadUrlShortcuts()
{
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.OpenUri, out var remapShortcutIds);

View File

@@ -122,21 +122,6 @@ namespace KeyboardEventHandlers
key_count = std::get<Shortcut>(it->second).Size();
}
const DWORD sourceKey = data->lParam->vkCode;
const bool isKeyUp = (data->wParam == WM_KEYUP || data->wParam == WM_SYSKEYUP);
// If the matching key-down injection was blocked earlier, we passed the
// original key-down through to the foreground app to keep the key alive.
// The corresponding key-up must be passed through as well; otherwise the
// physical key is stranded DOWN (its down reached the app, but its up would
// be swallowed by the remap). Key-down and key-up arrive as separate hook
// events, so this is the cross-invocation counterpart of the key-down
// passthrough handled below.
if (isKeyUp && state.ConsumeSingleKeyRemapInjectionFailed(sourceKey))
{
return 0;
}
std::vector<INPUT> keyEventList;
// Handle remaps to VK_WIN_BOTH
@@ -192,25 +177,7 @@ namespace KeyboardEventHandlers
}
}
if (!ii.SendVirtualInput(keyEventList))
{
// Injection was blocked (e.g. by UIPI). Return 0 so the ORIGINAL key is
// passed through instead of being swallowed, leaving no dead key. For a
// key-down, remember that we passed it through so the matching key-up is
// passed through too (handled above), preventing a key stranded DOWN.
if (!isKeyUp)
{
state.SetSingleKeyRemapInjectionFailed(sourceKey, true);
}
return 0;
}
// Injection succeeded; drop any stale passthrough marker for this key so its
// key-up follows the normal (suppressed) path.
if (!isKeyUp)
{
state.SetSingleKeyRemapInjectionFailed(sourceKey, false);
}
ii.SendVirtualInput(keyEventList);
if (data->wParam == WM_KEYDOWN || data->wParam == WM_SYSKEYDOWN)
{
@@ -585,12 +552,9 @@ namespace KeyboardEventHandlers
// Send modifier release events first, then send text directly
// (SendTextInput handles multiline by flushing between chunks)
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
keyEventList.clear();
Helpers::SendTextInput(remapping, ii);
Helpers::SendTextInput(remapping);
}
it->second.isShortcutInvoked = true;
@@ -602,10 +566,7 @@ namespace KeyboardEventHandlers
Logger::trace(L"ChordKeyboardHandler:keyEventList.size:{}", keyEventList.size());
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
if (activatedApp.has_value())
{
if (remapToKey)
@@ -744,10 +705,7 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
return 1;
}
@@ -777,14 +735,12 @@ namespace KeyboardEventHandlers
else if (remapToText)
{
auto& remapping = std::get<std::wstring>(it->second.targetShortcut);
Helpers::SendTextInput(remapping, ii);
ii.SendVirtualInput(keyEventList);
Helpers::SendTextInput(remapping);
return 1;
}
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
return 1;
}
@@ -871,10 +827,7 @@ namespace KeyboardEventHandlers
}
}
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
return 1;
}
@@ -999,10 +952,7 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
return 1;
}
else
@@ -1071,10 +1021,7 @@ namespace KeyboardEventHandlers
state.SetActivatedApp(KeyboardManagerConstants::NoActivatedApp);
}
if (!ii.SendVirtualInput(keyEventList))
{
return 0;
}
ii.SendVirtualInput(keyEventList);
return 1;
}
else
@@ -1852,9 +1799,8 @@ namespace KeyboardEventHandlers
return 0;
}
// Only send the text on key-down events. WM_SYSKEYDOWN is sent instead of
// WM_KEYDOWN while Alt is held, so accept it too or the remap silently drops.
if (data->wParam != WM_KEYDOWN && data->wParam != WM_SYSKEYDOWN)
// Only send the text on keydown event
if (data->wParam != WM_KEYDOWN)
{
return 0;
}
@@ -1865,43 +1811,7 @@ namespace KeyboardEventHandlers
return 0;
}
// Release held modifiers before text injection to prevent Ctrl+text corruption
constexpr int modifierKeys[] = { VK_LCONTROL, VK_RCONTROL, VK_LSHIFT, VK_RSHIFT, VK_LMENU, VK_RMENU, VK_LWIN, VK_RWIN };
std::vector<INPUT> releaseEvents;
// A dummy key event must precede the modifier releases so that releasing a
// held Win (Start Menu) or Alt (menu bar) does not trigger its lone-press
// action when we inject the modifier key-up.
Helpers::SetDummyKeyEvent(releaseEvents, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
bool anyModifierHeld = false;
for (int vk : modifierKeys)
{
if (ii.GetVirtualKeyState(vk))
{
Helpers::SetKeyEvent(releaseEvents, INPUT_KEYBOARD, static_cast<WORD>(vk), KEYEVENTF_KEYUP, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
anyModifierHeld = true;
}
}
// Only inject the dummy + modifier releases when a modifier was actually held.
if (anyModifierHeld)
{
if (!ii.SendVirtualInput(releaseEvents))
{
return 0;
}
}
Helpers::SendTextInput(*remapping, ii);
// Intentionally do NOT re-press the released modifiers. Once we inject a
// KEYUP for a modifier, GetAsyncKeyState (and therefore GetVirtualKeyState)
// reports it as up, so there is no reliable way to tell whether the user is
// still physically holding the key or has released it. Re-pressing
// unconditionally would risk leaving a modifier stuck down if the user let
// go during injection — the exact failure this change set prevents. Leaving
// the modifier released is always safe: the user taps it again to re-engage.
Helpers::SendTextInput(*remapping);
return 1;
}

View File

@@ -73,20 +73,3 @@ std::wstring State::GetActivatedApp()
{
return activatedAppSpecificShortcutTarget;
}
void State::SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed)
{
if (failed)
{
singleKeyRemapInjectionFailedKeys.insert(sourceKey);
}
else
{
singleKeyRemapInjectionFailedKeys.erase(sourceKey);
}
}
bool State::ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey)
{
return singleKeyRemapInjectionFailedKeys.erase(sourceKey) > 0;
}

View File

@@ -1,6 +1,5 @@
#pragma once
#include <keyboardmanager/common/MappingConfiguration.h>
#include <unordered_set>
class State : public MappingConfiguration
{
@@ -8,12 +7,6 @@ private:
// Stores the activated target application in app-specific shortcut
std::wstring activatedAppSpecificShortcutTarget;
// Source keys whose single-key remap key-down injection was blocked, so the original
// key-down was passed through to the foreground app. The matching key-up must be
// passed through too; otherwise the physical key is stranded DOWN. Only accessed from
// the (serialized) low-level keyboard hook thread.
std::unordered_set<DWORD> singleKeyRemapInjectionFailedKeys;
public:
// Function to get the iterator of a single key remap given the source key. Returns nullopt if it isn't remapped
std::optional<SingleKeyRemapTable::iterator> GetSingleKeyRemap(const DWORD& originalKey);
@@ -33,14 +26,4 @@ public:
// Gets the activated target application in app-specific shortcut
std::wstring GetActivatedApp();
// Records (failed == true) or clears (failed == false) that the single-key remap
// key-down injection for sourceKey was blocked and the original key-down was passed
// through to the foreground app.
void SetSingleKeyRemapInjectionFailed(const DWORD sourceKey, const bool failed);
// Returns true and clears the marker if sourceKey's single-key remap key-down
// injection was previously blocked, indicating that its key-up should be passed
// through as well.
bool ConsumeSingleKeyRemapInjectionFailed(const DWORD sourceKey);
};

View File

@@ -10,14 +10,8 @@ void MockedInput::SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> ho
}
// Function to simulate keyboard input - arguments and return value based on SendInput function (https://learn.microsoft.com/windows/win32/api/winuser/nf-winuser-sendinput)
bool MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
void MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
{
// Simulate an injection failure (e.g. SendInput blocked) when configured.
if (sendVirtualInputShouldFail != nullptr && sendVirtualInputShouldFail(inputs))
{
return false;
}
// Iterate over inputs
for (const INPUT& input : inputs)
{
@@ -113,7 +107,6 @@ bool MockedInput::SendVirtualInput(const std::vector<INPUT>& inputs)
}
}
}
return true;
}
// Function to simulate keyboard hook behavior
@@ -136,12 +129,6 @@ bool MockedInput::GetVirtualKeyState(int key)
return keyboardState[key];
}
// Function to set the state of a particular key for test setup
void MockedInput::SetKeyboardState(int key, bool state)
{
keyboardState[key] = state;
}
// Function to reset the mocked keyboard state
void MockedInput::ResetKeyboardState()
{
@@ -155,12 +142,6 @@ void MockedInput::SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyb
sendVirtualInputCallCondition = condition;
}
// Function to force SendVirtualInput to fail for calls matching a predicate
void MockedInput::SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition)
{
sendVirtualInputShouldFail = condition;
}
// Function to get SendVirtualInput call count
int MockedInput::GetSendVirtualInputCallCount()
{

View File

@@ -22,10 +22,6 @@ namespace KeyboardManagerInput
int sendVirtualInputCallCount = 0;
std::function<bool(LowlevelKeyboardEvent*)> sendVirtualInputCallCondition;
// Optional predicate; when set and it returns true for a SendVirtualInput
// call, that call fails (returns false) to simulate a SendInput failure.
std::function<bool(const std::vector<INPUT>&)> sendVirtualInputShouldFail;
std::wstring currentProcess;
public:
@@ -38,7 +34,7 @@ namespace KeyboardManagerInput
void SetHookProc(std::function<intptr_t(LowlevelKeyboardEvent*)> hookProcedure);
// Function to simulate keyboard input
bool SendVirtualInput(const std::vector<INPUT>& inputs);
void SendVirtualInput(const std::vector<INPUT>& inputs);
// Function to simulate keyboard hook behavior
intptr_t MockedKeyboardHook(LowlevelKeyboardEvent* data);
@@ -46,18 +42,12 @@ namespace KeyboardManagerInput
// Function to get the state of a particular key
bool GetVirtualKeyState(int key);
// Function to set the state of a particular key for test setup
void SetKeyboardState(int key, bool state);
// Function to reset the mocked keyboard state
void ResetKeyboardState();
// Function to set SendVirtualInput call count condition
void SetSendVirtualInputTestHandler(std::function<bool(LowlevelKeyboardEvent*)> condition);
// Function to force SendVirtualInput to fail for calls matching a predicate
void SetSendVirtualInputShouldFail(std::function<bool(const std::vector<INPUT>&)> condition);
// Function to get SendVirtualInput call count
int GetSendVirtualInputCallCount();

View File

@@ -63,84 +63,6 @@ namespace RemappingLogicTests
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x42), false);
}
// When injecting the remapped key fails (e.g. SendInput is blocked by UIPI or
// another hook), the handler must let the ORIGINAL key through instead of
// silently swallowing it, so the user is never left with a dead key. This
// exercises the stuck-key hardening that checks SendVirtualInput's return value.
TEST_METHOD (RemappedKey_ShouldPassOriginalKeyThrough_WhenInjectionFails)
{
// Remap A to B
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
// Fail only KBM-injected events (tagged with a non-zero dwExtraInfo),
// leaving the test's own driving input (dwExtraInfo == 0) untouched.
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
for (const auto& input : inputs)
{
if (input.ki.dwExtraInfo != 0)
{
return true;
}
}
return false;
});
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
};
// Send A keydown - injection of B fails, so A must pass through
mockedInputHandler.SendVirtualInput(inputs);
// The original A is let through (state true); B was never injected (false)
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
}
// When the remapped key-DOWN injection is blocked but the later key-UP injection
// would succeed, the handler must still let the ORIGINAL key-up through. The
// key-down was passed through to the app (key is physically DOWN), so swallowing
// the key-up would strand the physical key DOWN. This guards the asymmetric
// injection-failure stuck-key edge case, where key-down and key-up arrive as
// separate hook events.
TEST_METHOD (RemappedKey_ShouldReleaseOriginalKey_WhenKeyDownInjectionFailedButKeyUpSucceeds)
{
// Remap A to B
testState.AddSingleKeyRemap(0x41, (DWORD)0x42);
// Fail only KBM-injected key-DOWN events; allow injected key-ups (and the
// test's own driving input, which has dwExtraInfo == 0) through.
mockedInputHandler.SetSendVirtualInputShouldFail([](const std::vector<INPUT>& inputs) {
for (const auto& input : inputs)
{
if (input.ki.dwExtraInfo != 0 && (input.ki.dwFlags & KEYEVENTF_KEYUP) == 0)
{
return true;
}
}
return false;
});
std::vector<INPUT> keyDown{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A' } },
};
// Send A keydown - injection of B fails, so A passes through and is now DOWN
mockedInputHandler.SendVirtualInput(keyDown);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
std::vector<INPUT> keyUp{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 'A', .dwFlags = KEYEVENTF_KEYUP } },
};
// Send A keyup - even though injecting B's key-up would succeed, the original A
// key-up must pass through so the physical A key is released, not stranded down
mockedInputHandler.SendVirtualInput(keyUp);
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x41));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(0x42));
}
// Test if key is suppressed if a key is disabled by single key remap
TEST_METHOD (RemappedKeyDisabled_ShouldNotChangeKeyState_OnKeyEvent)
{
@@ -428,148 +350,4 @@ namespace RemappingLogicTests
Assert::AreEqual(mockedInputHandler.GetVirtualKeyState(0x56), false);
}
};
// Tests for single key to text remap modifier release logic
TEST_CLASS (SingleKeyToTextRemapModifierTests)
{
private:
KeyboardManagerInput::MockedInput mockedInputHandler;
State testState;
public:
TEST_METHOD_INITIALIZE(InitializeTestEnv)
{
TestHelpers::ResetTestEnv(mockedInputHandler, testState);
// Set HandleSingleKeyToTextRemapEvent as the hook procedure
std::function<intptr_t(LowlevelKeyboardEvent*)> currentHookProc = std::bind(&KeyboardEventHandlers::HandleSingleKeyToTextRemapEvent, std::ref(mockedInputHandler), std::placeholders::_1, std::ref(testState));
mockedInputHandler.SetHookProc(currentHookProc);
}
// A held Win key must be released before the text is injected and then left
// released — never re-pressed — so it can never be left stuck down.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseWinKeyAndNotRestore_WhenWinKeyIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LWin being held down
mockedInputHandler.SetKeyboardState(VK_LWIN, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown — handler releases LWin before the text and does not restore it
mockedInputHandler.SendVirtualInput(inputs);
// LWin must be left released so it can never be stuck down
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LWIN));
}
// A held Ctrl must be released before the text and left released afterwards.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseCtrlAndNotRestore_WhenCtrlIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl being held down
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// LCtrl must be left released so it can never be stuck down
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
}
// Every modifier that was held should be released, and none re-pressed.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldReleaseAllHeldModifiers_AndNotRestore)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl and LShift being held down together
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
mockedInputHandler.SetKeyboardState(VK_LSHIFT, true);
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// Both modifiers must be left released
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LCONTROL));
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LSHIFT));
}
// The handler must never inject a modifier key-down (re-press) event. Doing
// so could leave a modifier stuck down if the user released it during text
// injection, since GetAsyncKeyState cannot distinguish a still-held key from
// one we just released ourselves.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldNeverRePressModifier_WhenModifierIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate LCtrl being held down
mockedInputHandler.SetKeyboardState(VK_LCONTROL, true);
// Count any modifier key-down events the handler injects (i.e. a re-press)
mockedInputHandler.SetSendVirtualInputTestHandler([](LowlevelKeyboardEvent* keyEvent) {
const DWORD vk = keyEvent->lParam->vkCode;
const bool isModifier = (vk == VK_LCONTROL || vk == VK_RCONTROL || vk == VK_LSHIFT || vk == VK_RSHIFT || vk == VK_LMENU || vk == VK_RMENU || vk == VK_LWIN || vk == VK_RWIN);
return isModifier && keyEvent->wParam == WM_KEYDOWN;
});
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown
mockedInputHandler.SendVirtualInput(inputs);
// No modifier re-press should ever be injected
Assert::AreEqual(0, mockedInputHandler.GetSendVirtualInputCallCount());
}
// A key-to-text remap must still fire while Alt is held. Windows delivers a
// key pressed with Alt down as WM_SYSKEYDOWN rather than WM_KEYDOWN, so a
// handler that only accepted WM_KEYDOWN would silently drop the remap. Alt
// being held also drives the modifier-release path, so the proof that the
// WM_SYSKEYDOWN event was accepted and processed is that the held Alt ends
// up released. If WM_SYSKEYDOWN were rejected the handler would return
// before the release loop and Alt would remain down.
TEST_METHOD (HandleSingleKeyToTextRemapEvent_ShouldFireAndReleaseAlt_WhenAltIsHeld)
{
// Remap X to text "hello"
testState.AddSingleKeyToTextRemap(0x58, L"hello");
// Simulate Left Alt being held. VK_MENU makes the mock deliver the key
// as WM_SYSKEYDOWN (as the OS does while Alt is down); VK_LMENU is the
// physical key the handler sees as held and must release.
mockedInputHandler.SetKeyboardState(VK_MENU, true);
mockedInputHandler.SetKeyboardState(VK_LMENU, true);
Assert::AreEqual(true, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
std::vector<INPUT> inputs{
{ .type = INPUT_KEYBOARD, .ki = { .wVk = 0x58 } },
};
// Send X keydown — arrives as WM_SYSKEYDOWN because Alt is held
mockedInputHandler.SendVirtualInput(inputs);
// The remap fired: the held Alt was released and never re-pressed, so it
// can never be left stuck down.
Assert::AreEqual(false, mockedInputHandler.GetVirtualKeyState(VK_LMENU));
}
};
}

View File

@@ -11,12 +11,10 @@ namespace TestHelpers
input.ResetKeyboardState();
input.SetHookProc(nullptr);
input.SetSendVirtualInputTestHandler(nullptr);
input.SetSendVirtualInputShouldFail(nullptr);
input.SetForegroundProcess(L"");
state.ClearSingleKeyRemaps();
state.ClearOSLevelShortcuts();
state.ClearAppSpecificShortcuts();
state.ClearSingleKeyToTextRemaps();
// Allocate memory for the keyboardManagerState activatedApp member to avoid CRT assert errors
std::wstring maxLengthString;

View File

@@ -6,7 +6,6 @@
#include <common/utils/process_path.h>
#include "KeyboardManagerConstants.h"
#include "InputInterface.h"
namespace Helpers
{
@@ -314,7 +313,7 @@ namespace Helpers
// Shift+Enter. Each character is sent individually to avoid a synchronization
// error across key-down and key-up events that causes repeated or dropped characters
// when large batches of KEYEVENTF_UNICODE events are sent at once.
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii)
void SendTextInput(const std::wstring& text)
{
for (size_t i = 0; i < text.size(); ++i)
{
@@ -360,7 +359,7 @@ namespace Helpers
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
ii.SendVirtualInput(std::vector<INPUT>(returnInputs, returnInputs + ARRAYSIZE(returnInputs)));
SendInput(ARRAYSIZE(returnInputs), returnInputs, sizeof(INPUT));
continue;
}
@@ -375,7 +374,7 @@ namespace Helpers
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
charInputs[1].ki.wScan = c;
ii.SendVirtualInput(std::vector<INPUT>(charInputs, charInputs + ARRAYSIZE(charInputs)));
SendInput(ARRAYSIZE(charInputs), charInputs, sizeof(INPUT));
}
}

View File

@@ -4,11 +4,6 @@
class LayoutMap;
namespace KeyboardManagerInput
{
class InputInterface;
}
namespace Helpers
{
// Type to distinguish between keys
@@ -46,7 +41,7 @@ namespace Helpers
// Function to send text input directly, with multiline support.
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
// as separate SendInput calls to avoid mixing event types.
void SendTextInput(const std::wstring& text, KeyboardManagerInput::InputInterface& ii);
void SendTextInput(const std::wstring& text);
// Function to return window handle for a full screen UWP app
HWND GetFullscreenUWPWindowHandle();

View File

@@ -11,41 +11,17 @@ namespace KeyboardManagerInput
class Input : public InputInterface
{
public:
// Function to simulate input. Returns false only when nothing could be injected
// (the call was fully blocked); returns true on full or partial success. A partial
// injection means some remap events already reached the system, so passing the
// original key through on top of them would corrupt the input stream (e.g. leave a
// modifier stuck). In that rare case we suppress the original and log a warning.
bool SendVirtualInput(const std::vector<INPUT>& inputs)
// Function to simulate input
void SendVirtualInput(const std::vector<INPUT>& inputs)
{
if (inputs.empty())
{
return true;
}
std::vector<INPUT> copy = inputs;
UINT eventCount = SendInput(static_cast<UINT>(copy.size()), copy.data(), sizeof(INPUT));
if (eventCount == 0)
if (eventCount != copy.size())
{
// Nothing was injected (e.g. blocked by UIPI). The caller passes the
// original key through so the user is never left with a dead key.
Logger::error(
L"Failed to send input events. {}",
get_last_error_or_default(GetLastError()));
return false;
}
if (eventCount != copy.size())
{
// Partial injection: SendInput stopped after some events. Report success so
// the caller suppresses the original event rather than layering it on top of
// a half-applied remap, which could strand a key or modifier down.
Logger::warn(
L"Partially sent input events ({} of {}). {}",
eventCount,
static_cast<UINT>(copy.size()),
get_last_error_or_default(GetLastError()));
}
return true;
}
// Function to get the state of a particular key

View File

@@ -10,9 +10,8 @@ namespace KeyboardManagerInput
class InputInterface
{
public:
// Function to simulate input. Returns false only when nothing could be injected
// (the call was fully blocked); returns true on full or partial success.
virtual bool SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
// Function to simulate input
virtual void SendVirtualInput(const std::vector<INPUT>& inputs) = 0;
// Function to get the state of a particular key
virtual bool GetVirtualKeyState(int key) = 0;

View File

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

View File

@@ -37,7 +37,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.MeasureTool => "ms-appx:///Assets/Settings/Icons/ScreenRuler.png",
ModuleType.PowerLauncher => "ms-appx:///Assets/Settings/Icons/PowerToysRun.png",
ModuleType.GeneralSettings => "ms-appx:///Assets/Settings/Icons/PowerToys.png",
ModuleType.PowerScripts => "ms-appx:///Assets/Settings/Icons/PowerToys.png",
_ => $"ms-appx:///Assets/Settings/Icons/{moduleType}.png",
};
}
@@ -78,7 +77,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,
};
@@ -120,7 +118,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;
}
}
@@ -165,7 +162,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
ModuleType.Workspaces => WorkspacesSettings.ModuleName,
ModuleType.GrabAndMove => GrabAndMoveSettings.ModuleName,
ModuleType.ZoomIt => ZoomItSettings.ModuleName,
ModuleType.PowerScripts => "PowerScripts", // Prototype: no dedicated settings class
_ => moduleType.ToString(),
};
}

View File

@@ -59,7 +59,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),

View File

@@ -418,7 +418,6 @@ namespace Microsoft.PowerToys.Settings.UI
case "AdvancedPaste": return typeof(AdvancedPastePage);
case "AlwaysOnTop": return typeof(AlwaysOnTopPage);
case "Awake": return typeof(AwakePage);
case "PowerScripts": return typeof(PowerScriptsPage);
case "CmdNotFound": return typeof(CmdNotFoundPage);
case "ColorPicker": return typeof(ColorPickerPage);
case "LightSwitch": return typeof(LightSwitchPage);

View File

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

View File

@@ -1,61 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.ViewModels;
using Microsoft.UI.Xaml;
namespace Microsoft.PowerToys.Settings.UI.Views
{
public sealed partial class PowerScriptsPage : NavigablePage, IRefreshablePage
{
private readonly SettingsUtils _settingsUtils;
private readonly SettingsRepository<GeneralSettings> _generalSettingsRepository;
private PowerScriptsViewModel ViewModel { get; set; }
public PowerScriptsPage()
{
_settingsUtils = SettingsUtils.Default;
_generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils);
ViewModel = new PowerScriptsViewModel(_generalSettingsRepository, ShellPage.SendDefaultIPCMessage);
DataContext = ViewModel;
InitializeComponent();
}
public void RefreshEnabledState()
{
ViewModel.ReloadScripts();
}
private async void BrowseScriptsFolderButton_Click(object sender, RoutedEventArgs e)
{
var folder = await PickSingleFolderDialog();
if (!string.IsNullOrWhiteSpace(folder))
{
ViewModel.SetScriptsFolder(folder);
}
}
private void ResetScriptsFolderButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.ResetScriptsFolder();
}
private async Task<string> PickSingleFolderDialog()
{
// Use the shell32 folder dialog (works even when Settings runs elevated), matching GeneralPage.
var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
return await Task.FromResult(ShellGetFolder.GetFolderDialog(hwnd));
}
}
}

View File

@@ -198,12 +198,6 @@
helpers:NavHelper.NavigateTo="views:AwakePage"
AutomationProperties.AutomationId="AwakeNavItem"
Icon="{ui:BitmapIcon Source=/Assets/Settings/Icons/Awake.png}" />
<NavigationViewItem
x:Name="PowerScriptsNavigationItem"
Content="PowerScripts"
helpers:NavHelper.NavigateTo="views:PowerScriptsPage"
AutomationProperties.AutomationId="PowerScriptsNavItem"
Icon="{ui:FontIcon Glyph=&#xE756;}" />
<NavigationViewItem
x:Name="CmdPalNavigationItem"
x:Uid="Shell_CmdPal"

View File

@@ -137,14 +137,6 @@
<value>Screen Ruler</value>
<comment>"Screen Ruler" is the name of the utility</comment>
</data>
<data name="PowerScripts.ModuleTitle" xml:space="preserve">
<value>PowerScripts</value>
<comment>"PowerScripts" is the name of the utility</comment>
</data>
<data name="PowerScripts.ModuleDescription" xml:space="preserve">
<value>Write a small script once and surface it across PowerToys and the Windows shell.</value>
<comment>Description of the PowerScripts utility</comment>
</data>
<data name="MeasureTool_ActivationSettings.Header" xml:space="preserve">
<value>Activation</value>
</data>

View File

@@ -1,17 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
/// <summary>
/// The declared file-input contract for a script. Mirrors the <c>input</c> object emitted by
/// <c>PowerScripts.Host.exe list --json</c>.
/// </summary>
public sealed class PowerScriptInput
{
public List<string> Extensions { get; set; } = new();
}
}

View File

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

View File

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