Compare commits

...

13 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
24ce23fa12 [PowerScripts] Decouple script id from folder + add trust-on-first-use gate
Two prototype improvements toward a shareable, safe script catalogue:

Catalogue-readiness (id decoupled from folder):
- Script `id` is now the portable identity; the id-must-equal-folder-name
  rule is removed so a shared/downloaded script keeps its id in any folder.
- Registry enforces id uniqueness across the catalogue (duplicate id is
  reported and skipped rather than silently shadowed).
- Manifest gains optional provenance fields: publisher, version, source.

Capability safety (trust-on-first-use):
- New ScriptIntegrity content hash (SHA-256 over entry body + kind +
  declared capabilities) and a persisted TrustStore (trust.json).
- Host `run` now gates every execution (the single choke point for context
  menu, KBM and agents): untrusted scripts prompt a native consent dialog
  showing name/publisher/source/capabilities/path; editing the body or
  escalating capabilities invalidates trust and re-prompts.
- `--no-consent` / POWERSCRIPTS_NO_CONSENT refuses instead of prompting
  (for non-interactive/agent callers); new `trust list|approve|revoke`
  subcommands; `list --json` exposes a `trusted` flag.
- Settings page shows a read-only Trust status row per script.

Tests: id decoupling, duplicate-id rejection, integrity stability/
invalidation, and trust-store round-trip (16/16 Core tests pass).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:53:48 +08:00
Muyuan Li (from Dev Box)
6d4a1dee6e [PowerScripts] Make the new KBM editor's PowerScript action e2e-ready
The new Keyboard Manager editor (KeyboardManagerEditorUI) already exposes a
PowerScript action, but on a Debug build it couldn't locate PowerScripts.Host.exe
(it isn't copied next to the editor), so the picker came up empty.

- PowerScriptsCatalog.ResolveHostPath: add the same dev-bin fallback the Settings
  view-model uses (walk up from the editor's base dir and probe
  src\modules\PowerScripts\PowerScripts.Host\bin\{Debug,Release}). The editor can
  now enumerate system scripts and resolve the host path for the saved RunProgram
  mapping in a dev build.
- Add kbm-e2e.ps1: a self-contained end-to-end helper that forces the new editor
  (useNewEditor=true), opens it to assign a hotkey to a system PowerScript, then
  runs KeyboardManagerEngine standalone so the hotkey actually fires
  Host.exe run <id> — no full runner required.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:38:48 +08:00
Muyuan Li (from Dev Box)
cd327dda07 [PowerScripts] Make Settings page a read-only script catalogue
The manifest.json is the single source of truth for a script's trigger
extensions, surfaces and capabilities. Drop the in-Settings extensions editor
(which only rewrote the manifest via the host) and instead show that
information read-only, so the UI reflects the manifest rather than duplicating
authoring of it.

- PowerScriptListItem: replace the editable ExtensionsText with read-only
  display projections (ExtensionsDisplay/SurfacesDisplay/CapabilitiesDisplay/
  RuntimeDisplay); surface Runtime/Surfaces/Capabilities from list --json.
- PowerScriptsPage.xaml: each script expander now lists Trigger file types
  (file scripts), Runtime, Surfaces and Capabilities as read-only rows.
- Remove SetScriptExtensions / ApplyExtensionsButton_Click. The host
  set-extensions command remains as a CLI/agent capability.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 11:08:27 +08:00
Muyuan Li (from Dev Box)
54bd07c08d [PowerScripts] Add Win11 modern context-menu handler (IExplorerCommand)
Legacy registry verbs only appear under "Show more options" on Windows 11.
This adds a self-contained IExplorerCommand COM server (sparse MSIX package)
that surfaces a top-level "PowerScript" entry with a dynamic submenu of the
file scripts matching the current selection.

- PowerScripts.Host: new `shell-menu --files` command emitting tab-separated
  id/name lines for matching file scripts (no JSON parser needed in native code).
- PowerScriptsContextMenu: WRL ClassicCom DLL (dllmain.cpp, dll.def) with a
  top-level command (GetState runs Host shell-menu, caches matches, hides when
  none), an IEnumExplorerCommand enumerator, and per-script items whose Invoke
  runs `Host run <id> --files <path>`. Host located next to the DLL.
- AppxManifest.xml registers the verb (ItemType Type="*", runtime visibility),
  build.cmd compiles via cl.exe, register.ps1 builds+publishes Host+deploys+
  registers the unsigned package (Add-AppxPackage -Register, Developer Mode).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 10:40:55 +08:00
Muyuan Li (from Dev Box)
35ccc7658d PowerScripts: let users edit a file script's trigger extensions in Settings
Add a per-script "Trigger on file types" editor (a SettingsExpander with
an editable extensions box + Apply) on the PowerScripts page for file
scripts. Applying calls a new host command, set-extensions <id> --ext
<.md .txt ...>, which rewrites the manifest's input.extensions via the
shared serializer, then re-registers the Explorer right-click submenu
(uninstall old verbs first so a changed extension leaves nothing stale).
list --json now surfaces input.extensions so the box shows current values.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 16:03:42 +08:00
Muyuan Li (from Dev Box)
b58b2f1a4c PowerScripts: locate the Host exe from in-repo dev builds
The Settings page lists scripts by shelling out to PowerScripts.Host.exe,
but a dev build never copies the Host next to Settings, so the list was
always empty even when the default scripts folder had scripts. Walk up
from the Settings base directory and probe the Host project's bin output
(Debug/Release) as a fallback, in addition to the existing next-to-exe
and %LOCALAPPDATA% locations.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 14:49:17 +08:00
Muyuan Li (from Dev Box)
0188c1ac69 PowerScripts: let users choose the scripts folder from Settings
Add a "Scripts folder" card to the PowerScripts page with Browse/Reset.
The chosen path is persisted to the shared config.json, and Core's
ResolveScriptsRoot now reads it (explicit > env > config > default) so
every surface (Settings list, Explorer context menu, KBM run) honors the
same folder. Selecting a folder reloads the list and re-registers the
context-menu entries.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:39:08 +08:00
Muyuan Li (from Dev Box)
11bda2709b PowerScripts: add ModuleTitle/Description strings to fix blank Dashboard
The Dashboard builds every module tile via resourceLoader.GetString of
the module's ModuleTitle key, which throws COMException "NamedResource
Not Found" for a missing key and aborts BuildModuleList, blanking the
Home page. Add the PowerScripts.ModuleTitle/ModuleDescription resources.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 11:38:57 +08:00
Muyuan Li (from Dev Box)
a618b2f2f9 PowerScripts: update README with implemented-surface table and e2e demo
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:30:43 +08:00
Muyuan Li (from Dev Box)
3cdbca3fa6 PowerScripts: add Settings module page listing scripts + enable toggle
- Add ModuleType.PowerScripts and Enabled.PowerScripts plumbing (EnabledModules,
  ModuleHelper, ModuleGpoHelper, App.GetPage)
- Add PowerScripts Settings nav item + page (NavigablePage) that lists installed
  scripts via 'PowerScripts.Host.exe list --json' and shows an enable toggle
- Enable toggle wires the Explorer context menu directly (Host shell-install/
  shell-uninstall), so the prototype is functional without a runner module DLL

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:28:58 +08:00
Muyuan Li (from Dev Box)
be711d12bf PowerScripts: add 'PowerScript' action to Keyboard Manager editor
Adds a new 'PowerScript' action type in the KBM editor's mapping control. The
picker lists system PowerScripts (via PowerScripts.Host.exe list --json) and saves
an ordinary RunProgram mapping invoking 'Host.exe run <id>', so a hotkey can launch
a PowerScript. Editor stays decoupled from PowerScripts assemblies by shelling out
to the Host CLI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 16:15:53 +08:00
Muyuan Li (from Dev Box)
29ca6328f9 PowerScripts: add convert_md_to_txt + volume_up samples and context-menu registration
- Add two e2e sample scripts: convert_md_to_txt (file/.md) and volume_up (system)
- Add Host shell-install/shell-uninstall: registry-driven 'PowerScript' cascading
  submenu under SystemFileAssociations\\<ext>\\shell, one sub-verb per matching script
- Switch PowerScripts.Host TFM to net10.0-windows for registry access

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:50:41 +08:00
Muyuan Li (from Dev Box)
af2c3c61cd [PowerScripts] Add prototype module: core, host CLI, samples, tests
Introduces a prototype of the PowerScripts module (write a script once,
surface it across PowerToys). Includes:
- PowerScripts.Core: manifest schema, validation, registry, executor
- PowerScripts.Host: list/run/kbm CLI (shared invocation + KBM RunProgram mapping)
- PowerScripts.Core.Tests: MSTest unit tests (9 passing)
- Two sample scripts (system-snapshot, sha256-checksum) and README

Surfaces prioritized: Explorer right-click + Keyboard Manager. Build is
isolated from the repo (local Directory.Build.props/Packages/nuget.config)
while prototyping; remove to adopt standard PowerToys build rules.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 15:31:41 +08:00
54 changed files with 3806 additions and 0 deletions

View File

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

View File

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

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

View File

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

@@ -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.
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

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

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

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

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

@@ -0,0 +1,97 @@
<#
.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

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

@@ -0,0 +1,21 @@
{
"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

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

@@ -0,0 +1,20 @@
{
"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

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

@@ -0,0 +1,12 @@
{
"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

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

@@ -0,0 +1,12 @@
{
"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

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

View File

@@ -212,6 +212,12 @@
<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">
@@ -398,6 +404,27 @@
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,6 +34,8 @@ 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;
@@ -79,6 +81,7 @@ namespace KeyboardManagerEditorUI.Controls
OpenApp,
MouseClick,
Disable,
PowerScript,
}
/// <summary>
@@ -132,6 +135,7 @@ namespace KeyboardManagerEditorUI.Controls
"OpenApp" => ActionType.OpenApp,
"MouseClick" => ActionType.MouseClick,
"Disable" => ActionType.Disable,
"PowerScript" => ActionType.PowerScript,
_ => ActionType.KeyOrShortcut,
};
}
@@ -151,6 +155,14 @@ 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();
@@ -267,6 +279,18 @@ 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)
@@ -752,6 +776,26 @@ namespace KeyboardManagerEditorUI.Controls
/// </summary>
public string GetUrl() => UrlPathInput?.Text ?? string.Empty;
/// <summary>
/// Gets the selected PowerScript (for the PowerScript action type), or null if none selected.
/// </summary>
public Helpers.PowerScriptInfo? GetSelectedPowerScript() => PowerScriptComboBox?.SelectedItem as Helpers.PowerScriptInfo;
/// <summary>
/// Selects the PowerScript with the given id in the picker, if present.
/// </summary>
public void SelectPowerScript(string id)
{
foreach (var script in _powerScripts)
{
if (string.Equals(script.Id, id, StringComparison.OrdinalIgnoreCase))
{
PowerScriptComboBox.SelectedItem = script;
return;
}
}
}
/// <summary>
/// Gets the program path (for OpenApp action type).
/// </summary>

View File

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

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

View File

@@ -394,6 +394,7 @@ 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,
};
@@ -439,6 +440,10 @@ 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,
};
}
@@ -683,6 +688,47 @@ namespace KeyboardManagerEditorUI.Pages
return saved;
}
private bool SavePowerScriptMapping(List<string> triggerKeys)
{
var script = UnifiedMappingControl.GetSelectedPowerScript();
if (script is null)
{
return false;
}
string hostPath = PowerScriptsCatalog.ResolveHostPath() ?? string.Empty;
if (string.IsNullOrEmpty(hostPath))
{
return false;
}
string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
// A PowerScript hotkey is an ordinary "Run Program" mapping that invokes the shared executor.
var shortcutKeyMapping = new ShortcutKeyMapping
{
OperationType = ShortcutOperationType.RunProgram,
OriginalKeys = originalKeysString,
TargetKeys = originalKeysString,
ProgramPath = hostPath,
ProgramArgs = $"run {script.Id}",
StartInDirectory = string.Empty,
IfRunningAction = ProgramAlreadyRunningAction.StartAnother,
Visibility = StartWindowType.Hidden,
Elevation = ElevationLevel.NonElevated,
TargetApp = string.Empty,
};
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
if (saved)
{
_mappingService.SaveSettings();
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
}
return saved;
}
#endregion
#region Delete Handlers

View File

@@ -218,6 +218,22 @@ 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,6 +37,7 @@ 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",
};
}
@@ -77,6 +78,7 @@ 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,
};
@@ -118,6 +120,7 @@ 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;
}
}
@@ -162,6 +165,7 @@ 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,6 +59,7 @@ 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,6 +418,7 @@ 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

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

@@ -0,0 +1,61 @@
// 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,6 +198,12 @@
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,6 +137,14 @@
<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

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

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

@@ -0,0 +1,310 @@
// 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>();
}
}
}
}