mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 23:34:10 +02:00
feat: implement rollback functionality with pre-update backup and recovery options | Rebase
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
/// </summary>
|
||||
public event Action? OnDeleteChannelRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user requests to rollback to the previous version.
|
||||
/// </summary>
|
||||
public event Action? OnRollbackRequested;
|
||||
|
||||
/// <summary>
|
||||
/// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName.
|
||||
/// </summary>
|
||||
@@ -323,13 +329,20 @@ public sealed class MainWindow : Runnable
|
||||
};
|
||||
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(
|
||||
[
|
||||
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),
|
||||
|
||||
Reference in New Issue
Block a user