From 532e6b3a1a6f40485e69fac8c3865c95032b5ca8 Mon Sep 17 00:00:00 2001 From: Daniel Chalmers Date: Mon, 13 Jul 2026 12:30:09 -0500 Subject: [PATCH] Add a Script action type for running inline PowerShell Adds an ActionType.Script that runs a multiline PowerShell script written directly in the action editor, with no separate script file. The body is passed to the interpreter via -EncodedCommand (Base64 of the UTF-16LE bytes) so multiline scripts, quotes, and newlines need no escaping and no temp file. - Runs hidden by default (UseShellExecute=false + CreateNoWindow); a fresh Script action defaults to hidden while preserving the choice on type toggles. - Interpreter defaults to powershell.exe and is editable (e.g. pwsh.exe). - Optional working directory; blank script and over-long scripts surface clear errors through the existing tray notification path. Resolves #58. --- README.md | 1 + .../Actions/ActionsSettingsViewModelTests.cs | 35 +++++++++- RadialActions.Tests/Actions/PieActionTests.cs | 54 +++++++++++++++ .../Settings/SettingsSerializationTests.cs | 44 ++++++++++++ RadialActions/Actions/Action.cs | 68 +++++++++++++++++++ RadialActions/Properties/Settings.cs | 1 + RadialActions/Settings/ActionEditorView.xaml | 50 +++++++++++++- .../Settings/ActionEditorViewModel.cs | 13 ++++ 8 files changed, 264 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 40b3272..cdee336 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Radial Actions is a free Windows app that opens with a global hotkey. It gives y - **Instant access:** summon the menu anywhere with a global hotkey (`Ctrl+Alt+Space` by default, fully customizable). It opens at your cursor, or in the center of the screen if you prefer. - **Launch anything:** apps, files, folders, and websites, with optional arguments and working directory. +- **Run PowerShell scripts:** write a script right in the action and run it on click, optionally hidden with no console window. - **Media & volume controls:** play/pause, next/previous track, mute, and volume up/down from any app. - **Custom keyboard shortcuts:** assign any key combo to a slice and trigger it with a click. - **Make it yours:** name each slice, pick an emoji icon, resize the menu, and drag slices to reorder them. diff --git a/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs b/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs index c0aeb74..8307ac3 100644 --- a/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs +++ b/RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs @@ -121,7 +121,40 @@ public void ActionEditorViewModel_ActionTypes_HidesNoneType() { var viewModel = new ActionEditorViewModel(new ActionDefaultsService(), []); - Assert.Equal([ActionType.Key, ActionType.Open], viewModel.ActionTypes.Select(option => option.Type)); + Assert.Equal([ActionType.Key, ActionType.Open, ActionType.Script], viewModel.ActionTypes.Select(option => option.Type)); + } + + [Fact] + public void SelectedActionType_SwitchingToScript_DefaultsInterpreterAndRunHidden() + { + var action = PieAction.CreateOpenAction("Explorer", "explorer.exe"); + var viewModel = new ActionEditorViewModel(new ActionDefaultsService(), [action]) + { + SelectedAction = action + }; + + viewModel.SelectedActionType = ActionType.Script; + + Assert.Equal(ActionType.Script, action.Type); + Assert.Equal(PieAction.DefaultScriptInterpreter, action.Parameter); + Assert.True(action.RunHidden); + } + + [Fact] + public void SelectedActionType_ReturningToScript_PreservesRunHiddenChoice() + { + var action = PieAction.CreateScriptAction("Backup", "Get-Date", runHidden: true); + var viewModel = new ActionEditorViewModel(new ActionDefaultsService(), [action]) + { + SelectedAction = action + }; + action.RunHidden = false; // user unchecks Run hidden + + viewModel.SelectedActionType = ActionType.Open; + viewModel.SelectedActionType = ActionType.Script; + + Assert.Equal(ActionType.Script, action.Type); + Assert.False(action.RunHidden); // choice preserved, not re-defaulted to hidden } [Fact] diff --git a/RadialActions.Tests/Actions/PieActionTests.cs b/RadialActions.Tests/Actions/PieActionTests.cs index a7fe0b0..82bdffe 100644 --- a/RadialActions.Tests/Actions/PieActionTests.cs +++ b/RadialActions.Tests/Actions/PieActionTests.cs @@ -28,6 +28,40 @@ public void CreateOpenAction_SetsExpectedFields() Assert.Equal("C:\\", action.WorkingDirectory); } + [Fact] + public void CreateScriptAction_SetsExpectedFields() + { + var action = PieAction.CreateScriptAction("Backup", "Get-Date", "📜", "pwsh.exe", "C:\\Scripts", runHidden: true); + + Assert.Equal(ActionType.Script, action.Type); + Assert.True(action.IsEnabled); + Assert.Equal("Backup", action.Name); + Assert.Equal("📜", action.Icon); + Assert.Equal("pwsh.exe", action.Parameter); + Assert.Equal("Get-Date", action.Script); + Assert.Equal("C:\\Scripts", action.WorkingDirectory); + Assert.True(action.RunHidden); + } + + [Fact] + public void CreateScriptAction_DefaultsRunHiddenToFalse() + { + var action = PieAction.CreateScriptAction("Backup", "Get-Date"); + + Assert.False(action.RunHidden); + } + + [Fact] + public void EncodePowerShellCommand_RoundTripsAsUtf16LeBase64() + { + const string script = "Write-Host 'héllo'\r\nGet-Date"; + + var encoded = PieAction.EncodePowerShellCommand(script); + var decoded = System.Text.Encoding.Unicode.GetString(Convert.FromBase64String(encoded)); + + Assert.Equal(script, decoded); + } + [Fact] public void TryGetKeyAction_KnownId_ReturnsDefinition() { @@ -68,6 +102,26 @@ public void Execute_OpenActionWithoutTarget_ThrowsInvalidOperationException() Assert.Equal("Launch target not configured", ex.Message); } + [Fact] + public void Execute_ScriptActionWithoutScript_ThrowsInvalidOperationException() + { + var action = PieAction.CreateScriptAction("Backup", string.Empty); + + var ex = Assert.Throws(() => action.Execute()); + + Assert.Equal("Script is empty", ex.Message); + } + + [Fact] + public void Execute_ScriptTooLong_ThrowsInvalidOperationException() + { + var action = PieAction.CreateScriptAction("Big", new string('a', 20000)); + + var ex = Assert.Throws(() => action.Execute()); + + Assert.Equal("Script is too long to run", ex.Message); + } + [Fact] public void Execute_KeyActionWithInvalidShortcut_ThrowsInvalidOperationException() { diff --git a/RadialActions.Tests/Settings/SettingsSerializationTests.cs b/RadialActions.Tests/Settings/SettingsSerializationTests.cs index f0f3890..e7232e8 100644 --- a/RadialActions.Tests/Settings/SettingsSerializationTests.cs +++ b/RadialActions.Tests/Settings/SettingsSerializationTests.cs @@ -88,6 +88,50 @@ public void SerializeToJson_RoundTripsCoreValues() Assert.False(loaded.Actions[1].IsEnabled); } + [Fact] + public void SerializeToJson_RoundTripsScriptAction() + { + var settings = Settings.DeserializeFromJson("{}"); + settings.Actions = new System.Collections.ObjectModel.ObservableCollection + { + PieAction.CreateScriptAction("Backup", "Get-Date | Out-File $env:TEMP\\now.txt", "📜", "pwsh.exe", "C:\\Scripts", runHidden: true) + }; + + var json = settings.SerializeToJson(); + var loaded = Settings.DeserializeFromJson(json); + + Assert.Single(loaded.Actions); + Assert.Equal(ActionType.Script, loaded.Actions[0].Type); + Assert.Equal("pwsh.exe", loaded.Actions[0].Parameter); + Assert.Equal("Get-Date | Out-File $env:TEMP\\now.txt", loaded.Actions[0].Script); + Assert.Equal("C:\\Scripts", loaded.Actions[0].WorkingDirectory); + Assert.True(loaded.Actions[0].RunHidden); + } + + [Fact] + public void DeserializeFromJson_ScriptActionMissingRunHidden_DefaultsToFalse() + { + const string json = """ + { + "Actions": [ + { + "Name": "Backup", + "Icon": "*", + "Type": 3, + "Script": "Get-Date" + } + ] + } + """; + + var settings = Settings.DeserializeFromJson(json); + + Assert.Single(settings.Actions); + Assert.Equal(ActionType.Script, settings.Actions[0].Type); + Assert.Equal("Get-Date", settings.Actions[0].Script); + Assert.False(settings.Actions[0].RunHidden); + } + [Fact] public void DeserializeFromJson_MissingIsEnabled_DefaultsToTrue() { diff --git a/RadialActions/Actions/Action.cs b/RadialActions/Actions/Action.cs index 5fa3693..18186f0 100644 --- a/RadialActions/Actions/Action.cs +++ b/RadialActions/Actions/Action.cs @@ -1,5 +1,6 @@ using System.Diagnostics; using System.IO; +using System.Text; using CommunityToolkit.Mvvm.ComponentModel; namespace RadialActions; @@ -23,6 +24,11 @@ public enum ActionType /// Open an app, file, folder, or URL using shell execution. /// Open = 2, + + /// + /// Run an inline PowerShell script, optionally without a console window. + /// + Script = 3, } /// @@ -53,6 +59,7 @@ public partial class PieAction : ObservableObject { public const string DefaultName = "New Action"; public const string DefaultIcon = "⚡"; + public const string DefaultScriptInterpreter = "powershell.exe"; public const string MediaCategory = "Media"; public const string VolumeCategory = "Volume"; @@ -125,6 +132,18 @@ public static bool TryGetKeyAction(string id, out KeyActionDefinition definition [ObservableProperty] private string _workingDirectory = string.Empty; + /// + /// The inline script body for Script actions. + /// + [ObservableProperty] + private string _script = string.Empty; + + /// + /// For Script actions, runs the interpreter without showing a console window. + /// + [ObservableProperty] + private bool _runHidden; + /// /// Creates a new empty action. /// @@ -168,6 +187,19 @@ public static PieAction CreateOpenAction(string name, string target, string icon WorkingDirectory = workingDirectory }; + /// + /// Creates a script action. + /// + public static PieAction CreateScriptAction(string name, string script, string icon = DefaultIcon, string interpreter = "", string workingDirectory = "", bool runHidden = false) + => new(name, icon) + { + Type = ActionType.Script, + Parameter = interpreter, + Script = script, + WorkingDirectory = workingDirectory, + RunHidden = runHidden + }; + /// /// Executes the action. /// @@ -185,6 +217,9 @@ public void Execute() case ActionType.Open: ExecuteOpen(); return; + case ActionType.Script: + ExecuteScript(); + return; default: throw new NotSupportedException("Action type is not supported"); } @@ -226,5 +261,38 @@ private void ExecuteOpen() Process.Start(psi); } + private void ExecuteScript() + { + if (string.IsNullOrWhiteSpace(Script)) + throw new InvalidOperationException("Script is empty"); + + var interpreter = string.IsNullOrWhiteSpace(Parameter) ? DefaultScriptInterpreter : Parameter; + var arguments = $"-NoProfile -ExecutionPolicy Bypass -EncodedCommand {EncodePowerShellCommand(Script)}"; + + // The whole command line must fit CreateProcess's 32,767 character limit; fail with a clear message instead of an opaque OS error. + if (interpreter.Length + 1 + arguments.Length >= 32000) + throw new InvalidOperationException("Script is too long to run"); + + // UseShellExecute must be false so CreateNoWindow can suppress the console window for hidden scripts. + // The body is passed as a Base64-encoded command so multiline scripts, quotes, and newlines need no escaping and no temp file. + var psi = new ProcessStartInfo(interpreter) + { + UseShellExecute = false, + CreateNoWindow = RunHidden, + Arguments = arguments + }; + + if (!string.IsNullOrWhiteSpace(WorkingDirectory)) + { + psi.WorkingDirectory = WorkingDirectory; + } + + using var process = Process.Start(psi); + } + + // PowerShell -EncodedCommand expects Base64 of the UTF-16LE bytes of the script. + internal static string EncodePowerShellCommand(string script) + => Convert.ToBase64String(Encoding.Unicode.GetBytes(script ?? string.Empty)); + public override string ToString() => Name; } diff --git a/RadialActions/Properties/Settings.cs b/RadialActions/Properties/Settings.cs index d739700..a873f53 100644 --- a/RadialActions/Properties/Settings.cs +++ b/RadialActions/Properties/Settings.cs @@ -102,6 +102,7 @@ internal void NormalizeAfterLoad() action.Parameter ??= string.Empty; action.Arguments ??= string.Empty; action.WorkingDirectory ??= string.Empty; + action.Script ??= string.Empty; if (!Enum.IsDefined(typeof(ActionType), action.Type)) { diff --git a/RadialActions/Settings/ActionEditorView.xaml b/RadialActions/Settings/ActionEditorView.xaml index e404d45..7891956 100644 --- a/RadialActions/Settings/ActionEditorView.xaml +++ b/RadialActions/Settings/ActionEditorView.xaml @@ -51,7 +51,7 @@ - + + + + + + + + + + + + + + + + + + +