From 746b4a4714a87f75d5825008b0f8e8a1d8fb7d1d Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 25 Sep 2026 11:57:28 -0700 Subject: [PATCH 01/16] feat(migration): complete the Store migration engine Adds the consent store, recovery discard, and the startup/finalization decisions the Store handoff needs on top of the migration foundation. Records are judged one file at a time and on their own contents rather than on the exception raised while reading them, because decoding is pure in-memory work: a malformed length surfaces as IOException and must read as corruption, not as contention. Corruption routes to recovery, which is the only stage that offers a way out, so a damaged record can no longer block startup with no in-app escape. The completion receipt is planned last so an interrupted discard still looks like a handoff that needs recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52 --- .../Migration/InnoInstallation.cs | 3 +- .../Migration/InnoInstallationDetector.cs | 12 +- .../Migration/InnoMigrationConsentStore.cs | 166 ++++++++++++++ .../Migration/MigrationOperationLock.cs | 4 +- .../Migration/MigrationPreparation.cs | 16 +- .../Migration/MigrationRecordCodec.cs | 37 +++- .../Migration/MigrationRecordStorage.cs | 61 ++++++ .../Migration/MigrationStartupRecordReader.cs | 87 +++++++- ...MigrationAdoptionPreparationCoordinator.cs | 5 +- .../StoreMigrationCompletionCoordinator.cs | 20 +- .../StoreMigrationFinalizationCoordinator.cs | 11 +- .../StoreMigrationRecoveryDiscard.cs | 205 ++++++++++++++++++ .../StoreMigrationStartupCoordinator.cs | 78 +++++-- 13 files changed, 634 insertions(+), 71 deletions(-) create mode 100644 src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs create mode 100644 src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs create mode 100644 src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs diff --git a/src/OpenClaw.Connection/Migration/InnoInstallation.cs b/src/OpenClaw.Connection/Migration/InnoInstallation.cs index 48e8e90e3..9eb1abbf6 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallation.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallation.cs @@ -21,7 +21,8 @@ public sealed record InnoInstallation( public sealed record InnoInstallationDetection( InnoInstallationStatus Status, InnoInstallation? Installation = null, - string? Reason = null); + string? Reason = null, + Version? RegisteredVersion = null); public interface IInnoInstallationDetector { diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs index 9fa4749f3..3390075c9 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -76,7 +76,13 @@ public InnoInstallationDetection Detect() }) { if (!_source.IsOrdinaryFile(Path.Combine(_installDirectory, name))) - return Unsupported($"The required Inno payload {name} is missing or is not an ordinary file."); + { + // An otherwise canonical installation that predates the migration payload is + // simply too old. Carry the registered version so admission can ask the user + // to update instead of declaring the installation unsupportable. + return Unsupported( + $"The required Inno payload {name} is missing or is not an ordinary file.", version); + } } if (_source.ReadIdentity(Path.Combine(_installDirectory, "app-identity.txt")) is not @@ -123,10 +129,10 @@ private bool IsDefaultInstallPath(string? path) string.Equals(path, _installDirectory + "\\", StringComparison.OrdinalIgnoreCase)); } - private InnoInstallationDetection Unsupported(string reason) + private InnoInstallationDetection Unsupported(string reason, Version? registeredVersion = null) { _logger.Warn(reason); - return new(InnoInstallationStatus.Unsupported, Reason: reason); + return new(InnoInstallationStatus.Unsupported, Reason: reason, RegisteredVersion: registeredVersion); } } diff --git a/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs b/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs new file mode 100644 index 000000000..26f65b2b8 --- /dev/null +++ b/src/OpenClaw.Connection/Migration/InnoMigrationConsentStore.cs @@ -0,0 +1,166 @@ +using System.Runtime.Versioning; +using System.Security.Cryptography; +using System.Security.Principal; +using System.Text; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +/// +/// Persists explicit same-user Inno consent independently of a preparation inventory. +/// Call Grant only after UI confirmation and exact same-user Inno installation detection. +/// No source settings, services, or identities are inspected or changed. +/// +[SupportedOSPlatform("windows")] +public sealed class InnoMigrationConsentStore( + MigrationBinding binding, + IOpenClawLogger logger, + TimeProvider? timeProvider = null) +{ + internal const string WriterLockFileName = "consent.lock"; + private readonly TimeProvider _clock = timeProvider ?? TimeProvider.System; + + /// + /// Writes a fresh 30-day grant under a shared prepare.lock lease and an exclusive + /// consent writer lock, so consent can be granted while Inno is running. + /// Explicit confirmation may renew an + /// expired, otherwise valid grant or change its source version. Corrupt, wrong-kind, + /// wrong-binding, and future-dated records require recovery and are never overwritten. + /// Invalid-record failures surface as InvalidDataException with the original exception retained. + /// Any completion receipt, even an invalid one, blocks a new grant. + /// + public void Grant(string sourceVersion) + { + using var identity = WindowsIdentity.GetCurrent(); + if (binding.UserSid != identity.User?.Value) + throw new InvalidOperationException("Migration consent must use the current Windows user."); + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + try + { + MigrationRecordStorage.CreateProtectedDirectory(directory); + using var migrationLock = AcquireReadLease(directory, FileMode.OpenOrCreate); + var writerPath = Path.Combine(directory, WriterLockFileName); + MigrationRecordCodec.RejectReparsePoints(writerPath); + using var writerLock = new FileStream(writerPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + MigrationRecordCodec.RejectReparsePoints(completionPath); + if (Exists(completionPath)) + throw new InvalidOperationException("A completion receipt already exists. Resume or recover migration before granting consent again."); + + var now = _clock.GetUtcNow().UtcDateTime; + _ = ReadConsent(directory, now, allowExpired: true); + var record = new MigrationRecord + { + Kind = "consent", + MigrationId = Guid.NewGuid().ToString("D"), + SourceVersion = sourceVersion, + Binding = binding, + CreatedUtc = now, + ExpiresUtc = now.AddDays(30), + Fingerprint = MigrationRecordCodec.ConsentFingerprint + }; + MigrationRecordStorage.WriteAtomic( + Path.Combine(directory, MigrationRecordCodec.ConsentFileName), + MigrationRecordCodec.Encode(record, now)); + } + catch (Exception exception) when (IsInvalidRecord(exception)) + { + logger.Warn($"Inno migration consent requires recovery ({exception.GetType().Name})."); + throw new InvalidDataException("Inno migration consent requires recovery.", exception); + } + } + + /// + /// Missing, expired, or invalid consent returns false and requires fresh Store confirmation. + /// Inspection creates no files or directories. Operational IO/access failures, including + /// a missing or contended prepare.lock when consent exists, propagate to the caller. + /// This read never renews consent; inventory intents and completion receipts are not consent. + /// + public bool HasValidConsent(string sourceVersion) + { + var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + try + { + var consentPath = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); + MigrationRecordCodec.RejectReparsePoints(consentPath); + // A grant appearing after this check can safely require fresh confirmation. + if (!Exists(consentPath)) + return false; + using var migrationLock = AcquireReadLease(directory, FileMode.Open); + var record = ReadConsent(directory, _clock.GetUtcNow().UtcDateTime, allowExpired: false); + if (record is null) + return false; + if (!string.Equals(record.SourceVersion, sourceVersion, StringComparison.Ordinal)) + { + logger.Warn("Inno migration consent source version changed. Confirm migration again."); + return false; + } + return true; + } + catch (Exception exception) when (IsInvalidRecord(exception)) + { + logger.Warn($"Inno migration consent is invalid. Confirm migration again ({exception.GetType().Name})."); + return false; + } + } + + private static FileStream AcquireReadLease(string directory, FileMode mode) + { + var lockPath = Path.Combine(directory, "prepare.lock"); + MigrationRecordCodec.RejectReparsePoints(lockPath); + return new FileStream(lockPath, mode, FileAccess.Read, FileShare.Read); + } + + private MigrationRecord? ReadConsent(string directory, DateTime now, bool allowExpired) + { + var path = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); + MigrationRecordCodec.RejectReparsePoints(path); + FileStream stream; + try + { + stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + } + catch (FileNotFoundException) { return null; } + catch (DirectoryNotFoundException) { return null; } + + using (stream) + { + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + throw new InvalidDataException("Invalid migration consent size."); + using var reader = new BinaryReader(stream); + var bytes = reader.ReadBytes((int)stream.Length); + MigrationRecord record; + try + { + record = allowExpired + ? MigrationRecordCodec.DecodeForRenewedConsent(bytes, binding, now) + : MigrationRecordCodec.Decode(bytes, binding, now); + } + catch (IOException exception) + { + // Decode uses only memory. BinaryReader reports malformed string lengths + // as IO errors; file IO above must still propagate as an operational failure. + throw new InvalidDataException("Invalid migration consent encoding.", exception); + } + if (record.Kind != "consent") + throw new InvalidDataException("Expected explicit migration consent."); + return record; + } + } + + private static bool Exists(string path) + { + try + { + _ = File.GetAttributes(path); + return true; + } + catch (FileNotFoundException) { return false; } + catch (DirectoryNotFoundException) { return false; } + } + + private static bool IsInvalidRecord(Exception exception) => + exception is InvalidDataException or CryptographicException or EndOfStreamException or + ArgumentException or DecoderFallbackException or FormatException; +} diff --git a/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs index 15d819b21..66520cc52 100644 --- a/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs +++ b/src/OpenClaw.Connection/Migration/MigrationOperationLock.cs @@ -45,7 +45,7 @@ private static FileStream Open(MigrationBinding binding, FileAccess access, File info.Create(security); else { - if (RequiresHardening(info, owner)) + if (RequiresDirectoryHardening(info, owner)) info.SetAccessControl(security); ScrubExistingEntries(info, owner); } @@ -60,7 +60,7 @@ private static FileStream Open(MigrationBinding binding, FileAccess access, File /// launch can lack on a roaming or administratively hardened profile. Reading is cheap and /// always permitted to the owner, so only correct a directory that actually drifted. /// - private static bool RequiresHardening(DirectoryInfo info, SecurityIdentifier owner) + internal static bool RequiresDirectoryHardening(DirectoryInfo info, SecurityIdentifier owner) { try { diff --git a/src/OpenClaw.Connection/Migration/MigrationPreparation.cs b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs index f1a6c8559..f5732419b 100644 --- a/src/OpenClaw.Connection/Migration/MigrationPreparation.cs +++ b/src/OpenClaw.Connection/Migration/MigrationPreparation.cs @@ -59,21 +59,7 @@ internal MigrationRecord PrepareUnderLock(string sourceVersion) InventoryJson = JsonSerializer.Serialize(inventory) }; var bytes = MigrationRecordCodec.Encode(record, now); - var temporaryPath = Path.Combine(directory, $".{Guid.NewGuid():N}.tmp"); - try - { - using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) - { - stream.Write(bytes); - stream.Flush(flushToDisk: true); - } - File.Move(temporaryPath, intentPath, overwrite: true); - } - finally - { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); - } + MigrationRecordStorage.WriteAtomic(intentPath, bytes); return record; } } diff --git a/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs index 9d50211a4..c1634bd19 100644 --- a/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs +++ b/src/OpenClaw.Connection/Migration/MigrationRecordCodec.cs @@ -41,10 +41,22 @@ public MigrationRecord() } } + /// + /// A migration path is unusable because of its shape, not because a record is corrupt. + /// Startup admission reports this as an inspection problem: recovery cannot repair a + /// reparse point, so offering recovery would be dead-end guidance. + /// + public sealed class MigrationPathRejectedException : IOException + { + public MigrationPathRejectedException(string message) : base(message) { } + } + public static class MigrationRecordCodec { public const string DirectoryName = "store-migration"; public const string IntentFileName = "intent.dpapi"; + public const string ConsentFileName = "consent.dpapi"; + public const string ConsentFingerprint = "0000000000000000000000000000000000000000000000000000000000000000"; public const string CompletionFileName = "completed.dpapi"; public const string PackageName = "OpenClawFoundation.OpenClaw"; public const string PackagePublisher = "CN=4BA40A7A-B719-4C40-BF91-84AF4F1136FC"; @@ -104,7 +116,7 @@ public static MigrationRecord Decode(byte[] bytes, MigrationBinding expected, Da return DecodeCore(bytes, expected, utcNow, false); } - // Only a newly confirmed preparation may renew an expired intent. + // Only a newly confirmed grant/preparation may renew expired consent/intent. #if NET10_0_OR_GREATER [System.Runtime.Versioning.SupportedOSPlatform("windows")] #endif @@ -175,6 +187,16 @@ public static MigrationRecord ReadCompletion(string path, MigrationBinding expec } public static void RejectReparsePoints(string path) + { + if (HasReparsePointAncestor(path)) + throw new InvalidDataException("Migration paths must not contain reparse points."); + } + + /// + /// Probes the same condition as without throwing, for + /// callers that must report a path shape separately from a corrupt record. + /// + public static bool HasReparsePointAncestor(string path) { var current = Path.GetFullPath(path); while (!string.IsNullOrEmpty(current)) @@ -182,12 +204,14 @@ public static void RejectReparsePoints(string path) try { if ((File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) - throw new InvalidDataException("Migration paths must not contain reparse points."); + return true; } catch (FileNotFoundException) { } catch (DirectoryNotFoundException) { } current = Path.GetDirectoryName(current); } + + return false; } private static void Validate(MigrationRecord record, MigrationBinding expected, DateTime utcNow, bool renewConsent = false) @@ -223,6 +247,15 @@ private static void Validate(MigrationRecord record, MigrationBinding expected, record.TargetVersion != "" || string.IsNullOrWhiteSpace(record.InventoryJson)) throw new InvalidDataException("Migration intent is invalid or expired. Confirm migration again."); } + else if (record.Kind == "consent") + { + // Consent authorizes later preparation, not an inventory snapshot or uninstall. + if (record.CreatedUtc > utcNow || record.ExpiresUtc != record.CreatedUtc.AddDays(30) || + (!renewConsent && utcNow >= record.ExpiresUtc) || + record.TargetVersion != "" || record.InventoryJson != "" || + record.Fingerprint != ConsentFingerprint || record.AutoStart) + throw new InvalidDataException("Migration consent is invalid or expired. Confirm migration again."); + } else if (record.Kind == "completed") { // Neither aging nor a backward clock correction may re-enable destructive uninstall. diff --git a/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs b/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs new file mode 100644 index 000000000..b20cea9df --- /dev/null +++ b/src/OpenClaw.Connection/Migration/MigrationRecordStorage.cs @@ -0,0 +1,61 @@ +using System.Runtime.Versioning; +using System.Security.AccessControl; +using System.Security.Principal; + +namespace OpenClaw.Connection.Migration; + +[SupportedOSPlatform("windows")] +internal static class MigrationRecordStorage +{ + /// + /// An existing directory is only re-hardened when it actually drifted. Rewriting the DACL needs + /// WRITE_DAC and WRITE_OWNER, which a roaming or administratively hardened profile can withhold + /// from an ordinary launch, and a refusal here is not an invalid record: it would surface as a + /// generic inspection failure and block a migration whose state was already correct. + /// + internal static void CreateProtectedDirectory(string directory) + { + MigrationRecordCodec.RejectReparsePoints(directory); + using var identity = WindowsIdentity.GetCurrent(); + var owner = identity.User ?? throw new InvalidOperationException("Cannot resolve the current Windows user."); + var security = new DirectorySecurity(); + security.SetOwner(owner); + security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false); + foreach (var sid in new[] + { + owner, + new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null), + new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null) + }) + { + security.AddAccessRule(new FileSystemAccessRule(sid, FileSystemRights.FullControl, + InheritanceFlags.ContainerInherit | InheritanceFlags.ObjectInherit, + PropagationFlags.None, AccessControlType.Allow)); + } + var info = new DirectoryInfo(directory); + if (!info.Exists) + info.Create(security); + else if (MigrationOperationLock.RequiresDirectoryHardening(info, owner)) + info.SetAccessControl(security); + } + + internal static void WriteAtomic(string path, byte[] bytes) + { + MigrationRecordCodec.RejectReparsePoints(path); + var temporaryPath = Path.Combine(Path.GetDirectoryName(path)!, $".{Guid.NewGuid():N}.tmp"); + try + { + using (var stream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + { + stream.Write(bytes); + stream.Flush(flushToDisk: true); + } + File.Move(temporaryPath, path, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } +} diff --git a/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs index 4c6c5aa1a..46d1a34c0 100644 --- a/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs +++ b/src/OpenClaw.Connection/Migration/MigrationStartupRecordReader.cs @@ -15,9 +15,16 @@ public enum MigrationStartupRecordStatus Unavailable } +/// +/// reports that a completion receipt exists on disk even when +/// it could not be decoded. Data has already moved in that case, so admission must still protect +/// the handoff; alone cannot distinguish a +/// corrupt receipt from a corrupt intent. +/// public sealed record MigrationStartupRecord( MigrationStartupRecordStatus Status, - MigrationRecord? Record = null); + MigrationRecord? Record = null, + bool CompletionPresent = false); public interface IMigrationStartupRecordReader { @@ -40,30 +47,75 @@ public MigrationStartupRecord Read() { var completed = ReadFile(MigrationRecordCodec.CompletionFileName, "completed"); if (completed is not null) - return new(MigrationStartupRecordStatus.Completed, completed); + return new(MigrationStartupRecordStatus.Completed, completed, true); var intent = ReadFile(MigrationRecordCodec.IntentFileName, "intent"); return intent is null ? new(MigrationStartupRecordStatus.None) : new(MigrationStartupRecordStatus.Intent, intent); } + catch (MigrationPathRejectedException ex) + { + // A reparse point above the record directory is an environment shape, not a corrupt + // record. Recovery cannot repair it, so report it as an inspection problem instead. + // The receipt is still probed: refusing to open the path is about not reading or + // deleting through a redirection, and "the path is unsafe" is not evidence that + // data has not already moved. + logger.Error($"Store migration record path is unusable ({ex.Message})."); + return new(MigrationStartupRecordStatus.Unavailable, null, CompletionFileExists()); + } catch (Exception ex) when (ex is InvalidDataException or CryptographicException or - EndOfStreamException or ArgumentException or DecoderFallbackException) + EndOfStreamException or ArgumentException or FormatException or + DecoderFallbackException) { logger.Warn($"Store migration record requires recovery ({ex.GetType().Name})."); - return new(MigrationStartupRecordStatus.Invalid); + return new(MigrationStartupRecordStatus.Invalid, null, CompletionFileExists()); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) { logger.Error($"Store migration record inspection failed ({ex.GetType().Name})."); - return new(MigrationStartupRecordStatus.Unavailable); + return new(MigrationStartupRecordStatus.Unavailable, null, CompletionFileExists()); } } + /// + /// Existence only. A receipt that cannot be decoded still proves the handoff moved data, so + /// this answers "is the receipt definitely absent?" and treats every other outcome as + /// present. It runs from catch handlers and must never throw. + /// + /// is deliberately not used: it reports false for + /// an inaccessible path, an invalid path, and a directory of the same name, so it cannot + /// distinguish absence from failure and would fail open on exactly the paths that matter. + /// Only the not-found results prove nothing moved. + /// + /// + private bool CompletionFileExists() + { + try + { + _ = File.GetAttributes(RecordPath(MigrationRecordCodec.CompletionFileName)); + return true; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + logger.Info($"No migration receipt is present ({ex.GetType().Name})."); + return false; + } + catch (Exception ex) + { + logger.Warn($"Could not confirm the migration receipt ({ex.GetType().Name}); assuming it exists."); + return true; + } + } + + private string RecordPath(string fileName) => + Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, fileName); + private MigrationRecord? ReadFile(string fileName, string expectedKind) { - var path = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName, fileName); - MigrationRecordCodec.RejectReparsePoints(path); + var path = RecordPath(fileName); + if (MigrationRecordCodec.HasReparsePointAncestor(path)) + throw new MigrationPathRejectedException("Migration paths must not contain reparse points."); FileStream stream; try { @@ -77,9 +129,24 @@ public MigrationStartupRecord Read() if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) throw new InvalidDataException("Invalid migration startup record size."); using var reader = new BinaryReader(stream); - var record = MigrationRecordCodec.DecodeForRenewedConsent( - reader.ReadBytes((int)stream.Length), binding, - (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime); + var bytes = reader.ReadBytes((int)stream.Length); + MigrationRecord record; + try + { + record = MigrationRecordCodec.DecodeForRenewedConsent( + bytes, binding, (timeProvider ?? TimeProvider.System).GetUtcNow().UtcDateTime); + } + // Defensive only: nothing in the decode raises this today, but the type derives from + // IOException and would otherwise be silently reclassified as a corrupt record. + catch (MigrationPathRejectedException) { throw; } + catch (IOException exception) + { + // Decode uses only memory, so this is corruption rather than an IO failure. + // BinaryReader reports a malformed string length that way, and reporting it as an + // inspection failure would route the record to InspectionFailed instead of + // Recovery, which is the only screen that offers a way out. + throw new InvalidDataException("Invalid migration startup record encoding.", exception); + } if (record.Kind != expectedKind) throw new InvalidDataException("Unexpected migration startup record kind."); return record; diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs index 851d6fee0..9d16e184f 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationAdoptionPreparationCoordinator.cs @@ -1,5 +1,6 @@ using OpenClaw.Shared; using System.Runtime.Versioning; +using System.Security.Cryptography; namespace OpenClaw.Connection.Migration; @@ -67,7 +68,9 @@ public StoreMigrationPreparationDecision Prepare(InnoInstallation expected) logger.Info("Store migration preparation is blocked by an active runtime or migration operation."); return new(StoreMigrationPreparationState.InnoRunning); } - catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException) + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + InvalidDataException or InvalidOperationException or + FormatException or CryptographicException) { logger.Error($"Store migration preparation failed: {exception.Message}"); return new(StoreMigrationPreparationState.ValidationFailed); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs index f7a931a56..2eec2fc23 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationCompletionCoordinator.cs @@ -9,8 +9,6 @@ public enum StoreMigrationCompletionState InnoRunning, SourceChanged, IntentUnavailable, - NoActiveGateway, - CredentialUnavailable, ValidationFailed, Completed } @@ -21,15 +19,15 @@ public sealed record StoreMigrationCompletionDecision( /// /// Revalidates prepared source state under exclusive ownership and records completion. -/// It only proves a canonical active-gateway credential can resolve. It never starts, -/// repairs, or provisions gateway, node, or MCP services. +/// It never inspects gateway credentials: migration leaves gateway state in place, and +/// inventory capture has already rejected damaged identities before completion is reached. +/// It never starts, repairs, or provisions gateway, node, or MCP services. /// [SupportedOSPlatform("windows")] public sealed class StoreMigrationCompletionCoordinator( IMigrationSourceLeaseProvider leaseProvider, IInnoInstallationDetector detector, MigrationBinding binding, - ICredentialResolver credentialResolver, IOpenClawLogger logger, TimeProvider? timeProvider = null, IInnoSourceActivityVerifier? sourceActivity = null) @@ -75,16 +73,6 @@ public StoreMigrationCompletionDecision Complete(InnoInstallation expected, stri { return new(StoreMigrationCompletionState.SourceChanged); } - var registry = new GatewayRegistry(binding.RoamingDirectory, logger: logger); - registry.Load(); - var active = registry.GetActive(); - if (active is null) - return new(StoreMigrationCompletionState.NoActiveGateway); - - var resolution = credentialResolver.ResolveOperatorDetailed( - active, registry.GetIdentityDirectory(active.Id)); - if (resolution.Credential is null) - return new(StoreMigrationCompletionState.CredentialUnavailable); var receipt = new MigrationRecord { @@ -109,7 +97,7 @@ public StoreMigrationCompletionDecision Complete(InnoInstallation expected, stri } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException or InvalidOperationException or - CryptographicException) + FormatException or CryptographicException) { logger.Error($"Store migration completion failed: {exception.Message}"); return new(StoreMigrationCompletionState.ValidationFailed); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs index 5d9e1a9b8..ba896cbcc 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs @@ -3,6 +3,7 @@ using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security; +using System.Security.Cryptography; using OpenClaw.Shared; namespace OpenClaw.Connection.Migration; @@ -311,6 +312,7 @@ public MigrationInventory Capture() => /// /// Reparse-safe final cleanup that keeps the completion receipt until all earlier cleanup succeeds. +/// The caller must hold prepare.lock while consent, intent, then completion are removed. /// [SupportedOSPlatform("windows")] public sealed class MigrationFinalizationRecordCleaner(MigrationBinding binding) : IStoreMigrationRecordCleaner @@ -319,10 +321,14 @@ public void ClearCompleted(MigrationRecord receipt) { ArgumentNullException.ThrowIfNull(receipt); var directory = Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + var consentLockPath = Path.Combine(directory, InnoMigrationConsentStore.WriterLockFileName); + var consentPath = Path.Combine(directory, MigrationRecordCodec.ConsentFileName); var intentPath = Path.Combine(directory, MigrationRecordCodec.IntentFileName); var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); MigrationRecordCodec.RejectReparsePoints(directory); + MigrationRecordCodec.RejectReparsePoints(consentLockPath); + MigrationRecordCodec.RejectReparsePoints(consentPath); MigrationRecordCodec.RejectReparsePoints(intentPath); MigrationRecordCodec.RejectReparsePoints(completionPath); var durable = MigrationRecordCodec.ReadCompletion( @@ -331,6 +337,8 @@ public void ClearCompleted(MigrationRecord receipt) throw new InvalidDataException("Completion receipt changed before cleanup."); // The receipt is the recovery anchor, so it is always deleted last. + File.Delete(consentLockPath); + File.Delete(consentPath); File.Delete(intentPath); File.Delete(completionPath); } @@ -436,7 +444,8 @@ public async Task FinalizeAsync() cleaner.ClearCompleted(durable.Record); } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or - InvalidDataException) + InvalidDataException or FormatException or + CryptographicException) { logger.Error($"Store migration record cleanup failed: {exception.Message}"); return new(StoreMigrationFinalizationState.RecordCleanupFailed); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs new file mode 100644 index 000000000..c0f85ebfa --- /dev/null +++ b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs @@ -0,0 +1,205 @@ +using System.Runtime.Versioning; +using System.Security; +using System.Security.Cryptography; +using System.Text; +using OpenClaw.Shared; + +namespace OpenClaw.Connection.Migration; + +public enum StoreMigrationDiscardState +{ + Discarded, + RecordsAreReadable, + Busy, + Failed +} + +/// +/// The escape from when the records +/// cannot carry the migration any further. Without it a receipt that stops decoding, after a DPAPI +/// key loss or a profile move, blocks every launch with no in-app way back. +/// +/// Records are classified one file at a time rather than through the aggregate startup status, +/// which stops at the first unreadable file. A corrupt receipt beside a perfectly good intent must +/// only cost the receipt. +/// +/// +/// Two things are discardable: a record that does not decode, and any record left behind once the +/// source app is gone and no readable receipt exists, because there is then nothing left to +/// migrate from and the record can never be acted on again. A receipt that decodes is never +/// discarded; it is the proof that data moved. +/// +/// +/// Discarding does not expose user data. While the Store package stays registered, +/// scripts/Test-InnoMigration.ps1 answers a missing receipt with exit 11 and an undecodable +/// one with exit 2, and both preserve generated state and the gateway. +/// +/// +[SupportedOSPlatform("windows")] +public sealed class StoreMigrationRecoveryDiscard( + MigrationBinding binding, + IInnoInstallationDetector detector, + IOpenClawLogger logger) +{ + private enum RecordState + { + Missing, + Readable, + Unreadable + } + + /// + /// Best-effort and unlocked, for deciding whether to offer the action at all. It must not + /// throw: recovery is already on screen when this runs. re-plans under + /// the lock, so an offer made here is never trusted as authority to delete. + /// + public bool CanDiscard() + { + try + { + return Plan().Count > 0; + } + catch (Exception exception) + { + logger.Warn($"Could not classify the migration records ({exception.GetType().Name})."); + return false; + } + } + + public StoreMigrationDiscardState Discard() + { + var directory = Directory; + try + { + RejectPath(directory); + var lockPath = Path.Combine(directory, "prepare.lock"); + RejectPath(lockPath); + // Exclusive: uninstall reads these same records to decide whether to preserve data. + // OpenOrCreate, because a missing lease file is not evidence that there is nothing to + // discard. Opening it with Open would report success having deleted nothing, which is + // the same dead end this class exists to remove. + using var discardLock = new FileStream( + lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None); + + var targets = Plan(); + if (targets.Count == 0) + return StoreMigrationDiscardState.RecordsAreReadable; + + // The consent writer lock is residue rather than a record, but it is only meaningful + // alongside consent, and no grant can be in flight while this lease is held. + targets.Insert(0, Path.Combine(directory, InnoMigrationConsentStore.WriterLockFileName)); + foreach (var path in targets) + { + RejectPath(path); + File.Delete(path); + } + + logger.Info($"Discarded {targets.Count} store migration record file(s)."); + return StoreMigrationDiscardState.Discarded; + } + catch (DirectoryNotFoundException exception) + { + // Nothing is left to discard, which is the outcome the user asked for. + logger.Info($"No store migration records to discard ({exception.GetType().Name})."); + return StoreMigrationDiscardState.Discarded; + } + catch (MigrationPathRejectedException exception) + { + // A rejected path shape is not lock contention. Telling the user to close the previous + // app would send them after something that can never help. + logger.Error($"Store migration record paths were rejected: {exception.Message}"); + return StoreMigrationDiscardState.Failed; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.Warn($"Store migration records are in use ({exception.GetType().Name})."); + return StoreMigrationDiscardState.Busy; + } + catch (Exception exception) + { + logger.Error($"Could not discard store migration records: {exception}"); + return StoreMigrationDiscardState.Failed; + } + } + + private string Directory => Path.Combine(binding.RoamingDirectory, MigrationRecordCodec.DirectoryName); + + /// + /// A rejected path shape is an operational refusal, never evidence about a record's contents. + /// It is raised as so it stays distinguishable + /// from lock contention all the way out to the caller. + /// + private static void RejectPath(string path) + { + if (MigrationRecordCodec.HasReparsePointAncestor(path)) + throw new MigrationPathRejectedException($"Migration path {path} contains a reparse point."); + } + + /// + /// The receipt is planned last so an interrupted discard still looks like a handoff that needs + /// recovery rather than one that never happened. Operational failures propagate: a record that + /// merely could not be opened is not evidence that it is unreadable. + /// + private List Plan() + { + var directory = Directory; + var completionPath = Path.Combine(directory, MigrationRecordCodec.CompletionFileName); + var completion = Classify(completionPath, "completed"); + if (completion == RecordState.Readable) + return []; + + var sourceGone = detector.Detect().Status == InnoInstallationStatus.NotInstalled; + var targets = new List(); + Consider(Path.Combine(directory, MigrationRecordCodec.ConsentFileName), "consent"); + Consider(Path.Combine(directory, MigrationRecordCodec.IntentFileName), "intent"); + if (completion == RecordState.Unreadable) + targets.Add(completionPath); + return targets; + + void Consider(string path, string kind) + { + var state = Classify(path, kind); + if (state == RecordState.Unreadable || (sourceGone && state == RecordState.Readable)) + targets.Add(path); + } + } + + private RecordState Classify(string path, string expectedKind) + { + byte[] bytes; + try + { + // Mirrors the reader: a path shape is an operational refusal, not a corrupt record. + RejectPath(path); + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length == 0 || stream.Length > MigrationRecordCodec.MaximumRecordBytes) + return RecordState.Unreadable; + using var reader = new BinaryReader(stream); + bytes = reader.ReadBytes((int)stream.Length); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return RecordState.Missing; + } + + // Decoding is pure memory work, so it is judged separately. Everything outside the + // allowlist propagates: a record that merely could not be read is not evidence of + // corruption, and only corruption may nominate a file for deletion. IOException is part + // of the allowlist here precisely because there is no file IO left in this block: + // BinaryReader reports a malformed string length that way, and treating it as an + // operational failure would hide the discard offer and restore the lockout. + try + { + var record = MigrationRecordCodec.DecodeForRenewedConsent(bytes, binding, DateTime.UtcNow); + return record.Kind == expectedKind ? RecordState.Readable : RecordState.Unreadable; + } + catch (Exception exception) when (exception is InvalidDataException or CryptographicException + or EndOfStreamException or IOException + or ArgumentException or FormatException + or DecoderFallbackException) + { + logger.Warn($"Migration record {expectedKind} does not decode ({exception.GetType().Name})."); + return RecordState.Unreadable; + } + } +} diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs index 43ecdf955..624740ece 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs @@ -17,15 +17,33 @@ public enum StoreMigrationStartupState public sealed record StoreMigrationStartupDecision( StoreMigrationStartupState State, - InnoInstallation? Installation = null) + InnoInstallation? Installation = null, + bool HoldsCompletionReceipt = false) { public bool AllowsNormalStartup => State is StoreMigrationStartupState.Disabled or StoreMigrationStartupState.NotRequired; + + /// + /// Only a handoff that has already moved data may refuse launch. Every other state informs + /// the user and then gets out of the way: refusing to start cannot repair an unsupported + /// installation, a failed inspection, or a corrupt record, and the records stay on disk + /// either way. Blocking there would only deny the user the app. + /// + /// This is driven by receipt presence rather than by the state name. A receipt can coexist + /// with a state that reads as informational (an unsupported source, a version mismatch, an + /// undecodable record), and starting normally in those cases would leave both installations + /// live against the same data. + /// + /// + public bool BlocksStartup => + HoldsCompletionReceipt || + State is StoreMigrationStartupState.FinalizationRequired or + StoreMigrationStartupState.AwaitingInnoRemoval; } /// /// Read-only admission before instance forwarding or normal app services. -/// ConsentRequired is a handoff to a future consent workflow, not permission to migrate. +/// ConsentRequired is a handoff to the consent workflow, not permission to migrate. /// public sealed class StoreMigrationStartupCoordinator( IInnoInstallationDetector detector, @@ -37,24 +55,37 @@ public StoreMigrationStartupDecision Evaluate(bool enabled, string? minimumSourc if (!enabled) return new(StoreMigrationStartupState.Disabled); + // Read before the policy check so a malformed version or architecture policy cannot + // return a non-blocking decision while a receipt sits on disk. + var pending = records.Read(); + // Captured once so every downstream state carries it. A receipt outranks the state name. + // Status is checked alongside the flag so a caller that reports Completed without it + // still cannot produce a non-blocking decision. + var receipt = pending.CompletionPresent || pending.Status == MigrationStartupRecordStatus.Completed; + if (!MigrationVersionPolicy.TryParseReleaseVersion(minimumSourceVersion, out var minimum) || architecture is not ("x64" or "arm64")) { - logger.Error("Store migration preview has no valid source-version or architecture policy."); - return new(StoreMigrationStartupState.InspectionFailed); + logger.Error("Store migration has no valid source-version or architecture policy."); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); } - var pending = records.Read(); if (pending.Status == MigrationStartupRecordStatus.Unavailable) - return Decide(StoreMigrationStartupState.InspectionFailed); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); if (pending.Status == MigrationStartupRecordStatus.Invalid) - return Decide(StoreMigrationStartupState.RecoveryRequired); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt); var detected = detector.Detect(); if (detected.Status == InnoInstallationStatus.InspectionFailed) - return Decide(StoreMigrationStartupState.InspectionFailed); + return Decide(StoreMigrationStartupState.InspectionFailed, receipt); if (detected.Status == InnoInstallationStatus.Unsupported) - return Decide(StoreMigrationStartupState.UnsupportedInstallation); + { + // An installation that predates the migration payload is not unsupportable; it just + // needs the update that ships migration. Asking for that is actionable guidance. + return Decide(detected.RegisteredVersion is { } registered && registered < minimum + ? StoreMigrationStartupState.UpdateInno + : StoreMigrationStartupState.UnsupportedInstallation, receipt); + } if (detected.Status == InnoInstallationStatus.NotInstalled) { @@ -63,34 +94,41 @@ architecture is not ("x64" or "arm64")) MigrationStartupRecordStatus.None => StoreMigrationStartupState.NotRequired, MigrationStartupRecordStatus.Completed => StoreMigrationStartupState.FinalizationRequired, _ => StoreMigrationStartupState.RecoveryRequired - }); + }, receipt); } var installation = detected.Installation ?? throw new InvalidOperationException("Detected Inno installation has no installation evidence."); if (installation.Architecture != architecture) - return Decide(StoreMigrationStartupState.UnsupportedInstallation, installation); + return Decide(StoreMigrationStartupState.UnsupportedInstallation, receipt, installation); if (pending.Status == MigrationStartupRecordStatus.Completed) { - var completed = pending.Record - ?? throw new InvalidOperationException("Completed migration has no receipt."); + // Status without a decoded record should be unreachable, but throwing here would + // escape into the UI error boundary and be reported as a plain inspection failure, + // dropping the very receipt this branch proves exists. Recover instead. + if (pending.Record is not { } completed) + { + logger.Error("Completed migration has no decoded receipt; recovery is required."); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt, installation); + } if (!MigrationVersionPolicy.TryParseReleaseVersion(completed.SourceVersion, out var sourceVersion) || sourceVersion != installation.Version) - return Decide(StoreMigrationStartupState.RecoveryRequired, installation); - return Decide(StoreMigrationStartupState.AwaitingInnoRemoval, installation); + return Decide(StoreMigrationStartupState.RecoveryRequired, receipt, installation); + return Decide(StoreMigrationStartupState.AwaitingInnoRemoval, receipt, installation); } if (installation.Version < minimum) - return Decide(StoreMigrationStartupState.UpdateInno, installation); + return Decide(StoreMigrationStartupState.UpdateInno, receipt, installation); // Even a valid, unexpired Inno intent does not replace Store-side consent. - return Decide(StoreMigrationStartupState.ConsentRequired, installation); + return Decide(StoreMigrationStartupState.ConsentRequired, receipt, installation); } - private StoreMigrationStartupDecision Decide(StoreMigrationStartupState state, InnoInstallation? installation = null) + private StoreMigrationStartupDecision Decide( + StoreMigrationStartupState state, bool holdsCompletionReceipt, InnoInstallation? installation = null) { - logger.Info($"Store migration startup admission: {state}."); - return new(state, installation); + logger.Info($"Store migration startup admission: {state} (receipt: {holdsCompletionReceipt})."); + return new(state, installation, holdsCompletionReceipt); } } From e837a39f1d68afe7fdc2b48699e428343c83a29d Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 25 Sep 2026 11:57:28 -0700 Subject: [PATCH 02/16] feat(migration): localize the Store migration experience Adds the migration window, Settings card, and consent copy to all six locales, including the discard and failure states used by recovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52 --- .../Strings/en-us/Resources.resw | 142 +++++++++++++++- .../Strings/fr-fr/Resources.resw | 158 ++++++++++++++++-- .../Strings/nl-nl/Resources.resw | 158 ++++++++++++++++-- .../Strings/pt-br/Resources.resw | 144 +++++++++++++++- .../Strings/zh-cn/Resources.resw | 144 +++++++++++++++- .../Strings/zh-tw/Resources.resw | 144 +++++++++++++++- 6 files changed, 847 insertions(+), 43 deletions(-) diff --git a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw index bde45f74b..a1fd731c2 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/en-us/Resources.resw @@ -4997,7 +4997,7 @@ Commands are blocked while sandboxing is unavailable because strict fallback blo MSIX installation detected - Removing OpenClaw from Windows Settings &#x2192; Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw. + Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw. Removes the WSL distro (OpenClawGateway), its disk image, autostart entry, and clears gateway credentials. Your MCP token is preserved. Onboarding will reset. @@ -7232,9 +7232,6 @@ Choose {1} to exit this preview without starting migration and continue using th OpenClaw could not safely validate the previous setup for migration. No completion was recorded and the previous app was not changed. Close this preview and try again later. Do not uninstall the previous app, delete migration data, or run gateway cleanup. - - OpenClaw could not resolve a credential for the active gateway. No completion was recorded and the previous app was not changed. Close this preview and restore the active gateway configuration before retrying. Do not uninstall the previous app, delete migration data, or run gateway cleanup. - OpenClaw recorded migration completion and preserved your setup. Close this preview, uninstall the previous OpenClaw Companion app through Windows Settings, then open the Store version. Do not delete migration data or run gateway cleanup. @@ -7262,6 +7259,143 @@ Choose {1} to exit this preview without starting migration and continue using th A previous migration needs recovery or finalization. This test build cannot continue it or start normally. Close this preview. Do not uninstall the previous app, delete migration data, or run gateway cleanup. + + Move to the Store version + + + Keep your settings, gateway, files, and models where they are. + + + We'll prepare your setup + + + We'll safely close the previous app, check your installation and settings, and save protected migration records. + + + You'll remove the previous app + + + When prompted, uninstall it manually. Nothing is uninstalled automatically. + + + Then use the Store version + + + We verify removal before starting the Store version. + + + Choose Not now to leave without starting migration. + + + Before you continue + + + If validation succeeds, the previous app can no longer start normally. + + + Asking the previous app to close safely... + + + Validating your existing setup... + + + Recording migration completion... + + + Checking removal and finishing migration... + + + Close OpenClaw Companion from its tray menu, then choose Retry. + + + Close the previous app + + + Don't uninstall yet + + + Don't force-close or uninstall it yet. We'll tell you when it's safe to uninstall. + + + Your setup has been validated and migration completion recorded. + +1. Open Installed apps and uninstall only the previous OpenClaw Companion app. Keep the Store version. + +2. Return here and choose Retry to verify removal and finish. + +Your settings, gateway, files, and models stay in place. + + + Remove the previous app + + + Keep your setup + + + Don't delete migration data or run gateway cleanup. + + + OpenClaw could not safely complete migration. Do not uninstall the previous app or run gateway cleanup. Check the app log, resolve the problem, and retry. + + + Update the previous app to a supported migration-capable version first. Your setup has not been changed. + + + This installation cannot be migrated safely. Only the default installation for the same Windows user and matching architecture is supported. Your setup has not been changed. + + + OpenClaw could not safely inspect the installation or migration records. Check the app log before retrying. Do not delete migration data or run gateway cleanup. + + + This migration needs recovery. Check the app log. If the previous app is still installed, remove it from Installed apps and select Retry. Do not delete migration data by hand or run gateway cleanup. + + + OpenClaw could not finish migration. The completion receipt was kept for recovery. Check the app log and retry. Do not delete migration data or run gateway cleanup. + + + Open Installed apps + + + Discard unreadable migration data + + + OpenClaw could not discard the migration data. If the previous OpenClaw app is running, close it and try again. Check the app log for details. + + + Windows could not open the requested page. Try again, or open Windows Settings > Apps > Installed apps manually if migration is complete. + + + Recommended + + + Move to the Microsoft Store version + + + Get future OpenClaw updates through Microsoft Store. Your existing OpenClaw setup will be preserved. + + + Install Store version and migrate + + + Move to the Store version? + + + Microsoft Store opens so you can install the Store version. When you open it, it can close this app and finish the move without asking again. + +Your settings, gateway, files, and models stay where they are. + + + Migration consent was saved. Install and open the Store version to continue. Keep this app installed until migration completion is confirmed. + + + OpenClaw could not save migration consent or open the Store listing. Check the app log and retry. Do not uninstall this app. + + + Close + + + Please wait for this step to finish. + Copy code Code block Current model diff --git a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw index d563c5136..0c4e5b51c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/fr-fr/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4289,7 +4289,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Port local - Enregistrer &amp; connecter + Enregistrer & connecter Annuler @@ -4550,7 +4550,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Confidentialité Windows - Accès caméra, microphone &amp; écran + Accès caméra, microphone & écran Les autorisations de confidentialité au niveau système sont gérées par Windows. Ouvrez les Paramètres de confidentialité Windows pour accorder ou révoquer l'accès à OpenClaw. @@ -4628,7 +4628,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Téléchargements @@ -4640,7 +4640,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Bureau @@ -4652,7 +4652,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Les répertoires d'outils de votre PATH bénéficient également d'un accès en lecture seule dans le bac à sable, afin que les outils en ligne de commande comme git, node, python et dotnet soient utilisables. Les racines de lecteur ne sont jamais accordées. @@ -4670,7 +4670,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Lecture seule - Lecture &amp; écriture + Lecture & écriture Aucun dossier personnalisé ajouté. @@ -4694,7 +4694,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Écriture: l'agent peut remplacer votre presse-papiers (sans pouvoir le lire) - Lecture &amp; écriture + Lecture & écriture 📡 Réseau @@ -4992,7 +4992,7 @@ Les commandes sont bloquées tant que le sandboxing est indisponible, car le blo Installation MSIX détectée - La suppression d'OpenClaw via Paramètres Windows &amp;#x2192; Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw. + La suppression d'OpenClaw via Paramètres Windows → Applications ne nettoiera PAS la passerelle locale (distribution WSL, image disque). Utilisez d'abord Supprimer la passerelle locale ci-dessous, puis désinstallez OpenClaw. Supprime la distribution WSL (OpenClawGateway), son image disque, l'entrée de démarrage automatique et efface les identifiants de la passerelle. Votre jeton MCP est conservé. L'intégration sera réinitialisée. @@ -7168,9 +7168,6 @@ Choisissez {1} pour quitter cet aperçu sans démarrer la migration et continuer OpenClaw n'a pas pu valider en toute sécurité la configuration précédente pour la migration. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et réessayez plus tard. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. - - OpenClaw n'a pas pu résoudre un identifiant pour la passerelle active. Aucune finalisation n'a été enregistrée et l'application précédente n'a pas été modifiée. Fermez cet aperçu et restaurez la configuration de la passerelle active avant de réessayer. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. - OpenClaw a enregistré la fin de la migration et préservé votre configuration. Fermez cet aperçu, désinstallez l'application OpenClaw Companion précédente dans les paramètres Windows, puis ouvrez la version du Store. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. @@ -7198,6 +7195,143 @@ Choisissez {1} pour quitter cet aperçu sans démarrer la migration et continuer Une migration précédente nécessite une récupération ou une finalisation. Cette version de test ne peut ni la poursuivre ni démarrer normalement. Fermez cet aperçu. Ne désinstallez pas l'application précédente, ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + Passer à la version du Store + + + Gardez vos paramètres, votre passerelle, vos fichiers et vos modèles en place. + + + Nous préparerons votre configuration + + + Nous fermerons l'application précédente en toute sécurité, vérifierons votre installation et vos paramètres, puis enregistrerons des données de migration protégées. + + + Vous désinstallerez l'application précédente + + + Lorsque vous y serez invité, désinstallez-la manuellement. Rien ne sera désinstallé automatiquement. + + + Utilisez ensuite la version du Store + + + Nous vérifions la désinstallation avant de démarrer la version du Store. + + + Choisissez Pas maintenant pour quitter sans démarrer la migration. + + + Avant de continuer + + + Si la validation réussit, l'application précédente ne pourra plus démarrer normalement. + + + Demande de fermeture sécurisée de l'application précédente... + + + Validation de votre configuration existante... + + + Enregistrement de la fin de la migration... + + + Vérification de la désinstallation et finalisation de la migration... + + + Fermez OpenClaw Companion depuis son menu dans la zone de notification, puis choisissez Réessayer. + + + Fermez l'application précédente + + + Ne désinstallez pas encore + + + Ne forcez pas sa fermeture et ne la désinstallez pas encore. Nous vous indiquerons quand vous pourrez la désinstaller en toute sécurité. + + + Votre configuration a été validée et la fin de la migration enregistrée. + +1. Ouvrez Applications installées et désinstallez uniquement l'application OpenClaw Companion précédente. Gardez la version du Store. + +2. Revenez ici et choisissez Réessayer pour vérifier la désinstallation et terminer. + +Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place. + + + Désinstallez l'application précédente + + + Conservez votre configuration + + + Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu terminer la migration en toute sécurité. Ne désinstallez pas l'application précédente et ne lancez pas le nettoyage de la passerelle. Consultez le journal de l'application, résolvez le problème et réessayez. + + + Mettez d'abord l'application précédente à jour vers une version compatible prenant en charge la migration. Votre configuration n'a pas été modifiée. + + + Cette installation ne peut pas être migrée en toute sécurité. Seule l'installation par défaut pour le même utilisateur Windows et avec une architecture correspondante est prise en charge. Votre configuration n'a pas été modifiée. + + + OpenClaw n'a pas pu vérifier en toute sécurité l'installation ou les enregistrements de migration. Consultez le journal de l'application avant de réessayer. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Cette migration nécessite une récupération. Consultez le journal de l'application. Si l'application précédente est toujours installée, supprimez-la dans Applications installées, puis sélectionnez Réessayer. Ne supprimez pas les données de migration manuellement et ne lancez pas le nettoyage de la passerelle. + + + OpenClaw n'a pas pu terminer la migration. Le reçu de fin de migration a été conservé pour permettre une récupération. Consultez le journal de l'application et réessayez. Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle. + + + Ouvrir Applications installées + + + Supprimer les données de migration illisibles + + + OpenClaw n'a pas pu supprimer les données de migration. Si l'application OpenClaw précédente est en cours d'exécution, fermez-la et réessayez. Consultez le journal de l'application pour plus de détails. + + + Windows n'a pas pu ouvrir la page demandée. Réessayez ou ouvrez manuellement Paramètres Windows > Applications > Applications installées si la migration est terminée. + + + Recommandé + + + Passer à la version du Microsoft Store + + + Recevez les prochaines mises à jour d'OpenClaw via le Microsoft Store. Votre configuration OpenClaw existante sera conservée. + + + Installer la version du Store et migrer + + + Passer à la version du Store ? + + + Le Microsoft Store s'ouvre pour vous permettre d'installer la version du Store. Lorsque vous l'ouvrirez, elle pourra fermer cette application et terminer la migration sans redemander votre accord. + +Vos paramètres, votre passerelle, vos fichiers et vos modèles restent en place. + + + Le consentement à la migration a été enregistré. Installez et ouvrez la version du Store pour continuer. Gardez cette application installée jusqu'à la confirmation de la fin de la migration. + + + OpenClaw n'a pas pu enregistrer le consentement à la migration ou ouvrir la fiche du Store. Consultez le journal de l'application et réessayez. Ne désinstallez pas cette application. + + + Fermer + + + Veuillez patienter jusqu'à la fin de cette étape. + Copier le code Bloc de code Modèle actuel diff --git a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw index c25d699d3..1f73d26da 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/nl-nl/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4290,7 +4290,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Lokale poort - Opslaan &amp; verbinden + Opslaan & verbinden Annuleren @@ -4551,7 +4551,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Windows-privacy - Toegang tot camera, microfoon &amp; scherm + Toegang tot camera, microfoon & scherm Privacymachtigingen op systeemniveau worden beheerd door Windows. Open Windows-privacyinstellingen om OpenClaw toegang te verlenen of in te trekken. @@ -4629,7 +4629,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Downloadmap @@ -4641,7 +4641,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Bureaublad @@ -4653,7 +4653,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Mapdirectory's op uw PATH krijgen ook alleen-lezen toegang binnen de sandbox, zodat opdrachtregelhulpprogramma's zoals git, node, python en dotnet bruikbaar zijn. Stationswortels worden nooit toegekend. @@ -4671,7 +4671,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Alleen-lezen - Lezen &amp; schrijven + Lezen & schrijven Geen aangepaste mappen toegevoegd. @@ -4695,7 +4695,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik Schrijven: agent kan uw klembord vervangen (kan het niet lezen) - Lezen &amp; schrijven + Lezen & schrijven 📡 Netwerk @@ -4993,7 +4993,7 @@ Opdrachten worden geblokkeerd zolang sandboxing niet beschikbaar is, omdat strik MSIX-installatie gedetecteerd - OpenClaw verwijderen via Windows Instellingen &amp;#x2192; Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw. + OpenClaw verwijderen via Windows Instellingen → Apps zal de lokale gateway NIET opruimen (WSL-distributie, schijfafbeelding). Gebruik EERST Lokale gateway verwijderen hieronder, en verwijder daarna OpenClaw. Verwijdert de WSL-distributie (OpenClawGateway), de schijfafbeelding, het item voor automatisch starten en wist de gateway-referenties. Uw MCP-token blijft behouden. Onboarding wordt gereset. @@ -7169,9 +7169,6 @@ Kies {1} om dit voorbeeld te sluiten zonder de migratie te starten en de vorige OpenClaw kon de vorige configuratie niet veilig valideren voor migratie. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en probeer het later opnieuw. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. - - OpenClaw kon geen referentie voor de actieve gateway oplossen. Er is geen voltooiing vastgelegd en de vorige app is niet gewijzigd. Sluit dit voorbeeld en herstel de configuratie van de actieve gateway voordat u het opnieuw probeert. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. - OpenClaw heeft de voltooiing van de migratie vastgelegd en uw configuratie behouden. Sluit dit voorbeeld, verwijder de vorige OpenClaw Companion-app via Windows-instellingen en open daarna de Store-versie. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. @@ -7199,6 +7196,143 @@ Kies {1} om dit voorbeeld te sluiten zonder de migratie te starten en de vorige Een vorige migratie moet worden hersteld of afgerond. Deze testversie kan die migratie niet voortzetten of normaal starten. Sluit dit voorbeeld. Verwijder de vorige app of migratiegegevens niet en voer geen gatewayopruiming uit. + + Overstappen naar de Store-versie + + + Behoud uw instellingen, gateway, bestanden en modellen op hun huidige plek. + + + We bereiden uw configuratie voor + + + We sluiten de vorige app veilig af, controleren uw installatie en instellingen en slaan beveiligde migratiegegevens op. + + + U verwijdert de vorige app + + + Verwijder de app handmatig wanneer daarom wordt gevraagd. Er wordt niets automatisch verwijderd. + + + Gebruik daarna de Store-versie + + + We verifiëren de verwijdering voordat we de Store-versie starten. + + + Kies Nu niet om af te sluiten zonder de migratie te starten. + + + Voordat u doorgaat + + + Als de validatie slaagt, kan de vorige app niet meer normaal starten. + + + De vorige app wordt gevraagd veilig af te sluiten... + + + Uw bestaande configuratie wordt gevalideerd... + + + De voltooiing van de migratie wordt vastgelegd... + + + De verwijdering wordt gecontroleerd en de migratie wordt afgerond... + + + Sluit OpenClaw Companion via het menu in het systeemvak en kies daarna Opnieuw proberen. + + + Sluit de vorige app + + + Verwijder de app nog niet + + + Forceer het afsluiten niet en verwijder de app nog niet. We laten u weten wanneer u de app veilig kunt verwijderen. + + + Uw configuratie is gevalideerd en de voltooiing van de migratie is vastgelegd. + +1. Open Geïnstalleerde apps en verwijder alleen de vorige OpenClaw Companion-app. Behoud de Store-versie. + +2. Keer hier terug en kies Opnieuw proberen om de verwijdering te verifiëren en af te ronden. + +Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. + + + Verwijder de vorige app + + + Behoud uw configuratie + + + Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + OpenClaw kon de migratie niet veilig voltooien. Verwijder de vorige app niet en voer geen gatewayopruiming uit. Bekijk het applogboek, los het probleem op en probeer het opnieuw. + + + Werk de vorige app eerst bij naar een ondersteunde versie met migratieondersteuning. Uw configuratie is niet gewijzigd. + + + Deze installatie kan niet veilig worden gemigreerd. Alleen de standaardinstallatie voor dezelfde Windows-gebruiker met een overeenkomende architectuur wordt ondersteund. Uw configuratie is niet gewijzigd. + + + OpenClaw kon de installatie of migratierecords niet veilig controleren. Bekijk het applogboek voordat u het opnieuw probeert. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + Deze migratie moet worden hersteld. Bekijk het applogboek. Als de vorige app nog is geïnstalleerd, verwijder deze via Geïnstalleerde apps en kies Opnieuw proberen. Verwijder migratiegegevens niet handmatig en voer geen gatewayopruiming uit. + + + OpenClaw kon de migratie niet afronden. Het voltooiingsbewijs is bewaard voor herstel. Bekijk het applogboek en probeer het opnieuw. Verwijder geen migratiegegevens en voer geen gatewayopruiming uit. + + + Geïnstalleerde apps openen + + + Onleesbare migratiegegevens verwijderen + + + OpenClaw kon de migratiegegevens niet verwijderen. Als de vorige OpenClaw-app actief is, sluit deze dan en probeer het opnieuw. Bekijk het applogboek voor meer informatie. + + + Windows kon de gevraagde pagina niet openen. Probeer het opnieuw of open Windows-instellingen > Apps > Geïnstalleerde apps handmatig als de migratie is voltooid. + + + Aanbevolen + + + Overstappen naar de Microsoft Store-versie + + + Ontvang toekomstige OpenClaw-updates via de Microsoft Store. Uw bestaande OpenClaw-configuratie blijft behouden. + + + Store-versie installeren en migreren + + + Overstappen naar de Store-versie? + + + De Microsoft Store wordt geopend zodat u de Store-versie kunt installeren. Wanneer u die opent, kan deze app worden gesloten en de overstap worden voltooid zonder opnieuw om toestemming te vragen. + +Uw instellingen, gateway, bestanden en modellen blijven op hun plaats. + + + De toestemming voor migratie is opgeslagen. Installeer en open de Store-versie om door te gaan. Laat deze app geïnstalleerd totdat de voltooiing van de migratie is bevestigd. + + + OpenClaw kon de toestemming voor migratie niet opslaan of de Store-pagina niet openen. Bekijk het applogboek en probeer het opnieuw. Verwijder deze app niet. + + + Sluiten + + + Wacht totdat deze stap is voltooid. + Code kopiëren Codeblok Huidig model diff --git a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw index 9d7b57c76..51a33f85c 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/pt-br/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4971,7 +4971,7 @@ Os controles de arquivo, rede, área de transferência, tempo limite e aprovaç Instalação MSIX detectada - Remover o OpenClaw pelas Configurações do Windows &#x2192; Aplicativos NÃO removerá o Gateway local (distro WSL, imagem de disco). Use Remover Gateway local abaixo PRIMEIRO e só depois desinstale o OpenClaw. + Remover o OpenClaw pelas Configurações do Windows → Aplicativos NÃO removerá o Gateway local (distro WSL, imagem de disco). Use Remover Gateway local abaixo PRIMEIRO e só depois desinstale o OpenClaw. Remove a distro WSL (OpenClawGateway), sua imagem de disco, a entrada de inicialização automática, e apaga as credenciais do Gateway. Seu token MCP é preservado. A integração será reiniciada. @@ -7222,9 +7222,6 @@ Escolha {1} para sair desta prévia sem iniciar a migração e continuar usando O OpenClaw não pôde validar com segurança a configuração anterior para a migração. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e tente novamente mais tarde. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. - - O OpenClaw não conseguiu resolver uma credencial para o gateway ativo. Nenhuma conclusão foi registrada e o aplicativo anterior não foi alterado. Feche esta prévia e restaure a configuração do gateway ativo antes de tentar novamente. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. - O OpenClaw registrou a conclusão da migração e preservou sua configuração. Feche esta prévia, desinstale o aplicativo OpenClaw Companion anterior nas Configurações do Windows e abra a versão da Store. Não exclua os dados de migração nem execute a limpeza do gateway. @@ -7252,6 +7249,143 @@ Escolha {1} para sair desta prévia sem iniciar a migração e continuar usando Uma migração anterior precisa de recuperação ou finalização. Esta versão de teste não pode continuá-la nem iniciar normalmente. Feche esta prévia. Não desinstale o aplicativo anterior, não exclua os dados de migração nem execute a limpeza do gateway. + + Mudar para a versão da Store + + + Mantenha suas configurações, gateway, arquivos e modelos onde estão. + + + Vamos preparar sua configuração + + + Vamos fechar o aplicativo anterior com segurança, verificar sua instalação e configurações e salvar registros protegidos de migração. + + + Você removerá o aplicativo anterior + + + Quando solicitado, desinstale-o manualmente. Nada será desinstalado automaticamente. + + + Depois, use a versão da Store + + + Verificamos a remoção antes de iniciar a versão da Store. + + + Selecione Agora não para sair sem iniciar a migração. + + + Antes de continuar + + + Se a validação for bem-sucedida, o aplicativo anterior não poderá mais iniciar normalmente. + + + Solicitando o encerramento seguro do aplicativo anterior... + + + Validando sua configuração existente... + + + Registrando a conclusão da migração... + + + Verificando a desinstalação e finalizando a migração... + + + Feche o OpenClaw Companion pelo menu na bandeja do sistema e selecione Tentar novamente. + + + Feche o aplicativo anterior + + + Não desinstale ainda + + + Não force o encerramento nem desinstale o aplicativo ainda. Avisaremos quando for seguro desinstalar. + + + Sua configuração foi validada e a conclusão da migração foi registrada. + +1. Abra Aplicativos instalados e desinstale apenas o aplicativo OpenClaw Companion anterior. Mantenha a versão da Store. + +2. Volte aqui e selecione Tentar novamente para verificar a remoção e finalizar. + +Suas configurações, gateway, arquivos e modelos permanecem no mesmo local. + + + Remova o aplicativo anterior + + + Mantenha sua configuração + + + Não exclua os dados de migração nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu concluir a migração com segurança. Não desinstale o aplicativo anterior nem execute a limpeza do gateway. Consulte o log do aplicativo, resolva o problema e tente novamente. + + + Primeiro, atualize o aplicativo anterior para uma versão compatível com suporte à migração. Sua configuração não foi alterada. + + + Esta instalação não pode ser migrada com segurança. Apenas a instalação padrão para o mesmo usuário do Windows e com arquitetura correspondente é compatível. Sua configuração não foi alterada. + + + O OpenClaw não conseguiu verificar com segurança a instalação ou os registros de migração. Consulte o log do aplicativo antes de tentar novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Esta migração precisa de recuperação. Consulte o log do aplicativo. Se o aplicativo anterior ainda estiver instalado, remova-o em Aplicativos instalados e selecione Tentar novamente. Não exclua os dados de migração manualmente nem execute a limpeza do gateway. + + + O OpenClaw não conseguiu finalizar a migração. O recibo de conclusão foi preservado para recuperação. Consulte o log do aplicativo e tente novamente. Não exclua os dados de migração nem execute a limpeza do gateway. + + + Abrir Aplicativos instalados + + + Descartar dados de migração ilegíveis + + + O OpenClaw não conseguiu descartar os dados de migração. Se o aplicativo OpenClaw anterior estiver em execução, feche-o e tente novamente. Consulte o log do aplicativo para obter detalhes. + + + O Windows não conseguiu abrir a página solicitada. Tente novamente ou abra Configurações do Windows > Aplicativos > Aplicativos instalados manualmente se a migração estiver concluída. + + + Recomendado + + + Mudar para a versão da Microsoft Store + + + Receba futuras atualizações do OpenClaw pela Microsoft Store. Sua configuração atual do OpenClaw será preservada. + + + Instalar a versão da Store e migrar + + + Mudar para a versão da Store? + + + A Microsoft Store será aberta para você instalar a versão da Store. Quando você abri-la, ela poderá fechar este aplicativo e concluir a mudança sem pedir autorização novamente. + +Suas configurações, gateway, arquivos e modelos permanecem onde estão. + + + O consentimento para migração foi salvo. Instale e abra a versão da Store para continuar. Mantenha este aplicativo instalado até que a conclusão da migração seja confirmada. + + + O OpenClaw não conseguiu salvar o consentimento para migração ou abrir a página do aplicativo na Store. Consulte o log do aplicativo e tente novamente. Não desinstale este aplicativo. + + + Fechar + + + Aguarde a conclusão desta etapa. + Copiar código Bloco de código Modelo atual diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw index 01cdc8f1a..3a7299ae7 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-cn/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4992,7 +4992,7 @@ 检测到 MSIX 安装 - 通过 Windows 设置 &amp;#x2192; 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 + 通过 Windows 设置 → 应用删除 OpenClaw 不会清理本地网关(WSL 发行版、磁盘映像)。请先使用下面的“删除本地网关”,然后再卸载 OpenClaw。 删除 WSL 发行版 (OpenClawGateway)、其磁盘映像、自动启动项,并清除网关凭据。您的 MCP 令牌将被保留。引导将重置。 @@ -7168,9 +7168,6 @@ OpenClaw 无法安全验证旧版配置是否可迁移。未记录完成状态,旧版应用也未被更改。请关闭此预览,稍后重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 - - OpenClaw 无法解析活动网关的凭据。未记录完成状态,旧版应用也未被更改。请关闭此预览,恢复活动网关配置后再重试。请勿卸载旧版应用、删除迁移数据或运行网关清理。 - OpenClaw 已记录迁移完成并保留了您的配置。请关闭此预览,通过 Windows 设置卸载旧版 OpenClaw Companion 应用,然后打开 Store 版本。请勿删除迁移数据或运行网关清理。 @@ -7198,6 +7195,143 @@ 之前的迁移需要恢复或完成。此测试版本无法继续该迁移或正常启动。请关闭此预览。请勿卸载旧版应用、删除迁移数据或运行网关清理。 + + 迁移到 Store 版本 + + + 将您的设置、网关、文件和模型保留在原处。 + + + 我们将准备您的配置 + + + 我们将安全关闭旧版应用,检查您的安装和设置,并保存受保护的迁移记录。 + + + 您将卸载旧版应用 + + + 收到提示后,请手动卸载。不会自动卸载任何应用。 + + + 然后使用 Store 版本 + + + 我们会验证移除状态后再启动 Store 版本。 + + + 选择“暂不”即可退出,不会开始迁移。 + + + 继续之前 + + + 如果验证成功,旧版应用将无法正常启动。 + + + 正在请求旧版应用安全关闭... + + + 正在验证您的现有配置... + + + 正在记录迁移完成状态... + + + 正在检查卸载状态并完成迁移... + + + 请通过托盘菜单关闭 OpenClaw Companion,然后选择“重试”。 + + + 关闭旧版应用 + + + 暂时不要卸载 + + + 暂时不要强制关闭或卸载该应用。我们会在可以安全卸载时通知您。 + + + 您的配置已通过验证,迁移完成状态已记录。 + +1. 打开“已安装的应用”,仅卸载旧版 OpenClaw Companion 应用。请保留 Store 版本。 + +2. 返回此处并选择“重试”以验证移除状态并完成迁移。 + +您的设置、网关、文件和模型将保留在原处。 + + + 卸载旧版应用 + + + 保留您的配置 + + + 请勿删除迁移数据或运行网关清理。 + + + OpenClaw 无法安全完成迁移。请勿卸载旧版应用或运行网关清理。请查看应用日志,解决问题后重试。 + + + 请先将旧版应用更新到受支持且具备迁移功能的版本。您的配置未被更改。 + + + 无法安全迁移此安装。仅支持同一 Windows 用户且体系结构匹配的默认安装。您的配置未被更改。 + + + OpenClaw 无法安全检查安装或迁移记录。请先查看应用日志再重试。请勿删除迁移数据或运行网关清理。 + + + 此迁移需要恢复。请查看应用日志。如果旧版应用仍已安装,请在“已安装的应用”中将其卸载,然后选择“重试”。请勿手动删除迁移数据或运行网关清理。 + + + OpenClaw 无法完成迁移。完成收据已保留以供恢复。请查看应用日志并重试。请勿删除迁移数据或运行网关清理。 + + + 打开“已安装的应用” + + + 放弃无法读取的迁移数据 + + + OpenClaw 无法放弃迁移数据。如果旧版 OpenClaw 应用正在运行,请关闭它并重试。详情请查看应用日志。 + + + Windows 无法打开请求的页面。请重试,或在迁移完成后手动打开 Windows 设置 > 应用 > 已安装的应用。 + + + 推荐 + + + 迁移到 Microsoft Store 版本 + + + 通过 Microsoft Store 获取 OpenClaw 的后续更新。您现有的 OpenClaw 配置将会保留。 + + + 安装 Store 版本并迁移 + + + 迁移到 Store 版本? + + + 将打开 Microsoft Store,供您安装 Store 版本。您打开该版本后,它可以关闭此应用并完成迁移,无需再次征求您的同意。 + +您的设置、网关、文件和模型将保留在原处。 + + + 已保存迁移授权。请安装并打开 Store 版本以继续。在确认迁移完成之前,请保留此应用。 + + + OpenClaw 无法保存迁移授权或打开 Store 中的应用页面。请查看应用日志并重试。请勿卸载此应用。 + + + 关闭 + + + 请等待此步骤完成。 + 复制代码 代码块 当前模型 diff --git a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw index 6f4f74b00..873216b0b 100644 --- a/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw +++ b/src/OpenClaw.Tray.WinUI/Strings/zh-tw/Resources.resw @@ -1,4 +1,4 @@ - + @@ -4992,7 +4992,7 @@ 偵測到 MSIX 安裝 - 透過 Windows 設定 &amp;#x2192; 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 + 透過 Windows 設定 → 應用程式移除 OpenClaw 不會清除本機閘道(WSL 散發版、磁碟映像)。請先使用下方的「移除本機閘道」,然後再解除安裝 OpenClaw。 移除 WSL 散發版 (OpenClawGateway)、其磁碟映像、自動啟動項目,並清除閘道認證。您的 MCP 權杖會保留。導覽流程將重設。 @@ -7168,9 +7168,6 @@ OpenClaw 無法安全驗證舊版設定是否可移轉。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,稍後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 - - OpenClaw 無法解析作用中閘道的認證。未記錄完成狀態,舊版應用程式也未被變更。請關閉此預覽,還原作用中閘道設定後再試。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 - OpenClaw 已記錄移轉完成並保留您的設定。請關閉此預覽,透過 Windows 設定解除安裝舊版 OpenClaw Companion 應用程式,然後開啟 Store 版本。請勿刪除移轉資料或執行閘道清理。 @@ -7198,6 +7195,143 @@ 先前的移轉需要復原或完成。此測試版本無法繼續該移轉或正常啟動。請關閉此預覽。請勿解除安裝舊版應用程式、刪除移轉資料或執行閘道清理。 + + 移轉至 Store 版本 + + + 將您的設定、閘道、檔案和模型保留在原處。 + + + 我們將準備您的設定 + + + 我們將安全關閉舊版應用程式,檢查您的安裝和設定,並儲存受保護的移轉記錄。 + + + 您將解除安裝舊版應用程式 + + + 收到提示後,請手動解除安裝。不會自動解除安裝任何應用程式。 + + + 然後使用 Store 版本 + + + 我們會驗證移除狀態後再啟動 Store 版本。 + + + 選擇「現在不要」即可離開,不會開始移轉。 + + + 繼續之前 + + + 如果驗證成功,舊版應用程式將無法正常啟動。 + + + 正在要求舊版應用程式安全關閉... + + + 正在驗證您的現有設定... + + + 正在記錄移轉完成狀態... + + + 正在檢查解除安裝狀態並完成移轉... + + + 請透過系統匣選單關閉 OpenClaw Companion,然後選擇「重試」。 + + + 關閉舊版應用程式 + + + 暫時不要解除安裝 + + + 暫時不要強制關閉或解除安裝該應用程式。我們會在可以安全解除安裝時通知您。 + + + 您的設定已通過驗證,移轉完成狀態已記錄。 + +1. 開啟「已安裝的應用程式」,僅解除安裝舊版 OpenClaw Companion 應用程式。請保留 Store 版本。 + +2. 返回此處並選擇「重試」以驗證移除狀態並完成移轉。 + +您的設定、閘道、檔案和模型將保留在原處。 + + + 解除安裝舊版應用程式 + + + 保留您的設定 + + + 請勿刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法安全完成移轉。請勿解除安裝舊版應用程式或執行閘道清理。請查看應用程式記錄,解決問題後重試。 + + + 請先將舊版應用程式更新至受支援且具備移轉功能的版本。您的設定未被變更。 + + + 無法安全移轉此安裝。僅支援相同 Windows 使用者且架構相符的預設安裝。您的設定未被變更。 + + + OpenClaw 無法安全檢查安裝或移轉記錄。請先查看應用程式記錄再重試。請勿刪除移轉資料或執行閘道清理。 + + + 此移轉需要復原。請查看應用程式記錄。如果舊版應用程式仍已安裝,請在「已安裝的應用程式」中將其解除安裝,然後選擇「重試」。請勿手動刪除移轉資料或執行閘道清理。 + + + OpenClaw 無法完成移轉。完成收據已保留以供復原。請查看應用程式記錄並重試。請勿刪除移轉資料或執行閘道清理。 + + + 開啟「已安裝的應用程式」 + + + 捨棄無法讀取的移轉資料 + + + OpenClaw 無法捨棄移轉資料。如果舊版 OpenClaw 應用程式正在執行,請關閉它並重試。詳情請查看應用程式記錄。 + + + Windows 無法開啟要求的頁面。請再試一次,或在移轉完成後手動開啟 Windows 設定 > 應用程式 > 已安裝的應用程式。 + + + 建議 + + + 移轉至 Microsoft Store 版本 + + + 透過 Microsoft Store 取得 OpenClaw 的後續更新。您現有的 OpenClaw 設定將會保留。 + + + 安裝 Store 版本並移轉 + + + 移轉至 Store 版本? + + + 將開啟 Microsoft Store,讓您安裝 Store 版本。您開啟該版本後,它可以關閉此應用程式並完成移轉,無需再次徵求您的同意。 + +您的設定、閘道、檔案和模型將保留在原處。 + + + 已儲存移轉授權。請安裝並開啟 Store 版本以繼續。在確認移轉完成之前,請保留此應用程式。 + + + OpenClaw 無法儲存移轉授權或開啟 Store 中的應用程式頁面。請查看應用程式記錄並重試。請勿解除安裝此應用程式。 + + + 關閉 + + + 請等待此步驟完成。 + 複製程式碼 程式碼區塊 目前的模型 From 8d570f58adf40e45130d6df8822dd5ccfd4e88ca Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Fri, 25 Sep 2026 11:57:44 -0700 Subject: [PATCH 03/16] feat(migration): add the gated Store handoff experience Replaces the native dialog loop with a WinUI window offering directly labeled Migrate, Not now, and Retry actions, plus an entry point on the Settings page. StoreMigrationWorkflow owns serial interaction, StoreMigrationOperations adapts the migration coordinators, and StoreMigrationWindow applies the UI, so the startup guard keeps only the compile-time gate and pre-services window lifetime. Recovery surfaces a discard action, and a discard that could not run is reported instead of failing silently. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e51d7c52-25e3-43f8-a83a-31c105ba5b52 --- src/OpenClaw.Tray.WinUI/App.xaml.cs | 5 +- .../Helpers/InnoMigrationHandoff.cs | 87 +++++ .../Helpers/InnoMigrationStartupGuard.cs | 10 +- .../Helpers/MigrationEnvironment.cs | 42 +++ .../Helpers/StoreMigrationOperations.cs | 180 ++++++++++ .../Helpers/StoreMigrationStartupGuard.cs | 280 ++-------------- src/OpenClaw.Tray.WinUI/Migration.Build.props | 79 +++++ .../OpenClaw.Tray.WinUI.csproj | 18 +- .../Pages/SettingsPage.xaml | 63 +++- .../Pages/SettingsPage.xaml.cs | 40 +++ .../Services/ActivationRouter.cs | 37 +- .../Services/StoreMigrationListing.cs | 14 + .../Services/StoreMigrationWorkflow.cs | 316 ++++++++++++++++++ .../Windows/StoreMigrationWindow.xaml | 122 +++++++ .../Windows/StoreMigrationWindow.xaml.cs | 208 ++++++++++++ 15 files changed, 1219 insertions(+), 282 deletions(-) create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs create mode 100644 src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs create mode 100644 src/OpenClaw.Tray.WinUI/Migration.Build.props create mode 100644 src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs create mode 100644 src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs create mode 100644 src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml create mode 100644 src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml.cs diff --git a/src/OpenClaw.Tray.WinUI/App.xaml.cs b/src/OpenClaw.Tray.WinUI/App.xaml.cs index 24660f5cb..63ce8db28 100644 --- a/src/OpenClaw.Tray.WinUI/App.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/App.xaml.cs @@ -542,7 +542,7 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) return; // Environment.Exit called inside; defensive return } - if (StoreMigrationStartupGuard.ShouldStopLaunch()) + if (await StoreMigrationStartupGuard.ShouldStopLaunchAsync(DeepLinkPipeName)) { Exit(); return; @@ -593,7 +593,8 @@ private async Task OnLaunchedAsync(LaunchActivatedEventArgs args) return; } - _activationRouter = new ActivationRouter(AppIdentity.ProtocolScheme, DeepLinkPipeName); + _activationRouter = new ActivationRouter(AppIdentity.ProtocolScheme, DeepLinkPipeName, + InnoMigrationHandoff.CreateShutdownHandler(_dispatcherQueue!, ExitApplication)); if (!ownsMutex) { diff --git a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs new file mode 100644 index 000000000..dca67a5c5 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationHandoff.cs @@ -0,0 +1,87 @@ +using Microsoft.UI.Dispatching; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal static class InnoMigrationHandoff +{ + public static bool IsEnabled + { + get + { +#if INNO_MIGRATION_PREVIEW || PRODUCTION_MIGRATION + return !AppIdentity.IsDev && !PackageHelper.IsPackaged && + !GatewayFixtureIsolation.IsEnabled && !MigrationEnvironment.HasPathOverride; +#else + return false; +#endif + } + } + + public static bool IsAvailable => IsEnabled && StoreMigrationListing.TryCreateUri( + MigrationEnvironment.StoreProductId, out _); + + public static Action? CreateShutdownHandler(DispatcherQueue dispatcher, Action exit) => + IsEnabled ? () => + { + if (!dispatcher.TryEnqueue(() => + { + try + { + var (binding, installation) = InspectOwnInstallation(); + if (!new InnoMigrationConsentStore(binding, new AppLogger()) + .HasValidConsent(installation.Version.ToString())) + { + Logger.Warn("Migration shutdown rejected: explicit current-user consent is missing or invalid."); + return; + } + exit(); + } + catch (Exception exception) + { + Logger.Error($"Migration shutdown rejected: {exception.Message}"); + } + })) + Logger.Warn("Migration shutdown rejected: UI dispatcher is unavailable."); + } : null; + + public static async Task GrantAndLaunchAsync() + { + if (!IsAvailable || !StoreMigrationListing.TryCreateUri( + MigrationEnvironment.StoreProductId, out var uri)) + { + Logger.Error("Migration Store entry point is unavailable or has no configured product ID."); + return "Migration2_InnoFailed"; + } + + try + { + var (binding, installation) = InspectOwnInstallation(); + new InnoMigrationConsentStore(binding, new AppLogger()).Grant(installation.Version.ToString()); + if (!await global::Windows.System.Launcher.LaunchUriAsync(uri!)) + throw new InvalidOperationException("Windows declined the Store listing URI."); + return "Migration2_InnoGranted"; + } + catch (Exception exception) + { + Logger.Error($"Migration consent handoff failed: {exception}"); + return "Migration2_InnoFailed"; + } + } + + private static (MigrationBinding Binding, InnoInstallation Installation) InspectOwnInstallation() + { + if (!IsEnabled) + throw new InvalidOperationException("Inno migration is disabled."); + var binding = MigrationEnvironment.CreateBinding(); + var detected = MigrationEnvironment.CreateDetector().Detect(); + if (detected.Status != InnoInstallationStatus.Detected || detected.Installation is not { } installation || + installation.Architecture != binding.Architecture || + !string.Equals(Path.GetFullPath(Environment.ProcessPath ?? ""), + Path.Combine(binding.InstallDirectory, "OpenClaw.Tray.WinUI.exe"), StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("This process is not the supported current-user Inno installation."); + return (binding, installation); + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs index c7cbce2b4..ac91b459b 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/InnoMigrationStartupGuard.cs @@ -13,6 +13,9 @@ internal static class InnoMigrationStartupGuard public static bool ShouldStopLaunch(out IDisposable? runtimeLease) { runtimeLease = null; +#if !INNO_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION + return false; +#else if (AppIdentity.IsDev || PackageHelper.IsPackaged || GatewayFixtureIsolation.IsEnabled) return false; @@ -27,6 +30,9 @@ public static bool ShouldStopLaunch(out IDisposable? runtimeLease) }; // Acquisition must precede the receipt check, including when no receipt exists yet. // App retains this handle until process exit, even if startup or shutdown fails. + // An unavailable lock says nothing about whether migration completed, so it must not + // block startup on its own. InnoSourceActivityVerifier still detects this process by + // image path, so the Store side keeps its interlock without a lease. try { runtimeLease = MigrationOperationLock.AcquireRuntime(binding); @@ -60,7 +66,8 @@ InvalidDataException or ArgumentException or NotSupportedException or catch (FileNotFoundException) { return false; } catch (DirectoryNotFoundException) { return false; } catch (Exception ex) when (ex is InvalidDataException or CryptographicException or - EndOfStreamException or ArgumentException or DecoderFallbackException) + EndOfStreamException or ArgumentException or FormatException or + DecoderFallbackException) { Logger.Warn($"Migration completion receipt rejected ({ex.GetType().Name})."); return false; @@ -72,6 +79,7 @@ InvalidDataException or ArgumentException or NotSupportedException or } return ShowGuidance(resourceKey); +#endif } private static bool ShowGuidance(string resourceKey) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs b/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs new file mode 100644 index 000000000..6505169ad --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/MigrationEnvironment.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using System.Runtime.InteropServices; +using System.Security.Principal; +using OpenClaw.Connection.Migration; + +namespace OpenClawTray.Helpers; + +internal static class MigrationEnvironment +{ + public static bool HasPathOverride => + new[] + { + "OPENCLAW_TRAY_DATA_DIR", "OPENCLAW_TRAY_APPDATA_DIR", + "OPENCLAW_TRAY_LOCALAPPDATA_DIR", "OPENCLAW_TRAY_LOCAL_DATA_DIR" + }.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))); + + public static string? Metadata(string key) => typeof(MigrationEnvironment).Assembly + .GetCustomAttributes().SingleOrDefault(attribute => attribute.Key == key)?.Value; + + public static string? StoreProductId => + Metadata("MigrationStoreProductId") ?? Metadata("MigrationPreviewStoreProductId"); + + public static string? MinimumSourceVersion => + Metadata("MigrationMinimumSourceVersion") ?? Metadata("StoreMigrationPreviewMinimumSourceVersion"); + + public static MigrationBinding CreateBinding() + { + using var identity = WindowsIdentity.GetCurrent(); + return new MigrationBinding + { + InstallDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + AppIdentity.DataDirectoryName), + RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), + LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), + Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), + UserSid = identity.User?.Value ?? "" + }; + } + + public static InnoInstallationDetector CreateDetector() => + new(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), new AppLogger()); +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs new file mode 100644 index 000000000..9c38c5572 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs @@ -0,0 +1,180 @@ +using System.Diagnostics; +using OpenClaw.Connection; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClawTray.Helpers; + +internal sealed class StoreMigrationOperations(string pipeName) : IStoreMigrationOperations +{ + private readonly AppLogger _logger = new(); + private MigrationBinding? _binding; + private IInnoInstallationDetector? _detector; + + public StoreMigrationStartupDecision Inspect() + { + var identity = global::Windows.ApplicationModel.Package.Current.Id; + if (identity.Name != MigrationRecordCodec.PackageName || + identity.Publisher != MigrationRecordCodec.PackagePublisher || MigrationEnvironment.HasPathOverride) + { + Logger.Error("Store migration requires the production package identity and default data paths."); + return new(StoreMigrationStartupState.UnsupportedInstallation); + } + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationStartupCoordinator(_detector, new MigrationStartupRecordReader(_binding, _logger), _logger) + .Evaluate(true, MigrationEnvironment.MinimumSourceVersion, _binding.Architecture); + } + + /// + /// Deliberately independent of : it is called precisely when inspection + /// threw, so it must not depend on detection, policy, or package identity. + /// + public bool HoldsCompletionReceipt() + { + try + { + _binding ??= MigrationEnvironment.CreateBinding(); + return new MigrationStartupRecordReader(_binding, _logger).Read().CompletionPresent; + } + catch (Exception exception) + { + Logger.Error($"Could not confirm the migration receipt: {exception}"); + return true; + } + } + + public bool HasConsent(InnoInstallation installation) => + new InnoMigrationConsentStore(_binding!, _logger).HasValidConsent(installation.Version.ToString()); + + public void GrantConsent(InnoInstallation installation) => + new InnoMigrationConsentStore(_binding!, _logger).Grant(installation.Version.ToString()); + + public async Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken) + { + var coordinator = new StoreMigrationConsentCoordinator(new InnoMutexProbe()); + if (coordinator.Begin(admission, true).State == StoreMigrationConsentState.ReadyForAdoption) + return true; + + await using var router = new ActivationRouter(AppIdentity.ProtocolScheme, pipeName); + if (!await router.RequestMigrationShutdownAsync(cancellationToken)) + return false; + + var elapsed = Stopwatch.StartNew(); + while (elapsed.Elapsed < TimeSpan.FromSeconds(10)) + { + await Task.Delay(200, cancellationToken); + if (coordinator.Retry().State == StoreMigrationConsentState.ReadyForAdoption) + return true; + } + Logger.Warn("Migration shutdown did not release the Inno instance. Manual close and Retry are required."); + return false; + } + + public Task PrepareAsync(InnoInstallation installation) => Task.Run(() => + new StoreMigrationAdoptionPreparationCoordinator( + new InnoMutexLeaseProvider(), _detector!, new MigrationPreparation(_binding!), _logger) + .Prepare(installation).State); + + public Task CompleteAsync(InnoInstallation installation) + { + var version = global::Windows.ApplicationModel.Package.Current.Id.Version; + var targetVersion = $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; + return Task.Run(() => new StoreMigrationCompletionCoordinator( + new InnoMutexLeaseProvider(), _detector!, _binding!, _logger) + .Complete(installation, targetVersion).State); + } + + public Task FinalizeAsync() + { + var records = new MigrationStartupRecordReader(_binding!, _logger); + return new StoreMigrationFinalizationCoordinator( + _binding!, _detector!, new InnoSourceRemovalVerifier(_binding!, AppIdentity.MutexBaseName), + records, new MigrationInventoryCapture(_binding!), new StoreMigrationAutoStartApplier(), + new MigrationFinalizationRecordCleaner(_binding!), _logger).FinalizeAsync(); + } + + /// + /// Like , independent of policy and package identity: + /// recovery may be showing because inspection itself could not run. + /// + public bool CanDiscardRecords() + { + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationRecoveryDiscard(_binding, _detector, _logger).CanDiscard(); + } + + public StoreMigrationDiscardState DiscardUnreadableRecords() + { + _binding ??= MigrationEnvironment.CreateBinding(); + _detector ??= MigrationEnvironment.CreateDetector(); + return new StoreMigrationRecoveryDiscard(_binding, _detector, _logger).Discard(); + } + + private sealed class StoreMigrationAutoStartApplier : IStoreMigrationAutoStartApplier + { + public async Task ApplyAsync(bool enabled) + { + try + { + await AutoStartManager.SetAutoStartAsync(enabled); + } + catch (AutoStartRefusedException exception) when (exception.IsDurable) + { + // Only Windows can re-enable a durably refused startup task. Translating it here + // is what lets finalization report StartupPreferenceRefused and clear the receipt + // instead of retrying a preference that can never succeed. + throw new StoreMigrationAutoStartRefusedException(exception.Message, exception); + } + } + } + + private sealed class InnoMutexProbe : IInnoInstanceProbe + { + public bool IsRunning() + { + using var lease = new InnoMutexLeaseProvider().TryAcquire(); + return lease is null; + } + } + + private sealed class InnoMutexLeaseProvider : IMigrationSourceLeaseProvider + { + public IMigrationSourceLease? TryAcquire() + { + try + { + var mutex = new Mutex(true, AppIdentity.MutexBaseName, out var createdNew); + if (createdNew) + return new InnoMutexLease(mutex); + try + { + if (mutex.WaitOne(0)) + return new InnoMutexLease(mutex); + } + catch (AbandonedMutexException) + { + return new InnoMutexLease(mutex); + } + mutex.Dispose(); + return null; + } + catch (UnauthorizedAccessException exception) + { + Logger.Error($"Could not acquire the Inno instance mutex: {exception.Message}"); + return null; + } + } + } + + private sealed class InnoMutexLease(Mutex mutex) : IMigrationSourceLease + { + public void Dispose() + { + mutex.ReleaseMutex(); + mutex.Dispose(); + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs index f00d167ca..7565a256e 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationStartupGuard.cs @@ -1,282 +1,42 @@ -using System.Reflection; -using System.Runtime.InteropServices; -using System.Security.Principal; -using OpenClaw.Connection; +using Microsoft.UI.Xaml; using OpenClaw.Connection.Migration; -using OpenClaw.Shared; using OpenClawTray.Services; +using OpenClawTray.Windows; namespace OpenClawTray.Helpers; internal static class StoreMigrationStartupGuard { - public static bool ShouldStopLaunch() + public static async Task ShouldStopLaunchAsync(string pipeName) { -#if !STORE_MIGRATION_PREVIEW +#if !STORE_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION return false; #else if (!PackageHelper.IsPackaged || AppIdentity.IsDev) return false; - var identity = global::Windows.ApplicationModel.Package.Current.Id; - if (identity.Name != MigrationRecordCodec.PackageName || - identity.Publisher != MigrationRecordCodec.PackagePublisher || - HasPathOverride()) + var operations = new StoreMigrationOperations(pipeName); + StoreMigrationStartupDecision? admission = null; + try { - Logger.Error("Store migration preview requires the production package identity and default data paths."); - ShowGuidance("Migration_StoreUnsupported"); - return true; - } - - var localRoot = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData); - var binding = new MigrationBinding - { - InstallDirectory = Path.Combine(localRoot, AppIdentity.DataDirectoryName), - RoamingDirectory = AppIdentity.ResolveRoamingDataDirectory(), - LocalDirectory = AppIdentity.ResolveSetupLocalDataDirectory(), - Architecture = RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant(), - UserSid = WindowsIdentity.GetCurrent().User?.Value ?? "" - }; - var logger = new AppLogger(); - var minimum = typeof(StoreMigrationStartupGuard).Assembly.GetCustomAttributes() - .SingleOrDefault(attribute => attribute.Key == "StoreMigrationPreviewMinimumSourceVersion")?.Value; - var detector = new InnoInstallationDetector(localRoot, logger); - var coordinator = new StoreMigrationStartupCoordinator( - detector, - new MigrationStartupRecordReader(binding, logger), - logger); - var decision = coordinator.Evaluate(true, minimum, binding.Architecture); - if (decision.AllowsNormalStartup) - return false; - - if (decision.State == StoreMigrationStartupState.FinalizationRequired) - { - var finalization = FinalizeCompletedMigration(binding, detector, logger); - if (finalization.AllowsNormalStartup) - { - // A durable refusal is the one finalization outcome the user must act on - // themselves: only Windows can re-enable the startup task. Notify, then - // continue launching, because the migration itself succeeded. - if (finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused) - ShowGuidance("Migration_StoreStartupRefused"); + admission = operations.Inspect(); + if (admission.AllowsNormalStartup) return false; - } - - ShowGuidance(finalization.State switch - { - StoreMigrationFinalizationState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", - StoreMigrationFinalizationState.InspectionFailed => "Migration_StoreInspectionFailed", - _ => "Migration_StoreFinalizationFailed" - }); - return true; } - - if (decision.State == StoreMigrationStartupState.ConsentRequired) + catch (Exception exception) { - RunConsentWorkflow(decision, binding, detector, logger); - return true; + Logger.Error($"Migration startup inspection failed: {exception}"); + // The workflow retries inspection and presents a blocking error. } - ShowGuidance(decision.State switch - { - StoreMigrationStartupState.UpdateInno => "Migration_StoreUpdateRequired", - StoreMigrationStartupState.UnsupportedInstallation => "Migration_StoreUnsupported", - StoreMigrationStartupState.InspectionFailed => "Migration_StoreInspectionFailed", - StoreMigrationStartupState.AwaitingInnoRemoval => "Migration_StoreAwaitingInnoRemoval", - _ => "Migration_StorePending" - }); - return true; + // Bootstrap owns only migration UI. Closing it must not end the dispatcher + // before a successful finalization resumes ordinary App composition. + Application.Current.DispatcherShutdownMode = DispatcherShutdownMode.OnExplicitShutdown; + // The admission is handed on rather than discarded: if it already observed a completion + // receipt, a later failing inspection inside the workflow must not lose that. + var workflow = new StoreMigrationWorkflow(operations, new AppLogger(), admission); + var window = new StoreMigrationWindow(workflow); + return !await window.ShowAsync(); #endif } - -#if STORE_MIGRATION_PREVIEW - private static bool HasPathOverride() => - new[] - { - "OPENCLAW_TRAY_DATA_DIR", "OPENCLAW_TRAY_APPDATA_DIR", - "OPENCLAW_TRAY_LOCALAPPDATA_DIR", "OPENCLAW_TRAY_LOCAL_DATA_DIR" - }.Any(name => !string.IsNullOrEmpty(Environment.GetEnvironmentVariable(name))); - - private static void RunConsentWorkflow( - StoreMigrationStartupDecision admission, - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var coordinator = new StoreMigrationConsentCoordinator(new InnoMutexProbe()); - var consent = coordinator.Begin(admission, ShowChoice("Migration_StoreConsent")); - - while (consent.State == StoreMigrationConsentState.WaitingForInnoExit) - { - if (!ShowChoice("Migration_StoreCloseInno")) - return; - - consent = coordinator.Retry(); - } - - if (consent.State != StoreMigrationConsentState.ReadyForAdoption || - admission.Installation is null) - return; - - var preparation = new StoreMigrationAdoptionPreparationCoordinator( - new InnoMutexLeaseProvider(), detector, new MigrationPreparation(binding), logger); - while (true) - { - var result = preparation.Prepare(admission.Installation); - switch (result.State) - { - case StoreMigrationPreparationState.Prepared: - CompletePreparedMigration(admission.Installation, binding, detector, logger); - return; - case StoreMigrationPreparationState.InnoRunning: - if (!ShowChoice("Migration_StoreCloseInno")) - return; - continue; - case StoreMigrationPreparationState.SourceChanged: - ShowGuidance("Migration_StoreUnsupported"); - return; - default: - ShowGuidance("Migration_StoreValidationFailed"); - return; - } - } - } - - private static void CompletePreparedMigration( - InnoInstallation installation, - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var version = global::Windows.ApplicationModel.Package.Current.Id.Version; - var targetVersion = $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}"; - var completion = new StoreMigrationCompletionCoordinator( - new InnoMutexLeaseProvider(), - detector, - binding, - new CredentialResolver(DeviceIdentityFileReader.Instance), - logger); - var result = completion.Complete(installation, targetVersion); - while (result.State == StoreMigrationCompletionState.InnoRunning) - { - if (!ShowChoice("Migration_StoreCloseInno")) - return; - result = completion.Complete(installation, targetVersion); - } - ShowGuidance(result.State switch - { - StoreMigrationCompletionState.Completed => "Migration_StoreAwaitingInnoRemoval", - StoreMigrationCompletionState.NoActiveGateway or StoreMigrationCompletionState.CredentialUnavailable - => "Migration_StoreCredentialUnavailable", - StoreMigrationCompletionState.SourceChanged => "Migration_StoreUnsupported", - _ => "Migration_StoreValidationFailed" - }); - } - - private static StoreMigrationFinalizationDecision FinalizeCompletedMigration( - MigrationBinding binding, - IInnoInstallationDetector detector, - IOpenClawLogger logger) - { - var records = new MigrationStartupRecordReader(binding, logger); - if (records.Read().Status != MigrationStartupRecordStatus.Completed) - return new(StoreMigrationFinalizationState.InspectionFailed); - - return new StoreMigrationFinalizationCoordinator( - binding, - detector, - new InnoSourceRemovalVerifier(binding, AppIdentity.MutexBaseName), - records, - new MigrationInventoryCapture(binding), - new StoreMigrationAutoStartApplier(), - new MigrationFinalizationRecordCleaner(binding), - logger) - .FinalizeAsync() - .GetAwaiter() - .GetResult(); - } - - private static bool ShowChoice(string contentKey) - { - var content = LocalizationHelper.Format(contentKey, - LocalizationHelper.GetString("Migration_StoreYes"), - LocalizationHelper.GetString("Migration_StoreNo")); - return MessageBoxW(IntPtr.Zero, content, LocalizationHelper.GetString("Migration_StorePreviewTitle"), - 0x00000124) == 6; - } - - private static void ShowGuidance(string resourceKey) - { - if (MessageBoxW(IntPtr.Zero, LocalizationHelper.GetString(resourceKey), - LocalizationHelper.GetString("Migration_StorePreviewTitle"), 0x00000040) == 0) - Logger.Error($"Could not show Store migration preview guidance (Win32 {Marshal.GetLastWin32Error()})."); - } - - private sealed class InnoMutexProbe : IInnoInstanceProbe - { - public bool IsRunning() - { - using var lease = new InnoMutexLeaseProvider().TryAcquire(); - return lease is null; - } - } - - private sealed class StoreMigrationAutoStartApplier : IStoreMigrationAutoStartApplier - { - public async Task ApplyAsync(bool enabled) - { - try - { - await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false); - } - catch (AutoStartRefusedException exception) when (exception.IsDurable) - { - throw new StoreMigrationAutoStartRefusedException(exception.Message, exception); - } - } - } - - private sealed class InnoMutexLeaseProvider : IMigrationSourceLeaseProvider - { - public IMigrationSourceLease? TryAcquire() - { - try - { - var mutex = new Mutex(true, AppIdentity.MutexBaseName, out var createdNew); - if (createdNew) - return new InnoMutexLease(mutex); - - try - { - if (mutex.WaitOne(0)) - return new InnoMutexLease(mutex); - } - catch (AbandonedMutexException) - { - return new InnoMutexLease(mutex); - } - - mutex.Dispose(); - return null; - } - catch (UnauthorizedAccessException exception) - { - Logger.Error($"Could not acquire the Inno instance mutex: {exception.Message}"); - return null; - } - } - } - - private sealed class InnoMutexLease(Mutex mutex) : IMigrationSourceLease - { - public void Dispose() - { - mutex.ReleaseMutex(); - mutex.Dispose(); - } - } - - [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] - private static extern int MessageBoxW(IntPtr window, string text, string caption, uint type); -#endif } diff --git a/src/OpenClaw.Tray.WinUI/Migration.Build.props b/src/OpenClaw.Tray.WinUI/Migration.Build.props new file mode 100644 index 000000000..577e64e73 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Migration.Build.props @@ -0,0 +1,79 @@ + + + 9NFPR3BGDRR5 + + 2026.9.5.0 + + true + + + + $(DefineConstants);PRODUCTION_MIGRATION + + + + <_Parameter1>MigrationStoreProductId + <_Parameter2>$(MigrationStoreProductId) + + + <_Parameter1>MigrationMinimumSourceVersion + <_Parameter2>$(MigrationMinimumSourceVersion) + + + + + $(DefineConstants);INNO_MIGRATION_PREVIEW + + + + <_Parameter1>MigrationPreviewStoreProductId + <_Parameter2>$(MigrationPreviewStoreProductId) + + + + $(DefineConstants);STORE_MIGRATION_PREVIEW + + + + <_Parameter1>StoreMigrationPreviewMinimumSourceVersion + <_Parameter2>$(StoreMigrationPreviewMinimumSourceVersion) + + + + + + + + + + + + + + <_ValidatedMigrationMinimumSourceVersion>$([System.Version]::Parse('$(MigrationMinimumSourceVersion)').ToString()) + + + + + + + + + + + + diff --git a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj index c1e158f2b..1068ac0a3 100644 --- a/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj +++ b/src/OpenClaw.Tray.WinUI/OpenClaw.Tray.WinUI.csproj @@ -58,23 +58,7 @@ win-arm64 - - - $(DefineConstants);STORE_MIGRATION_PREVIEW - - - - <_Parameter1>StoreMigrationPreviewMinimumSourceVersion - <_Parameter2>$(StoreMigrationPreviewMinimumSourceVersion) - - - - - - + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index d3cecf4f1..d1125d4a7 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -32,6 +32,67 @@ Margin="0,0,0,8"/> + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -418,7 +479,7 @@ IsOpen="False" IsClosable="False" Title="MSIX installation detected" - Message="Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw."/> + Message="Removing OpenClaw from Windows Settings → Apps will NOT clean up the Local Gateway (WSL distro, disk image). Use Remove Local Gateway below FIRST, then uninstall OpenClaw."/> + AsyncEventHandlerGuard.Run(OnStoreMigrationAsync, new AppLogger(), nameof(OnStoreMigration)); + + private async Task OnStoreMigrationAsync() + { + StoreMigrationAction.IsEnabled = false; + try + { + var confirmation = new ContentDialog + { + XamlRoot = XamlRoot, + Title = LocalizationHelper.GetString("Migration2_InnoConsentTitle"), + Content = LocalizationHelper.GetString("Migration2_InnoConsent"), + PrimaryButtonText = LocalizationHelper.GetString("Migration2_InnoAction"), + CloseButtonText = LocalizationHelper.GetString("Migration_StoreNotNow"), + DefaultButton = ContentDialogButton.Close + }; + if (await confirmation.ShowAsync() != ContentDialogResult.Primary) + return; + var messageKey = await InnoMigrationHandoff.GrantAndLaunchAsync(); + StoreMigrationStatus.Message = LocalizationHelper.GetString(messageKey); + StoreMigrationStatus.Severity = messageKey == "Migration2_InnoGranted" + ? InfoBarSeverity.Success : InfoBarSeverity.Error; + StoreMigrationStatus.IsOpen = true; + } + catch (Exception exception) + { + Logger.Error($"Could not show migration consent: {exception.Message}"); + StoreMigrationStatus.Message = LocalizationHelper.GetString("Migration2_InnoFailed"); + StoreMigrationStatus.Severity = InfoBarSeverity.Error; + StoreMigrationStatus.IsOpen = true; + } + finally + { + StoreMigrationAction.IsEnabled = true; + } + } + /// /// The Settings view model is assigned as the page DataContext by the navigation activation /// hook. The two-way bindings handle load/persist; the page only subscribes to the view-only diff --git a/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs b/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs index bc5334038..b26bdc2b1 100644 --- a/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs +++ b/src/OpenClaw.Tray.WinUI/Services/ActivationRouter.cs @@ -17,6 +17,8 @@ internal sealed class ActivationRouter : IAsyncDisposable private static readonly TimeSpan ForwardRetryDelay = TimeSpan.FromMilliseconds(100); private readonly string _protocolScheme; private readonly string _pipeName; + private const string MigrationShutdownMessage = "OpenClaw.Migration.Shutdown.v1"; + private readonly Action? _migrationShutdown; private readonly object _lifecycleGate = new(); private readonly object _dispatchGate = new(); private readonly HashSet _pendingDispatches = new(); @@ -32,10 +34,11 @@ private sealed class DispatchOperation(CancellationTokenSource cancellation) public Task? Task { get; set; } } - public ActivationRouter(string protocolScheme, string pipeName) + public ActivationRouter(string protocolScheme, string pipeName, Action? migrationShutdown = null) { _protocolScheme = protocolScheme; _pipeName = pipeName; + _migrationShutdown = migrationShutdown; } public ActivationPlan PlanLaunch(LaunchActivationInput input) @@ -210,6 +213,16 @@ private async Task RunListenerLoopAsync(IActivationPlanSink sink, CancellationTo outBufferSize: 0); await pipe.WaitForConnectionAsync(token); var uri = await ReadIpcPayloadAsync(pipe, token); + if (uri == MigrationShutdownMessage) + { + // This is not a URI route. Only the preview Inno host supplies a + // callback, which revalidates protected consent before graceful exit. + if (_migrationShutdown is null) + Logger.Warn("Migration shutdown is not enabled for this host."); + else + _migrationShutdown(); + continue; + } if (!string.IsNullOrEmpty(uri)) { Logger.Info($"Received deep link via IPC: {DeepLinkSecurityPolicy.RedactForLog(uri)}"); @@ -271,6 +284,28 @@ private static async Task ObserveForwardedDispatchAsync( } } + [SupportedOSPlatform("windows")] + public async Task RequestMigrationShutdownAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(TimeSpan.FromSeconds(2)); + try + { + using var pipe = new NamedPipeClientStream(".", _pipeName, PipeDirection.Out, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await pipe.ConnectAsync(timeout.Token).ConfigureAwait(false); + await pipe.WriteAsync(Encoding.UTF8.GetBytes(MigrationShutdownMessage), timeout.Token).ConfigureAwait(false); + await pipe.FlushAsync(timeout.Token).ConfigureAwait(false); + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or OperationCanceledException) + { + Logger.Warn($"Migration shutdown request unavailable: {exception.Message}"); + cancellationToken.ThrowIfCancellationRequested(); + return false; + } + } + [SupportedOSPlatform("windows")] public async Task ForwardToPrimaryAsync(string uri, CancellationToken cancellationToken) { diff --git a/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs new file mode 100644 index 000000000..b6274e5bb --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationListing.cs @@ -0,0 +1,14 @@ +namespace OpenClawTray.Services; + +internal static class StoreMigrationListing +{ + public static bool TryCreateUri(string? productId, out Uri? uri) + { + uri = null; + if (productId is not { Length: 12 } || productId.Any(character => + character is not (>= 'A' and <= 'Z') and not (>= '0' and <= '9'))) + return false; + uri = new Uri($"ms-windows-store://pdp/?ProductId={productId}"); + return true; + } +} diff --git a/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs new file mode 100644 index 000000000..c0eea4512 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Services/StoreMigrationWorkflow.cs @@ -0,0 +1,316 @@ +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; + +namespace OpenClawTray.Services; + +internal enum StoreMigrationStage +{ + Inspecting, Consent, ClosingSource, Preparing, Completing, Finalizing, + CloseSource, AwaitingRemoval, ValidationFailed, + UpdateRequired, Unsupported, InspectionFailed, Recovery, FinalizationFailed, + StartupRefused, Ready +} + +internal interface IStoreMigrationOperations +{ + StoreMigrationStartupDecision Inspect(); + bool HasConsent(InnoInstallation installation); + void GrantConsent(InnoInstallation installation); + Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken); + Task PrepareAsync(InnoInstallation installation); + Task CompleteAsync(InnoInstallation installation); + Task FinalizeAsync(); + + /// + /// Whether recovery can be cleared from inside the app. Must not throw: it runs while recovery + /// is already showing. See . + /// + bool CanDiscardRecords(); + + /// Removes discardable records. See . + StoreMigrationDiscardState DiscardUnreadableRecords(); + + /// + /// Receipt presence only, for when itself fails and never produces a + /// decision. Must answer without requiring a successful inspection. + /// + bool HoldsCompletionReceipt(); +} + +/// Serializes UI actions without replacing the admission, adoption, or finalization owners. +internal sealed class StoreMigrationWorkflow( + IStoreMigrationOperations operations, + IOpenClawLogger logger, + StoreMigrationStartupDecision? initialAdmission = null) +{ + public StoreMigrationStage Stage { get; private set; } = StoreMigrationStage.Inspecting; + public bool IsBusy { get; private set; } + + /// + /// Whether recovery can be cleared from inside the app. Only undecodable records qualify: + /// every other recovery cause is repaired outside the window, by removing the previous app. + /// + public bool CanDiscardRecords { get; private set; } + + /// + /// The last discard outcome, so the window can say that nothing happened. A running previous + /// app holds the migration lease for its whole lifetime, so + /// is the expected answer there and silence would look like a dead button. + /// + public StoreMigrationDiscardState? LastDiscard { get; private set; } + + /// + /// Closing the window abandons migration, so only a handoff whose completion receipt already + /// exists may keep the app from starting. This tracks + /// rather than the visible stage, + /// because a receipt can survive a stage that later reports an inspection failure. Every + /// other state has nothing to protect, and refusing to launch would leave the user stuck. + /// + /// Before the first admission resolves, nothing is known yet, so closing is treated as + /// blocking. Otherwise a close raced against startup inspection would wave the user through + /// a handoff that had already moved data. + /// + /// + /// is excluded alongside + /// : finalization succeeded and cleared the receipt, + /// so the window is only reporting that Windows refused the startup task. Blocking there + /// would withhold an app whose migration is complete. + /// + /// + public bool BlocksStartup => + (!_admissionResolved || _holdsCompletedHandoff) && + Stage is not (StoreMigrationStage.Ready or StoreMigrationStage.StartupRefused); + public event Action? Changed; + private InnoInstallation? _promptInstallation; + // Seeded from the caller's admission so a receipt observed before this workflow existed is + // not lost when a later inspection pass fails. + private bool _holdsCompletedHandoff = initialAdmission?.BlocksStartup ?? false; + private bool _admissionResolved; + + public Task StartAsync(CancellationToken cancellationToken) => RunAsync(false, cancellationToken); + public Task ContinueAsync(CancellationToken cancellationToken) => + RunAsync(Stage == StoreMigrationStage.Consent, cancellationToken); + + /// + /// Deletes records that cannot be decoded, then re-inspects. The receipt hold is released only + /// on success: the hold exists because an undecodable receipt might mean data moved, and after + /// the records are gone there is nothing left for a later pass to read it from. + /// + public async Task DiscardRecordsAsync(CancellationToken cancellationToken) + { + if (IsBusy || Stage != StoreMigrationStage.Recovery) + return; + + IsBusy = true; + var result = StoreMigrationDiscardState.Failed; + try + { + LastDiscard = null; + Changed?.Invoke(); + result = await Task.Run(operations.DiscardUnreadableRecords, cancellationToken); + if (result != StoreMigrationDiscardState.Discarded) + logger.Error($"Could not discard the migration records ({result})."); + } + catch (Exception exception) + { + logger.Error($"Discarding the migration records failed: {exception}"); + } + finally + { + IsBusy = false; + LastDiscard = result; + // A contended or failing discard stays offered so the user can retry. Readable + // records mean the offer was wrong, and repeating it would only mislead. + if (result == StoreMigrationDiscardState.RecordsAreReadable) + CanDiscardRecords = false; + Changed?.Invoke(); + } + + if (result != StoreMigrationDiscardState.Discarded) + return; + + _holdsCompletedHandoff = false; + await RunAsync(false, cancellationToken); + } + + private async Task RunAsync(bool confirmed, CancellationToken cancellationToken) + { + // Recovery is deliberately retryable. Its usual cause, a receipt whose source version no + // longer matches the installed Inno, clears once the user removes that app, and a pass + // that refused to re-inspect would leave them looking at advice they had already followed. + if (IsBusy || Stage is StoreMigrationStage.Ready) + return; + + IsBusy = true; + // A new pass supersedes whatever the last discard reported. + LastDiscard = null; + try + { + SetStage(StoreMigrationStage.Inspecting); + cancellationToken.ThrowIfCancellationRequested(); + var admission = operations.Inspect(); + // Never cleared by a later pass: Retry re-inspects, and a transient failure there + // must not drop a receipt this session already observed or wrote. + _holdsCompletedHandoff |= admission.BlocksStartup; + if (admission.AllowsNormalStartup) + { + SetStage(StoreMigrationStage.Ready); + return; + } + + if (admission.State == StoreMigrationStartupState.FinalizationRequired) + { + SetStage(StoreMigrationStage.Finalizing); + var result = await operations.FinalizeAsync(); + // A durable startup refusal still finalizes the migration, so it must not be + // folded into Ready: the user has to be told that only Windows can re-enable + // the startup task. Launch still proceeds once they close the window. + if (result.State == StoreMigrationFinalizationState.StartupPreferenceRefused) + { + SetStage(StoreMigrationStage.StartupRefused); + return; + } + SetStage(result.AllowsNormalStartup ? StoreMigrationStage.Ready : result.State switch + { + StoreMigrationFinalizationState.AwaitingInnoRemoval => StoreMigrationStage.AwaitingRemoval, + StoreMigrationFinalizationState.InspectionFailed => StoreMigrationStage.InspectionFailed, + _ => StoreMigrationStage.FinalizationFailed + }); + return; + } + + if (admission.State != StoreMigrationStartupState.ConsentRequired || admission.Installation is null) + { + SetStage(admission.State switch + { + StoreMigrationStartupState.AwaitingInnoRemoval => StoreMigrationStage.AwaitingRemoval, + StoreMigrationStartupState.UpdateInno => StoreMigrationStage.UpdateRequired, + StoreMigrationStartupState.UnsupportedInstallation => StoreMigrationStage.Unsupported, + StoreMigrationStartupState.InspectionFailed => StoreMigrationStage.InspectionFailed, + _ => StoreMigrationStage.Recovery + }); + return; + } + + if ((confirmed && _promptInstallation != admission.Installation) || + (!confirmed && !operations.HasConsent(admission.Installation))) + { + _promptInstallation = admission.Installation; + SetStage(StoreMigrationStage.Consent); + return; + } + if (confirmed) + { + try + { + operations.GrantConsent(admission.Installation); + } + catch (Exception exception) when (exception is InvalidDataException or System.Security.Cryptography.CryptographicException) + { + logger.Error($"Migration consent needs recovery; the existing record was preserved: {exception.Message}"); + SetStage(StoreMigrationStage.Recovery); + return; + } + } + + SetStage(StoreMigrationStage.ClosingSource); + if (!await operations.CloseSourceAsync(admission, cancellationToken)) + { + SetStage(StoreMigrationStage.CloseSource); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + SetStage(StoreMigrationStage.Preparing); + var prepared = await operations.PrepareAsync(admission.Installation); + if (prepared != StoreMigrationPreparationState.Prepared) + { + SetStage(prepared switch + { + StoreMigrationPreparationState.InnoRunning => StoreMigrationStage.CloseSource, + StoreMigrationPreparationState.SourceChanged => StoreMigrationStage.Unsupported, + _ => StoreMigrationStage.ValidationFailed + }); + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + SetStage(StoreMigrationStage.Completing); + var completed = await operations.CompleteAsync(admission.Installation); + // A written receipt means data has moved; the window may no longer be dismissed + // into normal startup until finalization succeeds. + _holdsCompletedHandoff |= completed == StoreMigrationCompletionState.Completed; + SetStage(completed switch + { + StoreMigrationCompletionState.Completed => StoreMigrationStage.AwaitingRemoval, + StoreMigrationCompletionState.InnoRunning => StoreMigrationStage.CloseSource, + StoreMigrationCompletionState.SourceChanged => StoreMigrationStage.Unsupported, + _ => StoreMigrationStage.ValidationFailed + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + SetStage(StoreMigrationStage.CloseSource); + } + catch (Exception exception) + { + // This is the UI error boundary. A failure here is reported as an inspection problem; + // whether it also blocks startup is decided by the receipt, not by the failure. The + // receipt is probed directly because a throwing inspection never observed one, and + // leaving the flag clear would let a close resume startup against moved data. + logger.Error($"Migration workflow failed: {exception}"); + _holdsCompletedHandoff |= HoldsReceiptWithoutInspection(); + SetStage(StoreMigrationStage.InspectionFailed); + } + finally + { + // The pass reached a definite stage, so closing may now be judged on the receipt. + _admissionResolved = true; + IsBusy = false; + Changed?.Invoke(); + } + } + + /// + /// Fails safe: if the receipt cannot be confirmed after the workflow already failed, nothing + /// is known about whether data moved, and the app must not resume normal startup. + /// + private bool HoldsReceiptWithoutInspection() + { + try + { + return operations.HoldsCompletionReceipt(); + } + catch (Exception exception) + { + logger.Error($"Could not confirm the migration receipt after a workflow failure: {exception}"); + return true; + } + } + + private void SetStage(StoreMigrationStage stage) + { + Stage = stage; + if (stage == StoreMigrationStage.Recovery) + CanDiscardRecords = ProbeDiscardOffer(); + Changed?.Invoke(); + } + + /// + /// Recovery is already showing when this runs, so a failure here must not replace it with an + /// error. An unanswerable probe hides the offer rather than promising an escape that is not + /// there; the guidance text still names the remedies that work from outside the app. + /// + private bool ProbeDiscardOffer() + { + try + { + return operations.CanDiscardRecords(); + } + catch (Exception exception) + { + logger.Error($"Could not classify the migration records during recovery: {exception}"); + return false; + } + } +} diff --git a/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml b/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml new file mode 100644 index 000000000..13073d151 --- /dev/null +++ b/src/OpenClaw.Tray.WinUI/Windows/StoreMigrationWindow.xaml @@ -0,0 +1,122 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [Fact] public void StartupRefusal_NotifiesTheUserWithoutBlockingLaunch() { - var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); - var branch = helper[helper.IndexOf("if (finalization.AllowsNormalStartup)", StringComparison.Ordinal)..]; - var notice = branch.IndexOf(@"ShowGuidance(""Migration_StoreStartupRefused"")", StringComparison.Ordinal); - Assert.True(notice > 0, "The refusal notice must be raised inside the allows-normal-startup branch."); - Assert.Contains( - "finalization.State == StoreMigrationFinalizationState.StartupPreferenceRefused", - branch[..notice]); - Assert.True(branch.IndexOf("return false;", StringComparison.Ordinal) > notice, - "The refusal notice must be followed by return false so startup still proceeds."); - Assert.DoesNotContain("StartupPreferenceFailed", helper); + var workflow = Read("src", "OpenClaw.Tray.WinUI", "Services", "StoreMigrationWorkflow.cs"); + var refusal = workflow.IndexOf( + "result.State == StoreMigrationFinalizationState.StartupPreferenceRefused", StringComparison.Ordinal); + Assert.True(refusal > 0, "Finalization must special-case a durable startup refusal."); + Assert.True(refusal < workflow.IndexOf("result.AllowsNormalStartup ?", StringComparison.Ordinal), + "The refusal must be handled before it is folded into Ready."); + Assert.Contains("StoreMigrationStage.Ready or StoreMigrationStage.StartupRefused", workflow); + + var window = Read("src", "OpenClaw.Tray.WinUI", "Windows", "StoreMigrationWindow.xaml.cs"); + Assert.Contains(@"LocalizationHelper.GetString(""Migration_StoreStartupRefused"")", window); + // A refused startup preference is the one state with nothing to retry, so it is now the + // only state that collapses the primary action. + Assert.Contains("_workflow.Stage is StoreMigrationStage.StartupRefused", window); + Assert.Contains("or StoreMigrationStage.StartupRefused ? Dismiss : Primary", window); } [Theory] @@ -239,17 +327,18 @@ string Value(string key) => resources.Root!.Elements("data") } [Fact] - public void StorePreviewChoices_UseNativeYesNoWithoutActionLegends() + public void StoreMigrationChoices_UseDirectButtonLabelsWithoutNativeActionLegends() { var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "StoreMigrationStartupGuard.cs"); - var choice = helper[helper.IndexOf("private static bool ShowChoice(", StringComparison.Ordinal).. - helper.IndexOf("private static void ShowGuidance(", StringComparison.Ordinal)]; - Assert.Contains("ShowChoice(string contentKey)", choice); - Assert.Contains("LocalizationHelper.Format(contentKey,", choice); - Assert.DoesNotContain("primaryKey", choice); - Assert.DoesNotContain("secondaryKey", choice); - Assert.DoesNotContain("\\r\\n", choice); - Assert.Contains("0x00000124) == 6", choice); + Assert.DoesNotContain("ShowChoice(", helper); + Assert.DoesNotContain("MessageBoxW", helper); + var window = Read("src", "OpenClaw.Tray.WinUI", "Windows", "StoreMigrationWindow.xaml.cs"); + Assert.Contains("Primary.Content = LocalizationHelper.GetString(", window); + Assert.Contains("Dismiss.Content = LocalizationHelper.GetString(", window); + Assert.Contains("\"Migration_StoreMigrate\" : \"Migration_StoreRetry\"", window); + Assert.Contains("\"Migration_StoreNotNow\" : \"Migration2_Close\"", window); + Assert.DoesNotContain("Migration_StoreYes", window); + Assert.DoesNotContain("Migration_StoreNo\"", window); } [Fact] @@ -265,6 +354,7 @@ public void CompletedMigrationGuard_PrecedesSettingsAndActivation() Assert.True(guard < launch.IndexOf("new SettingsManager()", StringComparison.Ordinal)); Assert.DoesNotContain("MigrationRecordCodec", app); var helper = Read("src", "OpenClaw.Tray.WinUI", "Helpers", "InnoMigrationStartupGuard.cs"); + Assert.Contains("#if !INNO_MIGRATION_PREVIEW && !PRODUCTION_MIGRATION", helper); Assert.Contains("AppIdentity.IsDev || PackageHelper.IsPackaged", helper); Assert.Contains("MigrationRecordCodec.ReadCompletion", helper); Assert.Contains("Migration_InnoCompleted", helper); @@ -274,6 +364,8 @@ public void CompletedMigrationGuard_PrecedesSettingsAndActivation() var lockFailure = helper[acquire..helper.IndexOf("var path =", StringComparison.Ordinal)]; Assert.Contains("InvalidDataException", lockFailure); Assert.Contains("return ShowGuidance(\"Migration_InnoCheckFailed\")", lockFailure); + var receiptRejected = helper[helper.IndexOf("Migration completion receipt rejected", StringComparison.Ordinal)..]; + Assert.Contains("return false;", receiptRejected); Assert.DoesNotContain("_innoMigrationLease", Read("src", "OpenClaw.Tray.WinUI", "App.AppShutdownCoordinator.cs")); Assert.DoesNotContain("_innoMigrationLease?.Dispose", app); } @@ -380,6 +472,44 @@ public void CleanupScript_RechecksReceiptBeforeAnyDestructiveWork() Assert.DoesNotContain("Test-InnoMigration", logger); } + [Fact] + public void CleanupScript_RefusesToDeleteThroughAReparsePoint() + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + var cleanup = script[script.IndexOf("$identityDir = Join-Path $gatewaysDir $id", StringComparison.Ordinal)..]; + var guard = cleanup.IndexOf("[System.IO.FileAttributes]::ReparsePoint", StringComparison.Ordinal); + var delete = cleanup.IndexOf("Remove-Item -LiteralPath $identityDir -Recurse", StringComparison.Ordinal); + + // Remove-Item -Recurse follows junctions on Windows PowerShell 5.1, so the guard has + // to run first or the recursive delete destroys whatever the junction targets. + Assert.True(guard > 0, "Identity cleanup must reject reparse points."); + Assert.True(guard < delete, "The reparse-point guard must precede the recursive delete."); + Assert.Contains(@"\A[A-Za-z0-9._-]+\z", cleanup); + + // Checking only the leaf is not enough: a junction at 'gateways' redirects the whole + // subtree while the identity directory itself still looks ordinary. + var walk = cleanup.IndexOf("$probe = $identityDir", StringComparison.Ordinal); + Assert.True(walk > 0 && walk < delete, "The guard must walk ancestors, not just the leaf."); + Assert.Contains("Split-Path -Path $probe -Parent", cleanup); + } + + [Fact] + public void Installer_StopsTheReparseWalkAtAUncShareRoot() + { + var installer = Read("installer.iss"); + var walk = installer[installer.IndexOf("function MigrationPathIsOrdinary", StringComparison.Ordinal).. + installer.IndexOf("function InitializeUninstall:", StringComparison.Ordinal)]; + + // ExtractFileDir has no fixed point on a UNC path: it yields '\\server', which is not + // a filesystem object. Probing it fails with a code that is neither 2 nor 3, so the + // guard would Exit False and refuse the uninstall on redirected AppData. + Assert.Contains("MigrationPathIsUncRoot", installer); + var stop = walk.IndexOf("if MigrationPathIsUncRoot(Path) then", StringComparison.Ordinal); + var ascend = walk.IndexOf("Parent := ExtractFileDir(Path);", StringComparison.Ordinal); + Assert.True(stop > 0, "The walk must recognise a UNC share root."); + Assert.True(stop < ascend, "The walk must stop at the share root before ascending past it."); + } + [Fact] public void CleanupScript_BoundsEveryChildProcessWait() { @@ -647,8 +777,7 @@ private static void AssertBoundedProcessHelper(string script) @"\$stderr = \$process\.StandardError\.ReadToEndAsync\(\)\s+\r?\n?\s*" + @"if \(-not \$process\.WaitForExit\(\$TimeoutMilliseconds\)\)", script); - Assert.Contains("ExitCode = [int]$process.ExitCode", script); - } + Assert.Contains("ExitCode = [int]$process.ExitCode", script); } [Fact] public void Installer_HoldsMigrationLockThroughEntireUninstall() @@ -674,6 +803,22 @@ public void Installer_HoldsMigrationLockThroughEntireUninstall() Assert.Single(Regex.Matches(installer, @"CloseMigrationOperationFile\(MigrationOperationHandle\)")); } + [Fact] + public void Installer_ReadsAttributeFailureAsSignedSentinel() + { + var installer = Read("installer.iss"); + var scan = installer[installer.IndexOf("function MigrationPathIsOrdinary", StringComparison.Ordinal).. + installer.IndexOf("function InitializeUninstall:", StringComparison.Ordinal)]; + + // Pascal Script resolves the type of an unsigned $FFFFFFFF literal + // inconsistently, so the failure sentinel is compared as a signed -1. + Assert.Contains("function MigrationPathAttributes(FileName: String): Integer;", installer); + Assert.Contains("Attributes: Integer;", scan); + Assert.Contains("if Attributes = -1 then", scan); + Assert.DoesNotContain("= $FFFFFFFF", scan); + Assert.Contains("else if (Attributes and $400) <> 0 then", scan); + } + [Fact] public void RecordPackageIdentity_MatchesStoreManifest() { @@ -683,6 +828,164 @@ public void RecordPackageIdentity_MatchesStoreManifest() Assert.Equal(OpenClaw.Connection.Migration.MigrationRecordCodec.PackagePublisher, identity.Attribute("Publisher")!.Value); } + [Theory] + [InlineData("gateways")] + [InlineData("identity")] + [InlineData("child")] + public void CleanupScript_DoesNotDeleteThroughAJunction(string junctionAt) + { + // The shipped guard text is executed against a real junction. The source-order assertions + // above passed while the guard still checked only the leaf, so they cannot stand alone. + var temp = Path.Combine(Path.GetTempPath(), "oc-junction-" + Guid.NewGuid().ToString("N")); + var sentinel = Path.Combine(temp, "valuable"); + var dataDir = Path.Combine(temp, "data"); + var gateways = Path.Combine(dataDir, "gateways"); + var identity = Path.Combine(gateways, "victim"); + try + { + Directory.CreateDirectory(dataDir); + if (junctionAt == "gateways") + { + Directory.CreateDirectory(Path.Combine(sentinel, "victim")); + File.WriteAllText(Path.Combine(sentinel, "victim", "keep.txt"), "keep"); + Junction(gateways, sentinel); + } + else if (junctionAt == "child") + { + // A junction planted inside the identity directory. The ancestor walk cannot see + // it, and Remove-Item -Recurse on 5.1 deletes straight through it. + Directory.CreateDirectory(sentinel); + File.WriteAllText(Path.Combine(sentinel, "keep.txt"), "keep"); + Directory.CreateDirectory(identity); + Junction(Path.Combine(identity, "link"), sentinel); + } + else + { + Directory.CreateDirectory(sentinel); + File.WriteAllText(Path.Combine(sentinel, "keep.txt"), "keep"); + Directory.CreateDirectory(gateways); + Junction(identity, sentinel); + } + + var output = RunGuard(dataDir, identity); + + Assert.StartsWith("WARNED", output); + Assert.True(File.Exists(Path.Combine(sentinel, junctionAt == "gateways" ? "victim" : "", "keep.txt")), + $"The junction target was deleted through '{junctionAt}'."); + } + finally + { + TryRemove(gateways); + TryRemove(identity); + TryRemove(temp); + } + } + + [Fact] + public void CleanupScript_StillDeletesAnOrdinaryIdentityDirectory() + { + var temp = Path.Combine(Path.GetTempPath(), "oc-junction-" + Guid.NewGuid().ToString("N")); + var dataDir = Path.Combine(temp, "data"); + var identity = Path.Combine(dataDir, "gateways", "victim"); + try + { + Directory.CreateDirectory(identity); + File.WriteAllText(Path.Combine(identity, "device-key.json"), "{}"); + + var output = RunGuard(dataDir, identity); + + // The guard must not become so strict that ordinary cleanup stops working. + Assert.StartsWith("NOWARN", output); + Assert.False(Directory.Exists(identity)); + } + finally + { + TryRemove(temp); + } + } + + /// + /// Runs the guard exactly as shipped, lifted out of the surrounding uninstall so the test does + /// not touch WSL, scheduled tasks, or the registry. + /// + private static string RunGuard(string dataDir, string identityDir) + { + var script = Read("scripts", "Uninstall-LocalGateway.ps1"); + const string start = "if (Test-Path -LiteralPath $identityDir -PathType Container) {"; + const string end = "Write-GatewayLog \"Deleted identity directory for local gateway record $id.\""; + var from = script.IndexOf(start, StringComparison.Ordinal); + var to = script.IndexOf(end, StringComparison.Ordinal); + Assert.True(from > 0 && to > from, "The identity cleanup guard could not be located."); + var guard = script[from..(to + end.Length)] + "\n}"; + + var harness = $$""" + $ErrorActionPreference = 'Stop' + $DataDir = '{{dataDir}}' + $identityDir = '{{identityDir}}' + $id = 'victim' + $script:warnings = @() + function Add-CleanupWarning { param($Message) $script:warnings += $Message } + function Write-GatewayLog { param($Message) } + foreach ($once in @(1)) { + {{guard}} + } + if ($script:warnings) { "WARNED: $($script:warnings -join '; ')" } else { 'NOWARN' } + """; + + var file = Path.Combine(Path.GetTempPath(), "oc-guard-" + Guid.NewGuid().ToString("N") + ".ps1"); + File.WriteAllText(file, harness); + try + { + // Windows PowerShell 5.1 specifically: that is what Inno runs during uninstall, and + // its Remove-Item is the one that follows junctions. + var info = new System.Diagnostics.ProcessStartInfo("powershell.exe") + { + Arguments = $"-NoProfile -ExecutionPolicy Bypass -File \"{file}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = System.Diagnostics.Process.Start(info)!; + var output = process.StandardOutput.ReadToEnd().Trim(); + var error = process.StandardError.ReadToEnd().Trim(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"Guard harness failed: {error}"); + return output; + } + finally + { + File.Delete(file); + } + } + + private static void Junction(string link, string target) + { + var info = new System.Diagnostics.ProcessStartInfo("cmd.exe") + { + Arguments = $"/d /c mklink /J \"{link}\" \"{target}\"", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var process = System.Diagnostics.Process.Start(info)!; + var error = process.StandardError.ReadToEnd(); + process.WaitForExit(); + Assert.True(process.ExitCode == 0, $"Could not create junction: {error}"); + } + + private static void TryRemove(string path) + { + try + { + if (Directory.Exists(path)) + Directory.Delete(path, true); + } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } + [Fact] public async Task GatewayUninstall_PassesWslControlFlagsUnquoted() { @@ -831,7 +1134,6 @@ function New-Utf16Bleed { Write-Output 'CONTRACT-OK' "; - private static string Read(params string[] segments) => File.ReadAllText(Path.Combine(new[] { TestRepositoryPaths.GetRepositoryRoot() }.Concat(segments).ToArray())); } diff --git a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs index 0488b4649..20447079a 100644 --- a/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs +++ b/tests/OpenClaw.Tray.Tests/LocalizationValidationTests.cs @@ -837,6 +837,49 @@ public void AllLocales_ContainOnboardingKeys() } } + /// + /// Matches an XML entity or numeric character reference that survived XML + /// decoding. A .resw value is stored escaped and decoded once when loaded, + /// so a well-formed resource can never yield one of these after decoding. + /// Seeing one means the literal text was escaped an extra time. + /// + private static readonly Regex SurvivingXmlEntity = new( + @"&(?:#x?[0-9A-Fa-f]+|amp|lt|gt|quot|apos);", + RegexOptions.CultureInvariant | RegexOptions.Compiled); + + [Fact] + public void Resources_DoNotContainOverEscapedXmlEntities() + { + var stringsDir = GetStringsDirectory(); + var failures = new List(); + + foreach (var localeDir in Directory.GetDirectories(stringsDir).OrderBy(d => d, StringComparer.Ordinal)) + { + var reswPath = Path.Combine(localeDir, "Resources.resw"); + if (!File.Exists(reswPath)) continue; + + var locale = Path.GetFileName(localeDir); + foreach (var (key, value) in LoadResw(reswPath)) + { + var match = SurvivingXmlEntity.Match(value); + if (match.Success) + { + failures.Add($"{locale} :: {key} :: renders literal '{match.Value}'"); + } + } + } + + Assert.True( + failures.Count == 0, + "Resource values must not contain XML entities that survive decoding; users see the raw " + + "escape sequence instead of the intended character. This happens when already-escaped text " + + "is escaped again, for example lifting a XAML attribute into .resw ('→' becomes " + + "'&#x2192;') or re-escaping a translated value ('&' becomes '&amp;'). " + + "Store the literal character instead: write an arrow as '\u2192' and an ampersand as a " + + "single '&' in the source text, which the .resw writer escapes exactly once." + + Environment.NewLine + string.Join(Environment.NewLine, failures)); + } + [Fact] public void AllLocales_ContainRuntimeOnboardingKeys() { diff --git a/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs b/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs new file mode 100644 index 000000000..8c9f50a75 --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/Migration2LocalizationTests.cs @@ -0,0 +1,308 @@ +using System.Text.RegularExpressions; +using System.Xml.Linq; + +namespace OpenClaw.Tray.Tests; + +public sealed class Migration2LocalizationTests +{ + private static readonly string[] StoreConsentBodyKeys = + { + "Migration2_Consent", "Migration2_PrepareStepBody", "Migration2_RemoveStepBody", + "Migration2_FinishStepBody", "Migration2_ConsentHint", "Migration2_ConsentWarning" + }; + + private static readonly string[] WizardHeadingKeys = + { + "Migration2_PrepareStepTitle", "Migration2_RemoveStepTitle", "Migration2_FinishStepTitle", + "Migration2_ConsentWarningTitle", "Migration2_CloseInnoTitle", + "Migration2_CloseInnoWarningTitle", "Migration2_RemovalTitle", "Migration2_RemovalWarningTitle" + }; + + private static readonly string[] InnoPromotionKeys = + { + "Migration2_InnoRecommendation.Text", "Migration2_InnoCardTitle.Text", + "Migration2_InnoCardDescription.Text", "Migration2_InnoAction", + "Migration2_InnoConsentTitle" + }; + + [Theory] + [InlineData("en-us", "Microsoft Store opens", "close this app", + "without asking again", "settings, gateway, files, and models stay where they are")] + [InlineData("fr-fr", "Le Microsoft Store s'ouvre", "fermer cette application", + "sans redemander votre accord", + "paramètres, votre passerelle, vos fichiers et vos modèles restent en place")] + [InlineData("nl-nl", "De Microsoft Store wordt geopend", "deze app worden gesloten", + "zonder opnieuw om toestemming te vragen", + "instellingen, gateway, bestanden en modellen blijven op hun plaats")] + [InlineData("pt-br", "A Microsoft Store será aberta", "fechar este aplicativo", + "sem pedir autorização novamente", + "configurações, gateway, arquivos e modelos permanecem onde estão")] + [InlineData("zh-cn", "将打开 Microsoft Store", "关闭此应用", + "无需再次征求您的同意", "您的设置、网关、文件和模型将保留在原处")] + [InlineData("zh-tw", "將開啟 Microsoft Store", "關閉此應用程式", + "無需再次徵求您的同意", "您的設定、閘道、檔案和模型將保留在原處")] + public void InnoDialog_DisclosesSafetyBoundariesInEveryLocale( + string locale, string storeOpens, string closesThisApp, + string noRepeatedPrompt, string retainedData) + { + var resources = ReadResources(locale); + var consent = resources["Migration2_InnoConsent"]; + // The dialog discloses the handoff grant and what it does not touch. Detailed + // removal and finalization guidance is delivered by the Store wizard at the step + // where the user can act on it, so it is deliberately absent here. + foreach (var disclosure in new[] { storeOpens, closesThisApp, noRepeatedPrompt, retainedData }) + Assert.Contains(disclosure, consent); + + Assert.False(string.IsNullOrWhiteSpace(resources["Migration2_InnoConsentTitle"])); + // Chinese locales use the fullwidth question mark. + Assert.Contains(resources["Migration2_InnoConsentTitle"], + title => title is '?' or '\uFF1F'); + Assert.NotEqual(resources["Migration2_InnoAction"], resources["Migration2_InnoConsentTitle"]); + foreach (var nativeChoice in new[] { resources["Migration_StoreYes"], resources["Migration_StoreNo"] }) + { + Assert.DoesNotContain(nativeChoice + ":", consent); + Assert.DoesNotContain(nativeChoice + "\uFF1A", consent); + } + Assert.DoesNotContain("{0}", consent); + Assert.DoesNotContain("{1}", consent); + Assert.DoesNotContain("\u2014", consent); + } + + [Theory] + [InlineData("en-us", "settings, gateway, files, and models where they are", + "safely close the previous app", "check your installation and settings", + "protected migration records", "When prompted, uninstall it manually", + "Nothing is uninstalled automatically", "verify removal before starting the Store version", + "without starting migration", "If validation succeeds", "can no longer start normally")] + [InlineData("fr-fr", "paramètres, votre passerelle, vos fichiers et vos modèles en place", + "fermerons l'application précédente en toute sécurité", + "vérifierons votre installation et vos paramètres", + "données de migration protégées", "Lorsque vous y serez invité, désinstallez-la manuellement", + "Rien ne sera désinstallé automatiquement", "vérifions la désinstallation avant de démarrer la version du Store", + "sans démarrer la migration", "Si la validation réussit", "ne pourra plus démarrer normalement")] + [InlineData("nl-nl", "instellingen, gateway, bestanden en modellen op hun huidige plek", + "sluiten de vorige app veilig af", "controleren uw installatie en instellingen", + "beveiligde migratiegegevens", "Verwijder de app handmatig wanneer daarom wordt gevraagd", + "Er wordt niets automatisch verwijderd", "verifiëren de verwijdering voordat we de Store-versie starten", + "zonder de migratie te starten", "Als de validatie slaagt", "niet meer normaal starten")] + [InlineData("pt-br", "configurações, gateway, arquivos e modelos onde estão", + "fechar o aplicativo anterior com segurança", "verificar sua instalação e configurações", + "registros protegidos de migração", "Quando solicitado, desinstale-o manualmente", + "Nada será desinstalado automaticamente", "Verificamos a remoção antes de iniciar a versão da Store", + "sem iniciar a migração", "Se a validação for bem-sucedida", "não poderá mais iniciar normalmente")] + [InlineData("zh-cn", "设置、网关、文件和模型保留在原处", "安全关闭旧版应用", "检查您的安装和设置", + "受保护的迁移记录", "收到提示后,请手动卸载", "不会自动卸载任何应用", + "验证移除状态后再启动 Store 版本", "不会开始迁移", "如果验证成功", "将无法正常启动")] + [InlineData("zh-tw", "設定、閘道、檔案和模型保留在原處", "安全關閉舊版應用程式", "檢查您的安裝和設定", + "受保護的移轉記錄", "收到提示後,請手動解除安裝", "不會自動解除安裝任何應用程式", + "驗證移除狀態後再啟動 Store 版本", "不會開始移轉", "如果驗證成功", "將無法正常啟動")] + public void StoreComposedConsent_DisclosesSafetyAtTheRelevantStep( + string locale, string retainedData, string gracefulClose, string validation, + string protectedRecords, string promptedManualRemoval, string noAutomaticUninstall, + string verifiedRemoval, string cancelWithoutMigration, string validationCondition, string startupBlock) + { + var resources = ReadResources(locale); + Assert.Contains(retainedData, resources["Migration2_Consent"]); + Assert.Contains(gracefulClose, resources["Migration2_PrepareStepBody"]); + Assert.Contains(validation, resources["Migration2_PrepareStepBody"]); + Assert.Contains(protectedRecords, resources["Migration2_PrepareStepBody"]); + Assert.Contains(promptedManualRemoval, resources["Migration2_RemoveStepBody"]); + Assert.Contains(noAutomaticUninstall, resources["Migration2_RemoveStepBody"]); + Assert.Contains(verifiedRemoval, resources["Migration2_FinishStepBody"]); + Assert.Contains(resources["Migration_StoreNotNow"], resources["Migration2_ConsentHint"]); + Assert.Contains(cancelWithoutMigration, resources["Migration2_ConsentHint"]); + Assert.Contains(validationCondition, resources["Migration2_ConsentWarning"]); + Assert.Contains(startupBlock, resources["Migration2_ConsentWarning"]); + } + + [Theory] + [InlineData("en-us", "tray menu", "Don't force-close or uninstall it yet", + "We'll tell you when it's safe to uninstall", "uninstall", "verify", "finish")] + [InlineData("fr-fr", "menu dans la zone de notification", + "Ne forcez pas sa fermeture et ne la désinstallez pas encore", + "Nous vous indiquerons quand vous pourrez la désinstaller en toute sécurité", + "désinstall", "vérifi", "terminer")] + [InlineData("nl-nl", "menu in het systeemvak", + "Forceer het afsluiten niet en verwijder de app nog niet", + "We laten u weten wanneer u de app veilig kunt verwijderen", "verwijder", "verifi", "ronden")] + [InlineData("pt-br", "menu na bandeja do sistema", + "Não force o encerramento nem desinstale o aplicativo ainda", + "Avisaremos quando for seguro desinstalar", "desinstal", "verificar", "finalizar")] + [InlineData("zh-cn", "托盘菜单", "暂时不要强制关闭或卸载该应用", + "我们会在可以安全卸载时通知您", "卸载", "验证", "完成")] + [InlineData("zh-tw", "系統匣選單", "暫時不要強制關閉或解除安裝該應用程式", + "我們會在可以安全解除安裝時通知您", "解除安裝", "驗證", "完成")] + public void CloseStep_OnlyRequestsGracefulCloseAndRetry( + string locale, string trayMenu, string noForcedExit, string waitForPermission, + string uninstall, string verify, string finish) + { + var resources = ReadResources(locale); + var close = resources["Migration2_CloseInno"]; + var warning = resources["Migration2_CloseInnoWarning"]; + Assert.Contains("OpenClaw Companion", close); + Assert.Contains(trayMenu, close); + Assert.Contains(resources["Migration_StoreRetry"], close); + Assert.Contains(noForcedExit, warning); + Assert.Contains(waitForPermission, warning); + Assert.DoesNotContain(uninstall, close, StringComparison.OrdinalIgnoreCase); + foreach (var futureAction in new[] { verify, finish }) + { + Assert.DoesNotContain(futureAction, close, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(futureAction, warning, StringComparison.OrdinalIgnoreCase); + } + } + + [Theory] + [InlineData("en-us", "setup has been validated", "migration completion recorded", + "Installed apps", "uninstall only the previous OpenClaw Companion app", "Keep the Store version", + "Return here", "verify removal and finish", "settings, gateway, files, and models stay in place", + "Don't delete migration data or run gateway cleanup")] + [InlineData("fr-fr", "configuration a été validée", "fin de la migration enregistrée", + "Applications installées", "désinstallez uniquement l'application OpenClaw Companion précédente", + "Gardez la version du Store", "Revenez ici", "vérifier la désinstallation et terminer", + "paramètres, votre passerelle, vos fichiers et vos modèles restent en place", + "Ne supprimez pas les données de migration et ne lancez pas le nettoyage de la passerelle")] + [InlineData("nl-nl", "configuratie is gevalideerd", "voltooiing van de migratie is vastgelegd", + "Geïnstalleerde apps", "verwijder alleen de vorige OpenClaw Companion-app", "Behoud de Store-versie", + "Keer hier terug", "de verwijdering te verifiëren en af te ronden", + "instellingen, gateway, bestanden en modellen blijven op hun plaats", + "Verwijder geen migratiegegevens en voer geen gatewayopruiming uit")] + [InlineData("pt-br", "configuração foi validada", "conclusão da migração foi registrada", + "Aplicativos instalados", "desinstale apenas o aplicativo OpenClaw Companion anterior", + "Mantenha a versão da Store", "Volte aqui", "verificar a remoção e finalizar", + "configurações, gateway, arquivos e modelos permanecem no mesmo local", + "Não exclua os dados de migração nem execute a limpeza do gateway")] + [InlineData("zh-cn", "配置已通过验证", "迁移完成状态已记录", + "已安装的应用", "仅卸载旧版 OpenClaw Companion 应用", "请保留 Store 版本", + "返回此处", "验证移除状态并完成迁移", "设置、网关、文件和模型将保留在原处", + "请勿删除迁移数据或运行网关清理")] + [InlineData("zh-tw", "設定已通過驗證", "移轉完成狀態已記錄", + "已安裝的應用程式", "僅解除安裝舊版 OpenClaw Companion 應用程式", "請保留 Store 版本", + "返回此處", "驗證移除狀態並完成移轉", "設定、閘道、檔案和模型將保留在原處", + "請勿刪除移轉資料或執行閘道清理")] + public void RemovalStep_HasTwoOrderedActionsAfterValidationAndReceipt( + string locale, string validated, string receipt, string installedApps, + string onlyPreviousApp, string keepStore, string returnHere, + string verifyAndFinish, string retainedData, string noCleanup) + { + var resources = ReadResources(locale); + var paragraphs = Regex.Split(resources["Migration2_AwaitingRemoval"].Trim(), @"\r?\n\s*\r?\n"); + Assert.Equal(4, paragraphs.Length); + Assert.Contains(validated, paragraphs[0]); + Assert.Contains(receipt, paragraphs[0]); + Assert.StartsWith("1. ", paragraphs[1]); + Assert.Contains(installedApps, paragraphs[1]); + Assert.Contains(onlyPreviousApp, paragraphs[1]); + Assert.Contains(keepStore, paragraphs[1]); + Assert.StartsWith("2. ", paragraphs[2]); + Assert.Contains(returnHere, paragraphs[2]); + Assert.Contains(resources["Migration_StoreRetry"], paragraphs[2]); + Assert.Contains(verifyAndFinish, paragraphs[2]); + Assert.Contains(retainedData, paragraphs[3]); + Assert.Equal(2, Regex.Matches(resources["Migration2_AwaitingRemoval"], @"(?m)^\d+\. ").Count); + Assert.Contains(noCleanup, resources["Migration2_RemovalWarning"]); + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void WizardHeadingsBodiesAndWarnings_ArePresentAndLocalized(string locale) + { + var resources = ReadResources(locale); + var english = ReadResources("en-us"); + foreach (var key in WizardHeadingKeys.Concat(StoreConsentBodyKeys).Concat(new[] + { + "Migration2_CloseInno", "Migration2_CloseInnoWarning", + "Migration2_AwaitingRemoval", "Migration2_RemovalWarning" + })) + { + Assert.False(string.IsNullOrWhiteSpace(resources[key]), key); + Assert.DoesNotContain("\u2014", resources[key]); + Assert.DoesNotContain("{0}", resources[key]); + Assert.DoesNotContain("{1}", resources[key]); + if (locale != "en-us") + Assert.NotEqual(english[key], resources[key]); + } + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void InnoPromotion_IsPresentAndLocalized(string locale) + { + var resources = ReadResources(locale); + var english = ReadResources("en-us"); + foreach (var key in InnoPromotionKeys) + { + Assert.False(string.IsNullOrWhiteSpace(resources[key]), key); + Assert.DoesNotContain("\u2014", resources[key]); + if (locale != "en-us") + Assert.NotEqual(english[key], resources[key]); + } + } + + [Fact] + public void EnglishWizardCopy_StaysWithinWordBudgetsExcludingHeadings() + { + var resources = ReadResources("en-us"); + AssertWordBudget(110, StoreConsentBodyKeys); + AssertWordBudget(40, "Migration2_CloseInno", "Migration2_CloseInnoWarning"); + AssertWordBudget(75, "Migration2_AwaitingRemoval", "Migration2_RemovalWarning"); + + void AssertWordBudget(int maximum, params string[] keys) + { + var words = Regex.Matches(string.Join(" ", keys.Select(key => resources[key])), @"\S+").Count; + Assert.InRange(words, 1, maximum); + } + } + + [Fact] + public void EnglishShippingGuidance_DoesNotRequireReopeningStoreOrUsePreviewCopy() + { + var resources = ReadResources("en-us"); + foreach (var key in StoreConsentBodyKeys.Concat(WizardHeadingKeys).Concat(new[] + { + "Migration2_InnoConsent", "Migration2_CloseInno", "Migration2_CloseInnoWarning", + "Migration2_AwaitingRemoval", "Migration2_RemovalWarning" + })) + { + Assert.DoesNotContain("reopen", resources[key], StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("preview", resources[key], StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public void RecoveryGuidance_NamesTheRemedyInsteadOfForbiddingIt() + { + var recovery = ReadResources("en-us")["Migration2_Recovery"]; + // The previous copy forbade the only remedy that works, which left recovery a dead end. + Assert.DoesNotContain("Do not uninstall the previous app", recovery); + Assert.Contains("Installed apps", recovery); + Assert.Contains("Retry", recovery); + } + + [Theory] + [InlineData("en-us")] + [InlineData("fr-fr")] + [InlineData("nl-nl")] + [InlineData("pt-br")] + [InlineData("zh-cn")] + [InlineData("zh-tw")] + public void DiscardAction_IsNamedInEveryLocale(string locale) => + Assert.False(string.IsNullOrWhiteSpace(ReadResources(locale)["Migration2_DiscardRecords"])); + + private static Dictionary ReadResources(string locale) => XDocument.Load(Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), + "src", "OpenClaw.Tray.WinUI", "Strings", locale, "Resources.resw")) + .Root!.Elements("data").ToDictionary( + element => element.Attribute("name")!.Value, + element => element.Element("value")!.Value, + StringComparer.Ordinal); +} diff --git a/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs b/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs new file mode 100644 index 000000000..73655623a --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/MigrationBuildConfigurationTests.cs @@ -0,0 +1,184 @@ +using System.Diagnostics; +using System.Xml.Linq; +using OpenClaw.TestSupport; + +namespace OpenClaw.Tray.Tests; + +public sealed class MigrationBuildConfigurationTests +{ + [Theory] + [InlineData("win-x64", false)] + [InlineData("win-x64", true)] + [InlineData("win-arm64", false)] + [InlineData("win-arm64", true)] + public async Task Production_InnoAndStoreShareThePinnedReleasePolicy(string runtime, bool packaged) + { + var result = await EvaluateAsync( + ("MigrationProductionEnabled", "true"), + ("Version", "2027.1.1"), + ("RuntimeIdentifier", runtime), + ("PackageMsix", packaged.ToString())); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("PRODUCTION_MIGRATION", result.Output); + Assert.Contains("MigrationStoreProductId=9NFPR3BGDRR5", result.Output); + Assert.Contains("MigrationMinimumSourceVersion=2026.9.5.0", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=2027", result.Output); + Assert.DoesNotContain("MIGRATION_PREVIEW", result.Output); + } + + [Fact] + public async Task PinnedRelease_DefaultsEnableTheShippingPolicy() + { + var result = await EvaluateAsync(("Version", "2027.1.1")); + + Assert.Equal(0, result.ExitCode); + Assert.Contains("PRODUCTION_MIGRATION", result.Output); + Assert.Contains("MigrationStoreProductId=9NFPR3BGDRR5", result.Output); + Assert.Contains("MigrationMinimumSourceVersion=2026.9.5.0", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=2027", result.Output); + Assert.DoesNotContain("MIGRATION_PREVIEW", result.Output); + } + + [Theory] + [InlineData("MigrationMinimumSourceVersion", "", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "2026.9", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "2026.9.5-alpha.1", "MigrationMinimumSourceVersion")] + [InlineData("MigrationMinimumSourceVersion", "0.0.0", "zero version")] + [InlineData("MigrationMinimumSourceVersion", "0.0.0.0", "zero version")] + [InlineData("MigrationMinimumSourceVersion", "2147483648.1.1", "Version")] + [InlineData("MigrationStoreProductId", "not-a-product", "MigrationStoreProductId")] + [InlineData("RuntimeIdentifier", "win-x86", "win-x64 or win-arm64")] + [InlineData("MigrationProductionEnabled", "yes", "true or false")] + public async Task Production_RejectsIncompleteOrInvalidPolicy(string key, string value, string error) + { + var properties = new Dictionary + { + ["MigrationProductionEnabled"] = "true", + ["MigrationMinimumSourceVersion"] = "2026.9.5.0", + [key] = value + }; + var result = await EvaluateAsync(properties.Select(pair => (pair.Key, pair.Value)).ToArray()); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains(error, result.Output); + } + + [Theory] + [InlineData("Debug", "false", "true")] + [InlineData("Debug", "true", "true")] + [InlineData("Release", "true", "true")] + [InlineData("Release", "false", "false")] + public async Task Production_ExcludesDebugDevAndDisabledBuilds( + string configuration, string devBuild, string enabled) + { + var result = await EvaluateAsync( + ("Configuration", configuration), ("DevBuild", devBuild), + ("MigrationProductionEnabled", enabled)); + + Assert.Equal(0, result.ExitCode); + Assert.DoesNotContain("PRODUCTION_MIGRATION", result.Output); + Assert.DoesNotContain("MigrationStoreProductId=", result.Output); + Assert.DoesNotContain("MigrationMinimumSourceVersion=", result.Output); + } + + [Theory] + [InlineData(false, "InnoMigrationPreview", "INNO_MIGRATION_PREVIEW")] + [InlineData(true, "StoreMigrationPreview", "STORE_MIGRATION_PREVIEW")] + public async Task ExplicitDebugPreviewsRemainAvailable(bool packaged, string flag, string symbol) + { + var result = await EvaluateAsync( + ("Configuration", "Debug"), ("PackageMsix", packaged.ToString()), + ("MigrationProductionEnabled", "false"), (flag, "true"), + ("MigrationPreviewStoreProductId", "9NFPR3BGDRR5"), + ("StoreMigrationPreviewMinimumSourceVersion", "2026.9.5.0")); + + Assert.Equal(0, result.ExitCode); + Assert.Contains(symbol, result.Output); + Assert.DoesNotContain("PRODUCTION_MIGRATION", result.Output); + } + + [Theory] + [InlineData(false, "InnoMigrationPreview")] + [InlineData(true, "StoreMigrationPreview")] + public async Task PreviewFlagsCannotShipInRelease(bool packaged, string flag) + { + var result = await EvaluateAsync( + ("MigrationProductionEnabled", "false"), ("PackageMsix", packaged.ToString()), + (flag, "true"), ("StoreMigrationPreviewMinimumSourceVersion", "2026.9.5.0")); + + Assert.NotEqual(0, result.ExitCode); + Assert.Contains("restricted to Debug", result.Output); + } + + [Fact] + public void TrayImportsOneCanonicalMigrationBuildPolicy() + { + var directory = Path.Combine(TestRepositoryPaths.GetRepositoryRoot(), "src", "OpenClaw.Tray.WinUI"); + var project = XDocument.Load(Path.Combine(directory, "OpenClaw.Tray.WinUI.csproj")); + Assert.Single(project.Descendants("Import"), element => + (string?)element.Attribute("Project") == "Migration.Build.props"); + var policy = XDocument.Load(Path.Combine(directory, "Migration.Build.props")); + Assert.Equal("9NFPR3BGDRR5", policy.Descendants("MigrationStoreProductId").Single().Value); + var minimum = policy.Descendants("MigrationMinimumSourceVersion").Single().Value; + Assert.Equal("2026.9.5.0", minimum); + Assert.DoesNotContain("$(Version)", minimum); + Assert.DoesNotContain("GitVersion", minimum); + Assert.Equal("true", policy.Descendants("MigrationProductionEnabled").Single().Value); + Assert.Equal("CoreCompile", policy.Descendants("Target") + .Single(element => (string?)element.Attribute("Name") == "ValidateMigrationBuild") + .Attribute("BeforeTargets")!.Value); + } + + private static async Task<(int ExitCode, string Output)> EvaluateAsync(params (string Key, string Value)[] overrides) + { + using var temp = new TempDirectory(); + var root = TestRepositoryPaths.GetRepositoryRoot(); + var properties = new Dictionary + { + ["Configuration"] = "Release", + ["RuntimeIdentifier"] = "win-x64", + ["DevBuild"] = "false", + ["PackageMsix"] = "false" + }; + var path = temp.Combine("migration-build.proj"); + new XDocument(new XElement("Project", + new XElement("PropertyGroup", properties.Select(pair => new XElement(pair.Key, pair.Value))), + new XElement("Import", new XAttribute("Project", + Path.Combine(root, "src", "OpenClaw.Tray.WinUI", "Migration.Build.props"))), + new XElement("Target", new XAttribute("Name", "Probe"), + new XAttribute("DependsOnTargets", "ValidateMigrationBuild"), + new XElement("Message", new XAttribute("Importance", "high"), + new XAttribute("Text", "MIGRATION-DEFINES=$(DefineConstants)")), + new XElement("Message", new XAttribute("Importance", "high"), + new XAttribute("Text", "@(AssemblyAttribute->'%(_Parameter1)=%(_Parameter2)', '|')"))))) + .Save(path); + var start = new ProcessStartInfo("dotnet") + { + WorkingDirectory = root, + UseShellExecute = false, + RedirectStandardOutput = true, + RedirectStandardError = true, + CreateNoWindow = true + }; + foreach (var argument in new[] { "msbuild", path, "-t:Probe", "-nologo", "-v:minimal" }) + start.ArgumentList.Add(argument); + foreach (var (key, value) in overrides) + start.ArgumentList.Add($"-p:{key}={value}"); + using var process = Process.Start(start) ?? throw new InvalidOperationException("Could not start migration build validation."); + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromMinutes(1)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) when (timeout.IsCancellationRequested) + { + process.Kill(entireProcessTree: true); + await process.WaitForExitAsync(); + throw new TimeoutException("Migration build validation timed out."); + } + return (process.ExitCode, $"{await stdout}\n{await stderr}"); + } +} diff --git a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj index 3812cd5a2..a22423de8 100644 --- a/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj +++ b/tests/OpenClaw.Tray.Tests/OpenClaw.Tray.Tests.csproj @@ -93,6 +93,8 @@ + + diff --git a/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs b/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs new file mode 100644 index 000000000..00aa45e2b --- /dev/null +++ b/tests/OpenClaw.Tray.Tests/StoreMigrationWorkflowTests.cs @@ -0,0 +1,632 @@ +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Services; + +namespace OpenClaw.Tray.Tests; + +public sealed class StoreMigrationWorkflowTests +{ + [Fact] + public async Task WithoutExplicitConsent_DoesNotCloseOrMutateSource() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + Assert.Equal(new[] { "inspect", "consent?" }, operations.Calls); + Assert.False(workflow.IsBusy); + } + + [Fact] + public async Task ExplicitConfirmation_RecordsConsentBeforeGracefulShutdownAndCompletion() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "grant", "close", "prepare", "complete" }, operations.Calls); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.DoesNotContain("finalize", operations.Calls); + } + + [Fact] + public async Task ValidHandoff_SkipsSecondPromptButNotAdoptionValidation() + { + var operations = new Operations { Consent = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "consent?", "close", "prepare", "complete" }, operations.Calls); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + } + + [Fact] + public async Task ChangedSourceAfterPrompt_RequiresNewConfirmation() + { + var operations = new Operations(); + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Admission = operations.Admission with + { + Installation = operations.Admission.Installation! with { Version = new Version(2026, 10, 1) } + }; + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + Assert.Equal(new[] { "inspect" }, operations.Calls); + } + + [Fact] + public async Task ShutdownUnavailable_ManualRetryReinspectsAndThenCompletes() + { + var operations = new Operations { Consent = true, Closed = false }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.CloseSource, workflow.Stage); + Assert.DoesNotContain("prepare", operations.Calls); + operations.Closed = true; + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.Equal(2, operations.Calls.Count(call => call == "inspect")); + } + + [Theory] + [InlineData(StoreMigrationPreparationState.InnoRunning, StoreMigrationStage.CloseSource)] + [InlineData(StoreMigrationPreparationState.SourceChanged, StoreMigrationStage.Unsupported)] + [InlineData(StoreMigrationPreparationState.ValidationFailed, StoreMigrationStage.ValidationFailed)] + public async Task FailedPreparation_NeverCommits(StoreMigrationPreparationState result, object stage) + { + var operations = new Operations { Consent = true, Prepared = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + Assert.DoesNotContain("complete", operations.Calls); + } + + [Theory] + [InlineData(StoreMigrationCompletionState.InnoRunning, StoreMigrationStage.CloseSource)] + [InlineData(StoreMigrationCompletionState.SourceChanged, StoreMigrationStage.Unsupported)] + public async Task FailedCompletion_DoesNotOfferRemoval(StoreMigrationCompletionState result, object stage) + { + var operations = new Operations { Consent = true, Completed = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + } + + [Fact] + public async Task SourceRestartsBeforeCompletion_RetryReinspectsAndPreservesConsent() + { + var operations = new Operations { Consent = true, Completed = StoreMigrationCompletionState.InnoRunning }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.CloseSource, workflow.Stage); + operations.Completed = StoreMigrationCompletionState.Completed; + operations.Calls.Clear(); + + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.Equal(new[] { "inspect", "consent?", "close", "prepare", "complete" }, operations.Calls); + } + + [Theory] + [InlineData(StoreMigrationStartupState.UpdateInno, StoreMigrationStage.UpdateRequired)] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation, StoreMigrationStage.Unsupported)] + [InlineData(StoreMigrationStartupState.InspectionFailed, StoreMigrationStage.InspectionFailed)] + [InlineData(StoreMigrationStartupState.RecoveryRequired, StoreMigrationStage.Recovery)] + [InlineData(StoreMigrationStartupState.AwaitingInnoRemoval, StoreMigrationStage.AwaitingRemoval)] + public async Task BlockedAdmission_CannotBeOverriddenByConsent( + StoreMigrationStartupState admission, object stage) + { + var operations = new Operations { Admission = new(admission), Consent = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + Assert.Equal(new[] { "inspect" }, operations.Calls); + } + + [Theory] + [InlineData(StoreMigrationStartupState.UpdateInno, false)] + [InlineData(StoreMigrationStartupState.UnsupportedInstallation, false)] + [InlineData(StoreMigrationStartupState.InspectionFailed, false)] + [InlineData(StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(StoreMigrationStartupState.AwaitingInnoRemoval, true)] + public async Task InformationalAdmission_DoesNotKeepTheAppFromStarting( + StoreMigrationStartupState admission, bool blocks) + { + var operations = new Operations { Admission = new(admission), Consent = true }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(blocks, workflow.BlocksStartup); + } + + [Fact] + public async Task ReceiptWrittenThisSession_BlocksStartupEvenAfterAFailedStage() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.InspectionFailed + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + // The stage reports an inspection failure, but the completion receipt still exists. + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public void BeforeAdmissionResolves_ClosingIsTreatedAsBlocking() + { + // Dismiss is reachable while the first inspection is still in flight. Nothing is known + // yet, so closing must not wave the user past a handoff that already moved data. + var workflow = Create(new Operations()); + + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task ARetryThatNoLongerSeesTheReceipt_DoesNotReleaseTheBlock() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.InspectionFailed + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + + // Retry re-inspects. A transient failure there must not drop what we already know. + operations.Admission = new(StoreMigrationStartupState.InspectionFailed); + await workflow.ContinueAsync(CancellationToken.None); + + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task AdmissionCarryingAReceiptUnderAnInformationalState_Blocks() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.UnsupportedInstallation, null, true) + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Unsupported, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task SuccessfulFinalization_ReleasesTheStartupBlock() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.Finalized + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task CompletionWrittenDuringMigration_BlocksStartup() + { + var operations = new Operations { Consent = true }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.AwaitingRemoval, workflow.Stage); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task AbandonedConsent_LeavesStartupUnblocked() + { + var operations = new Operations(); + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Theory] + [InlineData(StoreMigrationFinalizationState.AwaitingInnoRemoval, StoreMigrationStage.AwaitingRemoval)] + // A transient startup-preference failure still finalizes the migration, so the app proceeds. + // A durable refusal is the only one the user must be told about. + [InlineData(StoreMigrationFinalizationState.StartupPreferenceFailed, StoreMigrationStage.Ready)] + [InlineData(StoreMigrationFinalizationState.StartupPreferenceRefused, StoreMigrationStage.StartupRefused)] + [InlineData(StoreMigrationFinalizationState.RecordCleanupFailed, StoreMigrationStage.FinalizationFailed)] + [InlineData(StoreMigrationFinalizationState.InspectionFailed, StoreMigrationStage.InspectionFailed)] + public async Task RemovalRetry_ResumesOnlyAfterSuccessfulFinalization( + StoreMigrationFinalizationState result, object stage) + { + var operations = new Operations { Admission = new(StoreMigrationStartupState.AwaitingInnoRemoval), Finalized = result }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Admission = new(StoreMigrationStartupState.FinalizationRequired); + operations.Calls.Clear(); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(new[] { "inspect", "finalize" }, operations.Calls); + Assert.Equal((StoreMigrationStage)stage, workflow.Stage); + } + + /// + /// The refusal notice exists to be read, not to trap the user: Windows alone can re-enable the + /// startup task, so the window must surface the stage while still letting the app launch. + /// + [Fact] + public async Task DurableStartupRefusal_SurfacesTheNoticeWithoutBlockingStartup() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.FinalizationRequired), + Finalized = StoreMigrationFinalizationState.StartupPreferenceRefused + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.StartupRefused, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_IsVisibleAndNeverStartsNormally() + { + var workflow = Create(new Operations { InspectError = new IOException("locked") }); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.False(workflow.IsBusy); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_KeepsBlockingWhenAReceiptIsOnDisk() + { + // Inspection threw before producing a decision, so the receipt was never observed. + // Closing the window here would resume startup against data that already moved. + var operations = new Operations { InspectError = new IOException("locked"), Receipt = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.InspectionFailed, workflow.Stage); + Assert.Contains("receipt?", operations.Calls); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_BlocksWhenTheReceiptCannotBeConfirmed() + { + var workflow = Create(new Operations + { + InspectError = new IOException("locked"), + ReceiptError = new UnauthorizedAccessException("denied") + }); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task InspectionFailure_CannotDropAReceiptTheCallerAlreadyObserved() + { + // The startup guard inspects first; a failing second pass inside the workflow must not + // discard what that first pass proved. + var seed = new StoreMigrationStartupDecision( + StoreMigrationStartupState.InspectionFailed, null, HoldsCompletionReceipt: true); + var workflow = new StoreMigrationWorkflow( + new Operations { InspectError = new IOException("locked") }, NullLogger.Instance, seed); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + } + + [Fact] + public async Task CorruptConsent_IsPreservedAndCannotLoopThroughConfirmation() + { + var operations = new Operations { GrantError = new InvalidDataException("corrupt consent") }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + operations.Calls.Clear(); + + // Recovery is retryable, so a retry re-inspects. What it must never do is treat the + // earlier confirmation as still standing and grant again over the preserved record. + await workflow.ContinueAsync(CancellationToken.None); + + Assert.DoesNotContain("grant", operations.Calls); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + } + + [Fact] + public async Task Recovery_RetriesInsteadOfStranding() + { + var operations = new Operations { Admission = new(StoreMigrationStartupState.RecoveryRequired) }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + // The remedy for the usual cause is removing the previous app, which only a fresh pass sees. + operations.Admission = new(StoreMigrationStartupState.NotRequired); + operations.Calls.Clear(); + + await workflow.ContinueAsync(CancellationToken.None); + + Assert.Contains("inspect", operations.Calls); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task DiscardIsOfferedOnlyForRecordsThatCannotBeRead(bool unreadable) + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired), + Unreadable = unreadable + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.Equal(unreadable, workflow.CanDiscardRecords); + } + + [Fact] + public async Task FailingToClassifyRecords_HidesTheDiscardOfferInsteadOfFailing() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired), + UnreadableError = new UnauthorizedAccessException("denied") + }; + var workflow = Create(operations); + + await workflow.StartAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.False(workflow.CanDiscardRecords); + } + + [Fact] + public async Task DiscardingUnreadableRecords_ReleasesTheStartupBlockAndReinspects() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.True(workflow.BlocksStartup); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(new[] { "discard", "inspect" }, operations.Calls); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + Assert.False(workflow.BlocksStartup); + } + + [Fact] + public async Task AFailedDiscard_KeepsTheReceiptHoldAndStaysInRecovery() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.Busy + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(new[] { "discard" }, operations.Calls); + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.True(workflow.BlocksStartup); + // A contended discard is worth retrying, so the offer stays. + Assert.True(workflow.CanDiscardRecords); + // The usual cause is the previous app still holding the lease, which the user can act on + // only if the window says so. A silent no-op makes the button look dead. + Assert.Equal(StoreMigrationDiscardState.Busy, workflow.LastDiscard); + } + + [Fact] + public async Task ARetryAfterAContendedDiscard_ClearsTheReport() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.Busy + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + await workflow.DiscardRecordsAsync(CancellationToken.None); + Assert.Equal(StoreMigrationDiscardState.Busy, workflow.LastDiscard); + + await workflow.ContinueAsync(CancellationToken.None); + + // Otherwise the discard message outlives the screen its button belongs to. + Assert.Null(workflow.LastDiscard); + } + + [Fact] + public async Task ASuccessfulDiscard_LeavesNothingToReport() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Null(workflow.LastDiscard); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + // Nothing for the window to report: the re-inspection that follows a successful discard + // supersedes the result. + Assert.Null(workflow.LastDiscard); + Assert.Equal(StoreMigrationStage.Ready, workflow.Stage); + } + + [Fact] + public async Task DiscardingReadableRecords_IsRefusedAndWithdrawsTheOffer() + { + var operations = new Operations + { + Admission = new(StoreMigrationStartupState.RecoveryRequired, null, true), + Unreadable = true, + Discarded = StoreMigrationDiscardState.RecordsAreReadable + }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Equal(StoreMigrationStage.Recovery, workflow.Stage); + Assert.True(workflow.BlocksStartup); + Assert.False(workflow.CanDiscardRecords); + } + + [Fact] + public async Task DiscardIsRefusedOutsideRecovery() + { + var operations = new Operations { Unreadable = true }; + var workflow = Create(operations); + await workflow.StartAsync(CancellationToken.None); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + operations.Calls.Clear(); + + await workflow.DiscardRecordsAsync(CancellationToken.None); + + Assert.Empty(operations.Calls); + Assert.Equal(StoreMigrationStage.Consent, workflow.Stage); + } + + [Fact] + public async Task ConcurrentClicks_DoNotDuplicatePreparation() + { + var pause = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var operations = new Operations { Consent = true, CloseTask = pause.Task }; + var workflow = Create(operations); + var first = workflow.StartAsync(CancellationToken.None); + await workflow.ContinueAsync(CancellationToken.None); + Assert.Equal(1, operations.Calls.Count(call => call == "inspect")); + pause.SetResult(true); + await first; + Assert.Equal(1, operations.Calls.Count(call => call == "complete")); + } + + [Fact] + public async Task CancelledBeforePreparation_DoesNotCommit() + { + using var cancellation = new CancellationTokenSource(); + var pause = new TaskCompletionSource(); + var operations = new Operations { Consent = true, CloseTask = pause.Task }; + var workflow = Create(operations); + var work = workflow.StartAsync(cancellation.Token); + cancellation.Cancel(); + pause.SetResult(true); + await work; + Assert.DoesNotContain("prepare", operations.Calls); + Assert.NotEqual(StoreMigrationStage.Ready, workflow.Stage); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData("9EXAMPLE00000&x=1")] + [InlineData("https://example.com")] + [InlineData("9example00000")] + public void StoreListing_RejectsAbsentOrUntrustedIdentifiers(string? productId) => + Assert.False(StoreMigrationListing.TryCreateUri(productId, out _)); + + [Fact] + public void StoreListing_UsesOnlyExplicitProductId() + { + Assert.True(StoreMigrationListing.TryCreateUri("9EXAMPLE0000", out var uri)); + Assert.Equal("ms-windows-store://pdp/?ProductId=9EXAMPLE0000", uri!.AbsoluteUri); + } + + private static StoreMigrationWorkflow Create(Operations operations) => new(operations, NullLogger.Instance); + + private sealed class Operations : IStoreMigrationOperations + { + public List Calls { get; } = []; + public StoreMigrationStartupDecision Admission { get; set; } = new(StoreMigrationStartupState.ConsentRequired, + new InnoInstallation(@"C:\fixture", @"C:\fixture\app.exe", @"C:\fixture\unins000.exe", "x64", new Version(2026, 9, 5))); + public bool Consent { get; set; } + public bool Closed { get; set; } = true; + public Task? CloseTask { get; set; } + public Exception? InspectError { get; set; } + public bool Receipt { get; set; } + public Exception? ReceiptError { get; set; } + public Exception? GrantError { get; set; } + public StoreMigrationPreparationState Prepared { get; set; } = StoreMigrationPreparationState.Prepared; + public StoreMigrationCompletionState Completed { get; set; } = StoreMigrationCompletionState.Completed; + public StoreMigrationFinalizationState Finalized { get; set; } = StoreMigrationFinalizationState.Finalized; + public StoreMigrationStartupDecision Inspect() + { + Calls.Add("inspect"); + if (InspectError is not null) throw InspectError; + return Admission; + } + public bool HasConsent(InnoInstallation installation) { Calls.Add("consent?"); return Consent; } + public bool HoldsCompletionReceipt() + { + Calls.Add("receipt?"); + if (ReceiptError is not null) throw ReceiptError; + return Receipt; + } + public void GrantConsent(InnoInstallation installation) + { + Calls.Add("grant"); + if (GrantError is not null) throw GrantError; + Consent = true; + } + public Task CloseSourceAsync(StoreMigrationStartupDecision admission, CancellationToken cancellationToken) + { Calls.Add("close"); return CloseTask ?? Task.FromResult(Closed); } + public Task PrepareAsync(InnoInstallation installation) + { Calls.Add("prepare"); return Task.FromResult(Prepared); } + public Task CompleteAsync(InnoInstallation installation) + { Calls.Add("complete"); return Task.FromResult(Completed); } + public Task FinalizeAsync() + { Calls.Add("finalize"); return Task.FromResult(new StoreMigrationFinalizationDecision(Finalized)); } + public bool Unreadable { get; set; } + public Exception? UnreadableError { get; set; } + public StoreMigrationDiscardState Discarded { get; set; } = StoreMigrationDiscardState.Discarded; + public bool CanDiscardRecords() + { + if (UnreadableError is not null) throw UnreadableError; + return Unreadable; + } + public StoreMigrationDiscardState DiscardUnreadableRecords() + { + Calls.Add("discard"); + if (Discarded == StoreMigrationDiscardState.Discarded) + { + Unreadable = false; + Admission = new(StoreMigrationStartupState.NotRequired); + } + return Discarded; + } + } +} diff --git a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj index 185e5e045..95278d4ba 100644 --- a/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj +++ b/tests/OpenClaw.Tray.UITests/OpenClaw.Tray.UITests.csproj @@ -57,4 +57,19 @@ + + + + <_TrayTestResourceRoot Include="@(ReferenceCopyLocalPaths)" + Condition="'%(Filename)' == 'OpenClaw.Tray.WinUI' and '%(Extension)' == '.dll'" /> + + + + + + diff --git a/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs new file mode 100644 index 000000000..4e3ae3139 --- /dev/null +++ b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs @@ -0,0 +1,746 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation; +using Microsoft.UI.Xaml.Automation.Peers; +using Microsoft.UI.Xaml.Automation.Provider; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Input; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; +using OpenClaw.Connection.Migration; +using OpenClaw.Shared; +using OpenClawTray.Helpers; +using OpenClawTray.Services; +using OpenClawTray.Windows; +using WinUIEx; +using Xunit.Abstractions; + +namespace OpenClaw.Tray.UITests; + +/// +/// Mounted production-window proof with in-memory operations only, not migration/package proof. +/// Set OPENCLAW_VISUAL_TEST=1 and OPENCLAW_VISUAL_TEST_DIR to capture rendered states. +/// Never invokes Installed apps or constructs the production migration operations. +/// +[Collection(UICollection.Name)] +public sealed class StoreMigrationWindowProofTests(UIThreadFixture ui, ITestOutputHelper output) +{ + [Fact] + public Task Consent_AssignsNativeIconsForTaskbarAndWindowSwitcher() + { + return WithWindowAsync(new Operations(), async (window, workflow, completion) => + { + await WaitForStageAsync(workflow, StoreMigrationStage.Consent); + await ui.RunOnUIAsync(() => + { + var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(window); + const uint wmGetIcon = 0x007f; + foreach (var iconType in new nuint[] { 0, 1 }) + { + var icon = SendMessage(hwnd, wmGetIcon, iconType, 0); + Assert.NotEqual(nint.Zero, icon); + output.WriteLine($"Native window icon type {iconType}: nonzero handle."); + } + }); + await InvokeAsync(window, "Dismiss"); + Assert.True(await completion.WaitAsync(TimeSpan.FromSeconds(10))); + }); + } + + [DllImport("user32.dll", EntryPoint = "SendMessageW", ExactSpelling = true)] + private static extern nint SendMessage(nint hwnd, uint message, nuint wParam, nint lParam); + + [Fact] + public Task Consent_DefaultsToNotNow_AndDismissesWithoutMigration() + { + var operations = new Operations(); + return WithWindowAsync(operations, async (window, workflow, completion) => + { + await WaitForStageAsync(workflow, StoreMigrationStage.Consent); + await ui.RunOnUIAsync(() => + { + AssertState(window, "Consent", consent: true); + var root = Root(window); + Assert.Same(Control - + + diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index c209186ba..66a65c860 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -80,17 +80,13 @@ private async Task OnStoreMigrationAsync() if (await confirmation.ShowAsync() != ContentDialogResult.Primary) return; var messageKey = await InnoMigrationHandoff.GrantAndLaunchAsync(); - StoreMigrationStatus.Message = LocalizationHelper.GetString(messageKey); - StoreMigrationStatus.Severity = messageKey == "Migration2_InnoGranted" - ? InfoBarSeverity.Success : InfoBarSeverity.Error; - StoreMigrationStatus.IsOpen = true; + ShowStoreMigrationStatus(messageKey, messageKey == "Migration2_InnoGranted" + ? InfoBarSeverity.Success : InfoBarSeverity.Error); } catch (Exception exception) { Logger.Error($"Could not show migration consent: {exception.Message}"); - StoreMigrationStatus.Message = LocalizationHelper.GetString("Migration2_InnoFailed"); - StoreMigrationStatus.Severity = InfoBarSeverity.Error; - StoreMigrationStatus.IsOpen = true; + ShowStoreMigrationStatus("Migration2_InnoFailed", InfoBarSeverity.Error); } finally { @@ -98,6 +94,14 @@ private async Task OnStoreMigrationAsync() } } + private void ShowStoreMigrationStatus(string messageKey, InfoBarSeverity severity) + { + StoreMigrationStatus.Message = LocalizationHelper.GetString(messageKey); + StoreMigrationStatus.Severity = severity; + StoreMigrationStatus.Visibility = Visibility.Visible; + StoreMigrationStatus.IsOpen = true; + } + /// /// The Settings view model is assigned as the page DataContext by the navigation activation /// hook. The two-way bindings handle load/persist; the page only subscribes to the view-only diff --git a/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs index 4e3ae3139..3a7448f33 100644 --- a/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs +++ b/tests/OpenClaw.Tray.UITests/StoreMigrationWindowProofTests.cs @@ -44,7 +44,8 @@ await ui.RunOnUIAsync(() => } }); await InvokeAsync(window, "Dismiss"); - Assert.True(await completion.WaitAsync(TimeSpan.FromSeconds(10))); + // Not now leaves the previous app installed, so the Store app must stay inactive. + Assert.False(await completion.WaitAsync(TimeSpan.FromSeconds(10))); }); } @@ -67,7 +68,8 @@ await ui.RunOnUIAsync(() => }); await CaptureAsync(window, "Consent"); await InvokeAsync(window, "Dismiss"); - Assert.True(await completion.WaitAsync(TimeSpan.FromSeconds(10))); + // Not now leaves the previous app installed, so the Store app must stay inactive. + Assert.False(await completion.WaitAsync(TimeSpan.FromSeconds(10))); await ui.RunOnUIAsync(() => Assert.Equal(new[] { "inspect", "consent?" }, operations.Calls)); }); @@ -151,7 +153,8 @@ await WaitUntilAsync(() => await CaptureAsync(window, "Consent-520x520-WarningScrolled"); } await InvokeAsync(window, "Dismiss"); - Assert.True(await completion.WaitAsync(TimeSpan.FromSeconds(10))); + // Not now leaves the previous app installed, so the Store app must stay inactive. + Assert.False(await completion.WaitAsync(TimeSpan.FromSeconds(10))); }); } From 40fc6e730cc3b4cf5b1850f2d84a95d6e71c9a41 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Mon, 28 Sep 2026 11:00:03 -0700 Subject: [PATCH 12/16] fix(migration): keep the Store status InfoBar announced to screen readers Collapsing the bar while idle removed its automation peer, and InfoBar announces through the existing peer only, so the open-time announcement was dropped. Realize the peer before opening, re-open a bar the user never dismissed so a second result is announced instead of silently swapping text, and only re-collapse on a close that stuck. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 16c59d10-1f82-4ce4-9c06-995ce44038ee --- src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml | 2 +- src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs | 14 ++++++++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml index 72306aa47..1427704ed 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml @@ -97,7 +97,7 @@ bottom settings row past the scroll viewport clip. --> + IsOpen="False" IsClosable="True" Closed="OnStoreMigrationStatusClosed"/> diff --git a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs index 66a65c860..0d95de7bb 100644 --- a/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs +++ b/src/OpenClaw.Tray.WinUI/Pages/SettingsPage.xaml.cs @@ -1,5 +1,6 @@ using Microsoft.Toolkit.Uwp.Notifications; using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Automation.Peers; using Microsoft.UI.Xaml.Controls; using OpenClaw.Connection; using OpenClaw.Shared; @@ -98,10 +99,23 @@ private void ShowStoreMigrationStatus(string messageKey, InfoBarSeverity severit { StoreMigrationStatus.Message = LocalizationHelper.GetString(messageKey); StoreMigrationStatus.Severity = severity; + // Re-open a bar the user never dismissed so a second result is announced + // rather than silently swapping the text of the first one. + StoreMigrationStatus.IsOpen = false; StoreMigrationStatus.Visibility = Visibility.Visible; + // The bar is collapsed while idle so it does not consume layout spacing, but a + // collapsed element has no automation peer, and InfoBar announces through the + // existing peer only. Realize it before opening or the announcement is dropped. + FrameworkElementAutomationPeer.CreatePeerForElement(StoreMigrationStatus); StoreMigrationStatus.IsOpen = true; } + private void OnStoreMigrationStatusClosed(InfoBar sender, InfoBarClosedEventArgs args) + { + if (!sender.IsOpen) + sender.Visibility = Visibility.Collapsed; + } + /// /// The Settings view model is assigned as the page DataContext by the navigation activation /// hook. The two-way bindings handle load/persist; the page only subscribes to the view-only From 93d9059e9d152bff428a5a75eea77b0987b1925f Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Mon, 28 Sep 2026 14:30:26 -0700 Subject: [PATCH 13/16] fix(migration): route a prerelease source to update guidance A prerelease Inno DisplayVersion such as 2026.9.5-alpha.19 never parses as a stable release version, so the detector refused it with no RegisteredVersion to compare against the minimum. Startup policy only recognized a version problem when a version had parsed, so a prerelease fell through to UnsupportedInstallation, which tells the user to check the install location, Windows user, and architecture. None of those is the problem, and the obvious way out is removing the source app, which forfeits the migration. Carry the version refusal explicitly so admission can tell it apart from a location, user, or architecture refusal, and route it to the update guidance that already exists and is already localized. Admission is unchanged: both states block startup, and only the message the user reads differs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa05ec-0a5a-461e-aef9-52c6a9c92ba2 --- .../Migration/InnoInstallation.cs | 10 ++++- .../Migration/InnoInstallationDetector.cs | 8 ++-- .../StoreMigrationStartupCoordinator.cs | 7 +++- .../InnoInstallationDetectorTests.cs | 40 +++++++++++++++++++ .../StoreMigrationStartupCoordinatorTests.cs | 25 ++++++++++++ 5 files changed, 85 insertions(+), 5 deletions(-) diff --git a/src/OpenClaw.Connection/Migration/InnoInstallation.cs b/src/OpenClaw.Connection/Migration/InnoInstallation.cs index abf2e435f..0508ced68 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallation.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallation.cs @@ -22,6 +22,13 @@ public sealed record InnoInstallation( /// Verified installation evidence, present only for . /// Why an unsupported or failed inspection reached that conclusion. /// The registered release version, when one could be parsed. +/// +/// Whether the registration was refused because its version is not a stable numeric release, +/// which is the case for a prerelease build. Such a version never parses, so it reaches startup +/// policy with no to compare against the minimum. Without this +/// flag the refusal is indistinguishable from a bad location, user, or architecture, and the +/// user is told to check things that are already correct. +/// /// /// Whether the source tray executable is actually on disk. A registration alone does not prove an /// installed app: a botched or interrupted uninstall can leave the registry entry behind with the @@ -33,7 +40,8 @@ public sealed record InnoInstallationDetection( InnoInstallation? Installation = null, string? Reason = null, Version? RegisteredVersion = null, - bool SourcePayloadPresent = false); + bool SourcePayloadPresent = false, + bool RegisteredVersionUnsupported = false); public interface IInnoInstallationDetector { diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs index 9144d3a29..2e4996629 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -77,7 +77,7 @@ public InnoInstallationDetection Detect() sourcePayloadPresent = HasTrayExecutable(registration); if (!MigrationVersionPolicy.TryParseReleaseVersion(registration.DisplayVersion, out var version)) return Unsupported("The Inno DisplayVersion is not a stable numeric release version.", - sourcePayloadPresent: sourcePayloadPresent); + sourcePayloadPresent: sourcePayloadPresent, registeredVersionUnsupported: true); // Inno's default UninstallDisplayName includes the unnormalized AppVersion. if (registration.DisplayName != $"OpenClaw Companion version {registration.DisplayVersion}" || registration.Publisher != "OpenClaw Foundation") @@ -199,11 +199,13 @@ IOException or UnauthorizedAccessException or } private InnoInstallationDetection Unsupported( - string reason, Version? registeredVersion = null, bool sourcePayloadPresent = false) + string reason, Version? registeredVersion = null, bool sourcePayloadPresent = false, + bool registeredVersionUnsupported = false) { _logger.Warn(reason); return new(InnoInstallationStatus.Unsupported, Reason: reason, - RegisteredVersion: registeredVersion, SourcePayloadPresent: sourcePayloadPresent); + RegisteredVersion: registeredVersion, SourcePayloadPresent: sourcePayloadPresent, + RegisteredVersionUnsupported: registeredVersionUnsupported); } } diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs index 65d66d0e1..cb0f2b3ab 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs @@ -109,7 +109,12 @@ architecture is not ("x64" or "arm64")) { // An installation that predates the migration payload is not unsupportable; it just // needs the update that ships migration. Asking for that is actionable guidance. - return Decide(detected.RegisteredVersion is { } registered && registered < minimum + // A prerelease source lands here as well: its version never parses, so it arrives + // with no version to compare, and reporting it as an unsupported location, user, or + // architecture would send the user to verify things that are already correct. + var needsSourceUpdate = detected.RegisteredVersionUnsupported || + (detected.RegisteredVersion is { } registered && registered < minimum); + return Decide(needsSourceUpdate ? StoreMigrationStartupState.UpdateInno : StoreMigrationStartupState.UnsupportedInstallation, receipt, sourcePayloadPresent: sourcePayloadPresent); diff --git a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs index b0ee1ec9b..c85228c3f 100644 --- a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs @@ -366,6 +366,46 @@ public void MalformedRegisteredVersion_IsUnsupported(string? version) fixture.AssertUnsupported(); } + /// + /// A prerelease source is refused for its version, not for its location, Windows user, or + /// architecture. Startup policy needs that distinction to ask for an update instead of + /// sending the user to verify settings that are already correct. Prerelease Inno installers + /// are published on the releases page, so this is a shipped path, not a theoretical one. + /// + [Theory] + [InlineData("2026.9.5-alpha.19")] + [InlineData("2026.9.4-alpha.1")] + [InlineData("1.2.3-beta")] + [InlineData("")] + [InlineData(null)] + public void AnUnreadableRegisteredVersion_IsReportedAsAVersionRefusal(string? version) + { + using var fixture = new Fixture(); + fixture.Registration = fixture.Registration with { DisplayVersion = version }; + + var result = fixture.Detect(); + + Assert.Equal(InnoInstallationStatus.Unsupported, result.Status); + Assert.True(result.RegisteredVersionUnsupported); + Assert.Null(result.RegisteredVersion); + } + + /// + /// The counterpart: a refusal that has nothing to do with the version must not borrow the + /// update guidance, or every unsupported installation would be told to update. + /// + [Fact] + public void ARefusalUnrelatedToTheVersion_IsNotReportedAsAVersionRefusal() + { + using var fixture = new Fixture(); + fixture.Source.Binary = fixture.Source.Binary with { Version = "2026.9.18" }; + + var result = fixture.Detect(); + + Assert.Equal(InnoInstallationStatus.Unsupported, result.Status); + Assert.False(result.RegisteredVersionUnsupported); + } + [Theory] [InlineData(null)] [InlineData("2026.9.18")] diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs index 7ba19ef5a..293be82a4 100644 --- a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs @@ -171,6 +171,31 @@ public void UnsupportedInstallationBelowMinimum_AsksForAnUpdateInstead( Assert.Equal(expected, result.State); } + /// + /// A prerelease source never parses, so it reaches admission with no version to compare + /// against the minimum. Before the explicit version refusal it fell through to + /// UnsupportedInstallation, which tells the user to check the install location, Windows + /// user, and architecture. None of those is the problem, and the only obvious way out is + /// removing the source app, which forfeits the migration. + /// + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + public void AnUnreadableSourceVersion_AsksForAnUpdateWithoutWideningTheBlock( + bool payloadPresent, bool blocks) + { + var detection = new InnoInstallationDetection( + InnoInstallationStatus.Unsupported, + Reason: "The Inno DisplayVersion is not a stable numeric release version.", + SourcePayloadPresent: payloadPresent, RegisteredVersionUnsupported: true); + + var result = Evaluate(detection, new(MigrationStartupRecordStatus.None)); + + Assert.Equal(StoreMigrationStartupState.UpdateInno, result.State); + Assert.Equal(blocks, result.BlocksStartup); + Assert.False(result.AllowsNormalStartup); + } + /// /// End to end through admission rather than on a hand-built decision: an installed source that /// is too old to migrate keeps the Store app inactive, and the same state without a payload From dde0a9bfb8726f35f10ec195511728ab02981f91 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Mon, 28 Sep 2026 14:46:24 -0700 Subject: [PATCH 14/16] docs(releasing): correct the prerelease refusal message in release QA The prerelease routing fix changed what a prerelease source is told, but the release guide still described both a prerelease suffix and a mismatched name as producing the unsupported-installation message. Release QA would look for the wrong message when validating the tag. Describe the two refusals separately. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa05ec-0a5a-461e-aef9-52c6a9c92ba2 --- docs/RELEASING.md | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 79f9c8584..74c6236bf 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -219,9 +219,12 @@ would keep the Store app closed after the previous app was already gone. The rem unhappy paths, a failed inspection or a record needing recovery, inform the user and then continue to normal startup, because refusing to launch cannot repair either one. Still confirm before the tag that the released Inno installer registers -`DisplayVersion` `2026.9.5` and `DisplayName` `OpenClaw Companion version 2026.9.5`: -a prerelease suffix or a mismatched name is rejected as an unsupported installation, -so that user is told migration is unavailable instead of being offered it. +`DisplayVersion` `2026.9.5` and `DisplayName` `OpenClaw Companion version 2026.9.5`. +Both are refused, and each produces a different message, so check for the right one. +A prerelease suffix is a version refusal: that user is told to update the previous app +to a supported version first. A mismatched name is an unsupported installation: that +user is told migration is unavailable. Neither is offered migration, so the registered +values still have to be exact. ### Developer migration test package From ac5ff7812ae02e5f75e086905a82bcf927badf53 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 29 Sep 2026 13:39:27 -0700 Subject: [PATCH 15/16] fix(migration): recover verified orphaned Inno registrations Uninstalling the Inno app can leave its HKCU uninstall key behind. The detector classified that leftover key as Unsupported, so the Store app refused to start while the old executable was already gone, leaving the user with no working app at all. Add an OrphanedRegistration status for a canonical HKCU registration whose identity checks pass but which has neither an executable nor an uninstaller payload, and admit it to finalization instead of blocking. Machine-wide and conflicting registrations are still rejected before the orphan check runs, and finalization independently re-verifies removal rather than trusting the earlier detection. Replace File.Exists with a tri-state probe so absence is proven rather than assumed: an unreadable or access-denied probe now fails closed and does not count as removed. A single surviving remnant stays Unsupported. After a successful recovery the leftover registry entry no longer triggers the migration window on later launches, because the receipt is cleared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa05ec-0a5a-461e-aef9-52c6a9c92ba2 --- .../Migration/InnoInstallation.cs | 14 +++ .../Migration/InnoInstallationDetector.cs | 40 +++++- .../StoreMigrationFinalizationCoordinator.cs | 47 ++++++- .../StoreMigrationRecoveryDiscard.cs | 6 +- .../StoreMigrationStartupCoordinator.cs | 8 +- .../InnoInstallationDetectorTests.cs | 116 ++++++++++++++++++ ...reMigrationFinalizationCoordinatorTests.cs | 83 +++++++++++++ .../StoreMigrationRecoveryDiscardTests.cs | 24 +++- .../StoreMigrationStartupCoordinatorTests.cs | 35 ++++++ 9 files changed, 360 insertions(+), 13 deletions(-) diff --git a/src/OpenClaw.Connection/Migration/InnoInstallation.cs b/src/OpenClaw.Connection/Migration/InnoInstallation.cs index 0508ced68..645db43a0 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallation.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallation.cs @@ -4,6 +4,20 @@ public enum InnoInstallationStatus { NotInstalled, Detected, + + /// + /// A canonical current-user registration whose payload is positively gone: the registration + /// passed every identity, location, and uninstall-command check, and both the tray executable + /// and the uninstaller are definitely absent rather than merely unreadable. + /// + /// This is deliberately not . An uninstall that removes the payload + /// but leaves the registration behind would otherwise be refused forever: the receipt holds a + /// durable startup block, the source can never come back, and the user is left with neither + /// the previous app nor the Store app. Absence proved here still authorizes nothing on its + /// own; finalization re-verifies removal independently before acting. + /// + /// + OrphanedRegistration, Unsupported, InspectionFailed } diff --git a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs index 2e4996629..ef77e4176 100644 --- a/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs +++ b/src/OpenClaw.Connection/Migration/InnoInstallationDetector.cs @@ -93,6 +93,21 @@ public InnoInstallationDetection Detect() return Unsupported("The Inno uninstall command must contain only the quoted canonical unins000.exe path.", sourcePayloadPresent: sourcePayloadPresent); + // Decided ahead of the payload sweep below. That sweep reports a missing tray + // executable as an installation too old to migrate and carries a version for admission + // to offer as an update, which for a registration with no payload at all would send the + // user to update an app that is no longer installed. Both the executable and the + // uninstaller must be positively absent: an unreadable path, an unexpected object + // shape, or a denied probe is unknown, and unknown must never qualify as removed. + if (_source.IsDefinitelyAbsent(executable) && _source.IsDefinitelyAbsent(uninstaller)) + { + const string orphanReason = + "The canonical Inno registration has neither an executable nor an uninstaller payload."; + _logger.Warn(orphanReason); + return new(InnoInstallationStatus.OrphanedRegistration, Reason: orphanReason, + RegisteredVersion: version); + } + foreach (var name in new[] { TrayExecutableName, "unins000.exe", "app-identity.txt", @@ -142,7 +157,8 @@ public InnoInstallationDetection Detect() return Unsupported("The installed executable is not a valid native PE executable.", sourcePayloadPresent: sourcePayloadPresent); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException or + InvalidDataException or ArgumentException or NotSupportedException) { const string reason = "The Inno installation could not be inspected."; _logger.Error(reason, ex); @@ -219,6 +235,14 @@ internal interface IInnoInstallationReadSource { InnoInstallationRegistration? ReadRegistration(RegistryHive hive, RegistryView view); bool IsOrdinaryFile(string path); + + /// + /// Whether the path is proved to hold nothing. Distinct from , + /// which answers false for a directory or device of the same name and so cannot be + /// read as absence. Only a not-found result proves absence here; every other failure throws + /// so the inspection fails closed rather than reporting a payload as removed. + /// + bool IsDefinitelyAbsent(string path); string ReadIdentity(string path); InnoInstallationExecutable ReadExecutable(string path); } @@ -253,6 +277,20 @@ public bool IsOrdinaryFile(string path) catch (DirectoryNotFoundException) { return false; } } + public bool IsDefinitelyAbsent(string path) + { + MigrationRecordCodec.RejectReparsePoints(path); + try + { + _ = File.GetAttributes(path); + return false; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return true; + } + } + public string ReadIdentity(string path) { MigrationRecordCodec.RejectReparsePoints(path); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs index 4456e5b4f..8ef4ed2f1 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationFinalizationCoordinator.cs @@ -148,8 +148,16 @@ public InnoSourceRemovalStatus VerifyRemoved() var uninstaller = Path.Combine(_sourceDirectory, "unins000.exe"); MigrationRecordCodec.RejectReparsePoints(executable); MigrationRecordCodec.RejectReparsePoints(uninstaller); - if (File.Exists(executable) || File.Exists(uninstaller)) + // This is the last gate before records are cleaned and the handoff is declared + // finished, so absence has to be proved rather than assumed. File.Exists answers + // false for a denied probe, an invalid path, and a directory of the same name alike, + // which would report a source that is merely unreadable as removed. + var executableState = ProbeSourceObject(executable); + var uninstallerState = ProbeSourceObject(uninstaller); + if (executableState == SourceObjectState.Present || uninstallerState == SourceObjectState.Present) return InnoSourceRemovalStatus.SourcePresent; + if (executableState == SourceObjectState.Unknown || uninstallerState == SourceObjectState.Unknown) + return InnoSourceRemovalStatus.InspectionFailed; var activity = new InnoSourceActivityVerifier(executable, _processes).VerifyStopped(); if (activity == InnoSourceActivityStatus.Running) @@ -161,8 +169,8 @@ public InnoSourceRemovalStatus VerifyRemoved() return createdNew ? InnoSourceRemovalStatus.Removed : InnoSourceRemovalStatus.SourcePresent; } catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or - SecurityException or Win32Exception or - InvalidOperationException or ArgumentException) + SecurityException or Win32Exception or InvalidDataException or + InvalidOperationException or ArgumentException or NotSupportedException) { return InnoSourceRemovalStatus.InspectionFailed; } @@ -174,6 +182,32 @@ private static string CanonicalizeDirectory(string directory) throw new ArgumentException("Source installation directory must be absolute.", nameof(directory)); return Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); } + + private enum SourceObjectState { Absent, Present, Unknown } + + /// + /// Any object at the path counts as present, whatever its shape: the question here is whether + /// the source is gone, and something occupying the executable's name is not gone. Only a + /// not-found result proves absence; anything that merely could not be probed is unknown and + /// must leave the caller blocking. + /// + private static SourceObjectState ProbeSourceObject(string path) + { + try + { + _ = File.GetAttributes(path); + return SourceObjectState.Present; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return SourceObjectState.Absent; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or + SecurityException or ArgumentException or NotSupportedException) + { + return SourceObjectState.Unknown; + } + } } [SupportedOSPlatform("windows")] @@ -392,7 +426,12 @@ public async Task FinalizeAsync() return new(StoreMigrationFinalizationState.AwaitingInnoRemoval); if (detected.Status is InnoInstallationStatus.Unsupported or InnoInstallationStatus.InspectionFailed) return new(StoreMigrationFinalizationState.InspectionFailed); - if (detected.Status != InnoInstallationStatus.NotInstalled) + // An orphaned registration is admitted here for the same reason admission allows it: the + // payload is proved gone and only the registry key remains. It is not taken as permission + // to finalize. The removal verifier below still has to return Removed on its own probes, + // so a surviving executable, uninstaller, process, or mutex continues to block. + if (detected.Status is not (InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration)) throw new InvalidOperationException("Unknown Inno installation detection result."); var sourceRemovalStatus = sourceRemoval.VerifyRemoved(); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs index c0f85ebfa..5b2f68e3e 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationRecoveryDiscard.cs @@ -148,7 +148,11 @@ private List Plan() if (completion == RecordState.Readable) return []; - var sourceGone = detector.Detect().Status == InnoInstallationStatus.NotInstalled; + // An orphaned registration counts as gone for the same reason admission does: the payload + // is proved absent. Without this a readable consent or intent left beside an orphan would + // be undiscardable, and recovery would offer a button that clears nothing. + var sourceGone = detector.Detect().Status is InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration; var targets = new List(); Consider(Path.Combine(directory, MigrationRecordCodec.ConsentFileName), "consent"); Consider(Path.Combine(directory, MigrationRecordCodec.IntentFileName), "intent"); diff --git a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs index cb0f2b3ab..df73b2c54 100644 --- a/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs +++ b/src/OpenClaw.Connection/Migration/StoreMigrationStartupCoordinator.cs @@ -120,7 +120,13 @@ architecture is not ("x64" or "arm64")) sourcePayloadPresent: sourcePayloadPresent); } - if (detected.Status == InnoInstallationStatus.NotInstalled) + // An orphaned canonical registration is treated exactly as an absent source. Its payload + // has been positively proved gone, so the leftover registry key is the only thing that + // separates it from NotInstalled, and refusing on a registry key alone is what would + // leave the user with no app at all. This grants nothing: a completed handoff still goes + // to finalization, which independently re-verifies removal before it acts. + if (detected.Status is InnoInstallationStatus.NotInstalled or + InnoInstallationStatus.OrphanedRegistration) { return Decide(pending.Status switch { diff --git a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs index c85228c3f..dff56e482 100644 --- a/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs +++ b/tests/OpenClaw.Connection.Tests/InnoInstallationDetectorTests.cs @@ -739,6 +739,113 @@ public void Dispose() } } + /// + /// An uninstall that removed the payload but left its registration behind. The registration + /// is otherwise canonical, so the only thing separating it from an absent source is a stale + /// registry key, and refusing on that alone leaves the user with no app at all. + /// + [Fact] + public void ACanonicalRegistrationWithNoPayloadAtAll_IsOrphanedRatherThanUnsupported() + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload("OpenClaw.Tray.WinUI.exe")); + File.Delete(fixture.Payload("unins000.exe")); + + var result = fixture.Detect(); + + Assert.Equal(InnoInstallationStatus.OrphanedRegistration, result.Status); + Assert.Null(result.Installation); + Assert.False(result.SourcePayloadPresent); + Assert.Equal(new Version(2026, 9, 17, 0), result.RegisteredVersion); + } + + /// + /// Half a payload is not a removed source. Either remnant can still be executed or resumed, + /// so both must be gone before the registration can be treated as an orphan. + /// + [Theory] + [InlineData("OpenClaw.Tray.WinUI.exe")] + [InlineData("unins000.exe")] + public void ARegistrationWithASurvivingRemnant_IsNotOrphaned(string survivor) + { + using var fixture = new Fixture(); + foreach (var name in new[] { "OpenClaw.Tray.WinUI.exe", "unins000.exe" }) + { + if (!string.Equals(name, survivor, StringComparison.Ordinal)) + File.Delete(fixture.Payload(name)); + } + + Assert.Equal(InnoInstallationStatus.Unsupported, fixture.Detect().Status); + } + + /// + /// Absence has to be proved. A probe that could not answer is unknown, and treating unknown + /// as removed is what would let the Store app start beside a source that is really there. + /// + [Fact] + public void APayloadProbeThatCannotAnswer_DoesNotQualifyAsOrphaned() + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload("OpenClaw.Tray.WinUI.exe")); + File.Delete(fixture.Payload("unins000.exe")); + fixture.Source.FileError = new UnauthorizedAccessException("Access is denied."); + fixture.Source.FileErrorPath = fixture.InstallDirectory; + + Assert.Equal(InnoInstallationStatus.InspectionFailed, fixture.Detect().Status); + } + + /// + /// The second probe is reached only because the first proved absence, so it is the one that + /// can still be answered wrongly. A refusal there must fail the whole inspection rather than + /// letting a half-proved payload qualify as removed. + /// + [Fact] + public void AnUninstallerProbeThatCannotAnswer_DoesNotQualifyAsOrphaned() + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload("OpenClaw.Tray.WinUI.exe")); + File.Delete(fixture.Payload("unins000.exe")); + fixture.Source.FileError = new UnauthorizedAccessException("Access is denied."); + fixture.Source.FileErrorPath = fixture.Payload("unins000.exe"); + + Assert.Equal(InnoInstallationStatus.InspectionFailed, fixture.Detect().Status); + } + + /// + /// The orphan outcome is admitted only after the registration itself is established as the + /// canonical current-user source. A registration that fails those checks stays unsupported + /// however little payload it has, so an empty directory cannot launder a bad registration. + /// + [Theory] + [InlineData("publisher")] + [InlineData("location")] + [InlineData("uninstall")] + [InlineData("machine")] + public void ANonCanonicalRegistrationWithNoPayload_StaysUnsupported(string defect) + { + using var fixture = new Fixture(); + File.Delete(fixture.Payload("OpenClaw.Tray.WinUI.exe")); + File.Delete(fixture.Payload("unins000.exe")); + var registration = fixture.Registration; + switch (defect) + { + case "publisher": + fixture.Registration = registration with { Publisher = "Someone Else" }; + break; + case "location": + fixture.Registration = registration with { InstallLocation = fixture.Temp.Combine("Elsewhere") }; + break; + case "uninstall": + fixture.Registration = registration with { UninstallString = "cmd.exe /c whatever" }; + break; + case "machine": + fixture.Source.Registrations[(RegistryHive.LocalMachine, RegistryView.Registry64)] = registration; + break; + } + + Assert.Equal(InnoInstallationStatus.Unsupported, fixture.Detect().Status); + } + private sealed class Fixture : IDisposable { public TempDirectory Temp { get; } = new(); @@ -807,6 +914,15 @@ public bool IsOrdinaryFile(string path) return _files.IsOrdinaryFile(path); } + public bool IsDefinitelyAbsent(string path) + { + FileReads.Add(path); + if (FileError is not null && + (FileErrorPath is null || path.StartsWith(FileErrorPath, StringComparison.OrdinalIgnoreCase))) + throw FileError; + return _files.IsDefinitelyAbsent(path); + } + public string ReadIdentity(string path) { FileReads.Add(path); diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs index d957ffc9b..0896f6bc3 100644 --- a/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationFinalizationCoordinatorTests.cs @@ -116,6 +116,89 @@ public async Task UnverifiableRegistrySource_FailsClosedAndKeepsReceipt(InnoInst Assert.True(File.Exists(fixture.CompletionPath)); } + /// + /// The last gate before the handoff is declared finished, so absence must be proved rather + /// than assumed. A directory occupying the executable's name is not a removed source, but an + /// existence check answers false for it exactly as it does for a path that is truly gone. + /// + [Theory] + [InlineData("OpenClaw.Tray.WinUI.exe")] + [InlineData("unins000.exe")] + public void ADirectoryOccupyingASourceName_BlocksRemoval(string name) + { + using var fixture = new Fixture(); + Directory.CreateDirectory(Path.Combine(fixture.Binding.InstallDirectory, name)); + + var result = new InnoSourceRemovalVerifier( + fixture.Binding, $"OpenClawMigrationTest-{Guid.NewGuid():N}").VerifyRemoved(); + + Assert.Equal(InnoSourceRemovalStatus.SourcePresent, result); + } + + /// + /// The recovery this whole outcome exists for: an uninstall removed the payload but left the + /// registration behind. Finalization must complete rather than refuse forever, which would + /// leave the user with neither the previous app nor the Store app. + /// + [Fact] + public async Task OrphanedRegistrationWithTheSourceGone_Finalizes() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + var autoStart = new AutoStart(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.OrphanedRegistration), + new InnoSourceRemovalVerifier(fixture.Binding, $"OpenClawMigrationTest-{Guid.NewGuid():N}"), + autoStart); + + Assert.Equal(StoreMigrationFinalizationState.Finalized, result.State); + Assert.True(result.SourceRemoved); + Assert.False(File.Exists(fixture.CompletionPath)); + } + + /// + /// Admitting the orphan outcome grants nothing on its own. Detection said the payload was + /// gone, but the verifier answers again here, and its answer is the one that decides. + /// + [Fact] + public async Task OrphanedRegistrationWhoseSourceReappears_WaitsWithoutMutation() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + File.WriteAllText(Path.Combine(fixture.Binding.InstallDirectory, "OpenClaw.Tray.WinUI.exe"), "source"); + var autoStart = new AutoStart(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.OrphanedRegistration), + new InnoSourceRemovalVerifier(fixture.Binding, $"OpenClawMigrationTest-{Guid.NewGuid():N}"), + autoStart); + + Assert.Equal(StoreMigrationFinalizationState.AwaitingInnoRemoval, result.State); + Assert.False(result.SourceRemoved); + Assert.Equal(0, autoStart.Calls); + Assert.True(File.Exists(fixture.CompletionPath)); + } + + /// + /// An access failure that arises only after admission still fails closed: the orphan outcome + /// moves where the check happens, never whether removal has to be proved. + /// + [Fact] + public async Task OrphanedRegistrationWithAnUnprovableSource_FailsClosedAndKeepsReceipt() + { + using var fixture = new Fixture(); + fixture.WriteRecords(); + + var result = await fixture.FinalizeAsync( + new(InnoInstallationStatus.OrphanedRegistration), + new SourceRemoval(InnoSourceRemovalStatus.InspectionFailed)); + + Assert.Equal(StoreMigrationFinalizationState.InspectionFailed, result.State); + Assert.False(result.SourceRemoved); + Assert.True(File.Exists(fixture.CompletionPath)); + } + [Fact] public async Task UncertainSourceRemoval_FailsClosedAndKeepsReceipt() { diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationRecoveryDiscardTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationRecoveryDiscardTests.cs index 39833e988..51a41fe92 100644 --- a/tests/OpenClaw.Connection.Tests/StoreMigrationRecoveryDiscardTests.cs +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationRecoveryDiscardTests.cs @@ -76,14 +76,18 @@ public void ACorruptReceiptBesideAValidIntent_CostsOnlyTheReceipt() /// /// Recovery after the source app is gone: an intent that still decodes can never be acted on, - /// because there is nothing left to migrate from. Retry cannot clear it, so discard must. + /// because there is nothing left to migrate from. Retry cannot clear it, so discard must. An + /// orphaned registration qualifies for the same reason: its payload is positively absent, and + /// without it recovery would offer a discard button that clears nothing. /// - [Fact] - public void AnAbandonedIntent_IsDiscardableOnceTheSourceIsGone() + [Theory] + [InlineData(InnoInstallationStatus.NotInstalled)] + [InlineData(InnoInstallationStatus.OrphanedRegistration)] + public void AnAbandonedIntent_IsDiscardableOnceTheSourceIsGone(InnoInstallationStatus source) { Seed(); WriteRecord("intent"); - _detector.Status = InnoInstallationStatus.NotInstalled; + _detector.Status = source; Assert.True(Discarder().CanDiscard()); Assert.Equal(StoreMigrationDiscardState.Discarded, Discarder().Discard()); @@ -92,10 +96,18 @@ public void AnAbandonedIntent_IsDiscardableOnceTheSourceIsGone() Assert.Equal(MigrationStartupRecordStatus.None, Read().Status); } - [Fact] - public void AValidIntent_IsKeptWhileTheSourceIsStillInstalled() + /// + /// An unsupported install is not a removed one. Nothing was proved about its payload, so a + /// decodable intent still belongs to a source that may yet be there. + /// + [Theory] + [InlineData(InnoInstallationStatus.Detected)] + [InlineData(InnoInstallationStatus.Unsupported)] + [InlineData(InnoInstallationStatus.InspectionFailed)] + public void AValidIntent_IsKeptWhileTheSourceIsStillInstalled(InnoInstallationStatus source) { Seed(); + _detector.Status = source; var intent = WriteRecord("intent"); var before = File.ReadAllBytes(intent); diff --git a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs index 293be82a4..ec5d6e4b5 100644 --- a/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs +++ b/tests/OpenClaw.Connection.Tests/StoreMigrationStartupCoordinatorTests.cs @@ -71,6 +71,41 @@ public void AbsentInno_OnlyFreshStateAllowsNormalStartup( Assert.Equal(allowsNormal, result.AllowsNormalStartup); } + /// + /// A registration left behind by an uninstall that already removed the payload. The source is + /// gone, so the surviving registry key must route exactly like an absent install: a completed + /// handoff finalizes, and a later launch with no records left starts normally instead of + /// showing the migration window forever. + /// + [Theory] + [InlineData(MigrationStartupRecordStatus.None, StoreMigrationStartupState.NotRequired, true)] + [InlineData(MigrationStartupRecordStatus.Intent, StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(MigrationStartupRecordStatus.Completed, StoreMigrationStartupState.FinalizationRequired, false)] + [InlineData(MigrationStartupRecordStatus.Invalid, StoreMigrationStartupState.RecoveryRequired, false)] + [InlineData(MigrationStartupRecordStatus.Unavailable, StoreMigrationStartupState.InspectionFailed, false)] + public void OrphanedRegistration_RoutesLikeAnAbsentInstall( + MigrationStartupRecordStatus pending, StoreMigrationStartupState expected, bool allowsNormal) + { + var result = Evaluate(new(InnoInstallationStatus.OrphanedRegistration), new(pending)); + + Assert.Equal(expected, result.State); + Assert.Equal(allowsNormal, result.AllowsNormalStartup); + } + + /// + /// An orphan of an old version must not be told to update. The payload is gone, so "install a + /// newer version of the app you no longer have" is advice the user cannot act on. + /// + [Fact] + public void AnOrphanedRegistrationOfAnOldVersion_IsNotAskedToUpdate() + { + var result = Evaluate( + new(InnoInstallationStatus.OrphanedRegistration, RegisteredVersion: new Version(2026, 8, 31, 0)), + new(MigrationStartupRecordStatus.Completed)); + + Assert.Equal(StoreMigrationStartupState.FinalizationRequired, result.State); + } + [Theory] [InlineData(InnoInstallationStatus.Unsupported, StoreMigrationStartupState.UnsupportedInstallation)] [InlineData(InnoInstallationStatus.InspectionFailed, StoreMigrationStartupState.InspectionFailed)] From 59e06bcd5ffadf73ee016722e54070fb2dacfa39 Mon Sep 17 00:00:00 2001 From: Natalie Aguinaldo Date: Tue, 29 Sep 2026 13:39:37 -0700 Subject: [PATCH 16/16] fix(migration): apply the startup task off the UI thread when finalizing Finalization can run inside OnLaunched, before the app's XAML content is composed. Calling StartupTask.GetAsync on the UI thread there fail-fasts through Microsoft.UI.Xaml as a stowed exception (0xc000027b), which kills the process past every managed catch, so no crash log and no .NET Runtime event is produced. Because the process died before the receipt was cleared, the receipt stayed on disk and every subsequent launch crashed the same way, permanently locking the user out of the Store app. Marshal the WinRT call off the UI thread so the failure stays catchable and reaches the existing finalization error handling. Verified on a real machine with a signed MSIX: finalization now completes, auto-start is applied, and the receipt is cleared. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b5aa05ec-0a5a-461e-aef9-52c6a9c92ba2 --- .../Helpers/StoreMigrationOperations.cs | 8 +++++++- tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs | 7 +++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs index 9c38c5572..47b4215f3 100644 --- a/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs +++ b/src/OpenClaw.Tray.WinUI/Helpers/StoreMigrationOperations.cs @@ -119,7 +119,13 @@ public async Task ApplyAsync(bool enabled) { try { - await AutoStartManager.SetAutoStartAsync(enabled); + // Finalization can run during OnLaunched, before the app's XAML content is + // composed. Calling StartupTask.GetAsync on the UI thread there fails fast + // through Microsoft.UI.Xaml (STATUS_STOWED_EXCEPTION 0xc000027b), which kills + // the process past every managed catch and leaves the receipt behind, so the + // next launch crashes the same way. Marshalling the WinRT call off the UI + // thread keeps the failure catchable. + await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false); } catch (AutoStartRefusedException exception) when (exception.IsDurable) { diff --git a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs index d4770297c..c5eec11bf 100644 --- a/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs +++ b/tests/OpenClaw.Tray.Tests/InnoMigrationContractTests.cs @@ -46,6 +46,13 @@ public void StorePreviewGuard_PrecedesInstanceForwardingAndNormalServices() Assert.Contains("new InnoSourceRemovalVerifier(_binding!, AppIdentity.MutexBaseName)", helper); Assert.Contains("new MigrationInventoryCapture(_binding!)", helper); Assert.Contains("AutoStartManager.SetAutoStartAsync(enabled)", helper); + // Finalization can run inside OnLaunched, before XAML content exists. StartupTask.GetAsync + // on the UI thread there fail-fasts through Microsoft.UI.Xaml (0xc000027b), bypassing every + // managed catch and stranding the receipt so every later launch dies the same way. The call + // must stay marshalled off the UI thread. + Assert.Contains( + "await Task.Run(() => AutoStartManager.SetAutoStartAsync(enabled)).ConfigureAwait(false)", + helper); Assert.DoesNotContain(".GetAwaiter().GetResult()", helper); // A Windows startup refusal is a durable answer, not a transient failure. The applier must // translate it so finalization clears the receipt instead of blocking every future launch.