@@ -26,6 +26,8 @@ public class App : Application
|
|||||||
public static IServiceProvider Services { get; private set; } = null!;
|
public static IServiceProvider Services { get; private set; } = null!;
|
||||||
private static ServiceProvider? _serviceProvider;
|
private static ServiceProvider? _serviceProvider;
|
||||||
private static bool _exitHandlerAttached;
|
private static bool _exitHandlerAttached;
|
||||||
|
private static Task _initialPolls = Task.CompletedTask;
|
||||||
|
private static readonly CancellationTokenSource StartupCancellation = new();
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
@@ -65,7 +67,11 @@ public class App : Application
|
|||||||
promptVm.OnUnlocked = async password =>
|
promptVm.OnUnlocked = async password =>
|
||||||
{
|
{
|
||||||
AppBootstrapper.DbPassword = 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;
|
desktop.MainWindow = promptWindow;
|
||||||
promptWindow.Show();
|
promptWindow.Show();
|
||||||
@@ -79,14 +85,21 @@ public class App : Application
|
|||||||
private static async Task StartMainAppAsync(
|
private static async Task StartMainAppAsync(
|
||||||
IClassicDesktopStyleApplicationLifetime desktop, Window? windowToClose = null)
|
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 = _serviceProvider;
|
||||||
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
Services.GetRequiredService<AppLogger>().Info("Anwendung gestartet.");
|
||||||
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
GlobalExceptionHandler.AttachNotifications(Services.GetRequiredService<NotificationService>());
|
||||||
// Papierkorb (14.3): Einträge älter als 30 Tage endgültig entfernen. Beim Start statt per
|
// 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
|
// Timer - reicht für ein Werkzeug, das ohnehin nur "Fehlklick eben rückgängig machen" sein
|
||||||
// soll, kein dauerhaftes Archiv.
|
// 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)
|
if (!_exitHandlerAttached)
|
||||||
{
|
{
|
||||||
@@ -99,19 +112,13 @@ public class App : Application
|
|||||||
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
// Opt-in - ein Neustart der App richtet den Pipe-Listener neu ein.
|
||||||
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
Services.GetRequiredService<Services.Mcp.McpServerHostedService>().Start();
|
||||||
|
|
||||||
|
splash?.SetProgress(75, "Übersicht vorbereiten …");
|
||||||
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
var mainVm = Services.GetRequiredService<MainWindowViewModel>();
|
||||||
WireCallbacks(mainVm);
|
WireCallbacks(mainVm);
|
||||||
|
|
||||||
// Beide optionalen Erstabgleiche noch unter dem Splashscreen abschließen. Ihre
|
splash?.SetProgress(90, "Hauptfenster öffnen …");
|
||||||
// CPU-/Datenbankarbeit läuft innerhalb der Dienste im Threadpool; dadurch öffnet das
|
await Dispatcher.UIThread.InvokeAsync(() => { }, DispatcherPriority.Background);
|
||||||
// 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);
|
|
||||||
|
|
||||||
var main = new MainWindow { DataContext = mainVm };
|
var main = new MainWindow { DataContext = mainVm };
|
||||||
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
main.EnableWindowSizePersistence(Services.GetRequiredService<WindowSettingsService>());
|
||||||
@@ -119,7 +126,30 @@ public class App : Application
|
|||||||
main.EnableFinalSync(syncEngine);
|
main.EnableFinalSync(syncEngine);
|
||||||
desktop.MainWindow = main;
|
desktop.MainWindow = main;
|
||||||
main.Show();
|
main.Show();
|
||||||
|
splash?.SetProgress(100, "Bereit");
|
||||||
windowToClose?.Close();
|
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
|
/// <summary>Wechselt die Darstellung sofort, ohne Neustart (12.4) — Avalonia stylt den
|
||||||
@@ -140,6 +170,8 @@ public class App : Application
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
StartupCancellation.Cancel();
|
||||||
|
_initialPolls.GetAwaiter().GetResult();
|
||||||
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
serviceProvider.GetService<LiteDbContext>()?.Checkpoint();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ public static class AppBootstrapper
|
|||||||
Environment.Exit(0);
|
Environment.Exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static ServiceProvider BuildServices()
|
public static ServiceProvider BuildServices(Action<int, string>? reportProgress = null)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
|
||||||
@@ -130,7 +130,9 @@ public static class AppBootstrapper
|
|||||||
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
// Verschlüsselung) — reine Datei-Kopie, kein offener LiteDB-Handle nötig.
|
||||||
var backupSettings = new BackupSettingsService(appData);
|
var backupSettings = new BackupSettingsService(appData);
|
||||||
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
var backup = new BackupService(appData) { SecondaryBackupDirectory = backupSettings.LoadSecondaryDirectory() };
|
||||||
|
reportProgress?.Invoke(10, "Sicherung erstellen …");
|
||||||
var backupPath = backup.CreateBackup(DbPath);
|
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
|
// 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.
|
// Programmstart — ein beschädigtes Backup soll auffallen, nicht den Nutzer aufhalten.
|
||||||
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
if (backupPath is not null && !new DatabaseEncryptionService().CanOpenAndRead(backupPath, DbPassword))
|
||||||
@@ -363,7 +365,9 @@ public static class AppBootstrapper
|
|||||||
services.AddTransient<TrashViewModel>();
|
services.AddTransient<TrashViewModel>();
|
||||||
services.AddTransient<SettingsViewModel>();
|
services.AddTransient<SettingsViewModel>();
|
||||||
|
|
||||||
|
reportProgress?.Invoke(40, "Datenbank öffnen und aktualisieren …");
|
||||||
var provider = services.BuildServiceProvider();
|
var provider = services.BuildServiceProvider();
|
||||||
|
provider.GetRequiredService<LiteDbContext>();
|
||||||
|
|
||||||
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
// Sync-agnostischer Hook auf LiteDbContext (siehe LiteDbContext.OnChange) wird erst hier,
|
||||||
// außerhalb der Repository-Registrierung, mit der tatsächlichen Sync-Logik verbunden.
|
// 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);
|
_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;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
@@ -47,7 +47,7 @@ public sealed class AnnualPlanSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ public class UntisSyncService : IDisposable
|
|||||||
TimeSpan.FromMinutes(PollIntervalMinutes), TimeSpan.FromMinutes(PollIntervalMinutes));
|
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;
|
if (!await _gate.WaitAsync(0).ConfigureAwait(false)) return;
|
||||||
try
|
try
|
||||||
@@ -76,7 +76,7 @@ public class UntisSyncService : IDisposable
|
|||||||
string icsText;
|
string icsText;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
icsText = await _http.GetStringAsync(url).ConfigureAwait(false);
|
icsText = await _http.GetStringAsync(url, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,8 +7,21 @@
|
|||||||
ShowInTaskbar="False"
|
ShowInTaskbar="False"
|
||||||
WindowDecorations="None"
|
WindowDecorations="None"
|
||||||
WindowStartupLocation="CenterScreen">
|
WindowStartupLocation="CenterScreen">
|
||||||
<Image Source="/Assets/SplashScreen.png"
|
<Viewbox Stretch="UniformToFill">
|
||||||
Stretch="UniformToFill"
|
<Canvas Width="1086" Height="1448">
|
||||||
HorizontalAlignment="Stretch"
|
<Image Source="/Assets/SplashScreen.png" Width="1086" Height="1448"/>
|
||||||
VerticalAlignment="Stretch"/>
|
<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>
|
</Window>
|
||||||
|
|||||||
@@ -5,4 +5,10 @@ namespace LehrerApp.Desktop.Views;
|
|||||||
public partial class SplashWindow : Window
|
public partial class SplashWindow : Window
|
||||||
{
|
{
|
||||||
public SplashWindow() => InitializeComponent();
|
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
|
**→ 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
|
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.
|
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