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),