UI-Entwurf umgesetzt, Rocket.Chat-Tool, Deploymentcenter 2.4
Sicherungspunkt vor dem Aufraeumen. Buendelt die Arbeit, die seit dem Abschluss der Avalonia-Portierung im Arbeitsverzeichnis lag. Oberflaeche - Entwurf aus Mockup/ umgesetzt: Theme.axaml (Farben je Thema, Barlow als mitgelieferte Schrift), Icons.axaml (Symbolgeometrien), Shell.axaml (eigene ControlThemes statt Fluent umzufaerben). - Neue Steuerelemente StrokeIcon und BlueprintFrame, Seiten fuer Token-Verbrauch und Agenten-Chats, Werkzeug-Einstellungen als Seite statt eigenem Fenster, Texteditor-Fenster. - ThemeManager mit hellem und dunklem Thema; die beiden Pinsel-Konverter entfallen, weil ein fester Farbwert den Themenwechsel nicht ueberlebt. Rocket.Chat - Neues Tool-Projekt (Client, Konfiguration, Workspace-Dateien) nach der Bauform des Telegram-Tools: rocketchat_poll als Tool-Job, geweckt wird nur, wenn wirklich etwas anliegt. - send_file ist freigabepflichtig, send_message bewusst nicht: Der Raum ist Arbeitsraum, der Schutz sitzt an der Raum-Allowlist. - Konzept-Doc um die Messung gegen die echte Instanz 8.7 ergaenzt; drei Annahmen waren falsch und sind korrigiert. Deploymentcenter - DC6 (Update anwenden) und DC7 (Erstinstallation ueber setup.json) erledigt, DC3 fuer win-x64/dev; deploy/publish.py als Release-Strecke. - AppHost.DisposeAsync gegen doppeltes Herunterfahren gesperrt - sonst ueberschreibt eine zweite Abmeldung den Wartungszustand am Watchdog. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
using System.Diagnostics;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
@@ -5,6 +6,7 @@ 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;
|
||||
@@ -13,8 +15,18 @@ 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()
|
||||
@@ -26,6 +38,10 @@ public partial class App : Application
|
||||
// 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();
|
||||
@@ -40,6 +56,30 @@ public partial class App : Application
|
||||
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
|
||||
@@ -70,15 +110,18 @@ public partial class App : Application
|
||||
HookErrorReporting(_host);
|
||||
HookLicenseWatch(_host, desktop);
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = new MainWindowViewModel(_host)
|
||||
};
|
||||
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 ShowUpdateNoticeAsync(_host);
|
||||
await OfferUpdateAsync(_host, desktop);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -132,11 +175,13 @@ public partial class App : Application
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// 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 ShowUpdateNoticeAsync(AppHost host)
|
||||
private static async Task OfferUpdateAsync(AppHost host, IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (host.Deploymentcenter is null) return;
|
||||
|
||||
@@ -146,13 +191,202 @@ public partial class App : Application
|
||||
|
||||
if (host.Deploymentcenter.Update is not { IsAvailable: true } update) return;
|
||||
|
||||
await new AvaloniaLicensePrompt().ShowInfoAsync(
|
||||
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."
|
||||
+ (string.IsNullOrWhiteSpace(update.ReleaseNotes) ? "" : $"\n\n{update.ReleaseNotes}"));
|
||||
$"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);
|
||||
}
|
||||
|
||||
private static async Task<string?> SelectInstanceAsync(InstanceDirectoryManager directories)
|
||||
/// <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);
|
||||
|
||||
Reference in New Issue
Block a user