Files
ClawdDotNet/src/ClawdDotNet.Desktop/App.axaml.cs
T
RichardandClaude Opus 5 b78e5d844f InstanceDirectoryManager nach Core verschoben (Vorarbeit #528 Stufe 3)
Reiner Umzug, keine Verhaltensaenderung. Der Instanzmanager braucht die
Verzeichnisstruktur einer Instanz - anlegen, auflisten, Konfiguration lesen
-, aber er darf ClawdDotNet.App nicht referenzieren: Dort haengen sechzehn
Werkzeugprojekte dran (Telegram, Mail, FTP, Rocket.Chat, ...), und der
Manager soll ausdruecklich keines davon kennen. Ein Projektverweis wuerde
sie nicht ausfuehren, aber laden - und die Zusage "keine Werkzeuge im
Manager" waere nur noch eine Absichtserklaerung statt einer Eigenschaft des
Bauplans.

Das Wissen selbst gehoert ohnehin nach Core: Es beschreibt, wie eine Instanz
auf der Platte liegt, und InstanceConfig und AgentConfig liegen dort schon.

Verschoben nach Core/Instances: InstanceDirectoryManager, InstanceInfo,
TokenUsageRecord/TokenUsageFile, AgentListItem/AgentListFile. In
ClawdDotNet.App/Models bleiben JobHistoryEntry und die
Werkzeug-Ansichtsmodelle, die dort richtig liegen.

Build ohne Warnungen, 717 Tests gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 22:17:46 +02:00

474 lines
18 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
using ClawdDotNet.Web;
using ClawdDotNet.Core.Instances;
namespace ClawdDotNet.Desktop;
public partial class App : Application
{
/// <summary>
/// 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.
/// </summary>
private const string InstanceArgument = CommandLine.InstanceArgument;
private AppHost? _host;
/// <summary>Die Weboberflaeche, falls sie eingeschaltet ist. Sonst null.</summary>
private InstanceHttpHost? _web;
/// <summary>Vorgewaehlte Instanz aus der Befehlszeile; wird genau einmal verbraucht.</summary>
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 = CommandLine.ReadInstancePath(desktop.Args);
ApplySavedThemeEarly();
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();
};
// 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();
}
/// <summary>
/// Setzt das gespeicherte Farbschema, bevor das erste Fenster erscheint.
///
/// <para>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.</para>
///
/// <para>Ein zweites Laden der Einstellungen ist unbedenklich: Load liest nur und
/// schreibt allenfalls beim allerersten Start die Vorgaben fest.</para>
/// </summary>
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);
// 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);
// 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);
}
/// <summary>
/// Meldet ungefangene Ausnahmen an den Fehler-Stream des Deploymentcenters.
///
/// <para>Erst hier verdrahtet, nicht in <c>Main</c>: 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 <em>während</em> des
/// Starts erreicht das Deploymentcenter nicht, steht aber im Protokoll.</para>
/// </summary>
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;
};
}
/// <summary>Ein Widerruf beendet die Anwendung, ohne Beenden-Rückfrage.</summary>
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));
};
}
/// <summary>
/// Fragt, ob ein verfügbares Update jetzt eingespielt werden soll.
///
/// <para>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.</para>
/// </summary>
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);
}
/// <summary>
/// Die Reihenfolge ist der ganze Punkt dieser Methode.
///
/// <list type="number">
/// <item>Dem Watchdog sagen, dass gleich ein Update kommt — sonst sieht das
/// Verschwinden der Instanz aus wie ein Ausfall.</item>
/// <item>Geordnet herunterfahren: Datenbank, Scanner, Telegram, Abmeldung.</item>
/// <item>Erst dann den Agenten starten. Er bekommt unsere Prozesskennung und wartet,
/// bis wir weg sind, statt über gesperrte Dateien zu kopieren.</item>
/// <item>Selbst beenden.</item>
/// </list>
///
/// <para>Die Verlockung wäre, <c>LaunchUpdateAgent</c> das Beenden zu überlassen —
/// das tut es aber über <c>Environment.Exit</c> und übergeht damit Schritt 2
/// vollständig.</para>
/// </summary>
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());
}
/// <summary>Ja/Nein-Rückfrage. Avalonia bringt kein MessageBox mit.</summary>
private static async Task<bool> ConfirmAsync(
string title, string message, string confirmText, string cancelText)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<bool>();
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;
});
/// <summary>
/// Instanzwechsel aus der laufenden Anwendung.
///
/// <para>Ein Wechsel im laufenden Prozess ist nicht vorgesehen und waere auch keine
/// kleine Sache: Engine, Scanner, Telegram-Anbindung und Datenbank haengen alle an
/// <em>einer</em> Instanz. Statt das umzubauen startet sich die Anwendung mit
/// <c>--instance</c> neu — derselbe Weg, den auch das Update geht.</para>
///
/// <para>Reihenfolge wie dort: erst geordnet herunterfahren, dann den neuen Prozess
/// starten. Andersherum liefen kurz zwei Prozesse auf derselben Datenbank.</para>
/// </summary>
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;
await ShutdownWebAsync();
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());
}
/// <summary>
/// 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.
/// </summary>
private async Task ShutdownWebAsync()
{
if (_web is null) return;
var web = _web;
_web = null;
await web.DisposeAsync();
}
/// <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)
{
var preselected = _preselectedInstancePath;
_preselectedInstancePath = null;
if (preselected is not null && Directory.Exists(preselected))
return preselected;
return await ShowInstancePickerAsync(directories);
}
private static async Task<string?> 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;
});
/// <summary>
/// Einzeilige Abfrage — ersetzt <c>Microsoft.VisualBasic.Interaction.InputBox</c>,
/// das die Telegram-Anmeldung an Windows band.
/// </summary>
private static async Task<string> AskAsync(string title, string prompt)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
var completion = new TaskCompletionSource<string>();
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;
});
}