diff --git a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs index 659e34f4e34..5270b7e9f20 100644 --- a/Flow.Launcher.Test/Plugins/ShellPluginTest.cs +++ b/Flow.Launcher.Test/Plugins/ShellPluginTest.cs @@ -1,47 +1,358 @@ +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 Cmd_CloseShellAfterPress_AppendsPause() + { + const string command = "test"; + var info = Create( + command: command, + closeShellAfterPress: true, + shell: Shell.Cmd); + + Assert.That(info.Arguments, Is.EqualTo($"/c {command} && echo {ClosePrompt} && pause > nul")); + } + [Test] - public void ConfigureCmdProcessStartInfo_ShouldPreserveQuotedCommands() + public void Cmd_TrimsCommandWhitespace() { - var info = new ProcessStartInfo(); + var info = Create( + command: " dir ", + shell: Shell.Cmd); + + Assert.That(info.Arguments, Is.EqualTo("/c dir")); + } - Main.ConfigureCmdProcessStartInfo( - info, - "\"cmd.exe\"", - leaveShellOpen: false, - closeShellAfterPress: false, - notifyStr: "Press any key to close", - useWindowsTerminal: false); + [Test] + public void Cmd_WithSpacesAndQuotes_PassesThroughUnchanged() + { + var info = Create( + command: "\"C:\\Program Files\\app.exe\" --flag", + 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 \"C:\\Program Files\\app.exe\" --flag")); } + #endregion + + #region PowerShell + [Test] - public void ConfigureCmdProcessStartInfo_ShouldKeepArgumentListForWindowsTerminal() + public void Powershell_DirectExecution() { - var info = new ProcessStartInfo(); + var info = Create(shell: Shell.Powershell); + + Assert.That(info.FileName, Is.EqualTo("powershell.exe")); + Assert.That(info.ArgumentList, Is.EqualTo(["-Command", "test;"])); + } - Main.ConfigureCmdProcessStartInfo( - info, - "\"cmd.exe\"", - leaveShellOpen: false, - closeShellAfterPress: false, - notifyStr: "Press any key to close", + [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); - ClassicAssert.AreEqual("wt.exe", info.FileName); - CollectionAssert.AreEqual(new[] { "cmd", "/c", "\"cmd.exe\"" }, info.ArgumentList); - ClassicAssert.IsEmpty(info.Arguments); + 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")); + Assert.That(info.Arguments, Is.Empty); + } + + [Test] + public void RunCommand_UnknownExecutable_SetsWholeCommandAsFileName() + { + var info = Create( + command: "nonexistentapp123 argument", + shell: Shell.RunCommand); + + 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 + + [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 } } diff --git a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs index 0f8868c4198..c4456403469 100644 --- a/Plugins/Flow.Launcher.Plugin.Shell/Main.cs +++ b/Plugins/Flow.Launcher.Plugin.Shell/Main.cs @@ -189,140 +189,99 @@ 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 notifyStr = Localize.flowlauncher_plugin_cmd_press_any_key_to_close(); - var addedCharacter = _settings.UseWindowsTerminal ? "\\" : ""; - switch (_settings.Shell) + + switch (shell) { case Shell.Cmd: - { - ConfigureCmdProcessStartInfo( - info, - command, - _settings.LeaveShellOpen, - _settings.CloseShellAfterPress, - notifyStr, - _settings.UseWindowsTerminal); - break; - } + ConfigureCmdProcessStartInfo( + info, + command, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, + closePrompt); + 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, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, + closePrompt); + 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, + leaveShellOpen, + closeShellAfterPress, + useWindowsTerminal, + closePrompt); + 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; - } - - info.UseShellExecute = true; - - break; - } + ConfigureRunCommandStartInfo( + info, + command); + break; default: throw new NotImplementedException(); } - info.UseShellExecute = true; - - _settings.AddCmdHistory(command); - return info; } - internal static void ConfigureCmdProcessStartInfo( + private static void ConfigureCmdProcessStartInfo( ProcessStartInfo info, 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" : "")}"; if (useWindowsTerminal) { @@ -341,6 +300,132 @@ internal static void ConfigureCmdProcessStartInfo( } } + private static void ConfigurePowershellProcessStartInfo( + ProcessStartInfo info, + string command, + bool leaveShellOpen, + bool closeShellAfterPress, + 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. + 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 '{closePrompt}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + } + info.ArgumentList.Add(commandStr); + } + } + + private static void ConfigurePwshProcessStartInfo( + ProcessStartInfo info, + string command, + bool leaveShellOpen, + bool closeShellAfterPress, + 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. + 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 '{closePrompt}'{escape}; [System.Console]::ReadKey(){escape}; exit"; + } + info.ArgumentList.Add(commandStr); + } + + private static void ConfigureRunCommandStartInfo( + ProcessStartInfo info, + string command) + { + string filename = null; + string arguments = null; + + bool isQuotedPath = command.StartsWith("\""); + + if (isQuotedPath) + { + // 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) + { + 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; + } + } + private void Execute(Func startProcess, ProcessStartInfo info) { try