From 3117d669dbaab28b77a39e67254c5da4750b6a19 Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Sat, 30 May 2026 14:44:11 +0200 Subject: [PATCH 1/2] refactor: extract UpdateCoordinator from App.xaml.cs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves update check, download, install, and dialog logic from App.xaml.cs into a dedicated UpdateCoordinator service. App retains a single _updateCoordinator field and delegates all four call sites (startup, CLI arg, IAppCommands, deep link). XamlRoot is resolved via Func (hub-first, keepalive fallback) so the coordinator has no direct window reference. Fixes a latent bug where UpdateCommandCenterInfo.Detail was mutated in-place on the existing object instead of being replaced — AppState.UpdateInfo only fires PropertyChanged on assignment, so the in-place mutation was silently dropped by the UI. Addresses part of #554. Co-Authored-By: Claude Sonnet 4.6 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 485 +----------------- .../Services/UpdateCoordinator.cs | 465 +++++++++++++++++ 2 files changed, 484 insertions(+), 466 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 51b1d1970..2374396ba 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -133,6 +133,7 @@ public IntPtr GetHubWindowHandle() private Microsoft.UI.Dispatching.DispatcherQueue? _dispatcherQueue; private AppState? _appState; internal AppState? AppState => _appState; + private UpdateCoordinator? _updateCoordinator; private GatewayService? _gatewayService; private CancellationTokenSource? _deepLinkCts; private bool _isExiting; @@ -440,7 +441,20 @@ _dispatcherQueue is null // Central observable model + gateway event handler. _appState = new AppState(_dispatcherQueue); - _appState.UpdateInfo = BuildInitialUpdateInfo(); + _updateCoordinator = new UpdateCoordinator( + AppUpdater, + _appState, + _settings, + () => + { + XamlRoot? r = null; + if (_hubWindow != null && !_hubWindow.IsClosed) + r = (_hubWindow.Content as FrameworkElement)?.XamlRoot; + return r ?? (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot; + }, + refreshStatus: UpdateStatusDetailWindow, + exit: Exit); + _appState.UpdateInfo = UpdateCoordinator.BuildInitialInfo(); _gatewayService = new GatewayService(_appState, _dispatcherQueue!); _gatewayService.ConnectionStatusChanged += OnGatewayConnectionStatusChanged; _gatewayService.AuthenticationFailed += OnGatewayAuthenticationFailed; @@ -465,7 +479,7 @@ _dispatcherQueue is null if (DataDirOverride is null && Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") != "1") { - var shouldLaunch = await CheckForUpdatesAsync(); + var shouldLaunch = await _updateCoordinator.CheckForUpdatesAsync(); if (!shouldLaunch) { Exit(); @@ -906,7 +920,7 @@ private void OnTrayMenuItemClicked(object? sender, string action) case "history": ShowHub("channels"); break; case "activity": ShowHub("channels"); break; case "healthcheck": _ = RunHealthCheckAsync(userInitiated: true); break; - case "checkupdates": _ = CheckForUpdatesUserInitiatedAsync(); break; + case "checkupdates": _ = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync(); break; case "settings": ShowSettings(); break; case "setup": _ = ShowOnboardingAsync(); break; case "autostart": ToggleAutoStart(); break; @@ -3143,7 +3157,7 @@ void IAppCommands.Disconnect() } void IAppCommands.ShowVoiceOverlay() => ShowHub("voice"); void IAppCommands.ShowChat() => ShowChatWindow(); - void IAppCommands.CheckForUpdates() => _ = CheckForUpdatesUserInitiatedAsync(); + void IAppCommands.CheckForUpdates() => _ = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync(); void IAppCommands.ShowOnboarding() => _ = ShowOnboardingAsync(); void IAppCommands.ShowConnectionStatus() => ShowConnectionStatusWindow(); void IAppCommands.NotifySettingsSaved() => OnSettingsSaved(this, EventArgs.Empty); @@ -3281,467 +3295,6 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) #endregion - #region Updates - - private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new() - { - Status = "Not checked", - CurrentVersion = AppVersionInfo.Version - }; - - // Cross-path concurrency for update checks, split into two phases: - // - _updateCheckGate: held only during the metadata/network check. - // Short timeout so contended callers don't block on user thinking. - // - _updateInstallInProgress: Interlocked flag covering the user-facing - // UpdateDialog + download + install. Prevents two parallel installs - // without holding a lock across user interaction. - private readonly System.Threading.SemaphoreSlim _updateCheckGate = new(1, 1); -#if !DEBUG - private int _updateInstallInProgress; -#endif - - private async Task CheckForUpdatesAsync(bool userInitiated = false) - { - // === Stage 1: metadata check (gate-protected) === - if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) - { - Logger.Warn("Update check gate timed out: another check is in progress"); - if (_appState != null) - { - _appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "another update check is already in progress; try again in a moment" - }; - } - return true; // Don't block launch - } - -#if DEBUG - try - { - Logger.Info("Skipping update check in debug build"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Skipped", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "debug build" - }; - return true; - } - finally - { - _updateCheckGate.Release(); - } -#else - string releaseTag; - string changelog; - try - { - Logger.Info("Checking for updates..."); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Checking", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow - }; - var updateFound = await AppUpdater.CheckForUpdatesAsync(); - - if (!updateFound) - { - Logger.Info("No updates available"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Current", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "no updates available" - }; - return true; - } - - var release = AppUpdater.LatestRelease!; - if (string.IsNullOrEmpty(release.TagName)) - { - // Defensive: AppUpdater says an update is available but the - // release has no tag. Don't silently claim "up to date" — - // surface as Failed so the user sees something is off. - Logger.Warn("Update reported available but release has no TagName"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "update metadata incomplete (missing version tag)" - }; - return true; - } - - releaseTag = release.TagName; - changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; - Logger.Info($"Update available: {releaseTag}"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "prompted" - }; - - if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && - string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) && - !userInitiated) - { - Logger.Info($"Skipping update prompt for remembered version {releaseTag}"); - _appState!.UpdateInfo.Detail = "skipped by user"; - return true; - } - } - catch (OperationCanceledException) - { - Logger.Info("Update check cancelled"); - if (_appState != null) - { - // Avoid leaving Status="Checking" stale for the manual flow - // or the command-center UI. Surface as Failed with a clear - // "cancelled" detail. - _appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "update check cancelled" - }; - } - return true; - } - catch (Exception ex) - { - Logger.Warn($"Update check failed: {ex.Message}"); - if (_appState != null) - { - _appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; - } - return true; - } - finally - { - // Release the gate BEFORE user interaction & download/install. - // Holding it across these long phases would silently time-out - // any concurrent manual click. - _updateCheckGate.Release(); - } - - // === Stage 2: user-interactive prompt + download/install === - // Gate is released. Use Interlocked flag so concurrent callers can't - // start a second parallel install while we're prompting/downloading. - if (System.Threading.Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0) - { - Logger.Info("Update prompt/install already in progress; skipping"); - if (_appState != null) - { - _appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "an update is already being downloaded or installed" - }; - } - return true; - } - - try - { - var dialog = new UpdateDialog(releaseTag, changelog); - UpdateDialogResult result; - try - { - result = await dialog.ShowAsync(); - } - catch (System.Runtime.InteropServices.COMException ex) - { - // Visual tree torn down mid-await (e.g. window closed). - // Treat as "remind me later" rather than tainting Status with - // "Failed" — the network check itself succeeded. - Logger.Warn($"[Update] Prompt dialog dismissed before completion: 0x{ex.HResult:X8}"); - return true; - } - catch (InvalidOperationException ex) - { - // Another ContentDialog is already open on this XamlRoot. - Logger.Warn($"[Update] Prompt dialog could not be shown: {ex.Message}"); - return true; - } - - if (result == UpdateDialogResult.Download) - { - // Assign a fresh object rather than mutating .Detail in place: - // a concurrent loser of the install-flag CAS may have just - // overwritten _appState.UpdateInfo with a "Failed" object, - // and mutating its Detail would leave Status="Failed" with - // our "download requested" detail — briefly inconsistent. - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "download requested" - }; - if (_settings != null) - { - _settings.SkippedUpdateTag = string.Empty; - _settings.Save(); - } - var installed = await DownloadAndInstallUpdateAsync(); - if (!installed) - { - // Surface the failure so callers (and the manual-check - // dialog) don't show stale "download requested" state. - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = "download or install failed" - }; - } - return !installed; // Don't launch if update succeeded - } - - if (result == UpdateDialogResult.Skip && _settings != null) - { - _settings.SkippedUpdateTag = releaseTag; - _settings.Save(); - _appState!.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Available", - CurrentVersion = AppVersionInfo.Version, - LatestVersion = releaseTag, - CheckedAt = DateTime.UtcNow, - Detail = "skipped by user" - }; - } - else if (userInitiated && _settings != null - && string.Equals(_settings.SkippedUpdateTag, releaseTag, - StringComparison.OrdinalIgnoreCase)) - { - // User explicitly bypassed the remembered skip for THIS - // release and picked RemindLater — clear the stale tag. - _settings.SkippedUpdateTag = string.Empty; - _settings.Save(); - } - - return true; // RemindLater or Skip - continue launch - } - catch (Exception ex) - { - Logger.Warn($"Update prompt/install failed: {ex.Message}"); - if (_appState != null) - { - _appState.UpdateInfo = new UpdateCommandCenterInfo - { - Status = "Failed", - CurrentVersion = AppVersionInfo.Version, - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; - } - return true; - } - finally - { - System.Threading.Interlocked.Exchange(ref _updateInstallInProgress, 0); - } -#endif - } - - // Re-entrancy guard: the button/menu/deep-link are all fire-and-forget - // (`_ = CheckForUpdatesUserInitiatedAsync()`), so a double-click would - // otherwise open two ContentDialogs on the same XamlRoot which throws - // COMException. One in-flight manual check at a time is enough. - private int _manualUpdateCheckInFlight; - - private async Task CheckForUpdatesUserInitiatedAsync() - { - if (System.Threading.Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0) - { - Logger.Info("Manual update check ignored: another check is already in progress"); - return; - } - - try - { - Logger.Info("Manual update check requested"); - // Pass userInitiated=true so an explicit click bypasses the - // "remind me later" SkippedUpdateTag — the user is asking *now*. - var shouldContinue = await CheckForUpdatesAsync(userInitiated: true); - UpdateStatusDetailWindow(); - - // The "Available" path already prompts via UpdateDialog. For the - // other terminal states a manual click would otherwise produce no - // UI at all, leaving users wondering whether the click registered. - // Surface each explicitly with a small OK dialog. - var info = _appState?.UpdateInfo; - if (info != null) - { - switch (info.Status) - { - case "Current": - await ShowUpdateInfoDialogAsync( - "UpToDate", - LocalizationHelper.GetString("Update_Title_UpToDate"), - LocalizationHelper.Format("Update_Message_UpToDate", info.CurrentVersion)); - break; - case "Failed": - // Format string ends with "\n\n{0}"; an empty Detail - // would leave a dangling blank line. Trim only the - // newline characters we added, never arbitrary - // whitespace from the localized string. - var failedMessage = LocalizationHelper - .Format("Update_Message_Failed", info.Detail ?? "") - .TrimEnd('\r', '\n'); - await ShowUpdateInfoDialogAsync( - "Failed", - LocalizationHelper.GetString("Update_Title_Failed"), - failedMessage); - break; -#if DEBUG - // Status="Skipped" is only produced by the DEBUG short-circuit - // in CheckForUpdatesAsync. User-skipped versions keep - // Status="Available", so this case must not exist in RELEASE - // or it would surface a confusing "disabled in debug builds" - // dialog to end users. - case "Skipped": - await ShowUpdateInfoDialogAsync( - "Skipped", - LocalizationHelper.GetString("Update_Title_Skipped"), - LocalizationHelper.GetString("Update_Message_Skipped_Debug")); - break; -#endif - } - } - - if (!shouldContinue) - { - Exit(); - } - } - finally - { - System.Threading.Interlocked.Exchange(ref _manualUpdateCheckInFlight, 0); - } - } - - private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string message) - { - // Prefer the Hub window when open so the dialog appears modal to what - // the user is actually looking at; fall back to the hidden keep-alive - // window so the dialog still renders if the Hub has been dismissed. - XamlRoot? xamlRoot = null; - if (_hubWindow != null && !_hubWindow.IsClosed) - xamlRoot = (_hubWindow.Content as FrameworkElement)?.XamlRoot; - if (xamlRoot == null) - xamlRoot = (_keepAliveWindow?.Content as FrameworkElement)?.XamlRoot; - if (xamlRoot == null) - { - // Log the stable English key, not the localized title, so log - // grepping works across locales. - Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}"); - return; - } - - var dialog = new ContentDialog - { - Title = title, - Content = message, - CloseButtonText = LocalizationHelper.GetString("Update_OK"), - DefaultButton = ContentDialogButton.Close, - XamlRoot = xamlRoot - }; - try - { - await dialog.ShowAsync(); - } - catch (System.Runtime.InteropServices.COMException ex) - { - // ContentDialog.ShowAsync throws COMException if its XamlRoot's - // visual tree is torn down mid-await (e.g. Hub window closed). - Logger.Warn($"[Update] Dialog dismissed before completion ({logKey}): 0x{ex.HResult:X8}"); - } - catch (InvalidOperationException ex) - { - // WinUI throws InvalidOperationException when another ContentDialog - // is already open on the same thread/XamlRoot. The re-entrancy - // guard only blocks duplicate *update* dialogs; collisions with - // other features' dialogs (onboarding, connection, etc.) must be - // tolerated here so the fire-and-forget call sites don't crash. - Logger.Warn($"[Update] Dialog could not be shown ({logKey}): {ex.Message}"); - } - } - - private async Task DownloadAndInstallUpdateAsync() - { - DownloadProgressDialog? progressDialog = null; - try - { - progressDialog = new DownloadProgressDialog(AppUpdater); - progressDialog.ShowAsync(); // Fire and forget - - var downloadedAsset = await AppUpdater.DownloadUpdateAsync(); - - TryCloseProgressDialog(progressDialog); - - if (downloadedAsset == null || !System.IO.File.Exists(downloadedAsset.FilePath)) - { - Logger.Error("Update download failed or file missing"); - return false; - } - - Logger.Info("Installing update and restarting..."); - await AppUpdater.InstallUpdateAsync(downloadedAsset); - return true; - } - catch (Exception ex) - { - Logger.Error($"Update failed: {ex.Message}"); - TryCloseProgressDialog(progressDialog); - return false; - } - } - - private static void TryCloseProgressDialog(DownloadProgressDialog? dialog) - { - if (dialog == null) return; - try - { - dialog.Close(); - } - catch (System.Runtime.InteropServices.COMException) - { - // Window already closed — closing a closed WinUI window throws - // COMException 0x80070578. Swallow so a real exception in the - // outer catch isn't masked by this cleanup failure. - } - catch (InvalidOperationException) - { - // Same as above for other "already-disposed" race variants. - } - } - - #endregion - #region Deep Links private void StartDeepLinkServer() @@ -3880,7 +3433,7 @@ private void HandleDeepLink(string uri) OpenSettings = ShowSettings, OpenSetup = () => _ = ShowOnboardingAsync(), RunHealthCheck = () => RunHealthCheckAsync(userInitiated: true), - CheckForUpdates = CheckForUpdatesUserInitiatedAsync, + CheckForUpdates = _updateCoordinator!.CheckForUpdatesUserInitiatedAsync, OpenLogFile = OpenLogFile, OpenLogFolder = OpenLogFolder, OpenConfigFolder = OpenConfigFolder, diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs new file mode 100644 index 000000000..c0a2e23e7 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -0,0 +1,465 @@ +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using OpenClaw.Shared; +using OpenClawTray.Dialogs; +using OpenClawTray.Helpers; +using System; +using System.IO; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using Updatum; + +namespace OpenClawTray.Services; + +/// +/// Coordinates update checks, user prompts, download, and installation. +/// All public methods must be called from the UI thread. +/// +internal sealed class UpdateCoordinator( + UpdatumManager updater, + AppState appState, + SettingsManager? settings, + Func getXamlRoot, + Action refreshStatus, + Action exit) +{ + private readonly SettingsManager? _settings = settings; + + // Cross-path concurrency for update checks, split into two phases: + // - _updateCheckGate: held only during the metadata/network check. + // Short timeout so contended callers don't block on user thinking. + // - _updateInstallInProgress: Interlocked flag covering the user-facing + // UpdateDialog + download + install. Prevents two parallel installs + // without holding a lock across user interaction. + private readonly SemaphoreSlim _updateCheckGate = new(1, 1); +#if !DEBUG + private int _updateInstallInProgress; +#endif + private int _manualUpdateCheckInFlight; + + public static UpdateCommandCenterInfo BuildInitialInfo() => new() + { + Status = "Not checked", + CurrentVersion = AppVersionInfo.Version + }; + + public async Task CheckForUpdatesAsync(bool userInitiated = false) + { + // === Stage 1: metadata check (gate-protected) === + if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) + { + Logger.Warn("Update check gate timed out: another check is in progress"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "another update check is already in progress; try again in a moment" + }; + return true; // Don't block launch + } + +#if DEBUG + try + { + Logger.Info("Skipping update check in debug build"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Skipped", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "debug build" + }; + return true; + } + finally + { + _updateCheckGate.Release(); + } +#else + string releaseTag; + string changelog; + try + { + Logger.Info("Checking for updates..."); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Checking", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow + }; + var updateFound = await updater.CheckForUpdatesAsync(); + + if (!updateFound) + { + Logger.Info("No updates available"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Current", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "no updates available" + }; + return true; + } + + var release = updater.LatestRelease!; + if (string.IsNullOrEmpty(release.TagName)) + { + // Defensive: AppUpdater says an update is available but the + // release has no tag. Don't silently claim "up to date" — + // surface as Failed so the user sees something is off. + Logger.Warn("Update reported available but release has no TagName"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "update metadata incomplete (missing version tag)" + }; + return true; + } + + releaseTag = release.TagName; + changelog = updater.GetChangelog(true) ?? "No release notes available."; + Logger.Info($"Update available: {releaseTag}"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "prompted" + }; + + if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && + string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) && + !userInitiated) + { + Logger.Info($"Skipping update prompt for remembered version {releaseTag}"); + // Replace the whole object rather than mutating Detail in place: + // AppState.UpdateInfo only fires PropertyChanged on assignment, not on mutation. + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "skipped by user" + }; + return true; + } + } + catch (OperationCanceledException) + { + Logger.Info("Update check cancelled"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "update check cancelled" + }; + return true; + } + catch (Exception ex) + { + Logger.Warn($"Update check failed: {ex.Message}"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = ex.Message + }; + return true; + } + finally + { + // Release the gate BEFORE user interaction & download/install. + // Holding it across these long phases would silently time-out + // any concurrent manual click. + _updateCheckGate.Release(); + } + + // === Stage 2: user-interactive prompt + download/install === + // Gate is released. Use Interlocked flag so concurrent callers can't + // start a second parallel install while we're prompting/downloading. + if (Interlocked.CompareExchange(ref _updateInstallInProgress, 1, 0) != 0) + { + Logger.Info("Update prompt/install already in progress; skipping"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "an update is already being downloaded or installed" + }; + return true; + } + + try + { + var dialog = new UpdateDialog(releaseTag, changelog); + UpdateDialogResult result; + try + { + result = await dialog.ShowAsync(); + } + catch (COMException ex) + { + // Visual tree torn down mid-await (e.g. window closed). + // Treat as "remind me later" rather than tainting Status with + // "Failed" — the network check itself succeeded. + Logger.Warn($"[Update] Prompt dialog dismissed before completion: 0x{ex.HResult:X8}"); + return true; + } + catch (InvalidOperationException ex) + { + // Another ContentDialog is already open on this XamlRoot. + Logger.Warn($"[Update] Prompt dialog could not be shown: {ex.Message}"); + return true; + } + + if (result == UpdateDialogResult.Download) + { + // Assign a fresh object rather than mutating .Detail in place: + // a concurrent loser of the install-flag CAS may have just + // overwritten appState.UpdateInfo with a "Failed" object, + // and mutating its Detail would leave Status="Failed" with + // our "download requested" detail — briefly inconsistent. + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "download requested" + }; + if (_settings != null) + { + _settings.SkippedUpdateTag = string.Empty; + _settings.Save(); + } + var installed = await DownloadAndInstallUpdateAsync(); + if (!installed) + { + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "download or install failed" + }; + } + return !installed; // Don't launch if update succeeded + } + + if (result == UpdateDialogResult.Skip && _settings != null) + { + _settings.SkippedUpdateTag = releaseTag; + _settings.Save(); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Available", + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, + CheckedAt = DateTime.UtcNow, + Detail = "skipped by user" + }; + } + else if (userInitiated && _settings != null + && string.Equals(_settings.SkippedUpdateTag, releaseTag, + StringComparison.OrdinalIgnoreCase)) + { + // User explicitly bypassed the remembered skip for THIS + // release and picked RemindLater — clear the stale tag. + _settings.SkippedUpdateTag = string.Empty; + _settings.Save(); + } + + return true; // RemindLater or Skip - continue launch + } + catch (Exception ex) + { + Logger.Warn($"Update prompt/install failed: {ex.Message}"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Failed", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = ex.Message + }; + return true; + } + finally + { + Interlocked.Exchange(ref _updateInstallInProgress, 0); + } +#endif + } + + // Re-entrancy guard: the button/menu/deep-link are all fire-and-forget + // (`_ = CheckForUpdatesUserInitiatedAsync()`), so a double-click would + // otherwise open two ContentDialogs on the same XamlRoot which throws + // COMException. One in-flight manual check at a time is enough. + public async Task CheckForUpdatesUserInitiatedAsync() + { + if (Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0) + { + Logger.Info("Manual update check ignored: another check is already in progress"); + return; + } + + try + { + Logger.Info("Manual update check requested"); + // Pass userInitiated=true so an explicit click bypasses the + // "remind me later" SkippedUpdateTag — the user is asking *now*. + var shouldContinue = await CheckForUpdatesAsync(userInitiated: true); + refreshStatus(); + + // The "Available" path already prompts via UpdateDialog. For the + // other terminal states a manual click would otherwise produce no + // UI at all, leaving users wondering whether the click registered. + // Surface each explicitly with a small OK dialog. + var info = appState.UpdateInfo; + if (info != null) + { + switch (info.Status) + { + case "Current": + await ShowUpdateInfoDialogAsync( + "UpToDate", + LocalizationHelper.GetString("Update_Title_UpToDate"), + LocalizationHelper.Format("Update_Message_UpToDate", info.CurrentVersion)); + break; + case "Failed": + // Format string ends with "\n\n{0}"; an empty Detail + // would leave a dangling blank line. Trim only the + // newline characters we added, never arbitrary + // whitespace from the localized string. + var failedMessage = LocalizationHelper + .Format("Update_Message_Failed", info.Detail ?? "") + .TrimEnd('\r', '\n'); + await ShowUpdateInfoDialogAsync( + "Failed", + LocalizationHelper.GetString("Update_Title_Failed"), + failedMessage); + break; +#if DEBUG + // Status="Skipped" is only produced by the DEBUG short-circuit + // in CheckForUpdatesAsync. User-skipped versions keep + // Status="Available", so this case must not exist in RELEASE + // or it would surface a confusing "disabled in debug builds" + // dialog to end users. + case "Skipped": + await ShowUpdateInfoDialogAsync( + "Skipped", + LocalizationHelper.GetString("Update_Title_Skipped"), + LocalizationHelper.GetString("Update_Message_Skipped_Debug")); + break; +#endif + } + } + + if (!shouldContinue) + exit(); + } + finally + { + Interlocked.Exchange(ref _manualUpdateCheckInFlight, 0); + } + } + + private async Task ShowUpdateInfoDialogAsync(string logKey, string title, string message) + { + // Prefer the Hub window when open so the dialog appears modal to what + // the user is actually looking at; fall back to the hidden keep-alive + // window so the dialog still renders if the Hub has been dismissed. + var xamlRoot = getXamlRoot(); + if (xamlRoot == null) + { + Logger.Warn($"[Update] No XAML root available to show dialog: {logKey}"); + return; + } + + var dialog = new ContentDialog + { + Title = title, + Content = message, + CloseButtonText = LocalizationHelper.GetString("Update_OK"), + DefaultButton = ContentDialogButton.Close, + XamlRoot = xamlRoot + }; + try + { + await dialog.ShowAsync(); + } + catch (COMException ex) + { + // ContentDialog.ShowAsync throws COMException if its XamlRoot's + // visual tree is torn down mid-await (e.g. Hub window closed). + Logger.Warn($"[Update] Dialog dismissed before completion ({logKey}): 0x{ex.HResult:X8}"); + } + catch (InvalidOperationException ex) + { + // WinUI throws InvalidOperationException when another ContentDialog + // is already open on the same thread/XamlRoot. The re-entrancy + // guard only blocks duplicate *update* dialogs; collisions with + // other features' dialogs (onboarding, connection, etc.) must be + // tolerated here so the fire-and-forget call sites don't crash. + Logger.Warn($"[Update] Dialog could not be shown ({logKey}): {ex.Message}"); + } + } + + private async Task DownloadAndInstallUpdateAsync() + { + DownloadProgressDialog? progressDialog = null; + try + { + progressDialog = new DownloadProgressDialog(updater); + progressDialog.ShowAsync(); // Fire and forget + + var downloadedAsset = await updater.DownloadUpdateAsync(); + + TryCloseProgressDialog(progressDialog); + + if (downloadedAsset == null || !File.Exists(downloadedAsset.FilePath)) + { + Logger.Error("Update download failed or file missing"); + return false; + } + + Logger.Info("Installing update and restarting..."); + await updater.InstallUpdateAsync(downloadedAsset); + return true; + } + catch (Exception ex) + { + Logger.Error($"Update failed: {ex.Message}"); + TryCloseProgressDialog(progressDialog); + return false; + } + } + + private static void TryCloseProgressDialog(DownloadProgressDialog? dialog) + { + if (dialog == null) return; + try + { + dialog.Close(); + } + catch (COMException) + { + // Window already closed — closing a closed WinUI window throws + // COMException 0x80070578. Swallow so a real exception in the + // outer catch isn't masked by this cleanup failure. + } + catch (InvalidOperationException) + { + // Same as above for other "already-disposed" race variants. + } + } +} From e41c322b0544e203859a28be3d3c77cae3abf3dd Mon Sep 17 00:00:00 2001 From: AlexAlves87 Date: Sat, 30 May 2026 17:05:23 +0200 Subject: [PATCH 2/2] chore: trigger ci