Instanzmanager: Weboberflaeche kennt Instanzen, geordnetes Ende, Puls (#528, #531)

Weboberflaeche (#531): X-Instanz an der einen fetch-Stelle, Instanzauswahl in der
Kopfzeile, Verwaltungsansicht mit Anlegen/Starten/Anhalten/Fenster, Herkunft der
Ereignisse, Sicherungs- und Einstellungsansicht. 402/409/502 werden eigen behandelt.

Sicherungen im Manager (#530): Auflisten, Anlegen, Wiederherstellen, Loeschen. Das
Zurueckspielen haelt die Instanz vorher an und startet sie danach wieder.

Geordnetes Ende (Konzept 9.8). Zwei Befunde waren der Anlass:

Der Manager konnte eine Instanz MIT FENSTER nie geordnet beenden.
Control.ShutdownRequested war nur in HeadlessRun verdrahtet, nicht in der
Avalonia-Anwendung - und ein Fenster ist die Vorgabe. Die Bitte wurde mit 202
beantwortet, niemand hoerte zu, und der Manager schoss den Prozess nach Ablauf
seiner Frist jedes Mal ab.

InstanceSupervisor.StopAsync hielt Socket-Schweigen fuer einen Todesnachweis und
meldete "beendet", ohne den Prozess anzufassen. Bei einer verstummten Instanz ist
der Socket von vornherein still - der Neustart haette einen zweiten Prozess auf
dieselbe state.db gesetzt. Genau der Schaden, gegen den #528 gebaut ist.

- ShutdownCoordinator: erst die Tuer schliessen, dann auf offene Arbeiten warten,
  Frist zwei Minuten. Vorher brach TaskScanner.DisposeAsync mitten in ProcessAsync
  ab, sodass die Aufgabe bis zum Lease-Ablauf auf "running" stehenblieb.
- GET /api/instanz/puls hinter dem Socket-Gate: Phase und offene Arbeiten. Weiter
  gefragt statt gemeldet - ein Puls, den die Instanz von sich aus schickt, fehlt
  genau dann, wenn sie haengt.
- Eskalationsleiter: drei Durchgaenge ohne Antwort, einmal um ein Ende bitten,
  nach 30s beenden und den Vollzug abwarten, gemeldet als Absturz.
- EnsureDeadAsync vor jedem Start.

Weiteres aus dem Review:
- .gitignore verankert: "Instances/" passte auch auf src/ClawdDotNet.Core/Instances/.
  InstanceEndpoint.cs und InstanceLayout.cs waren dadurch nie eingecheckt.
- Das WebUI-Token stand im Protokoll des Managers. Konsole ja, Datei nein.
- Ein Klick auf "Instanz anlegen" loeste requestSubmit() zweimal aus.
- [Obsolete] auf WebServerPort wieder entfernt: sechs Warnungen, die niemand
  abstellen konnte, solange E-M5 nicht fertig ist.
- Die Wiederherstellung bricht nicht mehr ab, wenn der Browser weggeht.
- Entwicklungspfade zum Instanzmanager aus Segmenten statt mit Rueckstrichen, und
  der Port kommt aus manager.json statt fest aus der Zeile.

Offen und gemeldet: --managed hebelt die Lizenzpruefung aus (#535).

806 Tests gruen, 0 Warnungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Richard
2026-09-10 11:15:59 +02:00
co-authored by Claude Opus 5
parent d44bbeac1d
commit dcffcdcff5
48 changed files with 4852 additions and 171 deletions
+398 -14
View File
@@ -4,6 +4,8 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using Avalonia.Layout;
using Avalonia.Media;
using ClawdDotNet.App;
using ClawdDotNet.App.Services;
using ClawdDotNet.App.Settings;
@@ -12,6 +14,8 @@ using ClawdDotNet.Desktop.ViewModels;
using ClawdDotNet.Desktop.Views;
using ClawdDotNet.Web;
using ClawdDotNet.Core.Instances;
using ClawdDotNet.Core.Storage;
using Microsoft.Extensions.Logging;
namespace ClawdDotNet.Desktop;
@@ -106,6 +110,7 @@ public partial class App : Application
{
SelectInstance = SelectInstanceAsync,
License = new AvaloniaLicensePrompt(),
IsManaged = _managed,
TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt),
Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:")
});
@@ -152,10 +157,25 @@ public partial class App : Application
// Der Manager bittet ueber den Socket, dieses Fenster nach vorn zu holen (E-M2).
if (_web is not null)
{
_web.Control.FocusRequested += () => BringToFront(desktop);
// Und er bittet, zu enden. Ohne diese Zeile lief die Bitte ins Leere: Der
// Endpunkt nahm sie an, antwortete mit 202 - und niemand hoerte zu. Der
// Manager wartete seine Frist ab und schoss den Prozess dann jedes Mal ab.
// Kopflos war das verdrahtet (HeadlessRun), mit Fenster nicht; und mit
// Fenster ist die Vorgabe (ManagerSettings.InstanceWindows).
_web.Control.ShutdownRequested += () => Dispatcher.UIThread.Post(() =>
{
if (desktop.MainWindow is MainWindow fenster)
fenster.BeendenOhneRueckfrage();
else
_ = BeendeGeordnetAsync(desktop, "Instanzmanager");
});
}
desktop.MainWindow.Show();
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
desktop.MainWindow.Closed += (_, _) => _ = BeendeGeordnetAsync(desktop, "Fenster");
await OfferUpdateAsync(_host, desktop);
}
@@ -373,14 +393,29 @@ public partial class App : Application
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
/// </summary>
private enum StartModeChoice
{
LaunchManager,
Standalone,
Cancel
}
private enum StandaloneSwitchChoice
{
LaunchManager,
SwitchStandalone,
Cancel
}
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
/// </summary>
private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
if (_host is null) return;
if (_managed)
{
// Sollte gar nicht erst aufrufbar sein (CanSwitchInstance), aber ein zweites
// Schloss an der Tuer, hinter der ein Selbst-Neustart wartet, ist billig.
await new AvaloniaLicensePrompt().ShowInfoAsync(
"ClawdDotNet – Instanzwechsel",
"Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — "
@@ -390,6 +425,18 @@ public partial class App : Application
return;
}
// Im Standalone-Modus den Benutzer fragen:
var switchChoice = await AskStandaloneSwitchModeAsync(desktop.MainWindow);
if (switchChoice == StandaloneSwitchChoice.LaunchManager)
{
LaunchInstanceManagerAndOpenBrowser();
return;
}
if (switchChoice == StandaloneSwitchChoice.Cancel)
{
return;
}
var viewModel = new InstancePickerViewModel(_host.Directories, _host.InstancePath);
var window = new InstancePickerWindow { DataContext = viewModel };
@@ -408,8 +455,6 @@ public partial class App : Application
if (path is null) return;
if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return;
await ShutdownWebAsync();
var executable = Environment.ProcessPath;
if (executable is null)
@@ -421,14 +466,13 @@ public partial class App : Application
return;
}
await _host.DisposeAsync();
// Neue Instanz mit UseShellExecute = true starten (keine geerbten Datei- oder Socket-Handles)
try
{
Process.Start(new ProcessStartInfo(executable)
{
ArgumentList = { InstanceArgument, path },
UseShellExecute = false
UseShellExecute = true
});
}
catch (Exception ex)
@@ -436,8 +480,340 @@ public partial class App : Application
await new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzwechsel",
$"Neustart fehlgeschlagen: {ex.Message}");
return;
}
// Hauptfenster sofort verbergen, damit es nicht eingefroren sichtbar bleibt
if (desktop.MainWindow is { } mainWin)
{
mainWin.Hide();
}
await ShutdownWebAsync();
var hostToDispose = _host;
_host = null;
if (hostToDispose is not null)
{
await hostToDispose.DisposeAsync();
}
// Sauber beenden
await Dispatcher.UIThread.InvokeAsync(() =>
{
desktop.Shutdown(0);
Environment.Exit(0);
});
}
private static void LaunchInstanceManagerAndOpenBrowser()
{
var exePath = FindInstanceManagerExecutable();
if (exePath is null)
{
_ = new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzmanager",
"Die Programmdatei des Instanzmanagers (ClawdDotNet.Instanzmanager.exe) wurde nicht gefunden.");
return;
}
try
{
Process.Start(new ProcessStartInfo(exePath)
{
UseShellExecute = true
});
SystemShell.OpenUrl($"http://localhost:{ManagerPort()}/#/instanzen");
}
catch (Exception ex)
{
_ = new AvaloniaLicensePrompt().ShowErrorAsync(
"ClawdDotNet – Instanzmanager",
$"Konnte Instanzmanager nicht starten: {ex.Message}");
}
}
/// <summary>
/// Sucht die Programmdatei des Instanzmanagers.
///
/// <para><b>Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette.</b> Hier
/// stand <c>@"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0"</c> — unter Linux,
/// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit
/// Rückstrichen darin, und die Suche ging dort nie auf.</para>
///
/// <para>Die Entwicklungspfade bleiben, weil im Betrieb beide Programme
/// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei
/// vergebliche <c>File.Exists</c>.</para>
/// </summary>
private static string? FindInstanceManagerExecutable()
{
var exeName = OperatingSystem.IsWindows()
? "ClawdDotNet.Instanzmanager.exe"
: "ClawdDotNet.Instanzmanager";
var nebenan = Path.Combine(AppContext.BaseDirectory, exeName);
if (File.Exists(nebenan)) return nebenan;
string[][] entwicklungspfade =
[
["..", "..", "..", "..", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"],
["..", "..", "..", "..", "..", "src", "ClawdDotNet.ManagerApp", "bin", "Debug", "net10.0"]
];
foreach (var segmente in entwicklungspfade)
{
var pfad = Path.GetFullPath(Path.Combine(
[AppContext.BaseDirectory, .. segmente, exeName]));
if (File.Exists(pfad)) return pfad;
}
return null;
}
/// <summary>
/// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet.
///
/// <para>Aus <c>manager.json</c> gelesen und nicht fest eingetragen: Wer den Port
/// dort ändert, bekäme sonst einen Browser auf eine Adresse, an der nichts
/// antwortet. Diese Anwendung kennt den Typ hinter der Datei nicht — sie liest das
/// eine Feld, das sie braucht, und nimmt bei jedem Zweifel die Vorgabe.</para>
/// </summary>
private static int ManagerPort()
{
const int vorgabe = 7000;
try
{
var datei = Path.Combine(AppPaths.ConfigDirectory, "manager.json");
if (!File.Exists(datei)) return vorgabe;
using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(datei));
return doc.RootElement.TryGetProperty("webUiPort", out var port)
&& port.ValueKind == System.Text.Json.JsonValueKind.Number
&& port.TryGetInt32(out var wert)
&& wert is > 0 and < 65536
? wert
: vorgabe;
}
catch (Exception)
{
// Eine unlesbare Managerdatei ist kein Grund, den Browser gar nicht zu oeffnen.
return vorgabe;
}
}
private static async Task<StandaloneSwitchChoice> AskStandaloneSwitchModeAsync(Window? owner)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<StandaloneSwitchChoice>();
var managerBtn = new Button
{
Content = "Instanzmanager starten (Empfohlen)",
Classes = { "primary" }
};
var switchBtn = new Button
{
Content = "Instanz trotzdem wechseln"
};
var cancelBtn = new Button
{
Content = "Abbrechen",
IsCancel = true
};
var window = new Window
{
Title = "ClawdDotNet – Instanzwechsel (Standalone)",
Width = 540,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Icon = owner?.Icon,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = "Achtung: Standalone-Modus",
Classes = { "h1" }
},
new TextBlock
{
Text = "Diese Instanz läuft eigenständig ohne Instanzmanager.\n\n"
+ "Wenn du die Instanz hier wechselst, wird diese Instanz vollständig beendet "
+ "und alle laufenden Agenten und Hintergrunddienste werden gestoppt.\n\n"
+ "Empfehlung: Starte stattdessen den Instanzmanager, um mehrere Instanzen gleichzeitig und unabhängig voneinander zu betreiben.",
TextWrapping = TextWrapping.Wrap
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancelBtn, switchBtn, managerBtn }
}
}
}
};
managerBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.LaunchManager);
window.Close();
};
switchBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.SwitchStandalone);
window.Close();
};
cancelBtn.Click += (_, _) =>
{
completion.TrySetResult(StandaloneSwitchChoice.Cancel);
window.Close();
};
window.Closed += (_, _) => completion.TrySetResult(StandaloneSwitchChoice.Cancel);
if (owner is not null)
await window.ShowDialog(owner);
else
window.Show();
return await completion.Task;
});
private static async Task<StartModeChoice> AskStartModeAsync()
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<StartModeChoice>();
var managerBtn = new Button
{
Content = "Instanzmanager starten (Empfohlen)",
Classes = { "primary" }
};
var standaloneBtn = new Button
{
Content = "Als Standalone-Instanz starten"
};
var cancelBtn = new Button
{
Content = "Abbrechen",
IsCancel = true
};
var window = new Window
{
Title = "ClawdDotNet – Startmodus wählen",
Width = 540,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Content = new StackPanel
{
Margin = new Thickness(22),
Spacing = 16,
Children =
{
new TextBlock
{
Text = "Wie möchtest du ClawdDotNet starten?",
Classes = { "h1" }
},
new TextBlock
{
Text = "• Instanzmanager (Empfohlen):\n"
+ " Startet den zentralen Hintergrund-Manager und öffnet die Verwaltung im Browser. "
+ "Ermöglicht den stabilen Dauerbetrieb mehrerer Instanzen parallel ohne Absturzrisiko.\n\n"
+ "• Standalone-Modus:\n"
+ " Startet genau eine Instanz direkt als einzelnes Desktop-Fenster.",
TextWrapping = TextWrapping.Wrap
},
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancelBtn, standaloneBtn, managerBtn }
}
}
}
};
managerBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.LaunchManager);
window.Close();
};
standaloneBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.Standalone);
window.Close();
};
cancelBtn.Click += (_, _) =>
{
completion.TrySetResult(StartModeChoice.Cancel);
window.Close();
};
window.Closed += (_, _) => completion.TrySetResult(StartModeChoice.Cancel);
window.Show();
return await completion.Task;
});
private int _beendetSich;
/// <summary>
/// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat.
///
/// <para><b>Erst auslaufen lassen, dann abschneiden.</b> Laufende Agenten und
/// beanspruchte Aufgaben bekommen ihre Frist
/// (<see cref="ShutdownCoordinator"/>), bevor Weboberfläche und Aufbau fallen. Ohne
/// das endete eine Aufgabe mitten in <c>ProcessAsync</c> und bliebe bis zum Ablauf
/// ihres Lease auf <c>running</c> stehen.</para>
///
/// <para><b>Und nicht im Rückruf von <c>desktop.ShutdownRequested</c>.</b> Avalonia
/// wartet dort nicht auf <c>async</c>-Behandlungen; was länger dauert als ein
/// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst
/// aufräumen und <em>dann</em> <c>Shutdown</c> rufen — der Rückruf dort bleibt als
/// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts.</para>
/// </summary>
private async Task BeendeGeordnetAsync(
IClassicDesktopStyleApplicationLifetime desktop, string grund)
{
if (Interlocked.Exchange(ref _beendetSich, 1) != 0) return;
var host = _host;
if (host is not null)
{
host.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown")
.LogInformation("Beendigung angefordert ({Grund}).", grund);
try { await host.Shutdown.BeginAsync(); }
catch (Exception) { /* Beim Beenden zaehlt, dass alle Schritte drankommen */ }
}
await ShutdownWebAsync();
if (host is not null) await host.DisposeAsync();
await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown());
}
@@ -458,12 +834,6 @@ public partial class App : Application
/// <summary>
/// Instanzauswahl beim Start.
///
/// <para>Kam die Anwendung mit <c>--instance</c> hoch, wird nicht gefragt: Dann hat
/// der Benutzer die Instanz im vorigen Prozess bereits gewaehlt, und ein zweiter
/// Dialog waere nur die Frage nach der schon gegebenen Antwort. Der Wert wird einmal
/// verbraucht — bleibt der Pfad ungueltig, erscheint doch der Dialog, statt dass der
/// Start wortlos scheitert.</para>
/// </summary>
private async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
{
@@ -473,6 +843,20 @@ public partial class App : Application
if (preselected is not null && Directory.Exists(preselected))
return preselected;
if (!_managed)
{
var choice = await AskStartModeAsync();
if (choice == StartModeChoice.LaunchManager)
{
LaunchInstanceManagerAndOpenBrowser();
return null;
}
if (choice == StartModeChoice.Cancel)
{
return null;
}
}
return await ShowInstancePickerAsync(directories);
}
+4 -4
View File
@@ -54,10 +54,10 @@ internal static class HeadlessRun
{
SelectInstance = _ => Task.FromResult<string?>(instancePath),
// Fragt nicht, sondern meldet, was zu tun ist. Mit E-M3 wandert die
// Lizenzpruefung ohnehin in den Instanzmanager; bis dahin ist das hier der
// ehrliche Weg: Ein Schluessel wird vorab hinterlegt, nicht erfragt.
License = new NonInteractiveLicensePrompt(Console.Error.WriteLine)
// Fragt nicht, sondern meldet, was zu tun ist. Im geführten Modus (E-M3)
// entfällt die lokale Prüfung; der Manager prüft zentral.
License = new NonInteractiveLicensePrompt(Console.Error.WriteLine),
IsManaged = CommandLine.IsManaged(args)
});
if (host is null)
@@ -1,13 +1,48 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Threading;
using ClawdDotNet.Desktop.ViewModels;
namespace ClawdDotNet.Desktop.Views;
public partial class MainWindow : Window
{
private bool _confirmedClose;
public MainWindow()
{
InitializeComponent();
Closing += OnClosing;
}
/// <summary>
/// Schließt ohne Rückfrage — für eine Beendigung, die nicht von diesem Fenster ausgeht.
///
/// <para><b>Warum das nötig ist.</b> Der Bestätigungsdialog schützt vor dem
/// versehentlichen Klick eines Menschen. Bittet dagegen der Instanzmanager über den
/// Socket, steht niemand davor: Der Dialog bliebe offen, die zehn Sekunden liefen ab,
/// und niemand klickte. Der Manager wartete seine Frist ab und schösse den Prozess
/// dann ab — der Dialog, der ein hartes Ende verhindern soll, hätte es garantiert.</para>
/// </summary>
public void BeendenOhneRueckfrage()
{
_confirmedClose = true;
Close();
}
private async void OnClosing(object? sender, WindowClosingEventArgs e)
{
if (_confirmedClose) return;
// Schließen zunächst abbrechen, um Bestätigungsdialog anzuzeigen
e.Cancel = true;
var confirmed = await ShowExitConfirmationAsync(this);
if (confirmed)
{
_confirmedClose = true;
Close();
}
}
protected override void OnDataContextChanged(EventArgs e)
@@ -21,4 +56,113 @@ public partial class MainWindow : Window
if (DataContext is MainWindowViewModel vm)
vm.RequestExit += Close;
}
internal static async Task<bool> ShowExitConfirmationAsync(Window? owner)
{
var completion = new TaskCompletionSource<bool>();
var remainingSeconds = 10;
var cancelBtn = new Button
{
Content = "Abbrechen (Instanz läuft weiter)",
IsCancel = true
};
var exitBtn = new Button
{
Content = $"Instanz beenden ({remainingSeconds}s)",
IsEnabled = false
};
exitBtn.Classes.Add("danger");
var countdownText = new TextBlock
{
Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…",
Classes = { "caption" }
};
var timer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(1) };
timer.Tick += (_, _) =>
{
remainingSeconds--;
if (remainingSeconds > 0)
{
countdownText.Text = $"Bestätigung gesperrt: Noch {remainingSeconds} Sekunden…";
exitBtn.Content = $"Instanz beenden ({remainingSeconds}s)";
}
else
{
timer.Stop();
exitBtn.IsEnabled = true;
exitBtn.Content = "Instanz jetzt endgültig beenden";
countdownText.Text = "Sperre aufgehoben — Bestätigung jetzt möglich.";
}
};
var window = new Window
{
Title = "ClawdDotNet – Instanz beenden",
Width = 500,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterScreen,
Icon = owner?.Icon,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock
{
Text = "Achtung: Produktivinstanz beenden",
Classes = { "h1" }
},
new TextBlock
{
Text = "ClawdDotNet-Instanzen sind auf dauerhaften Dauerbetrieb ausgelegt. "
+ "Das Beenden stoppt alle aktiven Agenten, Automationen, Web-Zugriffe und Hintergrundaufgaben dieser Instanz geordnet.",
TextWrapping = Avalonia.Media.TextWrapping.Wrap
},
countdownText,
new StackPanel
{
Orientation = Avalonia.Layout.Orientation.Horizontal,
Spacing = 10,
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right,
Children = { cancelBtn, exitBtn }
}
}
}
};
cancelBtn.Click += (_, _) =>
{
timer.Stop();
completion.TrySetResult(false);
window.Close();
};
exitBtn.Click += (_, _) =>
{
timer.Stop();
completion.TrySetResult(true);
window.Close();
};
window.Closed += (_, _) =>
{
timer.Stop();
completion.TrySetResult(false);
};
timer.Start();
if (owner is not null)
await window.ShowDialog(owner);
else
window.Show();
return await completion.Task;
}
}