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
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,18 @@ updates:
commit-message:
prefix: "deps(uitests)"

- package-ecosystem: "nuget"
directory: "/SysManager/SysManager.IntegrationTests"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 3
labels:
- "dependencies"
- "tests"
commit-message:
prefix: "deps(integration)"

# Keep GitHub Actions current.
- package-ecosystem: "github-actions"
directory: "/"
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

## [0.48.22] - 2026-05-15

### Fixed
- **AppAlertService** — `NewAppDetected` event now marshaled to the UI thread
via captured `SynchronizationContext`, preventing crashes when
`FileSystemWatcher`/`Timer` callbacks invoke subscribers directly.
- **NetworkRepairService** — added `SemaphoreSlim` gate to serialize
subscribe/unsubscribe on the shared `PowerShellRunner`, preventing
concurrent calls from interleaving output.
- **PerformanceService** — same `SemaphoreSlim` serialization for all methods
that subscribe to `PowerShellRunner.LineReceived`.
- **PowerShellRunner** — documented that `LineReceived` fires on thread-pool
threads; subscribers must marshal to the dispatcher for UI updates.
- **StartupService** — added `RuntimeBinderException` catch for dynamic COM
shortcut resolution (`.lnk` files with broken targets).
- **StartupService** — `GetAwaiter().GetResult()` on stderr task now guarded
with a 5-second timeout to prevent hangs if the pipe isn't fully drained.
- **AppAlertsViewModel** — use `Application.Current.Dispatcher` instead of
`Dispatcher.CurrentDispatcher` to avoid capturing the wrong dispatcher.
- **NetworkSharedState** — documented that `FlushPending` direct-call path
(when `Dispatcher == null`) is intentional for unit tests / headless mode.
- **AboutViewModel** — removed auto-download of updates without user consent;
user must now explicitly click Download.
- **App.xaml.cs** — single-instance activation now uses a named pipe listener,
fixing activation when the window is minimized to tray (no `MainWindowHandle`).
- **MainWindow.xaml.cs** — ViewModel disposal now also hooks
`Application.Current.Exit` as a safety net for when `OnClosed` is not called.

### Changed
- **SysManager.csproj** — version updated from 0.12.1 to 0.48.21 (cosmetic;
auto-release overrides at build time).
- **SysManager.Tests.csproj** — xunit bumped from 2.5.3 to 2.9.3 (matches
UITests project).
- **SysManager.IntegrationTests.csproj** — xunit bumped from 2.5.3 to 2.9.3.
- **dependabot.yml** — added `IntegrationTests` directory entry for NuGet
dependency monitoring.

## [0.48.21] - 2026-05-15

### Fixed
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>

Expand Down
2 changes: 1 addition & 1 deletion SysManager/SysManager.Tests/SysManager.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="10.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageReference Include="xunit" Version="2.5.3" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageReference Include="Xunit.StaFact" Version="1.1.11" />
</ItemGroup>
Expand Down
52 changes: 52 additions & 0 deletions SysManager/SysManager/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// License: MIT

using System.Diagnostics;
using System.IO;
using System.IO.Pipes;
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Threading;
Expand All @@ -14,8 +16,10 @@ namespace SysManager;
public partial class App : Application
{
private const string MutexName = "Global\\SysManager_SingleInstance_laurentiu021";
private const string PipeName = "SysManager_SingleInstance_Pipe_laurentiu021";
private Mutex? _instanceMutex;
private TrayIconService? _trayService;
private CancellationTokenSource? _pipeCts;

// Guard against cascading error dialogs — show at most one at a time.
private static int _errorDialogActive;
Expand Down Expand Up @@ -69,10 +73,15 @@ protected override void OnStartup(StartupEventArgs e)
AppDomain.CurrentDomain.UnhandledException += OnDomain;
TaskScheduler.UnobservedTaskException += OnTask;
base.OnStartup(e);

// Start listening for activation requests from subsequent instances
StartPipeListener();
}

