mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-07 11:00:32 +02:00
Compare commits
20 Commits
niels9001/
...
powerscrip
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe13577c8d | ||
|
|
aa9b94d0cf | ||
|
|
85228635fc | ||
|
|
12769727ca | ||
|
|
944ae41cf7 | ||
|
|
97ba9b8ec5 | ||
|
|
1cbc5d6fdb | ||
|
|
24ce23fa12 | ||
|
|
6d4a1dee6e | ||
|
|
cd327dda07 | ||
|
|
54bd07c08d | ||
|
|
35ccc7658d | ||
|
|
b58b2f1a4c | ||
|
|
0188c1ac69 | ||
|
|
11bda2709b | ||
|
|
a618b2f2f9 | ||
|
|
3cdbca3fa6 | ||
|
|
be711d12bf | ||
|
|
29ca6328f9 | ||
|
|
af2c3c61cd |
@@ -38,6 +38,7 @@ namespace ManagedCommon
|
||||
Workspaces,
|
||||
GrabAndMove,
|
||||
ZoomIt,
|
||||
PowerScripts,
|
||||
GeneralSettings,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,15 @@ public sealed class PasteFormat
|
||||
IsEnabled = SupportsClipboardFormats(clipboardFormats) && (isAIServiceEnabled || !Metadata.RequiresAIService);
|
||||
}
|
||||
|
||||
// Per-instance constructor for PowerScripts, whose supported clipboard formats vary per script
|
||||
// (derived from the script's own transform contract) rather than from the static enum metadata.
|
||||
private PasteFormat(PasteFormats format, ClipboardFormat scriptSupportedFormats, ClipboardFormat availableClipboardFormats, bool perScript)
|
||||
{
|
||||
_ = perScript;
|
||||
Format = format;
|
||||
IsEnabled = (availableClipboardFormats & scriptSupportedFormats) != ClipboardFormat.None;
|
||||
}
|
||||
|
||||
public static PasteFormat CreateStandardFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func<string, string> resourceLoader) =>
|
||||
new(format, clipboardFormats, isAIServiceEnabled)
|
||||
{
|
||||
@@ -40,6 +49,19 @@ public sealed class PasteFormat
|
||||
IsSavedQuery = isSavedQuery,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Creates a paste format backed by a user-authored PowerScript. The script's own transform
|
||||
/// contract determines which clipboard formats it accepts, so enablement is computed per instance.
|
||||
/// </summary>
|
||||
public static PasteFormat CreatePowerScriptFormat(string scriptId, string name, ClipboardFormat scriptSupportedFormats, ClipboardFormat availableClipboardFormats) =>
|
||||
new(PasteFormats.PowerScript, scriptSupportedFormats, availableClipboardFormats, perScript: true)
|
||||
{
|
||||
Name = name,
|
||||
Prompt = string.Empty,
|
||||
IsSavedQuery = false,
|
||||
ScriptId = scriptId,
|
||||
};
|
||||
|
||||
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
|
||||
|
||||
public string IconGlyph => Metadata.IconGlyph;
|
||||
@@ -52,6 +74,9 @@ public sealed class PasteFormat
|
||||
|
||||
public bool IsSavedQuery { get; private init; }
|
||||
|
||||
/// <summary>The PowerScript id backing this format, when <see cref="Format"/> is <see cref="PasteFormats.PowerScript"/>.</summary>
|
||||
public string ScriptId { get; private init; }
|
||||
|
||||
public bool IsEnabled { get; private init; }
|
||||
|
||||
public string AccessibleName => $"{Name} ({ShortcutText})";
|
||||
|
||||
@@ -122,4 +122,12 @@ public enum PasteFormats
|
||||
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
|
||||
RequiresPrompt = true)]
|
||||
CustomTextTransformation,
|
||||
|
||||
[PasteFormatMetadata(
|
||||
IsCoreAction = false,
|
||||
IconGlyph = "\uE756",
|
||||
RequiresAIService = false,
|
||||
CanPreview = false,
|
||||
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.Image)]
|
||||
PowerScript,
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
|
||||
{
|
||||
PasteFormats.KernelQuery => await _kernelService.TransformClipboardAsync(pasteFormat.Prompt, clipboardData, pasteFormat.IsSavedQuery, cancellationToken, progress),
|
||||
PasteFormats.CustomTextTransformation => DataPackageHelpers.CreateFromText((await _customActionTransformService.TransformAsync(pasteFormat.Prompt, await clipboardData.GetTextOrHtmlTextAsync(), await clipboardData.GetImageAsPngBytesAsync(), cancellationToken, progress))?.Content ?? string.Empty),
|
||||
PasteFormats.PowerScript => DataPackageHelpers.CreateFromText(await PowerScriptsService.TransformTextAsync(pasteFormat.ScriptId, await clipboardData.GetTextOrHtmlTextAsync(), cancellationToken)),
|
||||
_ => await TransformHelpers.TransformAsync(format, clipboardData, cancellationToken, progress),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
// 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;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
using AdvancedPaste.Models;
|
||||
|
||||
namespace AdvancedPaste.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges Advanced Paste to PowerScripts. PowerScripts are user-authored scripts (PowerShell or
|
||||
/// Python) that declare an <c>advancedPaste</c> surface and a transform contract
|
||||
/// (<c>powerscript_from_<input>_to_<output></c>). This service enumerates those scripts and runs
|
||||
/// one as a clipboard transform by shelling the shared <c>PowerScripts.Host.exe</c>, so every surface
|
||||
/// (a hotkey, the Explorer context menu, Advanced Paste) executes them through the same gated host.
|
||||
/// </summary>
|
||||
internal static class PowerScriptsService
|
||||
{
|
||||
private const string HostExeName = "PowerScripts.Host.exe";
|
||||
private const string AdvancedPasteSurface = "advancedPaste";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
/// <summary>A PowerScript that opts into the Advanced Paste surface.</summary>
|
||||
public sealed record PowerScriptInfo(string Id, string Name, string Description, ClipboardFormat SupportedFormats);
|
||||
|
||||
/// <summary>
|
||||
/// Enumerates PowerScripts that declare the <c>advancedPaste</c> surface and expose a transform
|
||||
/// contract. Returns an empty list when PowerScripts is disabled or the host is unavailable, so
|
||||
/// disabling PowerScripts makes its scripts disappear from Advanced Paste.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PowerScriptInfo> GetAdvancedPasteScripts()
|
||||
{
|
||||
if (!IsPowerScriptsEnabled())
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
string hostPath = ResolveHostPath();
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = "list --json",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
string json = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
var scripts = JsonSerializer.Deserialize<List<HostScript>>(json, JsonOptions) ?? [];
|
||||
|
||||
return scripts
|
||||
.Where(s => s.Surfaces is not null && s.Surfaces.Contains(AdvancedPasteSurface, StringComparer.OrdinalIgnoreCase))
|
||||
.Where(s => s.Transform is not null)
|
||||
.Select(s => new PowerScriptInfo(s.Id, s.Name, s.Description ?? string.Empty, MapInputFormat(s.Transform.InputFormat)))
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A missing/failed host simply yields no Advanced Paste PowerScripts.
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the given PowerScript as a text transform: sends <c>{"text": ...}</c> on stdin and returns
|
||||
/// the <c>text</c> field of the host's JSON result.
|
||||
/// </summary>
|
||||
public static async Task<string> TransformTextAsync(string scriptId, string inputText, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(scriptId))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
string hostPath = ResolveHostPath();
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = $"transform {scriptId}",
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi) ?? throw new InvalidOperationException("Failed to start PowerScripts host.");
|
||||
|
||||
var payload = JsonSerializer.Serialize(new TransformPayload { Text = inputText ?? string.Empty });
|
||||
await process.StandardInput.WriteAsync(payload);
|
||||
process.StandardInput.Close();
|
||||
|
||||
string output = await process.StandardOutput.ReadToEndAsync(cancellationToken);
|
||||
await process.WaitForExitAsync(cancellationToken);
|
||||
|
||||
if (process.ExitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var result = JsonSerializer.Deserialize<TransformPayload>(output, JsonOptions);
|
||||
return result?.Text ?? string.Empty;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClipboardFormat MapInputFormat(string inputFormat) =>
|
||||
inputFormat?.ToLowerInvariant() switch
|
||||
{
|
||||
"html" => ClipboardFormat.Html,
|
||||
"image" => ClipboardFormat.Image,
|
||||
"audio" => ClipboardFormat.Audio,
|
||||
"video" => ClipboardFormat.Video,
|
||||
"files" or "file" => ClipboardFormat.File,
|
||||
|
||||
// "text" and "none" (no clipboard input needed) both surface for text on the clipboard.
|
||||
_ => ClipboardFormat.Text,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Whether PowerScripts is enabled. The module-owned <c>config.json</c> <c>enabled</c> flag is
|
||||
/// authoritative when present (it survives the runner rewriting <c>settings.json</c>); otherwise
|
||||
/// the PowerToys <c>settings.json</c> <c>enabled.PowerScripts</c> flag is consulted. A missing
|
||||
/// settings file (dev/test) or an absent key is treated as enabled, matching the host's own gate,
|
||||
/// so a disabled module never surfaces scripts while an ambiguous state keeps them working.
|
||||
/// </summary>
|
||||
private static bool IsPowerScriptsEnabled()
|
||||
{
|
||||
if (Environment.GetEnvironmentVariable("POWERSCRIPTS_IGNORE_ENABLED") == "1")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var configOverride = ReadConfigEnabledOverride();
|
||||
if (configOverride.HasValue)
|
||||
{
|
||||
return configOverride.Value;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string settingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"settings.json");
|
||||
|
||||
if (!File.Exists(settingsPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(settingsPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("enabled", out var enabled) &&
|
||||
enabled.TryGetProperty("PowerScripts", out var value) &&
|
||||
value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return value.GetBoolean();
|
||||
}
|
||||
|
||||
// Absent key: the runner strips unknown modules, so treat as enabled (matches the host).
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt/unreadable settings file falls back to allowing the module (matches host behavior).
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads the module-owned <c>enabled</c> override from PowerScripts' <c>config.json</c>, or null when absent.</summary>
|
||||
private static bool? ReadConfigEnabledOverride()
|
||||
{
|
||||
try
|
||||
{
|
||||
string configPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"PowerScripts",
|
||||
"config.json");
|
||||
|
||||
if (!File.Exists(configPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(configPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("enabled", out var value) &&
|
||||
value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return value.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt/unreadable config yields no override.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ResolveHostPath()
|
||||
{
|
||||
string moduleDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"PowerScripts");
|
||||
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, HostExeName),
|
||||
Path.Combine(AppContext.BaseDirectory, "PowerScripts", HostExeName),
|
||||
Path.Combine(moduleDir, HostExeName),
|
||||
};
|
||||
|
||||
// Prototype dev fallback: when running an in-repo build the host isn't copied next to Advanced
|
||||
// Paste, 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;
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists) ?? string.Empty;
|
||||
}
|
||||
|
||||
private sealed class HostScript
|
||||
{
|
||||
public string Id { get; set; }
|
||||
|
||||
public string Name { get; set; }
|
||||
|
||||
public string Description { get; set; }
|
||||
|
||||
public List<string> Surfaces { get; set; }
|
||||
|
||||
public TransformContract Transform { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TransformContract
|
||||
{
|
||||
public string Function { get; set; }
|
||||
|
||||
public string InputFormat { get; set; }
|
||||
|
||||
public string OutputFormat { get; set; }
|
||||
}
|
||||
|
||||
private sealed class TransformPayload
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string Text { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -350,6 +350,13 @@ namespace AdvancedPaste.ViewModels
|
||||
private PasteFormat CreateCustomAIPasteFormat(string name, string prompt, bool isSavedQuery) =>
|
||||
PasteFormat.CreateCustomAIFormat(CustomAIFormat, name, prompt, isSavedQuery, AvailableClipboardFormats, IsCustomAIServiceEnabled);
|
||||
|
||||
// Enumerates user-authored PowerScripts that opt into the Advanced Paste surface, so they appear
|
||||
// alongside the built-in paste actions. Each script's own transform contract decides which
|
||||
// clipboard formats enable it, and disabling PowerScripts hides them all.
|
||||
private IEnumerable<PasteFormat> CreatePowerScriptPasteFormats() =>
|
||||
PowerScriptsService.GetAdvancedPasteScripts()
|
||||
.Select(script => PasteFormat.CreatePowerScriptFormat(script.Id, script.Name, script.SupportedFormats, AvailableClipboardFormats));
|
||||
|
||||
private void UpdateAIProviderActiveFlags()
|
||||
{
|
||||
var providers = _userSettings?.PasteAIConfiguration?.Providers;
|
||||
@@ -417,7 +424,8 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
||||
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
|
||||
.Select(CreateStandardPasteFormat));
|
||||
.Select(CreateStandardPasteFormat)
|
||||
.Concat(CreatePowerScriptPasteFormats()));
|
||||
|
||||
UpdateFormats(
|
||||
CustomActionPasteFormats,
|
||||
|
||||
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,87 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ManifestTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Serializer_RoundTrips_WithCamelCaseEnums()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "demo",
|
||||
Name = "Demo",
|
||||
Kind = ScriptKind.File,
|
||||
Runtime = ScriptRuntime.PowerShell,
|
||||
Entry = "run.ps1",
|
||||
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 1, MaxFiles = 0 },
|
||||
Output = new ScriptOutput { Type = ScriptOutputType.SideEffect },
|
||||
Surfaces = { "contextMenu" },
|
||||
};
|
||||
|
||||
var json = ManifestSerializer.Serialize(manifest);
|
||||
StringAssert.Contains(json, "\"kind\": \"file\"");
|
||||
StringAssert.Contains(json, "\"runtime\": \"powerShell\"");
|
||||
|
||||
var back = ManifestSerializer.Deserialize(json);
|
||||
Assert.IsNotNull(back);
|
||||
Assert.AreEqual(ScriptKind.File, back!.Kind);
|
||||
Assert.AreEqual(ScriptOutputType.SideEffect, back.Output!.Type);
|
||||
Assert.AreEqual(".png", back.Input!.Extensions[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Allows_IdFolderMismatch()
|
||||
{
|
||||
// A script's id is portable and intentionally decoupled from its folder name, so a mismatch
|
||||
// is no longer an error (a downloaded/shared script keeps its id in any folder).
|
||||
var manifest = new PowerScriptManifest { Id = "abc", Name = "x", Entry = "run.ps1" };
|
||||
var errors = ManifestValidator.Validate(manifest, folderName: "different");
|
||||
Assert.AreEqual(0, errors.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_MissingId()
|
||||
{
|
||||
var manifest = new PowerScriptManifest { Id = string.Empty, Name = "x", Entry = "run.ps1" };
|
||||
var errors = ManifestValidator.Validate(manifest, folderName: "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("'id' is required")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_FileKind_WithoutExtensions()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "abc",
|
||||
Name = "x",
|
||||
Entry = "run.ps1",
|
||||
Kind = ScriptKind.File,
|
||||
};
|
||||
|
||||
var errors = ManifestValidator.Validate(manifest, "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("input.extensions")));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Validator_Flags_MaxFiles_LessThanMin()
|
||||
{
|
||||
var manifest = new PowerScriptManifest
|
||||
{
|
||||
Id = "abc",
|
||||
Name = "x",
|
||||
Entry = "run.ps1",
|
||||
Kind = ScriptKind.File,
|
||||
Input = new ScriptInput { Extensions = { ".png" }, MinFiles = 3, MaxFiles = 2 },
|
||||
};
|
||||
|
||||
var errors = ManifestValidator.Validate(manifest, "abc");
|
||||
Assert.IsTrue(errors.Any(e => e.Contains("maxFiles")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ModuleStateTests
|
||||
{
|
||||
private string _folder = string.Empty;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-state-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_folder);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_folder))
|
||||
{
|
||||
Directory.Delete(_folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private string WriteSettings(string json)
|
||||
{
|
||||
var path = Path.Combine(_folder, "settings.json");
|
||||
File.WriteAllText(path, json);
|
||||
return path;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Missing_SettingsFile_Allows()
|
||||
{
|
||||
// No PowerToys governance (standalone/dev/test) -> allow.
|
||||
var path = Path.Combine(_folder, "does-not-exist.json");
|
||||
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExplicitlyEnabled_Allows()
|
||||
{
|
||||
var path = WriteSettings("{ \"enabled\": { \"PowerScripts\": true } }");
|
||||
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ExplicitlyDisabled_Refuses()
|
||||
{
|
||||
var path = WriteSettings("{ \"enabled\": { \"PowerScripts\": false } }");
|
||||
Assert.IsFalse(ModuleState.IsPowerScriptsEnabled(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void KeyAbsent_Allows_BecauseRunnerStripsUnknownModules()
|
||||
{
|
||||
// The runner rewrites settings.json and drops entries for modules it does not itself host,
|
||||
// so an absent PowerScripts key is ambiguous rather than a deliberate "off". The settings-file
|
||||
// overload therefore falls back to enabled; a deliberate "off" is expressed either as an
|
||||
// explicit false here or via the module-owned config.json override.
|
||||
var path = WriteSettings("{ \"enabled\": { \"Keyboard Manager\": true } }");
|
||||
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Corrupt_SettingsFile_Allows_ToNotBreakBindings()
|
||||
{
|
||||
var path = WriteSettings("{ this is not valid json");
|
||||
Assert.IsTrue(ModuleState.IsPowerScriptsEnabled(path));
|
||||
}
|
||||
}
|
||||
@@ -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,66 @@
|
||||
// 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.Execution;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class PythonConventionTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Parse_TextToText_ReturnsSignature()
|
||||
{
|
||||
var signature = PowerScriptPythonConvention.Parse("def powerscript_from_text_to_text(text):\n return text.upper()\n");
|
||||
|
||||
Assert.IsNotNull(signature);
|
||||
Assert.AreEqual("powerscript_from_text_to_text", signature.FunctionName);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Text, signature.Input);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Text, signature.Output);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_ImageToText_MapsFormats()
|
||||
{
|
||||
var signature = PowerScriptPythonConvention.Parse("def powerscript_from_image_to_text(image_path):\n return ''\n");
|
||||
|
||||
Assert.IsNotNull(signature);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Image, signature.Input);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Text, signature.Output);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_FileOutputToken_MapsToFiles()
|
||||
{
|
||||
var signature = PowerScriptPythonConvention.Parse("def powerscript_from_text_to_file(text):\n return []\n");
|
||||
|
||||
Assert.IsNotNull(signature);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Text, signature.Input);
|
||||
Assert.AreEqual(PowerScriptDataFormat.Files, signature.Output);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_NoMatchingFunction_ReturnsNull()
|
||||
{
|
||||
Assert.IsNull(PowerScriptPythonConvention.Parse("def do_something(x):\n return x\n"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_MultipleMatchingFunctions_ReturnsNull()
|
||||
{
|
||||
var source =
|
||||
"def powerscript_from_text_to_text(text):\n return text\n\n" +
|
||||
"def powerscript_from_html_to_text(html):\n return html\n";
|
||||
|
||||
Assert.IsNull(PowerScriptPythonConvention.Parse(source));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Parse_NullOrEmpty_ReturnsNull()
|
||||
{
|
||||
Assert.IsNull(PowerScriptPythonConvention.Parse(null!));
|
||||
Assert.IsNull(PowerScriptPythonConvention.Parse(string.Empty));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Registry;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ScriptRegistryTests
|
||||
{
|
||||
private string _root = string.Empty;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_root = Path.Combine(Path.GetTempPath(), "powerscripts-tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_root);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void WriteScript(string id, string manifestJson, string entryFile = "run.ps1")
|
||||
{
|
||||
var folder = Path.Combine(_root, id);
|
||||
Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(Path.Combine(folder, "manifest.json"), manifestJson);
|
||||
File.WriteAllText(Path.Combine(folder, entryFile), "# noop");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Skips_Invalid_And_Records_Error()
|
||||
{
|
||||
WriteScript("good", """
|
||||
{ "id": "good", "name": "Good", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
// Missing 'id' -> should be rejected.
|
||||
WriteScript("bad", """
|
||||
{ "name": "Bad", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual("good", registry.Scripts[0].Id);
|
||||
Assert.AreEqual(1, registry.Errors.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Allows_IdDecoupledFromFolder()
|
||||
{
|
||||
// The folder name differs from the id; the script is still loaded and keyed by its id.
|
||||
WriteScript("some-folder", """
|
||||
{ "id": "portable.id", "name": "Portable", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual("portable.id", registry.Scripts[0].Id);
|
||||
Assert.AreEqual(0, registry.Errors.Count);
|
||||
Assert.IsNotNull(registry.Get("portable.id"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_Rejects_DuplicateIds()
|
||||
{
|
||||
WriteScript("folder-a", """
|
||||
{ "id": "dup", "name": "First", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
WriteScript("folder-b", """
|
||||
{ "id": "dup", "name": "Second", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
// Only the first wins; the collision is reported.
|
||||
Assert.AreEqual(1, registry.Scripts.Count);
|
||||
Assert.AreEqual(1, registry.Errors.Count);
|
||||
Assert.IsTrue(registry.Errors[0].Message.Contains("duplicate id"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileScriptsFor_Matches_Extension_And_Wildcard()
|
||||
{
|
||||
WriteScript("png-only", """
|
||||
{ "id": "png-only", "name": "PNG", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 0 } }
|
||||
""");
|
||||
|
||||
WriteScript("any-file", """
|
||||
{ "id": "any-file", "name": "Any", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": ["*"], "minFiles": 1, "maxFiles": 0 } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
var forPng = registry.FileScriptsFor(".PNG").Select(s => s.Id).OrderBy(x => x).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "any-file", "png-only" }, forPng);
|
||||
|
||||
var forTxt = registry.FileScriptsFor(".txt").Select(s => s.Id).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "any-file" }, forTxt);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FileScriptsForSelection_Respects_MinMax_And_MixedExtensions()
|
||||
{
|
||||
WriteScript("single-png", """
|
||||
{ "id": "single-png", "name": "Single PNG", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": [".png"], "minFiles": 1, "maxFiles": 1 } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
// Two files exceeds maxFiles=1.
|
||||
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.png", "b.png" }).Count());
|
||||
|
||||
// One file is fine.
|
||||
Assert.AreEqual(1, registry.FileScriptsForSelection(new[] { "a.png" }).Count());
|
||||
|
||||
// Mixed extensions: not all match .png.
|
||||
Assert.AreEqual(0, registry.FileScriptsForSelection(new[] { "a.txt" }).Count());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SystemScripts_Filters_ByKind()
|
||||
{
|
||||
WriteScript("sys", """
|
||||
{ "id": "sys", "name": "Sys", "kind": "system", "entry": "run.ps1" }
|
||||
""");
|
||||
WriteScript("file", """
|
||||
{ "id": "file", "name": "File", "kind": "file", "entry": "run.ps1",
|
||||
"input": { "extensions": ["*"] } }
|
||||
""");
|
||||
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
|
||||
var system = registry.SystemScripts.Select(s => s.Id).ToList();
|
||||
CollectionAssert.AreEqual(new[] { "sys" }, system);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Load_EmptyRoot_YieldsNoScripts()
|
||||
{
|
||||
var registry = new ScriptRegistry(_root);
|
||||
registry.Load();
|
||||
Assert.AreEqual(0, registry.Scripts.Count);
|
||||
Assert.AreEqual(0, registry.Errors.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Security;
|
||||
|
||||
namespace PowerScripts.Core.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class SecurityTests
|
||||
{
|
||||
private string _folder = string.Empty;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_folder = Path.Combine(Path.GetTempPath(), "powerscripts-sec-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_folder);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
if (Directory.Exists(_folder))
|
||||
{
|
||||
Directory.Delete(_folder, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
private PowerScriptManifest WriteScript(string id, string body, params string[] capabilities)
|
||||
{
|
||||
var entry = "run.ps1";
|
||||
File.WriteAllText(Path.Combine(_folder, entry), body);
|
||||
return new PowerScriptManifest
|
||||
{
|
||||
Id = id,
|
||||
Name = id,
|
||||
Kind = ScriptKind.System,
|
||||
Entry = entry,
|
||||
FolderPath = _folder,
|
||||
Capabilities = capabilities.ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_IsStable_ForSameContent()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi");
|
||||
var first = ScriptIntegrity.ComputeHash(a);
|
||||
var second = ScriptIntegrity.ComputeHash(a);
|
||||
Assert.AreEqual(first, second);
|
||||
Assert.AreNotEqual(string.Empty, first);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_Changes_WhenBodyChanges()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi");
|
||||
var before = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
File.WriteAllText(Path.Combine(_folder, "run.ps1"), "Remove-Item C:\\ -Recurse");
|
||||
var after = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
Assert.AreNotEqual(before, after);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Integrity_Changes_WhenCapabilitiesChange()
|
||||
{
|
||||
var a = WriteScript("s", "Write-Host hi", "fileRead");
|
||||
var before = ScriptIntegrity.ComputeHash(a);
|
||||
|
||||
var b = WriteScript("s", "Write-Host hi", "fileRead", "process");
|
||||
var after = ScriptIntegrity.ComputeHash(b);
|
||||
|
||||
Assert.AreNotEqual(before, after);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TrustStore_RoundTrips_And_Enforces_Hash()
|
||||
{
|
||||
var path = Path.Combine(_folder, "trust.json");
|
||||
var manifest = WriteScript("s", "Write-Host hi");
|
||||
var hash = ScriptIntegrity.ComputeHash(manifest);
|
||||
|
||||
var store = new TrustStore(path);
|
||||
Assert.IsFalse(store.IsTrusted("s", hash));
|
||||
|
||||
store.Trust(new TrustRecord { Id = "s", Hash = hash, ApprovedUtc = DateTimeOffset.UtcNow });
|
||||
Assert.IsTrue(store.IsTrusted("s", hash));
|
||||
|
||||
// A different content hash for the same id is NOT trusted (edit invalidates approval).
|
||||
Assert.IsFalse(store.IsTrusted("s", "deadbeef"));
|
||||
|
||||
// Persisted across instances.
|
||||
var reopened = new TrustStore(path);
|
||||
Assert.IsTrue(reopened.IsTrusted("s", hash));
|
||||
|
||||
// Revoke clears it.
|
||||
Assert.IsTrue(reopened.Revoke("s"));
|
||||
Assert.IsFalse(new TrustStore(path).IsTrusted("s", hash));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// 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.RegularExpressions;
|
||||
|
||||
namespace PowerScripts.Core.Execution;
|
||||
|
||||
/// <summary>
|
||||
/// The data shapes a Python PowerScript can consume or produce. These map directly onto the
|
||||
/// Advanced Paste clipboard formats so a Python PowerScript can be surfaced there, and onto the
|
||||
/// file/text inputs used by the Keyboard Manager and context-menu surfaces.
|
||||
/// </summary>
|
||||
public enum PowerScriptDataFormat
|
||||
{
|
||||
None,
|
||||
Text,
|
||||
Html,
|
||||
Image,
|
||||
Audio,
|
||||
Video,
|
||||
Files,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The convention that turns a Python function name into a typed transform. Every Python PowerScript
|
||||
/// declares exactly one function named <c>powerscript_from_<input>_to_<output></c>; the
|
||||
/// input/output tokens tell each surface what the script consumes and produces (e.g. which clipboard
|
||||
/// formats it accepts in Advanced Paste). Keeping the contract in the function name means the same
|
||||
/// <c>.py</c> file works unchanged whether it is invoked by a hotkey, the context menu, or a paste.
|
||||
/// </summary>
|
||||
public static partial class PowerScriptPythonConvention
|
||||
{
|
||||
public const string FunctionPrefix = "powerscript_from_";
|
||||
|
||||
[GeneratedRegex(@"^\s*def\s+(powerscript_from_(text|html|image|audio|video|files|none)_to_(text|html|image|audio|video|file|files|none))\s*\(", RegexOptions.Multiline)]
|
||||
private static partial Regex FunctionRegex();
|
||||
|
||||
/// <summary>The parsed transform contract of a Python PowerScript.</summary>
|
||||
public sealed record TransformSignature(string FunctionName, PowerScriptDataFormat Input, PowerScriptDataFormat Output);
|
||||
|
||||
/// <summary>
|
||||
/// Finds the single <c>powerscript_from_*_to_*</c> function declaration in the given Python
|
||||
/// source and returns its parsed input/output contract, or <c>null</c> when none (or more than
|
||||
/// one) is present.
|
||||
/// </summary>
|
||||
public static TransformSignature? Parse(string pythonSource)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pythonSource))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var matches = FunctionRegex().Matches(pythonSource);
|
||||
if (matches.Count != 1)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var match = matches[0];
|
||||
return new TransformSignature(
|
||||
match.Groups[1].Value,
|
||||
ParseFormat(match.Groups[2].Value),
|
||||
ParseFormat(match.Groups[3].Value));
|
||||
}
|
||||
|
||||
/// <summary>Reads and parses the transform signature from a Python file on disk.</summary>
|
||||
public static TransformSignature? ParseFile(string pythonFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.Exists(pythonFilePath) ? Parse(File.ReadAllText(pythonFilePath)) : null;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PowerScriptDataFormat ParseFormat(string token) => token.ToLowerInvariant() switch
|
||||
{
|
||||
"text" => PowerScriptDataFormat.Text,
|
||||
"html" => PowerScriptDataFormat.Html,
|
||||
"image" => PowerScriptDataFormat.Image,
|
||||
"audio" => PowerScriptDataFormat.Audio,
|
||||
"video" => PowerScriptDataFormat.Video,
|
||||
"file" or "files" => PowerScriptDataFormat.Files,
|
||||
_ => PowerScriptDataFormat.None,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
// 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 System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Execution;
|
||||
|
||||
/// <summary>The typed input handed to a Python PowerScript transform.</summary>
|
||||
public sealed class PythonTransformInput
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; set; }
|
||||
|
||||
[JsonPropertyName("html")]
|
||||
public string? Html { get; set; }
|
||||
|
||||
[JsonPropertyName("image_path")]
|
||||
public string? ImagePath { get; set; }
|
||||
|
||||
[JsonPropertyName("file_paths")]
|
||||
public List<string>? FilePaths { get; set; }
|
||||
|
||||
[JsonPropertyName("audio_path")]
|
||||
public string? AudioPath { get; set; }
|
||||
|
||||
[JsonPropertyName("video_path")]
|
||||
public string? VideoPath { get; set; }
|
||||
|
||||
[JsonPropertyName("params")]
|
||||
public Dictionary<string, string>? Params { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>The typed result produced by a Python PowerScript transform.</summary>
|
||||
public sealed class PythonTransformResult
|
||||
{
|
||||
[JsonPropertyName("text")]
|
||||
public string? Text { get; set; }
|
||||
|
||||
[JsonPropertyName("html")]
|
||||
public string? Html { get; set; }
|
||||
|
||||
[JsonPropertyName("image_path")]
|
||||
public string? ImagePath { get; set; }
|
||||
|
||||
[JsonPropertyName("file_paths")]
|
||||
public List<string>? FilePaths { get; set; }
|
||||
|
||||
[JsonPropertyName("audio_path")]
|
||||
public string? AudioPath { get; set; }
|
||||
|
||||
[JsonPropertyName("video_path")]
|
||||
public string? VideoPath { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public int ExitCode { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public string StdErr { get; set; } = string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public bool Succeeded => ExitCode == 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes Python PowerScripts either on native Windows Python or inside WSL, via the bundled
|
||||
/// <c>_runner.py</c> and a JSON stdin/stdout protocol. This is the single Python execution path
|
||||
/// shared by every surface (Keyboard Manager, context menu, Advanced Paste), so behavior and the
|
||||
/// input/output contract stay identical wherever a script is invoked.
|
||||
/// </summary>
|
||||
public sealed class PythonRuntime
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private static readonly Encoding Utf8NoBom = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
|
||||
|
||||
private readonly PythonSettings _settings;
|
||||
|
||||
public PythonRuntime(PythonSettings? settings = null)
|
||||
{
|
||||
_settings = settings ?? PythonSettings.Load();
|
||||
}
|
||||
|
||||
/// <summary>Whether Python PowerScripts are enabled at all (mode is not Disabled).</summary>
|
||||
public bool IsEnabled => _settings.Mode != PythonRuntimeMode.Disabled;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a Python PowerScript transform. Resolves the runner + interpreter, serializes the input
|
||||
/// to the runner's stdin, and parses its stdout back into a typed result.
|
||||
/// </summary>
|
||||
public PythonTransformResult Run(PowerScriptManifest manifest, PythonTransformInput input)
|
||||
{
|
||||
if (manifest.Runtime != ScriptRuntime.Python)
|
||||
{
|
||||
throw new InvalidOperationException($"Script '{manifest.Id}' is not a Python script.");
|
||||
}
|
||||
|
||||
if (_settings.Mode == PythonRuntimeMode.Disabled)
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = 2, StdErr = "Python PowerScripts are disabled. Enable them in PowerScripts settings (Windows or WSL)." };
|
||||
}
|
||||
|
||||
var runnerPath = ResolveRunnerPath();
|
||||
if (runnerPath is null)
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = 2, StdErr = "Could not locate the PowerScripts Python runner (_runner.py)." };
|
||||
}
|
||||
|
||||
var scriptPath = manifest.EntryFullPath;
|
||||
if (!File.Exists(scriptPath))
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = 2, StdErr = $"Script entry file not found: {scriptPath}" };
|
||||
}
|
||||
|
||||
return _settings.Mode == PythonRuntimeMode.Wsl
|
||||
? RunViaWsl(runnerPath, scriptPath, input)
|
||||
: RunOnWindows(runnerPath, scriptPath, input);
|
||||
}
|
||||
|
||||
private PythonTransformResult RunOnWindows(string runnerPath, string scriptPath, PythonTransformInput input)
|
||||
{
|
||||
var interpreter = ResolveWindowsInterpreter();
|
||||
if (interpreter is null)
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = 2, StdErr = "No Python interpreter found. Install Python or set an interpreter path in PowerScripts settings." };
|
||||
}
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = interpreter.Value.FileName,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Utf8NoBom,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
foreach (var arg in interpreter.Value.LeadingArgs)
|
||||
{
|
||||
psi.ArgumentList.Add(arg);
|
||||
}
|
||||
|
||||
psi.ArgumentList.Add("-X");
|
||||
psi.ArgumentList.Add("utf8");
|
||||
psi.ArgumentList.Add(runnerPath);
|
||||
psi.ArgumentList.Add(scriptPath);
|
||||
|
||||
return RunProcess(psi, JsonSerializer.Serialize(input, JsonOptions), translateResultPaths: false);
|
||||
}
|
||||
|
||||
private PythonTransformResult RunViaWsl(string runnerPath, string scriptPath, PythonTransformInput input)
|
||||
{
|
||||
// Translate the runner + script + any input paths to /mnt/... so the Linux Python can read them.
|
||||
var wslInput = new PythonTransformInput
|
||||
{
|
||||
Text = input.Text,
|
||||
Html = input.Html,
|
||||
ImagePath = ToWslPath(input.ImagePath),
|
||||
AudioPath = ToWslPath(input.AudioPath),
|
||||
VideoPath = ToWslPath(input.VideoPath),
|
||||
FilePaths = input.FilePaths?.Select(p => ToWslPath(p)!).ToList(),
|
||||
Params = input.Params,
|
||||
};
|
||||
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "wsl.exe",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true,
|
||||
StandardInputEncoding = Utf8NoBom,
|
||||
StandardOutputEncoding = Encoding.UTF8,
|
||||
StandardErrorEncoding = Encoding.UTF8,
|
||||
};
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_settings.Wsl.Distribution))
|
||||
{
|
||||
psi.ArgumentList.Add("-d");
|
||||
psi.ArgumentList.Add(_settings.Wsl.Distribution);
|
||||
}
|
||||
|
||||
psi.ArgumentList.Add("--");
|
||||
psi.ArgumentList.Add("bash");
|
||||
psi.ArgumentList.Add("-l");
|
||||
psi.ArgumentList.Add("-c");
|
||||
psi.ArgumentList.Add($"python3 -X utf8 {ShellQuote(ToWslPath(runnerPath)!)} {ShellQuote(ToWslPath(scriptPath)!)}");
|
||||
|
||||
var result = RunProcess(psi, JsonSerializer.Serialize(wslInput, JsonOptions), translateResultPaths: true);
|
||||
return result;
|
||||
}
|
||||
|
||||
private PythonTransformResult RunProcess(ProcessStartInfo psi, string payload, bool translateResultPaths)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var process = new Process { StartInfo = psi };
|
||||
process.Start();
|
||||
|
||||
var stdOutTask = process.StandardOutput.ReadToEndAsync();
|
||||
var stdErrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
process.StandardInput.Write(payload);
|
||||
process.StandardInput.Close();
|
||||
|
||||
var timeoutMs = Math.Max(1, _settings.TimeoutSeconds) * 1000;
|
||||
if (!process.WaitForExit(timeoutMs))
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Best effort.
|
||||
}
|
||||
|
||||
return new PythonTransformResult { ExitCode = 124, StdErr = $"Python script timed out after {_settings.TimeoutSeconds}s." };
|
||||
}
|
||||
|
||||
var stdout = stdOutTask.GetAwaiter().GetResult();
|
||||
var stderr = stdErrTask.GetAwaiter().GetResult();
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = process.ExitCode, StdErr = string.IsNullOrEmpty(stderr) ? "Python script failed." : stderr };
|
||||
}
|
||||
|
||||
PythonTransformResult result;
|
||||
try
|
||||
{
|
||||
result = string.IsNullOrWhiteSpace(stdout)
|
||||
? new PythonTransformResult()
|
||||
: JsonSerializer.Deserialize<PythonTransformResult>(stdout, JsonOptions) ?? new PythonTransformResult();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// A script that just printed text (didn't return via the protocol) still yields output.
|
||||
result = new PythonTransformResult { Text = stdout };
|
||||
}
|
||||
|
||||
result.ExitCode = 0;
|
||||
result.StdErr = stderr;
|
||||
|
||||
if (translateResultPaths)
|
||||
{
|
||||
result.ImagePath = FromWslPath(result.ImagePath);
|
||||
result.AudioPath = FromWslPath(result.AudioPath);
|
||||
result.VideoPath = FromWslPath(result.VideoPath);
|
||||
result.FilePaths = result.FilePaths?.Select(p => FromWslPath(p)!).ToList();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new PythonTransformResult { ExitCode = 2, StdErr = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Locates <c>_runner.py</c>, bundled next to the executing assembly.</summary>
|
||||
private static string? ResolveRunnerPath()
|
||||
{
|
||||
var candidates = new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "_runner.py"),
|
||||
Path.Combine(AppContext.BaseDirectory, "Execution", "_runner.py"),
|
||||
};
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the Windows Python interpreter: an explicit configured path, then the <c>py</c>
|
||||
/// launcher (which itself honors PEP 514 registered interpreters), then <c>python.exe</c>.
|
||||
/// </summary>
|
||||
private (string FileName, string[] LeadingArgs)? ResolveWindowsInterpreter()
|
||||
{
|
||||
var configured = _settings.Windows.InterpreterPath;
|
||||
if (!string.IsNullOrWhiteSpace(configured) && File.Exists(configured))
|
||||
{
|
||||
return (configured, Array.Empty<string>());
|
||||
}
|
||||
|
||||
if (ExistsOnPath("py.exe"))
|
||||
{
|
||||
return ("py.exe", new[] { "-3" });
|
||||
}
|
||||
|
||||
if (ExistsOnPath("python.exe"))
|
||||
{
|
||||
return ("python.exe", Array.Empty<string>());
|
||||
}
|
||||
|
||||
if (ExistsOnPath("python3.exe"))
|
||||
{
|
||||
return ("python3.exe", Array.Empty<string>());
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 (Exception)
|
||||
{
|
||||
// Ignore malformed PATH entries.
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Converts a Windows path (e.g. <c>C:\a\b</c>) to a WSL path (<c>/mnt/c/a/b</c>).</summary>
|
||||
internal static string? ToWslPath(string? windowsPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(windowsPath))
|
||||
{
|
||||
return windowsPath;
|
||||
}
|
||||
|
||||
if (windowsPath.Length >= 2 && windowsPath[1] == ':')
|
||||
{
|
||||
var drive = char.ToLowerInvariant(windowsPath[0]);
|
||||
var rest = windowsPath[2..].Replace('\\', '/');
|
||||
return $"/mnt/{drive}{rest}";
|
||||
}
|
||||
|
||||
return windowsPath.Replace('\\', '/');
|
||||
}
|
||||
|
||||
/// <summary>Converts a WSL path (<c>/mnt/c/a/b</c>) back to a Windows path (<c>C:\a\b</c>).</summary>
|
||||
internal static string? FromWslPath(string? wslPath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(wslPath))
|
||||
{
|
||||
return wslPath;
|
||||
}
|
||||
|
||||
if (wslPath.StartsWith("/mnt/", StringComparison.Ordinal) && wslPath.Length >= 6)
|
||||
{
|
||||
var drive = char.ToUpperInvariant(wslPath[5]);
|
||||
var rest = wslPath.Length > 6 ? wslPath[6..].Replace('/', '\\') : string.Empty;
|
||||
return $"{drive}:{rest}";
|
||||
}
|
||||
|
||||
return wslPath;
|
||||
}
|
||||
|
||||
private static string ShellQuote(string value) => "'" + value.Replace("'", "'\\''") + "'";
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// 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.Python)
|
||||
{
|
||||
return ExecutePython(manifest, files, parameters);
|
||||
}
|
||||
|
||||
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>
|
||||
/// Runs a Python PowerScript for the file/hotkey surfaces by adapting the file list + parameters
|
||||
/// onto the shared <see cref="PythonRuntime"/> transform contract. Text/HTML outputs are returned
|
||||
/// as stdout; produced file/image paths are returned newline-separated so the CLI surfaces can
|
||||
/// report them.
|
||||
/// </summary>
|
||||
private static ScriptExecutionResult ExecutePython(
|
||||
PowerScriptManifest manifest,
|
||||
IReadOnlyList<string>? files,
|
||||
IReadOnlyDictionary<string, string?>? parameters)
|
||||
{
|
||||
var input = new PythonTransformInput
|
||||
{
|
||||
FilePaths = files?.ToList() ?? new List<string>(),
|
||||
Params = parameters?
|
||||
.Where(kv => kv.Value is not null)
|
||||
.ToDictionary(kv => kv.Key, kv => kv.Value!),
|
||||
};
|
||||
|
||||
var result = new PythonRuntime().Run(manifest, input);
|
||||
|
||||
var stdoutParts = new List<string>();
|
||||
if (!string.IsNullOrEmpty(result.Text))
|
||||
{
|
||||
stdoutParts.Add(result.Text);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.Html))
|
||||
{
|
||||
stdoutParts.Add(result.Html);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(result.ImagePath))
|
||||
{
|
||||
stdoutParts.Add(result.ImagePath);
|
||||
}
|
||||
|
||||
if (result.FilePaths is { Count: > 0 })
|
||||
{
|
||||
stdoutParts.AddRange(result.FilePaths);
|
||||
}
|
||||
|
||||
return new ScriptExecutionResult
|
||||
{
|
||||
ExitCode = result.ExitCode,
|
||||
StdOut = string.Join(Environment.NewLine, stdoutParts),
|
||||
StdErr = result.StdErr,
|
||||
};
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
135
src/modules/PowerScripts/PowerScripts.Core/Execution/_runner.py
Normal file
135
src/modules/PowerScripts/PowerScripts.Core/Execution/_runner.py
Normal file
@@ -0,0 +1,135 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
|
||||
"""PowerScripts Python runner.
|
||||
|
||||
Bootstraps a user Python PowerScript in a stable, host-agnostic way. The C# host (PowerScripts.Host)
|
||||
invokes this as:
|
||||
|
||||
python -X utf8 _runner.py <script.py>
|
||||
|
||||
and writes a single JSON object to stdin describing the input. This runner:
|
||||
1. loads the user script module from <script.py>,
|
||||
2. finds its single ``powerscript_from_<input>_to_<output>`` function,
|
||||
3. calls it with arguments derived from the input JSON, and
|
||||
4. writes a single JSON result object to stdout.
|
||||
|
||||
Keeping this protocol identical for Windows and WSL means the user script never has to care where or
|
||||
how it runs.
|
||||
|
||||
Input JSON fields (all optional): text, html, image_path, file_paths (list), audio_path, video_path,
|
||||
params (object of name->string).
|
||||
|
||||
Output JSON fields (any subset): text, html, image_path, file_paths (list), audio_path, video_path.
|
||||
A script may also just return a plain string (treated as text) or a path-like value.
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import traceback
|
||||
|
||||
FUNCTION_RE = re.compile(
|
||||
r"^\s*def\s+(powerscript_from_(text|html|image|audio|video|files|none)"
|
||||
r"_to_(text|html|image|audio|video|file|files|none))\s*\(",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
|
||||
def _fail(message):
|
||||
sys.stderr.write(message)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _load_module(script_path):
|
||||
spec = importlib.util.spec_from_file_location("powerscript_user_module", script_path)
|
||||
if spec is None or spec.loader is None:
|
||||
_fail(f"Could not load script module from '{script_path}'.")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _find_signature(script_path):
|
||||
with open(script_path, "r", encoding="utf-8") as handle:
|
||||
source = handle.read()
|
||||
matches = FUNCTION_RE.findall(source)
|
||||
if len(matches) != 1:
|
||||
_fail(
|
||||
"Expected exactly one 'powerscript_from_<input>_to_<output>' function in the script, "
|
||||
f"found {len(matches)}."
|
||||
)
|
||||
name, input_format, _output_format = matches[0]
|
||||
return name, input_format
|
||||
|
||||
|
||||
def _build_args(input_format, payload):
|
||||
"""Maps the declared input format onto the argument passed to the user function."""
|
||||
if input_format == "text":
|
||||
return [payload.get("text", "") or ""]
|
||||
if input_format == "html":
|
||||
return [payload.get("html", "") or ""]
|
||||
if input_format == "image":
|
||||
return [payload.get("image_path", "") or ""]
|
||||
if input_format == "audio":
|
||||
return [payload.get("audio_path", "") or ""]
|
||||
if input_format == "video":
|
||||
return [payload.get("video_path", "") or ""]
|
||||
if input_format == "files":
|
||||
return [payload.get("file_paths", []) or []]
|
||||
return [] # none
|
||||
|
||||
|
||||
def _normalize_result(result):
|
||||
"""Turns a user function's return value into the output JSON contract."""
|
||||
if result is None:
|
||||
return {}
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
if isinstance(result, (list, tuple)):
|
||||
return {"file_paths": [str(item) for item in result]}
|
||||
# A bare string / path-like scalar is returned as text; callers that expect a file/image path
|
||||
# read the same "text" field and interpret it per the function's declared output format.
|
||||
return {"text": str(result)}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
_fail("usage: _runner.py <script.py>")
|
||||
script_path = sys.argv[1]
|
||||
|
||||
try:
|
||||
raw = sys.stdin.read()
|
||||
payload = json.loads(raw) if raw.strip() else {}
|
||||
except json.JSONDecodeError as exc:
|
||||
_fail(f"Invalid input JSON: {exc}")
|
||||
return
|
||||
|
||||
try:
|
||||
function_name, input_format = _find_signature(script_path)
|
||||
module = _load_module(script_path)
|
||||
function = getattr(module, function_name, None)
|
||||
if function is None or not callable(function):
|
||||
_fail(f"Script does not define callable '{function_name}'.")
|
||||
return
|
||||
|
||||
params = payload.get("params") or {}
|
||||
args = _build_args(input_format, payload)
|
||||
try:
|
||||
result = function(*args, **params) if params else function(*args)
|
||||
except TypeError:
|
||||
# The script may not accept keyword params; retry positionally only.
|
||||
result = function(*args)
|
||||
|
||||
sys.stdout.write(json.dumps(_normalize_result(result)))
|
||||
except SystemExit:
|
||||
raise
|
||||
except Exception: # noqa: BLE001 - surface any script error to the host's stderr
|
||||
_fail("PowerScript raised an exception:\n" + traceback.format_exc())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -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,62 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace PowerScripts.Core.Manifest;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a parsed manifest. Returns human-readable errors rather than throwing so the registry
|
||||
/// can skip a single bad script without failing the whole catalogue.
|
||||
///
|
||||
/// A script's <c>id</c> is its portable identity and is intentionally decoupled from the folder it
|
||||
/// happens to live in: this lets a script keep a stable id when it is shared, downloaded from a
|
||||
/// community catalogue, or dropped into a differently-named folder to avoid a local name clash.
|
||||
/// Uniqueness of ids across the catalogue is enforced by the registry, not here.
|
||||
/// </summary>
|
||||
public static class ManifestValidator
|
||||
{
|
||||
public static IReadOnlyList<string> Validate(PowerScriptManifest manifest, string folderName)
|
||||
{
|
||||
_ = folderName;
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Id))
|
||||
{
|
||||
errors.Add("'id' is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Name))
|
||||
{
|
||||
errors.Add("'name' is required.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(manifest.Entry))
|
||||
{
|
||||
errors.Add("'entry' is required.");
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(manifest.FolderPath) && !File.Exists(manifest.EntryFullPath))
|
||||
{
|
||||
errors.Add($"entry script not found: '{manifest.Entry}'.");
|
||||
}
|
||||
|
||||
if (manifest.Kind == ScriptKind.File)
|
||||
{
|
||||
if (manifest.Input is null || manifest.Input.Extensions.Count == 0)
|
||||
{
|
||||
errors.Add("file scripts must declare 'input.extensions'.");
|
||||
}
|
||||
|
||||
if (manifest.Input is { MinFiles: < 1 })
|
||||
{
|
||||
errors.Add("'input.minFiles' must be at least 1.");
|
||||
}
|
||||
|
||||
if (manifest.Input is { MaxFiles: > 0 } input && input.MaxFiles < input.MinFiles)
|
||||
{
|
||||
errors.Add("'input.maxFiles' must be 0 (unbounded) or >= minFiles.");
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
// 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. PowerShell and Python are supported; the enum exists
|
||||
/// so further runtimes (e.g. Node) can be added without a schema break.
|
||||
/// </summary>
|
||||
public enum ScriptRuntime
|
||||
{
|
||||
PowerShell,
|
||||
|
||||
/// <summary>
|
||||
/// A Python script whose entry file exposes a single
|
||||
/// <c>powerscript_from_<input>_to_<output></c> function. Runs on native Windows Python or
|
||||
/// inside WSL depending on the user's PowerScripts Python settings.
|
||||
/// </summary>
|
||||
Python,
|
||||
}
|
||||
|
||||
/// <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, portable identifier. Intentionally decoupled from the containing folder name so a
|
||||
/// script can be renamed on disk (or dropped into a differently-named folder to avoid a local
|
||||
/// clash) without changing its identity. Must be unique across the catalogue.
|
||||
/// </summary>
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Optional icon file name, relative to the script folder.</summary>
|
||||
public string? Icon { get; set; }
|
||||
|
||||
/// <summary>Optional author/publisher, shown in the trust prompt (e.g. "contoso" or a GitHub user).</summary>
|
||||
public string? Publisher { get; set; }
|
||||
|
||||
/// <summary>Optional semantic version of the script (e.g. "1.2.0").</summary>
|
||||
public string? Version { get; set; }
|
||||
|
||||
/// <summary>Optional provenance, e.g. the catalogue URL the script was adopted from.</summary>
|
||||
public string? Source { get; set; }
|
||||
|
||||
public ScriptKind Kind { get; set; }
|
||||
|
||||
public ScriptRuntime Runtime { get; set; } = ScriptRuntime.PowerShell;
|
||||
|
||||
/// <summary>Script body file name, relative to the script folder (e.g. "run.ps1").</summary>
|
||||
public string Entry { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>File input contract; required for <see cref="ScriptKind.File"/>.</summary>
|
||||
public ScriptInput? Input { get; set; }
|
||||
|
||||
public ScriptOutput? Output { get; set; }
|
||||
|
||||
public List<ScriptParameter> Parameters { get; set; } = new();
|
||||
|
||||
/// <summary>Where the script appears, e.g. "contextMenu", "keyboardManager", "commandPalette".</summary>
|
||||
public List<string> Surfaces { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Declared capabilities (e.g. "fileRead", "fileWrite", "process"). Doubles as the user-consent
|
||||
/// string and the permission contract an agent / MCP server must respect.
|
||||
/// </summary>
|
||||
public List<string> Capabilities { get; set; } = new();
|
||||
|
||||
/// <summary>Prototype always runs "asInvoker" (non-elevated).</summary>
|
||||
public string Elevation { get; set; } = "asInvoker";
|
||||
|
||||
/// <summary>Absolute path to the folder that contains this manifest. Populated by the registry.</summary>
|
||||
[JsonIgnore]
|
||||
public string FolderPath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Absolute path to the script body file.</summary>
|
||||
[JsonIgnore]
|
||||
public string EntryFullPath => string.IsNullOrEmpty(FolderPath) ? Entry : Path.Combine(FolderPath, Entry);
|
||||
|
||||
/// <summary>True if this script declares the given surface (case-insensitive).</summary>
|
||||
public bool HasSurface(string surface) =>
|
||||
Surfaces.Any(s => string.Equals(s, surface, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
95
src/modules/PowerScripts/PowerScripts.Core/ModuleState.cs
Normal file
95
src/modules/PowerScripts/PowerScripts.Core/ModuleState.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowerScripts.Core;
|
||||
|
||||
/// <summary>
|
||||
/// The single runtime gate for whether the PowerScripts module is enabled in PowerToys.
|
||||
///
|
||||
/// Every surface — the Keyboard Manager hotkey, the Explorer context menu, and any future module —
|
||||
/// runs a script through <c>PowerScripts.Host.exe run</c>. Enforcing the enabled state here (rather
|
||||
/// than in each caller) means turning PowerScripts off makes *all* existing bindings inert without
|
||||
/// having to find, delete, or rewrite them, and re-enabling restores them exactly as they were. Any
|
||||
/// module that later integrates with PowerScripts inherits this behavior for free.
|
||||
/// </summary>
|
||||
public static class ModuleState
|
||||
{
|
||||
/// <summary>
|
||||
/// Environment escape hatch (set to <c>1</c>) that bypasses the enabled gate, for hermetic tests
|
||||
/// and standalone/dev runs of the host outside a PowerToys installation.
|
||||
/// </summary>
|
||||
public const string IgnoreEnabledEnvironmentVariable = "POWERSCRIPTS_IGNORE_ENABLED";
|
||||
|
||||
/// <summary>
|
||||
/// True when scripts are allowed to run.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// - The <see cref="IgnoreEnabledEnvironmentVariable"/> bypass wins if set.
|
||||
/// - If the PowerToys settings file is absent (no PowerToys governance — standalone/dev/test),
|
||||
/// execution is allowed.
|
||||
/// - If the settings file exists, the module must be explicitly enabled
|
||||
/// (<c>enabled.PowerScripts == true</c>); an absent key means off, mirroring PowerToys'
|
||||
/// "default off" for this module.
|
||||
/// - If the settings file is unreadable, we fail open (allow) so a transient/corrupt file does
|
||||
/// not silently break a user's existing hotkeys; the context-menu install/remove still tracks
|
||||
/// the toggle in that edge case.
|
||||
/// </summary>
|
||||
public static bool IsPowerScriptsEnabled()
|
||||
{
|
||||
if (string.Equals(Environment.GetEnvironmentVariable(IgnoreEnabledEnvironmentVariable), "1", StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// The module's own config.json is authoritative when present: it is written by the Settings
|
||||
// toggle and, unlike settings.json, is never rewritten by the runner, so an explicit on/off
|
||||
// survives PowerToys restarts even though the prototype is not yet a registered runner module.
|
||||
var configOverride = PowerScriptsPaths.ReadEnabledOverride();
|
||||
if (configOverride.HasValue)
|
||||
{
|
||||
return configOverride.Value;
|
||||
}
|
||||
|
||||
return IsPowerScriptsEnabled(PowerScriptsPaths.PowerToysSettingsFilePath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Core resolution against an explicit settings file (the production overload passes the
|
||||
/// well-known PowerToys path). Split out so the gate can be unit-tested hermetically, mirroring
|
||||
/// how <c>TrustStore</c> takes its file path.
|
||||
/// </summary>
|
||||
public static bool IsPowerScriptsEnabled(string settingsPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(settingsPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(settingsPath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("enabled", out var enabled) &&
|
||||
enabled.ValueKind == JsonValueKind.Object &&
|
||||
enabled.TryGetProperty("PowerScripts", out var flag))
|
||||
{
|
||||
return flag.ValueKind == JsonValueKind.True;
|
||||
}
|
||||
|
||||
// Settings file present but no PowerScripts entry. Because the runner strips unknown
|
||||
// modules from settings.json on launch, an absent key is ambiguous rather than a
|
||||
// deliberate "off"; fall back to enabled so a hotkey/context-menu binding keeps working
|
||||
// across restarts. An explicit off is still honored via the config.json override above
|
||||
// (and via an explicit false here).
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Unreadable settings: cannot govern, so don't break existing bindings.
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<RootNamespace>PowerScripts.Core</RootNamespace>
|
||||
<AssemblyName>PowerScripts.Core</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<!-- The Python bootstrap ships next to the executor and any project that references Core. -->
|
||||
<None Update="Execution\_runner.py">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
187
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
187
src/modules/PowerScripts/PowerScripts.Core/PowerScriptsPaths.cs
Normal file
@@ -0,0 +1,187 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowerScripts.Core;
|
||||
|
||||
/// <summary>
|
||||
/// Well-known filesystem locations for the PowerScripts module. The scripts root can be overridden
|
||||
/// (explicit path, environment variable, or a persisted user setting) which keeps tests and ad-hoc
|
||||
/// runs hermetic and lets the user point PowerScripts at their own folder from Settings.
|
||||
/// </summary>
|
||||
public static class PowerScriptsPaths
|
||||
{
|
||||
/// <summary>Environment variable that overrides the default scripts root.</summary>
|
||||
public const string RootEnvironmentVariable = "POWERSCRIPTS_ROOT";
|
||||
|
||||
/// <summary>The folder a single script lives in must contain a file with this name.</summary>
|
||||
public const string ManifestFileName = "manifest.json";
|
||||
|
||||
/// <summary>The user-settings file name persisted next to the module data.</summary>
|
||||
public const string ConfigFileName = "config.json";
|
||||
|
||||
/// <summary>
|
||||
/// The module's data directory: <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts</c>.
|
||||
/// </summary>
|
||||
public static string ModuleDirectory
|
||||
{
|
||||
get
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "Microsoft", "PowerToys", "PowerScripts");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The PowerToys settings file (<c>%LOCALAPPDATA%\Microsoft\PowerToys\settings.json</c>) that
|
||||
/// records which modules are enabled. Read by <see cref="ModuleState"/> to gate execution.
|
||||
/// </summary>
|
||||
public static string PowerToysSettingsFilePath
|
||||
{
|
||||
get
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
return Path.Combine(localAppData, "Microsoft", "PowerToys", "settings.json");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The user-settings file that persists the chosen scripts root.</summary>
|
||||
public static string ConfigFilePath => Path.Combine(ModuleDirectory, ConfigFileName);
|
||||
|
||||
/// <summary>The trust store file name (records which script contents the user has approved).</summary>
|
||||
public const string TrustFileName = "trust.json";
|
||||
|
||||
/// <summary>The trust store: which (script id, content hash) pairs the user has approved to run.</summary>
|
||||
public static string TrustFilePath => Path.Combine(ModuleDirectory, TrustFileName);
|
||||
|
||||
/// <summary>
|
||||
/// Default scripts root:
|
||||
/// <c>%LOCALAPPDATA%\Microsoft\PowerToys\PowerScripts\scripts</c>.
|
||||
/// </summary>
|
||||
public static string DefaultScriptsRoot => Path.Combine(ModuleDirectory, "scripts");
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the scripts root, honoring (in order): an explicit path, the environment override,
|
||||
/// the persisted user setting, then the default.
|
||||
/// </summary>
|
||||
public static string ResolveScriptsRoot(string? explicitRoot = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(explicitRoot))
|
||||
{
|
||||
return explicitRoot;
|
||||
}
|
||||
|
||||
var fromEnv = Environment.GetEnvironmentVariable(RootEnvironmentVariable);
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
{
|
||||
return fromEnv;
|
||||
}
|
||||
|
||||
var fromConfig = ReadConfiguredScriptsRoot();
|
||||
return string.IsNullOrWhiteSpace(fromConfig) ? DefaultScriptsRoot : fromConfig;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the user-chosen scripts root from <see cref="ConfigFilePath"/>, or <c>null</c> if it is
|
||||
/// missing, empty, or unreadable.
|
||||
/// </summary>
|
||||
public static string? ReadConfiguredScriptsRoot()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(ConfigFilePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("scriptsRoot", out var value) &&
|
||||
value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var root = value.GetString();
|
||||
return string.IsNullOrWhiteSpace(root) ? null : root;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt or unreadable config simply falls back to the default.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the user-chosen scripts root to <see cref="ConfigFilePath"/>. Passing <c>null</c> or
|
||||
/// whitespace clears the override so the default is used again.
|
||||
/// </summary>
|
||||
public static void SaveConfiguredScriptsRoot(string? root)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(root) ? string.Empty : root.Trim();
|
||||
MergeConfigValue("scriptsRoot", JsonSerializer.SerializeToElement(normalized));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the module's own enabled override from <see cref="ConfigFilePath"/>, or <c>null</c> when
|
||||
/// it is missing/unreadable. This is written by Settings and, unlike the PowerToys
|
||||
/// <c>settings.json</c> <c>enabled.PowerScripts</c> flag, it is owned solely by this module so it
|
||||
/// survives the runner rewriting <c>settings.json</c> (which drops entries for modules the runner
|
||||
/// does not itself host — as the prototype is not yet a registered runner module).
|
||||
/// </summary>
|
||||
public static bool? ReadEnabledOverride()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(ConfigFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(ConfigFilePath);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("enabled", out var value) &&
|
||||
value.ValueKind is JsonValueKind.True or JsonValueKind.False)
|
||||
{
|
||||
return value.GetBoolean();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt or unreadable config simply yields no override.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Persists the module's enabled override to <see cref="ConfigFilePath"/>, preserving other keys.</summary>
|
||||
public static void SaveEnabled(bool enabled) => MergeConfigValue("enabled", JsonSerializer.SerializeToElement(enabled));
|
||||
|
||||
/// <summary>Merges a single top-level key into <see cref="ConfigFilePath"/> without clobbering the rest.</summary>
|
||||
private static void MergeConfigValue(string key, JsonElement value)
|
||||
{
|
||||
Directory.CreateDirectory(ModuleDirectory);
|
||||
var options = new JsonSerializerOptions { WriteIndented = true };
|
||||
var merged = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigFilePath))
|
||||
{
|
||||
using var stream = File.OpenRead(ConfigFilePath);
|
||||
using var existing = JsonDocument.Parse(stream);
|
||||
foreach (var property in existing.RootElement.EnumerateObject())
|
||||
{
|
||||
merged[property.Name] = property.Value.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Overwrite a corrupt config rather than fail.
|
||||
}
|
||||
|
||||
merged[key] = value;
|
||||
File.WriteAllText(ConfigFilePath, JsonSerializer.Serialize(merged, options));
|
||||
}
|
||||
}
|
||||
126
src/modules/PowerScripts/PowerScripts.Core/PythonSettings.cs
Normal file
126
src/modules/PowerScripts/PowerScripts.Core/PythonSettings.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
// 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;
|
||||
|
||||
/// <summary>How Python PowerScripts are executed.</summary>
|
||||
public enum PythonRuntimeMode
|
||||
{
|
||||
/// <summary>Python PowerScripts are not run.</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>Run using native Windows Python (auto-detected or a configured interpreter path).</summary>
|
||||
Windows,
|
||||
|
||||
/// <summary>Run inside a WSL distribution.</summary>
|
||||
Wsl,
|
||||
}
|
||||
|
||||
/// <summary>Windows-mode Python settings.</summary>
|
||||
public sealed class PythonWindowsSettings
|
||||
{
|
||||
/// <summary>Optional explicit interpreter path; empty means auto-detect (py.exe / python.exe / PEP 514 registry).</summary>
|
||||
[JsonPropertyName("interpreterPath")]
|
||||
public string InterpreterPath { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>WSL-mode Python settings.</summary>
|
||||
public sealed class PythonWslSettings
|
||||
{
|
||||
/// <summary>The WSL distribution to run scripts in; empty means the default distribution.</summary>
|
||||
[JsonPropertyName("distribution")]
|
||||
public string Distribution { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Python runtime configuration for PowerScripts, persisted in the module's <c>config.json</c>
|
||||
/// (alongside <c>scriptsRoot</c>). Mirrors the Advanced Paste Python settings (mode + Windows/WSL
|
||||
/// sub-settings) so users see a familiar shape and the two features can converge.
|
||||
/// </summary>
|
||||
public sealed class PythonSettings
|
||||
{
|
||||
[JsonPropertyName("mode")]
|
||||
[JsonConverter(typeof(JsonStringEnumConverter))]
|
||||
public PythonRuntimeMode Mode { get; set; } = PythonRuntimeMode.Disabled;
|
||||
|
||||
[JsonPropertyName("windows")]
|
||||
public PythonWindowsSettings Windows { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("wsl")]
|
||||
public PythonWslSettings Wsl { get; set; } = new();
|
||||
|
||||
/// <summary>Hard timeout for a single Python run, in seconds.</summary>
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Loads the Python settings from the module <c>config.json</c>'s <c>python</c> section, falling
|
||||
/// back to defaults (disabled) when absent or unreadable.
|
||||
/// </summary>
|
||||
public static PythonSettings Load()
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = PowerScriptsPaths.ConfigFilePath;
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new PythonSettings();
|
||||
}
|
||||
|
||||
using var stream = File.OpenRead(path);
|
||||
using var document = JsonDocument.Parse(stream);
|
||||
if (document.RootElement.TryGetProperty("python", out var python) &&
|
||||
python.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
return python.Deserialize<PythonSettings>(SerializerOptions) ?? new PythonSettings();
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt or unreadable config simply falls back to defaults (Python disabled).
|
||||
}
|
||||
|
||||
return new PythonSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists these settings into the module <c>config.json</c>'s <c>python</c> section, preserving
|
||||
/// any other keys (e.g. <c>scriptsRoot</c>).
|
||||
/// </summary>
|
||||
public void Save()
|
||||
{
|
||||
Directory.CreateDirectory(PowerScriptsPaths.ModuleDirectory);
|
||||
var path = PowerScriptsPaths.ConfigFilePath;
|
||||
|
||||
var root = new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
using var existing = JsonDocument.Parse(stream);
|
||||
foreach (var property in existing.RootElement.EnumerateObject())
|
||||
{
|
||||
root[property.Name] = property.Value.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Overwrite a corrupt config rather than fail.
|
||||
}
|
||||
|
||||
root["python"] = JsonSerializer.SerializeToElement(this, SerializerOptions);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(root, SerializerOptions));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Registry;
|
||||
|
||||
/// <summary>
|
||||
/// A manifest that failed to load or validate, kept so the UI can surface problems.
|
||||
/// </summary>
|
||||
public sealed record ScriptLoadError(string FolderPath, string Message);
|
||||
|
||||
/// <summary>
|
||||
/// The single source of truth for installed PowerScripts. Every surface (context menu, Keyboard
|
||||
/// Manager editor, Command Palette, agents) reads from this registry rather than defining scripts
|
||||
/// of its own. The registry only reads the filesystem; it never executes anything.
|
||||
/// </summary>
|
||||
public sealed class ScriptRegistry
|
||||
{
|
||||
private readonly List<PowerScriptManifest> _scripts = new();
|
||||
private readonly List<ScriptLoadError> _errors = new();
|
||||
|
||||
public ScriptRegistry(string? root = null)
|
||||
{
|
||||
Root = PowerScriptsPaths.ResolveScriptsRoot(root);
|
||||
}
|
||||
|
||||
/// <summary>Absolute path to the scanned scripts root.</summary>
|
||||
public string Root { get; }
|
||||
|
||||
public IReadOnlyList<PowerScriptManifest> Scripts => _scripts;
|
||||
|
||||
public IReadOnlyList<ScriptLoadError> Errors => _errors;
|
||||
|
||||
/// <summary>
|
||||
/// Scans <see cref="Root"/> for <c><id>/manifest.json</c> folders, parses and validates each,
|
||||
/// and rebuilds the in-memory catalogue. Bad scripts are recorded in <see cref="Errors"/> and skipped.
|
||||
/// </summary>
|
||||
public void Load()
|
||||
{
|
||||
_scripts.Clear();
|
||||
_errors.Clear();
|
||||
|
||||
if (!Directory.Exists(Root))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var folder in Directory.EnumerateDirectories(Root))
|
||||
{
|
||||
var manifestPath = Path.Combine(folder, PowerScriptsPaths.ManifestFileName);
|
||||
if (!File.Exists(manifestPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
PowerScriptManifest? manifest;
|
||||
try
|
||||
{
|
||||
manifest = ManifestSerializer.Deserialize(File.ReadAllText(manifestPath));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, $"failed to parse manifest.json: {ex.Message}"));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (manifest is null)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, "manifest.json deserialized to null."));
|
||||
continue;
|
||||
}
|
||||
|
||||
manifest.FolderPath = folder;
|
||||
|
||||
var folderName = new DirectoryInfo(folder).Name;
|
||||
var validationErrors = ManifestValidator.Validate(manifest, folderName);
|
||||
if (validationErrors.Count > 0)
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, string.Join(" ", validationErrors)));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ids are the portable identity and must be unique across the catalogue, since every
|
||||
// surface resolves a script by id. A collision (e.g. two adopted scripts sharing an id)
|
||||
// is reported and the duplicate skipped rather than silently shadowed.
|
||||
if (!seenIds.Add(manifest.Id))
|
||||
{
|
||||
_errors.Add(new ScriptLoadError(folder, $"duplicate id '{manifest.Id}' - already defined by another script; skipped."));
|
||||
continue;
|
||||
}
|
||||
|
||||
_scripts.Add(manifest);
|
||||
}
|
||||
|
||||
_scripts.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public PowerScriptManifest? Get(string id) =>
|
||||
_scripts.FirstOrDefault(s => string.Equals(s.Id, id, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>System scripts (no file input) — candidates for Keyboard Manager / Command Palette.</summary>
|
||||
public IEnumerable<PowerScriptManifest> SystemScripts =>
|
||||
_scripts.Where(s => s.Kind == ScriptKind.System);
|
||||
|
||||
/// <summary>
|
||||
/// File scripts whose declared input extensions match the given file extension (e.g. ".png").
|
||||
/// A declared extension of "*" matches anything. Used to build the right-click submenu.
|
||||
/// </summary>
|
||||
public IEnumerable<PowerScriptManifest> FileScriptsFor(string extension)
|
||||
{
|
||||
var ext = NormalizeExtension(extension);
|
||||
return _scripts.Where(s =>
|
||||
s.Kind == ScriptKind.File &&
|
||||
s.Input is not null &&
|
||||
s.Input.Extensions.Any(e => MatchesExtension(e, ext)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// File scripts that accept <em>all</em> of the given files (every extension matches and the
|
||||
/// count is within the declared min/max). Used when a multi-file selection is right-clicked.
|
||||
/// </summary>
|
||||
public IEnumerable<PowerScriptManifest> FileScriptsForSelection(IReadOnlyCollection<string> files)
|
||||
{
|
||||
var extensions = files.Select(f => NormalizeExtension(Path.GetExtension(f))).Distinct().ToList();
|
||||
return _scripts.Where(s =>
|
||||
s.Kind == ScriptKind.File &&
|
||||
s.Input is not null &&
|
||||
extensions.All(ext => s.Input.Extensions.Any(e => MatchesExtension(e, ext))) &&
|
||||
files.Count >= s.Input.MinFiles &&
|
||||
(s.Input.MaxFiles == 0 || files.Count <= s.Input.MaxFiles));
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string extension)
|
||||
{
|
||||
if (string.IsNullOrEmpty(extension))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return extension.StartsWith('.') ? extension.ToLowerInvariant() : "." + extension.ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static bool MatchesExtension(string declared, string normalizedTarget)
|
||||
{
|
||||
if (declared == "*")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return string.Equals(NormalizeExtension(declared), normalizedTarget, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// Computes a stable content fingerprint for a script. The fingerprint covers both the executable
|
||||
/// body and the parts of the manifest that define what the script is allowed to do, so that editing
|
||||
/// the script <em>or</em> escalating its declared capabilities invalidates any prior user trust and
|
||||
/// forces a fresh consent prompt (trust-on-first-use).
|
||||
/// </summary>
|
||||
public static class ScriptIntegrity
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the lowercase hex SHA-256 of the script's entry-file bytes combined with its declared
|
||||
/// <c>kind</c> and (sorted) <c>capabilities</c>. Returns an empty string if the entry file is
|
||||
/// missing (an untrusted state that will never match a stored trust record).
|
||||
/// </summary>
|
||||
public static string ComputeHash(PowerScriptManifest manifest)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
|
||||
var entryPath = manifest.EntryFullPath;
|
||||
if (string.IsNullOrEmpty(entryPath) || !File.Exists(entryPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var body = File.ReadAllBytes(entryPath);
|
||||
|
||||
var capabilities = manifest.Capabilities
|
||||
.Select(c => c.Trim().ToLowerInvariant())
|
||||
.Where(c => c.Length > 0)
|
||||
.OrderBy(c => c, StringComparer.Ordinal);
|
||||
|
||||
var declaration = $"\nkind={manifest.Kind}\ncapabilities={string.Join(',', capabilities)}\n";
|
||||
|
||||
using var sha = SHA256.Create();
|
||||
sha.TransformBlock(body, 0, body.Length, null, 0);
|
||||
var declarationBytes = Encoding.UTF8.GetBytes(declaration);
|
||||
sha.TransformFinalBlock(declarationBytes, 0, declarationBytes.Length);
|
||||
|
||||
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerScripts.Core.Security;
|
||||
|
||||
/// <summary>
|
||||
/// A single trust-on-first-use record: the user approved a script id whose content matched
|
||||
/// <see cref="Hash"/>. If the script's content or declared capabilities later change, the recomputed
|
||||
/// hash no longer matches and the user is asked to approve again.
|
||||
/// </summary>
|
||||
public sealed class TrustRecord
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Hash { get; set; } = string.Empty;
|
||||
|
||||
public IReadOnlyList<string> Capabilities { get; set; } = [];
|
||||
|
||||
public string? Source { get; set; }
|
||||
|
||||
public string? Publisher { get; set; }
|
||||
|
||||
public DateTimeOffset ApprovedUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists which script contents the user has explicitly allowed to run. This is the enforcement
|
||||
/// point behind the manifest's declared <c>capabilities</c>: a script only runs once the user has
|
||||
/// approved its exact current content, and re-approves whenever that content changes.
|
||||
/// </summary>
|
||||
public sealed class TrustStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions Options = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
WriteIndented = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
private readonly string _path;
|
||||
private readonly Dictionary<string, TrustRecord> _records;
|
||||
|
||||
public TrustStore(string path)
|
||||
{
|
||||
_path = path ?? throw new ArgumentNullException(nameof(path));
|
||||
_records = Load(path);
|
||||
}
|
||||
|
||||
/// <summary>All current trust records.</summary>
|
||||
public IReadOnlyCollection<TrustRecord> Records => _records.Values;
|
||||
|
||||
/// <summary>Returns true if the user has approved this id with exactly this content hash.</summary>
|
||||
public bool IsTrusted(string id, string hash)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(hash))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _records.TryGetValue(id, out var record)
|
||||
&& string.Equals(record.Hash, hash, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>Records (or updates) approval for an id at the given content hash and persists it.</summary>
|
||||
public void Trust(TrustRecord record)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(record);
|
||||
_records[record.Id] = record;
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>Removes approval for an id. Returns true if a record was removed.</summary>
|
||||
public bool Revoke(string id)
|
||||
{
|
||||
if (string.IsNullOrEmpty(id) || !_records.Remove(id))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Save();
|
||||
return true;
|
||||
}
|
||||
|
||||
private static Dictionary<string, TrustRecord> Load(string path)
|
||||
{
|
||||
var result = new Dictionary<string, TrustRecord>(StringComparer.OrdinalIgnoreCase);
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var records = JsonSerializer.Deserialize<List<TrustRecord>>(File.ReadAllText(path), Options);
|
||||
if (records is not null)
|
||||
{
|
||||
foreach (var record in records.Where(r => !string.IsNullOrEmpty(r.Id)))
|
||||
{
|
||||
result[record.Id] = record;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException)
|
||||
{
|
||||
// A corrupt or unreadable trust file is treated as "nothing trusted" so the user is
|
||||
// simply re-prompted, rather than crashing every surface that runs a script.
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private void Save()
|
||||
{
|
||||
var directory = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
File.WriteAllText(_path, JsonSerializer.Serialize(_records.Values.ToList(), Options));
|
||||
}
|
||||
}
|
||||
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal file
61
src/modules/PowerScripts/PowerScripts.Host/ConsentPrompt.cs
Normal file
@@ -0,0 +1,61 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.InteropServices;
|
||||
using PowerScripts.Core.Manifest;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the trust-on-first-use consent dialog. Because every surface (context menu, Keyboard
|
||||
/// Manager, agents) funnels through <c>Host run <id></c>, this single prompt is the one place a
|
||||
/// user sees, in plain language, exactly what a script is and what it declares it can do before it
|
||||
/// ever executes. A native top-most MessageBox is used so the prompt is visible even when the Host
|
||||
/// was launched hidden by a surface.
|
||||
/// </summary>
|
||||
internal static class ConsentPrompt
|
||||
{
|
||||
private const uint MB_YESNO = 0x00000004;
|
||||
private const uint MB_ICONWARNING = 0x00000030;
|
||||
private const uint MB_DEFBUTTON2 = 0x00000100;
|
||||
private const uint MB_TOPMOST = 0x00040000;
|
||||
private const uint MB_SETFOREGROUND = 0x00010000;
|
||||
private const int IDYES = 6;
|
||||
|
||||
[DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern int MessageBoxW(IntPtr hWnd, string text, string caption, uint type);
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the user approves running this script. Presents the script's identity,
|
||||
/// provenance and declared capabilities so the decision is informed.
|
||||
/// </summary>
|
||||
public static bool Confirm(PowerScriptManifest manifest)
|
||||
{
|
||||
var capabilities = manifest.Capabilities.Count > 0
|
||||
? string.Join(", ", manifest.Capabilities)
|
||||
: "(none declared)";
|
||||
|
||||
var publisher = string.IsNullOrWhiteSpace(manifest.Publisher) ? "(unknown)" : manifest.Publisher;
|
||||
var source = string.IsNullOrWhiteSpace(manifest.Source) ? "(local)" : manifest.Source;
|
||||
|
||||
var text =
|
||||
$"A PowerScript is about to run for the first time (or its contents changed).\n\n" +
|
||||
$"Name: {manifest.Name}\n" +
|
||||
$"Id: {manifest.Id}\n" +
|
||||
$"Publisher: {publisher}\n" +
|
||||
$"Source: {source}\n" +
|
||||
$"Runtime: {manifest.Runtime}\n" +
|
||||
$"Declares: {capabilities}\n" +
|
||||
$"Script file: {manifest.EntryFullPath}\n\n" +
|
||||
"Only allow scripts you trust. Allow this script to run?";
|
||||
|
||||
var result = MessageBoxW(
|
||||
IntPtr.Zero,
|
||||
text,
|
||||
"PowerScripts — allow this script to run?",
|
||||
MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2 | MB_TOPMOST | MB_SETFOREGROUND);
|
||||
|
||||
return result == IDYES;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
615
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
615
src/modules/PowerScripts/PowerScripts.Host/Program.cs
Normal file
@@ -0,0 +1,615 @@
|
||||
// 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;
|
||||
using PowerScripts.Core;
|
||||
using PowerScripts.Core.Execution;
|
||||
using PowerScripts.Core.Manifest;
|
||||
using PowerScripts.Core.Registry;
|
||||
using PowerScripts.Core.Security;
|
||||
|
||||
namespace PowerScripts.Host;
|
||||
|
||||
/// <summary>
|
||||
/// The shared PowerScripts executor / catalogue CLI.
|
||||
///
|
||||
/// This is the single invocation entry point every surface points at:
|
||||
/// - Keyboard Manager maps a hotkey to: PowerScripts.Host.exe run <id>
|
||||
/// - The Explorer context menu invokes: PowerScripts.Host.exe run <id> --files <paths>
|
||||
/// - The KBM editor / agents enumerate via: PowerScripts.Host.exe list --json
|
||||
///
|
||||
/// Usage:
|
||||
/// PowerScripts.Host list [--json] [--root <dir>]
|
||||
/// PowerScripts.Host run <id> [--files <f1> <f2> ...] [--set name=value ...] [--root <dir>]
|
||||
/// </summary>
|
||||
internal static class Program
|
||||
{
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (args.Length == 0)
|
||||
{
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
var (positional, options) = ParseArgs(args.Skip(1).ToArray());
|
||||
var root = options.TryGetValue("root", out var r) ? r.FirstOrDefault() : null;
|
||||
|
||||
var registry = new ScriptRegistry(root);
|
||||
registry.Load();
|
||||
|
||||
return args[0].ToLowerInvariant() switch
|
||||
{
|
||||
"list" => RunList(registry, options.ContainsKey("json")),
|
||||
"run" => RunScript(registry, positional, options),
|
||||
"transform" => RunTransform(registry, positional, options),
|
||||
"trust" => RunTrust(registry, positional),
|
||||
"kbm" => RunKbm(registry, positional, options.ContainsKey("json")),
|
||||
"set-extensions" => RunSetExtensions(registry, positional, options),
|
||||
"shell-menu" => RunShellMenu(registry, options),
|
||||
"shell-install" => ShellRegistration.Install(registry, Environment.ProcessPath ?? "PowerScripts.Host.exe"),
|
||||
"shell-uninstall" => ShellRegistration.Uninstall(registry),
|
||||
"-h" or "--help" or "help" => PrintUsage(),
|
||||
_ => Unknown(args[0]),
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"PowerScripts error: {ex.Message}");
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
private static int RunList(ScriptRegistry registry, bool asJson)
|
||||
{
|
||||
if (asJson)
|
||||
{
|
||||
// Structured, permissioned capability list — also the shape the KBM editor picker and
|
||||
// future agents/MCP servers consume.
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
var projection = registry.Scripts.Select(s =>
|
||||
{
|
||||
// For Python scripts, derive the transform contract from the single
|
||||
// powerscript_from_<input>_to_<output> function so surfaces (e.g. Advanced Paste) know
|
||||
// which data shapes the script consumes/produces without running it.
|
||||
var signature = s.Runtime == ScriptRuntime.Python
|
||||
? PowerScriptPythonConvention.ParseFile(s.EntryFullPath)
|
||||
: null;
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Description,
|
||||
kind = s.Kind.ToString(),
|
||||
runtime = s.Runtime.ToString(),
|
||||
s.Publisher,
|
||||
s.Version,
|
||||
s.Source,
|
||||
s.Surfaces,
|
||||
s.Capabilities,
|
||||
trusted = trustStore.IsTrusted(s.Id, ScriptIntegrity.ComputeHash(s)),
|
||||
input = s.Input,
|
||||
parameters = s.Parameters,
|
||||
transform = signature is null ? null : new
|
||||
{
|
||||
function = signature.FunctionName,
|
||||
inputFormat = signature.Input.ToString(),
|
||||
outputFormat = signature.Output.ToString(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
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 gate = EnsureRunnable(manifest, options, "run");
|
||||
if (gate != 0)
|
||||
{
|
||||
return gate;
|
||||
}
|
||||
|
||||
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>
|
||||
/// The shared enabled + trust gate for any surface that runs a script. Returns 0 when the script
|
||||
/// may run, or a non-zero exit code (with a message on stderr) when it must not. Records trust on
|
||||
/// first interactive approval, exactly like <c>run</c>.
|
||||
/// </summary>
|
||||
private static int EnsureRunnable(
|
||||
PowerScriptManifest manifest,
|
||||
IReadOnlyDictionary<string, List<string>> options,
|
||||
string command)
|
||||
{
|
||||
// Central enabled gate: every surface runs scripts through this path, so a single check here
|
||||
// makes all bindings (Keyboard Manager, context menu, Advanced Paste, future modules) inert
|
||||
// when the user turns PowerScripts off — without deleting or rewriting them.
|
||||
if (!ModuleState.IsPowerScriptsEnabled())
|
||||
{
|
||||
Console.Error.WriteLine($"{command}: PowerScripts is disabled in PowerToys settings; refusing to run. Enable PowerScripts to use this binding.");
|
||||
return 4;
|
||||
}
|
||||
|
||||
// Trust-on-first-use gate. A script only runs once the user has approved its exact current
|
||||
// content, and is re-prompted whenever the script body or its declared capabilities change.
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
var contentHash = ScriptIntegrity.ComputeHash(manifest);
|
||||
if (trustStore.IsTrusted(manifest.Id, contentHash))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var nonInteractive = options.ContainsKey("no-consent")
|
||||
|| string.Equals(Environment.GetEnvironmentVariable("POWERSCRIPTS_NO_CONSENT"), "1", StringComparison.Ordinal);
|
||||
|
||||
if (nonInteractive)
|
||||
{
|
||||
Console.Error.WriteLine($"{command}: script '{manifest.Id}' is not trusted and consent is disabled; refusing to run. Approve it with 'trust approve {manifest.Id}'.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (!ConsentPrompt.Confirm(manifest))
|
||||
{
|
||||
Console.Error.WriteLine($"{command}: user declined to trust script '{manifest.Id}'.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
trustStore.Trust(new TrustRecord
|
||||
{
|
||||
Id = manifest.Id,
|
||||
Hash = contentHash,
|
||||
Capabilities = manifest.Capabilities,
|
||||
Source = manifest.Source,
|
||||
Publisher = manifest.Publisher,
|
||||
ApprovedUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a script as a typed data transform for surfaces like Advanced Paste. Reads a single JSON
|
||||
/// payload from stdin (<c>text</c>, <c>html</c>, <c>image_path</c>, <c>file_paths</c>,
|
||||
/// <c>params</c>), runs the script through the shared runtime, and writes a single JSON result
|
||||
/// object (<c>text</c>, <c>html</c>, <c>image_path</c>, <c>file_paths</c>, ...) to stdout. This
|
||||
/// is the same execution + trust path as <c>run</c>, so a script behaves identically whether it
|
||||
/// is triggered by a hotkey, the context menu, or a paste.
|
||||
/// </summary>
|
||||
private static int RunTransform(
|
||||
ScriptRegistry registry,
|
||||
IReadOnlyList<string> positional,
|
||||
IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("transform: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"transform: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var gate = EnsureRunnable(manifest, options, "transform");
|
||||
if (gate != 0)
|
||||
{
|
||||
return gate;
|
||||
}
|
||||
|
||||
var stdin = Console.In.ReadToEnd();
|
||||
PythonTransformInput input;
|
||||
try
|
||||
{
|
||||
input = string.IsNullOrWhiteSpace(stdin)
|
||||
? new PythonTransformInput()
|
||||
: JsonSerializer.Deserialize<PythonTransformInput>(stdin, TransformJsonOptions) ?? new PythonTransformInput();
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
Console.Error.WriteLine($"transform: invalid input JSON: {ex.Message}");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (manifest.Runtime == ScriptRuntime.Python)
|
||||
{
|
||||
var result = new PythonRuntime().Run(manifest, input);
|
||||
if (!string.IsNullOrEmpty(result.StdErr))
|
||||
{
|
||||
Console.Error.Write(result.StdErr);
|
||||
}
|
||||
|
||||
Console.Out.Write(JsonSerializer.Serialize(result, TransformJsonOptions));
|
||||
return result.ExitCode;
|
||||
}
|
||||
|
||||
// PowerShell transform: hand the text/html in as parameters and any files, and return the
|
||||
// script's stdout as the transformed text.
|
||||
var parameters = new Dictionary<string, string?>
|
||||
{
|
||||
["Text"] = input.Text ?? string.Empty,
|
||||
["Html"] = input.Html ?? string.Empty,
|
||||
};
|
||||
|
||||
var psResult = new ScriptExecutor().Execute(manifest, input.FilePaths, parameters);
|
||||
if (!string.IsNullOrEmpty(psResult.StdErr))
|
||||
{
|
||||
Console.Error.Write(psResult.StdErr);
|
||||
}
|
||||
|
||||
Console.Out.Write(JsonSerializer.Serialize(new PythonTransformResult { Text = psResult.StdOut }, TransformJsonOptions));
|
||||
return psResult.ExitCode;
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions TransformJsonOptions = new()
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Manages the trust store — the record of which script contents the user has approved to run.
|
||||
/// trust list show every approved script id + the content hash approved
|
||||
/// trust approve <id> approve the script's current content without running it
|
||||
/// trust revoke <id> forget approval, so the next run re-prompts
|
||||
/// </summary>
|
||||
private static int RunTrust(ScriptRegistry registry, IReadOnlyList<string> positional)
|
||||
{
|
||||
var sub = positional.Count > 0 ? positional[0].ToLowerInvariant() : "list";
|
||||
var trustStore = new TrustStore(PowerScriptsPaths.TrustFilePath);
|
||||
|
||||
switch (sub)
|
||||
{
|
||||
case "list":
|
||||
if (trustStore.Records.Count == 0)
|
||||
{
|
||||
Console.WriteLine("(no scripts trusted yet)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var record in trustStore.Records.OrderBy(r => r.Id, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.WriteLine($" {record.Id,-24} {record.Hash[..Math.Min(12, record.Hash.Length)]} approved {record.ApprovedUtc:u}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
|
||||
case "approve":
|
||||
{
|
||||
if (positional.Count < 2)
|
||||
{
|
||||
Console.Error.WriteLine("trust approve: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[1]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"trust approve: no script with id '{positional[1]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
trustStore.Trust(new TrustRecord
|
||||
{
|
||||
Id = manifest.Id,
|
||||
Hash = ScriptIntegrity.ComputeHash(manifest),
|
||||
Capabilities = manifest.Capabilities,
|
||||
Source = manifest.Source,
|
||||
Publisher = manifest.Publisher,
|
||||
ApprovedUtc = DateTimeOffset.UtcNow,
|
||||
});
|
||||
|
||||
Console.WriteLine($"trust approve: '{manifest.Id}' approved.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
case "revoke":
|
||||
if (positional.Count < 2)
|
||||
{
|
||||
Console.Error.WriteLine("trust revoke: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (trustStore.Revoke(positional[1]))
|
||||
{
|
||||
Console.WriteLine($"trust revoke: '{positional[1]}' will be re-prompted on next run.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"trust revoke: '{positional[1]}' was not trusted.");
|
||||
return 1;
|
||||
|
||||
default:
|
||||
Console.Error.WriteLine($"trust: unknown subcommand '{sub}'. Use list | approve <id> | revoke <id>.");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the Keyboard Manager "Run Program" mapping for a system PowerScript so a user (or the
|
||||
/// future KBM editor picker) can bind a hotkey to it. KBM's existing RunProgram action already
|
||||
/// supports this — no KBM engine change is needed. The app path + args go straight into the
|
||||
/// editor's "Run Program" fields; <c>--json</c> emits the on-disk mapping shape (the user still
|
||||
/// chooses the trigger keys, so <c>originalKeys</c> is left as a placeholder).
|
||||
/// </summary>
|
||||
private static int RunKbm(ScriptRegistry registry, IReadOnlyList<string> positional, bool asJson)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("kbm: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"kbm: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var hostPath = Environment.ProcessPath ?? "PowerScripts.Host.exe";
|
||||
var programArgs = $"run {manifest.Id}";
|
||||
|
||||
if (asJson)
|
||||
{
|
||||
// Field names match the KBM engine (see common/KeyboardManagerConstants.h /
|
||||
// MappingConfiguration.cpp). Append this to remapShortcutsToRunProgram and set
|
||||
// originalKeys to your chosen trigger (e.g. "162;91;83" for Ctrl+Win+S).
|
||||
var mapping = new Dictionary<string, object>
|
||||
{
|
||||
["originalKeys"] = "<set-your-trigger-keys>",
|
||||
["operationType"] = 1,
|
||||
["runProgramFilePath"] = hostPath,
|
||||
["runProgramArgs"] = programArgs,
|
||||
["runProgramStartInDir"] = string.Empty,
|
||||
["runProgramElevationLevel"] = 0,
|
||||
["runProgramAlreadyRunningAction"] = 0,
|
||||
["runProgramStartWindowType"] = 0,
|
||||
["unicodeText"] = "*Unsupported*",
|
||||
};
|
||||
|
||||
Console.WriteLine(JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }));
|
||||
return 0;
|
||||
}
|
||||
|
||||
Console.WriteLine($"PowerScript '{manifest.Id}' ({manifest.Name}) — Keyboard Manager 'Run Program' action:");
|
||||
Console.WriteLine($" Program: {hostPath}");
|
||||
Console.WriteLine($" Arguments: {programArgs}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("In Keyboard Manager: Remap a shortcut -> action 'Run Program', paste the values above,");
|
||||
Console.WriteLine("then pick the trigger shortcut. (Use 'kbm <id> --json' for the raw mapping object.)");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Emits the file scripts that match a right-clicked selection as tab-separated
|
||||
/// <c><id>\t<name></c> lines (one per script). This is the machine-readable feed the
|
||||
/// Windows 11 modern context-menu handler (IExplorerCommand) consumes to build its submenu; a
|
||||
/// line-based format keeps the native handler free of a JSON parser.
|
||||
/// </summary>
|
||||
private static int RunShellMenu(ScriptRegistry registry, IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
// When the module is disabled, emit nothing so the Explorer submenu has no items to show.
|
||||
if (!ModuleState.IsPowerScriptsEnabled())
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var files = options.TryGetValue("files", out var f) ? f : new List<string>();
|
||||
if (files.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
foreach (var script in registry.FileScriptsForSelection(files))
|
||||
{
|
||||
Console.WriteLine($"{script.Id}\t{script.Name}");
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rewrites a file script's declared input extensions in its manifest.json. This is the write
|
||||
/// side of the Settings "trigger on these file types" editor; the user picks the extensions and
|
||||
/// every surface (context menu, selection matching) then reflects them. System scripts have no
|
||||
/// file input, so they are rejected.
|
||||
/// </summary>
|
||||
private static int RunSetExtensions(
|
||||
ScriptRegistry registry,
|
||||
IReadOnlyList<string> positional,
|
||||
IReadOnlyDictionary<string, List<string>> options)
|
||||
{
|
||||
if (positional.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: missing <id>.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var manifest = registry.Get(positional[0]);
|
||||
if (manifest is null)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: no script with id '{positional[0]}'. Try 'list'.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (manifest.Kind != ScriptKind.File)
|
||||
{
|
||||
Console.Error.WriteLine($"set-extensions: '{manifest.Id}' is a {manifest.Kind} script; extensions only apply to File scripts.");
|
||||
return 1;
|
||||
}
|
||||
|
||||
var raw = options.TryGetValue("ext", out var values) ? values : new List<string>();
|
||||
var normalized = raw
|
||||
.SelectMany(v => v.Split(new[] { ',', ';', ' ' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Select(NormalizeExtension)
|
||||
.Where(e => !string.IsNullOrEmpty(e))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("set-extensions: at least one extension is required (e.g. --ext .md .txt).");
|
||||
return 1;
|
||||
}
|
||||
|
||||
manifest.Input ??= new ScriptInput();
|
||||
manifest.Input.Extensions = normalized;
|
||||
|
||||
var manifestPath = Path.Combine(manifest.FolderPath, PowerScriptsPaths.ManifestFileName);
|
||||
File.WriteAllText(manifestPath, ManifestSerializer.Serialize(manifest));
|
||||
|
||||
Console.WriteLine($"set-extensions: {manifest.Id} -> [{string.Join(", ", normalized)}]");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>Normalizes a user-typed extension to lower-case with a leading dot ("md" -> ".md").</summary>
|
||||
private static string NormalizeExtension(string raw)
|
||||
{
|
||||
var e = raw.Trim().ToLowerInvariant();
|
||||
if (string.IsNullOrEmpty(e) || e == "*")
|
||||
{
|
||||
return e;
|
||||
}
|
||||
|
||||
return e.StartsWith('.') ? e : "." + e;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Minimal parser. Recognizes <c>--name value [value ...]</c> (multi-value, e.g. --files) and
|
||||
/// <c>--flag</c> (no value, e.g. --json). Everything else is positional.
|
||||
/// </summary>
|
||||
private static (List<string> Positional, Dictionary<string, List<string>> Options) ParseArgs(string[] args)
|
||||
{
|
||||
var positional = new List<string>();
|
||||
var options = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
string? current = null;
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
current = arg[2..];
|
||||
if (!options.ContainsKey(current))
|
||||
{
|
||||
options[current] = new List<string>();
|
||||
}
|
||||
}
|
||||
else if (current is not null)
|
||||
{
|
||||
options[current].Add(arg);
|
||||
}
|
||||
else
|
||||
{
|
||||
positional.Add(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return (positional, options);
|
||||
}
|
||||
|
||||
private static int Unknown(string command)
|
||||
{
|
||||
Console.Error.WriteLine($"Unknown command '{command}'.");
|
||||
PrintUsage();
|
||||
return 1;
|
||||
}
|
||||
|
||||
private static int PrintUsage()
|
||||
{
|
||||
Console.WriteLine("PowerScripts.Host — run and enumerate PowerScripts.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(" list [--json] [--root <dir>]");
|
||||
Console.WriteLine(" run <id> [--files <f1> <f2> ...] [--set name=value ...] [--no-consent] [--root <dir>]");
|
||||
Console.WriteLine(" transform <id> [--no-consent] (run as a data transform; JSON payload on stdin, JSON result on stdout)");
|
||||
Console.WriteLine(" trust list | approve <id> | revoke <id> (manage which scripts are allowed to run)");
|
||||
Console.WriteLine(" kbm <id> [--json] [--root <dir>] (Keyboard Manager 'Run Program' mapping)");
|
||||
Console.WriteLine(" set-extensions <id> --ext <.md .txt ...> (set a file script's trigger extensions)");
|
||||
Console.WriteLine(" shell-menu --files <f1> <f2> ... (tab-separated id/name of matching file scripts)");
|
||||
Console.WriteLine(" shell-install [--root <dir>] (register the Explorer right-click submenu)");
|
||||
Console.WriteLine(" shell-uninstall [--root <dir>] (remove the Explorer right-click submenu)");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
146
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
146
src/modules/PowerScripts/PowerScripts.Host/ShellRegistration.cs
Normal file
@@ -0,0 +1,146 @@
|
||||
// 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 "PowerScripts" 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 = "PowerScripts";
|
||||
private const string ClassesRoot = @"Software\Classes\SystemFileAssociations";
|
||||
|
||||
/// <summary>Root under HKCU\Software\Classes that holds the per-extension cascade command trees.</summary>
|
||||
private const string CascadeRoot = @"Software\Classes";
|
||||
private const string CascadeKeyPrefix = "PowerScripts.Cascade.";
|
||||
|
||||
/// <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);
|
||||
|
||||
// Explorer renders a cascading submenu when the verb points at an "ExtendedSubCommandsKey":
|
||||
// a key (relative to HKCU\Software\Classes) whose \shell subtree holds the child commands.
|
||||
// This is the documented per-user pattern and is reliable on Windows 11, unlike an empty
|
||||
// "SubCommands" value which frequently renders nothing.
|
||||
var cascadeName = CascadeKeyPrefix + ext.TrimStart('.');
|
||||
|
||||
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);
|
||||
verbKey.SetValue("ExtendedSubCommandsKey", cascadeName);
|
||||
}
|
||||
|
||||
var cascadeShellPath = $@"{CascadeRoot}\{cascadeName}\shell";
|
||||
using var subShell = Microsoft.Win32.Registry.CurrentUser.CreateSubKey(cascadeShellPath)!;
|
||||
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 not null)
|
||||
{
|
||||
// Only delete the verb if we own it.
|
||||
var owned = false;
|
||||
using (var verbKey = shellKey.OpenSubKey(RootVerb))
|
||||
{
|
||||
owned = verbKey is not null && verbKey.GetValue(OwnerMarkerName) is not null;
|
||||
}
|
||||
|
||||
if (owned)
|
||||
{
|
||||
shellKey.DeleteSubKeyTree(RootVerb, throwOnMissingSubKey: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the associated cascade command tree.
|
||||
var cascadeName = CascadeKeyPrefix + ext.TrimStart('.');
|
||||
using var cascadeParent = Microsoft.Win32.Registry.CurrentUser.OpenSubKey(CascadeRoot, writable: true);
|
||||
cascadeParent?.DeleteSubKeyTree(cascadeName, 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"PowerScripts", name); }
|
||||
IFACEMETHODIMP GetIcon(IShellItemArray*, PWSTR* icon) override { *icon = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetToolTip(IShellItemArray*, PWSTR* tip) override { *tip = nullptr; return E_NOTIMPL; }
|
||||
IFACEMETHODIMP GetCanonicalName(GUID* guid) override { *guid = GUID_NULL; return S_OK; }
|
||||
|
||||
// Called before EnumSubCommands on the same instance; we use it to compute (and cache) the
|
||||
// matching scripts and to hide the entry when nothing matches.
|
||||
IFACEMETHODIMP GetState(IShellItemArray* selection, BOOL, EXPCMDSTATE* state) override
|
||||
{
|
||||
m_files = ExtractPaths(selection);
|
||||
m_entries.clear();
|
||||
|
||||
if (!m_files.empty())
|
||||
{
|
||||
const std::wstring output = RunHostCapture(L"shell-menu --files" + BuildFilesArguments(m_files));
|
||||
m_entries = ParseMenu(output);
|
||||
}
|
||||
|
||||
*state = m_entries.empty() ? ECS_HIDDEN : ECS_ENABLED;
|
||||
return S_OK;
|
||||
}
|
||||
|
||||
IFACEMETHODIMP GetFlags(EXPCMDFLAGS* flags) override { *flags = ECF_HASSUBCOMMANDS; return S_OK; }
|
||||
|
||||
IFACEMETHODIMP EnumSubCommands(IEnumExplorerCommand** enumerator) override
|
||||
{
|
||||
*enumerator = nullptr;
|
||||
|
||||
std::vector<ComPtr<IExplorerCommand>> commands;
|
||||
for (const auto& entry : m_entries)
|
||||
{
|
||||
commands.push_back(Make<PowerScriptSubCommand>(entry.Id, entry.Name, m_files));
|
||||
}
|
||||
|
||||
auto enumObject = Make<PowerScriptEnum>(std::move(commands));
|
||||
return enumObject.CopyTo(enumerator);
|
||||
}
|
||||
|
||||
IFACEMETHODIMP Invoke(IShellItemArray*, IBindCtx*) override { return S_OK; }
|
||||
|
||||
// IObjectWithSite
|
||||
IFACEMETHODIMP SetSite(IUnknown* site) override { m_site = site; return S_OK; }
|
||||
IFACEMETHODIMP GetSite(REFIID riid, void** ppv) override { return m_site.CopyTo(riid, ppv); }
|
||||
|
||||
private:
|
||||
ComPtr<IUnknown> m_site;
|
||||
std::vector<std::wstring> m_files;
|
||||
std::vector<ScriptEntry> m_entries;
|
||||
};
|
||||
|
||||
CoCreatableClass(PowerScriptCommand);
|
||||
|
||||
STDAPI DllGetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ IActivationFactory** factory)
|
||||
{
|
||||
return Module<ModuleType::InProc>::GetModule().GetActivationFactory(activatableClassId, factory);
|
||||
}
|
||||
|
||||
STDAPI DllCanUnloadNow()
|
||||
{
|
||||
return (Module<InProc>::GetModule().GetObjectCount() == 0 && g_refModule == 0) ? S_OK : S_FALSE;
|
||||
}
|
||||
|
||||
STDAPI DllGetClassObject(_In_ REFCLSID rclsid, _In_ REFIID riid, _COM_Outptr_ void** ppv)
|
||||
{
|
||||
return Module<InProc>::GetModule().GetClassObject(rclsid, riid, ppv);
|
||||
}
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID)
|
||||
{
|
||||
switch (reason)
|
||||
{
|
||||
case DLL_PROCESS_ATTACH:
|
||||
g_hModule = hModule;
|
||||
DisableThreadLibraryCalls(hModule);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
@@ -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). 27 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"
|
||||
}
|
||||
14
src/modules/PowerScripts/samples/py_beep/manifest.json
Normal file
14
src/modules/PowerScripts/samples/py_beep/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "py_beep",
|
||||
"name": "Beep (Python)",
|
||||
"description": "Play a short beep. A Python system PowerScript you can bind to a hotkey from Keyboard Manager.",
|
||||
"kind": "system",
|
||||
"runtime": "python",
|
||||
"entry": "run.py",
|
||||
"publisher": "PowerToys samples",
|
||||
"version": "1.0.0",
|
||||
"surfaces": ["keyboardManager", "commandPalette"],
|
||||
"capabilities": ["systemControl"],
|
||||
"elevation": "asInvoker"
|
||||
}
|
||||
24
src/modules/PowerScripts/samples/py_beep/run.py
Normal file
24
src/modules/PowerScripts/samples/py_beep/run.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# 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.
|
||||
|
||||
# @powerscript:name Beep (Python)
|
||||
# @powerscript:desc Play a short beep
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def powerscript_from_none_to_none():
|
||||
"""Play a short beep.
|
||||
|
||||
This is a Python *system* PowerScript: it takes no clipboard/file input and
|
||||
produces no output (``none`` -> ``none``), so it is a good fit for a Keyboard
|
||||
Manager hotkey. winsound is Windows-only stdlib; fall back to the terminal
|
||||
bell elsewhere so the script still runs.
|
||||
"""
|
||||
try:
|
||||
import winsound
|
||||
|
||||
winsound.Beep(880, 200)
|
||||
except Exception:
|
||||
sys.stdout.write("\a")
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "py_uppercase_files",
|
||||
"name": "Uppercase File (Python)",
|
||||
"description": "Write an UPPERCASED copy of the selected text file(s) next to the original. A Python file PowerScript for the Explorer right-click menu.",
|
||||
"kind": "file",
|
||||
"runtime": "python",
|
||||
"entry": "run.py",
|
||||
"publisher": "PowerToys samples",
|
||||
"version": "1.0.0",
|
||||
"input": {
|
||||
"extensions": [".txt", ".md"],
|
||||
"minFiles": 1,
|
||||
"maxFiles": 0
|
||||
},
|
||||
"output": {
|
||||
"type": "convertedFile",
|
||||
"extension": ".UPPER.txt"
|
||||
},
|
||||
"surfaces": ["contextMenu"],
|
||||
"capabilities": ["fileRead", "fileWrite"],
|
||||
"elevation": "asInvoker"
|
||||
}
|
||||
28
src/modules/PowerScripts/samples/py_uppercase_files/run.py
Normal file
28
src/modules/PowerScripts/samples/py_uppercase_files/run.py
Normal file
@@ -0,0 +1,28 @@
|
||||
# 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.
|
||||
|
||||
# @powerscript:name Uppercase File (Python)
|
||||
# @powerscript:desc Write an UPPERCASED copy of each selected text file
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def powerscript_from_files_to_files(file_paths):
|
||||
"""Write an uppercased copy of each selected file next to the original.
|
||||
|
||||
The function name follows the PowerScripts convention
|
||||
``powerscript_from_<input>_to_<output>``. Because it consumes files and
|
||||
produces files, the Explorer right-click menu passes the selected paths in
|
||||
and PowerScripts reports the produced paths back.
|
||||
"""
|
||||
outputs = []
|
||||
for path in file_paths:
|
||||
root, _ext = os.path.splitext(path)
|
||||
out_path = root + ".UPPER.txt"
|
||||
with open(path, "r", encoding="utf-8") as source:
|
||||
content = source.read()
|
||||
with open(out_path, "w", encoding="utf-8") as target:
|
||||
target.write(content.upper())
|
||||
outputs.append(out_path)
|
||||
return outputs
|
||||
@@ -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
|
||||
14
src/modules/PowerScripts/samples/uppercase/manifest.json
Normal file
14
src/modules/PowerScripts/samples/uppercase/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "uppercase",
|
||||
"name": "Uppercase Text",
|
||||
"description": "Convert clipboard text to UPPERCASE. A Python PowerScript usable from Advanced Paste or a hotkey.",
|
||||
"kind": "system",
|
||||
"runtime": "python",
|
||||
"entry": "run.py",
|
||||
"publisher": "PowerToys samples",
|
||||
"version": "1.0.0",
|
||||
"surfaces": ["advancedPaste", "keyboardManager"],
|
||||
"capabilities": [],
|
||||
"elevation": "asInvoker"
|
||||
}
|
||||
16
src/modules/PowerScripts/samples/uppercase/run.py
Normal file
16
src/modules/PowerScripts/samples/uppercase/run.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# 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.
|
||||
|
||||
# @powerscript:name Uppercase Text
|
||||
# @powerscript:desc Convert clipboard text to UPPERCASE
|
||||
|
||||
|
||||
def powerscript_from_text_to_text(text: str) -> str:
|
||||
"""Return the input text converted to uppercase.
|
||||
|
||||
The function name follows the PowerScripts convention
|
||||
``powerscript_from_<input>_to_<output>``: it consumes clipboard text and
|
||||
produces text, so Advanced Paste offers it whenever the clipboard has text.
|
||||
"""
|
||||
return text.upper()
|
||||
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>
|
||||
@@ -819,6 +863,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
ActionType.Text => !string.IsNullOrEmpty(TextContentBox?.Text),
|
||||
ActionType.OpenUrl => !string.IsNullOrWhiteSpace(UrlPathInput?.Text),
|
||||
ActionType.OpenApp => !string.IsNullOrWhiteSpace(ProgramPathInput?.Text),
|
||||
ActionType.PowerScript => GetSelectedPowerScript() != null,
|
||||
ActionType.Disable => true,
|
||||
_ => false,
|
||||
};
|
||||
@@ -877,6 +922,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
ActionType.OpenApp => "OpenApp",
|
||||
ActionType.Disable => "Disable",
|
||||
ActionType.MouseClick => "MouseClick",
|
||||
ActionType.PowerScript => "PowerScript",
|
||||
_ => "KeyOrShortcut",
|
||||
};
|
||||
|
||||
|
||||
@@ -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,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace KeyboardManagerEditorUI.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// A saved Keyboard Manager hotkey that runs a PowerScript. Although it is persisted as a
|
||||
/// "Run Program" mapping (the engine's execution primitive), it is presented in the editor as a
|
||||
/// first-class PowerScript action so the user sees "PowerScript: <name>" rather than a raw
|
||||
/// program-path card.
|
||||
/// </summary>
|
||||
public class PowerScriptShortcut : IToggleableShortcut
|
||||
{
|
||||
public List<string> Shortcut { get; set; } = new List<string>();
|
||||
|
||||
/// <summary>The PowerScript id this hotkey runs.</summary>
|
||||
public string ScriptId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>The PowerScript's friendly name, for display.</summary>
|
||||
public string ScriptName { get; set; } = string.Empty;
|
||||
|
||||
public bool IsActive { get; set; } = true;
|
||||
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string AppName { get; set; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
// 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>();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records the user's trust for a script's current content, so the engine can run it silently
|
||||
/// via <c>Host.exe run <id> --no-consent</c>. Assigning a script to a hotkey is itself an
|
||||
/// explicit consent, so we approve it here rather than popping a dialog from the (hidden)
|
||||
/// engine-launched process. If the script's contents later change, the non-interactive run
|
||||
/// simply no-ops until the user re-assigns it.
|
||||
/// </summary>
|
||||
public static void ApproveTrust(string id)
|
||||
{
|
||||
var hostPath = ResolveHostPath();
|
||||
if (hostPath is null || string.IsNullOrEmpty(id))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = hostPath,
|
||||
Arguments = $"trust approve {id}",
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
process?.WaitForExit(5000);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Prototype: if trust can't be recorded the hotkey just won't run; not fatal.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when a Keyboard Manager "Run Program" mapping actually launches a PowerScript, i.e. its
|
||||
/// program path is <c>PowerScripts.Host.exe</c>. Used to present these mappings as first-class
|
||||
/// PowerScript actions instead of raw run-program cards.
|
||||
/// </summary>
|
||||
public static bool IsPowerScriptProgramPath(string? programPath) =>
|
||||
!string.IsNullOrEmpty(programPath) &&
|
||||
string.Equals(Path.GetFileName(programPath), HostExeName, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the script id from a PowerScript mapping's arguments (<c>run <id> ...</c>),
|
||||
/// or null if the arguments aren't a recognizable PowerScript run command.
|
||||
/// </summary>
|
||||
public static string? ParseScriptId(string? programArgs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(programArgs))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var tokens = programArgs.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokens.Length >= 2 && string.Equals(tokens[0], "run", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return tokens[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Resolves a script's display name from its id, falling back to the id itself.</summary>
|
||||
public static string GetScriptName(string id)
|
||||
{
|
||||
foreach (var script in GetSystemScripts())
|
||||
{
|
||||
if (string.Equals(script.Id, id, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return script.Name;
|
||||
}
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -452,6 +452,89 @@
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
|
||||
<!-- PowerScripts Section -->
|
||||
<StackPanel Orientation="Vertical" Visibility="{x:Bind PowerScriptShortcuts.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
|
||||
<TextBlock
|
||||
Margin="16,16,0,8"
|
||||
Style="{StaticResource BodyStrongTextBlockStyle}"
|
||||
Text="PowerScripts" />
|
||||
<Rectangle
|
||||
Height="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Fill="{ThemeResource CardStrokeColorDefaultBrush}" />
|
||||
|
||||
<ListView
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="PowerScriptShortcutsList_ItemClick"
|
||||
ItemsSource="{x:Bind PowerScriptShortcuts}"
|
||||
ScrollViewer.VerticalScrollMode="Disabled"
|
||||
SelectionMode="None">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="helper:PowerScriptShortcut">
|
||||
<Grid MinHeight="48" Padding="0,8,0,8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Rectangle Style="{StaticResource ItemDividerStyle}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<ItemsControl VerticalAlignment="Center" ItemsSource="{x:Bind Shortcut}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<commoncontrols:KeyVisual Content="{Binding}" Style="{StaticResource OriginalKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="runs PowerScript" />
|
||||
<FontIcon
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="SemiBold"
|
||||
Text="{x:Bind ScriptName}" />
|
||||
</StackPanel>
|
||||
<StackPanel
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
<ToggleSwitch
|
||||
IsOn="{x:Bind IsActive}"
|
||||
Style="{StaticResource RightAlignedCompactToggleSwitchStyle}"
|
||||
Toggled="ToggleSwitch_Toggled" />
|
||||
<Button
|
||||
VerticalAlignment="Center"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=14}"
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuFlyoutItem
|
||||
x:Uid="DeleteMenuItem"
|
||||
Click="DeleteMapping_Click"
|
||||
Icon="{ui:FontIcon Glyph=,
|
||||
FontSize=14}"
|
||||
Tag="{x:Bind}" />
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</StackPanel>
|
||||
|
||||
<!-- URLs Section -->
|
||||
<StackPanel Orientation="Vertical" Visibility="{x:Bind UrlShortcuts.Count, Mode=OneWay, Converter={StaticResource CountToVisibilityConverter}}">
|
||||
<TextBlock
|
||||
|
||||
@@ -79,6 +79,8 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
|
||||
public ObservableCollection<ProgramShortcut> ProgramShortcuts { get; } = new();
|
||||
|
||||
public ObservableCollection<PowerScriptShortcut> PowerScriptShortcuts { get; } = new();
|
||||
|
||||
public ObservableCollection<URLShortcut> UrlShortcuts { get; } = new();
|
||||
|
||||
[DllImport("PowerToys.KeyboardManagerEditorLibraryWrapper.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)]
|
||||
@@ -92,6 +94,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
TextMapping,
|
||||
ProgramShortcut,
|
||||
UrlShortcut,
|
||||
PowerScriptShortcut,
|
||||
}
|
||||
|
||||
public ItemType Type { get; set; }
|
||||
@@ -305,6 +308,31 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
private async void PowerScriptShortcutsList_ItemClick(object sender, ItemClickEventArgs e)
|
||||
{
|
||||
if (e.ClickedItem is not PowerScriptShortcut powerScriptShortcut)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isEditMode = true;
|
||||
_editingItem = new EditingItem
|
||||
{
|
||||
Type = EditingItem.ItemType.PowerScriptShortcut,
|
||||
Item = powerScriptShortcut,
|
||||
OriginalTriggerKeys = powerScriptShortcut.Shortcut.ToList(),
|
||||
AppName = powerScriptShortcut.AppName,
|
||||
IsAllApps = true,
|
||||
};
|
||||
|
||||
UnifiedMappingControl.Reset();
|
||||
UnifiedMappingControl.SetTriggerKeys(powerScriptShortcut.Shortcut.ToList());
|
||||
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.PowerScript);
|
||||
UnifiedMappingControl.SelectPowerScript(powerScriptShortcut.ScriptId);
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ShowRemappingDialog()
|
||||
{
|
||||
RemappingDialog.PrimaryButtonClick += RemappingDialog_PrimaryButtonClick;
|
||||
@@ -394,6 +422,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 +468,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 +716,52 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
return saved;
|
||||
}
|
||||
|
||||
private bool SavePowerScriptMapping(List<string> triggerKeys)
|
||||
{
|
||||
var script = UnifiedMappingControl.GetSelectedPowerScript();
|
||||
if (script is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string hostPath = PowerScriptsCatalog.ResolveHostPath() ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(hostPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string originalKeysString = string.Join(";", triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
// Persisted as a "Run Program" mapping (the engine's execution primitive) but presented in
|
||||
// the editor as a dedicated PowerScript action. Assigning the hotkey is explicit consent,
|
||||
// so we record trust below and run non-interactively (--no-consent) — the engine launches
|
||||
// the Host hidden, where a consent dialog could not be reliably shown.
|
||||
var shortcutKeyMapping = new ShortcutKeyMapping
|
||||
{
|
||||
OperationType = ShortcutOperationType.RunProgram,
|
||||
OriginalKeys = originalKeysString,
|
||||
TargetKeys = originalKeysString,
|
||||
ProgramPath = hostPath,
|
||||
ProgramArgs = $"run {script.Id} --no-consent",
|
||||
StartInDirectory = string.Empty,
|
||||
IfRunningAction = ProgramAlreadyRunningAction.StartAnother,
|
||||
Visibility = StartWindowType.Hidden,
|
||||
Elevation = ElevationLevel.NonElevated,
|
||||
TargetApp = string.Empty,
|
||||
};
|
||||
|
||||
bool saved = _mappingService!.AddShortcutMapping(shortcutKeyMapping);
|
||||
if (saved)
|
||||
{
|
||||
// Record trust for the script's current content so the engine can run it silently.
|
||||
PowerScriptsCatalog.ApproveTrust(script.Id);
|
||||
_mappingService.SaveSettings();
|
||||
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
|
||||
}
|
||||
|
||||
return saved;
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delete Handlers
|
||||
@@ -881,13 +960,14 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
LoadRemappings();
|
||||
LoadTextMappings();
|
||||
LoadProgramShortcuts();
|
||||
LoadPowerScriptShortcuts();
|
||||
LoadUrlShortcuts();
|
||||
UpdateHasAnyMappings();
|
||||
}
|
||||
|
||||
private void UpdateHasAnyMappings()
|
||||
{
|
||||
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
|
||||
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || PowerScriptShortcuts.Count > 0 || UrlShortcuts.Count > 0;
|
||||
MappingState = hasAny ? "HasMappings" : "Empty";
|
||||
}
|
||||
|
||||
@@ -976,6 +1056,14 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
|
||||
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
|
||||
|
||||
// PowerScript hotkeys are stored as Run-Program mappings but shown in their own
|
||||
// dedicated section, so skip them here.
|
||||
if (PowerScriptsCatalog.IsPowerScriptProgramPath(mapping.ProgramPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
|
||||
|
||||
ProgramShortcuts.Add(new ProgramShortcut
|
||||
@@ -995,6 +1083,47 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadPowerScriptShortcuts()
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RunProgram, out var remapShortcutIds);
|
||||
|
||||
PowerScriptShortcuts.Clear();
|
||||
|
||||
if (remapShortcutIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var id in remapShortcutIds)
|
||||
{
|
||||
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
|
||||
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
|
||||
|
||||
if (!PowerScriptsCatalog.IsPowerScriptProgramPath(mapping.ProgramPath))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var scriptId = PowerScriptsCatalog.ParseScriptId(mapping.ProgramArgs);
|
||||
if (string.IsNullOrEmpty(scriptId))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
|
||||
|
||||
PowerScriptShortcuts.Add(new PowerScriptShortcut
|
||||
{
|
||||
Shortcut = originalKeyNames,
|
||||
ScriptId = scriptId,
|
||||
ScriptName = PowerScriptsCatalog.GetScriptName(scriptId),
|
||||
IsActive = shortcutSettings.IsActive,
|
||||
Id = shortcutSettings.Id,
|
||||
AppName = mapping.TargetApp ?? string.Empty,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadUrlShortcuts()
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.OpenUri, out var remapShortcutIds);
|
||||
|
||||
@@ -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,162 @@
|
||||
<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="Python runtime" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard
|
||||
Description="Run Python PowerScripts (scripts whose manifest sets "runtime": "python") natively on Windows or inside WSL."
|
||||
Header="Python execution"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ComboBox MinWidth="160" SelectedIndex="{x:Bind ViewModel.PythonModeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem Content="Disabled" />
|
||||
<ComboBoxItem Content="Windows" />
|
||||
<ComboBoxItem Content="WSL" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard
|
||||
Description="Optional. Leave empty to auto-detect via the Python launcher (py.exe) or python.exe on PATH."
|
||||
Header="Python interpreter path"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
Visibility="{x:Bind ViewModel.IsWindowsMode, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.PythonInterpreterPath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
x:Name="BrowsePythonInterpreterButton"
|
||||
VerticalAlignment="Center"
|
||||
Click="BrowsePythonInterpreterButton_Click"
|
||||
Content="Browse..." />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard
|
||||
Description="The WSL distribution scripts run in. Leave empty to use the default distribution."
|
||||
Header="WSL distribution"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
Visibility="{x:Bind ViewModel.IsWslMode, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<ComboBox
|
||||
MinWidth="200"
|
||||
IsEditable="True"
|
||||
ItemsSource="{x:Bind ViewModel.WslDistributions, Mode=OneWay}"
|
||||
Text="{x:Bind ViewModel.WslDistribution, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup Header="Installed scripts" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<InfoBar
|
||||
IsClosable="False"
|
||||
IsOpen="{x:Bind ViewModel.HasScripts, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
|
||||
Message="No PowerScripts found. Pick a scripts folder above (or use the default) that contains script subfolders, each with a manifest.json."
|
||||
Severity="Informational" />
|
||||
|
||||
<ItemsControl ItemsSource="{x:Bind ViewModel.Scripts, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="viewModels:PowerScriptListItem">
|
||||
<tkcontrols:SettingsExpander
|
||||
Margin="0,2,0,2"
|
||||
Description="{x:Bind Description}"
|
||||
Header="{x:Bind Name}"
|
||||
IsExpanded="False">
|
||||
<tkcontrols:SettingsExpander.HeaderIcon>
|
||||
<FontIcon Glyph="{x:Bind KindGlyph}" />
|
||||
</tkcontrols:SettingsExpander.HeaderIcon>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind Kind}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard
|
||||
ContentAlignment="Right"
|
||||
Header="Trigger on file types"
|
||||
Visibility="{x:Bind IsFileScript, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind ExtensionsDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Runtime">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind RuntimeDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Surfaces">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind SurfacesDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Capabilities">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind CapabilitiesDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Right" Header="Trust">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind TrustDisplay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
|
||||
<controls:SettingsPageControl.PrimaryLinks>
|
||||
<controls:PageLink Link="https://aka.ms/PowerToysOverview" Text="Documentation" />
|
||||
</controls:SettingsPageControl.PrimaryLinks>
|
||||
</controls:SettingsPageControl>
|
||||
</local:NavigablePage>
|
||||
@@ -0,0 +1,93 @@
|
||||
// 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.Runtime.InteropServices;
|
||||
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 void BrowsePythonInterpreterButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var file = PickFileDialog(
|
||||
"Python Executable\0python.exe;python3.exe;py.exe\0All Executables\0*.exe\0",
|
||||
"Select Python interpreter");
|
||||
if (!string.IsNullOrWhiteSpace(file))
|
||||
{
|
||||
ViewModel.SetPythonInterpreterPath(file);
|
||||
}
|
||||
}
|
||||
|
||||
// Uses the Win32 OpenFileName dialog as FileOpenPicker doesn't work when Settings runs elevated.
|
||||
private static string PickFileDialog(string filter, string title)
|
||||
{
|
||||
OpenFileName openFileName = new OpenFileName();
|
||||
openFileName.StructSize = Marshal.SizeOf(openFileName);
|
||||
openFileName.Filter = filter;
|
||||
|
||||
// Make buffer double MAX_PATH since it can use 2 chars per char.
|
||||
openFileName.File = new string(new char[260 * 2]);
|
||||
openFileName.MaxFile = openFileName.File.Length;
|
||||
openFileName.FileTitle = new string(new char[260 * 2]);
|
||||
openFileName.MaxFileTitle = openFileName.FileTitle.Length;
|
||||
openFileName.Title = title;
|
||||
openFileName.DefExt = null;
|
||||
openFileName.Flags = (int)OpenFileNameFlags.OFN_NOCHANGEDIR;
|
||||
openFileName.Hwnd = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
|
||||
|
||||
return NativeMethods.GetOpenFileName(openFileName) ? openFileName.File : null;
|
||||
}
|
||||
|
||||
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,71 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
/// <summary>
|
||||
/// A single PowerScript shown in the Settings list. This is a read-only projection of the
|
||||
/// script's <c>manifest.json</c> (the source of truth), as emitted by
|
||||
/// <c>PowerScripts.Host.exe list --json</c>. The Settings page only displays this information;
|
||||
/// authors change it by editing the manifest.
|
||||
/// </summary>
|
||||
public sealed class PowerScriptListItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
public string Description { get; set; } = string.Empty;
|
||||
|
||||
public string Kind { get; set; } = string.Empty;
|
||||
|
||||
public string Runtime { get; set; } = string.Empty;
|
||||
|
||||
public PowerScriptInput Input { get; set; }
|
||||
|
||||
public List<string> Surfaces { get; set; } = new();
|
||||
|
||||
public List<string> Capabilities { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// True once the user has approved this script's current content to run (trust-on-first-use).
|
||||
/// Emitted by the Host as <c>trusted</c>; recomputed from the script's content hash, so it
|
||||
/// flips back to false if the script body or its declared capabilities change.
|
||||
/// </summary>
|
||||
public bool Trusted { get; set; }
|
||||
|
||||
public string KindGlyph => string.Equals(Kind, "file", StringComparison.OrdinalIgnoreCase)
|
||||
? "\uE8A5" // file action
|
||||
: "\uE756"; // system action
|
||||
|
||||
/// <summary>True for file scripts, which can be triggered from the Explorer right-click menu.</summary>
|
||||
public bool IsFileScript => string.Equals(Kind, "file", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Comma-separated trigger extensions declared in the manifest (file scripts only).</summary>
|
||||
public string ExtensionsDisplay => Input?.Extensions is { Count: > 0 } exts
|
||||
? string.Join(", ", exts)
|
||||
: "—";
|
||||
|
||||
/// <summary>Comma-separated list of the surfaces this script appears on.</summary>
|
||||
public string SurfacesDisplay => Surfaces is { Count: > 0 }
|
||||
? string.Join(", ", Surfaces)
|
||||
: "—";
|
||||
|
||||
/// <summary>Comma-separated list of the capabilities the script declares.</summary>
|
||||
public string CapabilitiesDisplay => Capabilities is { Count: > 0 }
|
||||
? string.Join(", ", Capabilities)
|
||||
: "—";
|
||||
|
||||
/// <summary>Friendly runtime label (e.g. "PowerShell").</summary>
|
||||
public string RuntimeDisplay => string.IsNullOrEmpty(Runtime) ? "—" : Runtime;
|
||||
|
||||
/// <summary>Human-readable trust state shown in the Settings list.</summary>
|
||||
public string TrustDisplay => Trusted
|
||||
? "Trusted"
|
||||
: "Not yet trusted — you'll be asked to allow it the first time it runs";
|
||||
}
|
||||
}
|
||||
550
src/settings-ui/Settings.UI/ViewModels/PowerScriptsViewModel.cs
Normal file
550
src/settings-ui/Settings.UI/ViewModels/PowerScriptsViewModel.cs
Normal file
@@ -0,0 +1,550 @@
|
||||
// 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;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
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;
|
||||
private int _pythonModeIndex;
|
||||
private string _pythonInterpreterPath;
|
||||
private string _wslDistribution;
|
||||
|
||||
public PowerScriptsViewModel(ISettingsRepository<GeneralSettings> generalSettingsRepository, Func<string, int> sendConfigMsg)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(generalSettingsRepository);
|
||||
|
||||
_generalSettingsRepository = generalSettingsRepository;
|
||||
_sendConfigMsg = sendConfigMsg;
|
||||
|
||||
// The module-owned config.json override (if present) is authoritative, since the runner
|
||||
// strips PowerScripts from settings.json on launch; fall back to the settings flag.
|
||||
_isEnabled = ReadEnabledOverride() ?? generalSettingsRepository.SettingsConfig.Enabled.PowerScripts;
|
||||
_scriptsFolder = ResolveScriptsFolder();
|
||||
|
||||
Scripts = new ObservableCollection<PowerScriptListItem>();
|
||||
WslDistributions = new ObservableCollection<string>();
|
||||
|
||||
LoadPythonSettings();
|
||||
LoadWslDistributions();
|
||||
ReloadScripts();
|
||||
}
|
||||
|
||||
public ObservableCollection<PowerScriptListItem> Scripts { get; }
|
||||
|
||||
/// <summary>The WSL distributions detected via <c>wsl.exe -l -q</c>, offered when Python runs in WSL mode.</summary>
|
||||
public ObservableCollection<string> WslDistributions { 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());
|
||||
}
|
||||
|
||||
// Also persist the enabled state into the module's own config.json. The runner
|
||||
// rewrites settings.json on launch and drops entries for modules it does not host
|
||||
// (the prototype is not yet a registered runner module), so the config.json flag is
|
||||
// the authoritative, restart-durable gate that every surface (hotkey, context menu,
|
||||
// Advanced Paste) honors.
|
||||
SaveEnabledOverride(value);
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Python execution mode: 0 = Disabled, 1 = Windows, 2 = WSL. Persisted to the shared
|
||||
/// <c>config.json</c>'s <c>python.mode</c> so the Host runs Python PowerScripts the same way from
|
||||
/// every surface (a hotkey, the context menu, or Advanced Paste).
|
||||
/// </summary>
|
||||
public int PythonModeIndex
|
||||
{
|
||||
get => _pythonModeIndex;
|
||||
set
|
||||
{
|
||||
if (_pythonModeIndex != value)
|
||||
{
|
||||
_pythonModeIndex = value;
|
||||
SavePythonSettings();
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(nameof(IsWindowsMode));
|
||||
OnPropertyChanged(nameof(IsWslMode));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWindowsMode => _pythonModeIndex == 1;
|
||||
|
||||
public bool IsWslMode => _pythonModeIndex == 2;
|
||||
|
||||
/// <summary>Optional explicit Windows interpreter path; empty means auto-detect (py.exe / python.exe).</summary>
|
||||
public string PythonInterpreterPath
|
||||
{
|
||||
get => _pythonInterpreterPath;
|
||||
set
|
||||
{
|
||||
var normalized = value ?? string.Empty;
|
||||
if (_pythonInterpreterPath != normalized)
|
||||
{
|
||||
_pythonInterpreterPath = normalized;
|
||||
SavePythonSettings();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The WSL distribution scripts run in; empty means the default distribution.</summary>
|
||||
public string WslDistribution
|
||||
{
|
||||
get => _wslDistribution;
|
||||
set
|
||||
{
|
||||
var normalized = value ?? string.Empty;
|
||||
if (_wslDistribution != normalized)
|
||||
{
|
||||
_wslDistribution = normalized;
|
||||
SavePythonSettings();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void SetPythonInterpreterPath(string path)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
PythonInterpreterPath = path.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var normalized = string.IsNullOrWhiteSpace(folder) ? string.Empty : folder.Trim();
|
||||
var config = LoadConfigNode();
|
||||
config["scriptsRoot"] = normalized;
|
||||
WriteConfigNode(config);
|
||||
}
|
||||
|
||||
/// <summary>Reads the module <c>config.json</c> into a mutable node, preserving unknown keys.</summary>
|
||||
private static JsonObject LoadConfigNode()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(ConfigFilePath))
|
||||
{
|
||||
var text = File.ReadAllText(ConfigFilePath);
|
||||
if (JsonNode.Parse(text) is JsonObject existing)
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt config is replaced rather than blocking the write.
|
||||
}
|
||||
|
||||
return new JsonObject();
|
||||
}
|
||||
|
||||
private static void WriteConfigNode(JsonObject config)
|
||||
{
|
||||
Directory.CreateDirectory(ModuleDirectory);
|
||||
File.WriteAllText(ConfigFilePath, config.ToJsonString(WriteJsonOptions));
|
||||
}
|
||||
|
||||
/// <summary>Reads the module-owned enabled override from config.json, or null when absent.</summary>
|
||||
private static bool? ReadEnabledOverride()
|
||||
{
|
||||
try
|
||||
{
|
||||
var config = LoadConfigNode();
|
||||
if (config["enabled"] is JsonValue value && value.TryGetValue<bool>(out var enabled))
|
||||
{
|
||||
return enabled;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// A corrupt/unreadable config yields no override.
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Persists the enabled override into config.json, preserving all other keys.</summary>
|
||||
private static void SaveEnabledOverride(bool enabled)
|
||||
{
|
||||
var config = LoadConfigNode();
|
||||
config["enabled"] = enabled;
|
||||
WriteConfigNode(config);
|
||||
}
|
||||
|
||||
private void LoadPythonSettings()
|
||||
{
|
||||
_pythonModeIndex = 0;
|
||||
_pythonInterpreterPath = string.Empty;
|
||||
_wslDistribution = string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
var config = LoadConfigNode();
|
||||
if (config["python"] is JsonObject python)
|
||||
{
|
||||
var mode = python["mode"]?.GetValue<string>() ?? "disabled";
|
||||
_pythonModeIndex = mode.ToLowerInvariant() switch
|
||||
{
|
||||
"windows" => 1,
|
||||
"wsl" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
_pythonInterpreterPath = python["windows"]?["interpreterPath"]?.GetValue<string>() ?? string.Empty;
|
||||
_wslDistribution = python["wsl"]?["distribution"]?.GetValue<string>() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Missing/corrupt config leaves Python disabled with default paths.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists the Python section into <c>config.json</c>, preserving <c>scriptsRoot</c> and any
|
||||
/// timeout the Host may have written. Mode is stored as "disabled"/"windows"/"wsl".
|
||||
/// </summary>
|
||||
private void SavePythonSettings()
|
||||
{
|
||||
var config = LoadConfigNode();
|
||||
|
||||
var mode = _pythonModeIndex switch
|
||||
{
|
||||
1 => "windows",
|
||||
2 => "wsl",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
var existingTimeout = (config["python"] as JsonObject)?["timeoutSeconds"]?.GetValue<int>() ?? 30;
|
||||
|
||||
config["python"] = new JsonObject
|
||||
{
|
||||
["mode"] = mode,
|
||||
["windows"] = new JsonObject { ["interpreterPath"] = _pythonInterpreterPath ?? string.Empty },
|
||||
["wsl"] = new JsonObject { ["distribution"] = _wslDistribution ?? string.Empty },
|
||||
["timeoutSeconds"] = existingTimeout,
|
||||
};
|
||||
|
||||
WriteConfigNode(config);
|
||||
}
|
||||
|
||||
/// <summary>Populates <see cref="WslDistributions"/> from <c>wsl.exe -l -q</c> (best-effort).</summary>
|
||||
private void LoadWslDistributions()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "wsl.exe",
|
||||
Arguments = "-l -q",
|
||||
RedirectStandardOutput = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
|
||||
// wsl.exe emits UTF-16LE for list output.
|
||||
StandardOutputEncoding = Encoding.Unicode,
|
||||
};
|
||||
|
||||
using var process = Process.Start(psi);
|
||||
if (process is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string output = process.StandardOutput.ReadToEnd();
|
||||
process.WaitForExit(5000);
|
||||
|
||||
foreach (var line in output.Split('\n'))
|
||||
{
|
||||
var distro = line.Trim().Trim('\0', '\r');
|
||||
if (!string.IsNullOrWhiteSpace(distro) && !WslDistributions.Contains(distro))
|
||||
{
|
||||
WslDistributions.Add(distro);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// WSL not installed / unavailable: leave the list empty.
|
||||
}
|
||||
}
|
||||
|
||||
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