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>
448 lines
17 KiB
C#
448 lines
17 KiB
C#
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;
|
||
});
|
||
}
|