protected override void OnExit(ExitEventArgs e)
{
_pipeCts?.Cancel();
_pipeCts?.Dispose();
_trayService?.Dispose();
(Services as IDisposable)?.Dispose();
LogService.Shutdown();
Expand All @@ -84,6 +93,17 @@ protected override void OnExit(ExitEventArgs e)

private static void ActivateExistingInstance()
{
// Try named pipe first — works even when the window is hidden (tray mode)
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(timeout: 2000);
// Connection alone signals the running instance to activate
}
catch (TimeoutException) { /* pipe not available, fall back to window activation */ }
catch (IOException) { /* pipe not available */ }
Comment on lines +96 to +104

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Return immediately after a successful pipe signal.

Lines 96-104 still fall through into the process/window scan after client.Connect(...) succeeds, so a normal second launch can trigger both activation paths instead of using the Win32 path only as fallback. Adding an early return; keeps the old logic as a true backup path.

Proposed fix
         try
         {
             using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
             client.Connect(timeout: 2000);
             // Connection alone signals the running instance to activate
+            return;
         }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Try named pipe first — works even when the window is hidden (tray mode)
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(timeout: 2000);
// Connection alone signals the running instance to activate
}
catch (TimeoutException) { /* pipe not available, fall back to window activation */ }
catch (IOException) { /* pipe not available */ }
// Try named pipe first — works even when the window is hidden (tray mode)
try
{
using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out);
client.Connect(timeout: 2000);
// Connection alone signals the running instance to activate
return;
}
catch (TimeoutException) { /* pipe not available, fall back to window activation */ }
catch (IOException) { /* pipe not available */ }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/App.xaml.cs` around lines 96 - 104, After successfully
signaling the running instance via the named pipe, return immediately so the
method does not continue to the process/window scan fallback; specifically,
inside the try block that creates the NamedPipeClientStream (using PipeName and
PipeDirection.Out) and calls client.Connect(...), add an early return right
after the successful client.Connect(...) call to ensure the Win32 fallback only
runs when the pipe path fails.


// Fallback: find the window handle (works when window is visible)
using var current = Process.GetCurrentProcess();
foreach (var proc in Process.GetProcessesByName(current.ProcessName))
{
Expand All @@ -100,6 +120,38 @@ private static void ActivateExistingInstance()
}
}

/// <summary>
/// Listens for named pipe connections from subsequent instances and
/// activates the main window when one connects.
/// </summary>
private async void StartPipeListener()
{
_pipeCts = new CancellationTokenSource();
var ct = _pipeCts.Token;
try
{
while (!ct.IsCancellationRequested)
{
await using var server = new NamedPipeServerStream(
PipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(ct).ConfigureAwait(false);

// A second instance connected — activate our window on the UI thread
_ = Dispatcher.BeginInvoke(() =>
{
var win = MainWindow;
if (win != null)
{
TrayIconService.ShowWindow(win);
}
});
}
}
catch (OperationCanceledException) { /* shutdown */ }
catch (IOException) { /* pipe broken during shutdown */ }
}

private static void OnUi(object s, DispatcherUnhandledExceptionEventArgs e)
{
LogService.Logger?.Error(e.Exception, "UI thread exception");
Expand Down
9 changes: 9 additions & 0 deletions SysManager/SysManager/MainWindow.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,15 @@ public partial class MainWindow : Window
public MainWindow()
{
InitializeComponent();

// Ensure ViewModel disposal even if OnClosed is not called (e.g. app shutdown)
if (Application.Current != null)
Application.Current.Exit += OnApplicationExit;
}

private void OnApplicationExit(object sender, ExitEventArgs e)
{
(DataContext as MainWindowViewModel)?.Dispose();
}
Comment on lines +21 to 29

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify whether MainWindowViewModel.Dispose() is idempotent and whether duplicate disposal is currently possible.

set -euo pipefail

echo "== MainWindow exit/dispose call sites =="
fd -i '^MainWindow\.xaml\.cs$' --exec rg -n -C3 --type=cs 'Exit \+=|OnApplicationExit|OnClosed|Dispose\s*\(' {}

echo
echo "== MainWindowViewModel dispose implementation =="
for f in $(fd -i '^MainWindowViewModel\.cs$'); do
  echo "-- $f --"
  rg -n -C5 --type=cs '\bclass\s+MainWindowViewModel\b|\bvoid\s+Dispose\s*\(|\bbool\s+_disposed\b|Interlocked|GC\.SuppressFinalize' "$f"
done

echo
echo "Expected verification result:"
echo "1) Dispose implementation has an idempotency guard (_disposed/Interlocked/etc.), OR"
echo "2) MainWindow is updated to ensure dispose is invoked exactly once."

Repository: laurentiu021/SystemManager

Length of output: 2067


Add an idempotency guard to MainWindowViewModel.Dispose() or ensure single invocation.

On normal shutdown, both OnApplicationExit (line 28) and OnClosed (line 94) call Dispose() on the same MainWindowViewModel instance. The Dispose() implementation has no guard against multiple calls and will attempt to dispose already-disposed items, risking ObjectDisposedException.

Either:

  1. Add a _disposed boolean guard in MainWindowViewModel.Dispose() to make it idempotent, or
  2. Unsubscribe from Application.Current.Exit in OnClosed to ensure disposal runs exactly once.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/MainWindow.xaml.cs` around lines 21 - 29, The viewmodel
