using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; using Dalamud.Plugin; using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Pal.Client.Commands; using Pal.Client.Configuration; using Pal.Client.Configuration.Legacy; using Pal.Client.Database; using Pal.Client.DependencyInjection; using Pal.Client.Properties; using Pal.Client.Windows; namespace Pal.Client { /// /// Takes care of async plugin init - this is mostly everything that requires either the config or the database to /// be available. /// internal sealed class DependencyInjectionLoader { private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; public DependencyInjectionLoader(ILogger logger, IServiceProvider serviceProvider) { _logger = logger; _serviceProvider = serviceProvider; } public ELoadState LoadState { get; private set; } = ELoadState.Initializing; public event Action? InitCompleted; public async Task InitializeAsync(CancellationToken cancellationToken) { using IDisposable? logScope = _logger.BeginScope("AsyncInit"); Chat? chat = null; try { _logger.LogInformation("Starting async init"); chat = _serviceProvider.GetService(); await RemoveOldBackups(); await CreateBackups(); cancellationToken.ThrowIfCancellationRequested(); await RunMigrations(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); await RunCleanup(_logger); cancellationToken.ThrowIfCancellationRequested(); // v1 migration: config migration for import history, json migration for markers _serviceProvider.GetRequiredService().Migrate(); await _serviceProvider.GetRequiredService().MigrateAsync(cancellationToken); cancellationToken.ThrowIfCancellationRequested(); // windows that have logic to open on startup _serviceProvider.GetRequiredService(); // initialize components that are mostly self-contained/self-registered _serviceProvider.GetRequiredService(); _serviceProvider.GetRequiredService(); _serviceProvider.GetRequiredService(); // eager load any commands to find errors now, not when running them _serviceProvider.GetRequiredService(); _serviceProvider.GetRequiredService(); _serviceProvider.GetRequiredService(); _serviceProvider.GetRequiredService(); cancellationToken.ThrowIfCancellationRequested(); LoadState = ELoadState.Loaded; InitCompleted?.Invoke(null); _logger.LogInformation("Async init complete"); } catch (ObjectDisposedException) { InitCompleted?.Invoke(null); LoadState = ELoadState.Error; } catch (Exception e) { _logger.LogError(e, "Async load failed"); InitCompleted?.Invoke(() => chat?.Error(string.Format(Localization.Error_LoadFailed, $"{e.GetType()} - {e.Message}"))); LoadState = ELoadState.Error; } } private async Task RemoveOldBackups() { await using var scope = _serviceProvider.CreateAsyncScope(); var pluginInterface = scope.ServiceProvider.GetRequiredService(); var configuration = scope.ServiceProvider.GetRequiredService(); var paths = Directory.GetFiles(pluginInterface.GetPluginConfigDirectory(), "backup-*.data.sqlite3", new EnumerationOptions { IgnoreInaccessible = true, RecurseSubdirectories = false, MatchCasing = MatchCasing.CaseSensitive, AttributesToSkip = FileAttributes.ReadOnly | FileAttributes.Hidden | FileAttributes.System, ReturnSpecialDirectories = false, }); if (paths.Length == 0) return; Regex backupRegex = new Regex(@"backup-([\d\-]{10})\.data\.sqlite3", RegexOptions.Compiled); List<(DateTime Date, string Path)> backupFiles = new(); foreach (string path in paths) { var match = backupRegex.Match(Path.GetFileName(path)); if (!match.Success) continue; if (DateTime.TryParseExact(match.Groups[1].Value, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out DateTime backupDate)) { backupFiles.Add((backupDate, path)); } } var toDelete = backupFiles.OrderByDescending(x => x.Date) .Skip(configuration.Backups.MinimumBackupsToKeep) .Where(x => (DateTime.Today.ToUniversalTime() - x.Date).Days > configuration.Backups.DaysToDeleteAfter) .Select(x => x.Path); foreach (var path in toDelete) { try { File.Delete(path); _logger.LogInformation("Deleted old backup file '{Path}'", path); } catch (Exception e) { _logger.LogWarning(e, "Could not delete backup file '{Path}'", path); } } } private async Task CreateBackups() { await using var scope = _serviceProvider.CreateAsyncScope(); var pluginInterface = scope.ServiceProvider.GetRequiredService(); string backupPath = Path.Join(pluginInterface.GetPluginConfigDirectory(), $"backup-{DateTime.Today.ToUniversalTime():yyyy-MM-dd}.data.sqlite3"); if (!File.Exists(backupPath)) { _logger.LogInformation("Creating database backup '{Path}'", backupPath); await using var db = scope.ServiceProvider.GetRequiredService(); await using SqliteConnection source = new(db.Database.GetConnectionString()); await source.OpenAsync(); await using SqliteConnection backup = new($"Data Source={backupPath}"); source.BackupDatabase(backup); SqliteConnection.ClearPool(backup); } else _logger.LogInformation("Database backup in '{Path}' already exists", backupPath); } private async Task RunMigrations(CancellationToken cancellationToken) { await using var scope = _serviceProvider.CreateAsyncScope(); _logger.LogInformation("Loading database & running migrations"); await using var dbContext = scope.ServiceProvider.GetRequiredService(); // takes 2-3 seconds with initializing connections, loading driver etc. await dbContext.Database.MigrateAsync(cancellationToken); _logger.LogInformation("Completed database migrations"); } private async Task RunCleanup(ILogger logger) { await using var scope = _serviceProvider.CreateAsyncScope(); await using var dbContext = scope.ServiceProvider.GetRequiredService(); var cleanup = scope.ServiceProvider.GetRequiredService(); cleanup.Purge(dbContext); await dbContext.SaveChangesAsync(); } public enum ELoadState { Initializing, Loaded, Error } } }