using System.Diagnostics; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using Avalonia.Threading; using ClawdDotNet.App; using ClawdDotNet.App.Services; using ClawdDotNet.App.Settings; using ClawdDotNet.Desktop.Services; using ClawdDotNet.Desktop.ViewModels; using ClawdDotNet.Desktop.Views; 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 = "--instance"; private AppHost? _host; /// Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht. private string? _preselectedInstancePath; 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 = ReadInstanceArgument(desktop.Args); ApplySavedThemeEarly(); desktop.ShutdownRequested += async (_, _) => { if (_host is not null) await _host.DisposeAsync(); }; // 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. } } private async Task StartAsync(IClassicDesktopStyleApplicationLifetime desktop) { var result = await AppHost.StartAsync(new AppHost.Callbacks { SelectInstance = SelectInstanceAsync, License = new AvaloniaLicensePrompt(), 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; HookErrorReporting(_host); HookLicenseWatch(_host, desktop); var mainViewModel = new MainWindowViewModel(_host); // 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 }; desktop.MainWindow.Show(); desktop.MainWindow.Closed += (_, _) => desktop.Shutdown(); 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 static 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 static async Task StartUpdateAsync( AppHost host, IClassicDesktopStyleApplicationLifetime desktop, string version) { host.Deploymentcenter!.AnnounceUpdate(version); var settings = host.Settings.AppSettings; 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; }); /// /// 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 async Task SwitchInstanceAsync(IClassicDesktopStyleApplicationLifetime desktop) { if (_host is null) 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; } await _host.DisposeAsync(); try { Process.Start(new ProcessStartInfo(executable) { ArgumentList = { InstanceArgument, path }, UseShellExecute = false }); } catch (Exception ex) { await new AvaloniaLicensePrompt().ShowErrorAsync( "ClawdDotNet – Instanzwechsel", $"Neustart fehlgeschlagen: {ex.Message}"); } await Dispatcher.UIThread.InvokeAsync(() => desktop.Shutdown()); } private static string? ReadInstanceArgument(IReadOnlyList? args) { if (args is null) return null; for (var i = 0; i < args.Count - 1; i++) { if (string.Equals(args[i], InstanceArgument, StringComparison.OrdinalIgnoreCase)) return args[i + 1]; } return null; } /// /// Instanzauswahl beim Start. /// /// Kam die Anwendung mit --instance 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. /// private async Task SelectInstanceAsync(InstanceDirectoryManager directories) { var preselected = _preselectedInstancePath; _preselectedInstancePath = null; if (preselected is not null && Directory.Exists(preselected)) return preselected; 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 = "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; }); }