may be disposed twice because both OnApplicationExit (hooked to
Application.Current.Exit) and MainWindow.OnClosed call (DataContext as
MainWindowViewModel)?.Dispose(); fix by making disposal idempotent: add a
private bool _disposed field and check/set it inside
MainWindowViewModel.Dispose() so subsequent calls return immediately, or
alternatively unsubscribe Application.Current.Exit in MainWindow.OnClosed
(Application.Current.Exit -= OnApplicationExit) before calling Dispose() to
guarantee a single invocation; reference MainWindowViewModel.Dispose(),
MainWindow.OnClosed, and OnApplicationExit/Application.Current.Exit when making
the change.


/// <summary>
Expand Down
30 changes: 27 additions & 3 deletions SysManager/SysManager/Services/AppAlertService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,24 @@ public sealed class AppAlertService : IDisposable
private readonly object _watcherLock = new();
private readonly ConcurrentDictionary<string, bool> _knownFolders = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentDictionary<string, bool> _knownRegistryApps = new(StringComparer.OrdinalIgnoreCase);
private readonly SynchronizationContext? _syncContext;
private Timer? _registryTimer;
private bool _disposed;

/// <summary>Raised when a new application installation is detected.</summary>
/// <summary>Raised when a new application installation is detected.
/// Marshaled to the <see cref="SynchronizationContext"/> captured at construction
/// (typically the UI thread) so subscribers can safely update UI elements.</summary>
public event Action<AppInstallEntry>? NewAppDetected;

/// <summary>
/// Creates a new instance, capturing the current <see cref="SynchronizationContext"/>
/// so that events are raised on the UI thread.
/// </summary>
public AppAlertService()
{
_syncContext = SynchronizationContext.Current;
}

/// <summary>
/// Takes a snapshot of currently installed apps (baseline).
/// Call this before starting monitoring.
Expand Down Expand Up @@ -161,7 +173,7 @@ private void OnDirectoryCreated(object sender, FileSystemEventArgs e)
};

Log.Information("New app folder detected: {Path}", e.FullPath);
NewAppDetected?.Invoke(entry);
RaiseNewAppDetected(entry);
}

private void CheckRegistry(object? state)
Expand All @@ -175,14 +187,26 @@ private void CheckRegistry(object? state)

app.DetectedAt = DateTime.Now;
Log.Information("New app detected in registry: {Name}", app.Name);
NewAppDetected?.Invoke(app);
RaiseNewAppDetected(app);
}
}
catch (IOException) { /* registry read failed — retry next cycle */ }
catch (UnauthorizedAccessException) { /* registry read failed — retry next cycle */ }
catch (System.Security.SecurityException) { /* registry read failed — retry next cycle */ }
}

/// <summary>
/// Raises <see cref="NewAppDetected"/> on the captured synchronization context
/// (UI thread) if available, otherwise invokes directly on the current thread.
/// </summary>
private void RaiseNewAppDetected(AppInstallEntry entry)
{
if (_syncContext != null)
_syncContext.Post(_ => NewAppDetected?.Invoke(entry), null);
else
NewAppDetected?.Invoke(entry);
}

