Files
ClawdDotNet/src/ClawdDotNet.Desktop/App.axaml.cs
T
RichardandClaude Opus 5 33d95a6c3f UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem
Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag.

Oberflaeche
- Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als
  mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml
  (eigene ControlThemes statt Fluent umzufaerben).
- Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer
  Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite
  statt eigenem Fenster, Texteditor-Fenster.
- ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter
  entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt.

Rocket.Chat
- Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der
  Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird
  nur, wenn wirklich etwas anliegt.
- send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum
  ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist.
- Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei
  Annahmen waren falsch und sind korrigiert.

Deploymentcenter
- DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json)
  erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke.
- AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst
  ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 12:16:05 +02:00

448 lines
17 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;
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 = "--instance";
private AppHost? _host;
/// <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 = 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();
}
/// <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);
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 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);
}
/// <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 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());
}
/// <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;
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<string>? 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;
}
/// <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;
});
}