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
84 changes: 80 additions & 4 deletions Source/NETworkManager/ViewModels/PowerShellHostViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public class PowerShellHostViewModel : ViewModelBase, IProfileManager
#region Variables
private static readonly ILog Log = LogManager.GetLogger(typeof(PowerShellHostViewModel));

private readonly IDialogCoordinator _dialogCoordinator;
private readonly DispatcherTimer _searchDispatcherTimer = new();

public IInterTabClient InterTabClient { get; }
Expand Down Expand Up @@ -307,16 +306,15 @@ public bool ProfileContextMenuIsOpen

#region Constructor, load settings

public PowerShellHostViewModel(IDialogCoordinator instance)
public PowerShellHostViewModel()
{
_isLoading = true;

_dialogCoordinator = instance;

// Check if PowerShell executable is configured
CheckExecutable();

// Try to find PowerShell executable

if (!IsExecutableConfigured)
TryFindExecutable();

Expand Down Expand Up @@ -569,8 +567,24 @@ private void TryFindExecutable()

var applicationFilePath = ApplicationHelper.Find(PowerShell.PwshFileName);

// Workaround for: https://github.com/BornToBeRoot/NETworkManager/issues/3223
if (applicationFilePath.EndsWith("AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"))
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path check using .EndsWith() is case-sensitive and may not handle all variations of the path (e.g., lowercase drive letters, forward slashes). Consider using a more robust check:

if (!string.IsNullOrEmpty(applicationFilePath) && 
    applicationFilePath.Contains("\\AppData\\Local\\Microsoft\\WindowsApps\\", StringComparison.OrdinalIgnoreCase) &&
    applicationFilePath.EndsWith("pwsh.exe", StringComparison.OrdinalIgnoreCase))

This handles case variations and ensures we're matching the correct directory structure.

Suggested change
if (applicationFilePath.EndsWith("AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe"))
var normalizedPath = applicationFilePath?.Replace('/', '\\');
if (!string.IsNullOrEmpty(normalizedPath) &&
normalizedPath.EndsWith("AppData\\Local\\Microsoft\\WindowsApps\\pwsh.exe", StringComparison.OrdinalIgnoreCase))

Copilot uses AI. Check for mistakes.
{
Log.Info("Found pwsh.exe in AppData (Microsoft Store installation). Trying to resolve real path...");

var realPwshPath = FindRealPwshPath(applicationFilePath);

if (realPwshPath != null)
applicationFilePath = realPwshPath;
Comment on lines +577 to +578
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Logic issue: If FindRealPwshPath returns null (line 575), the code keeps the WindowsApps stub path in applicationFilePath and proceeds to set it in the settings (line 585). This means the app will still try to use the stub path instead of falling back to Windows PowerShell.

Consider updating the logic:

if (realPwshPath != null)
    applicationFilePath = realPwshPath;
else
{
    Log.Warn("Failed to resolve real pwsh path. Falling back to Windows PowerShell.");
    applicationFilePath = ApplicationHelper.Find(PowerShell.WindowsPowerShellFileName);
}
Suggested change
if (realPwshPath != null)
applicationFilePath = realPwshPath;
if (realPwshPath != null)
{
applicationFilePath = realPwshPath;
}
else
{
Log.Warn("Failed to resolve real pwsh path. Falling back to Windows PowerShell.");
applicationFilePath = ApplicationHelper.Find(PowerShell.WindowsPowerShellFileName);
}

Copilot uses AI. Check for mistakes.
}

// Fallback to Windows PowerShell
if (string.IsNullOrEmpty(applicationFilePath))
{
Log.Warn("Failed to resolve pwsh.exe path. Falling back to Windows PowerShell.");

applicationFilePath = ApplicationHelper.Find(PowerShell.WindowsPowerShellFileName);
}

SettingsManager.Current.PowerShell_ApplicationFilePath = applicationFilePath;

Expand All @@ -580,6 +594,68 @@ private void TryFindExecutable()
Log.Warn("Install PowerShell or configure the path in the settings.");
}

