diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 383387975..af4f04c5e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -281,18 +281,19 @@ jobs: - name: Patch MSIX manifest metadata shell: pwsh run: | - $version = "${{ needs.test.outputs.majorMinorPatch }}.0" + # NOTE: Identity/Version is auto-synced from /p:Version by the SyncAppxManifestVersion + # target in OpenClaw.Tray.WinUI.csproj during the MSIX build below; we only patch the + # alpha/non-alpha identity and display name here. $isAlpha = "${{ startsWith(github.ref, 'refs/tags/v') && contains(github.ref_name, '-') }}" -eq "true" $identityName = if ($isAlpha) { "OpenClaw.Companion.Alpha" } else { "OpenClaw.Companion" } $displayName = if ($isAlpha) { "OpenClaw Companion Alpha" } else { "OpenClaw Companion" } $manifest = "src/OpenClaw.Tray.WinUI/Package.appxmanifest" [xml]$xml = Get-Content $manifest $xml.Package.Identity.Name = $identityName - $xml.Package.Identity.Version = $version $xml.Package.Properties.DisplayName = $displayName $xml.Package.Applications.Application.VisualElements.DisplayName = $displayName $xml.Save((Resolve-Path $manifest)) - Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName', version $version" + Write-Host "Patched MSIX manifest to identity $identityName, display name '$displayName' (Version will be synced from /p:Version by msbuild target)" - name: Build MSIX Package run: > diff --git a/docs/VERSIONING.md b/docs/VERSIONING.md index 674f018d0..de1ad8f03 100644 --- a/docs/VERSIONING.md +++ b/docs/VERSIONING.md @@ -70,6 +70,32 @@ By removing the hardcoded `FileVersion` and `AssemblyVersion` properties, they n 2. **Let GitVersion and CI control the version** - the csproj's `` is just a fallback for local development builds 3. **Test version detection** - after building, check the EXE properties to ensure FileVersion matches expectations 4. **Use semantic versioning** - tags should follow `v{major}.{minor}.{patch}` format (e.g., `v0.4.0`) +5. **Use `OpenClaw.Shared.AppVersionInfo` for any user-visible or wire-exposed version string** - never re-roll + `typeof(...).Assembly.GetName().Version` or hardcode literals like `"v0.1.0"`. `AppVersionInfo` is the single + source of truth driven by ``, used by the About page, Update dialog, support-context dump, + `device.info` capability, MCP `serverVersion` handshake, and the update-check diagnostics. + +## Runtime Version Resolution (AppVersionInfo) + +`src/OpenClaw.Shared/AppVersionInfo.cs` exposes: + +- `AppVersionInfo.Version` → bare string, e.g. `"0.4.7"` +- `AppVersionInfo.DisplayVersion` → `"v"` prefix, e.g. `"v0.4.7"` + +It resolves the version by: + +1. Looking for the `OpenClaw.Tray.WinUI` assembly in the current `AppDomain` (so `dotnet test` and CLI siblings + still report the tray's version rather than the testhost / dotnet host). +2. Falling back to `Assembly.GetEntryAssembly()`, then to the Shared assembly. +3. Reading `AssemblyInformationalVersionAttribute` (preferred) or `AssemblyVersion`. +4. Stripping SourceLink build metadata (`+abc123`) **and** the SemVer pre-release suffix (`-beta.1`) so the + displayed value matches what Updatum compares (Updatum reads the numeric `AssemblyVersion` only). + +For tests that need a deterministic value regardless of host process, set the `internal` test hook: + +```csharp +AppVersionInfo.TestOverride = "9.9.9"; +``` ## References diff --git a/src/OpenClaw.Shared/AppVersionInfo.cs b/src/OpenClaw.Shared/AppVersionInfo.cs new file mode 100644 index 000000000..3f292bc58 --- /dev/null +++ b/src/OpenClaw.Shared/AppVersionInfo.cs @@ -0,0 +1,101 @@ +using System; +using System.Reflection; + +namespace OpenClaw.Shared; + +/// +/// Single source of truth for the OpenClaw Companion app version that is +/// surfaced to users. Reads +/// (or falls back to AssemblyVersion) from the tray executable so every +/// UI/diagnostic/handshake site reports the same number driven by the csproj +/// <Version> property. +/// +/// +/// Under dotnet test and inside CLI siblings, +/// is the host process (testhost / dotnet), not the tray exe — so we first +/// search the current for the tray assembly by name. +/// Tests that need a deterministic value can set . +/// +public static class AppVersionInfo +{ + private const string TrayAssemblyName = "OpenClaw.Tray.WinUI"; + + private static readonly string _version = ResolveVersion(); + + /// + /// Test-only override. When non-null, returns this + /// value instead of the reflected one, giving tests a deterministic + /// version string regardless of the host process. + /// + internal static string? TestOverride { get; set; } + + /// Bare version string, e.g. "0.4.7". + public static string Version => TestOverride ?? _version; + + /// Version prefixed with v, e.g. "v0.4.7". + public static string DisplayVersion => "v" + Version; + + private static string ResolveVersion() + { + try + { + var assembly = FindTrayAssembly() + ?? Assembly.GetEntryAssembly() + ?? typeof(AppVersionInfo).Assembly; + + var informational = assembly + .GetCustomAttribute() + ?.InformationalVersion; + if (!string.IsNullOrWhiteSpace(informational)) + { + return NormalizeSemVer(informational); + } + + var name = assembly.GetName().Version; + if (name != null) + { + // System.Version uses -1 for unspecified components; coerce to 0. + var build = Math.Max(0, name.Build); + var revision = Math.Max(0, name.Revision); + return revision == 0 + ? $"{name.Major}.{name.Minor}.{build}" + : $"{name.Major}.{name.Minor}.{build}.{revision}"; + } + + return "0.0.0"; + } + catch + { + // Never let the type initializer fail — a TypeInitializationException + // would poison every future access from every caller. + return "0.0.0"; + } + } + + private static Assembly? FindTrayAssembly() + { + foreach (var asm in AppDomain.CurrentDomain.GetAssemblies()) + { + if (string.Equals(asm.GetName().Name, TrayAssemblyName, StringComparison.Ordinal)) + return asm; + } + return null; + } + + private static string NormalizeSemVer(string s) + { + // Strip SourceLink build metadata, e.g. "0.4.7+abc123" -> "0.4.7". + var plus = s.IndexOf('+'); + if (plus >= 0) s = s.Substring(0, plus); + + // Strip the SemVer pre-release suffix, e.g. "0.4.7-beta.1" -> "0.4.7". + // This keeps the UI string aligned with Updatum, which compares the + // numeric AssemblyVersion only. Revisit if pre-release labels should + // ever be surfaced to users. + var dash = s.IndexOf('-'); + if (dash >= 0) s = s.Substring(0, dash); + + return s; + } +} + diff --git a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs index 2c05ae9ac..01298377a 100644 --- a/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs +++ b/src/OpenClaw.Shared/Capabilities/DeviceCapability.cs @@ -4,7 +4,6 @@ using System.IO; using System.Linq; using System.Net.NetworkInformation; -using System.Reflection; using System.Runtime.InteropServices; using System.Threading.Tasks; @@ -53,10 +52,7 @@ private NodeInvokeResponse HandleInfo() { Logger.Info("device.info"); - var assembly = typeof(DeviceCapability).Assembly; - var version = assembly.GetCustomAttribute()?.InformationalVersion - ?? assembly.GetName().Version?.ToString() - ?? "unknown"; + var version = AppVersionInfo.Version; return Success(new { @@ -65,7 +61,7 @@ private NodeInvokeResponse HandleInfo() systemName = OperatingSystem.IsWindows() ? "Windows" : RuntimeInformation.OSDescription, systemVersion = RuntimeInformation.OSDescription, appVersion = version, - appBuild = assembly.GetName().Version?.ToString() ?? version, + appBuild = version, locale = CultureInfo.CurrentCulture.Name }); } diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 248b67ac1..b9819bdb1 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -3369,29 +3369,64 @@ private void OnSettingsHotkeyPressed(object? sender, EventArgs e) private static UpdateCommandCenterInfo BuildInitialUpdateInfo() => new() { Status = "Not checked", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown" + CurrentVersion = AppVersionInfo.Version }; - private async Task CheckForUpdatesAsync() + // 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); + private int _updateInstallInProgress; + + private async Task CheckForUpdatesAsync(bool userInitiated = false) { - try + // === 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 = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + 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 = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionInfo.Version, CheckedAt = DateTime.UtcNow }; var updateFound = await AppUpdater.CheckForUpdatesAsync(); @@ -3402,7 +3437,7 @@ private async Task CheckForUpdatesAsync() _appState!.UpdateInfo = new UpdateCommandCenterInfo { Status = "Current", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", + CurrentVersion = AppVersionInfo.Version, CheckedAt = DateTime.UtcNow, Detail = "no updates available" }; @@ -3410,72 +3445,330 @@ private async Task CheckForUpdatesAsync() } var release = AppUpdater.LatestRelease!; - var changelog = AppUpdater.GetChangelog(true) ?? "No release notes available."; - Logger.Info($"Update available: {release.TagName}"); + 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 = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - LatestVersion = release.TagName, + CurrentVersion = AppVersionInfo.Version, + LatestVersion = releaseTag, CheckedAt = DateTime.UtcNow, Detail = "prompted" }; if (!string.IsNullOrWhiteSpace(_settings?.SkippedUpdateTag) && - string.Equals(_settings.SkippedUpdateTag, release.TagName, StringComparison.OrdinalIgnoreCase)) + string.Equals(_settings.SkippedUpdateTag, releaseTag, StringComparison.OrdinalIgnoreCase) && + !userInitiated) { - Logger.Info($"Skipping update prompt for remembered version {release.TagName}"); + 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; + } - var dialog = new UpdateDialog(release.TagName, changelog); - var result = await dialog.ShowAsync(); + 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) { - _appState!.UpdateInfo.Detail = "download requested"; + // 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 = release.TagName ?? string.Empty; + _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(); - _appState!.UpdateInfo.Detail = "skipped by user"; } - return true; // RemindLater or Skip - continue -#endif + return true; // RemindLater or Skip - continue launch } catch (Exception ex) { - Logger.Warn($"Update check failed: {ex.Message}"); - _appState!.UpdateInfo = new UpdateCommandCenterInfo + Logger.Warn($"Update prompt/install failed: {ex.Message}"); + if (_appState != null) { - Status = "Failed", - CurrentVersion = typeof(App).Assembly.GetName().Version?.ToString() ?? "unknown", - CheckedAt = DateTime.UtcNow, - Detail = ex.Message - }; + _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() { - Logger.Info("Manual update check requested"); - var shouldContinue = await CheckForUpdatesAsync(); - UpdateStatusDetailWindow(); - if (!shouldContinue) + if (System.Threading.Interlocked.CompareExchange(ref _manualUpdateCheckInFlight, 1, 0) != 0) { - Exit(); + 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}"); } } @@ -3489,7 +3782,7 @@ private async Task DownloadAndInstallUpdateAsync() var downloadedAsset = await AppUpdater.DownloadUpdateAsync(); - progressDialog?.Close(); + TryCloseProgressDialog(progressDialog); if (downloadedAsset == null || !System.IO.File.Exists(downloadedAsset.FilePath)) { @@ -3504,11 +3797,30 @@ private async Task DownloadAndInstallUpdateAsync() catch (Exception ex) { Logger.Error($"Update failed: {ex.Message}"); - progressDialog?.Close(); + 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 diff --git a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs index 5767a2d97..3df7ea909 100644 --- a/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs +++ b/src/OpenClaw.Tray.WinUI/Dialogs/UpdateDialog.cs @@ -1,6 +1,7 @@ using Microsoft.UI.Xaml; using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Media; +using OpenClaw.Shared; using OpenClawTray.Helpers; using OpenClawTray.Services; using System; @@ -54,7 +55,7 @@ public UpdateDialog(string version, string changelog) // Content var content = new StackPanel { Spacing = 12 }; - var currentVersion = typeof(UpdateDialog).Assembly.GetName().Version?.ToString() ?? "Unknown"; + var currentVersion = AppVersionInfo.Version; content.Children.Add(new TextBlock { Text = string.Format(LocalizationHelper.GetString("Update_CurrentVersion"), currentVersion), diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index cb77f8daa..7d78a0987 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -9,7 +9,7 @@ true Assets\openclaw.ico OpenClawTray - 0.4.4 + 0.4.7 en-US x64;ARM64 win-x64;win-arm64 @@ -38,8 +38,9 @@ true Never SideloadOnly - + @@ -79,6 +80,108 @@ $([System.IO.Path]::GetFullPath('$(MSBuildThisFileDirectory)..\..\')) + + + + + + + + + ]*?\\bVersion\\s*=\\s*[\"'])[^\"']+([\"'])", + System.Text.RegularExpressions.RegexOptions.IgnoreCase); + if (!regex.IsMatch(text)) { + Log.LogError("SyncAppxManifestVersion: could not find Identity/@Version in " + ManifestPath); + return false; + } + var updated = regex.Replace(text, "${1}" + FourPartVersion + "$2", 1); + if (updated == text) { + return true; + } + + // Clear any read-only attribute (some non-Git SCMs mark files read-only) so the + // write doesn't fail with an opaque UnauthorizedAccessException. + try { + var attrs = System.IO.File.GetAttributes(ManifestPath); + if ((attrs & System.IO.FileAttributes.ReadOnly) != 0) { + System.IO.File.SetAttributes(ManifestPath, attrs & ~System.IO.FileAttributes.ReadOnly); + } + } catch (System.Exception ex) { + Log.LogWarning("SyncAppxManifestVersion: could not clear read-only on " + ManifestPath + ": " + ex.Message); + } + + // Atomic write: stage to a sibling temp file then replace, so parallel readers never + // see a truncated manifest. File.Replace is atomic on NTFS. The finally block makes + // sure the temp file never lingers (it isn't in .gitignore and would otherwise show + // up in `git status` if Replace throws something other than FileNotFoundException). + var tempPath = ManifestPath + ".sync-tmp"; + System.IO.File.WriteAllText(tempPath, updated); + try { + try { + System.IO.File.Replace(tempPath, ManifestPath, destinationBackupFileName: null); + } catch (System.IO.FileNotFoundException) { + // Replace requires destination to exist; if a concurrent build deleted it, fall + // back to a plain move (no 3-arg overload on .NET Framework, which is what the + // inline task compiler targets). + System.IO.File.Move(tempPath, ManifestPath); + } + } finally { + try { + if (System.IO.File.Exists(tempPath)) { + System.IO.File.Delete(tempPath); + } + } catch { /* best-effort cleanup */ } + } + Log.LogMessage(MessageImportance.High, "Synced Package.appxmanifest Identity/@Version to " + FourPartVersion); + ]]> + + + + + + + + + <_AppxManifestPath>$(MSBuildThisFileDirectory)Package.appxmanifest + <_StrippedVersion>$([System.Text.RegularExpressions.Regex]::Replace('$(Version)', '[-+].*$', '')) + <_VersionDotCount>$([System.Text.RegularExpressions.Regex]::Matches('$(_StrippedVersion)', '\.').Count) + <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '3'">$(_StrippedVersion) + <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '2'">$(_StrippedVersion).0 + <_AppxManifestVersion Condition="'$(_VersionDotCount)' == '1'">$(_StrippedVersion).0.0 + + + + + diff --git a/src/OpenClaw.Tray.WinUI/Package.appxmanifest b/src/OpenClaw.Tray.WinUI/Package.appxmanifest index cbab15c60..7016ca5c7 100644 --- a/src/OpenClaw.Tray.WinUI/Package.appxmanifest +++ b/src/OpenClaw.Tray.WinUI/Package.appxmanifest @@ -9,10 +9,13 @@ + + Version="0.0.0.0" /> OpenClaw Companion diff --git a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml index e65636683..345baa6c8 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/AboutPage.xaml @@ -18,7 +18,7 @@ - { if (_appState != null) _appState.PropertyChanged -= OnAppStateChanged; @@ -107,7 +109,7 @@ private async void OnCopySupportClick(object sender, RoutedEventArgs e) } else { - context = $"OpenClaw Hub v0.1.0\n" + context = $"OpenClaw Hub {AppVersionInfo.DisplayVersion}\n" + $"OS: {Environment.OSVersion}\n" + $"Runtime: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}\n" + $"Connection: {CurrentApp.AppState?.Status}\n" diff --git a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs index d03d69494..f05081430 100644 --- a/src/OpenClaw.Tray.WinUI/Services/NodeService.cs +++ b/src/OpenClaw.Tray.WinUI/Services/NodeService.cs @@ -598,7 +598,7 @@ private void StartMcpServer() () => { lock (_capabilitiesLock) return _capabilities.ToArray(); }, _logger, serverName: "openclaw-tray-mcp", - serverVersion: typeof(NodeService).Assembly.GetName().Version?.ToString() ?? "0.0.0"); + serverVersion: AppVersionInfo.Version); // Bearer-token auth. Token is created on first start and persists // alongside other OpenClawTray data (so OPENCLAW_TRAY_DATA_DIR // isolation in tests scopes the token too); CLI/agent registration diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index 052ca9801..05e3fcdbc 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -403,6 +403,29 @@ Download & Install + + You're up to date + + + You're running the latest version (v{0}). + + + Couldn't check for updates + + + Something went wrong while checking for updates. + +{0} + + + Update check skipped + + + Update checks are disabled in debug builds. + + + OK + Open Dashboard @@ -1527,9 +1550,6 @@ On your gateway host (Mac/Linux), run: OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index 69c912520..9925ff15b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -374,6 +374,29 @@ Télécharger & Installer + + Vous êtes à jour + + + Vous utilisez la dernière version (v{0}). + + + Impossible de vérifier les mises à jour + + + Une erreur s'est produite lors de la vérification des mises à jour. + +{0} + + + Vérification ignorée + + + La vérification des mises à jour est désactivée dans les builds de débogage. + + + OK + Ouvrir le tableau de bord @@ -1084,7 +1107,7 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : OpenClaw a encore besoin d’être configuré avant de pouvoir ouvrir le Hub. Utilisez Retour pour revenir à l’assistant ou aux étapes de connexion, corrigez la configuration manquante, puis réessayez Terminer. - D'accord + OK Bienvenue dans OpenClaw @@ -1478,9 +1501,6 @@ Sur votre hôte passerelle (Mac/Linux), exécutez : OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index fe766d944..7d4e4f19f 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -375,6 +375,29 @@ Downloaden en installeren + + Je bent up-to-date + + + Je gebruikt de nieuwste versie (v{0}). + + + Kan niet op updates controleren + + + Er is iets misgegaan tijdens het controleren op updates. + +{0} + + + Updatecontrole overgeslagen + + + Updatecontroles zijn uitgeschakeld in debug-builds. + + + OK + Dashboard openen @@ -1085,7 +1108,7 @@ Voer op uw gateway-host (Mac/Linux) uit: OpenClaw heeft nog setup nodig voordat de Hub kan worden geopend. Gebruik Terug om naar de wizard of verbindingsstappen te gaan, los de ontbrekende setup op en probeer daarna Voltooien opnieuw. - Oké + OK Welkom bij OpenClaw @@ -1479,9 +1502,6 @@ Voer op uw gateway-host (Mac/Linux) uit: OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index c1b9c1ed9..be369d14d 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -374,6 +374,29 @@ 下载并安装 + + 已是最新版本 + + + 您正在使用最新版本 (v{0})。 + + + 无法检查更新 + + + 检查更新时出错。 + +{0} + + + 已跳过更新检查 + + + 调试版本已禁用更新检查。 + + + 确定 + 打开仪表板 @@ -1478,9 +1501,6 @@ OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index c9218f538..b7eb7a627 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -374,6 +374,29 @@ 下載並安裝 + + 已是最新版本 + + + 您正在使用最新版本 (v{0})。 + + + 無法檢查更新 + + + 檢查更新時發生錯誤。 + +{0} + + + 已略過更新檢查 + + + 偵錯版本已停用更新檢查。 + + + 確定 + 打開儀表板 @@ -1478,9 +1501,6 @@ OpenClaw Hub - - v0.1.0 - .NET 10 / WinUI 3 / WinAppSDK 1.8 diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 655a27bba..a03b2b161 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -221,6 +221,34 @@ private static bool IsInvariantValue(string value) => value.Contains("https://", StringComparison.Ordinal) || value.Contains("~/", StringComparison.Ordinal); + /// + /// Keys whose value is a Latin-script loanword (e.g. "OK") that reads + /// natively in English/French/Dutch but should still be translated for + /// non-Latin scripts (zh-CN, zh-TW). For these keys the test permits + /// fr-fr and nl-nl to be identical to en-us while zh-cn and zh-tw differ — + /// the "all-or-nothing" rule does not apply. + /// + private static readonly HashSet LatinScriptInvariantResourceKeys = new(StringComparer.Ordinal) + { + "Update_OK", + "Onboarding_IncompleteSetup_Close", + }; + + // Locales whose translations are allowed to remain identical to en-us + // for keys in LatinScriptInvariantResourceKeys (e.g. "OK"). The check in + // Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales requires the + // set of locales sharing the en-us value to *exactly* equal this set. + // + // Pitfall: adding a new Latin-script locale (say de-de) that also uses + // "OK" verbatim will break that test unless de-de is added here too. If + // you add such a locale, update this set; if you add a non-Latin-script + // locale, do nothing. + private static readonly HashSet LatinScriptLocales = new(StringComparer.OrdinalIgnoreCase) + { + "fr-fr", + "nl-nl", + }; + private static bool IsInvariantOrDeferred(string key, string value) => InvariantOrDeferredResourceKeys.Contains(key) || IsInvariantValue(value) @@ -516,6 +544,15 @@ public void Resources_AreTranslatedAllOrNoneAcrossNonEnglishLocales() if (identicalLocales.Count != localeResw.Count) { + // Allow Latin-script loanwords (e.g. "OK") to be identical + // across en-us/fr-fr/nl-nl while still being translated for + // non-Latin-script locales (zh-CN, zh-TW). + if (LatinScriptInvariantResourceKeys.Contains(key) + && identicalLocales.All(l => LatinScriptLocales.Contains(l)) + && LatinScriptLocales.All(l => identicalLocales.Contains(l, StringComparer.OrdinalIgnoreCase))) + { + continue; + } partial.Add($"{key} ({enValue}) identical in [{string.Join(", ", identicalLocales)}]"); continue; } diff --git a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs index 1d2d0ac27..bea35e8c2 100644 --- a/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs +++ b/tests/OpenClaw.Tray.UITests/A2UIDashboardScaleTest.cs @@ -320,7 +320,7 @@ private static string BuildDashboardJsonl() // ── Footer row ──────────────────────────────────────────────────── components.Add(Component("ftr", "Row", new() { ["children"] = Children("ftrVer", "ftrDiv", "ftrConn") })); - components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.4"), ["usageHint"] = "caption" })); + components.Add(Component("ftrVer", "Text", new() { ["text"] = Lit("v0.4.7"), ["usageHint"] = "caption" })); components.Add(Component("ftrDiv", "Divider", new() { ["axis"] = "vertical" })); components.Add(Component("ftrConn", "Text", new() { ["text"] = Lit("Connected"), ["usageHint"] = "caption" }));