private static List<string> GetMonitoredDirectories()
{
var dirs = new List<string>();
Expand Down
10 changes: 7 additions & 3 deletions SysManager/SysManager/Services/NetworkRepairService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ namespace SysManager.Services;
public sealed class NetworkRepairService
{
private readonly PowerShellRunner _ps;
private readonly SemaphoreSlim _gate = new(1, 1);

public NetworkRepairService(PowerShellRunner ps) => _ps = ps;

Expand All @@ -21,6 +22,7 @@ public sealed class NetworkRepairService
/// </summary>
public async Task<NetworkRepairResult> FlushDnsAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
var output = new System.Collections.Concurrent.ConcurrentQueue<string>();
void OnLine(PowerShellLine line) => output.Enqueue(line.Text);
_ps.LineReceived += OnLine;
Expand All @@ -34,14 +36,15 @@ public async Task<NetworkRepairResult> FlushDnsAsync(CancellationToken ct = defa
string.Join(Environment.NewLine, output),
NeedsReboot: false);
}
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _gate.Release(); }
}

/// <summary>
/// Reset the Winsock catalog. Requires a reboot to take effect.
/// </summary>
public async Task<NetworkRepairResult> ResetWinsockAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
var output = new System.Collections.Concurrent.ConcurrentQueue<string>();
void OnLine(PowerShellLine line) => output.Enqueue(line.Text);
_ps.LineReceived += OnLine;
Expand All @@ -55,14 +58,15 @@ public async Task<NetworkRepairResult> ResetWinsockAsync(CancellationToken ct =
string.Join(Environment.NewLine, output),
NeedsReboot: true);
}
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _gate.Release(); }
}

