From 76b4aa8bf812e2d58518198d9b6851b77542c87c Mon Sep 17 00:00:00 2001 From: Andrew Clinick <80841394+aclinick@users.noreply.github.com> Date: Fri, 17 Jul 2026 10:42:22 -0700 Subject: [PATCH] Add 'Open install folder in Terminal' to installed apps Right-clicking an app in the Apps pane now offers 'Open install folder in Terminal', which opens that package's install location in a terminal so users can inspect what's inside. TerminalLauncher (Core, testable) returns an ordered list of launch candidates: Windows Terminal (wt.exe -d), then PowerShell, then cmd.exe. The UI tries each until one starts, so machines without Windows Terminal still fall back to a shell that always ships with Windows. Launch uses ProcessStartInfo with UseShellExecute=true (allowed via runFullTrust). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../TerminalLauncherTests.cs | 56 +++++++++++++++++++ .../Models/TerminalLaunchOption.cs | 14 +++++ MSIXplainer.Core/Services/TerminalLauncher.cs | 35 ++++++++++++ MSIXplainer/MainPage.xaml | 10 ++++ MSIXplainer/MainPage.xaml.cs | 32 +++++++++++ 5 files changed, 147 insertions(+) create mode 100644 MSIXplainer.Core.Tests/TerminalLauncherTests.cs create mode 100644 MSIXplainer.Core/Models/TerminalLaunchOption.cs create mode 100644 MSIXplainer.Core/Services/TerminalLauncher.cs diff --git a/MSIXplainer.Core.Tests/TerminalLauncherTests.cs b/MSIXplainer.Core.Tests/TerminalLauncherTests.cs new file mode 100644 index 0000000..ad5fb42 --- /dev/null +++ b/MSIXplainer.Core.Tests/TerminalLauncherTests.cs @@ -0,0 +1,56 @@ +using MSIXplainer.Services; +using Xunit; + +namespace MSIXplainer.Core.Tests; + +public class TerminalLauncherTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void GetLaunchOptions_BlankPath_ReturnsEmpty(string? path) + { + Assert.Empty(TerminalLauncher.GetLaunchOptions(path)); + } + + [Fact] + public void GetLaunchOptions_OrdersWindowsTerminalFirstThenPowerShellThenCmd() + { + var options = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp"); + + Assert.Equal(3, options.Count); + Assert.Equal("wt.exe", options[0].FileName); + Assert.Equal("powershell.exe", options[1].FileName); + Assert.Equal("cmd.exe", options[2].FileName); + } + + [Fact] + public void GetLaunchOptions_WindowsTerminal_EncodesFolderInQuotedDArgument() + { + var wt = TerminalLauncher.GetLaunchOptions(@"C:\apps\my app").First(); + + Assert.Equal("-d \"C:\\apps\\my app\"", wt.Arguments); + Assert.Null(wt.WorkingDirectory); + } + + [Fact] + public void GetLaunchOptions_PowerShellAndCmd_UseWorkingDirectory() + { + var options = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp"); + + Assert.Equal(@"C:\apps\myapp", options[1].WorkingDirectory); + Assert.Equal("-NoExit", options[1].Arguments); + + Assert.Equal(@"C:\apps\myapp", options[2].WorkingDirectory); + Assert.Null(options[2].Arguments); + } + + [Fact] + public void GetLaunchOptions_TrimsTrailingSeparator() + { + var wt = TerminalLauncher.GetLaunchOptions(@"C:\apps\myapp\").First(); + + Assert.Equal("-d \"C:\\apps\\myapp\"", wt.Arguments); + } +} diff --git a/MSIXplainer.Core/Models/TerminalLaunchOption.cs b/MSIXplainer.Core/Models/TerminalLaunchOption.cs new file mode 100644 index 0000000..5577ac1 --- /dev/null +++ b/MSIXplainer.Core/Models/TerminalLaunchOption.cs @@ -0,0 +1,14 @@ +namespace MSIXplainer.Models; + +/// +/// A single terminal launch candidate produced by +/// . The UI layer maps this onto a +/// ProcessStartInfo with UseShellExecute = true. +/// +/// Executable to launch (e.g. wt.exe). +/// Command-line arguments, or null for none. +/// +/// Working directory to start in, or null when the folder is already +/// encoded in (as it is for Windows Terminal). +/// +public sealed record TerminalLaunchOption(string FileName, string? Arguments, string? WorkingDirectory); diff --git a/MSIXplainer.Core/Services/TerminalLauncher.cs b/MSIXplainer.Core/Services/TerminalLauncher.cs new file mode 100644 index 0000000..e9c4072 --- /dev/null +++ b/MSIXplainer.Core/Services/TerminalLauncher.cs @@ -0,0 +1,35 @@ +using MSIXplainer.Models; + +namespace MSIXplainer.Services; + +/// +/// Builds an ordered list of terminal launch candidates for a folder. The UI +/// layer tries each candidate in turn until one starts, so a machine without +/// Windows Terminal still falls back to PowerShell and finally cmd.exe (both +/// of which ship with every supported Windows release). +/// +public static class TerminalLauncher +{ + /// + /// Returns the terminals to try, most-preferred first, for opening + /// . Returns an empty list when the path is + /// null/blank so callers can skip launching entirely. + /// + public static IReadOnlyList GetLaunchOptions(string? folderPath) + { + if (string.IsNullOrWhiteSpace(folderPath)) + return []; + + var path = folderPath.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + + return + [ + // Windows Terminal opens directly in the folder via -d. + new TerminalLaunchOption("wt.exe", $"-d \"{path}\"", WorkingDirectory: null), + // PowerShell always ships with Windows; -NoExit keeps the window open. + new TerminalLaunchOption("powershell.exe", "-NoExit", WorkingDirectory: path), + // cmd.exe is the last-resort fallback. + new TerminalLaunchOption("cmd.exe", Arguments: null, WorkingDirectory: path), + ]; + } +} diff --git a/MSIXplainer/MainPage.xaml b/MSIXplainer/MainPage.xaml index 3a0b7a7..84ba79a 100644 --- a/MSIXplainer/MainPage.xaml +++ b/MSIXplainer/MainPage.xaml @@ -97,6 +97,16 @@ + + + + + + + + + diff --git a/MSIXplainer/MainPage.xaml.cs b/MSIXplainer/MainPage.xaml.cs index b61f4e4..75acf8f 100644 --- a/MSIXplainer/MainPage.xaml.cs +++ b/MSIXplainer/MainPage.xaml.cs @@ -178,6 +178,38 @@ private void OnInstalledAppClick(object sender, ItemClickEventArgs e) } } + // Opens the selected app's install folder in a terminal. Tries Windows + // Terminal first, then falls back to PowerShell / cmd (see TerminalLauncher). + private void OnOpenInstallFolderInTerminal(object sender, RoutedEventArgs e) + { + if (sender is not FrameworkElement { DataContext: InstalledPackage pkg }) + return; + + foreach (var option in Services.TerminalLauncher.GetLaunchOptions(pkg.InstallLocation)) + { + try + { + var psi = new System.Diagnostics.ProcessStartInfo + { + FileName = option.FileName, + UseShellExecute = true, + }; + if (!string.IsNullOrEmpty(option.Arguments)) + psi.Arguments = option.Arguments; + if (!string.IsNullOrEmpty(option.WorkingDirectory)) + psi.WorkingDirectory = option.WorkingDirectory; + + System.Diagnostics.Process.Start(psi); + return; + } + catch (System.Exception ex) + { + System.Diagnostics.Debug.WriteLine( + $"[MSIXplainer] Terminal launch '{option.FileName}' failed: {ex.Message}"); + } + } + } + private void EnterCompareMode() { ViewModel.IsCompareMode = true;