diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 77b4c0d..9d2ff70 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -40,6 +40,12 @@ public sealed class AppOrchestrator : IDisposable public MainWindow MainWindow => _mainWindow; + /// + /// Set when the user confirms an update. The host must run this after the Terminal.Gui main + /// loop exits (console restored), so the updater's in-place restart doesn't fight the TUI. + /// + public Func? PendingUpdate => _updateService.PendingUpdate; + public AppOrchestrator(IApplication app, ClientConfig config) { _app = app; diff --git a/src/EchoHub.Client/Program.cs b/src/EchoHub.Client/Program.cs index f573467..00a0466 100644 --- a/src/EchoHub.Client/Program.cs +++ b/src/EchoHub.Client/Program.cs @@ -126,10 +126,24 @@ try var theme = ThemeManager.GetTheme(config.ActiveTheme); ThemeManager.ApplyTheme(theme); - using var orchestrator = new AppOrchestrator(app, config); + var orchestrator = new AppOrchestrator(app, config); app.Run(orchestrator.MainWindow); + + // Capture any confirmed update before tearing anything down, then restore the console. + var pendingUpdate = orchestrator.PendingUpdate; app.Dispose(); + + // Apply the update on a clean console: the TUI has released it, so the updater can extract + // and restart the process without deadlocking against the alternate-screen buffer. This call + // ends by starting the new version and calling Environment.Exit, so it does not return. + if (pendingUpdate is not null) + { + Log.Information("Applying confirmed update after shutdown"); + await pendingUpdate(); + } + + orchestrator.Dispose(); } catch (Exception ex) { diff --git a/src/EchoHub.Client/Services/UpdateChecker.cs b/src/EchoHub.Client/Services/UpdateChecker.cs index d8e43bc..079617d 100644 --- a/src/EchoHub.Client/Services/UpdateChecker.cs +++ b/src/EchoHub.Client/Services/UpdateChecker.cs @@ -15,9 +15,17 @@ public sealed class UpdateChecker : IDisposable private readonly Updater _updater; private readonly IApplication _app; - private UpdateProgressDialog? _progressDialog; + private string? _pendingVersion; + private bool _applying; + private UpdateStep _lastStep = (UpdateStep)(-1); private bool _manualCheck; + /// + /// Set when the user confirms an update. The host runs this after the Terminal.Gui + /// main loop has exited and the console is restored, so the library's in-place restart doesn't + /// deadlock against a TUI that still owns the console. + /// + public Func? PendingUpdate { get; private set; } public static string CurrentVersion => typeof(UpdateChecker).Assembly.GetName().Version?.ToString(3) ?? "0.0.0"; @@ -40,7 +48,6 @@ public sealed class UpdateChecker : IDisposable #endif } - public async Task CheckNowAsync() { _manualCheck = true; @@ -54,65 +61,66 @@ public sealed class UpdateChecker : IDisposable } } - private async void OnUpdateAvailable(string version, string changelogUrl) + private void OnUpdateAvailable(string version, string changelogUrl) { Log.Information("Update available: v{Version}", version); _app.Invoke(() => { var confirmed = UpdateConfirmDialog.Show(_app, CurrentVersion, version); + if (!confirmed) + return; - if (confirmed) - { - _progressDialog = new UpdateProgressDialog(_app, version); - - _ = Task.Run(async () => - { - // Create backup before the update starts - try - { - _progressDialog?.UpdateProgress(0f, "Creating backup..."); - UpdateBackupService.CreateBackup(); - } - catch (Exception ex) - { - Log.Error(ex, "Failed to create pre-update backup"); - - var proceed = false; - proceed = MessageBox.Query( - _app, - "Backup Warning", - $"Could not create backup: {ex.Message}\n\nContinue update without backup?", - "Continue", "Cancel") == 0; - - if (!proceed) - { - _progressDialog?.Close(); - _progressDialog = null; - return; - } - } - - _progressDialog?.UpdateProgress(0f, "Downloading update..."); - await _updater.UpdateAsync(); - }); - - _progressDialog.Show(); - } + // Defer the actual download/extract/restart to after the TUI is torn down. + // Running it under the live main loop lets the library restart the process while + // this one still holds the console in raw/alternate-screen mode — the two processes + // then deadlock over the console (the "stuck at N/N extracting" hang). + _pendingVersion = version; + PendingUpdate = ApplyUpdateAsync; + _app.RequestStop(); }); } + /// + /// Runs the update on a plain console (invoked by the host after the main loop exits). + /// Ends by restarting the app and exiting the process, or restoring the backup on failure. + /// + private async Task ApplyUpdateAsync() + { + _applying = true; + Console.WriteLine(); + Console.WriteLine($"Updating EchoHub to v{_pendingVersion}..."); + + try + { + Console.WriteLine("Creating backup..."); + UpdateBackupService.CreateBackup(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to create pre-update backup"); + Console.WriteLine($"Warning: could not create a backup ({ex.Message}). Continuing without one."); + } + + Console.WriteLine("Downloading update..."); + await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0) + } + private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage) { - var fraction = progressPercentage.HasValue ? (float)(progressPercentage.Value / 100.0) : 0f; - var statusText = $"{step}: {itemsProcessed}/{totalItems ?? 0} ({progressPercentage ?? 0:F0}%)"; + // Before the TUI is torn down (i.e. during a check) there is no progress surface; the + // real work happens headless after shutdown, so report it on the console. + if (!_applying) + return; - if (!progressPercentage.HasValue) + if (step != _lastStep) { - statusText = $"{step}..."; + Console.WriteLine(); + _lastStep = step; } - _progressDialog?.UpdateProgress(fraction, statusText); + var pct = progressPercentage ?? 0; + Console.Write($"\r {step}: {itemsProcessed}/{totalItems ?? 0} ({pct:F0}%) "); } private void OnUpdateStarted(string version) @@ -135,40 +143,40 @@ public sealed class UpdateChecker : IDisposable private void OnException(Exception exception) { Log.Error(exception, "Update failed"); - _app.Invoke(() => + + // Headless failure (post-shutdown): report and offer rollback on the console. + if (_applying) { - _progressDialog?.Close(); - _progressDialog = null; + Console.Error.WriteLine(); + Console.Error.WriteLine($"Update failed: {exception.Message}"); 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) + Console.WriteLine("Restoring the previous version..."); + try { - 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"); - } + UpdateBackupService.RestoreBackup(); // calls Environment.Exit(0) + } + catch (Exception restoreEx) + { + Log.Error(restoreEx, "Backup restoration failed"); + Console.Error.WriteLine($"Restore failed: {restoreEx.Message}. Re-download EchoHub to recover."); + Environment.Exit(1); } } else { - MessageBox.ErrorQuery(_app, "Update Failed", - $"The update failed: {exception.Message}\n\nYou may need to re-download the application.", "OK"); + Console.Error.WriteLine("No backup available. Re-download EchoHub if it no longer starts."); + Environment.Exit(1); } + return; + } + + // Failure during a check while the TUI is still running. + _app.Invoke(() => + { + MessageBox.ErrorQuery(_app, "Update Check Failed", + $"Could not check for updates: {exception.Message}", "OK"); }); } @@ -179,5 +187,6 @@ public sealed class UpdateChecker : IDisposable _updater.UpdateStarted -= OnUpdateStarted; _updater.NoUpdateAvailable -= OnNoUpdateAvailable; _updater.OnException -= OnException; + _updater.Dispose(); } }