feat: implement rollback functionality with pre-update backup and recovery options | Rebase

This commit is contained in:
HueByte
2026-02-23 18:15:16 +01:00
parent 760693befe
commit 38df05df41
7 changed files with 359 additions and 13 deletions
+13 -1
View File
@@ -71,6 +71,10 @@ jobs:
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' 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 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 - name: Publish Client win-x64
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' 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 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' 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 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 - name: Zip artifacts
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false'
run: | run: |
@@ -95,10 +103,12 @@ jobs:
zip -r ../EchoHub-Server-linux-x64.zip server-linux-x64/ 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-x64.zip server-osx-x64/
zip -r ../EchoHub-Server-osx-arm64.zip server-osx-arm64/ 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-win-x64.zip client-win-x64/
zip -r ../EchoHub-Client-linux-x64.zip client-linux-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-x64.zip client-osx-x64/
zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/ zip -r ../EchoHub-Client-osx-arm64.zip client-osx-arm64/
zip -r ../EchoHub-Client-linux-arm64.zip client-linux-arm64/
- name: Build release notes - name: Build release notes
if: steps.changes.outputs.src_changed == 'true' && steps.check_release.outputs.exists == 'false' 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-linux-x64.zip \
EchoHub-Server-osx-x64.zip \ EchoHub-Server-osx-x64.zip \
EchoHub-Server-osx-arm64.zip \ EchoHub-Server-osx-arm64.zip \
EchoHub-Server-linux-arm64.zip \
EchoHub-Client-win-x64.zip \ EchoHub-Client-win-x64.zip \
EchoHub-Client-linux-x64.zip \ EchoHub-Client-linux-x64.zip \
EchoHub-Client-osx-x64.zip \ EchoHub-Client-osx-x64.zip \
EchoHub-Client-osx-arm64.zip EchoHub-Client-osx-arm64.zip \
EchoHub-Client-linux-arm64.zip
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -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 - 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) - 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 ## Refactoring
@@ -21,3 +24,4 @@
## CI ## CI
- Add Docker workflow — builds and pushes multi-arch (`amd64`/`arm64`) server image to GHCR on release - 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)
+27
View File
@@ -86,6 +86,7 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested; _mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested; _mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested; _mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -907,6 +908,32 @@ public sealed class AppOrchestrator : IDisposable
RunAsync(_updateService.CheckNowAsync, "Failed to check for updates"); 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 Helpers ────────────────────────────────────────────────────
private void FetchAndUpdateOnlineUsers() private void FetchAndUpdateOnlineUsers()
+79 -1
View File
@@ -1,12 +1,58 @@
using EchoHub.Client; using EchoHub.Client;
using EchoHub.Client.Config; using EchoHub.Client.Config;
using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Terminal.Gui.App; 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"); var appSettingsPath = Path.Combine(AppContext.BaseDirectory, "appsettings.json");
if (!File.Exists(appSettingsPath)) if (!File.Exists(appSettingsPath))
{ {
@@ -31,6 +77,38 @@ Log.Logger = new LoggerConfiguration()
Log.Information("EchoHub client starting"); 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 try
{ {
var config = ConfigManager.Load(); var config = ConfigManager.Load();
@@ -0,0 +1,153 @@
using System.Diagnostics;
using System.IO.Compression;
using System.Text.Json;
using Serilog;
namespace EchoHub.Client.Services;
/// <summary>
/// Manages pre-update backups and rollback restoration for the auto-updater.
/// Backup location: ~/.echohub/update-backup/
/// </summary>
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");
/// <summary>
/// True if a backup exists from a recent update (set at startup).
/// </summary>
public static bool IsPostUpdate { get; set; }
/// <summary>
/// Creates a ZIP backup of the current app directory before an update.
/// Deletes any previous backup first. Uses fastest compression for speed.
/// </summary>
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);
}
/// <summary>
/// Returns true if a valid backup exists (both ZIP and metadata file present).
/// </summary>
public static bool BackupExists()
=> File.Exists(BackupZipPath) && File.Exists(BackupInfoPath);
/// <summary>
/// Reads backup metadata. Returns null if no backup exists or metadata is unreadable.
/// </summary>
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;
}
}
/// <summary>
/// Restores the backup ZIP to the app directory, then restarts the process.
/// This method does not return — it calls Environment.Exit(0).
/// </summary>
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);
}
/// <summary>
/// Deletes the backup directory and all contents.
/// </summary>
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;
+65 -6
View File
@@ -58,23 +58,52 @@ public sealed class UpdateChecker : IDisposable
{ {
Log.Information("Update available: v{Version}", version); Log.Information("Update available: v{Version}", version);
var confirmed = false;
_app.Invoke(() => _app.Invoke(() =>
{ {
confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version); var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version);
if (confirmed) if (confirmed)
{ {
_progressDialog = new UpdateProgressDialog(_app, version); _progressDialog = new UpdateProgressDialog(_app, version);
// Start the update; progress is reported via OnProgressChanged
_ = Task.Run(async () => _ = 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(); await _updater.UpdateAsync();
}); });
_progressDialog?.Show(); _progressDialog.Show();
} }
}); });
} }
@@ -111,11 +140,41 @@ public sealed class UpdateChecker : IDisposable
private void OnException(Exception exception) private void OnException(Exception exception)
{ {
Log.Error(exception, "Update check failed"); Log.Error(exception, "Update failed");
_app.Invoke(() => _app.Invoke(() =>
{ {
_progressDialog?.Close(); _progressDialog?.Close();
_progressDialog = null; _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");
}
}); });
} }
+18 -5
View File
@@ -1,3 +1,4 @@
using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using EchoHub.Client.UI.Chat; using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Helpers; using EchoHub.Client.UI.Helpers;
@@ -124,6 +125,11 @@ public sealed class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnDeleteChannelRequested; public event Action? OnDeleteChannelRequested;
/// <summary>
/// Fired when the user requests to rollback to the previous version.
/// </summary>
public event Action? OnRollbackRequested;
/// <summary> /// <summary>
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
/// </summary> /// </summary>
@@ -323,13 +329,20 @@ public sealed class MainWindow : Runnable
}; };
allUserItems.AddRange(themeItems); allUserItems.AddRange(themeItems);
var fileItems = new List<View>();
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( var menuBar = new MenuBar(
[ [
new MenuBarItem("_File", new MenuBarItem("_File", fileItems),
[
new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty),
new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty)
]),
new MenuBarItem("_Server", new View[] new MenuBarItem("_Server", new View[]
{ {
new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty),