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..7826920f1 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; @@ -920,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 && @@ -2155,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))) @@ -2191,6 +2195,29 @@ 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?.CheckForAutomaticUpdatesAfterGatewayResolutionAsync(client)); + } + + private void StartAutomaticUpdateCheckWithoutGateway() + { + if (DataDirOverride is not null || + Environment.GetEnvironmentVariable("OPENCLAW_SKIP_UPDATE_CHECK") == "1") + { + return; + } + + OnUiThread(() => _ = _updateCoordinator?.CheckForAutomaticUpdatesAfterGatewayResolutionAsync()); + } + private void RaiseChatProviderChanged() { ChatProviderChanged?.Invoke(this, EventArgs.Empty); @@ -2235,6 +2262,12 @@ private void OnManagerStateChanged(object? sender, GatewayConnectionSnapshot sna { _lastManagerConnectedSideEffectsKey = null; } + + // 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(); } private NodeService? EnsureNodeService(SettingsManager settings) diff --git a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs index 553a82f89..2ceac49ad 100644 --- a/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs +++ b/src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs @@ -20,11 +20,13 @@ internal sealed class UpdateCoordinator( UpdatumManager updater, AppState appState, SettingsManager? settings, + Func getGatewayClient, Func getXamlRoot, Action refreshStatus, 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. @@ -37,6 +39,7 @@ internal sealed class UpdateCoordinator( private int _updateInstallInProgress; #endif private int _manualUpdateCheckInFlight; + private int _automaticUpdateCheckStarted; public static UpdateCommandCenterInfo BuildInitialInfo() => new() { @@ -44,7 +47,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 +101,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 +334,40 @@ public async Task CheckForUpdatesAsync(bool userInitiated = false) #endif } + /// + /// 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 CheckForAutomaticUpdatesAfterGatewayResolutionAsync( + IOperatorGatewayClient? handshakeClient = null) + { + if (Interlocked.Exchange(ref _automaticUpdateCheckStarted, 1) != 0) + return; + if (!await CheckForUpdatesAsync(handshakeClient: handshakeClient)) + 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; + } + } + + 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 @@ -369,7 +423,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/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.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..019286dc0 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", @@ -43,6 +42,35 @@ public void Startup_Order_PreservesInitializationInvariants() "StartDeepLinkServer();"); } + [Fact] + 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); + Assert.Contains("CheckForAutomaticUpdatesAfterGatewayResolutionAsync(client)", source); + var updateCoordinator = File.ReadAllText(Path.Combine( + TestRepositoryPaths.GetRepositoryRoot(), + "src", + "OpenClaw.Tray.WinUI", + "Services", + "UpdateCoordinator.cs")); + Assert.Contains("private readonly Func _getGatewayClient = getGatewayClient;", updateCoordinator); + Assert.Contains("CheckForUpdatesAsync(handshakeClient: handshakeClient)", updateCoordinator); + AssertInOrder( + clientChanged, + "e.NewClient.HandshakeSucceeded += OnGatewayUpdateCheckHandshakeSucceeded;", + "if (_dispatcherQueue is { HasThreadAccess: false } dispatcher)"); + Assert.Contains("StartAutomaticUpdateCheckWithoutGateway();", startup); + Assert.Contains( + "if (snap.OperatorState is RoleConnectionState.Error or RoleConnectionState.PairingRequired)", + managerStateChanged); + } + [Fact] public void Startup_WslKeepAlive_IsOwnedByDedicatedService() {