/// <summary>
/// Reset the TCP/IP stack. Requires a reboot to take effect.
/// </summary>
public async Task<NetworkRepairResult> ResetTcpIpAsync(CancellationToken ct = default)
{
await _gate.WaitAsync(ct).ConfigureAwait(false);
var output = new System.Collections.Concurrent.ConcurrentQueue<string>();
void OnLine(PowerShellLine line) => output.Enqueue(line.Text);
_ps.LineReceived += OnLine;
Expand All @@ -76,6 +80,6 @@ public async Task<NetworkRepairResult> ResetTcpIpAsync(CancellationToken ct = de
string.Join(Environment.NewLine, output),
NeedsReboot: true);
}
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _gate.Release(); }
}
}
13 changes: 9 additions & 4 deletions SysManager/SysManager/Services/PerformanceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ namespace SysManager.Services;
public sealed class PerformanceService
{
private readonly PowerShellRunner _ps;
private readonly SemaphoreSlim _psGate = new(1, 1);

// ── Well-known power plan GUIDs ──
internal const string BalancedGuid = "381b4222-f694-41f0-9685-ff5bb260df2e";
Expand Down Expand Up @@ -163,11 +164,12 @@ public async Task<PerformanceProfile> ReadProfileAsync(CancellationToken ct = de
/// <summary>Parse active plan from powercfg /getactivescheme output.</summary>
public async Task<(string Name, string Guid)> GetActivePlanAsync(CancellationToken ct = default)
{
await _psGate.WaitAsync(ct).ConfigureAwait(false);
var lines = new List<string>();
void OnLine(PowerShellLine l) => lines.Add(l.Text);
_ps.LineReceived += OnLine;
try { await _ps.RunProcessAsync("powercfg.exe", "/getactivescheme", ct, PowerShellRunner.OemEncoding); }
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _psGate.Release(); }
Comment on lines +167 to +172

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Wrap handler subscription in the same try/finally as gate ownership.

Right now, WaitAsync succeeds before try, and handler subscription also happens before try. If subscription throws (e.g., disposal race), the semaphore is never released, blocking future calls.

Suggested pattern (apply to all four methods)
 await _psGate.WaitAsync(ct).ConfigureAwait(false);
 var lines = new List<string>();
 void OnLine(PowerShellLine l) => lines.Add(l.Text);
-_ps.LineReceived += OnLine;
-try { await _ps.RunProcessAsync("powercfg.exe", "/getactivescheme", ct, PowerShellRunner.OemEncoding); }
-finally { _ps.LineReceived -= OnLine; _psGate.Release(); }
+var subscribed = false;
+try
+{
+    _ps.LineReceived += OnLine;
+    subscribed = true;
+    await _ps.RunProcessAsync("powercfg.exe", "/getactivescheme", ct, PowerShellRunner.OemEncoding).ConfigureAwait(false);
+}
+finally
+{
+    if (subscribed) _ps.LineReceived -= OnLine;
+    _psGate.Release();
+}

Also applies to: 217-220, 238-243, 468-477

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@SysManager/SysManager/Services/PerformanceService.cs` around lines 167 - 172,
The semaphore (_psGate) is acquired before the try and the event handler (OnLine
-> _ps.LineReceived += OnLine) is subscribed outside the try, so if subscription
throws the semaphore is never released; fix by moving the handler subscription
inside the try block immediately after await _psGate.WaitAsync(ct) (i.e.,
acquire gate, then enter try where you subscribe the handler), and ensure the
finally block always removes the handler (_ps.LineReceived -= OnLine) and
releases the gate (_psGate.Release()); apply the same pattern to the other
methods that subscribe to _ps.LineReceived (the blocks around RunProcessAsync
and their corresponding OnLine handlers).


return ParseActivePlan(lines);
}
Expand Down Expand Up @@ -212,9 +214,10 @@ public async Task<string> EnsureUltimatePerformancePlanAsync(CancellationToken c

var lines = new List<string>();
void OnLine(PowerShellLine l) => lines.Add(l.Text);
await _psGate.WaitAsync(ct).ConfigureAwait(false);
_ps.LineReceived += OnLine;
try { await _ps.RunProcessAsync("powercfg.exe", $"-duplicatescheme {UltimatePerfScheme}", ct, PowerShellRunner.OemEncoding); }
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _psGate.Release(); }

// Parse GUID from output: "Power Scheme GUID: <guid> (Ultimate Performance)"
foreach (var line in lines)
Expand All @@ -232,11 +235,12 @@ public async Task<string> EnsureUltimatePerformancePlanAsync(CancellationToken c
/// <summary>Find a plan GUID by name substring.</summary>
public async Task<string?> FindPlanGuidByNameAsync(string nameSubstring, CancellationToken ct = default)
{
await _psGate.WaitAsync(ct).ConfigureAwait(false);
var lines = new List<string>();
void OnLine(PowerShellLine l) => lines.Add(l.Text);
_ps.LineReceived += OnLine;
try { await _ps.RunProcessAsync("powercfg.exe", "/list", ct, PowerShellRunner.OemEncoding); }
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _psGate.Release(); }

return ParsePlanGuidByName(lines, nameSubstring);
}
Expand Down Expand Up @@ -461,6 +465,7 @@ public static bool SetGpuMaxPerformance(string subKey, bool maxPerformance)
/// <summary>Read the current processor minimum state percentage (AC).</summary>
internal async Task<int> ReadProcessorMinPercentAsync(CancellationToken ct = default)
{
await _psGate.WaitAsync(ct).ConfigureAwait(false);
var lines = new List<string>();
void OnLine(PowerShellLine l) => lines.Add(l.Text);
_ps.LineReceived += OnLine;
Expand All @@ -469,7 +474,7 @@ internal async Task<int> ReadProcessorMinPercentAsync(CancellationToken ct = def
await _ps.RunProcessAsync("powercfg.exe",
"/query SCHEME_CURRENT SUB_PROCESSOR PROCTHROTTLEMIN", ct, PowerShellRunner.OemEncoding).ConfigureAwait(false);
}
finally { _ps.LineReceived -= OnLine; }
finally { _ps.LineReceived -= OnLine; _psGate.Release(); }

return ParseProcessorMinPercent(lines);
}
Expand Down
5 changes: 5 additions & 0 deletions SysManager/SysManager/Services/PowerShellRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ namespace SysManager.Services;
/// </summary>
public sealed class PowerShellRunner
{
/// <summary>
/// Raised for each line of output from any stream (stdout, stderr, information,
/// warning, error, verbose, debug, progress). Fires on a thread-pool thread —
/// subscribers that update UI elements must marshal to the dispatcher.
/// </summary>
public event Action<PowerShellLine>? LineReceived;
public event Action<int>? ProgressChanged; // 0-100

Expand Down
Loading
Loading