From 552579e43d5247249901496ac0b14d16f61a669b Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 09:15:20 +0200 Subject: [PATCH 1/9] Remove duplicate setting of UseShellExecute in RunCommand branch of shell plugin --- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 0f8868c4198..8560a998f0f 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -297,8 +297,6 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin info.FileName = command; } - info.UseShellExecute = true; - break; } From 38d6fbf705a0abfc20b24d40446adae1735965ea Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 09:23:47 +0200 Subject: [PATCH 2/9] Add matching static methods to configure the process start info for each shell type in shell plugin --- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 217 ++++++++++++--------- 1 file changed, 128 insertions(+), 89 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 8560a998f0f..751f9b205d4 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -201,104 +201,43 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin WorkingDirectory = workingDirectory, }; var notifyStr = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); - var addedCharacter = _settings.UseWindowsTerminal ? "\\" : ""; switch (_settings.Shell) { case Shell.Cmd: - { - ConfigureCmdProcessStartInfo( - info, - command, - _settings.LeaveShellOpen, - _settings.CloseShellAfterPress, - notifyStr, - _settings.UseWindowsTerminal); - break; - } + ConfigureCmdProcessStartInfo( + info, + command, + _settings.LeaveShellOpen, + _settings.CloseShellAfterPress, + notifyStr, + _settings.UseWindowsTerminal); + break; case Shell.Powershell: - { - // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window - // \\ must be escaped for it to work properly, or breaking it into multiple arguments - if (_settings.UseWindowsTerminal) - { - info.FileName = "wt.exe"; - info.ArgumentList.Add("powershell"); - } - else - { - info.FileName = "powershell.exe"; - } - if (_settings.LeaveShellOpen) - { - info.ArgumentList.Add("-NoExit"); - info.ArgumentList.Add(command); - } - else - { - info.ArgumentList.Add("-Command"); - info.ArgumentList.Add( - $"{command}{addedCharacter};" + - $"{(_settings.CloseShellAfterPress ? - $" Write-Host '{notifyStr}'{addedCharacter}; [System.Console]::ReadKey(){addedCharacter}; exit" : - "")}"); - } - break; - } + ConfigurePowershellProcessStartInfo( + info, + command, + _settings.LeaveShellOpen, + _settings.CloseShellAfterPress, + notifyStr, + _settings.UseWindowsTerminal); + break; case Shell.Pwsh: - { - // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window - // \\ must be escaped for it to work properly, or breaking it into multiple arguments - if (_settings.UseWindowsTerminal) - { - info.FileName = "wt.exe"; - info.ArgumentList.Add("pwsh"); - } - else - { - info.FileName = "pwsh.exe"; - } - if (_settings.LeaveShellOpen) - { - info.ArgumentList.Add("-NoExit"); - } - info.ArgumentList.Add("-Command"); - info.ArgumentList.Add( - $"{command}{addedCharacter};" + - $"{(_settings.CloseShellAfterPress ? - $" Write-Host '{notifyStr}'{addedCharacter}; [System.Console]::ReadKey(){addedCharacter}; exit" : - "")}"); - break; - } + ConfigurePwshProcessStartInfo( + info, + command, + _settings.LeaveShellOpen, + _settings.CloseShellAfterPress, + notifyStr, + _settings.UseWindowsTerminal); + break; case Shell.RunCommand: - { - var parts = command.Split( - [ - ' ' - ], 2); - if (parts.Length == 2) - { - var filename = parts[0]; - if (ExistInPath(filename)) - { - var arguments = parts[1]; - info.FileName = filename; - info.ArgumentList.Add(arguments); - } - else - { - info.FileName = command; - } - } - else - { - info.FileName = command; - } - - break; - } + ConfigureRunCommandStartInfo( + info, + command); + break; default: throw new NotImplementedException(); @@ -339,6 +278,106 @@ internal static void ConfigureCmdProcessStartInfo( } } + internal static void ConfigurePowershellProcessStartInfo( + ProcessStartInfo info, + string command, + bool leaveShellOpen, + bool closeShellAfterPress, + string notifyStr, + bool useWindowsTerminal) + { + // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window. + // \\ must be escaped for it to work properly, or breaking it into multiple arguments. + var escape = useWindowsTerminal ? "\\" : ""; + + if (useWindowsTerminal) + { + info.FileName = "wt.exe"; + info.ArgumentList.Add("powershell"); + } + else + { + info.FileName = "powershell.exe"; + } + + if (leaveShellOpen) + { + info.ArgumentList.Add("-NoExit"); + info.ArgumentList.Add(command); + } + else + { + info.ArgumentList.Add("-Command"); + var commandStr = $"{command}{escape};"; + if (closeShellAfterPress) + { + commandStr += $" Write-Host '{notifyStr}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + } + info.ArgumentList.Add(commandStr); + } + } + + internal static void ConfigurePwshProcessStartInfo( + ProcessStartInfo info, + string command, + bool leaveShellOpen, + bool closeShellAfterPress, + string notifyStr, + bool useWindowsTerminal) + { + // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window. + // \\ must be escaped for it to work properly, or breaking it into multiple arguments. + var escape = useWindowsTerminal ? "\\" : ""; + + if (useWindowsTerminal) + { + info.FileName = "wt.exe"; + info.ArgumentList.Add("pwsh"); + } + else + { + info.FileName = "pwsh.exe"; + } + + if (leaveShellOpen) + { + info.ArgumentList.Add("-NoExit"); + } + + info.ArgumentList.Add("-Command"); + var commandStr = $"{command}{escape};"; + if (closeShellAfterPress) + { + commandStr += $" Write-Host '{notifyStr}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + } + info.ArgumentList.Add(commandStr); + } + + internal static void ConfigureRunCommandStartInfo( + ProcessStartInfo info, + string command + ) { + var parts = command.Split([' '], 2); + if (parts.Length == 2) + { + var filename = parts[0]; + if (ExistInPath(filename)) + { + var arguments = parts[1]; + info.FileName = filename; + info.ArgumentList.Add(arguments); + } + else + { + info.FileName = command; + } + } + else + { + info.FileName = command; + } + } + private void Execute(Func startProcess, ProcessStartInfo info) { try From 7bb0147f125f0e9e7739f61f6040bc3dccd2b02c Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 09:33:20 +0200 Subject: [PATCH 3/9] Rename notifyStr to closePrompt in shell plugin This is a clearer name which is more important now that its also a param --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 8 ++-- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 38 +++++++++---------- 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index 659e34f4e34..aa08dd3be70 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -18,8 +18,8 @@ public void ConfigureCmdProcessStartInfo_ShouldPreserveQuotedCommands() "\"cmd.exe\"", leaveShellOpen: false, closeShellAfterPress: false, - notifyStr: "Press any key to close", - useWindowsTerminal: false); + useWindowsTerminal: false, + closePrompt: "Press any key to close"); ClassicAssert.AreEqual("cmd.exe", info.FileName); ClassicAssert.AreEqual("/c \"cmd.exe\"", info.Arguments); @@ -36,8 +36,8 @@ public void ConfigureCmdProcessStartInfo_ShouldKeepArgumentListForWindowsTermina "\"cmd.exe\"", leaveShellOpen: false, closeShellAfterPress: false, - notifyStr: "Press any key to close", - useWindowsTerminal: true); + useWindowsTerminal: true, + closePrompt: "Press any key to close"); ClassicAssert.AreEqual("wt.exe", info.FileName); CollectionAssert.AreEqual(new[] { "cmd", "/c", "\"cmd.exe\"" }, info.ArgumentList); diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 751f9b205d4..5c8bc180378 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -200,7 +200,7 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin Verb = runAsAdministratorArg, WorkingDirectory = workingDirectory, }; - var notifyStr = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); + var closePrompt = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); switch (_settings.Shell) { case Shell.Cmd: @@ -209,8 +209,8 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin command, _settings.LeaveShellOpen, _settings.CloseShellAfterPress, - notifyStr, - _settings.UseWindowsTerminal); + _settings.UseWindowsTerminal, + closePrompt); break; case Shell.Powershell: @@ -219,8 +219,8 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin command, _settings.LeaveShellOpen, _settings.CloseShellAfterPress, - notifyStr, - _settings.UseWindowsTerminal); + _settings.UseWindowsTerminal, + closePrompt); break; case Shell.Pwsh: @@ -229,8 +229,8 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin command, _settings.LeaveShellOpen, _settings.CloseShellAfterPress, - notifyStr, - _settings.UseWindowsTerminal); + _settings.UseWindowsTerminal, + closePrompt); break; case Shell.RunCommand: @@ -255,11 +255,11 @@ internal static void ConfigureCmdProcessStartInfo( string command, bool leaveShellOpen, bool closeShellAfterPress, - string notifyStr, - bool useWindowsTerminal) + bool useWindowsTerminal, + string closePrompt) { var shellSwitch = leaveShellOpen ? "/k" : "/c"; - var commandToRun = $"{command}{(closeShellAfterPress ? $" && echo {notifyStr} && pause > nul /c" : "")}"; + var commandToRun = $"{command}{(closeShellAfterPress ? $" && echo {closePrompt} && pause > nul /c" : "")}"; if (useWindowsTerminal) { @@ -283,8 +283,8 @@ internal static void ConfigurePowershellProcessStartInfo( string command, bool leaveShellOpen, bool closeShellAfterPress, - string notifyStr, - bool useWindowsTerminal) + bool useWindowsTerminal, + string closePrompt) { // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window. // \\ must be escaped for it to work properly, or breaking it into multiple arguments. @@ -311,7 +311,7 @@ internal static void ConfigurePowershellProcessStartInfo( var commandStr = $"{command}{escape};"; if (closeShellAfterPress) { - commandStr += $" Write-Host '{notifyStr}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + commandStr += $" Write-Host '{closePrompt}'{escape}; [System.Console]::ReadKey(){escape}; exit"; } info.ArgumentList.Add(commandStr); } @@ -322,8 +322,8 @@ internal static void ConfigurePwshProcessStartInfo( string command, bool leaveShellOpen, bool closeShellAfterPress, - string notifyStr, - bool useWindowsTerminal) + bool useWindowsTerminal, + string closePrompt) { // Using just a ; doesn't work with wt, as it's used to create a new tab for the terminal window. // \\ must be escaped for it to work properly, or breaking it into multiple arguments. @@ -348,15 +348,15 @@ internal static void ConfigurePwshProcessStartInfo( var commandStr = $"{command}{escape};"; if (closeShellAfterPress) { - commandStr += $" Write-Host '{notifyStr}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + commandStr += $" Write-Host '{closePrompt}'{escape}; [System.Console]::ReadKey(){escape}; exit"; } info.ArgumentList.Add(commandStr); } internal static void ConfigureRunCommandStartInfo( - ProcessStartInfo info, - string command - ) { + ProcessStartInfo info, + string command) + { var parts = command.Split([' '], 2); if (parts.Length == 2) { From 0448bfc7c874a79195e6f4306b969f4ebe94d371 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 11:17:02 +0200 Subject: [PATCH 4/9] Simplify runAsAdministratorArg boolean logic in shell plugin Logically equivalent as per De Morgan's Laws This also makes the intent clearer - we use "runas" if either runAsAdministrator or _settings.RunAsAdministrator is true --- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 5c8bc180378..2e3b38ce18e 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -193,7 +193,7 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin command = command.Trim(); command = Environment.ExpandEnvironmentVariables(command); var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var runAsAdministratorArg = !runAsAdministrator && !_settings.RunAsAdministrator ? "" : "runas"; + var runAsAdministratorArg = (runAsAdministrator || _settings.RunAsAdministrator) ? "runas" : ""; var info = new ProcessStartInfo() { From ff86a38831d92f209917825f799b9a723e082ec5 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 11:23:41 +0200 Subject: [PATCH 5/9] Extract static part of PrepareProcessStartInfo to new CreateProcessStartInfo in shell plugin --- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 54 +++++++++++++++------- 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 2e3b38ce18e..b4f6110d9d3 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -189,27 +189,53 @@ private List ResultsFromHistory() } private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdministrator = false) + { + var runAsAdmin = runAsAdministrator || _settings.RunAsAdministrator; + var closePrompt = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); + var info = CreateProcessStartInfo( + command, + _settings.Shell, + _settings.LeaveShellOpen, + _settings.CloseShellAfterPress, + _settings.UseWindowsTerminal, + runAsAdmin, + closePrompt); + + _settings.AddCmdHistory(command); + return info; + } + + internal static ProcessStartInfo CreateProcessStartInfo( + string command, + Shell shell, + bool leaveShellOpen, + bool closeShellAfterPress, + bool useWindowsTerminal, + bool runAsAdmin, + string closePrompt) { command = command.Trim(); command = Environment.ExpandEnvironmentVariables(command); + var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); - var runAsAdministratorArg = (runAsAdministrator || _settings.RunAsAdministrator) ? "runas" : ""; + var runAsAdministratorArg = runAsAdmin ? "runas" : ""; var info = new ProcessStartInfo() { Verb = runAsAdministratorArg, WorkingDirectory = workingDirectory, + UseShellExecute = true, }; - var closePrompt = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); - switch (_settings.Shell) + + switch (shell) { case Shell.Cmd: ConfigureCmdProcessStartInfo( info, command, - _settings.LeaveShellOpen, - _settings.CloseShellAfterPress, - _settings.UseWindowsTerminal, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, closePrompt); break; @@ -217,9 +243,9 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin ConfigurePowershellProcessStartInfo( info, command, - _settings.LeaveShellOpen, - _settings.CloseShellAfterPress, - _settings.UseWindowsTerminal, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, closePrompt); break; @@ -227,9 +253,9 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin ConfigurePwshProcessStartInfo( info, command, - _settings.LeaveShellOpen, - _settings.CloseShellAfterPress, - _settings.UseWindowsTerminal, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, closePrompt); break; @@ -243,10 +269,6 @@ private ProcessStartInfo PrepareProcessStartInfo(string command, bool runAsAdmin throw new NotImplementedException(); } - info.UseShellExecute = true; - - _settings.AddCmdHistory(command); - return info; } From f64d94f5b4dbfcc4463920ca277812a8825f9a87 Mon Sep 17 00:00:00 2001 From: David Brett Date: Sun, 28 Jun 2026 11:36:51 +0200 Subject: [PATCH 6/9] Make shell plugin tests use CreateProcessStartInfo instead of shell specific methods so full pipeline is tested The shell specific methods such as ConfigureCmdProcessStartInfo are now private to prevent them from being tested instead of CreateProcessStartInfo --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 19 +++++++++---------- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 8 ++++---- 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index aa08dd3be70..e025764fdb2 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.Shell; using NUnit.Framework; using NUnit.Framework.Legacy; @@ -9,16 +10,15 @@ namespace Flow.Launcher.Test.Plugins public class ShellPluginTest { [Test] - public void ConfigureCmdProcessStartInfo_ShouldPreserveQuotedCommands() + public void CreateProcessStartInfo_Cmd_ShouldPreserveQuotedCommands() { - var info = new ProcessStartInfo(); - - Main.ConfigureCmdProcessStartInfo( - info, + var info = Main.CreateProcessStartInfo( "\"cmd.exe\"", + Shell.Cmd, leaveShellOpen: false, closeShellAfterPress: false, useWindowsTerminal: false, + runAsAdmin: false, closePrompt: "Press any key to close"); ClassicAssert.AreEqual("cmd.exe", info.FileName); @@ -27,16 +27,15 @@ public void ConfigureCmdProcessStartInfo_ShouldPreserveQuotedCommands() } [Test] - public void ConfigureCmdProcessStartInfo_ShouldKeepArgumentListForWindowsTerminal() + public void CreateProcessStartInfo_Cmd_ShouldUseArgumentListForWindowsTerminal() { - var info = new ProcessStartInfo(); - - Main.ConfigureCmdProcessStartInfo( - info, + var info = Main.CreateProcessStartInfo( "\"cmd.exe\"", + Shell.Cmd, leaveShellOpen: false, closeShellAfterPress: false, useWindowsTerminal: true, + runAsAdmin: false, closePrompt: "Press any key to close"); ClassicAssert.AreEqual("wt.exe", info.FileName); diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index b4f6110d9d3..87c0e593004 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -272,7 +272,7 @@ internal static ProcessStartInfo CreateProcessStartInfo( return info; } - internal static void ConfigureCmdProcessStartInfo( + private static void ConfigureCmdProcessStartInfo( ProcessStartInfo info, string command, bool leaveShellOpen, @@ -300,7 +300,7 @@ internal static void ConfigureCmdProcessStartInfo( } } - internal static void ConfigurePowershellProcessStartInfo( + private static void ConfigurePowershellProcessStartInfo( ProcessStartInfo info, string command, bool leaveShellOpen, @@ -339,7 +339,7 @@ internal static void ConfigurePowershellProcessStartInfo( } } - internal static void ConfigurePwshProcessStartInfo( + private static void ConfigurePwshProcessStartInfo( ProcessStartInfo info, string command, bool leaveShellOpen, @@ -375,7 +375,7 @@ internal static void ConfigurePwshProcessStartInfo( info.ArgumentList.Add(commandStr); } - internal static void ConfigureRunCommandStartInfo( + private static void ConfigureRunCommandStartInfo( ProcessStartInfo info, string command) { From 20678a3fae68485214f42209689058f082149cd3 Mon Sep 17 00:00:00 2001 From: David Brett Date: Mon, 29 Jun 2026 13:02:14 +0200 Subject: [PATCH 7/9] Add more tests in shell plugin for all shell types Existing two tests are preserved but renamed and rewritten slightly --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 304 ++++++++++++++++-- 1 file changed, 279 insertions(+), 25 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index e025764fdb2..e4505369530 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -1,46 +1,300 @@ +using System; using System.Diagnostics; +using System.Linq; using Flow.Launcher.Plugin; using Flow.Launcher.Plugin.Shell; using NUnit.Framework; -using NUnit.Framework.Legacy; namespace Flow.Launcher.Test.Plugins { [TestFixture] public class ShellPluginTest { + private const string ClosePrompt = "Press any key to close..."; + + private static ProcessStartInfo Create( + string command = "test", + Shell shell = Shell.Cmd, + bool leaveShellOpen = false, + bool closeShellAfterPress = false, + bool useWindowsTerminal = false, + bool runAsAdmin = false) + => Main.CreateProcessStartInfo( + command, + shell, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, + runAsAdmin, + ClosePrompt); + + #region CMD + + [Test] + public void Cmd_ShouldPreserveQuotedCommands() + { + var info = Create( + command: "\"cmd.exe\"", + shell: Shell.Cmd); + + Assert.That(info.FileName, Is.EqualTo("cmd.exe")); + Assert.That(info.Arguments, Is.EqualTo("/c \"cmd.exe\"")); + Assert.That(info.ArgumentList, Is.Empty); + } + + [Test] + public void Cmd_ShouldUseArgumentListForWindowsTerminal() + { + var info = Create( + command: "\"cmd.exe\"", + shell: Shell.Cmd, + useWindowsTerminal: true); + + Assert.That(info.FileName, Is.EqualTo("wt.exe")); + Assert.That(info.ArgumentList, Is.EqualTo(["cmd", "/c", "\"cmd.exe\""])); + Assert.That(info.Arguments, Is.Empty); + } + + [TestCase(true, "/k")] + [TestCase(false, "/c")] + public void Cmd_UsesCorrectShellSwitch(bool leaveShellOpen, string expectedSwitch) + { + const string command = "test"; + var info = Create( + command: command, + leaveShellOpen: leaveShellOpen, + shell: Shell.Cmd); + + Assert.That(info.Arguments, Is.EqualTo($"{expectedSwitch} {command}")); + } + [Test] - public void CreateProcessStartInfo_Cmd_ShouldPreserveQuotedCommands() + public void Cmd_CloseShellAfterPress_AppendsPause() { - var info = Main.CreateProcessStartInfo( - "\"cmd.exe\"", - Shell.Cmd, - leaveShellOpen: false, - closeShellAfterPress: false, - useWindowsTerminal: false, - runAsAdmin: false, - closePrompt: "Press any key to close"); + const string command = "test"; + var info = Create( + command: command, + closeShellAfterPress: true, + shell: Shell.Cmd); - ClassicAssert.AreEqual("cmd.exe", info.FileName); - ClassicAssert.AreEqual("/c \"cmd.exe\"", info.Arguments); - ClassicAssert.IsEmpty(info.ArgumentList); + Assert.That(info.Arguments, Is.EqualTo($"/c {command} && echo {ClosePrompt} && pause > nul /c")); } [Test] - public void CreateProcessStartInfo_Cmd_ShouldUseArgumentListForWindowsTerminal() + public void Cmd_TrimsCommandWhitespace() { - var info = Main.CreateProcessStartInfo( - "\"cmd.exe\"", - Shell.Cmd, - leaveShellOpen: false, - closeShellAfterPress: false, - useWindowsTerminal: true, - runAsAdmin: false, - closePrompt: "Press any key to close"); + var info = Create( + command: " dir ", + shell: Shell.Cmd); - ClassicAssert.AreEqual("wt.exe", info.FileName); - CollectionAssert.AreEqual(new[] { "cmd", "/c", "\"cmd.exe\"" }, info.ArgumentList); - ClassicAssert.IsEmpty(info.Arguments); + Assert.That(info.Arguments, Is.EqualTo("/c dir")); } + + [Test] + public void Cmd_WithSpacesAndQuotes_PassesThroughUnchanged() + { + var info = Create( + command: "\"C:\\Program Files\\app.exe\" --flag", + shell: Shell.Cmd); + + Assert.That(info.Arguments, Is.EqualTo("/c \"C:\\Program Files\\app.exe\" --flag")); + } + + #endregion + + #region PowerShell + + [Test] + public void Powershell_DirectExecution() + { + var info = Create(shell: Shell.Powershell); + + Assert.That(info.FileName, Is.EqualTo("powershell.exe")); + Assert.That(info.ArgumentList, Is.EqualTo(["-Command", "test;"])); + } + + [Test] + public void Powershell_WithSpecialCharacters_PassesThrough() + { + var info = Create( + command: "$env:USERNAME", + shell: Shell.Powershell); + + Assert.That(info.ArgumentList, Is.EqualTo(["-Command", "$env:USERNAME;"])); + } + + [Test] + public void Powershell_CloseShellAfterPress_AppendsPrompt() + { + var info = Create( + shell: Shell.Powershell, + closeShellAfterPress: true); + + var commandArg = info.ArgumentList.Last(); + Assert.That(commandArg, Does.Contain($"Write-Host '{ClosePrompt}'")); + } + + [Test] + public void Powershell_WT_UsesWindowsTerminal() + { + var info = Create( + shell: Shell.Powershell, + useWindowsTerminal: true); + + Assert.That(info.FileName, Is.EqualTo("wt.exe")); + Assert.That(info.ArgumentList, Does.Contain("powershell")); + } + + [TestCase(false)] + [TestCase(true)] + public void Powershell_AddsNoExitOnlyWhenLeaveShellOpen(bool leaveShellOpen) + { + var info = Create( + shell: Shell.Powershell, + leaveShellOpen: leaveShellOpen); + + Assert.That(info.ArgumentList, leaveShellOpen + ? Does.Contain("-NoExit") + : Does.Not.Contain("-NoExit")); + } + + [Test] + public void Powershell_LeaveShellOpen_OmitsCommandSwitch() + { + var info = Create( + shell: Shell.Powershell, + leaveShellOpen: true); + + Assert.That(info.ArgumentList, Does.Not.Contain("-Command")); + } + + #endregion + + #region Pwsh + + [Test] + public void Pwsh_AlwaysAddsCommandSwitch() + { + var info = Create(shell: Shell.Pwsh); + + Assert.That(info.FileName, Is.EqualTo("pwsh.exe")); + Assert.That(info.ArgumentList, Does.Contain("-Command")); + } + + [TestCase(false)] + [TestCase(true)] + public void Pwsh_AddsNoExitOnlyWhenLeaveShellOpen(bool leaveShellOpen) + { + var info = Create( + shell: Shell.Pwsh, + leaveShellOpen: leaveShellOpen); + + Assert.That( + info.ArgumentList, + leaveShellOpen + ? Does.Contain("-NoExit") + : Does.Not.Contain("-NoExit") + ); + } + + [Test] + public void Pwsh_CloseShellAfterPress_AppendsPrompt() + { + var info = Create( + shell: Shell.Pwsh, + closeShellAfterPress: true); + + var commandArg = info.ArgumentList.Last(); + Assert.That(commandArg, Does.Contain($"Write-Host '{ClosePrompt}'")); + } + + #endregion + + #region RunCommand + + [Test] + public void RunCommand_SingleWord_SetsFileName() + { + var info = Create( + command: "notepad", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo("notepad")); + } + + [Test] + public void RunCommand_UnknownExecutable_SetsWholeCommandAsFileName() + { + var info = Create( + command: "nonexistentapp123 argument", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo("nonexistentapp123 argument")); + } + + #endregion + + #region Common + + [TestCase(false, "")] + [TestCase(true, "runas")] + public void SetsRunAsAdminVerb(bool runAsAdmin, string expectedVerb) + { + var info = Create(runAsAdmin: runAsAdmin); + + Assert.That(info.Verb, Is.EqualTo(expectedVerb)); + } + + [TestCase(Shell.Cmd)] + [TestCase(Shell.Powershell)] + [TestCase(Shell.Pwsh)] + [TestCase(Shell.RunCommand)] + public void SetsWorkingDirectory(Shell shell) + { + var info = Create(shell: shell); + + var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + Assert.That(info.WorkingDirectory, Is.EqualTo(expected)); + } + + [TestCase(Shell.Cmd)] + [TestCase(Shell.Powershell)] + [TestCase(Shell.Pwsh)] + [TestCase(Shell.RunCommand)] + public void SetsUseShellExecute(Shell shell) + { + var info = Create(shell: shell); + + Assert.That(info.UseShellExecute, Is.True); + } + + [TestCase(Shell.Cmd)] + [TestCase(Shell.Powershell)] + [TestCase(Shell.Pwsh)] + [TestCase(Shell.RunCommand)] + public void ExpandsEnvironmentVariables(Shell shell) + { + var info = Create( + command: "%USERPROFILE%\\test", + shell: shell); + + var expandedPath = Environment.ExpandEnvironmentVariables("%USERPROFILE%\\test"); + + switch (shell) + { + case Shell.Cmd: + Assert.That(info.Arguments, Is.EqualTo($"/c {expandedPath}")); + break; + case Shell.Powershell: + case Shell.Pwsh: + Assert.That(info.ArgumentList, Does.Contain(expandedPath + ";")); + break; + case Shell.RunCommand: + Assert.That(info.FileName, Is.EqualTo(expandedPath)); + break; + } + } + + #endregion } } From 91dfeb58efe2912897cd593543d4565151855748 Mon Sep 17 00:00:00 2001 From: David Brett Date: Tue, 30 Jun 2026 20:07:59 +0200 Subject: [PATCH 8/9] Remove trailing /c from CMD close after press pause command in shell plugin The trailing /c after pause > nul had no purpose as pause ignores arguments. Adjusted the test to match. --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 2 +- Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index e4505369530..0d33c5d6acc 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -77,7 +77,7 @@ public void Cmd_CloseShellAfterPress_AppendsPause() closeShellAfterPress: true, shell: Shell.Cmd); - Assert.That(info.Arguments, Is.EqualTo($"/c {command} && echo {ClosePrompt} && pause > nul /c")); + Assert.That(info.Arguments, Is.EqualTo($"/c {command} && echo {ClosePrompt} && pause > nul")); } [Test] diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 87c0e593004..3465005db8a 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -281,7 +281,7 @@ private static void ConfigureCmdProcessStartInfo( string closePrompt) { var shellSwitch = leaveShellOpen ? "/k" : "/c"; - var commandToRun = $"{command}{(closeShellAfterPress ? $" && echo {closePrompt} && pause > nul /c" : "")}"; + var commandToRun = $"{command}{(closeShellAfterPress ? $" && echo {closePrompt} && pause > nul" : "")}"; if (useWindowsTerminal) { From 049ffe58bfdcfd7713d7075a523dabaeb4acd3d9 Mon Sep 17 00:00:00 2001 From: David Brett Date: Wed, 1 Jul 2026 18:16:10 +0200 Subject: [PATCH 9/9] Rewrite ConfigureRunCommandStartInfo with proper argument and quoted-path handling - Uses Arguments instead of ArgumentList so that OS parser can correctly split the args by spaces. - Quoted executable paths with spaces are now parsed correctly. - Arguments are now always trimmed to catch extra whitespace --- Flow.Launcher.Test/Plugins/ShellPluginTest.cs | 58 +++++++++++++++++++ Plugins/Flow.Launcher.Plugin.Shell/Main.cs | 48 +++++++++++---- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index 0d33c5d6acc..5270b7e9f20 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -220,6 +220,7 @@ public void RunCommand_SingleWord_SetsFileName() shell: Shell.RunCommand); Assert.That(info.FileName, Is.EqualTo("notepad")); + Assert.That(info.Arguments, Is.Empty); } [Test] @@ -232,6 +233,63 @@ public void RunCommand_UnknownExecutable_SetsWholeCommandAsFileName() Assert.That(info.FileName, Is.EqualTo("nonexistentapp123 argument")); } + [Test] + public void RunCommand_UnknownQuotedExecutable_SetsWholeCommandAsFileName() + { + var info = Create( + command: "\"C:\\nonexistent\\app.exe\" --flag", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo("\"C:\\nonexistent\\app.exe\" --flag")); + } + + [Test] + public void RunCommand_QuotedPathNoArgs_ExtractsFileName() + { + var systemDir = Environment.SystemDirectory; + var info = Create( + command: $"\"{systemDir}\\cmd.exe\"", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo($"{systemDir}\\cmd.exe")); + Assert.That(info.Arguments, Is.Empty); + } + + [Test] + public void RunCommand_QuotedPath_WithQuotedArgs_Preserved() + { + var systemDir = Environment.SystemDirectory; + var info = Create( + command: $"\"{systemDir}\\cmd.exe\" /c echo \"hello world\"", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo($"{systemDir}\\cmd.exe")); + Assert.That(info.Arguments, Is.EqualTo("/c echo \"hello world\"")); + } + + [Test] + public void RunCommand_UsesArgumentsForCommandTail() + { + var info = Create( + command: "cmd /c echo hello", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo("cmd")); + Assert.That(info.Arguments, Is.EqualTo("/c echo hello")); + } + + [Test] + public void RunCommand_QuotedExecutablePath_ExtractsFileName() + { + var systemDir = Environment.SystemDirectory; + var info = Create( + command: $"\"{systemDir}\\cmd.exe\" /c echo hello", + shell: Shell.RunCommand); + + Assert.That(info.FileName, Is.EqualTo($"{systemDir}\\cmd.exe")); + Assert.That(info.Arguments, Is.EqualTo("/c echo hello")); + } + #endregion #region Common diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 3465005db8a..c4456403469 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -379,23 +379,49 @@ private static void ConfigureRunCommandStartInfo( ProcessStartInfo info, string command) { - var parts = command.Split([' '], 2); - if (parts.Length == 2) + string filename = null; + string arguments = null; + + bool isQuotedPath = command.StartsWith("\""); + + if (isQuotedPath) { - var filename = parts[0]; - if (ExistInPath(filename)) - { - var arguments = parts[1]; - info.FileName = filename; - info.ArgumentList.Add(arguments); - } - else + // Quoted paths ("C:\Program Files\app.exe") can have spaces in the path. + + // We know the command starts with a quote, + // So strip it off and split to see if theres a second one + var parts = command[1..].Split("\"", 2); + bool hasMatchingQuotes = parts.Length > 1; + + if (hasMatchingQuotes) { - info.FileName = command; + filename = parts[0]; + arguments = parts[1]; } + // If there is no closing quote then we leave both as null + } + else + { + // Without a quoted path, + // we split on the first space to get the filename and args + var parts = command.Split(' ', 2); + filename = parts[0]; + arguments = parts.Length > 1 ? parts[1] : null; + } + + if (filename != null && ExistInPath(filename)) + { + info.FileName = filename; + + // catch unnecessary space or arguments that are just whitespace + var trimmedArgs = arguments?.TrimStart(); + if (!string.IsNullOrEmpty(trimmedArgs)) + info.Arguments = trimmedArgs; } else { + // Could not parse a valid filename or it was not found. + // Pass the whole command anyways so the OS produces a meaningful error. info.FileName = command; } }