@@ -26,6 +26,8 @@ public class App : Application
|
||||
public static IServiceProvider Services { get; private set; } = null!;
|
||||
private static ServiceProvider? _serviceProvider;
|
||||
private static bool _exitHandlerAttached;
|
||||
private static Task _initialPolls = Task.CompletedTask;
|
||||
private static readonly CancellationTokenSource StartupCancellation = new();
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
@@ -65,7 +67,11 @@ public class App : Application
|
||||
promptVm.OnUnlocked = async password =>
|
||||
{
|
||||
AppBootstrapper.DbPassword = password;
|
||||
await StartMainAppAsync(desktop, promptWindow);
|
||||
var unlockedSplash = new SplashWindow();
|
||||
desktop.MainWindow = unlockedSplash;
|
||||
unlockedSplash.Show();
|
||||
promptWindow.Close();
|
||||
await StartMainAppAsync(desktop, unlockedSplash);
|
||||
};
|
||||
desktop.MainWindow = promptWindow;
|
||||
promptWindow.Show();
|
||||
@@ -79,14 +85,21 @@ public class App : Application
|
||||
private static async Task StartMainAppAsync(
|
||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
||||
{
|
||||
_serviceProvider = AppBootstrapper.BuildServices();
|
||||
var splash = windowToClose as SplashWindow;
|
||||
var timer = System.Diagnostics.Stopwatch.StartNew();
|
||||
var progress = new Progress<(int Value, string Text)>(step =>
|
||||
splash?.SetProgress(step.Value, step.Text));
|
||||
_serviceProvider = await Task.Run(() => AppBootstrapper.BuildServices(
|
||||
(value, text) => ((IProgress<(int, string)>)progress).Report((value, text))));
|
||||
Services = _serviceProvider;
|
||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
||||
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||
// soll, kein dauerhaftes Archiv.
|
||||
Services.GetRequiredService<ITrashRepository>().PurgeOlderThan(DateTime.UtcNow.AddDays(-30));
|
||||
splash?.SetProgress(60, "Papierkorb aufräumen …");
|
||||
await Task.Run(() => Services.GetRequiredService<ITrashRepository>()
|
||||
.PurgeOlderThan(DateTime.UtcNow.AddDays(-30)));
|
||||
|
||||
if (!_exitHandlerAttached)
|
||||
{
|
||||
@@ -99,19 +112,13 @@ public class App : Application
|
||||
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||
|
||||
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||
WireCallbacks(mainVm);
|
||||
|
||||
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
|
||||
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
|
||||
// Hauptfenster mit fertigem Datenstand und friert nicht kurz danach ein. Das Auflösen
|
||||
// aktiviert zugleich die periodischen Timer.
|
||||
var initialPolls = new List<Task>();
|
||||
if (Services.GetService<UntisSyncService>() is { } untisSync)
|
||||
initialPolls.Add(untisSync.PollAsync());
|
||||
if (Services.GetService<AnnualPlanSyncService>() is { } annualPlanSync)
|
||||
initialPolls.Add(annualPlanSync.PollAsync());
|
||||
await Task.WhenAll(initialPolls);
|
||||
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
||||
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||
|
||||
var main = new MainWindow { DataContext = mainVm };
|
||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||
@@ -119,7 +126,30 @@ public class App : Application
|
||||
main.EnableFinalSync(syncEngine);
|
||||
desktop.MainWindow = main;
|
||||
main.Show();
|
||||
splash?.SetProgress(100, "Bereit");
|
||||
windowToClose?.Close();
|
||||
AppBootstrapper.Logger.Info($"Start: Hauptfenster nach {timer.ElapsedMilliseconds} ms geöffnet.");
|
||||
|
||||
// Vorhandene lokale Daten sind sofort nutzbar; Netzwerkzugriffe blockieren den Start nicht.
|
||||
var untis = Services.GetService<UntisSyncService>();
|
||||
var annualPlan = Services.GetService<AnnualPlanSyncService>();
|
||||
if (untis is not null)
|
||||
untis.DataChanged += () => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
Services.GetRequiredService<TimetableViewModel>().Load();
|
||||
Services.GetRequiredService<DashboardViewModel>().RefreshCommand.Execute(null);
|
||||
});
|
||||
_initialPolls = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(
|
||||
untis?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask,
|
||||
annualPlan?.PollAsync(StartupCancellation.Token) ?? Task.CompletedTask);
|
||||
}
|
||||
catch (OperationCanceledException) when (StartupCancellation.IsCancellationRequested) { }
|
||||
catch (Exception ex) { AppBootstrapper.Logger.Error("Erstabgleich fehlgeschlagen.", ex); }
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||
@@ -140,6 +170,8 @@ public class App : Application
|
||||
|
||||
try
|
||||
{
|
||||
StartupCancellation.Cancel();
|
||||
_initialPolls.GetAwaiter().GetResult();
|
||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -101,7 +101,7 @@ public static class AppBootstrapper
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
public static ServiceProvider BuildServices()
|
||||
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -130,7 +130,9 @@ public static class AppBootstrapper
|
||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||
var backupSettings = new BackupSettingsService(appData);
|
||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||
reportProgress?.Invoke(10, "Sicherung erstellen …");
|
||||
var backupPath = backup.CreateBackup(DbPath);
|
||||
reportProgress?.Invoke(25, "Sicherung prüfen …");
|
||||
// Best-effort-Prüfung des automatischen Startbackups: nur geloggt, kein Blocker für den
|
||||
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||
@@ -363,7 +365,9 @@ public static class AppBootstrapper
|
||||
services.AddTransient<TrashViewModel>();
|
||||
services.AddTransient<SettingsViewModel>();
|
||||
|
||||
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
|
||||
var provider = services.BuildServiceProvider();
|
||||
provider.GetRequiredService<LiteDbContext>();
|
||||
|
||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
||||
|
||||
@@ -36,7 +36,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
_timer = new Timer(async _ => await PollAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
public async Task PollAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||
try
|
||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
||||
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
|
||||
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
||||
}
|
||||
|
||||
public async Task PollAsync()
|
||||
public async Task PollAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||
try
|
||||
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
|
||||
string icsText;
|
||||
try
|
||||
{
|
||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
||||
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -7,8 +7,21 @@
|
||||
ShowInTaskbar="False"
|
||||
WindowDecorations="None"
|
||||
WindowStartupLocation="CenterScreen">
|
||||
<Image Source="/Assets/SplashScreen.png"
|
||||
Stretch="UniformToFill"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"/>
|
||||
<Viewbox Stretch="UniformToFill">
|
||||
<Canvas Width="1086" Height="1448">
|
||||
<Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
|
||||
<Border Canvas.Left="326" Canvas.Top="1004" Width="480" Height="31"
|
||||
Background="#303234" CornerRadius="16" ClipToBounds="True">
|
||||
<ProgressBar x:Name="StartupProgress" Minimum="0" Maximum="100" Value="0"
|
||||
Height="31" Background="#303234" Foreground="#229DDD"
|
||||
ShowProgressText="False"/>
|
||||
</Border>
|
||||
<Border Canvas.Left="295" Canvas.Top="1042" Width="520" Height="58"
|
||||
Background="#202326" CornerRadius="12">
|
||||
<TextBlock x:Name="StartupStatus" Text="Start wird vorbereitet …"
|
||||
Foreground="White" FontSize="24" TextAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
</Border>
|
||||
</Canvas>
|
||||
</Viewbox>
|
||||
</Window>
|
||||
|
||||
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
|
||||
public partial class SplashWindow : Window
|
||||
{
|
||||
public SplashWindow() => InitializeComponent();
|
||||
|
||||
public void SetProgress(int value, string status)
|
||||
{
|
||||
StartupProgress.Value = value;
|
||||
StartupStatus.Text = status;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4801,3 +4801,12 @@ Die Abschnitte sind thematisch, nicht chronologisch nummeriert. Sinnvolle Bearbe
|
||||
**→ nächster sinnvoller Schritt:** offene größere Kapitel wie **3.1** (Mitarbeit-Aspekte
|
||||
pro Gruppe verwalten), **9** (Dashboard-Kacheln), **10** (Sync) oder **11** (Export) — oder
|
||||
gezielt eines der oben zurückgestellten 4.5.x-Punkte, falls der Bedarf danach entsteht.
|
||||
|
||||
|
||||
### Startoptimierung (September 2026)
|
||||
|
||||
- [x] Backup, Backup-Pruefung und Datenbankinitialisierung laufen ausserhalb des UI-Threads.
|
||||
- [x] Splashscreen mit echtem, phasenbasiertem Ladebalken und aktuellem Arbeitsschritt.
|
||||
- [x] Optionale iCal-Erstabgleiche starten nach dem Hauptfenster; lokale Daten sind sofort nutzbar.
|
||||
Laufende Erstabgleiche werden vor der Freigabe der Datenbank beendet; HTTP-Abrufe beim Beenden abgebrochen.
|
||||
Die Zeit bis zum Hauptfenster wird im App-Log protokolliert.
|
||||
|
||||
Reference in New Issue
Block a user