/// <summary>
/// Resolves the actual installation path of a PowerShell executable that was installed via the
/// Microsoft Store / WindowsApps and therefore appears as a proxy stub in the user's AppData.
///
/// Typical input is a path like:
/// <c>C:\Users\{USERNAME}\AppData\Local\Microsoft\WindowsApps\pwsh.exe</c>
///
/// This helper attempts to locate the corresponding real executable under the Program Files
/// WindowsApps package layout, e.g.:
/// <c>C:\Program Files\WindowsApps\Microsoft.PowerShell_7.*_8wekyb3d8bbwe\pwsh.exe</c>.
///
/// Workaround for: https://github.com/BornToBeRoot/NETworkManager/issues/3223
/// </summary>
/// <param name="path">Path to the pwsh proxy stub, typically located under the current user's <c>%LocalAppData%\Microsoft\WindowsApps\pwsh.exe</c>.</param>
/// <returns>Full path to the real pwsh executable under Program Files WindowsApps when found; otherwise null.</returns>
private string FindRealPwshPath(string path)
{
try
{
var command = "(Get-Command pwsh).Source";

ProcessStartInfo psi = new()
{
FileName = path,
Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{command}\"",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};

using Process process = Process.Start(psi);

string output = process.StandardOutput.ReadToEnd();

if(!process.WaitForExit(10000))
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Missing space after if keyword. Should be if (!process.WaitForExit(10000)) for consistency with C# formatting conventions.

Suggested change
if(!process.WaitForExit(10000))
if (!process.WaitForExit(10000))

Copilot uses AI. Check for mistakes.
Comment on lines +629 to +631
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading StandardOutput before calling WaitForExit() can cause a deadlock if the output buffer fills up. The process will block waiting for the buffer to be read, while this code blocks waiting for the process to exit. Use WaitForExit() first, then read the output, or read the output asynchronously.

Copilot uses AI. Check for mistakes.
{
process.Kill();
Log.Warn("Timeout while trying to resolve real pwsh path.");

return null;
}

if (string.IsNullOrEmpty(output))
return null;

output = output.Replace(@"\\", @"\")
.Replace(@"\r", string.Empty)
.Replace(@"\n", string.Empty)
.Replace("\r\n", string.Empty)
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);
Comment on lines +643 to +647
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The string replacement logic is redundant and inefficient. Lines 643-647 replace \r, \n, and \r\n multiple times in different forms. Line 645 replaces \r\n which makes lines 646-647 redundant since they would have no effect. Consider simplifying this to a single set of replacements: first \r\n, then \r, then \n.

Suggested change
.Replace(@"\r", string.Empty)
.Replace(@"\n", string.Empty)
.Replace("\r\n", string.Empty)
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);
.Replace("\r\n", string.Empty)
.Replace("\r", string.Empty)
.Replace("\n", string.Empty);

Copilot uses AI. Check for mistakes.

return output.Trim();
Comment on lines +627 to +649
Copy link

Copilot AI Nov 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential resource leak here. If Process.Start(psi) returns null (which can happen if the process fails to start), the null reference will cause an exception before reaching the using statement's disposal. Additionally, if WaitForExit returns false and the process is killed, StandardError is never read, which could lead to deadlock if the error stream buffer fills up. Consider checking for null after Process.Start() and reading StandardError before or alongside StandardOutput.

Suggested change
using Process process = Process.Start(psi);
string output = process.StandardOutput.ReadToEnd();
if(!process.WaitForExit(10000))
{
process.Kill();
Log.Warn("Timeout while trying to resolve real pwsh path.");
return null;
}
if (string.IsNullOrEmpty(output))
return null;
output = output.Replace(@"\\", @"\")
.Replace(@"\r", string.Empty)
.Replace(@"\n", string.Empty)
.Replace("\r\n", string.Empty)
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);
return output.Trim();
Process process = Process.Start(psi);
if (process == null)
{
Log.Error("Failed to start pwsh process to resolve real pwsh path.");
return null;
}
using (process)
{
// Read both output and error streams asynchronously to avoid deadlocks
Task<string> outputTask = process.StandardOutput.ReadToEndAsync();
Task<string> errorTask = process.StandardError.ReadToEndAsync();
bool exited = process.WaitForExit(10000);
if (!exited)
{
process.Kill();
Log.Warn("Timeout while trying to resolve real pwsh path.");
}
// Ensure both output and error are read
Task.WaitAll(outputTask, errorTask);
string output = outputTask.Result;
string error = errorTask.Result;
if (!string.IsNullOrEmpty(error))
{
Log.Warn($"Error while resolving real pwsh path: {error}");
}
if (string.IsNullOrEmpty(output))
return null;
output = output.Replace(@"\\", @"\")
.Replace(@"\r", string.Empty)
.Replace(@"\n", string.Empty)
.Replace("\r\n", string.Empty)
.Replace("\n", string.Empty)
.Replace("\r", string.Empty);
return output.Trim();
}

Copilot uses AI. Check for mistakes.
}
catch (Exception ex)
{
Log.Error($"Failed to resolve real pwsh path: {ex.Message}");

return null;
}
}

