Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/OpenClaw.Shared/IOperatorGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,12 @@ async Task<CronRunRequestResult> RunCronJobDetailedAsync(string jobId, bool forc
Task<bool> StopChannelAsync(string channelName);
/// <summary>Fetch the rich channels.status snapshot from the gateway. Mac/web canonical wire method.</summary>
Task<ChannelsStatusSnapshot?> GetChannelsStatusAsync(bool probe = false, int timeoutMs = 12000);
/// <summary>
/// Fetches the gateway's resolved update track (<c>update.status</c>). Older
/// gateways return <c>null</c>, so callers retain their existing update behavior.
/// </summary>
Task<GatewayUpdateStatus?> GetUpdateStatusAsync(int timeoutMs = 5000) =>
Task.FromResult<GatewayUpdateStatus?>(null);
/// <summary>Log out / unlink a channel (whatsapp, telegram). Sends channels.logout { channel }.</summary>
Task<bool> LogoutChannelAsync(string channelName, int timeoutMs = 12000);
/// <summary>Begin a QR linking flow (whatsapp, signal). Sends web.login.start { force, timeoutMs }.</summary>
Expand Down
20 changes: 20 additions & 0 deletions src/OpenClaw.Shared/OpenClawGatewayClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,26 @@ public async Task<bool> StopChannelAsync(string channelName)
}
}

/// <summary>
/// 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.
/// </summary>
public async Task<GatewayUpdateStatus?> 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;
}
}

/// <summary>Log out / unlink a channel. Sends <c>channels.logout { channel }</c>.</summary>
public async Task<bool> LogoutChannelAsync(string channelName, int timeoutMs = 12000)
{
Expand Down
34 changes: 34 additions & 0 deletions src/OpenClaw.Shared/UpdateStatus.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using System;
using System.Text.Json;

namespace OpenClaw.Shared;

/// <summary>
/// The gateway's resolved update track. This is authoritative for the installed
/// OpenClaw runtime, rather than the companion's separate release repository.
/// </summary>
public sealed class GatewayUpdateStatus
{
public string? EffectiveChannel { get; init; }

public bool SuppressesCompanionUpdate => string.Equals(
EffectiveChannel,
"extended-stable",
StringComparison.OrdinalIgnoreCase);
}

public static class GatewayUpdateStatusParser
{
/// <summary>
/// Parses the additive <c>effectiveChannel</c> field from <c>update.status</c>.
/// Older gateways omit it, which deliberately preserves the legacy updater path.
/// </summary>
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
};
}
59 changes: 46 additions & 13 deletions src/OpenClaw.Tray.WinUI/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -642,6 +643,7 @@ _dispatcherQueue is null
AppUpdater,
_appState,
_settings,
() => _connectionManager?.OperatorClient,
() =>
{
XamlRoot? r = null;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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 &&
Expand Down Expand Up @@ -2155,6 +2151,14 @@ private bool TryStartLocalMcpOnlyNode()
/// </summary>
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)))
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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)
Expand Down
58 changes: 56 additions & 2 deletions src/OpenClaw.Tray.WinUI/Services/UpdateCoordinator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ internal sealed class UpdateCoordinator(
UpdatumManager updater,
AppState appState,
SettingsManager? settings,
Func<IOperatorGatewayClient?> getGatewayClient,
Func<XamlRoot?> getXamlRoot,
Action refreshStatus,
Action exit)
{
private readonly SettingsManager? _settings = settings;
private readonly Func<IOperatorGatewayClient?> _getGatewayClient = getGatewayClient;

// Cross-path concurrency for update checks, split into two phases:
// - _updateCheckGate: held only during the metadata/network check.
Expand All @@ -37,14 +39,17 @@ internal sealed class UpdateCoordinator(
private int _updateInstallInProgress;
#endif
private int _manualUpdateCheckInFlight;
private int _automaticUpdateCheckStarted;

public static UpdateCommandCenterInfo BuildInitialInfo() => new()
{
Status = "Not checked",
CurrentVersion = AppVersionInfo.Version
};

public async Task<bool> CheckForUpdatesAsync(bool userInitiated = false)
public async Task<bool> CheckForUpdatesAsync(
bool userInitiated = false,
IOperatorGatewayClient? handshakeClient = null)
{
// === Stage 1: metadata check (gate-protected) ===
if (!await _updateCheckGate.WaitAsync(TimeSpan.FromSeconds(30)))
Expand Down Expand Up @@ -96,6 +101,21 @@ public async Task<bool> 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
{
Expand Down Expand Up @@ -314,6 +334,40 @@ public async Task<bool> CheckForUpdatesAsync(bool userInitiated = false)
#endif
}

/// <summary>
/// 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.
/// </summary>
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<GatewayUpdateStatus?> 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
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions tests/OpenClaw.Shared.Tests/GatewayProtocolModelsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions tests/OpenClaw.Shared.Tests/OpenClawGatewayClientTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
33 changes: 33 additions & 0 deletions tests/OpenClaw.Shared.Tests/UpdateStatusTests.cs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading