using System.Diagnostics; using Avalonia; 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; using ClawdDotNet.Desktop.Services; 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; public partial class App : Application { /// /// Schalter der Befehlszeile, mit dem sich ClawdDotNet auf eine bestimmte Instanz /// starten laesst. Gesetzt wird er nur von der Anwendung selbst, wenn der /// Instanzdialog einen Wechsel verlangt — deshalb ist er nirgends dokumentiert. /// private const string InstanceArgument = CommandLine.InstanceArgument; private AppHost? _host; /// Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null. private InstanceHttpHost? _web; /// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht. private string? _preselectedInstancePath; /// /// Vom Instanzmanager gestartet (--managed). /// /// Dann gehoert der Instanzwechsel dorthin: Der Manager haelt die Fenster aller /// Instanzen im Hintergrund und holt das gewuenschte nach vorn (E-M2). Ein Fenster, /// das sich selbst mit einer anderen Instanz neu startet, naehme genau die Instanz /// mit, aus der heraus jemand wechselt. /// private bool _managed; // #576: Muessen leben, solange die Anwendung laeuft - eine eingesammelte // Registrierung meldet sich ab. private System.Runtime.InteropServices.PosixSignalRegistration? _sigterm; private System.Runtime.InteropServices.PosixSignalRegistration? _sigint; /// /// SIGTERM und SIGINT beenden auch mit Fenster geordnet (#576). /// /// Kopflos war das seit HeadlessRun verdrahtet, mit Fenster nicht: Unter Linux /// reagierte die Instanz auf SIGTERM gar nicht und wurde nach Ablauf jeder Frist hart /// abgeschossen - beim Abmelden, bei systemctl stop, beim Herunterfahren. Derselbe Weg /// wie die Bitte des Instanzmanagers: das Fenster ohne Rueckfrage schliessen, das /// geordnete Ende laeuft von dort. /// private void RegisterSignals(IClassicDesktopStyleApplicationLifetime desktop) { void Beenden(System.Runtime.InteropServices.PosixSignalContext context) { // Abfangen, nicht durchlassen: Sonst raeumte die Laufzeit den Prozess ab, // waehrend das geordnete Ende noch laeuft. context.Cancel = true; var signal = context.Signal.ToString(); Dispatcher.UIThread.Post(() => { _host?.LoggerFactory.CreateLogger("ClawdDotNet.Shutdown") .LogInformation("Signal {Signal} empfangen - die Instanz endet geordnet.", signal); if (_host is null) desktop.Shutdown(); else if (desktop.MainWindow is MainWindow fenster) fenster.BeendenOhneRueckfrage(); else _ = BeendeGeordnetAsync(desktop, signal); }); } try { _sigterm = System.Runtime.InteropServices.PosixSignalRegistration.Create( System.Runtime.InteropServices.PosixSignal.SIGTERM, Beenden); _sigint = System.Runtime.InteropServices.PosixSignalRegistration.Create( System.Runtime.InteropServices.PosixSignal.SIGINT, Beenden); } catch (PlatformNotSupportedException) { // Ohne Signale bleibt es beim Schliessen ueber Fenster und Manager. } } public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void OnFrameworkInitializationCompleted() { if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { // Erst beenden, wenn wir es sagen: Zwischen Instanzauswahl und Hauptfenster // ist kurz gar kein Fenster offen. Mit OnLastWindowClose würde die Anwendung // in genau dieser Lücke aussteigen. desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; _preselectedInstancePath = CommandLine.ReadInstancePath(desktop.Args); _managed = CommandLine.IsManaged(desktop.Args); ApplySavedThemeEarly(); ApplySavedLanguageEarly(); desktop.ShutdownRequested += async (_, _) => { // Reihenfolge: erst die Weboberflaeche, dann der Aufbau. Andersherum // bediente Kestrel noch Anfragen gegen eine bereits geschlossene // Datenbank. await ShutdownWebAsync(); if (_host is not null) await _host.DisposeAsync(); }; RegisterSignals(desktop); // Nicht abwarten: OnFrameworkInitializationCompleted muss zurückkehren, // damit die Nachrichtenschleife anläuft — sonst gäbe es keinen Faden, auf // dem die Fenster des Startvorgangs überhaupt erscheinen könnten. _ = StartAsync(desktop); } base.OnFrameworkInitializationCompleted(); } /// /// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint. /// /// Das Ansichtsmodell des Hauptfensters tut dasselbe — aber erst, wenn der /// Aufbau steht. Dazwischen liegt die Instanzauswahl, und die waere sonst bei einem /// Benutzer im Dunkelmodus hell und der Rest der Anwendung dunkel. /// /// Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und /// schreibt allenfalls beim allerersten Start die Vorgaben fest. /// private static void ApplySavedThemeEarly() { try { var settings = new SettingsManager(); settings.Load(); ThemeManager.Apply(ThemeManager.IsDarkName(settings.AppSettings.Theme)); } catch (Exception) { // Ohne lesbare Einstellungen bleibt es beim hellen Schema. } } /// /// Setzt die gespeicherte Sprache, bevor das erste Fenster erscheint. /// private static void ApplySavedLanguageEarly() { try { var settings = new SettingsManager(); settings.Load(); LocalizationManager.Instance.SetLanguage(settings.AppSettings.Language); } catch (Exception) { LocalizationManager.Instance.SetLanguage(LocalizationManager.DefaultLanguage); } } private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) { var result = await AppHost.StartAsync(new AppHost.Callbacks { SelectInstance = SelectInstanceAsync, License = new AvaloniaLicensePrompt(), IsManaged = _managed, TelegramLogin = prompt => AskAsync("Telegram Verifizierung", prompt), Telegram2FA = () => AskAsync("Telegram 2FA", "Bitte 2FA-Passwort eingeben:") }); if (result.Error is { } error) { await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Fehler", error); desktop.Shutdown(1); return; } if (result.Host is null) { // Abbruch durch den Benutzer oder fehlende Lizenz — beides ist bereits // erklärt worden, hier kommt keine weitere Meldung hinterher. desktop.Shutdown(); return; } _host = result.Host; LocalizationManager.Instance.SetLanguage(_host.Settings.AppSettings.Language); HookErrorReporting(_host); HookLicenseWatch(_host, desktop); // Nach dem Aufbau und vor dem Fenster: Die HTTP-Flaeche braucht den fertigen // AppHost, und ein belegter Port darf das Fenster nicht aufhalten — TryStart // gibt in jedem Fehlerfall null zurueck und schreibt den Grund ins Protokoll. // // Sie ist mehr als die Weboberflaeche: Immer dabei ist der Socket im // Instanzverzeichnis, ueber den der Instanzmanager die Instanz erreicht (#528). _web = InstanceHttpHost.TryStart(_host, _host.LoggerFactory); var mainViewModel = new MainWindowViewModel(_host) { // Wer beaufsichtigt wird, wechselt nicht selbst. Der geprüfte Befund des // Aufbaus, nicht der Schalter der Befehlszeile (#535): Wer --managed ohne // gueltige Unterschrift setzt, ist eine eigenstaendige Instanz und darf // wechseln wie jede andere. CanSwitchInstance = !_host.IsManaged }; // Der Wechsel gehoert hierher und nicht ins Fenster: Er braucht den Aufbau und // die Lebensdauer der Anwendung, und beides kennt nur diese Klasse. mainViewModel.RequestInstanceManagerDialog += () => _ = SwitchInstanceAsync(desktop); desktop.MainWindow = new MainWindow { DataContext = mainViewModel }; // 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 += (_, _) => _ = BeendeGeordnetAsync(desktop, "Fenster"); await OfferUpdateAsync(_host, desktop); } /// /// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters. /// /// Erst hier verdrahtet, nicht in Main: Vor dem Aufbau gibt es weder /// Einstellungen noch Token, und ohne die wäre der Meldeweg ohnehin der Leerlauf. /// Die Kehrseite ist bewusst in Kauf genommen — ein Absturz während des /// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll. /// private static void HookErrorReporting(AppHost host) { AppDomain.CurrentDomain.UnhandledException += (_, args) => { if (args.ExceptionObject is Exception ex) { // Der Prozess endet gleich: kurze Frist, dann weiterlaufen lassen. host.Errors.ReportAsync(ex, fatal: args.IsTerminating) .Wait(TimeSpan.FromSeconds(3)); } }; TaskScheduler.UnobservedTaskException += (_, args) => { _ = host.Errors.ReportAsync(args.Exception, fatal: false); // Ohne Observe reißt eine unbeobachtete Ausnahme in manchen Konfigurationen // den Prozess mit — und das wäre eine Nebenwirkung des Meldens. args.SetObserved(); }; Dispatcher.UIThread.UnhandledException += (_, args) => { _ = host.Errors.ReportAsync(args.Exception, fatal: false); // Ein Fehler in einem Ereignisbehandler soll die Oberfläche nicht beenden. args.Handled = true; }; } /// Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage. private static void HookLicenseWatch(AppHost host, IClassicDesktopStyleApplicationLifetime desktop) { if (host.LicenseWatch is null) return; host.LicenseWatch.Revoked += async message => { await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Lizenz", message); await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(2)); }; } /// /// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll. /// /// Bewusst eine Frage und kein Selbstlauf: Wann aktualisiert wird, entscheidet /// der Benutzer — eine Anwendung, die sich beim Start selbst beendet, um sich zu /// erneuern, ist genau dann im Weg, wenn man sie braucht. /// private async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop) { if (host.Deploymentcenter is null) return; // Die Prüfung läuft nebenher; kurz Zeit geben, dann aufgeben. for (var waited = 0; host.Deploymentcenter.Update is null && waited < 10; waited++) await Task.Delay(TimeSpan.FromSeconds(1)); if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return; var notes = string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"; var install = await ConfirmAsync( update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update", $"Version {update.LatestVersion} ist verfügbar.{notes}\n\n" + "Zum Einspielen wird ClawdDotNet beendet und danach neu gestartet.", confirmText: "Jetzt installieren", cancelText: "Später"); if (!install) return; await StartUpdateAsync(host, desktop, update.LatestVersion); } /// /// Die Reihenfolge ist der ganze Punkt dieser Methode. /// /// /// Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das /// Verschwinden der Instanz aus wie ein Ausfall. /// Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung. /// Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet, /// bis wir weg sind, statt über gesperrte Dateien zu kopieren. /// Selbst beenden. /// /// /// Die Verlockung wäre, LaunchUpdateAgent das Beenden zu überlassen — /// das tut es aber über Environment.Exit und übergeht damit Schritt 2 /// vollständig. /// private async Task StartUpdateAsync( AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version) { host.Deploymentcenter!.AnnounceUpdate(version); var settings = host.Settings.AppSettings; // Vor dem Aufbau: Kestrel darf keine Anfrage mehr gegen eine geschlossene // Datenbank bedienen — und schon gar nicht, waehrend der update-agent // gleich die Dateien darunter austauscht. await ShutdownWebAsync(); await host.DisposeAsync(); var problem = host.Deploymentcenter.StartUpdate(settings, AppHost.AppVersion); if (problem is not null) { await new AvaloniaLicensePrompt().ShowErrorAsync("ClawdDotNet – Update", problem); // Der Aufbau ist bereits abgebaut — weiterarbeiten wäre nicht ehrlich. await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown(1)); return; } await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown()); } /// Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit. private static async Task ConfirmAsync( string title, string message, string confirmText, string cancelText) => await Dispatcher.UIThread.InvokeAsync(async () => { var completion = new TaskCompletionSource(); var confirm = new Button { Content = confirmText, IsDefault = true }; var cancel = new Button { Content = cancelText, IsCancel = true }; var window = new Window { Title = title, Width = 480, SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterScreen, Content = new StackPanel { Margin = new Thickness(20), Spacing = 16, Children = { new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, new StackPanel { Orientation = Avalonia.Layout.Orientation.Horizontal, HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Right, Spacing = 8, Children = { cancel, confirm } } } } }; var answer = false; confirm.Click += (_, _) => { answer = true; window.Close(); }; cancel.Click += (_, _) => window.Close(); // Schließt der Benutzer das Fenster, gilt das als „Später". window.Closed += (_, _) => completion.TrySetResult(answer); window.Show(); return await completion.Task; }); /// /// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers. /// /// Die Instanz macht das selbst, statt dass jemand von aussen an ihrem /// Fenster zieht. Der Manager kennt kein Fenstersystem und soll keines kennen; er /// bittet über den Socket, und wer ein Fenster hat, holt es nach vorn. Kopflos läuft /// die Bitte ins Leere, und das ist die richtige Antwort. /// /// Das letzte Wort hat der Fenstermanager. Windows lässt einen Prozess /// im Hintergrund den Vordergrund nicht einfach an sich reissen — Activate /// blinkt dann nur in der Leiste. Der kurze Ausflug über Topmost bringt es in /// den meisten Fällen trotzdem nach vorn; unter Linux entscheidet der Fenstermanager, /// und manche sind da streng. Mehr als bitten kann eine Anwendung nicht, und mehr /// sollte sie auch nicht tun. /// private static void BringToFront(IClassicDesktopStyleApplicationLifetime desktop) => Dispatcher.UIThread.Post(() => { if (desktop.MainWindow is not { } fenster) return; if (fenster.WindowState == WindowState.Minimized) fenster.WindowState = WindowState.Normal; fenster.Show(); var vorher = fenster.Topmost; fenster.Topmost = true; fenster.Topmost = vorher; fenster.Activate(); }); /// /// Instanzwechsel aus der laufenden Anwendung. /// /// Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine /// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an /// einer Instanz. Statt das umzubauen startet sich die Anwendung mit /// --instance neu — derselbe Weg, den auch das Update geht. /// /// Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess /// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank. /// private enum StartModeChoice { LaunchManager, Standalone, Cancel } private enum StandaloneSwitchChoice { LaunchManager, SwitchStandalone, Cancel } /// /// Instanzwechsel aus der laufenden Anwendung. /// private async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop) { if (_host is null) return; if (_host.IsManaged) { await new AvaloniaLicensePrompt().ShowInfoAsync( "ClawdDotNet – Instanzwechsel", "Diese Instanz wird vom Instanzmanager geführt. Gewechselt wird dort — " + "er holt das Fenster der gewünschten Instanz nach vorn, statt eine " + "laufende zu beenden."); 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 }; // Schliesst der Benutzer das Fenster, gilt das als Abbruch. window.Closed += (_, _) => viewModel.CancelCommand.Execute(null); // Umgekehrt: Sobald eine Wahl feststeht, schliesst sich das Fenster von selbst. _ = viewModel.Result.ContinueWith( _ => Dispatcher.UIThread.Post(window.Close), TaskScheduler.Default); if (desktop.MainWindow is { } owner) await window.ShowDialog(owner); else window.Show(); var path = await viewModel.Result; if (path is null) return; if (string.Equals(path, _host.InstancePath, StringComparison.OrdinalIgnoreCase)) return; var executable = Environment.ProcessPath; if (executable is null) { await new AvaloniaLicensePrompt().ShowErrorAsync( "ClawdDotNet – Instanzwechsel", "Der eigene Programmpfad ist nicht ermittelbar; ein Neustart ist von hier " + "aus nicht möglich. Bitte ClawdDotNet von Hand neu starten."); return; } // Neue Instanz mit UseShellExecute = true starten (keine geerbten Datei- oder Socket-Handles) try { Process.Start(new ProcessStartInfo(executable) { ArgumentList = { InstanceArgument, path }, UseShellExecute = true }); } catch (Exception ex) { 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}"); } } /// /// Sucht die Programmdatei des Instanzmanagers. /// /// Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette. Hier /// stand @"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0" — unter Linux, /// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit /// Rückstrichen darin, und die Suche ging dort nie auf. /// /// Die Entwicklungspfade bleiben, weil im Betrieb beide Programme /// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei /// vergebliche File.Exists. /// 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; } /// /// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet. /// /// Aus manager.json 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. /// 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 AskStandaloneSwitchModeAsync(Window? owner) => await Dispatcher.UIThread.InvokeAsync(async () => { var completion = new TaskCompletionSource(); var l = LocalizationManager.Instance; var managerBtn = new Button { Content = l["desktop.startMode.managerBtn"], Classes = { "primary" } }; var switchBtn = new Button { Content = l["desktop.standaloneSwitch.switchAnyway"] }; var cancelBtn = new Button { Content = l["common.cancel"], IsCancel = true }; var window = new Window { Title = l["desktop.standaloneSwitch.title"], 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 = l["desktop.standaloneSwitch.warning"], Classes = { "h1" } }, new TextBlock { Text = l["desktop.standaloneSwitch.text"], 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 AskStartModeAsync() => await Dispatcher.UIThread.InvokeAsync(async () => { var completion = new TaskCompletionSource(); var l = LocalizationManager.Instance; var managerBtn = new Button { Content = l["desktop.startMode.managerBtn"], Classes = { "primary" } }; var standaloneBtn = new Button { Content = l["desktop.startMode.standaloneBtn"] }; var cancelBtn = new Button { Content = l["common.cancel"], IsCancel = true }; var window = new Window { Title = l["desktop.startMode.title"], Width = 540, SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterScreen, Content = new StackPanel { Margin = new Thickness(22), Spacing = 16, Children = { new TextBlock { Text = l["desktop.startMode.question"], Classes = { "h1" } }, new TextBlock { Text = l["desktop.startMode.explanation"], 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; /// /// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat. /// /// Erst auslaufen lassen, dann abschneiden. Laufende Agenten und /// beanspruchte Aufgaben bekommen ihre Frist /// (), bevor Weboberfläche und Aufbau fallen. Ohne /// das endete eine Aufgabe mitten in ProcessAsync und bliebe bis zum Ablauf /// ihres Lease auf running stehen. /// /// Und nicht im Rückruf von desktop.ShutdownRequested. Avalonia /// wartet dort nicht auf async-Behandlungen; was länger dauert als ein /// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst /// aufräumen und dann Shutdown rufen — der Rückruf dort bleibt als /// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts. /// 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()); } /// /// Faehrt die Weboberflaeche herunter, falls sie laeuft, und merkt sich, dass sie /// weg ist. Mehrfach aufrufbar: Update, Instanzwechsel und das gewoehnliche /// Beenden nehmen denselben Weg, und der letzte von ihnen soll nicht ins Leere /// greifen. /// private async Task ShutdownWebAsync() { if (_web is null) return; var web = _web; _web = null; await web.DisposeAsync(); } /// /// Instanzauswahl beim Start. /// private async Task SelectInstanceAsync(InstanceDirectoryManager directories) { var preselected = _preselectedInstancePath; _preselectedInstancePath = null; if (preselected is not null && Directory.Exists(preselected)) return preselected; // Hier steht bewusst der Schalter und nicht der geprüfte Befund: Diese Methode // läuft mitten im Aufbau, bevor es einen AppHost gibt, der geprüft haben könnte. // Sie entscheidet auch nur, ob ein Dialog erscheint — wer --managed ohne gültige // Unterschrift setzt, bekommt ihn nicht und wird gleich darauf trotzdem nach // seiner Lizenz gefragt (#535). if (!_managed) { var choice = await AskStartModeAsync(); if (choice == StartModeChoice.LaunchManager) { LaunchInstanceManagerAndOpenBrowser(); return null; } if (choice == StartModeChoice.Cancel) { return null; } } return await ShowInstancePickerAsync(directories); } private static async Task ShowInstancePickerAsync(InstanceDirectoryManager directories) => await Dispatcher.UIThread.InvokeAsync(async () => { var viewModel = new InstancePickerViewModel(directories); var window = new InstancePickerWindow { DataContext = viewModel }; // Schließt der Benutzer das Fenster, gilt das als Abbruch — sonst wartete // der Start für immer auf eine Auswahl, die nie kommt. window.Closed += (_, _) => viewModel.CancelCommand.Execute(null); window.Show(); var path = await viewModel.Result; window.Close(); return path; }); /// /// Einzeilige Abfrage — ersetzt Microsoft.VisualBasic.Interaction.InputBox, /// das die Telegram-Anmeldung an Windows band. /// private static async Task AskAsync(string title, string prompt) => await Dispatcher.UIThread.InvokeAsync(async () => { var completion = new TaskCompletionSource(); var input = new TextBox(); var ok = new Button { Content = LocalizationManager.Instance["common.ok"], IsDefault = true }; var window = new Window { Title = title, Width = 420, SizeToContent = SizeToContent.Height, CanResize = false, WindowStartupLocation = WindowStartupLocation.CenterScreen, Content = new StackPanel { Margin = new Thickness(20), Spacing = 12, Children = { new TextBlock { Text = prompt, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, input, ok } } }; ok.Click += (_, _) => window.Close(); window.Closed += (_, _) => completion.TrySetResult(input.Text?.Trim() ?? ""); window.Show(); input.Focus(); return await completion.Task; }); }