Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
35 changes: 34 additions & 1 deletion RadialActions.Tests/Actions/ActionsSettingsViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
54 changes: 54 additions & 0 deletions RadialActions.Tests/Actions/PieActionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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<InvalidOperationException>(() => 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<InvalidOperationException>(() => action.Execute());

Assert.Equal("Script is too long to run", ex.Message);
}

[Fact]
public void Execute_KeyActionWithInvalidShortcut_ThrowsInvalidOperationException()
{
Expand Down
44 changes: 44 additions & 0 deletions RadialActions.Tests/Settings/SettingsSerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
{
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()
{
Expand Down
68 changes: 68 additions & 0 deletions RadialActions/Actions/Action.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Diagnostics;
using System.IO;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;

namespace RadialActions;
Expand All @@ -23,6 +24,11 @@ public enum ActionType
/// Open an app, file, folder, or URL using shell execution.
/// </summary>
Open = 2,

/// <summary>
/// Run an inline PowerShell script, optionally without a console window.
/// </summary>
Script = 3,
}

/// <summary>
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -125,6 +132,18 @@ public static bool TryGetKeyAction(string id, out KeyActionDefinition definition
[ObservableProperty]
private string _workingDirectory = string.Empty;

/// <summary>
/// The inline script body for Script actions.
/// </summary>
[ObservableProperty]
private string _script = string.Empty;

/// <summary>
/// For Script actions, runs the interpreter without showing a console window.
/// </summary>
[ObservableProperty]
private bool _runHidden;

/// <summary>
/// Creates a new empty action.
/// </summary>
Expand Down Expand Up @@ -168,6 +187,19 @@ public static PieAction CreateOpenAction(string name, string target, string icon
WorkingDirectory = workingDirectory
};

/// <summary>
/// Creates a script action.
/// </summary>
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
};

/// <summary>
/// Executes the action.
/// </summary>
Expand All @@ -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");
}
Expand Down Expand Up @@ -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;
}
1 change: 1 addition & 0 deletions RadialActions/Properties/Settings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
{
Expand Down
50 changes: 49 additions & 1 deletion RadialActions/Settings/ActionEditorView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Use Key for shortcuts and media controls; Open for apps, files, folders, URLs, or commands."
<TextBlock Text="Use Key for shortcuts and media controls, Open for apps, files, folders, or URLs, and Script to run PowerShell."
Style="{StaticResource DescriptionTextBlock}" />

<StackPanel Margin="0,0,0,4"
Expand Down Expand Up @@ -148,5 +148,53 @@
<TextBlock Text="Optional folder to use as the target's start location."
Style="{StaticResource DescriptionTextBlock}" />
</StackPanel>

<StackPanel
Visibility="{Binding SelectedActionType, Converter={local:MatchToVisibilityConverter}, ConverterParameter={x:Static local:ActionType.Script}}">
<TextBlock Text="Script:"
Style="{StaticResource ActionEditorLabelTextBlock}" />
<TextBox Text="{Binding SelectedAction.Script, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
AcceptsReturn="True"
AcceptsTab="True"
TextWrapping="NoWrap"
FontFamily="Consolas, Cascadia Mono, Courier New, monospace"
MinHeight="150"
MaxHeight="320"
VerticalContentAlignment="Top"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Auto" />
<TextBlock Text="Runs when the slice is clicked. No separate file needed, for example: Get-Date | Out-File $env:TEMP\now.txt"
Style="{StaticResource DescriptionTextBlock}" />

<TextBlock Text="Run With:"
Style="{StaticResource ActionEditorLabelTextBlock}" />
<TextBox Text="{Binding SelectedAction.Parameter, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock Text="The PowerShell to run the script with: powershell.exe (default), or pwsh.exe for PowerShell 7."
Style="{StaticResource DescriptionTextBlock}" />

<TextBlock Text="Working Directory:"
Style="{StaticResource ActionEditorLabelTextBlock}" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="6" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding SelectedAction.WorkingDirectory, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<Button Grid.Column="2"
Content="Browse..."
Command="{Binding BrowseWorkingDirectoryCommand}"
Padding="8,2" />
</Grid>
<TextBlock Text="Optional folder to run the script in."
Style="{StaticResource DescriptionTextBlock}" />

<CheckBox Content="Run hidden"
Margin="0,8,0,0"
IsChecked="{Binding SelectedAction.RunHidden, Mode=TwoWay}" />
<TextBlock Text="Run without showing a console window."
Style="{StaticResource DescriptionTextBlock}" />
</StackPanel>
</StackPanel>
</UserControl>
13 changes: 13 additions & 0 deletions RadialActions/Settings/ActionEditorViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public ActionEditorViewModel(ActionDefaultsService actionDefaultsService, IEnume
[
new(ActionType.Key, "Key", "⌨️"),
new(ActionType.Open, "Open", "🚀"),
new(ActionType.Script, "Script", "📜"),
];

public IReadOnlyList<KeyActionDefinition> KeyActionOptions { get; } =
Expand Down Expand Up @@ -62,6 +63,8 @@ public ActionType SelectedActionType
SelectedAction.Parameter = string.Empty;
SelectedAction.Arguments = string.Empty;
SelectedAction.WorkingDirectory = string.Empty;
SelectedAction.Script = string.Empty;
SelectedAction.RunHidden = false;
}
else if (value == ActionType.Key)
{
Expand All @@ -71,6 +74,16 @@ public ActionType SelectedActionType
{
SelectedAction.Parameter = string.Empty;
}
else if (value == ActionType.Script)
{
SelectedAction.Parameter = PieAction.DefaultScriptInterpreter;

// Default a fresh Script action to hidden, but keep the user's choice when returning to an already-written script.
if (string.IsNullOrEmpty(SelectedAction.Script))
{
SelectedAction.RunHidden = true;
}
}

OnPropertyChanged();
}
Expand Down