private Task Connect(string host = null)
{
var childWindow = new PowerShellConnectChildWindow();
Expand Down
2 changes: 0 additions & 2 deletions Source/NETworkManager/Views/PowerShellHostView.xaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
xmlns:iconPacks="http://metro.mahapps.com/winfx/xaml/iconpacks"
xmlns:converters="clr-namespace:NETworkManager.Converters;assembly=NETworkManager.Converters"
xmlns:controls="clr-namespace:NETworkManager.Controls;assembly=NETworkManager.Controls"
xmlns:dialogs="clr-namespace:MahApps.Metro.Controls.Dialogs;assembly=MahApps.Metro"
xmlns:viewModels="clr-namespace:NETworkManager.ViewModels"
xmlns:localization="clr-namespace:NETworkManager.Localization.Resources;assembly=NETworkManager.Localization"
xmlns:settings="clr-namespace:NETworkManager.Settings;assembly=NETworkManager.Settings"
Expand All @@ -18,7 +17,6 @@
xmlns:profiles="clr-namespace:NETworkManager.Profiles;assembly=NETworkManager.Profiles"
xmlns:wpfHelpers="clr-namespace:NETworkManager.Utilities.WPF;assembly=NETworkManager.Utilities.WPF"
xmlns:networkManager="clr-namespace:NETworkManager"
dialogs:DialogParticipation.Register="{Binding}"
Loaded="UserControl_Loaded"
mc:Ignorable="d" d:DataContext="{d:DesignInstance viewModels:PowerShellHostViewModel}">
<UserControl.Resources>
Expand Down
5 changes: 2 additions & 3 deletions Source/NETworkManager/Views/PowerShellHostView.xaml.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using MahApps.Metro.Controls.Dialogs;
using NETworkManager.ViewModels;
using NETworkManager.ViewModels;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
Expand All @@ -9,7 +8,7 @@ namespace NETworkManager.Views;

public partial class PowerShellHostView
{
private readonly PowerShellHostViewModel _viewModel = new(DialogCoordinator.Instance);
private readonly PowerShellHostViewModel _viewModel = new();

private bool _loaded;

Expand Down
9 changes: 8 additions & 1 deletion Website/docs/changelog/next-release.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,14 @@ Release date: **xx.xx.2025**

## Bug Fixes

- The new profile filter popup introduced in version `2025.10.18.0` was instantly closed when a `PuTTY`, `PowerShell` or `AWS Session Manager` session was opened and the respective application / view was selected. [#3219](https://github.com/BornToBeRoot/NETworkManager/pull/3219)
**PowerShell**

- Resolve the actual path to `pwsh.exe` under `C:\Program Files\WindowsApps\` instead of relying on the stub located at `%LocalAppData%\Microsoft\WindowsApps\`. The stub simply redirects to the real executable, and settings such as themes are applied only to the real binary via the registry. [#3246](https://github.com/BornToBeRoot/NETworkManager/pull/3246)
- The new profile filter popup introduced in version `2025.10.18.0` was instantly closed when a `PowerShell` session was opened and the respective application / view was selected. [#3219](https://github.com/BornToBeRoot/NETworkManager/pull/3219)

**PuTTY**

- The new profile filter popup introduced in version `2025.10.18.0` was instantly closed when a `PuTTY` session was opened and the respective application / view was selected. [#3219](https://github.com/BornToBeRoot/NETworkManager/pull/3219)

## Dependencies, Refactoring & Documentation

Expand Down
Loading