Files
ClaudeDo/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs
T
mika kuns 57c61a2043 fix(ui): gate Mission Control submit-for-review, add retry, fix event leak
Submit for Review is now disabled while a ConPTY pane is starting, has
failed to launch, or has already exited, and MissionControlViewModel
guards against a rapid double-click racing two SubmitTaskForReviewAsync
calls. A failed launch no longer permanently occupies its TaskId dedupe
slot -- a Retry button re-fetches the launch spec and restarts the pane
in place. CloseConPtySession/Dispose now also unsubscribe
SubmitForReviewRequested, matching the other pane event handlers. Also
fixes the pre-existing nullable-dereference warning in
IslandsShellViewModel.SyncInteractiveSessionChips.
2026-08-06 13:42:06 +02:00

664 lines
25 KiB
C#

using Avalonia.Threading;
using System;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel? Tasks { get; }
public DetailsIslandViewModel? Details { get; }
public IWorkerClient? Worker { get; }
public MissionControlViewModel? MissionControl { get; }
public UsagePillViewModel? UsagePill { get; }
public UpdateCheckService UpdateCheck => _updateCheck;
public string ConnectionText =>
Worker?.IsConnected == true ? Loc.T("vm.connection.online")
: Worker?.IsReconnecting == true ? Loc.T("vm.connection.connecting")
: Loc.T("vm.connection.offline");
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
private readonly GitService? _git;
private readonly IDbContextFactory<ClaudeDoDbContext>? _dbFactory;
private readonly Func<WorktreesOverviewModalViewModel> _worktreesOverviewVmFactory = () => null!;
private readonly Func<WeeklyReportModalViewModel> _weeklyReportVmFactory = () => null!;
private readonly Func<UsageMonitorModalViewModel> _usageMonitorVmFactory = () => null!;
private readonly Func<MergeModalViewModel> _mergeVmFactory = () => null!;
private readonly Func<RepoImportModalViewModel>? _repoImportVmFactory;
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
// Layer C seam: composition root sets the factory; the dialog service shows the resolver.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
// Set by MainWindow so a reveal can bring the main window to the foreground.
public Action? BringToFront { get; set; }
// Single dialog seam (set by MainWindow); propagated to the lists island.
private IDialogService? _dialogs;
public IDialogService? Dialogs
{
get => _dialogs;
set
{
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
}
}
public async Task RevealTaskAsync(string taskId)
{
if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; }
string? listId = null;
if (_dbFactory is not null)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
listId = entity?.ListId;
}
catch { /* best-effort list resolution */ }
}
if (listId is not null)
{
var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}");
if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem))
Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList
}
await Tasks.SelectByIdAsync(taskId);
BringToFront?.Invoke();
}
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await Dialogs.ShowConflictResolverAsync(vm);
}
[ObservableProperty] private bool _isUpdateBannerVisible;
[ObservableProperty] private string? _updateBannerLatestVersion;
private bool _bannerDismissedThisSession;
// Persistent (not auto-clearing) banner: the running worker predates the selected list's
// merged HEAD, so "verified" claims made against the current process are stale. ClaudeDo-repo
// only — see RefreshStaleWorkerCheckAsync.
[ObservableProperty] private bool _isStaleWorkerBannerVisible;
// planningTaskId -> subtaskId, for unit-merge conflicts an MCP session (not the UI) started.
// Kept as a dictionary so a later conflict on the same planning task updates in place instead
// of piling up duplicate entries.
private readonly Dictionary<string, string> _externalMergeConflicts = new();
[ObservableProperty] private bool _isExternalMergeBannerVisible;
[ObservableProperty]
private double _windowWidth = 1280;
[ObservableProperty]
private string? _workerLogText;
[ObservableProperty]
private WorkerLogLevel _workerLogLevel;
[ObservableProperty]
private bool _isWorkerLogVisible;
public bool ShowDetails => WindowWidth >= 1100;
public bool ShowLists => WindowWidth >= 780;
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
private readonly System.Timers.Timer _connectTimer = new(12_000) { AutoReset = false };
[ObservableProperty] private string? _primeStatus;
private readonly System.Timers.Timer _primeStatusTimer = new(5_000) { AutoReset = false };
[RelayCommand]
private void FocusSearch() => Lists?.RequestFocusSearch();
[RelayCommand]
private void FocusAddTask() => Tasks?.RequestFocusAddTask();
public async Task ToggleSelectedDoneAsync()
{
if (Tasks?.SelectedTask is { } row)
await Tasks.ToggleDoneCommand.ExecuteAsync(row);
}
partial void OnWindowWidthChanged(double value)
{
OnPropertyChanged(nameof(ShowDetails));
OnPropertyChanged(nameof(ShowLists));
}
public void OnWorkerLogReceived(WorkerLogEntry entry)
{
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
WorkerLogText = $"{hhmm} · {entry.Message}";
WorkerLogLevel = entry.Level;
IsWorkerLogVisible = true;
_clearTimer.Stop();
_clearTimer.Start();
}
public void ClearWorkerLog()
{
IsWorkerLogVisible = false;
WorkerLogText = null;
}
// Surfaces a UI-originated failure in the footer status strip (same line as the
// worker log), color-coded as an error and auto-cleared by _clearTimer.
public void FlashFooterError(string message)
{
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
WorkerLogLevel = WorkerLogLevel.Error;
IsWorkerLogVisible = true;
_clearTimer.Stop();
_clearTimer.Start();
}
private void OnPrimeFired(PrimeFiredEvent evt)
{
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
PrimeStatus = evt.Success
? $"✓ Primed Claude at {when}"
: $"⚠ Prime failed: {evt.Message}";
_primeStatusTimer.Stop();
_primeStatusTimer.Start();
}
public void OnPlanningMergeConflict(
string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles, bool externallyDriven)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
if (externallyDriven)
{
// An MCP session (review_task/continue_merge) is driving this merge — it owns
// resolution. Auto-opening the resolver here raced with the session's own writes
// (two parties editing the same shared checkout at once); show a banner instead and
// leave the resolver reachable only via a deliberate click.
_externalMergeConflicts[planningTaskId] = subtaskId;
IsExternalMergeBannerVisible = true;
return;
}
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
public void OnPlanningMergeAborted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
public void OnPlanningMergeCompleted(string planningTaskId) => ClearExternalMergeConflict(planningTaskId);
private void ClearExternalMergeConflict(string planningTaskId)
{
_externalMergeConflicts.Remove(planningTaskId);
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
/// <summary>Re-syncs the external-merge banner from the worker on (re)connect — the
/// one-shot PlanningMergeConflict broadcast isn't replayed after a UI restart, so this is
/// the recovery path. The worker checks MERGE_HEAD before reporting a conflict as active,
/// so a session that died mid-merge without cleaning up doesn't leave a stale banner up.</summary>
private async Task RefreshExternalMergeConflictsAsync()
{
if (Worker is null) return;
IReadOnlyList<PlanningMergeConflictStateDto> active;
try { active = await Worker.GetActiveExternalPlanningMergeConflictsAsync(); }
catch { return; }
_externalMergeConflicts.Clear();
foreach (var c in active)
_externalMergeConflicts[c.PlanningTaskId] = c.SubtaskId;
IsExternalMergeBannerVisible = _externalMergeConflicts.Count > 0;
}
[RelayCommand]
private Task OpenExternalMergeConflictAsync()
{
if (_externalMergeConflicts.Count == 0) return Task.CompletedTask;
var (planningTaskId, subtaskId) = _externalMergeConflicts.First();
return OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ConflictResolverFactory is null || Dialogs is null) return;
var vm = ConflictResolverFactory(subtaskId);
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
if (hasConflicts)
await Dialogs.ShowConflictResolverAsync(vm);
}
// For tests only — does NOT wire up events.
internal IslandsShellViewModel() { }
public IslandsShellViewModel(
ListsIslandViewModel lists,
TasksIslandViewModel tasks,
DetailsIslandViewModel details,
IWorkerClient worker,
UpdateCheckService updateCheck,
InstallerLocator installerLocator,
WorkerLocator workerLocator,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
Func<UsageMonitorModalViewModel> usageMonitorVmFactory,
Func<MergeModalViewModel> mergeVmFactory,
Func<RepoImportModalViewModel> repoImportVmFactory,
MissionControlViewModel missionControl,
UsagePillViewModel usagePill,
GitService? git = null)
{
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
MissionControl = missionControl;
UsagePill = usagePill;
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
MissionControl.ErrorReported += FlashFooterError;
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
_updateCheck = updateCheck;
_installerLocator = installerLocator;
_workerLocator = workerLocator;
_dbFactory = dbFactory;
_worktreesOverviewVmFactory = worktreesOverviewVmFactory;
_weeklyReportVmFactory = weeklyReportVmFactory;
_usageMonitorVmFactory = usageMonitorVmFactory;
_mergeVmFactory = mergeVmFactory;
_repoImportVmFactory = repoImportVmFactory;
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
Lists.ErrorReported += FlashFooterError;
Tasks.OpenConPtySessionRequested += taskId =>
{
OpenMissionControl();
_ = MissionControl.OpenConPtySessionAsync(taskId);
};
Tasks.OpenQuickClaudeSessionRequested += dir =>
{
OpenMissionControl();
_ = MissionControl.OpenAdHocConPtySessionAsync(dir);
};
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
{
OpenMissionControl();
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
};
Lists.LetClaudeHandleRequested += req =>
{
OpenMissionControl();
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
};
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
Tasks.OpenListSettingsRequested += (_, _) =>
{
if (Lists.SelectedList is { } row)
Lists.OpenListSettingsCommand.Execute(row);
};
Tasks.LetClaudeHandleRequested += (_, _) =>
{
if (Lists.SelectedList is { } row)
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.ErrorReported += FlashFooterError;
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = row =>
{
Tasks.LoadForList(Lists.SelectedList);
_ = Lists.RefreshCountsAsync();
return System.Threading.Tasks.Task.CompletedTask;
};
Worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
{
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));
}
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
Worker.ConnectionRestoredEvent += () => _ = RefreshStaleWorkerCheckAsync();
Worker.PlanningMergeConflictEvent += OnPlanningMergeConflict;
Worker.PlanningMergeAbortedEvent += OnPlanningMergeAborted;
Worker.PlanningCompletedEvent += OnPlanningMergeCompleted;
Worker.ConnectionRestoredEvent += () => _ = RefreshExternalMergeConflictsAsync();
Worker.PrimeFired += OnPrimeFired;
_clearTimer.Elapsed += (_, _) =>
{
if (Dispatcher.UIThread.CheckAccess())
ClearWorkerLog();
else
Dispatcher.UIThread.Post(ClearWorkerLog);
};
_primeStatusTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => PrimeStatus = null);
_connectTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() =>
{
if (DecideShowConnectionPrompt(IsOffline)) _ = OpenWorkerConnectionHelpAsync();
});
_connectTimer.Start();
_ = Lists.LoadAsync();
_updateCheck.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(UpdateCheckService.LastCheckStatus))
{
RefreshBannerFromStatus();
}
};
// Fire-and-forget startup check — never block UI.
_ = Task.Run(async () =>
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
}
public void Dispose()
{
_clearTimer.Stop();
_clearTimer.Dispose();
_connectTimer.Stop();
_connectTimer.Dispose();
_primeStatusTimer.Stop();
_primeStatusTimer.Dispose();
_staleWorkerCts?.Cancel();
_staleWorkerCts?.Dispose();
}
private void RefreshBannerFromStatus()
{
switch (_updateCheck.LastCheckStatus)
{
case UpdateCheckStatus.UpdateAvailable:
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
IsUpdateBannerVisible = true;
break;
case UpdateCheckStatus.UpToDate:
IsUpdateBannerVisible = false;
break;
case UpdateCheckStatus.CheckFailed:
break;
}
}
private CancellationTokenSource? _staleWorkerCts;
// Re-evaluates the stale-worker banner for the currently selected list. Cheap (one hub call +
// up to two git subprocesses) and only ever runs for a git-backed list, so it's fine to fire on
// every selection change / reconnect rather than caching.
private async Task RefreshStaleWorkerCheckAsync()
{
_staleWorkerCts?.Cancel();
var cts = new CancellationTokenSource();
_staleWorkerCts = cts;
var stale = await ComputeIsStaleWorkerAsync(Lists?.SelectedList?.WorkingDir, cts.Token);
if (cts.IsCancellationRequested) return;
IsStaleWorkerBannerVisible = stale;
}
private async Task<bool> ComputeIsStaleWorkerAsync(string? workingDir, CancellationToken ct)
{
if (_git is null || Worker is null || string.IsNullOrWhiteSpace(workingDir)) return false;
try
{
var buildInfo = await Worker.GetWorkerBuildInfoAsync();
var buildSha = buildInfo?.BuildSha;
if (string.IsNullOrWhiteSpace(buildSha)) return false;
if (!await _git.IsGitRepoAsync(workingDir, ct)) return false;
var head = await _git.RevParseHeadAsync(workingDir, ct);
var isAncestor = string.Equals(buildSha, head, StringComparison.OrdinalIgnoreCase)
? (bool?)false // equal — never "stale" on a match, and no need to ask git
: await _git.IsAncestorAsync(workingDir, buildSha, head, ct);
return ShouldShowStaleWorkerBanner(buildSha, head, isAncestor);
}
catch
{
// Worker offline, dir no longer a repo, etc. — unknown, so stay quiet.
return false;
}
}
// Pure decision extracted for testability. isAncestor is the tri-state result of
// `git merge-base --is-ancestor buildSha head`: true = worker predates head (stale), false =
// equal or diverged (never claim "stale" on a match or an unrelated history), null = unknown
// (e.g. buildSha isn't a commit this repo knows about — never treat "unknown" as "stale").
internal static bool ShouldShowStaleWorkerBanner(string? buildSha, string? headSha, bool? isAncestor)
{
if (string.IsNullOrWhiteSpace(buildSha) || string.IsNullOrWhiteSpace(headSha)) return false;
if (string.Equals(buildSha, headSha, StringComparison.OrdinalIgnoreCase)) return false;
return isAncestor == true;
}
[RelayCommand]
private void OpenMissionControl()
{
if (Dialogs is not null && MissionControl is not null)
Dialogs.ShowMissionControl(MissionControl);
}
private void SyncInteractiveSessionChips()
{
if (MissionControl is null || Tasks is null) return;
Tasks.SyncInteractiveSessions(
MissionControl.ConPtySessions
.Select(s => s.TaskId)
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => id!));
}
[RelayCommand]
private async Task OpenAbout()
{
var vm = new AboutModalViewModel();
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
}
[RelayCommand]
private async Task OpenLogVisualizer()
{
if (Dialogs is null || Worker is null) return;
var vm = new LogVisualizerViewModel(Worker);
await vm.RefreshAsync();
await Dialogs.ShowLogVisualizerAsync(vm);
}
private bool _connectionPromptShown;
internal bool DecideShowConnectionPrompt(bool isOffline)
{
if (!isOffline) return false;
if (_connectionPromptShown) return false;
_connectionPromptShown = true;
return true;
}
private async Task OpenWorkerConnectionHelpAsync()
{
var vm = new WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
}
[RelayCommand]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
[RelayCommand]
private async Task OpenRepoImport()
{
if (Dialogs is null || _repoImportVmFactory is null) return;
var vm = _repoImportVmFactory();
await vm.LoadAsync();
await Dialogs.ShowRepoImportAsync(vm);
if (Lists is not null) await Lists.LoadAsync();
}
private bool _worktreesOverviewOpen;
[RelayCommand]
private async Task OpenWorktreesOverviewGlobalAsync()
{
if (Dialogs is null || _worktreesOverviewOpen) return;
_worktreesOverviewOpen = true;
try
{
var vm = _worktreesOverviewVmFactory();
vm.Configure(null, null);
await vm.LoadAsync();
await Dialogs.ShowWorktreesOverviewAsync(vm);
}
finally { _worktreesOverviewOpen = false; }
}
private bool _weeklyReportOpen;
[RelayCommand]
private async Task OpenWeeklyReport()
{
if (Dialogs is null || _weeklyReportOpen) return;
_weeklyReportOpen = true;
try
{
var vm = _weeklyReportVmFactory();
await vm.InitializeAsync();
await Dialogs.ShowWeeklyReportAsync(vm);
}
finally { _weeklyReportOpen = false; }
}
private bool _usageMonitorOpen;
[RelayCommand]
private async Task OpenUsageMonitor()
{
if (Dialogs is null || _usageMonitorOpen) return;
_usageMonitorOpen = true;
try
{
var vm = _usageMonitorVmFactory();
vm.ErrorReported += FlashFooterError;
await vm.LoadAsync();
await Dialogs.ShowUsageMonitorAsync(vm);
}
finally { _usageMonitorOpen = false; }
}
[RelayCommand]
private async Task CheckForUpdatesAsync()
{
await _updateCheck.CheckNowAsync(CancellationToken.None);
}
[ObservableProperty] private string? _restartWorkerStatus;
[RelayCommand]
private async Task RestartWorkerAsync()
{
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
try
{
await Task.Run(RestartWorkerService);
await FlashRestartStatusAsync("Worker restarted.");
}
catch (Exception ex)
{
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
}
}
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
var exeFull = System.IO.Path.GetFullPath(exe);
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try
{
var path = p.MainModule?.FileName;
if (path is not null &&
!string.Equals(System.IO.Path.GetFullPath(path), exeFull, StringComparison.OrdinalIgnoreCase))
continue;
p.Kill(entireProcessTree: true);
p.WaitForExit(10000);
}
catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); }
}
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
}
private async Task FlashRestartStatusAsync(string text)
{
RestartWorkerStatus = text;
await Task.Delay(3000);
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
}
[RelayCommand]
private void DismissBanner()
{
_bannerDismissedThisSession = true;
IsUpdateBannerVisible = false;
}
[RelayCommand]
private void UpdateNow()
{
var path = _installerLocator.Find();
if (path is null) return;
try
{
// WorkingDirectory must NOT stay empty: the child would inherit ours (<InstallDir>\app)
// and its locked current directory blocks the installer's own app\ rename.
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path)
{
UseShellExecute = true,
WorkingDirectory = System.IO.Path.GetTempPath(),
});
Environment.Exit(0);
}
catch
{
// Intentionally silent — if this fails there's nothing useful to show.
}
}
}