- #578: Abgebrochene Laeufe (Schrittgrenze, Zeitueberschreitung, Abbruch, Ausnahme) tragen ihre bezahlten Token - vorher 0 in RunUsage, fuer das Budget unsichtbar. - #580: WebFetch prueft die aufgeloeste Zieladresse beim Verbinden; ein erlaubter Name, der auf 127.0.0.1 aufloest, wird gesperrt. - #575: Dateien entstehen unter Unix mit 0600 (AtomicFile, Protokolle, FileRW-Anhaengen, state.db), Protokollverzeichnisse mit 0700. - #576: SIGTERM/SIGINT beenden auch mit Fenster geordnet. - #593: OpenRouter-Statusdienst wird gestartet. - #589: Auftragsnachricht nennt aktuelle Zeit, Zeitzone und Termin. - #581: Unvollstaendiges Argument-JSON erreicht das Werkzeug nicht mehr; das Modell bekommt eine verstaendliche Rueckmeldung statt Parsertext. - #591: Weboberflaeche liest die Felder, die API und Ereignisstrom liefern (Freigaben, Protokoll, Taskboard mit Termin, Werkzeug-Einstellungen, Tokennutzung); Ereignisstrom in camelCase. - #592: Chat zeigt Nachrichten nicht mehr doppelt. - #536 (teilweise): A2A-Antworten tragen Absender und Empfaenger, Parser wirft nicht mehr; Datums- und Volltextfilter in Agenten-Chats. - deploy/publish.py: Packager-Pfad net10.0, Schalter --agent-file. - Version 0.1.4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
982 lines
38 KiB
C#
982 lines
38 KiB
C#
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
|
||
{
|
||
/// <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;
|
||
|
||
/// <summary>
|
||
/// Vom Instanzmanager gestartet (<c>--managed</c>).
|
||
///
|
||
/// <para>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.</para>
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <summary>
|
||
/// SIGTERM und SIGINT beenden auch mit Fenster geordnet (#576).
|
||
///
|
||
/// <para>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.</para>
|
||
/// </summary>
|
||
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();
|
||
|
||
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();
|
||
}
|
||
|
||
/// <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(),
|
||
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;
|
||
|
||
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);
|
||
}
|
||
|
||
/// <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>
|
||
/// Holt das Fenster dieser Instanz nach vorn — auf Bitte des Instanzmanagers.
|
||
///
|
||
/// <para><b>Die Instanz macht das selbst, statt dass jemand von aussen an ihrem
|
||
/// Fenster zieht.</b> 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.</para>
|
||
///
|
||
/// <para><b>Das letzte Wort hat der Fenstermanager.</b> Windows lässt einen Prozess
|
||
/// im Hintergrund den Vordergrund nicht einfach an sich reissen — <c>Activate</c>
|
||
/// blinkt dann nur in der Leiste. Der kurze Ausflug über <c>Topmost</c> 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.</para>
|
||
/// </summary>
|
||
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();
|
||
});
|
||
|
||
/// <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 enum StartModeChoice
|
||
{
|
||
LaunchManager,
|
||
Standalone,
|
||
Cancel
|
||
}
|
||
|
||
private enum StandaloneSwitchChoice
|
||
{
|
||
LaunchManager,
|
||
SwitchStandalone,
|
||
Cancel
|
||
}
|
||
|
||
/// <summary>
|
||
/// Instanzwechsel aus der laufenden Anwendung.
|
||
/// </summary>
|
||
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}");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sucht die Programmdatei des Instanzmanagers.
|
||
///
|
||
/// <para><b>Die Pfade werden aus Segmenten gebaut, nicht als Zeichenkette.</b> Hier
|
||
/// stand <c>@"..\..\..\..\ClawdDotNet.ManagerApp\bin\Debug\net10.0"</c> — unter Linux,
|
||
/// dem einzigen Zielbetrieb, ist das kein Pfad, sondern ein einziger Dateiname mit
|
||
/// Rückstrichen darin, und die Suche ging dort nie auf.</para>
|
||
///
|
||
/// <para>Die Entwicklungspfade bleiben, weil im Betrieb beide Programme
|
||
/// nebeneinanderliegen und der erste Versuch trifft; sie kosten dort nur zwei
|
||
/// vergebliche <c>File.Exists</c>.</para>
|
||
/// </summary>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Der Port, auf dem der Instanzmanager seine Oberfläche anbietet.
|
||
///
|
||
/// <para>Aus <c>manager.json</c> 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.</para>
|
||
/// </summary>
|
||
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<StandaloneSwitchChoice> AskStandaloneSwitchModeAsync(Window? owner)
|
||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||
{
|
||
var completion = new TaskCompletionSource<StandaloneSwitchChoice>();
|
||
|
||
var managerBtn = new Button
|
||
{
|
||
Content = "Instanzmanager starten (Empfohlen)",
|
||
Classes = { "primary" }
|
||
};
|
||
|
||
var switchBtn = new Button
|
||
{
|
||
Content = "Instanz trotzdem wechseln"
|
||
};
|
||
|
||
var cancelBtn = new Button
|
||
{
|
||
Content = "Abbrechen",
|
||
IsCancel = true
|
||
};
|
||
|
||
var window = new Window
|
||
{
|
||
Title = "ClawdDotNet – Instanzwechsel (Standalone)",
|
||
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 = "Achtung: Standalone-Modus",
|
||
Classes = { "h1" }
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = "Diese Instanz läuft eigenständig ohne Instanzmanager.\n\n"
|
||
+ "Wenn du die Instanz hier wechselst, wird diese Instanz vollständig beendet "
|
||
+ "und alle laufenden Agenten und Hintergrunddienste werden gestoppt.\n\n"
|
||
+ "Empfehlung: Starte stattdessen den Instanzmanager, um mehrere Instanzen gleichzeitig und unabhängig voneinander zu betreiben.",
|
||
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<StartModeChoice> AskStartModeAsync()
|
||
=> await Dispatcher.UIThread.InvokeAsync(async () =>
|
||
{
|
||
var completion = new TaskCompletionSource<StartModeChoice>();
|
||
|
||
var managerBtn = new Button
|
||
{
|
||
Content = "Instanzmanager starten (Empfohlen)",
|
||
Classes = { "primary" }
|
||
};
|
||
|
||
var standaloneBtn = new Button
|
||
{
|
||
Content = "Als Standalone-Instanz starten"
|
||
};
|
||
|
||
var cancelBtn = new Button
|
||
{
|
||
Content = "Abbrechen",
|
||
IsCancel = true
|
||
};
|
||
|
||
var window = new Window
|
||
{
|
||
Title = "ClawdDotNet – Startmodus wählen",
|
||
Width = 540,
|
||
SizeToContent = SizeToContent.Height,
|
||
CanResize = false,
|
||
WindowStartupLocation = WindowStartupLocation.CenterScreen,
|
||
Content = new StackPanel
|
||
{
|
||
Margin = new Thickness(22),
|
||
Spacing = 16,
|
||
Children =
|
||
{
|
||
new TextBlock
|
||
{
|
||
Text = "Wie möchtest du ClawdDotNet starten?",
|
||
Classes = { "h1" }
|
||
},
|
||
new TextBlock
|
||
{
|
||
Text = "• Instanzmanager (Empfohlen):\n"
|
||
+ " Startet den zentralen Hintergrund-Manager und öffnet die Verwaltung im Browser. "
|
||
+ "Ermöglicht den stabilen Dauerbetrieb mehrerer Instanzen parallel ohne Absturzrisiko.\n\n"
|
||
+ "• Standalone-Modus:\n"
|
||
+ " Startet genau eine Instanz direkt als einzelnes Desktop-Fenster.",
|
||
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;
|
||
|
||
/// <summary>
|
||
/// Das geordnete Ende dieser Instanz — der eine Weg, egal wer darum gebeten hat.
|
||
///
|
||
/// <para><b>Erst auslaufen lassen, dann abschneiden.</b> Laufende Agenten und
|
||
/// beanspruchte Aufgaben bekommen ihre Frist
|
||
/// (<see cref="ShutdownCoordinator"/>), bevor Weboberfläche und Aufbau fallen. Ohne
|
||
/// das endete eine Aufgabe mitten in <c>ProcessAsync</c> und bliebe bis zum Ablauf
|
||
/// ihres Lease auf <c>running</c> stehen.</para>
|
||
///
|
||
/// <para><b>Und nicht im Rückruf von <c>desktop.ShutdownRequested</c>.</b> Avalonia
|
||
/// wartet dort nicht auf <c>async</c>-Behandlungen; was länger dauert als ein
|
||
/// Wimpernschlag, läuft gegen einen Prozess, der schon weg ist. Deshalb erst
|
||
/// aufräumen und <em>dann</em> <c>Shutdown</c> rufen — der Rückruf dort bleibt als
|
||
/// zweites Schloss stehen und tut dank der Sperren beim zweiten Mal nichts.</para>
|
||
/// </summary>
|
||
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());
|
||
}
|
||
|
||
/// <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.
|
||
/// </summary>
|
||
private async Task<string?> 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<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;
|
||
});
|
||
}
|