using System.Diagnostics; using System.IO.Compression; using System.Text.Json; using Serilog; namespace EchoHub.Client.Services; /// /// Manages pre-update backups and rollback restoration for the auto-updater. /// Backup location: ~/.echohub/update-backup/ /// public static class UpdateBackupService { private static readonly string BackupDir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".echohub", "update-backup"); private static readonly string BackupZipPath = Path.Combine(BackupDir, "backup.zip"); private static readonly string BackupInfoPath = Path.Combine(BackupDir, "backup-info.json"); /// /// True if a backup exists from a recent update (set at startup). /// public static bool IsPostUpdate { get; set; } /// /// Creates a ZIP backup of the current app directory before an update. /// Deletes any previous backup first. Uses fastest compression for speed. /// public static void CreateBackup() { var appDir = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var version = UpdateChecker.CurrentVersion; if (Directory.Exists(BackupDir)) Directory.Delete(BackupDir, true); Directory.CreateDirectory(BackupDir); Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version); using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create)) { foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories)) { var relativePath = Path.GetRelativePath(appDir, file); // Skip log files to prevent locking errors with Serilog while zipping if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) || relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase)) { continue; } // Normalize path separators for the zip archive format var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/'); try { archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest); } catch (IOException ex) { Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath); } catch (UnauthorizedAccessException ex) { Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath); } } } var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow); var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo); File.WriteAllText(BackupInfoPath, json); Log.Information("Backup created at {BackupPath}", BackupZipPath); } /// /// Returns true if a valid backup exists (both ZIP and metadata file present). /// public static bool BackupExists() => File.Exists(BackupZipPath) && File.Exists(BackupInfoPath); /// /// Reads backup metadata. Returns null if no backup exists or metadata is unreadable. /// public static BackupInfo? GetBackupInfo() { if (!File.Exists(BackupInfoPath)) return null; try { var json = File.ReadAllText(BackupInfoPath); return JsonSerializer.Deserialize(json, BackupJsonContext.Default.BackupInfo); } catch (Exception ex) { Log.Warning(ex, "Failed to read backup metadata"); return null; } } /// /// Restores the backup ZIP to the app directory, then restarts the process. /// This method does not return — it calls Environment.Exit(0). /// public static void RestoreBackup() { var info = GetBackupInfo() ?? throw new InvalidOperationException("No backup metadata found"); var appDir = info.AppDirectory; Log.Information("Restoring backup v{Version} to {AppDir}", info.Version, appDir); // On Windows, rename the running executable so extraction can overwrite it if (OperatingSystem.IsWindows()) { var currentExe = Environment.ProcessPath; if (!string.IsNullOrEmpty(currentExe) && File.Exists(currentExe)) { var oldExe = currentExe + ".old"; if (File.Exists(oldExe)) File.Delete(oldExe); File.Move(currentExe, oldExe); } } ZipFile.ExtractToDirectory(BackupZipPath, appDir, overwriteFiles: true); // Restore execute permission on Unix if (!OperatingSystem.IsWindows()) { var exePath = Environment.ProcessPath ?? Path.Combine(appDir, "EchoHub.Client"); if (File.Exists(exePath)) { var mode = File.GetUnixFileMode(exePath); File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute); } } // Start the restored version and exit var processPath = Environment.ProcessPath ?? Path.Combine(appDir, "EchoHub.Client"); Log.Information("Launching restored version v{Version}: {Path}", info.Version, processPath); Process.Start(new ProcessStartInfo(processPath) { UseShellExecute = false }); Environment.Exit(0); } /// /// Deletes the backup directory and all contents. /// public static void DeleteBackup() { if (!Directory.Exists(BackupDir)) return; try { Directory.Delete(BackupDir, true); Log.Information("Update backup deleted"); } catch (Exception ex) { Log.Warning(ex, "Failed to delete update backup"); } } } public record BackupInfo( string Version, string AppDirectory, DateTimeOffset CreatedAt); [System.Text.Json.Serialization.JsonSerializable(typeof(BackupInfo))] internal partial class BackupJsonContext : System.Text.Json.Serialization.JsonSerializerContext;