mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 07:59:36 +02:00
Compare commits
12 Commits
dev/crutka
...
powerscrip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d4a1dee6e | ||
|
|
cd327dda07 | ||
|
|
54bd07c08d | ||
|
|
35ccc7658d | ||
|
|
b58b2f1a4c | ||
|
|
0188c1ac69 | ||
|
|
11bda2709b | ||
|
|
a618b2f2f9 | ||
|
|
3cdbca3fa6 | ||
|
|
be711d12bf | ||
|
|
29ca6328f9 | ||
|
|
af2c3c61cd |
@@ -38,6 +38,7 @@ namespace ManagedCommon
|
||||
Workspaces,
|
||||
GrabAndMove,
|
||||
ZoomIt,
|
||||
PowerScripts,
|
||||
GeneralSettings,
|
||||
}
|
||||
}
|
||||
|
||||
17
src/modules/PowerScripts/Directory.Build.props
Normal file
17
src/modules/PowerScripts/Directory.Build.props
Normal 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>
|
||||
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
3
src/modules/PowerScripts/Directory.Build.targets
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Empty: stops MSBuild from walking up to the repo-root targets for the prototype. -->
|
||||
</Project>
|
||||
11
src/modules/PowerScripts/Directory.Packages.props
Normal file
11
src/modules/PowerScripts/Directory.Packages.props
Normal 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>
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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_Flags_IdFolderMismatch()
|
||||
{
|
||||
var manifest = new PowerScriptManifest { Id = "abc", Name = "x", Entry = "run.ps1" };
|
||||
var errors = ManifestValidator.Validate(manifest, folderName: "different");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("must match the folder name")));
|
||||
}
|
||||
|
||||
[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")));
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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" }
|
||||
""");
|
||||
|
||||
// id does not match the folder name -> should be rejected.
|
||||
WriteScript("bad", """
|
||||
{ "id": "mismatch", "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 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// 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.
|
||||
/// </summary>
|
||||
public static class ManifestValidator
|
||||
{
|
||||
public static IReadOnlyList<string> Validate(PowerScriptManifest manifest, string folderName)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
errors.Add("'id' is required.");
|
||||
}
|
||||
else if (!string.Equals(manifest.Id, folderName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add($"'id' ('{manifest.Id}') must match the folder name ('{folderName}').");
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 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; }
|
||||
|
||||
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));
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
108
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
108
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
// 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>
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// A manifest that failed to load or validate, kept so the UI can surface problems.
|
||||
/// </summary>
|
||||
public sealed record ScriptLoadError(string FolderPath, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for installed PowerScripts. Every surface (context menu, Keyboard
|
||||
/// Manager editor, Command Palette, agents) reads from this registry rather than defining scripts
|
||||
/// of its own. The registry only reads the filesystem; it never executes anything.
|
||||
/// </summary>
|
||||
public sealed class ScriptRegistry
|
||||
{
|
||||
private readonly List<PowerScriptManifest> _scripts = new();
|
||||
private readonly List<ScriptLoadError> _errors = new();
|
||||
|
||||
public ScriptRegistry(string? root = null)
|
||||
{
|
||||
Root = PowerScriptsPaths.ResolveScriptsRoot(root);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the scanned scripts root.</summary>
|
||||
public string Root { get; }
|
||||
|
||||
public IReadOnlyList<PowerScriptManifest> Scripts => _scripts;
|
||||
|
||||
public IReadOnlyList<ScriptLoadError> Errors => _errors;
|
||||
|
||||
/// <summary>
|
||||
/// Scans <see cref="Root"/> for <c><id>/manifest.json</c> folders, parses and validates each,
|
||||
/// and rebuilds the in-memory catalogue. Bad scripts are recorded in <see cref="Errors"/> and skipped.
|
||||
/// </summary>
|
||||
public void Load()
|
||||
{
|
||||
_scripts.Clear();
|
||||
_errors.Clear();
|
||||
|
||||
if (!Directory.Exists(Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
_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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
361
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
361
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
// 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;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// The shared PowerScripts executor / catalogue CLI.
|
||||
///
|
||||
/// This is the single invocation entry point every surface points at:
|
||||
/// - Keyboard Manager maps a hotkey to: PowerScripts.Host.exe run <id>
|
||||
/// - The Explorer context menu invokes: PowerScripts.Host.exe run <id> --files <paths>
|
||||
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
|
||||
///
|
||||
/// Usage:
|
||||
/// PowerScripts.Host list [--json] [--root <dir>]
|
||||
/// PowerScripts.Host run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var (positional, options) = ParseArgs(args.Skip(1).ToArray());
|
||||
var root = options.TryGetValue("root", out var r) ? r.FirstOrDefault() : null;
|
||||
|
||||
var registry = new ScriptRegistry(root);
|
||||
registry.Load();
|
||||
|
||||
return args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"list" => RunList(registry, options.ContainsKey("json")),
|
||||
"run" => RunScript(registry, positional, options),
|
||||
"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 projection = registry.Scripts.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Description,
|
||||
kind = s.Kind.ToString(),
|
||||
runtime = s.Runtime.ToString(),
|
||||
s.Surfaces,
|
||||
s.Capabilities,
|
||||
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>();
|
||||
|
||||
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>
|
||||
/// Emits the Keyboard Manager "Run Program" mapping for a system PowerScript so a user (or the
|
||||
/// future KBM editor picker) can bind a hotkey to it. KBM's existing RunProgram action already
|
||||
/// supports this — no KBM engine change is needed. The app path + args go straight into the
|
||||
/// editor's "Run Program" fields; <c>--json</c> emits the on-disk mapping shape (the user still
|
||||
/// chooses the trigger keys, so <c>originalKeys</c> is left as a placeholder).
|
||||
/// </summary>
|
||||
private static int RunKbm(ScriptRegistry registry, IReadOnlyList<string> positional, bool asJson)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("kbm: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"kbm: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var hostPath = Environment.ProcessPath ?? "PowerScripts.Host.exe";
|
||||
var programArgs = $"run {manifest.Id}";
|
||||
|
||||
if (asJson)
|
||||
{
|
||||
// Field names match the KBM engine (see common/KeyboardManagerConstants.h /
|
||||
// MappingConfiguration.cpp). Append this to remapShortcutsToRunProgram and set
|
||||
// originalKeys to your chosen trigger (e.g. "162;91;83" for Ctrl+Win+S).
|
||||
var mapping = new Dictionary<string, object>
|
||||
{
|
||||
["originalKeys"] = "<set-your-trigger-keys>",
|
||||
["operationType"] = 1,
|
||||
["runProgramFilePath"] = hostPath,
|
||||
["runProgramArgs"] = programArgs,
|
||||
["runProgramStartInDir"] = string.Empty,
|
||||
["runProgramElevationLevel"] = 0,
|
||||
["runProgramAlreadyRunningAction"] = 0,
|
||||
["runProgramStartWindowType"] = 0,
|
||||
["unicodeText"] = "*Unsupported*",
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"PowerScript '{manifest.Id}' ({manifest.Name}) — Keyboard Manager 'Run Program' action:");
|
||||
Console.WriteLine($" Program: {hostPath}");
|
||||
Console.WriteLine($" Arguments: {programArgs}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("In Keyboard Manager: Remap a shortcut -> action 'Run Program', paste the values above,");
|
||||
Console.WriteLine("then pick the trigger shortcut. (Use 'kbm <id> --json' for the raw mapping object.)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the file scripts that match a right-clicked selection as tab-separated
|
||||
/// <c><id>\t<name></c> lines (one per script). This is the machine-readable feed the
|
||||
/// Windows 11 modern context-menu handler (IExplorerCommand) consumes to build its submenu; a
|
||||
/// line-based format keeps the native handler free of a JSON parser.
|
||||
/// </summary>
|
||||
private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var script in registry.FileScriptsForSelection(files))
|
||||
{
|
||||
Console.WriteLine($"{script.Id}\t{script.Name}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a file script's declared input extensions in its manifest.json. This is the write
|
||||
/// side of the Settings "trigger on these file types" editor; the user picks the extensions and
|
||||
/// every surface (context menu, selection matching) then reflects them. System scripts have no
|
||||
/// file input, so they are rejected.
|
||||
/// </summary>
|
||||
private static int RunSetExtensions(
|
||||
ScriptRegistry registry,
|
||||
IReadOnlyList<string> positional,
|
||||
IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (manifest.Kind != ScriptKind.File)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: '{manifest.Id}' is a {manifest.Kind} script; extensions only apply to File scripts.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var raw = options.TryGetValue("ext", out var values) ? values : new List<string>();
|
||||
var normalized = raw
|
||||
.SelectMany(v => v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(NormalizeExtension)
|
||||
.Where(e => !string.IsNullOrEmpty(e))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: at least one extension is required (e.g. --ext .md .txt).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
manifest.Input ??= new ScriptInput();
|
||||
manifest.Input.Extensions = normalized;
|
||||
|
||||
var manifestPath = Path.Combine(manifest.FolderPath, PowerScriptsPaths.ManifestFileName);
|
||||
File.WriteAllText(manifestPath, ManifestSerializer.Serialize(manifest));
|
||||
|
||||
Console.WriteLine($"set-extensions: {manifest.Id} -> [{string.Join(", ", normalized)}]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Normalizes a user-typed extension to lower-case with a leading dot ("md" -> ".md").</summary>
|
||||
private static string NormalizeExtension(string raw)
|
||||
{
|
||||
var e = raw.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(e) || e == "*")
|
||||
{
|
||||
return e;
|
||||
}
|
||||
|
||||
return e.StartsWith('.') ? e : "." + e;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal parser. Recognizes <c>--name value [value ...]</c> (multi-value, e.g. --files) and
|
||||
/// <c>--flag</c> (no value, e.g. --json). Everything else is positional.
|
||||
/// </summary>
|
||||
private static (List<string> Positional, Dictionary<string, List<string>> Options) ParseArgs(string[] args)
|
||||
{
|
||||
var positional = new List<string>();
|
||||
var options = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string? current = null;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
current = arg[2..];
|
||||
if (!options.ContainsKey(current))
|
||||
{
|
||||
options[current] = new List<string>();
|
||||
}
|
||||
}
|
||||
else if (current is not null)
|
||||
{
|
||||
options[current].Add(arg);
|
||||
}
|
||||
else
|
||||
{
|
||||
positional.Add(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return (positional, options);
|
||||
}
|
||||
|
||||
private static int Unknown(string command)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command '{command}'.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int PrintUsage()
|
||||
{
|
||||
Console.WriteLine("PowerScripts.Host — run and enumerate PowerScripts.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" list [--json] [--root <dir>]");
|
||||
Console.WriteLine(" run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]");
|
||||
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;
|
||||
}
|
||||
}
|
||||
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
134
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal 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\<ext>\shell\PowerScripts</c> whose nested
|
||||
/// sub-verbs (one per matching script) invoke <c>PowerScripts.Host.exe run <id> --files "%1"</c>.
|
||||
///
|
||||
/// This is the prototype's context-menu surface: it needs no COM DLL and is driven entirely by the
|
||||
/// script registry, so right-click works immediately and reflects the installed scripts. The
|
||||
/// PowerScripts module (runner) calls <c>shell-install</c> on enable and <c>shell-uninstall</c> on
|
||||
/// disable.
|
||||
/// </summary>
|
||||
internal static class ShellRegistration
|
||||
{
|
||||
private const string RootVerb = "PowerScripts";
|
||||
private const string MenuLabel = "PowerScript";
|
||||
private const string ClassesRoot = @"Software\Classes\SystemFileAssociations";
|
||||
|
||||
/// <summary>Marker value so uninstall only removes keys this tool created.</summary>
|
||||
private const string OwnerMarkerName = "PowerScriptsOwned";
|
||||
|
||||
public static int Install(ScriptRegistry registry, string hostExePath)
|
||||
{
|
||||
// Group file scripts by each declared extension (skip the "*" wildcard for the static menu).
|
||||
var byExtension = new Dictionary<string, List<PowerScriptManifest>>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var script in registry.Scripts.Where(s => s.Kind == ScriptKind.File && s.Input is not null))
|
||||
{
|
||||
foreach (var rawExt in script.Input!.Extensions)
|
||||
{
|
||||
if (rawExt == "*")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var ext = rawExt.StartsWith('.') ? rawExt : "." + rawExt;
|
||||
if (!byExtension.TryGetValue(ext, out var list))
|
||||
{
|
||||
list = new List<PowerScriptManifest>();
|
||||
byExtension[ext] = list;
|
||||
}
|
||||
|
||||
list.Add(script);
|
||||
}
|
||||
}
|
||||
|
||||
if (byExtension.Count == 0)
|
||||
{
|
||||
Console.WriteLine("shell-install: no file scripts with concrete extensions to register.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var (ext, scripts) in byExtension)
|
||||
{
|
||||
RemoveVerbForExtension(ext);
|
||||
|
||||
var verbPath = $@"{ClassesRoot}\{ext}\shell\{RootVerb}";
|
||||
using var verbKey = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(verbPath)!;
|
||||
verbKey.SetValue("MUIVerb", MenuLabel);
|
||||
verbKey.SetValue(OwnerMarkerName, 1, RegistryValueKind.DWord);
|
||||
|
||||
// Presence of "SubCommands" makes Explorer render the nested \shell verbs as a submenu.
|
||||
verbKey.SetValue("SubCommands", string.Empty);
|
||||
|
||||
using var subShell = verbKey.CreateSubKey("shell")!;
|
||||
foreach (var script in scripts)
|
||||
{
|
||||
using var item = subShell.CreateSubKey(script.Id)!;
|
||||
item.SetValue("MUIVerb", script.Name);
|
||||
using var command = item.CreateSubKey("command")!;
|
||||
command.SetValue(null, $"\"{hostExePath}\" run {script.Id} --files \"%1\"");
|
||||
}
|
||||
|
||||
Console.WriteLine($" registered {scripts.Count} script(s) for {ext}");
|
||||
}
|
||||
|
||||
Console.WriteLine($"shell-install: done ({byExtension.Count} extension(s)).");
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static int Uninstall(ScriptRegistry registry)
|
||||
{
|
||||
// Remove for every extension currently declared, plus best-effort sweep is unnecessary since
|
||||
// we only ever create owned keys.
|
||||
var extensions = registry.Scripts
|
||||
.Where(s => s.Kind == ScriptKind.File && s.Input is not null)
|
||||
.SelectMany(s => s.Input!.Extensions)
|
||||
.Where(e => e != "*")
|
||||
.Select(e => e.StartsWith('.') ? e : "." + e)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var ext in extensions)
|
||||
{
|
||||
RemoveVerbForExtension(ext);
|
||||
}
|
||||
|
||||
Console.WriteLine("shell-uninstall: done.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static void RemoveVerbForExtension(string ext)
|
||||
{
|
||||
var verbParent = $@"{ClassesRoot}\{ext}\shell";
|
||||
using var shellKey = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(verbParent, writable: true);
|
||||
if (shellKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Only delete the verb if we own it.
|
||||
using (var verbKey = shellKey.OpenSubKey(RootVerb))
|
||||
{
|
||||
if (verbKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (verbKey.GetValue(OwnerMarkerName) is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
shellKey.DeleteSubKeyTree(RootVerb, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
9
src/modules/PowerScripts/PowerScriptsContextMenu/.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# Native handler build artifacts
|
||||
*.dll
|
||||
*.lib
|
||||
*.exp
|
||||
*.obj
|
||||
*.pdb
|
||||
*.ilk
|
||||
# Host publish output used by register.ps1
|
||||
hostpublish/
|
||||
@@ -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>
|
||||
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal file
15
src/modules/PowerScripts/PowerScriptsContextMenu/build.cmd
Normal 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
|
||||
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
4
src/modules/PowerScripts/PowerScriptsContextMenu/dll.def
Normal file
@@ -0,0 +1,4 @@
|
||||
EXPORTS
|
||||
DllCanUnloadNow PRIVATE
|
||||
DllGetClassObject PRIVATE
|
||||
DllGetActivationFactory PRIVATE
|
||||
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal file
388
src/modules/PowerScripts/PowerScriptsContextMenu/dllmain.cpp
Normal 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;
|
||||
}
|
||||
@@ -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).'
|
||||
165
src/modules/PowerScripts/README.md
Normal file
165
src/modules/PowerScripts/README.md
Normal 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.
|
||||
97
src/modules/PowerScripts/kbm-e2e.ps1
Normal file
97
src/modules/PowerScripts/kbm-e2e.ps1
Normal 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.'
|
||||
}
|
||||
11
src/modules/PowerScripts/nuget.config
Normal file
11
src/modules/PowerScripts/nuget.config
Normal 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>
|
||||
@@ -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"
|
||||
}
|
||||
35
src/modules/PowerScripts/samples/convert_md_to_txt/run.ps1
Normal file
35
src/modules/PowerScripts/samples/convert_md_to_txt/run.ps1
Normal 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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
30
src/modules/PowerScripts/samples/sha256-checksum/run.ps1
Normal file
30
src/modules/PowerScripts/samples/sha256-checksum/run.ps1
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
12
src/modules/PowerScripts/samples/system-snapshot/run.ps1
Normal file
12
src/modules/PowerScripts/samples/system-snapshot/run.ps1
Normal 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
|
||||
12
src/modules/PowerScripts/samples/volume_up/manifest.json
Normal file
12
src/modules/PowerScripts/samples/volume_up/manifest.json
Normal 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"
|
||||
}
|
||||
11
src/modules/PowerScripts/samples/volume_up/run.ps1
Normal file
11
src/modules/PowerScripts/samples/volume_up/run.ps1
Normal 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.'
|
||||
@@ -212,6 +212,12 @@
|
||||
<TextBlock x:Uid="ActionType_Disable_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem Tag="PowerScript">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock Text="PowerScript" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<!--
|
||||
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 <id></c>.
|
||||
/// </summary>
|
||||
public static class PowerScriptsCatalog
|
||||
{
|
||||
private const string HostExeName = "PowerScripts.Host.exe";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the full path to <c>PowerScripts.Host.exe</c>, or null if it can't be found.
|
||||
/// Search order: explicit override env var, next to the editor, then the default install root.
|
||||
/// </summary>
|
||||
public static string? ResolveHostPath()
|
||||
{
|
||||
var overridePath = Environment.GetEnvironmentVariable("POWERSCRIPTS_HOST");
|
||||
if (!string.IsNullOrWhiteSpace(overridePath) && File.Exists(overridePath))
|
||||
{
|
||||
return overridePath;
|
||||
}
|
||||
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, HostExeName),
|
||||
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"PowerScripts",
|
||||
HostExeName),
|
||||
};
|
||||
|
||||
// Prototype dev fallback: in an in-repo build the Host isn't copied next to the editor,
|
||||
// so walk up from the base directory and probe the Host project's bin output. This keeps
|
||||
// the PowerScript action usable for end-to-end testing from a Debug build.
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
foreach (var config in new[] { "Debug", "Release" })
|
||||
{
|
||||
var hostBin = Path.Combine(
|
||||
dir.FullName,
|
||||
"src",
|
||||
"modules",
|
||||
"PowerScripts",
|
||||
"PowerScripts.Host",
|
||||
"bin",
|
||||
config);
|
||||
|
||||
if (Directory.Exists(hostBin))
|
||||
{
|
||||
var found = Directory
|
||||
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(found))
|
||||
{
|
||||
candidates.Add(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the list of system PowerScripts available for hotkey assignment, or an empty list
|
||||
/// when PowerScripts isn't installed or no system scripts exist.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PowerScriptInfo> GetSystemScripts()
|
||||
{
|
||||
var hostPath = ResolveHostPath();
|
||||
if (hostPath is null)
|
||||
{
|
||||
return Array.Empty<PowerScriptInfo>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = "list --json",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
return Array.Empty<PowerScriptInfo>();
|
||||
}
|
||||
|
||||
string json = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
var all = JsonSerializer.Deserialize<List<PowerScriptInfo>>(json, JsonOptions) ?? new List<PowerScriptInfo>();
|
||||
|
||||
var systemScripts = new List<PowerScriptInfo>();
|
||||
foreach (var script in all)
|
||||
{
|
||||
if (string.Equals(script.Kind, "system", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
systemScripts.Add(script);
|
||||
}
|
||||
}
|
||||
|
||||
return systemScripts;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Prototype: a missing/failed PowerScripts host simply yields no scripts to pick.
|
||||
return Array.Empty<PowerScriptInfo>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -394,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
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
<local:NavigablePage
|
||||
x:Class="Microsoft.PowerToys.Settings.UI.Views.PowerScriptsPage"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:Microsoft.PowerToys.Settings.UI.Helpers"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
xmlns:viewModels="using:Microsoft.PowerToys.Settings.UI.ViewModels"
|
||||
d:DataContext="{d:DesignInstance Type=viewModels:PowerScriptsViewModel}"
|
||||
AutomationProperties.LandmarkType="Main"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<controls:SettingsPageControl
|
||||
x:Name="PowerScriptsSettingsPage"
|
||||
ModuleDescription="Write a small script once and surface it everywhere — the Explorer right-click menu and Keyboard Manager. This is an experimental prototype."
|
||||
ModuleTitle="PowerScripts"
|
||||
IsTabStop="False">
|
||||
<controls:SettingsPageControl.ModuleContent>
|
||||
<StackPanel ChildrenTransitions="{StaticResource SettingsCardsAnimations}" Orientation="Vertical">
|
||||
<tkcontrols:SettingsCard
|
||||
Name="PowerScriptsEnableSettingsCard"
|
||||
Description="Enable PowerScripts"
|
||||
Header="PowerScripts"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.IsEnabled, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard
|
||||
Name="PowerScriptsFolderSettingsCard"
|
||||
Description="{x:Bind ViewModel.ScriptsFolder, Mode=OneWay}"
|
||||
Header="Scripts folder"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="BrowseScriptsFolderButton"
|
||||
Click="BrowseScriptsFolderButton_Click"
|
||||
Content="Browse..." />
|
||||
<Button
|
||||
x:Name="ResetScriptsFolderButton"
|
||||
Click="ResetScriptsFolderButton_Click"
|
||||
Content="Reset"
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomFolder, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<controls:SettingsGroup Header="Installed scripts" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<InfoBar
|
||||
IsClosable="False"
|
||||
IsOpen="{x:Bind ViewModel.HasScripts, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
|
||||
Message="No PowerScripts found. Pick a scripts folder above (or use the default) that contains script subfolders, each with a manifest.json."
|
||||
Severity="Informational" />
|
||||
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.Scripts, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="viewModels:PowerScriptListItem">
|
||||
<tkcontrols:SettingsExpander
|
||||
Margin="0,2,0,2"
|
||||
Description="{x:Bind Description}"
|
||||
Header="{x:Bind Name}"
|
||||
IsExpanded="False">
|
||||
<tkcontrols:SettingsExpander.HeaderIcon>
|
||||
<FontIcon Glyph="{x:Bind KindGlyph}" />
|
||||
</tkcontrols:SettingsExpander.HeaderIcon>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind Kind}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard
|
||||
ContentAlignment="Right"
|
||||
Header="Trigger on file types"
|
||||
Visibility="{x:Bind IsFileScript, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind ExtensionsDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Runtime">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind RuntimeDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Surfaces">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind SurfacesDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Capabilities">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind CapabilitiesDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols: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>
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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=}" />
|
||||
<NavigationViewItem
|
||||
x:Name="CmdPalNavigationItem"
|
||||
x:Uid="Shell_CmdPal"
|
||||
|
||||
@@ -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>
|
||||
|
||||
17
src/settings-ui/Settings.UI/ViewModels/PowerScriptInput.cs
Normal file
17
src/settings-ui/Settings.UI/ViewModels/PowerScriptInput.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// 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();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
310
src/settings-ui/Settings.UI/ViewModels/PowerScriptsViewModel.cs
Normal file
310
src/settings-ui/Settings.UI/ViewModels/PowerScriptsViewModel.cs
Normal 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><id>\manifest.json</c> script folders. Persisted to
|
||||
/// the shared <c>config.json</c> so every surface (Settings, the Explorer context menu, and the
|
||||
/// Keyboard Manager mapping) resolves the same folder.
|
||||
/// </summary>
|
||||
public string ScriptsFolder
|
||||
{
|
||||
get => _scriptsFolder;
|
||||
private set
|
||||
{
|
||||
if (_scriptsFolder != value)
|
||||
{
|
||||
_scriptsFolder = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsCustomFolder));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsCustomFolder =>
|
||||
!string.Equals(ScriptsFolder, DefaultScriptsFolder, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set
|
||||
{
|
||||
if (_isEnabled != value)
|
||||
{
|
||||
_isEnabled = value;
|
||||
|
||||
GeneralSettings generalSettings = _generalSettingsRepository.SettingsConfig;
|
||||
generalSettings.Enabled.PowerScripts = value;
|
||||
|
||||
if (_sendConfigMsg != null)
|
||||
{
|
||||
var outgoing = new OutGoingGeneralSettings(generalSettings);
|
||||
_sendConfigMsg(outgoing.ToString());
|
||||
}
|
||||
|
||||
// Prototype: wire the Explorer right-click submenu directly from Settings, so
|
||||
// enabling/disabling PowerScripts installs/removes the context-menu entries even
|
||||
// without a dedicated runner module.
|
||||
RunHostShellCommand(value ? "shell-install" : "shell-uninstall");
|
||||
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void ReloadScripts()
|
||||
{
|
||||
Scripts.Clear();
|
||||
foreach (var script in LoadScriptsFromHost())
|
||||
{
|
||||
Scripts.Add(script);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasScripts));
|
||||
}
|
||||
|
||||
/// <summary>Persists a user-chosen scripts folder and refreshes every surface that reads it.</summary>
|
||||
public void SetScriptsFolder(string folder)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folder))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
SaveConfiguredScriptsRoot(folder.Trim());
|
||||
ScriptsFolder = ResolveScriptsFolder();
|
||||
ReloadScripts();
|
||||
|
||||
// Re-register the Explorer submenu so right-click entries reflect the new folder's scripts.
|
||||
if (_isEnabled)
|
||||
{
|
||||
RunHostShellCommand("shell-install");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the override so the default folder under %LOCALAPPDATA% is used again.</summary>
|
||||
public void ResetScriptsFolder()
|
||||
{
|
||||
SaveConfiguredScriptsRoot(null);
|
||||
ScriptsFolder = ResolveScriptsFolder();
|
||||
ReloadScripts();
|
||||
|
||||
if (_isEnabled)
|
||||
{
|
||||
RunHostShellCommand("shell-install");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ModuleDirectory => Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"PowerScripts");
|
||||
|
||||
private static string ConfigFilePath => Path.Combine(ModuleDirectory, "config.json");
|
||||
|
||||
private static string DefaultScriptsFolder => Path.Combine(ModuleDirectory, "scripts");
|
||||
|
||||
private static string ResolveScriptsFolder()
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("POWERSCRIPTS_ROOT");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
{
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigFilePath))
|
||||
{
|
||||
using var stream = File.OpenRead(ConfigFilePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
|
||||
value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var root = value.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(root))
|
||||
{
|
||||
return root;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt or unreadable config falls back to the default.
|
||||
}
|
||||
|
||||
return DefaultScriptsFolder;
|
||||
}
|
||||
|
||||
private static void SaveConfiguredScriptsRoot(string folder)
|
||||
{
|
||||
Directory.CreateDirectory(ModuleDirectory);
|
||||
var normalized = string.IsNullOrWhiteSpace(folder) ? string.Empty : folder.Trim();
|
||||
var json = JsonSerializer.Serialize(new { scriptsRoot = normalized }, WriteJsonOptions);
|
||||
File.WriteAllText(ConfigFilePath, json);
|
||||
}
|
||||
|
||||
private static string ResolveHostPath()
|
||||
{
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, HostExeName),
|
||||
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
|
||||
Path.Combine(ModuleDirectory, HostExeName),
|
||||
};
|
||||
|
||||
// Prototype dev fallback: when running an in-repo build, the Host isn't copied next to
|
||||
// Settings, so walk up from the base directory and probe the Host project's bin output.
|
||||
var dir = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (dir is not null)
|
||||
{
|
||||
foreach (var config in new[] { "Debug", "Release" })
|
||||
{
|
||||
var hostBin = Path.Combine(
|
||||
dir.FullName,
|
||||
"src",
|
||||
"modules",
|
||||
"PowerScripts",
|
||||
"PowerScripts.Host",
|
||||
"bin",
|
||||
config);
|
||||
|
||||
if (Directory.Exists(hostBin))
|
||||
{
|
||||
var found = Directory
|
||||
.EnumerateFiles(hostBin, HostExeName, SearchOption.AllDirectories)
|
||||
.FirstOrDefault();
|
||||
if (!string.IsNullOrEmpty(found))
|
||||
{
|
||||
candidates.Add(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dir = dir.Parent;
|
||||
}
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (File.Exists(candidate))
|
||||
{
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static void RunHostShellCommand(string command)
|
||||
{
|
||||
string hostPath = ResolveHostPath();
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = command,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
process?.WaitForExit(5000);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Prototype: best-effort context-menu (un)registration.
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PowerScriptListItem> LoadScriptsFromHost()
|
||||
{
|
||||
string hostPath = ResolveHostPath();
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return Array.Empty<PowerScriptListItem>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = "list --json",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
return Array.Empty<PowerScriptListItem>();
|
||||
}
|
||||
|
||||
string json = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
return JsonSerializer.Deserialize<List<PowerScriptListItem>>(json, JsonOptions)
|
||||
?? new List<PowerScriptListItem>();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Prototype: a missing/failed host simply yields an empty list.
|
||||
return Array.Empty<PowerScriptListItem>();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user