From 38df05df416d37a0a127cd09ed242faa4c35f685 Mon Sep 17 00:00:00 2001 From: HueByte Date: Mon, 23 Feb 2026 18:12:07 +0100 Subject: [PATCH] feat: implement rollback functionality with pre-update backup and recovery options | Rebase --- .github/workflows/release.yml | 14 +- docs/changelog/v0.2.8.md | 4 + src/EchoHub.Client/AppOrchestrator.cs | 27 ++++ src/EchoHub.Client/Program.cs | 80 ++++++++- .../Services/UpdateBackupService.cs | 153 ++++++++++++++++++ src/EchoHub.Client/Services/UpdateChecker.cs | 71 +++++++- src/EchoHub.Client/UI/MainWindow.cs | 23 ++- 7 files changed, 359 insertions(+), 13 deletions(-) create mode 100644 src/EchoHub.Client/Services/UpdateBackupService.cs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b5e8ab9..834855c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -71,6 +71,10 @@ jobs: if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r osx-arm64 --self-contained true -o publish/server-osx-arm64 + - name: Publish Server linux-arm64 + if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Server/EchoHub.Server.csproj -c Release -r linux-arm64 --self-contained true -o publish/server-linux-arm64 + - name: Publish Client win-x64 if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r win-x64 --self-contained true -o publish/client-win-x64 @@ -87,6 +91,10 @@ jobs: if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r osx-arm64 --self-contained true -o publish/client-osx-arm64 + - name: Publish Client linux-arm64 + if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' + run: dotnet publish src/EchoHub.Client/EchoHub.Client.csproj -c Release -r linux-arm64 --self-contained true -o publish/client-linux-arm64 + - name: Zip artifacts if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' run: | @@ -95,10 +103,12 @@ jobs: zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ zip -r ../EchoHub-Server-osx-x64.zip server-osx-x64/ zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/ + zip -r ../EchoHub-Server-linux-arm64.zip server-linux-arm64/ zip -r ../EchoHub-Client-win-x64.zip client-win-x64/ zip -r ../EchoHub-Client-linux-x64.zip client-linux-x64/ zip -r ../EchoHub-Client-osx-x64.zip client-osx-x64/ zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/ + zip -r ../EchoHub-Client-linux-arm64.zip client-linux-arm64/ - name: Build release notes if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' @@ -139,9 +149,11 @@ jobs: EchoHub-Server-linux-x64.zip \ EchoHub-Server-osx-x64.zip \ EchoHub-Server-osx-arm64.zip \ + EchoHub-Server-linux-arm64.zip \ EchoHub-Client-win-x64.zip \ EchoHub-Client-linux-x64.zip \ EchoHub-Client-osx-x64.zip \ - EchoHub-Client-osx-arm64.zip + EchoHub-Client-osx-arm64.zip \ + EchoHub-Client-linux-arm64.zip env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/docs/changelog/v0.2.8.md b/docs/changelog/v0.2.8.md index d147319..0f7d6ea 100644 --- a/docs/changelog/v0.2.8.md +++ b/docs/changelog/v0.2.8.md @@ -12,6 +12,9 @@ - Add Docker support for EchoHub.Server — `docker compose up -d` for easy self-hosting with persistent volume for database, uploads, and logs - IRC account creation — connecting with a new username auto-registers the account (PASS and SASL PLAIN) +- Auto-updater rollback — pre-update backup created automatically before each update; restore via File > Rollback menu or `--rollback` CLI flag +- Update failure recovery — if an update fails mid-extraction, offers to restore from the backup immediately +- Defensive Unix permission check — verify execute permission on startup after auto-update (defense-in-depth) ## Refactoring @@ -21,3 +24,4 @@ ## CI - Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release +- Add `linux-arm64` builds to release pipeline — server and client binaries for ARM Linux (Raspberry Pi, cloud ARM instances) diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index b8c84a7..8bd4957 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -86,6 +86,7 @@ public sealed class AppOrchestrator : IDisposable _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; + _mainWindow.OnRollbackRequested += HandleRollbackRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -907,6 +908,32 @@ public sealed class AppOrchestrator : IDisposable RunAsync(_updateService.CheckNowAsync, "Failed to check for updates"); } + private void HandleRollbackRequested() + { + if (!UpdateBackupService.BackupExists()) + { + MessageBox.ErrorQuery(_app, "No Backup", "No backup is available to restore.", "OK"); + return; + } + + var info = UpdateBackupService.GetBackupInfo(); + var confirm = MessageBox.Query(_app, "Rollback Update", + $"Restore to version {info?.Version ?? "unknown"}?\n\nThe app will restart.", "Restore", "Cancel"); + + if (confirm != 0) return; + + try + { + UpdateBackupService.RestoreBackup(); + // RestoreBackup calls Environment.Exit(0) + } + catch (Exception ex) + { + Log.Error(ex, "Rollback failed"); + MessageBox.ErrorQuery(_app, "Rollback Failed", $"Could not restore: {ex.Message}", "OK"); + } + } + // ── Private Helpers ──────────────────────────────────────────────────── private void FetchAndUpdateOnlineUsers() diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index 2955339..1394ab2 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -1,12 +1,58 @@ using EchoHub.Client; using EchoHub.Client.Config; +using EchoHub.Client.Services; using EchoHub.Client.Themes; using Microsoft.Extensions.Configuration; using Serilog; using Terminal.Gui.App; -using Terminal.Gui.Drawing; +// == CLI rollback: works without TUI, before anything else ================ +if (args.Contains("--rollback")) +{ + if (UpdateBackupService.BackupExists()) + { + var info = UpdateBackupService.GetBackupInfo(); + Console.WriteLine($"Rolling back to version {info?.Version ?? "unknown"}..."); + try + { + UpdateBackupService.RestoreBackup(); + // RestoreBackup calls Environment.Exit(0) + } + catch (Exception ex) + { + Console.Error.WriteLine($"Rollback failed: {ex.Message}"); + Environment.Exit(1); + } + } + else + { + Console.Error.WriteLine("No backup available to restore."); + Environment.Exit(1); + } +} +// == Unix permission self-check (defense-in-depth after auto-update) == +if (!OperatingSystem.IsWindows()) +{ + var exePath = Environment.ProcessPath; + if (!string.IsNullOrEmpty(exePath)) + { + try + { + var mode = File.GetUnixFileMode(exePath); + if ((mode & UnixFileMode.UserExecute) == 0) + { + File.SetUnixFileMode(exePath, mode | UnixFileMode.UserExecute); + } + } + catch + { + // Best-effort; if we're running, we already have execute permission + } + } +} + +// == Normal startup == var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json"); if (!File.Exists(appSettingsPath)) { @@ -31,6 +77,38 @@ Log.Logger = new LoggerConfiguration() Log.Information("EchoHub client starting"); +// == Post-update detection: stale backup cleanup or flag for rollback menu ================ +if (UpdateBackupService.BackupExists()) +{ + var backupInfo = UpdateBackupService.GetBackupInfo(); + if (backupInfo is not null && DateTimeOffset.UtcNow - backupInfo.CreatedAt > TimeSpan.FromDays(7)) + { + Log.Information("Deleting stale update backup from {Date}", backupInfo.CreatedAt); + UpdateBackupService.DeleteBackup(); + } + else + { + Log.Information("Post-update: backup of v{OldVersion} available for rollback", + backupInfo?.Version ?? "unknown"); + UpdateBackupService.IsPostUpdate = true; + } +} + +// == Windows: clean up .old executable left by rollback restore =========== +if (OperatingSystem.IsWindows()) +{ + var currentExe = Environment.ProcessPath; + if (!string.IsNullOrEmpty(currentExe)) + { + var oldExe = currentExe + ".old"; + if (File.Exists(oldExe)) + { + try { File.Delete(oldExe); } + catch { /* locked or permission issue — will be cleaned next launch */ } + } + } +} + try { var config = ConfigManager.Load(); diff --git a/src/EchoHub.Client/Services/UpdateBackupService.cs b/src/EchoHub.Client/Services/UpdateBackupService.cs new file mode 100644 index 0000000..fb1e5d6 --- /dev/null +++ b/src/EchoHub.Client/Services/UpdateBackupService.cs @@ -0,0 +1,153 @@ +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); + + ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false); + + 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; diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index 6f64bbb..e136864 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -58,23 +58,52 @@ public sealed class UpdateChecker : IDisposable { Log.Information("Update available: v{Version}", version); - var confirmed = false; _app.Invoke(() => { - confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version); - + var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version); if (confirmed) { _progressDialog = new UpdateProgressDialog(_app, version); - // Start the update; progress is reported via OnProgressChanged _ = Task.Run(async () => { + // Create backup before the update starts + try + { + _app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup...")); + UpdateBackupService.CreateBackup(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to create pre-update backup"); + + var proceed = false; + _app.Invoke(() => + { + proceed = MessageBox.Query( + _app, + "Backup Warning", + $"Could not create backup: {ex.Message}\n\nContinue update without backup?", + "Continue", "Cancel") == 0; + }); + + if (!proceed) + { + _app.Invoke(() => + { + _progressDialog?.Close(); + _progressDialog = null; + }); + return; + } + } + + _app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update...")); await _updater.UpdateAsync(); }); - _progressDialog?.Show(); + _progressDialog.Show(); } }); } @@ -111,11 +140,41 @@ public sealed class UpdateChecker : IDisposable private void OnException(Exception exception) { - Log.Error(exception, "Update check failed"); + Log.Error(exception, "Update failed"); _app.Invoke(() => { _progressDialog?.Close(); _progressDialog = null; + + if (UpdateBackupService.BackupExists()) + { + var restore = MessageBox.Query( + _app, + "Update Failed", + $"The update failed: {exception.Message}\n\n" + + "A backup of the previous version is available.\nRestore now? (The app will restart.)", + "Restore", "Cancel"); + + if (restore == 0) + { + try + { + UpdateBackupService.RestoreBackup(); + // RestoreBackup calls Environment.Exit(0) + } + catch (Exception restoreEx) + { + Log.Error(restoreEx, "Backup restoration failed"); + MessageBox.ErrorQuery(_app, "Restore Failed", + $"Could not restore backup: {restoreEx.Message}\n\nYou may need to re-download the application.", "OK"); + } + } + } + else + { + MessageBox.ErrorQuery(_app, "Update Failed", + $"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK"); + } }); } diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index de60da9..8f0929c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1,3 +1,4 @@ +using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI.Chat; using EchoHub.Client.UI.Helpers; @@ -124,6 +125,11 @@ public sealed class MainWindow : Runnable /// public event Action? OnDeleteChannelRequested; + /// + /// Fired when the user requests to rollback to the previous version. + /// + public event Action? OnRollbackRequested; + /// /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. /// @@ -323,13 +329,20 @@ public sealed class MainWindow : Runnable }; allUserItems.AddRange(themeItems); + var fileItems = new List(); + if (UpdateBackupService.BackupExists()) + { + var info = UpdateBackupService.GetBackupInfo(); + var label = info is not null ? $"_Rollback to v{info.Version}..." : "_Rollback Update..."; + fileItems.Add(new MenuItem(label, "Restore previous version", () => OnRollbackRequested?.Invoke(), Key.Empty)); + fileItems.Add(new Line()); + } + fileItems.Add(new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty)); + fileItems.Add(new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)); + var menuBar = new MenuBar( [ - new MenuBarItem("_File", - [ - new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty), - new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty) - ]), + new MenuBarItem("_File", fileItems), new MenuBarItem("_Server", new View[] { new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),