Files
ClawdDotNet/src/ClawdDotNet.Desktop/App.axaml.cs
T
RichardandClaude Opus 5 6581fd0187 Befunde aus dem Testbetrieb auf der Linux-VM behoben (#572)
- #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>
2026-09-14 15:40:56 +02:00

982 lines
38 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 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;
});
}