feat(ui): complete Avalonia UI port with 7 main pages, tool settings & top MenuBar
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
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.Desktop.Services;
|
||||
using ClawdDotNet.Desktop.ViewModels;
|
||||
using ClawdDotNet.Desktop.Views;
|
||||
|
||||
namespace ClawdDotNet.Desktop;
|
||||
|
||||
public partial class App : Application
|
||||
{
|
||||
private AppHost? _host;
|
||||
|
||||
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;
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(_host)
|
||||
};
|
||||
|
||||
desktop.MainWindow.Show();
|
||||
desktop.MainWindow.Closed += (_, _) => desktop.Shutdown();
|
||||
|
||||
await ShowUpdateNoticeAsync(_host);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Hinweis auf ein verfügbares Update. Bewusst nur ein Hinweis: 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.
|
||||
/// </summary>
|
||||
private static async Task ShowUpdateNoticeAsync(AppHost host)
|
||||
{
|
||||
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;
|
||||
|
||||
await new AvaloniaLicensePrompt().ShowInfoAsync(
|
||||
update.IsCritical ? "ClawdDotNet – Wichtiges Update" : "ClawdDotNet – Update",
|
||||
$"Version {update.LatestVersion} ist verfügbar."
|
||||
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
|
||||
}
|
||||
|
||||
private static async Task<string?> SelectInstanceAsync(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;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user