From 9c97c74c88888d5872ea7ff3a02a1c0b8dad272b Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:01:30 +0800 Subject: [PATCH 1/7] fix(update): respect gateway extended-stable channel --- src/OpenClaw.Shared/IOperatorGatewayClient.cs | 6 +++ src/OpenClaw.Shared/OpenClawGatewayClient.cs | 20 +++++++ src/OpenClaw.Shared/UpdateStatus.cs | 34 ++++++++++++ src/OpenClaw.Tray.WinUI/App.xaml.cs | 32 ++++++----- .../Services/UpdateCoordinator.cs | 53 ++++++++++++++++++- .../GatewayProtocolModelsTests.cs | 1 + .../UpdateStatusTests.cs | 33 ++++++++++++ .../AppRefactorContractTests.cs | 14 ++++- 8 files changed, 177 insertions(+), 16 deletions(-) create mode 100644 src/OpenClaw.Shared/UpdateStatus.cs create mode 100644 tests/OpenClaw.Shared.Tests/UpdateStatusTests.cs diff --git a/src/OpenClaw.Shared/IOperatorGatewayClient.cs b/src/OpenClaw.Shared/IOperatorGatewayClient.cs index 6c93e99c2..7b35a740c 100644 --- a/src/OpenClaw.Shared/IOperatorGatewayClient.cs +++ b/src/OpenClaw.Shared/IOperatorGatewayClient.cs @@ -138,6 +138,12 @@ async Task RunCronJobDetailedAsync(string jobId, bool forc Task StopChannelAsync(string channelName); /// Fetch the rich channels.status snapshot from the gateway. Mac/web canonical wire method. Task GetChannelsStatusAsync(bool probe = false, int timeoutMs = 12000); + /// + /// Fetches the gateway's resolved update track (update.status). Older + /// gateways return null, so callers retain their existing update behavior. + /// + Task GetUpdateStatusAsync(int timeoutMs = 5000) => + Task.FromResult(null); /// Log out / unlink a channel (whatsapp, telegram). Sends channels.logout { channel }. Task LogoutChannelAsync(string channelName, int timeoutMs = 12000); /// Begin a QR linking flow (whatsapp, signal). Sends web.login.start { force, timeoutMs }. diff --git a/src/OpenClaw.Shared/OpenClawGatewayClient.cs b/src/OpenClaw.Shared/OpenClawGatewayClient.cs index bc8363ff5..f5f0d6a2c 100644 --- a/src/OpenClaw.Shared/OpenClawGatewayClient.cs +++ b/src/OpenClaw.Shared/OpenClawGatewayClient.cs @@ -1650,6 +1650,26 @@ public async Task StopChannelAsync(string channelName) } } + /// + /// Fetches the installed gateway's effective update channel. A missing field + /// is intentionally represented as null so older gateways keep the desktop + /// updater's existing behavior. + /// + public async Task GetUpdateStatusAsync(int timeoutMs = 5000) + { + if (!IsConnected) return null; + try + { + var response = await SendWizardRequestAsync("update.status", new { }, timeoutMs); + return GatewayUpdateStatusParser.Parse(response); + } + catch (Exception ex) + { + _logger.Warn($"update.status request failed: {ex.Message}"); + return null; + } + } + /// Log out / unlink a channel. Sends channels.logout { channel }. public async Task LogoutChannelAsync(string channelName, int timeoutMs = 12000) { diff --git a/src/OpenClaw.Shared/UpdateStatus.cs b/src/OpenClaw.Shared/UpdateStatus.cs new file mode 100644 index 000000000..eb362a540 --- /dev/null +++ b/src/OpenClaw.Shared/UpdateStatus.cs @@ -0,0 +1,34 @@ +using System; +using System.Text.Json; + +namespace OpenClaw.Shared; + +/// +/// The gateway's resolved update track. This is authoritative for the installed +/// OpenClaw runtime, rather than the companion's separate release repository. +/// +public sealed class GatewayUpdateStatus +{ + public string? EffectiveChannel { get; init; } + + public bool SuppressesCompanionUpdate => string.Equals( + EffectiveChannel, + "extended-stable", + StringComparison.OrdinalIgnoreCase); +} + +public static class GatewayUpdateStatusParser +{ + /// + /// Parses the additive effectiveChannel field from update.status. + /// Older gateways omit it, which deliberately preserves the legacy updater path. + /// + public static GatewayUpdateStatus Parse(JsonElement payload) => new() + { + EffectiveChannel = payload.ValueKind == JsonValueKind.Object && + payload.TryGetProperty("effectiveChannel", out var channel) && + channel.ValueKind == JsonValueKind.String + ? channel.GetString() + : null + }; +} diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index d317e399b..818b7a220 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -210,6 +210,7 @@ public IntPtr GetHubWindowHandle() private AppState? _appState; internal AppState? AppState => _appState; private UpdateCoordinator? _updateCoordinator; + private IOperatorGatewayClient? _updateCheckClient; private GatewayService? _gatewayService; private PairingApprovalCoordinator? _pairingApprovalCoordinator; private OpenClawTray.Dialogs.PairingApprovalDialog? _pairingApprovalDialog; @@ -642,6 +643,7 @@ _dispatcherQueue is null AppUpdater, _appState, _settings, + () => _connectionManager?.OperatorClient, () => { XamlRoot? r = null; @@ -706,19 +708,6 @@ _dispatcherQueue is null // explicitly via Application.Exit(). DispatcherShutdownMode = DispatcherShutdownMode.OnExplicitShutdown; - // Check for updates before launching. Skip in test instances — no UI dialogs, - // no network calls, no startup delay. - if (DataDirOverride is null && - Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") != "1") - { - var shouldLaunch = await _updateCoordinator.CheckForUpdatesAsync(); - if (!shouldLaunch) - { - Exit(); - return; - } - } - // Register toast activation handler ToastNotificationManagerCompat.OnActivated += OnToastActivated; @@ -2166,6 +2155,11 @@ private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventA // Delegate all 27 event subscriptions to GatewayService _gatewayService?.AttachClient(e.NewClient, e.OldClient); + if (e.OldClient != null) + e.OldClient.HandshakeSucceeded -= OnGatewayUpdateCheckHandshakeSucceeded; + _updateCheckClient = e.NewClient; + if (e.NewClient != null) + e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded; // Configure new client if (e.NewClient is { } client) @@ -2191,6 +2185,18 @@ private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventA _appState.GatewaySelf = null; } + private void OnGatewayUpdateCheckHandshakeSucceeded(object? sender, EventArgs e) + { + if (sender is not IOperatorGatewayClient client || !ReferenceEquals(client, _updateCheckClient) || + DataDirOverride is not null || + Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") == "1") + { + return; + } + + OnUiThread(() => _ = _updateCoordinator?.CheckForAutomaticUpdatesAfterHandshakeAsync(client)); + } + private void RaiseChatProviderChanged() { ChatProviderChanged?.Invoke(this, EventArgs.Empty); diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index 553a82f89..e2846d8a3 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -20,6 +20,7 @@ internal sealed class UpdateCoordinator( UpdatumManager updater, AppState appState, SettingsManager? settings, + Func getGatewayClient, Func getXamlRoot, Action refreshStatus, Action exit) @@ -37,6 +38,7 @@ internal sealed class UpdateCoordinator( private int _updateInstallInProgress; #endif private int _manualUpdateCheckInFlight; + private int _automaticUpdateCheckStarted; public static UpdateCommandCenterInfo BuildInitialInfo() => new() { @@ -44,7 +46,9 @@ internal sealed class UpdateCoordinator( CurrentVersion = AppVersionInfo.Version }; - public async Task CheckForUpdatesAsync(bool userInitiated = false) + public async Task CheckForUpdatesAsync( + bool userInitiated = false, + IOperatorGatewayClient? handshakeClient = null) { // === Stage 1: metadata check (gate-protected) === if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30))) @@ -96,6 +100,21 @@ public async Task CheckForUpdatesAsync(bool userInitiated = false) string changelog; try { + var gatewayUpdateStatus = await TryGetGatewayUpdateStatusAsync( + handshakeClient ?? getGatewayClient()); + if (gatewayUpdateStatus?.SuppressesCompanionUpdate == true) + { + Logger.Info("Skipping companion update check: gateway is on extended-stable"); + appState.UpdateInfo = new UpdateCommandCenterInfo + { + Status = "Skipped", + CurrentVersion = AppVersionInfo.Version, + CheckedAt = DateTime.UtcNow, + Detail = "The connected Gateway uses extended-stable, so ordinary Windows release updates are not offered." + }; + return true; + } + Logger.Info("Checking for updates..."); appState.UpdateInfo = new UpdateCommandCenterInfo { @@ -314,6 +333,36 @@ public async Task CheckForUpdatesAsync(bool userInitiated = false) #endif } + /// + /// Starts the one automatic update check only after hello-ok. The Gateway owns + /// the installed release track; startup has no authenticated client yet. + /// + public async Task CheckForAutomaticUpdatesAfterHandshakeAsync(IOperatorGatewayClient client) + { + if (Interlocked.Exchange(ref _automaticUpdateCheckStarted, 1) != 0) + return; + if (!await CheckForUpdatesAsync(handshakeClient: client)) + exit(); + } + + private static async Task TryGetGatewayUpdateStatusAsync( + IOperatorGatewayClient? gatewayClient) + { + if (gatewayClient is null) + return null; + try + { + return await gatewayClient.GetUpdateStatusAsync(); + } + catch (Exception ex) + { + // An older or unauthorized gateway cannot identify its release track. + // Preserve the standalone updater behavior instead of losing update checks. + Logger.Info($"Gateway update channel unavailable; using companion updater: {ex.Message}"); + return null; + } + } + // 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 @@ -369,7 +418,7 @@ await ShowUpdateInfoDialogAsync( await ShowUpdateInfoDialogAsync( "Skipped", LocalizationHelper.GetString("Update_Title_Skipped"), - LocalizationHelper.GetString( + info.Detail ?? LocalizationHelper.GetString( AppIdentity.IsDev ? "Update_Message_Skipped_Dev" : "Update_Message_Skipped_Debug")); diff --git a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs index 634a9c0e9..4935c216c 100644 --- a/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs +++ b/tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs @@ -36,6 +36,7 @@ public void NewGatewayProtocolMembers_AreDefaultInterfaceMethods_SoTheyDoNotSour ("CreateSessionAsync", new[] { typeof(SessionCreateRequest), typeof(int) }), ("ResetSessionDetailedAsync", new[] { typeof(string), typeof(int) }), ("CompactSessionDetailedAsync", new[] { typeof(string), typeof(int) }), + ("GetUpdateStatusAsync", new[] { typeof(int) }), }; foreach (var (name, args) in newMembers) diff --git a/tests/OpenClaw.Shared.Tests/UpdateStatusTests.cs b/tests/OpenClaw.Shared.Tests/UpdateStatusTests.cs new file mode 100644 index 000000000..af0d87a9a --- /dev/null +++ b/tests/OpenClaw.Shared.Tests/UpdateStatusTests.cs @@ -0,0 +1,33 @@ +using System.Text.Json; +using OpenClaw.Shared; + +namespace OpenClaw.Shared.Tests; + +public sealed class UpdateStatusTests +{ + [Fact] + public void Parse_ExtendedStable_SuppressesCompanionUpdate() + { + using var document = JsonDocument.Parse("""{ "effectiveChannel": "extended-stable" }"""); + + var status = GatewayUpdateStatusParser.Parse(document.RootElement); + + Assert.Equal("extended-stable", status.EffectiveChannel); + Assert.True(status.SuppressesCompanionUpdate); + } + + [Theory] + [InlineData("stable")] + [InlineData("beta")] + [InlineData(null)] + public void Parse_OtherOrMissingChannel_PreservesCompanionUpdate(string? channel) + { + using var document = JsonDocument.Parse(channel is null + ? "{}" + : $$"""{ "effectiveChannel": "{{channel}}" }"""); + + var status = GatewayUpdateStatusParser.Parse(document.RootElement); + + Assert.False(status.SuppressesCompanionUpdate); + } +} diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 96421b7f1..faaa10bd6 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -32,7 +32,6 @@ public void Startup_Order_PreservesInitializationInvariants() "AppUserModelIdRegistrar.RegisterCurrentProcess(AppIdentity.AppUserModelId);", "appUserModelIdRegistration.Attempted", "_settings = new SettingsManager();", - "CheckForUpdatesAsync();", "ToastNotificationManagerCompat.OnActivated += OnToastActivated;", "InitializeTrayIcon();", "_gatewayRegistry = new GatewayRegistry", @@ -40,9 +39,22 @@ public void Startup_Order_PreservesInitializationInvariants() "await ShowOnboardingAsync();", "EnsureNodeService(_settings);", "InitializeGatewayClient();", + "client.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", "StartDeepLinkServer();"); } + [Fact] + public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() + { + var source = ReadAppSources(); + var startup = ExtractMethod(source, "OnLaunchedAsync"); + var clientChanged = ExtractMethod(source, "OnOperatorClientChanged"); + + Assert.DoesNotContain("await _updateCoordinator.CheckForUpdatesAsync()", startup); + Assert.Contains("client.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded", clientChanged); + Assert.Contains("CheckForAutomaticUpdatesAfterHandshakeAsync(client)", source); + } + [Fact] public void Startup_WslKeepAlive_IsOwnedByDedicatedService() { From e22e965d5c100c29d85ea76976a8516dd87a86d4 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:02:52 +0800 Subject: [PATCH 2/7] fix(update): preserve standalone update fallback --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 36 +++++++++-- .../Services/UpdateCoordinator.cs | 8 ++- .../OpenClawGatewayClientTests.cs | 63 +++++++++++++++++++ .../AppRefactorContractTests.cs | 11 +++- 4 files changed, 106 insertions(+), 12 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 818b7a220..5cde7f013 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -909,6 +909,13 @@ _dispatcherQueue is null _managedLocalAutoRepairMonitor.Start(); InitializeGatewayClient(); + if (_connectionManager.CurrentSnapshot.OperatorState == RoleConnectionState.Idle && + _connectionManager.OperatorClient is null) + { + // InitializeGatewayClient found no operator connection to resolve. This + // is a terminal startup state, so retain the standalone updater path. + StartAutomaticUpdateCheckWithoutGateway(); + } // Pre-warm chat window (WebView2 init takes 1-3s, do it now so left-click is instant) if (_settings != null && @@ -2144,6 +2151,14 @@ private bool TryStartLocalMcpOnlyNode() /// private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventArgs e) { + // Subscribe before UI dispatch: GatewayConnectionManager starts its transport + // immediately after this event, and a local hello-ok can otherwise win the race. + if (e.OldClient != null) + e.OldClient.HandshakeSucceeded -= OnGatewayUpdateCheckHandshakeSucceeded; + _updateCheckClient = e.NewClient; + if (e.NewClient != null) + e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded; + if (_dispatcherQueue is { HasThreadAccess: false } dispatcher) { if (!dispatcher.TryEnqueue(() => OnOperatorClientChanged(sender, e))) @@ -2155,11 +2170,6 @@ private void OnOperatorClientChanged(object? sender, OperatorClientChangedEventA // Delegate all 27 event subscriptions to GatewayService _gatewayService?.AttachClient(e.NewClient, e.OldClient); - if (e.OldClient != null) - e.OldClient.HandshakeSucceeded -= OnGatewayUpdateCheckHandshakeSucceeded; - _updateCheckClient = e.NewClient; - if (e.NewClient != null) - e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded; // Configure new client if (e.NewClient is { } client) @@ -2194,7 +2204,18 @@ DataDirOverride is not null || return; } - OnUiThread(() => _ = _updateCoordinator?.CheckForAutomaticUpdatesAfterHandshakeAsync(client)); + OnUiThread(() => _ = _updateCoordinator?.CheckForAutomaticUpdatesAfterGatewayResolutionAsync(client)); + } + + private void StartAutomaticUpdateCheckWithoutGateway() + { + if (DataDirOverride is not null || + Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") == "1") + { + return; + } + + OnUiThread(() => _ = _updateCoordinator?.CheckForAutomaticUpdatesAfterGatewayResolutionAsync()); } private void RaiseChatProviderChanged() @@ -2241,6 +2262,9 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna { _lastManagerConnectedSideEffectsKey = null; } + + if (snap.OperatorState == RoleConnectionState.Error) + StartAutomaticUpdateCheckWithoutGateway(); } private NodeService? EnsureNodeService(SettingsManager settings) diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index e2846d8a3..a9ad1dbaf 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -334,10 +334,12 @@ public async Task CheckForUpdatesAsync( } /// - /// Starts the one automatic update check only after hello-ok. The Gateway owns - /// the installed release track; startup has no authenticated client yet. + /// Starts the one automatic update check after Gateway resolution. A successful + /// hello-ok supplies the authoritative track; unavailable Gateway paths retain + /// the standalone updater's established behavior. /// - public async Task CheckForAutomaticUpdatesAfterHandshakeAsync(IOperatorGatewayClient client) + public async Task CheckForAutomaticUpdatesAfterGatewayResolutionAsync( + IOperatorGatewayClient? handshakeClient = null) { if (Interlocked.Exchange(ref _automaticUpdateCheckStarted, 1) != 0) return; diff --git a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs index c72b43df0..457a0fce6 100644 --- a/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs +++ b/tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs @@ -564,6 +564,69 @@ await server.SendTextAsync( Assert.Equal((0, 0), helper.GetPendingRequestCounts()); } + [Fact] + public async Task GetUpdateStatusAsync_RequestsEffectiveChannelAndParsesExtendedStable() + { + using var server = new LoopbackWebSocketServer(); + using var identity = new TempDirectory("update-status-"); + await server.StartAsync(); + var helper = new GatewayClientTestHelper( + gatewayUrl: server.WebSocketUrl, + identityPath: identity.Path); + using var client = helper.Client; + await client.ConnectAsync(); + + var statusTask = client.GetUpdateStatusAsync(timeoutMs: 10_000); + var request = await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2)); + using var requestDocument = JsonDocument.Parse(request); + Assert.Equal("update.status", requestDocument.RootElement.GetProperty("method").GetString()); + var requestId = ReadRequestId(request); + + await server.SendTextAsync( + JsonSerializer.Serialize(new + { + type = "res", + id = requestId, + ok = true, + payload = new { effectiveChannel = "extended-stable" } + })); + + var status = await statusTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.NotNull(status); + Assert.True(status!.SuppressesCompanionUpdate); + } + + [Fact] + public async Task GetUpdateStatusAsync_GatewayError_PreservesLegacyUpdaterFallback() + { + using var server = new LoopbackWebSocketServer(); + using var identity = new TempDirectory("update-status-"); + await server.StartAsync(); + var helper = new GatewayClientTestHelper( + gatewayUrl: server.WebSocketUrl, + identityPath: identity.Path); + using var client = helper.Client; + await client.ConnectAsync(); + + var statusTask = client.GetUpdateStatusAsync(timeoutMs: 10_000); + var request = await server.ReceiveTextAsync().WaitAsync(TimeSpan.FromSeconds(2)); + var requestId = ReadRequestId(request); + + await server.SendTextAsync( + JsonSerializer.Serialize(new + { + type = "res", + id = requestId, + ok = false, + error = new { message = "unknown method" } + })); + + var status = await statusTask.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.Null(status); + } + [Fact] public async Task SendWizardRequestAsync_GatewayError_PropagatesUnchangedAndCleansTracking() { diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index faaa10bd6..2b5519f64 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -39,7 +39,7 @@ public void Startup_Order_PreservesInitializationInvariants() "await ShowOnboardingAsync();", "EnsureNodeService(_settings);", "InitializeGatewayClient();", - "client.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", + "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", "StartDeepLinkServer();"); } @@ -51,8 +51,13 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() var clientChanged = ExtractMethod(source, "OnOperatorClientChanged"); Assert.DoesNotContain("await _updateCoordinator.CheckForUpdatesAsync()", startup); - Assert.Contains("client.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded", clientChanged); - Assert.Contains("CheckForAutomaticUpdatesAfterHandshakeAsync(client)", source); + Assert.Contains("e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded", clientChanged); + Assert.Contains("CheckForAutomaticUpdatesAfterGatewayResolutionAsync(client)", source); + AssertInOrder( + clientChanged, + "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", + "if (_dispatcherQueue is { HasThreadAccess: false } dispatcher)"); + Assert.Contains("StartAutomaticUpdateCheckWithoutGateway();", startup); } [Fact] From 98b5c78b0bea60b9229b533283c7657d55ef2dfc Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:03:15 +0800 Subject: [PATCH 3/7] test(update): correct startup lifecycle assertion --- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 2b5519f64..8944d0cec 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -39,7 +39,6 @@ public void Startup_Order_PreservesInitializationInvariants() "await ShowOnboardingAsync();", "EnsureNodeService(_settings);", "InitializeGatewayClient();", - "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", "StartDeepLinkServer();"); } From 01c77d4bed506c654d2032f1f24026fda226a3a7 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:03:53 +0800 Subject: [PATCH 4/7] test(update): cover unavailable gateway fallback --- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 8944d0cec..d87345bd0 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -57,6 +57,7 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", "if (_dispatcherQueue is { HasThreadAccess: false } dispatcher)"); Assert.Contains("StartAutomaticUpdateCheckWithoutGateway();", startup); + Assert.Contains("if (snap.OperatorState == RoleConnectionState.Error)", source); } [Fact] From d201429f158634eca2e479c9e2af66bc99cff417 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:43:36 +0800 Subject: [PATCH 5/7] fix(update): pass resolved gateway client to updater --- src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs | 2 +- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index a9ad1dbaf..1bd8bfda2 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -343,7 +343,7 @@ public async Task CheckForAutomaticUpdatesAfterGatewayResolutionAsync( { if (Interlocked.Exchange(ref _automaticUpdateCheckStarted, 1) != 0) return; - if (!await CheckForUpdatesAsync(handshakeClient: client)) + if (!await CheckForUpdatesAsync(handshakeClient: handshakeClient)) exit(); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index d87345bd0..cdcae1a60 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -52,6 +52,13 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() Assert.DoesNotContain("await _updateCoordinator.CheckForUpdatesAsync()", startup); Assert.Contains("e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded", clientChanged); Assert.Contains("CheckForAutomaticUpdatesAfterGatewayResolutionAsync(client)", source); + var updateCoordinator = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Services", + "UpdateCoordinator.cs")); + Assert.Contains("CheckForUpdatesAsync(handshakeClient: handshakeClient)", updateCoordinator); AssertInOrder( clientChanged, "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", From 7f03dffc2278a6a667812d71798a5f9137bd1438 Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Sun, 9 Aug 2026 16:50:10 +0800 Subject: [PATCH 6/7] fix(update): retain gateway resolver in debug builds --- src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs | 5 ++++- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index 1bd8bfda2..2ceac49ad 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -26,6 +26,7 @@ internal sealed class UpdateCoordinator( Action exit) { private readonly SettingsManager? _settings = settings; + private readonly Func _getGatewayClient = getGatewayClient; // Cross-path concurrency for update checks, split into two phases: // - _updateCheckGate: held only during the metadata/network check. @@ -101,7 +102,7 @@ public async Task CheckForUpdatesAsync( try { var gatewayUpdateStatus = await TryGetGatewayUpdateStatusAsync( - handshakeClient ?? getGatewayClient()); + handshakeClient ?? GetGatewayClient()); if (gatewayUpdateStatus?.SuppressesCompanionUpdate == true) { Logger.Info("Skipping companion update check: gateway is on extended-stable"); @@ -365,6 +366,8 @@ public async Task CheckForAutomaticUpdatesAfterGatewayResolutionAsync( } } + private IOperatorGatewayClient? GetGatewayClient() => _getGatewayClient(); + // 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 diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index cdcae1a60..08036414a 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -58,6 +58,7 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() "OpenClaw.Tray.WinUI", "Services", "UpdateCoordinator.cs")); + Assert.Contains("private readonly Func _getGatewayClient = getGatewayClient;", updateCoordinator); Assert.Contains("CheckForUpdatesAsync(handshakeClient: handshakeClient)", updateCoordinator); AssertInOrder( clientChanged, From e00bf14bfb7a6558bcca85854a82753c943e6c9f Mon Sep 17 00:00:00 2001 From: Dallin Romney Date: Wed, 12 Aug 2026 00:56:35 +0700 Subject: [PATCH 7/7] fix(update): preserve fallback while gateway pairing --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 ++++- tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 5cde7f013..7826920f1 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -2263,7 +2263,10 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna _lastManagerConnectedSideEffectsKey = null; } - if (snap.OperatorState == RoleConnectionState.Error) + // Only a successful operator handshake can identify the Gateway's release track. + // PairingRequired intentionally persists after its socket closes, so preserve the + // pre-existing standalone updater instead of silently losing automatic checks. + if (snap.OperatorState is RoleConnectionState.Error or RoleConnectionState.PairingRequired) StartAutomaticUpdateCheckWithoutGateway(); } diff --git a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs index 08036414a..019286dc0 100644 --- a/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/AppRefactorContractTests.cs @@ -48,6 +48,7 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() var source = ReadAppSources(); var startup = ExtractMethod(source, "OnLaunchedAsync"); var clientChanged = ExtractMethod(source, "OnOperatorClientChanged"); + var managerStateChanged = ExtractMethod(source, "OnManagerStateChanged"); Assert.DoesNotContain("await _updateCoordinator.CheckForUpdatesAsync()", startup); Assert.Contains("e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded", clientChanged); @@ -65,7 +66,9 @@ public void AutomaticUpdateCheck_WaitsForAuthenticatedGatewayHandshake() "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", "if (_dispatcherQueue is { HasThreadAccess: false } dispatcher)"); Assert.Contains("StartAutomaticUpdateCheckWithoutGateway();", startup); - Assert.Contains("if (snap.OperatorState == RoleConnectionState.Error)", source); + Assert.Contains( + "if (snap.OperatorState is RoleConnectionState.Error or RoleConnectionState.PairingRequired)", + managerStateChanged); } [Fact]