diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 0f2f252b..78a6ccb7 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -60,11 +60,15 @@ jobs:
- name: Verify Sandbox report contract (Windows PowerShell 5.1)
shell: powershell
- run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1
+ run: |
+ ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1
+ ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1
- name: Verify Sandbox report contract (PowerShell 7)
shell: pwsh
- run: ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1
+ run: |
+ ./ClashSharp/SandboxTest/Test-SandboxReportContract.ps1
+ ./ClashSharp/SandboxTest/Test-SandboxStartupEvidence.ps1
- name: Build Release
run: dotnet build ClashSharp/ClashSharp.slnx -c Release -p:Platform=x64 --no-restore
diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs
new file mode 100644
index 00000000..b670d1f6
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.Services.cs
@@ -0,0 +1,31 @@
+namespace ClashSharp.ApplicationModel.Data;
+
+public sealed partial class DataGenerationManager
+{
+ /// Resolves a generation-owned service and pins its complete asynchronous operation before transition can retire it.
+ /// Service provided by the scope's owned lifetime through .
+ /// Immutable operation result that can outlive the scope.
+ /// Owned operation; it must await all work and must not return a service or live repository handle.
+ /// Cancels acquisition and is passed to the owned operation without abandoning its task.
+ public async Task ExecuteAsync(
+ Func> operation,
+ CancellationToken cancellationToken) where TService : class
+ {
+ ArgumentNullException.ThrowIfNull(operation);
+ await using DataGenerationLease lease = await AcquireAsync(cancellationToken).ConfigureAwait(false);
+ TService service = lease.Scope.GetOwnedService();
+ return await operation(service, lease.Descriptor, cancellationToken).ConfigureAwait(false);
+ }
+
+ /// Captures an immutable in-memory projection under a short synchronous generation pin without blocking on asynchronous work.
+ /// Service provided by this generation's owned lifetime.
+ /// Immutable projection that can outlive the scope.
+ /// Pure synchronous reader; it must not perform I/O, start tasks, or return a live service or repository.
+ public TResult ReadSnapshot(Func capture)
+ where TService : class
+ {
+ ArgumentNullException.ThrowIfNull(capture);
+ using DataGenerationLease lease = AcquireCore(CancellationToken.None);
+ return capture(lease.Scope.GetOwnedService(), lease.Descriptor);
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs
index 35117e90..3377ec11 100644
--- a/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs
+++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationManager.cs
@@ -72,6 +72,9 @@ public void Initialize(
/// Cancels acquisition before a lease is granted.
/// A lease that must cover the complete repository operation.
public ValueTask AcquireAsync(CancellationToken cancellationToken)
+ => ValueTask.FromResult(AcquireCore(cancellationToken));
+
+ private DataGenerationLease AcquireCore(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_syncLock)
@@ -93,7 +96,7 @@ public ValueTask AcquireAsync(CancellationToken cancellatio
_leaseCount++;
}
- return ValueTask.FromResult(new DataGenerationLease(this, _currentScope));
+ return new DataGenerationLease(this, _currentScope);
}
}
diff --git a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs
index bf6165b3..e468f682 100644
--- a/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs
+++ b/ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs
@@ -12,7 +12,7 @@ public sealed class DataGenerationScope : IAsyncDisposable
/// Initializes a paused scope without starting work or touching the filesystem.
/// Immutable generation descriptor.
- /// Optional composite repository lifetime transferred to this scope.
+ /// Optional composite repository lifetime transferred to this scope; scoped service access also requires it to implement .
public DataGenerationScope(
DataGenerationDescriptor descriptor,
IAsyncDisposable? ownedLifetime = null)
@@ -37,6 +37,17 @@ public DataGenerationScopeState State
}
}
+ internal TService GetOwnedService() where TService : class
+ {
+ if (_ownedLifetime is not IServiceProvider provider
+ || provider.GetService(typeof(TService)) is not TService service)
+ {
+ throw new InvalidOperationException("The requested service is not registered in this generation's owned lifetime.");
+ }
+
+ return service;
+ }
+
/// Disposes an unclaimed staged scope; claimed scopes remain owner-controlled.
public ValueTask DisposeAsync()
{
diff --git a/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs
new file mode 100644
index 00000000..df6e6fde
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Data/RepositoryOperationLifetime.cs
@@ -0,0 +1,65 @@
+namespace ClashSharp.ApplicationModel.Data;
+
+/// Retires a repository only after every accepted operation has completed its full storage work.
+public sealed class RepositoryOperationLifetime : IAsyncDisposable
+{
+ private readonly object _gate = new();
+ private readonly object _repository;
+ private readonly TaskCompletionSource _drained = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _operations;
+ private bool _retired;
+
+ /// Creates a lifetime without accessing storage or accepting any operations.
+ /// Repository identified by a rejection after retirement.
+ public RepositoryOperationLifetime(object repository)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ }
+
+ /// Accepts one operation whose lease must span all asynchronous work and compensation.
+ /// An idempotent lease released when the complete operation leaves the repository.
+ public IDisposable Enter()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_retired, _repository);
+ _operations++;
+ return new Operation(this);
+ }
+ }
+
+ /// Rejects new operations immediately and asynchronously waits for accepted operations to drain.
+ /// The shared completion of repository retirement.
+ public ValueTask DisposeAsync()
+ {
+ lock (_gate)
+ {
+ _retired = true;
+ if (_operations == 0)
+ {
+ _drained.TrySetResult();
+ }
+
+ return new ValueTask(_drained.Task);
+ }
+ }
+
+ private void Leave()
+ {
+ lock (_gate)
+ {
+ _operations--;
+ if (_retired && _operations == 0)
+ {
+ _drained.TrySetResult();
+ }
+ }
+ }
+
+ private sealed class Operation(RepositoryOperationLifetime owner) : IDisposable
+ {
+ private RepositoryOperationLifetime? _owner = owner;
+
+ public void Dispose() => Interlocked.Exchange(ref _owner, null)?.Leave();
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs
new file mode 100644
index 00000000..2010c125
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs
@@ -0,0 +1,125 @@
+using ClashSharp.ApplicationModel.Diagnostics;
+
+namespace ClashSharp.ApplicationModel.Presentation;
+
+/// Owns queued synchronous UI operations until they finish or are revoked before execution.
+/// The containing scope retires this owner before releasing its window and generation.
+public sealed class OwnedUiDispatcher : IAsyncDisposable
+{
+ private readonly object _gate = new();
+ private readonly Func _hasThreadAccess;
+ private readonly Func _tryEnqueue;
+ private readonly HashSet _operations = [];
+ private readonly CancellationTokenRegistration _windowLifetime;
+ private bool _closed;
+ private Task? _retirement;
+
+ /// Creates a dispatcher boundary without accessing the platform or scheduling work.
+ /// Reports access to the owning UI thread.
+ /// Queues one callback; rejection never permits a fallback on another thread.
+ /// Revoked when the window can no longer execute queued work.
+ public OwnedUiDispatcher(Func hasThreadAccess, Func tryEnqueue, CancellationToken windowLifetime)
+ {
+ _hasThreadAccess = hasThreadAccess ?? throw new ArgumentNullException(nameof(hasThreadAccess));
+ _tryEnqueue = tryEnqueue ?? throw new ArgumentNullException(nameof(tryEnqueue));
+ _windowLifetime = windowLifetime.Register(static state => ((OwnedUiDispatcher)state!).Close(), this);
+ }
+
+ /// Waits for the actual callback, including after cancellation or a lost enqueue reply.
+ /// The immutable result captured on the UI thread.
+ /// Synchronous work that must not launch unowned asynchronous operations.
+ /// Cancels before the callback starts effects.
+ /// The callback's result or original failure.
+ public Task InvokeAsync(Func action, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(action);
+ cancellationToken.ThrowIfCancellationRequested();
+ QueuedOperation operation = new(this, action, cancellationToken);
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_closed, this);
+ _operations.Add(operation);
+ }
+ try
+ {
+ if (_hasThreadAccess()) { operation.Run(); }
+ else if (!_tryEnqueue(operation.Run)) { operation.Reject(new InvalidOperationException("The UI dispatcher rejected the operation.")); }
+ }
+ catch (Exception exception)
+ {
+ // A throwing enqueue may already have handed the callback to the queue. Revoke
+ // only unstarted work; a callback already executing retains its completion owner.
+ operation.Reject(exception);
+ if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; }
+ }
+ return operation.Result;
+ }
+
+ /// Rejects new and unstarted work, then drains every callback that has begun execution.
+ public ValueTask DisposeAsync()
+ {
+ lock (_gate) { return new(_retirement ??= RetireAsync()); }
+ }
+
+ private async Task RetireAsync()
+ {
+ Task[] pending = Close();
+ await _windowLifetime.DisposeAsync().ConfigureAwait(false);
+ foreach (Task completion in pending)
+ {
+ try { await completion.ConfigureAwait(false); }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { }
+ }
+ }
+
+ private Task[] Close()
+ {
+ Operation[] operations;
+ lock (_gate) { _closed = true; operations = _operations.ToArray(); }
+ foreach (Operation operation in operations) { operation.Reject(new ObjectDisposedException(nameof(OwnedUiDispatcher))); }
+ return operations.Select(operation => operation.Completion).ToArray();
+ }
+
+ private void Complete(Operation operation) { lock (_gate) { _operations.Remove(operation); } }
+
+ private abstract class Operation
+ {
+ public abstract Task Completion { get; }
+ public abstract void Reject(Exception exception);
+ }
+
+ private sealed class QueuedOperation(OwnedUiDispatcher owner, Func action, CancellationToken cancellationToken) : Operation
+ {
+ private readonly TaskCompletionSource _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ private int _state;
+ public Task Result => _completion.Task;
+ public override Task Completion => _completion.Task;
+
+ public void Run()
+ {
+ if (Interlocked.CompareExchange(ref _state, 1, 0) != 0) { return; }
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ _completion.TrySetResult(action());
+ }
+ catch (OperationCanceledException exception) when (cancellationToken.IsCancellationRequested && exception.CancellationToken == cancellationToken)
+ {
+ _completion.TrySetCanceled(cancellationToken);
+ }
+ catch (Exception exception)
+ {
+ _completion.TrySetException(exception);
+ if (ExceptionGraphClassifier.IsProcessFatal(exception)) { throw; }
+ }
+ finally { Volatile.Write(ref _state, 2); owner.Complete(this); }
+ }
+
+ public override void Reject(Exception exception)
+ {
+ if (Interlocked.CompareExchange(ref _state, 2, 0) != 0) { return; }
+ _completion.TrySetException(exception);
+ owner.Complete(this);
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs
new file mode 100644
index 00000000..52e76dc0
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialException.cs
@@ -0,0 +1,16 @@
+namespace ClashSharp.ApplicationModel.Security;
+
+/// Reports an unavailable credential using a stable code without carrying private storage values.
+public sealed class ControllerCredentialException : InvalidOperationException
+{
+ /// Creates a value-free credential failure.
+ /// Stable diagnostic code.
+ public ControllerCredentialException(string code) : base(code)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(code);
+ Code = code;
+ }
+
+ /// Gets the stable failure code.
+ public string Code { get; }
+}
diff --git a/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs
new file mode 100644
index 00000000..7e8b70e0
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Security/ControllerCredentialService.cs
@@ -0,0 +1,107 @@
+using System.Security.Cryptography;
+using ClashSharp.ApplicationModel.Diagnostics;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Security;
+
+namespace ClashSharp.ApplicationModel.Security;
+
+/// Owns verified initialization and explicit deletion of the process controller credential.
+/// Constructors and runtime reads have no storage effects. Only admitted startup and data deletion may write.
+public sealed class ControllerCredentialService : IControllerCredentialProvider, IDisposable
+{
+ private readonly object _gate = new();
+ private readonly IControllerCredentialStore _store;
+ private readonly MutationAdmissionBarrier _admission;
+ private string? _secret;
+ private bool _disposed;
+
+ /// Creates an uninitialized credential owner without opening storage.
+ /// Independent private credential slot.
+ /// Process-wide mutation admission.
+ public ControllerCredentialService(IControllerCredentialStore store, MutationAdmissionBarrier admission)
+ {
+ _store = store ?? throw new ArgumentNullException(nameof(store));
+ _admission = admission ?? throw new ArgumentNullException(nameof(admission));
+ }
+
+ ///
+ public string GetSecret()
+ {
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ return _secret ?? throw new ControllerCredentialException("controller.credential.not_initialized");
+ }
+ }
+
+ /// Loads an existing valid credential or creates and independently verifies its durable replacement.
+ /// Active startup authority retained through verification, including a lost write reply.
+ /// Cancels before the first storage mutation; verification after a write is mandatory.
+ public void InitializeAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken)
+ {
+ _admission.EnsureActiveLease(lease);
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (_secret is not null) { return; }
+ if (TryRead(out string? stored) && ControllerCredentialPolicy.IsValid(stored))
+ {
+ _secret = stored;
+ return;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ _admission.EnsureActiveLease(lease);
+ string generated = Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(32));
+ bool failed = false;
+ try { _store.Write(generated); }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; }
+ if (!TryRead(out string? verified) || !StringComparer.Ordinal.Equals(generated, verified))
+ {
+ throw new ControllerCredentialException(failed
+ ? "controller.credential.write_failed" : "controller.credential.verification_failed");
+ }
+
+ _secret = verified;
+ }
+ }
+
+ /// Invalidates the cached credential and verifies removal after the runtime has been stopped.
+ /// Active data-deletion authority; ordinary settings resets must never call this method.
+ /// Cancels before deletion begins; absence must be verified after the attempt.
+ public void ClearAdmitted(MutationAdmissionLease lease, CancellationToken cancellationToken)
+ {
+ _admission.EnsureActiveLease(lease);
+ lock (_gate)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ cancellationToken.ThrowIfCancellationRequested();
+ _admission.EnsureActiveLease(lease);
+ _secret = null;
+ bool failed = false;
+ try { _store.Delete(); }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failed = true; }
+ if (TryRead(out _))
+ {
+ throw new ControllerCredentialException(failed
+ ? "controller.credential.delete_failed" : "controller.credential.delete_not_verified");
+ }
+ }
+ }
+
+ /// Retires the process projection without changing durable storage.
+ public void Dispose()
+ {
+ lock (_gate) { _disposed = true; _secret = null; }
+ }
+
+ private bool TryRead(out string? secret)
+ {
+ try { return _store.TryRead(out secret); }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception))
+ {
+ throw new ControllerCredentialException("controller.credential.read_failed");
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs
new file mode 100644
index 00000000..90fa10aa
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialProvider.cs
@@ -0,0 +1,9 @@
+namespace ClashSharp.ApplicationModel.Security;
+
+/// Supplies the independently initialized App-owned controller credential to runtime consumers.
+public interface IControllerCredentialProvider
+{
+ /// Reads the verified process credential without opening storage or acquiring mutation admission.
+ /// The private credential; callers must not log, export, or include it in preference snapshots.
+ string GetSecret();
+}
diff --git a/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs
new file mode 100644
index 00000000..656e6ae8
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Security/IControllerCredentialStore.cs
@@ -0,0 +1,17 @@
+namespace ClashSharp.ApplicationModel.Security;
+
+/// Persists exactly one controller credential outside the preferences authority.
+public interface IControllerCredentialStore
+{
+ /// Reads the credential slot without enumerating user preferences.
+ /// Stored string, or null when absent or present with an invalid storage type.
+ /// Whether the slot exists; a present invalid value remains distinct from absence.
+ bool TryRead(out string? secret);
+
+ /// Writes one canonical private credential; return alone does not prove persistence.
+ /// Canonical lowercase hexadecimal credential.
+ void Write(string secret);
+
+ /// Removes only the credential slot; callers independently verify absence.
+ void Delete();
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs
new file mode 100644
index 00000000..66f2947c
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorConfiguration.cs
@@ -0,0 +1,31 @@
+using ClashSharp.Model;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Contains the configured accent policy and canonical custom color, including the retained color while following Windows.
+public sealed record AccentColorConfiguration
+{
+ /// Normalizes one complete accent selection without touching platform resources.
+ /// A declared accent policy.
+ /// A valid custom ARGB color.
+ public AccentColorConfiguration(AppAccentColorMode mode, string colorValue)
+ {
+ if (!Enum.IsDefined(mode)) { throw new ArgumentOutOfRangeException(nameof(mode)); }
+ SettingNormalizationResult normalized = SettingsRegistry.Default.Get(SettingsRegistry.Keys.AppAccentColorValue.Value).Normalize(colorValue);
+ if (!normalized.IsSuccess) { throw new ArgumentException("The accent color is invalid.", nameof(colorValue)); }
+ Mode = mode;
+ ColorValue = normalized.Value!.Get();
+ }
+
+ /// Gets the installed accent policy.
+ public AppAccentColorMode Mode { get; }
+
+ /// Gets the normalized configured custom color.
+ public string ColorValue { get; }
+}
+
+/// Describes one color or fully opaque solid-brush resource independently of WinUI objects.
+/// The exact ARGB channels; color alpha is independent of brush opacity.
+/// Whether the resource must be a solid brush instead of a color value.
+public readonly record struct AccentResourceValue(uint Argb, bool IsBrush);
diff --git a/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs
new file mode 100644
index 00000000..a41aaf26
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/AccentColorRuntime.cs
@@ -0,0 +1,91 @@
+using System.Collections.ObjectModel;
+using ClashSharp.ApplicationModel.Diagnostics;
+using ClashSharp.Model;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Records accent application only after independently observing the complete resource palette.
+/// All calls are made on the resource dictionary's owning UI thread. Construction performs no platform access.
+public sealed class AccentColorRuntime
+{
+ private readonly IAccentResourceStore _resources;
+ private readonly IReadOnlyCollection _ownedKeys;
+ private readonly Func> _buildPalette;
+ private Palette _verified;
+ private Palette? _attempted;
+
+ /// Creates an owner with a declared default policy that still requires a real resource observation.
+ /// The actual application dictionary boundary.
+ /// Every primary resource key this owner can change.
+ /// Pure complete-palette construction; follow-system selections produce no overrides.
+ public AccentColorRuntime(IAccentResourceStore resources, IEnumerable ownedKeys,
+ Func> buildPalette)
+ {
+ _resources = resources ?? throw new ArgumentNullException(nameof(resources));
+ ArgumentNullException.ThrowIfNull(ownedKeys);
+ string[] keys = ownedKeys.ToArray();
+ if (keys.Length == 0 || keys.Any(string.IsNullOrWhiteSpace) || keys.Distinct(StringComparer.Ordinal).Count() != keys.Length)
+ {
+ throw new ArgumentException("Accent resource keys must be nonempty and unique.", nameof(ownedKeys));
+ }
+ _ownedKeys = Array.AsReadOnly(keys);
+ _buildPalette = buildPalette ?? throw new ArgumentNullException(nameof(buildPalette));
+ _verified = Build(new(AppAccentColorMode.FollowSystem, "#FF0078D4"));
+ }
+
+ /// Reads configured state only when the actual complete resource set supports that state.
+ /// The independently verified current selection.
+ public AccentColorConfiguration CaptureConfiguration()
+ {
+ IReadOnlyDictionary actual = _resources.CaptureLocalOverrides(_ownedKeys);
+ if (_attempted is not null && Matches(_attempted, actual)) { _verified = _attempted; _attempted = null; }
+ if (!Matches(_verified, actual)) { throw new InvalidOperationException("The application accent resources are not verified."); }
+ return _verified.Configuration;
+ }
+
+ /// Writes a complete palette, then reads every owned override before acknowledging installation.
+ /// The complete canonical selection.
+ public void Apply(AccentColorConfiguration configuration)
+ {
+ ArgumentNullException.ThrowIfNull(configuration);
+ Palette target = Build(configuration);
+ // Confirm availability before the first effect. An attempted palette is only a candidate
+ // for later independent observation, never evidence that a write succeeded.
+ _ = _resources.CaptureLocalOverrides(_ownedKeys);
+ _attempted = target;
+ Exception? writeFailure = null;
+ try
+ {
+ foreach (string key in _ownedKeys)
+ {
+ if (target.Values.TryGetValue(key, out AccentResourceValue value)) { _resources.WriteOverride(key, value); }
+ else { _resources.RemoveOverride(key); }
+ }
+ }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { writeFailure = exception; }
+
+ if (!Matches(target, _resources.CaptureLocalOverrides(_ownedKeys)))
+ {
+ if (writeFailure is not null) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(writeFailure).Throw(); }
+ throw new InvalidOperationException("The complete application accent palette could not be verified.");
+ }
+ _verified = target;
+ _attempted = null;
+ }
+
+ private Palette Build(AccentColorConfiguration configuration)
+ {
+ Dictionary values = _buildPalette(configuration).ToDictionary(pair => pair.Key, pair => pair.Value, StringComparer.Ordinal);
+ if (values.Keys.Any(key => !_ownedKeys.Contains(key, StringComparer.Ordinal))
+ || values.Count != (configuration.Mode == AppAccentColorMode.FollowSystem ? 0 : _ownedKeys.Count))
+ {
+ throw new InvalidOperationException("The accent palette does not cover the declared resource contract.");
+ }
+ return new(configuration, new ReadOnlyDictionary(values));
+ }
+
+ private static bool Matches(Palette target, IReadOnlyDictionary actual) =>
+ actual.Count == target.Values.Count && target.Values.All(pair => actual.TryGetValue(pair.Key, out AccentResourceValue? observed) && observed == pair.Value);
+
+ private sealed record Palette(AccentColorConfiguration Configuration, IReadOnlyDictionary Values);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs
new file mode 100644
index 00000000..1539c074
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsParticipant.cs
@@ -0,0 +1,175 @@
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.ApplicationModel.Diagnostics;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.ApplicationModel.Presentation;
+using ClashSharp.Model;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Applies complete appearance batches on the owning UI thread and independently observes their results.
+public sealed class AppearanceSettingsParticipant : ISettingsApplicationParticipant, IAppearanceSettingsReader, IAsyncDisposable
+{
+ private static readonly HashSet NativeKeys =
+ [SettingsRegistry.Keys.DisplayLanguage, SettingsRegistry.Keys.AppThemeMode,
+ SettingsRegistry.Keys.AppAccentColorMode, SettingsRegistry.Keys.AppAccentColorValue];
+ private readonly object _lifetimeGate = new();
+ private readonly DataGenerationDescriptor _generation;
+ private readonly MutationAdmissionBarrier _admission;
+ private readonly OwnedUiDispatcher _dispatcher;
+ private readonly IAppearanceNativeSettings _native;
+ private readonly IReadOnlyDictionary _definitions;
+ private AppearanceSettingsSnapshot? _installed;
+ private int _retiring;
+ private Task? _retirement;
+
+ /// Creates a pure configuration owner; the caller transfers this generation's dispatcher lifetime.
+ /// The exact generation that owns the installed policies.
+ /// Process-wide admission retained by the settings authority.
+ /// The same canonical registry used by the settings session.
+ /// A dedicated operation owner bound to the live window.
+ /// The actual UI configuration boundary.
+ public AppearanceSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission,
+ SettingsRegistry registry, OwnedUiDispatcher dispatcher, IAppearanceNativeSettings native)
+ {
+ _generation = generation ?? throw new ArgumentNullException(nameof(generation));
+ _admission = admission ?? throw new ArgumentNullException(nameof(admission));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _native = native ?? throw new ArgumentNullException(nameof(native));
+ ArgumentNullException.ThrowIfNull(registry);
+ _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Appearance)
+ .ToDictionary(definition => definition.Key);
+ if (_definitions.Count <= NativeKeys.Count || NativeKeys.Any(key => !_definitions.TryGetValue(key, out SettingDefinition? definition)
+ || definition.ValueType != SettingsRegistry.Default.Get(key.Value).ValueType)
+ || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal))
+ {
+ throw new ArgumentException("Appearance requires canonical UI definitions and application-owned consumer policies.", nameof(registry));
+ }
+ _installed = new(_generation, _definitions.Where(pair => !NativeKeys.Contains(pair.Key))
+ .Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue)));
+ }
+
+ ///
+ public SettingApplicationKind ApplicationKind => SettingApplicationKind.Appearance;
+ ///
+ public AppearanceSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed)
+ ?? throw new ObjectDisposedException(nameof(AppearanceSettingsParticipant));
+
+ ///
+ public Task ProbeAsync(SettingsApplicationRequest request,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ Validate(request, admissionLease);
+ return _dispatcher.InvokeAsync(() =>
+ {
+ Validate(request, admissionLease);
+ AppearanceNativeConfiguration native = _native.CaptureConfiguration();
+ AppearanceSettingsSnapshot installed = CaptureSnapshot();
+ return new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId,
+ request.Values.Keys.Select(key => new SettingValueChange(key, Read(key, native, installed))));
+ }, cancellationToken);
+ }
+
+ ///
+ public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ Validate(request, admissionLease);
+ return _dispatcher.InvokeAsync(() =>
+ {
+ Validate(request, admissionLease);
+ AppearanceNativeConfiguration before = _native.CaptureConfiguration();
+ AppearanceSettingsSnapshot installed = CaptureSnapshot();
+ T Target(SettingKey key) where T : notnull => request.Values.TryGetValue(key, out SettingValue? value)
+ ? value.Get() : Read(key, before, installed).Get();
+ AppearanceNativeConfiguration target = new(Target(SettingsRegistry.Keys.DisplayLanguage),
+ Target(SettingsRegistry.Keys.AppThemeMode),
+ new(Target(SettingsRegistry.Keys.AppAccentColorMode), Target(SettingsRegistry.Keys.AppAccentColorValue)));
+ Dictionary policies = installed.Values.ToDictionary();
+ foreach ((SettingKey key, SettingValue value) in request.Values.Where(pair => !NativeKeys.Contains(pair.Key))) { policies[key] = value; }
+ AppearanceSettingsSnapshot next = new(_generation, policies);
+ ApplyNative(before, target);
+ Volatile.Write(ref _installed, next);
+ return true;
+ }, cancellationToken);
+ }
+
+ /// Retires queued work and drains started UI callbacks before withdrawing the consumer snapshot.
+ public ValueTask DisposeAsync()
+ {
+ lock (_lifetimeGate) { return new(_retirement ??= RetireAsync()); }
+ }
+
+ private async Task RetireAsync()
+ {
+ Volatile.Write(ref _retiring, 1);
+ try { await _dispatcher.DisposeAsync().ConfigureAwait(false); }
+ finally { Volatile.Write(ref _installed, null); }
+ }
+
+ private void ApplyNative(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target)
+ {
+ try
+ {
+ ApplyNativeDifference(before, target);
+ if (_native.CaptureConfiguration() != target) { throw new InvalidOperationException("The requested appearance could not be verified."); }
+ }
+ catch (Exception failure) when (!ExceptionGraphClassifier.IsProcessFatal(failure))
+ {
+ if (TryObserve(target)) { return; }
+ if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); }
+ List failures = [failure];
+ // These effects only replace this application's UI configuration. Recover the
+ // observed baseline so an explicit retry can start from independently known state.
+ Restore(() => { if (before.Language != target.Language) { _native.ApplyLanguage(before.Language); } }, failures);
+ Restore(() => { if (before.Theme != target.Theme) { _native.ApplyTheme(before.Theme); } }, failures);
+ Restore(() => { if (before.Accent != target.Accent) { _native.ApplyAccent(before.Accent); } }, failures);
+ if (TryObserve(before)) { System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(failure).Throw(); }
+ failures.Add(new InvalidOperationException("The previous appearance could not be independently restored."));
+ throw new AggregateException("Appearance application and recovery did not reach a verified configuration.", failures);
+ }
+ }
+
+ private void ApplyNativeDifference(AppearanceNativeConfiguration before, AppearanceNativeConfiguration target)
+ {
+ if (before.Accent != target.Accent) { _native.ApplyAccent(target.Accent); }
+ if (before.Theme != target.Theme) { _native.ApplyTheme(target.Theme); }
+ if (before.Language != target.Language) { _native.ApplyLanguage(target.Language); }
+ }
+
+ private bool TryObserve(AppearanceNativeConfiguration expected)
+ {
+ try { return _native.CaptureConfiguration() == expected; }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { return false; }
+ }
+
+ private static void Restore(Action action, ICollection failures)
+ {
+ try { action(); }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception)) { failures.Add(exception); }
+ }
+
+ private SettingValue Read(SettingKey key, AppearanceNativeConfiguration native, AppearanceSettingsSnapshot installed)
+ {
+ if (!NativeKeys.Contains(key)) { return installed.Values[key]; }
+ object value = key == SettingsRegistry.Keys.DisplayLanguage ? native.Language
+ : key == SettingsRegistry.Keys.AppThemeMode ? native.Theme
+ : key == SettingsRegistry.Keys.AppAccentColorMode ? native.Accent.Mode : native.Accent.ColorValue;
+ SettingNormalizationResult normalized = _definitions[key].NormalizeValue(value);
+ return normalized.IsSuccess ? normalized.Value!
+ : throw new InvalidOperationException("The UI returned a noncanonical appearance value.");
+ }
+
+ private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ _admission.EnsureActiveLease(lease);
+ ObjectDisposedException.ThrowIf(Volatile.Read(ref _retiring) != 0, this);
+ if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); }
+ if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0
+ || request.Values.Any(pair => !_definitions.TryGetValue(pair.Key, out SettingDefinition? definition)
+ || !pair.Value.Equals(definition.Normalize(pair.Value.CanonicalText).Value)))
+ {
+ throw new InvalidOperationException("The appearance attempt does not match its generation, participant or canonical values.");
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs
new file mode 100644
index 00000000..933e84dd
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/AppearanceSettingsSnapshot.cs
@@ -0,0 +1,34 @@
+using System.Collections.ObjectModel;
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Reads the installed tray, regional and tile configuration without exposing preference mutation or platform access.
+public interface IAppearanceSettingsReader
+{
+ /// Captures one immutable configuration from the owning generation.
+ AppearanceSettingsSnapshot CaptureSnapshot();
+}
+
+/// Contains the installed policies consumed by tray, regional display and tile layout services.
+/// Native language, theme and accent observations belong to the UI adapter and are not cached here.
+public sealed class AppearanceSettingsSnapshot
+{
+ internal AppearanceSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values)
+ {
+ Generation = generation;
+ Values = new ReadOnlyDictionary(values.ToDictionary());
+ }
+
+ /// Gets the lifetime that owns these installed policies.
+ public DataGenerationDescriptor Generation { get; }
+ /// Gets the complete installed policy set, excluding desired intent and native UI values.
+ public IReadOnlyDictionary Values { get; }
+
+ /// Reads a registry-typed immutable policy value.
+ /// The exact registry type.
+ /// A tray, regional or tile policy key.
+ /// The value installed when the snapshot was captured.
+ public T Get(SettingKey key) where T : notnull => Values[key].Get();
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs
new file mode 100644
index 00000000..a66a6f01
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/GenerationSettingsAuthority.cs
@@ -0,0 +1,175 @@
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Owns admission and one generation pin across complete consumer commands, including every affected runtime batch.
+public sealed class GenerationSettingsAuthority : ISettingsAuthority
+{
+ private readonly DataGenerationManager _generations;
+ private readonly MutationAdmissionBarrier _admission;
+ private readonly SemaphoreSlim _commandGate = new(1, 1);
+
+ /// Creates the facade without resolving services, opening data, or publishing defaults.
+ /// Owner of current repositories and transition drain.
+ /// Shared mutation authority for ordinary and exclusive work.
+ public GenerationSettingsAuthority(DataGenerationManager generations, MutationAdmissionBarrier admission)
+ {
+ _generations = generations ?? throw new ArgumentNullException(nameof(generations));
+ _admission = admission ?? throw new ArgumentNullException(nameof(admission));
+ }
+
+ ///
+ public SettingsAuthoritySnapshot CaptureSnapshot() => _generations.ReadSnapshot(
+ (context, generation) => new(generation, RequireSession(context, generation).Snapshot));
+
+ ///
+ public Task OpenAsync(CancellationToken cancellationToken) =>
+ ExecuteOrdinaryAsync((context, lease, token) => context.Session.OpenAdmittedAsync(lease, token), cancellationToken);
+
+ ///
+ public Task ApplyChangesAsync(
+ IEnumerable changes, Guid transactionId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(changes);
+ SettingValueChange[] snapshot = changes.ToArray();
+ return ExecuteConsumerAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token),
+ RequiresProducerDrain(snapshot.Select(change => change.Key)), cancellationToken);
+ }
+
+ ///
+ public Task ApplyChangesAdmittedAsync(
+ IEnumerable changes, Guid transactionId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(changes);
+ SettingValueChange[] snapshot = changes.ToArray();
+ if (RequiresProducerDrain(snapshot.Select(change => change.Key))) { _admission.EnsureActiveExclusiveLease(admissionLease); }
+ return ExecuteAdmittedAsync((context, lease, token) => ChangeAndApplyAsync(context, snapshot, transactionId, lease, token),
+ admissionLease, cancellationToken);
+ }
+
+ ///
+ public Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+ SettingKey[] snapshot = keys.ToArray();
+ return ExecuteConsumerAsync(async (context, lease, token) =>
+ {
+ SettingsAuthorityResult reverted = await context.Session.RevertAdmittedAsync(snapshot, transactionId, lease, token).ConfigureAwait(false);
+ return await ApplyAffectedAsync(context, reverted, snapshot.ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false);
+ }, RequiresProducerDrain(snapshot), cancellationToken);
+ }
+
+ ///
+ public Task RetryAsync(Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken) =>
+ ExecuteConsumerAsync(async (context, lease, token) =>
+ {
+ SettingsAuthorityResult retry = await context.Session.RetryAdmittedAsync(batchId, expectedAttemptId, newAttemptId, lease, token).ConfigureAwait(false);
+ if (!retry.IsSucceeded) { return retry; }
+ SettingsApplicationBatch batch = retry.Envelope!.PendingApplications.Single(item => item.BatchId == batchId);
+ return await ApplyOneAsync(context, retry.Envelope, batch, SettingsApplicationPhase.Live, lease).ConfigureAwait(false);
+ }, drainProducers: true, cancellationToken);
+
+ ///
+ public Task ReconcileStartupAdmittedAsync(
+ Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ _admission.EnsureActiveExclusiveLease(admissionLease);
+ return ExecuteAdmittedAsync(async (context, lease, token) =>
+ {
+ SettingsAuthorityResult prepared = await context.Session.PrepareStartupAdmittedAsync(startupId, lease, token).ConfigureAwait(false);
+ return !prepared.IsSucceeded ? prepared : await ApplyAffectedAsync(context, prepared,
+ prepared.Envelope!.Desired.Keys.ToHashSet(), SettingsApplicationPhase.Startup, lease).ConfigureAwait(false);
+ }, admissionLease, cancellationToken);
+ }
+
+ private static async Task ChangeAndApplyAsync(
+ SettingsGenerationContext context, SettingValueChange[] changes, Guid transactionId,
+ MutationAdmissionLease lease, CancellationToken cancellationToken)
+ {
+ SettingsAuthorityResult changed = await context.Session.ChangeAdmittedAsync(changes, transactionId, lease, cancellationToken).ConfigureAwait(false);
+ return !changed.IsSucceeded ? changed : await ApplyAffectedAsync(context, changed,
+ changes.Select(change => change.Key).ToHashSet(), SettingsApplicationPhase.Live, lease).ConfigureAwait(false);
+ }
+
+ private static async Task ApplyAffectedAsync(
+ SettingsGenerationContext context, SettingsAuthorityResult committed, IReadOnlySet keys,
+ SettingsApplicationPhase phase, MutationAdmissionLease lease)
+ {
+ if (!committed.IsSucceeded) { return committed; }
+ SettingsApplicationBatch[] batches = committed.Envelope!.PendingApplications
+ .Where(batch => batch.Entries.Any(entry => keys.Contains(entry.Key))).ToArray();
+ SettingsAuthorityResult result = committed;
+ bool deferred = false;
+ foreach (SettingsApplicationBatch batch in batches)
+ {
+ SettingsAuthorityResult applied = await ApplyOneAsync(context, result.Envelope!, batch, phase, lease).ConfigureAwait(false);
+ if (applied.Status == SettingsAuthorityStatus.DeferredToRestart) { deferred = true; result = applied; continue; }
+ if (!applied.IsSucceeded) { return applied; }
+ result = applied;
+ }
+
+ return deferred ? new(SettingsAuthorityStatus.DeferredToRestart, result.Envelope, "settings.application.restart_required") : result;
+ }
+
+ private static Task ApplyOneAsync(
+ SettingsGenerationContext context, SettingsEnvelope envelope, SettingsApplicationBatch batch,
+ SettingsApplicationPhase phase, MutationAdmissionLease lease)
+ {
+ if (!context.Participants.TryGetValue(batch.ApplicationKind, out ISettingsApplicationParticipant? participant))
+ {
+ return Task.FromResult(new SettingsAuthorityResult(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_missing"));
+ }
+
+ return context.Session.ContinueCommittedBatchAdmittedAsync(batch.BatchId, batch.AttemptId, participant, phase, lease);
+ }
+
+ private static bool RequiresProducerDrain(IEnumerable keys) => keys.Any(key =>
+ key == SettingsRegistry.Keys.TriggersEnabled || key == SettingsRegistry.Keys.TriggerNotificationsEnabled);
+
+ private Task ExecuteOrdinaryAsync(
+ Func> command,
+ CancellationToken cancellationToken) => ExecuteConsumerAsync(command, drainProducers: false, cancellationToken);
+
+ private async Task ExecuteConsumerAsync(
+ Func> command,
+ bool drainProducers, CancellationToken cancellationToken)
+ {
+ // A trigger evaluation can itself submit a settings command. Drain/revoke its ordinary
+ // authority before taking the command gate, so quiescence never waits on that gate's owner.
+ // Retry resolves its batch inside the pinned command; exclusive admission avoids a racy
+ // preflight lookup and covers a retry that must quiesce the trigger scheduler.
+ await using MutationAdmissionLease lease = drainProducers
+ ? await _admission.CloseAndDrainAsync(MutationAdmissionClosure.Destructive, cancellationToken).ConfigureAwait(false)
+ : await _admission.AcquireOrdinaryAsync(cancellationToken).ConfigureAwait(false);
+ return await ExecuteAdmittedAsync(command, lease, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task ExecuteAdmittedAsync(
+ Func> command,
+ MutationAdmissionLease lease, CancellationToken cancellationToken)
+ {
+ _admission.EnsureActiveLease(lease);
+ using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lease.RevocationToken);
+ await _commandGate.WaitAsync(waiting.Token).ConfigureAwait(false);
+ try
+ {
+ _admission.EnsureActiveLease(lease);
+ waiting.Token.ThrowIfCancellationRequested();
+ return await _generations.ExecuteAsync((context, generation, token) =>
+ {
+ _ = RequireSession(context, generation);
+ return command(context, lease, token);
+ }, waiting.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ _commandGate.Release();
+ }
+ }
+
+ private static SettingsAuthoritySession RequireSession(SettingsGenerationContext context, DataGenerationDescriptor generation) =>
+ context.Session.Generation.IsSameGeneration(generation) ? context.Session
+ : throw new InvalidOperationException("The resolved settings session does not belong to the pinned generation.");
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs
new file mode 100644
index 00000000..42727ff9
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/IAccentResourceStore.cs
@@ -0,0 +1,19 @@
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Accesses the primary application dictionary's accent overrides on its owning UI thread.
+public interface IAccentResourceStore
+{
+ /// Captures locally defined owned keys; absent keys are omitted and incompatible resource values are represented by null.
+ /// The complete set of keys owned by accent application.
+ /// A stable snapshot that excludes inherited and merged resources.
+ IReadOnlyDictionary CaptureLocalOverrides(IReadOnlyCollection ownedKeys);
+
+ /// Installs one exact owned color or solid-brush resource.
+ /// The exact owned resource key.
+ /// The requested color and resource kind.
+ void WriteOverride(string key, AccentResourceValue value);
+
+ /// Removes one primary-dictionary override while preserving merged system resources.
+ /// The exact owned resource key.
+ void RemoveOverride(string key);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs
new file mode 100644
index 00000000..92e70410
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/IAppearanceNativeSettings.cs
@@ -0,0 +1,46 @@
+using ClashSharp.Model;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Reads and changes actual UI configuration on the owning window thread, without preference storage.
+public interface IAppearanceNativeSettings
+{
+ /// Reads the actual selected language, window theme and independently verified accent resources.
+ AppearanceNativeConfiguration CaptureConfiguration();
+
+ /// Installs the requested language in the resource resolver.
+ /// Canonical selected language.
+ void ApplyLanguage(AppLanguage language);
+
+ /// Installs the requested theme on the actual window root.
+ /// Canonical selected theme.
+ void ApplyTheme(AppThemeMode theme);
+
+ /// Installs and verifies the complete application accent resource set.
+ /// The configured mode and retained custom color.
+ void ApplyAccent(AccentColorConfiguration accent);
+}
+
+/// Contains UI configuration observed in one synchronous operation on the window thread.
+public sealed record AppearanceNativeConfiguration
+{
+ /// Creates an immutable and canonical UI configuration.
+ /// Selected resource language.
+ /// Requested window theme.
+ /// Independently verified accent configuration.
+ public AppearanceNativeConfiguration(AppLanguage language, AppThemeMode theme, AccentColorConfiguration accent)
+ {
+ if (!Enum.IsDefined(language)) { throw new ArgumentOutOfRangeException(nameof(language)); }
+ if (!Enum.IsDefined(theme)) { throw new ArgumentOutOfRangeException(nameof(theme)); }
+ Language = language;
+ Theme = theme;
+ Accent = accent ?? throw new ArgumentNullException(nameof(accent));
+ }
+
+ /// Gets the selected resource language.
+ public AppLanguage Language { get; }
+ /// Gets the requested window theme.
+ public AppThemeMode Theme { get; }
+ /// Gets the configured accent whose resources were verified.
+ public AccentColorConfiguration Accent { get; }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs
new file mode 100644
index 00000000..a8549f28
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/IInternalSettingsReader.cs
@@ -0,0 +1,9 @@
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Provides internal consumers with installed configuration without granting settings mutation or storage access.
+public interface IInternalSettingsReader
+{
+ /// Captures one immutable, generation-bound configuration without I/O; a retired owner rejects new captures.
+ /// The complete internal configuration installed at this observation.
+ InternalSettingsSnapshot CaptureSnapshot();
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs
new file mode 100644
index 00000000..da5b3a70
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/ILegacySettingsSource.cs
@@ -0,0 +1,9 @@
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Reads legacy preferences once during migration without exposing any writer.
+public interface ILegacySettingsSource
+{
+ /// Reads one atomic allowlisted legacy preference snapshot.
+ /// Cancels observation before a migration write starts.
+ Task ReadSnapshotAsync(CancellationToken cancellationToken);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs
new file mode 100644
index 00000000..d1b32b08
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsApplicationParticipant.cs
@@ -0,0 +1,25 @@
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Applies one settings batch through an admitted runtime boundary and independently probes its effect.
+/// Implementations use explicit request values, never write settings authority, and own all tasks until completion.
+public interface ISettingsApplicationParticipant
+{
+ /// Gets the application kind this participant can observe and apply.
+ SettingApplicationKind ApplicationKind { get; }
+
+ /// Observes actual effective values without changing runtime or preference state.
+ /// Immutable generation and attempt to observe.
+ /// Caller-owned lease retained for the complete command.
+ /// Observation cancellation; failures must not fabricate desired values.
+ Task ProbeAsync(
+ SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken);
+
+ /// Converges idempotently toward explicit desired values; successful return still requires an independent probe.
+ /// Immutable target and companion settings.
+ /// Existing authority; implementations must not reacquire ordinary admission.
+ /// Owner-controlled token; a page cancellation cannot abandon a started effect.
+ Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs
new file mode 100644
index 00000000..177f1f72
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/ISettingsAuthority.cs
@@ -0,0 +1,55 @@
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Provides generation-pinned snapshots and complete asynchronous desired/application commands to production consumers.
+public interface ISettingsAuthority
+{
+ /// Captures current immutable state without storage I/O or retaining a repository reference.
+ SettingsAuthoritySnapshot CaptureSnapshot();
+
+ /// Opens the current initialized authority under ordinary admission.
+ /// Cancels acquisition and storage observation.
+ Task OpenAsync(CancellationToken cancellationToken);
+
+ /// Commits a complete desired change set and verifies affected application batches under one generation pin.
+ /// Changes that quiesce settings-producing triggers acquire exclusive admission before the command gate.
+ /// Canonical typed desired changes copied before waiting.
+ /// Stable identity of the requested change set.
+ /// Cancels waiting and work before the desired publication boundary.
+ Task ApplyChangesAsync(
+ IEnumerable changes, Guid transactionId, CancellationToken cancellationToken);
+
+ /// Performs the complete change and application using an existing caller-owned admission lease.
+ /// Trigger settings require an exclusive lease; an ordinary lease is rejected before desired publication.
+ /// Canonical typed desired changes copied before waiting.
+ /// Stable identity of the change set.
+ /// Active lease retained by the caller until the complete command finishes.
+ /// Cancels work before durable desired publication.
+ Task ApplyChangesAdmittedAsync(
+ IEnumerable changes, Guid transactionId,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken);
+
+ /// Reverts selected desired values to verified evidence or explicit safe fallbacks and applies affected work.
+ /// Canonical keys to revert as one change set.
+ /// Stable identity of the revert command.
+ /// Cancels work before publication.
+ Task RevertAsync(IEnumerable keys, Guid transactionId, CancellationToken cancellationToken);
+
+ /// Explicitly retries a failed attempt under a fresh identity and verifies its effect.
+ /// Acquires exclusive admission before resolving the failed batch so runtime producers cannot form a wait cycle.
+ /// Exact failed batch.
+ /// Identity of the failed attempt being replaced.
+ /// Fresh nonempty retry identity.
+ /// Cancels work before durable retry publication.
+ Task RetryAsync(
+ Guid batchId, Guid expectedAttemptId, Guid newAttemptId, CancellationToken cancellationToken);
+
+ /// Reobserves previous-process evidence and reconciles startup work under exclusive admission.
+ /// Fresh nonempty startup identity.
+ /// Exclusive lease retained through startup application.
+ /// Cancels work before startup reconciliation is durable.
+ Task ReconcileStartupAdmittedAsync(
+ Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs
new file mode 100644
index 00000000..c4001376
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsParticipant.cs
@@ -0,0 +1,99 @@
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Owns atomic installation and independent observation of the application-internal consumer configuration.
+/// Defaults initialize the actual consumer contract without claiming durable application. The containing generation owns retirement.
+public sealed class InternalSettingsParticipant : ISettingsApplicationParticipant, IInternalSettingsReader, IDisposable
+{
+ private readonly object _gate = new();
+ private readonly DataGenerationDescriptor _generation;
+ private readonly MutationAdmissionBarrier _admission;
+ private readonly IReadOnlyDictionary _definitions;
+ private InternalSettingsSnapshot? _installed;
+
+ /// Creates a pure memory owner without opening storage, reading legacy preferences or starting tasks.
+ /// Immutable consumer lifetime.
+ /// The process-wide admission owner used by the settings authority.
+ /// Canonical definitions used by the same generation's settings session.
+ public InternalSettingsParticipant(DataGenerationDescriptor generation, MutationAdmissionBarrier admission, SettingsRegistry registry)
+ {
+ _generation = generation ?? throw new ArgumentNullException(nameof(generation));
+ _admission = admission ?? throw new ArgumentNullException(nameof(admission));
+ ArgumentNullException.ThrowIfNull(registry);
+ _definitions = registry.Definitions.Where(definition => definition.ApplicationKind == SettingApplicationKind.Internal)
+ .ToDictionary(definition => definition.Key);
+ if (_definitions.Count == 0 || _definitions.Values.Any(definition => definition.Authority != SettingAuthority.Internal))
+ {
+ throw new ArgumentException("Internal settings require application-owned definitions.", nameof(registry));
+ }
+ _installed = new(_generation, _definitions.Select(pair => KeyValuePair.Create(pair.Key, pair.Value.DefaultValue)));
+ }
+
+ ///
+ public SettingApplicationKind ApplicationKind => SettingApplicationKind.Internal;
+
+ /// Reads one immutable installed snapshot; subsequent changes cannot modify an already captured value.
+ /// The complete current consumer configuration from this generation.
+ public InternalSettingsSnapshot CaptureSnapshot() => Volatile.Read(ref _installed)
+ ?? throw new ObjectDisposedException(nameof(InternalSettingsParticipant));
+
+ ///
+ public Task ProbeAsync(SettingsApplicationRequest request,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ lock (_gate)
+ {
+ Validate(request, admissionLease);
+ cancellationToken.ThrowIfCancellationRequested();
+ InternalSettingsSnapshot snapshot = CaptureSnapshot();
+ return Task.FromResult(new SettingsApplicationObservation(_generation, request.Batch.BatchId, request.Batch.AttemptId,
+ request.Values.Keys.Select(key => new SettingValueChange(key, snapshot.Values[key]))));
+ }
+ }
+
+ ///
+ public Task ApplyAsync(SettingsApplicationRequest request, MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ lock (_gate)
+ {
+ Validate(request, admissionLease);
+ cancellationToken.ThrowIfCancellationRequested();
+ Dictionary installed = CaptureSnapshot().Values.ToDictionary();
+ foreach ((SettingKey key, SettingValue value) in request.Values) { installed[key] = value; }
+ InternalSettingsSnapshot next = new(_generation, installed);
+ cancellationToken.ThrowIfCancellationRequested();
+ Volatile.Write(ref _installed, next);
+ return Task.CompletedTask;
+ }
+ }
+
+ /// Rejects future calls without changing historical snapshots or durable preferences.
+ public void Dispose()
+ {
+ lock (_gate) { Volatile.Write(ref _installed, null); }
+ }
+
+ private void Validate(SettingsApplicationRequest request, MutationAdmissionLease lease)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ _admission.EnsureActiveLease(lease);
+ _ = CaptureSnapshot();
+ if (request.Phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(lease); }
+ if (!_generation.IsSameGeneration(request.Generation) || request.Batch.ApplicationKind != ApplicationKind || request.Values.Count == 0)
+ {
+ throw new InvalidOperationException("The internal settings attempt belongs to another participant or generation.");
+ }
+ foreach ((SettingKey key, SettingValue value) in request.Values)
+ {
+ if (!_definitions.TryGetValue(key, out SettingDefinition? definition)
+ || definition.ApplicationTiming == SettingApplicationTiming.Restart && request.Phase != SettingsApplicationPhase.Startup
+ || !value.Equals(definition.Normalize(value.CanonicalText).Value))
+ {
+ throw new InvalidOperationException("The internal settings attempt contains an unsupported key or value.");
+ }
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs
new file mode 100644
index 00000000..1d9c4b8d
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/InternalSettingsSnapshot.cs
@@ -0,0 +1,27 @@
+using System.Collections.ObjectModel;
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Captures the complete immutable configuration actually installed for internal consumers in one generation.
+public sealed class InternalSettingsSnapshot
+{
+ internal InternalSettingsSnapshot(DataGenerationDescriptor generation, IEnumerable> values)
+ {
+ Generation = generation;
+ Values = new ReadOnlyDictionary(values.ToDictionary());
+ }
+
+ /// Gets the exact lifetime that published this historical snapshot.
+ public DataGenerationDescriptor Generation { get; }
+
+ /// Gets installed values only, excluding pending desired intent and other participants' settings.
+ public IReadOnlyDictionary Values { get; }
+
+ /// Reads an exact typed internal value without storage I/O.
+ /// The registry-declared immutable value type.
+ /// An internal consumer key.
+ /// The value installed when this snapshot was captured.
+ public T Get(SettingKey key) where T : notnull => Values[key].Get();
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs
new file mode 100644
index 00000000..f71d75b3
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/LegacySettingsSnapshot.cs
@@ -0,0 +1,78 @@
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using System.Text.Json;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Owns one immutable, allowlisted legacy preference snapshot without internal credentials.
+public sealed class LegacySettingsSnapshot
+{
+ private const int MaximumStringLength = 1024 * 1024;
+ private readonly IReadOnlyDictionary _values;
+
+ /// Copies only registered preference keys and read-only aliases before computing their identity.
+ /// Canonical preference schema; unregistered keys are never copied or hashed.
+ /// Atomic legacy read whose primitive values are snapshotted by this constructor.
+ public LegacySettingsSnapshot(SettingsRegistry registry, IReadOnlyDictionary values)
+ {
+ ArgumentNullException.ThrowIfNull(registry);
+ ArgumentNullException.ThrowIfNull(values);
+ Dictionary snapshot = new(StringComparer.Ordinal);
+ foreach (string key in registry.Definitions.SelectMany(
+ definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value))))
+ {
+ if (!values.Keys.Contains(key, StringComparer.Ordinal) || !values.TryGetValue(key, out object? value))
+ {
+ continue;
+ }
+
+ if (value is string text && text.Length > MaximumStringLength)
+ {
+ throw new ArgumentException("A legacy preference exceeds the snapshot size limit.", nameof(values));
+ }
+
+ // LocalSettings preferences use only these immutable primitives. Unknown
+ // types become an invalid-value marker without invoking arbitrary ToString.
+ snapshot.Add(key, value is bool or int or string ? value : null);
+ }
+
+ _values = new ReadOnlyDictionary(snapshot);
+ SourceHash = ComputeHash(snapshot);
+ }
+
+ /// Gets the SHA-256 identity of the allowlisted primitive snapshot.
+ public string SourceHash { get; }
+
+ /// Gets a copied raw preference; null denotes a present but unsupported legacy value.
+ /// Canonical or registered legacy key.
+ /// Copied immutable primitive or the invalid-value marker.
+ /// Whether the allowlisted source contained this exact key.
+ public bool TryGetValue(string key, out object? value) => _values.TryGetValue(key, out value);
+
+ private static string ComputeHash(IReadOnlyDictionary values)
+ {
+ using MemoryStream bytes = new();
+ using (Utf8JsonWriter writer = new(bytes))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("schema", "clashsharp-legacy-preferences-v1");
+ writer.WriteStartObject("values");
+ foreach ((string key, object? value) in values.OrderBy(pair => pair.Key, StringComparer.Ordinal))
+ {
+ switch (value)
+ {
+ case bool boolean: writer.WriteBoolean(key, boolean); break;
+ case int integer: writer.WriteNumber(key, integer); break;
+ case string text: writer.WriteString(key, text); break;
+ default: writer.WriteNull(key); break;
+ }
+ }
+
+ writer.WriteEndObject();
+ writer.WriteEndObject();
+ }
+
+ return Convert.ToHexStringLower(SHA256.HashData(bytes.GetBuffer().AsSpan(0, checked((int)bytes.Length))));
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs
new file mode 100644
index 00000000..f6e66ae3
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsApplicationRequest.cs
@@ -0,0 +1,76 @@
+using System.Collections.ObjectModel;
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Specifies whether application occurs during an exclusively owned startup or a live command.
+public enum SettingsApplicationPhase
+{
+ /// Live application cannot consume restart-bound batches.
+ Live,
+ /// Startup application requires an active exclusive mutation lease.
+ Startup,
+}
+
+/// Captures the exact generation, attempt, and immutable desired values assigned to a participant.
+public sealed class SettingsApplicationRequest
+{
+ internal SettingsApplicationRequest(
+ DataGenerationDescriptor generation, SettingsEnvelope envelope, SettingsApplicationBatch batch,
+ SettingsApplicationPhase phase)
+ {
+ Generation = generation;
+ Envelope = envelope;
+ Batch = batch;
+ Phase = phase;
+ Values = new ReadOnlyDictionary(
+ batch.Entries.ToDictionary(entry => entry.Key, entry => envelope.Desired[entry.Key].Value));
+ }
+
+ /// Gets the pinned storage generation.
+ public DataGenerationDescriptor Generation { get; }
+
+ /// Gets the durable running envelope, including companion desired settings.
+ public SettingsEnvelope Envelope { get; }
+
+ /// Gets the exact current attempt.
+ public SettingsApplicationBatch Batch { get; }
+
+ /// Gets the owned application phase.
+ public SettingsApplicationPhase Phase { get; }
+
+ /// Gets only this batch's canonical desired values.
+ public IReadOnlyDictionary Values { get; }
+}
+
+/// Contains independently probed values bound to one exact generation and application attempt.
+public sealed class SettingsApplicationObservation
+{
+ /// Copies a completed participant probe without deriving observed values from the request.
+ /// Generation under which the participant was observed.
+ /// Observed batch identity.
+ /// Observed attempt identity.
+ /// Independent effective values; the authority validates complete canonical coverage.
+ public SettingsApplicationObservation(
+ DataGenerationDescriptor generation, Guid batchId, Guid attemptId, IEnumerable values)
+ {
+ Generation = generation ?? throw new ArgumentNullException(nameof(generation));
+ ArgumentNullException.ThrowIfNull(values);
+ BatchId = batchId;
+ AttemptId = attemptId;
+ Values = Array.AsReadOnly(values.ToArray());
+ }
+
+ /// Gets the generation actually observed.
+ public DataGenerationDescriptor Generation { get; }
+
+ /// Gets the batch identity of the observation.
+ public Guid BatchId { get; }
+
+ /// Gets the current attempt identity of the observation.
+ public Guid AttemptId { get; }
+
+ /// Gets the defensively copied effective values.
+ public IReadOnlyList Values { get; }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs
new file mode 100644
index 00000000..3e952815
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityBootstrapper.cs
@@ -0,0 +1,73 @@
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Opens a pinned settings authority or initializes a demonstrably unused repository from legacy preferences.
+/// The caller retains exclusive startup or generation-transition ownership for the complete operation.
+public sealed class SettingsAuthorityBootstrapper
+{
+ private readonly ISettingsRepository _repository;
+ private readonly ILegacySettingsSource _legacy;
+ private readonly SettingsMigrationPlanner _migration;
+ private readonly SemaphoreSlim _initializationGate = new(1, 1);
+
+ /// Creates the initializer without reading either storage system.
+ /// Repository pinned to the generation being opened.
+ /// Read-only source consulted only for an unused repository.
+ /// Canonical legacy normalization and initial pending-state planner.
+ public SettingsAuthorityBootstrapper(
+ ISettingsRepository repository,
+ ILegacySettingsSource legacy,
+ SettingsMigrationPlanner migration)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _legacy = legacy ?? throw new ArgumentNullException(nameof(legacy));
+ _migration = migration ?? throw new ArgumentNullException(nameof(migration));
+ }
+
+ /// Returns a verified nonempty authority or a persistence failure without silently replacing existing data.
+ /// Stable identity for a possible first migration.
+ /// Cancels observation and repository work before its durable commit point.
+ public async Task OpenAsync(Guid migrationId, CancellationToken cancellationToken)
+ {
+ if (migrationId == Guid.Empty)
+ {
+ throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId));
+ }
+
+ await _initializationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
+ try
+ {
+ SettingsPersistenceResult existing = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false);
+ if (!existing.IsSucceeded || existing.Envelope is not null)
+ {
+ return existing;
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ LegacySettingsSnapshot snapshot = await _legacy.ReadSnapshotAsync(cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ SettingsMigrationPlan plan = _migration.CreatePlan(snapshot, migrationId);
+ SettingsPersistenceResult saved = await _repository.SaveAsync(
+ plan.Envelope, expectedRevision: 0, cancellationToken).ConfigureAwait(false);
+ if (saved.Status == SettingsPersistenceStatus.Conflict && saved.Envelope is not null)
+ {
+ // Another initializer won the atomic first publication. Its verified
+ // authority wins; never repeat migration over the observed revision.
+ return SettingsPersistenceResult.Succeeded(saved.Envelope);
+ }
+
+ if (saved.IsSucceeded && saved.Envelope is null)
+ {
+ return SettingsPersistenceResult.Invalid(new SettingsPersistenceDiagnostic(
+ "settings.bootstrap.empty_commit", "envelope"));
+ }
+
+ return saved;
+ }
+ finally
+ {
+ _initializationGate.Release();
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs
new file mode 100644
index 00000000..fcc0aef9
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthorityResult.cs
@@ -0,0 +1,49 @@
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Classifies a settings command independently of whether desired values are durable.
+public enum SettingsAuthorityStatus
+{
+ /// The requested command completed and its resulting envelope was verified.
+ Succeeded,
+ /// The requested edit already matched the verified envelope.
+ NoChange,
+ /// The command was stale, invalid, or incompatible with the current application state.
+ Rejected,
+ /// Storage did not verify the requested commit.
+ PersistenceFailed,
+ /// The participant did not verify the target; failed work remains durable.
+ ApplicationFailed,
+ /// The batch requires startup ownership and was left unchanged.
+ DeferredToRestart,
+}
+
+/// Contains the verified outcome of a command without equating persistence with runtime application.
+public sealed class SettingsAuthorityResult
+{
+ internal SettingsAuthorityResult(
+ SettingsAuthorityStatus status, SettingsEnvelope? envelope, string? code = null,
+ SettingsPersistenceStatus? persistenceStatus = null)
+ {
+ Status = status;
+ Envelope = envelope;
+ Code = code;
+ PersistenceStatus = persistenceStatus;
+ }
+
+ /// Gets the command outcome.
+ public SettingsAuthorityStatus Status { get; }
+
+ /// Gets the last verified envelope, when one was observed by this command.
+ public SettingsEnvelope? Envelope { get; }
+
+ /// Gets a stable value-free diagnostic.
+ public string? Code { get; }
+
+ /// Gets the storage classification when persistence failed.
+ public SettingsPersistenceStatus? PersistenceStatus { get; }
+
+ /// Gets whether the requested command completed successfully.
+ public bool IsSucceeded => Status is SettingsAuthorityStatus.Succeeded or SettingsAuthorityStatus.NoChange;
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs
new file mode 100644
index 00000000..db7ace3b
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Application.cs
@@ -0,0 +1,151 @@
+using ClashSharp.ApplicationModel.Diagnostics;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+public sealed partial class SettingsAuthoritySession
+{
+ /// Persists running intent, probes before applying, and clears work only after complete independent verification.
+ /// Exact batch selected from the verified envelope.
+ /// Expected current attempt identity.
+ /// Admitted runtime implementation for this batch's application kind.
+ /// Live application or startup under exclusive ownership.
+ /// Active lease retained through observation, effects, and final durable classification.
+ /// Cancels waiting and work before running intent is durably acknowledged.
+ public Task ApplyBatchAdmittedAsync(
+ Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken) =>
+ ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: true, cancellationToken);
+
+ /// Continues a facade-owned durable command while retaining its active lease across admission drain.
+ internal Task ContinueCommittedBatchAdmittedAsync(
+ Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase,
+ MutationAdmissionLease admissionLease) =>
+ ApplyBatchEntryAsync(batchId, attemptId, participant, phase, admissionLease, honorRevocation: false, CancellationToken.None);
+
+ private Task ApplyBatchEntryAsync(
+ Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase,
+ MutationAdmissionLease admissionLease, bool honorRevocation,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(participant);
+ if (!Enum.IsDefined(phase)) { throw new ArgumentOutOfRangeException(nameof(phase)); }
+ if (phase == SettingsApplicationPhase.Startup) { _admission.EnsureActiveExclusiveLease(admissionLease); }
+ return ExecuteAdmittedAsync(admissionLease,
+ waiting => ApplyCoreAsync(batchId, attemptId, participant, phase, admissionLease, waiting), honorRevocation, cancellationToken);
+ }
+
+ private async Task ApplyCoreAsync(
+ Guid batchId, Guid attemptId, ISettingsApplicationParticipant participant, SettingsApplicationPhase phase,
+ MutationAdmissionLease lease, CancellationToken waiting)
+ {
+ SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false);
+ if (!read.IsSucceeded) { return read; }
+ SettingsEnvelope envelope = read.Envelope!;
+ SettingsApplicationBatch? batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId);
+ if (batch is null || batch.AttemptId != attemptId)
+ {
+ return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.stale_attempt");
+ }
+
+ if (batch.ApplicationKind != participant.ApplicationKind)
+ {
+ return new(SettingsAuthorityStatus.Rejected, envelope, "settings.application.participant_mismatch");
+ }
+
+ if (batch.Kind == SettingsApplicationBatchKind.Restart && phase != SettingsApplicationPhase.Startup)
+ {
+ return new(SettingsAuthorityStatus.DeferredToRestart, envelope, "settings.application.restart_required");
+ }
+
+ waiting.ThrowIfCancellationRequested();
+ SettingsAuthorityResult started = await PersistEditAsync(envelope,
+ _batches.BeginAttempt(envelope, batchId, attemptId), waiting).ConfigureAwait(false);
+ if (!started.IsSucceeded) { return started; }
+ envelope = started.Envelope!;
+ batch = envelope.PendingApplications.Single(item => item.BatchId == batchId);
+ SettingsApplicationRequest request = new(Generation, envelope, batch, phase);
+
+ // Running intent is durable. Ignore page cancellation until the participant and
+ // the final save finish; exclusive generation changes still wait for this lease.
+ SettingsApplicationObservation? observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false);
+ if (!ValidateObservation(request, observation, out bool matches))
+ {
+ return await FailCoreAsync(envelope, batch, "settings.application.probe_failed").ConfigureAwait(false);
+ }
+
+ bool applied = false;
+ bool replyFailed = false;
+ if (!matches)
+ {
+ try
+ {
+ await participant.ApplyAsync(request, lease, CancellationToken.None).ConfigureAwait(false);
+ applied = true;
+ }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception))
+ {
+ replyFailed = true;
+ // A participant may have completed its effect before losing the reply.
+ // Resolve that ambiguity by probing; never infer rollback from an exception.
+ }
+
+ observation = await TryProbeAsync(participant, request, lease).ConfigureAwait(false);
+ if (!ValidateObservation(request, observation, out matches) || !matches)
+ {
+ return await FailCoreAsync(envelope, batch, replyFailed
+ ? "settings.application.participant_failed" : "settings.application.verification_failed").ConfigureAwait(false);
+ }
+ }
+
+ SettingAppliedValueSource source = phase == SettingsApplicationPhase.Startup
+ ? SettingAppliedValueSource.StartupReconciliation
+ : applied ? SettingAppliedValueSource.MutationVerification : SettingAppliedValueSource.RuntimeProbe;
+ SettingsEnvelopeEditResult complete = _batches.CompleteAttempt(envelope, batchId, attemptId,
+ observation!.Values, source, _time.GetUtcNow());
+ SettingsAuthorityResult completed = await PersistEditAsync(envelope, complete, CancellationToken.None).ConfigureAwait(false);
+ return completed.IsSucceeded && replyFailed
+ ? new(completed.Status, completed.Envelope, "settings.application.reply_lost_resolved") : completed;
+ }
+
+ private async Task FailCoreAsync(SettingsEnvelope source, SettingsApplicationBatch batch, string code)
+ {
+ SettingsAuthorityResult failed = await PersistEditAsync(source,
+ _batches.FailAttempt(source, batch.BatchId, batch.AttemptId, new(code)), CancellationToken.None).ConfigureAwait(false);
+ return failed.IsSucceeded ? new(SettingsAuthorityStatus.ApplicationFailed, failed.Envelope, code) : failed;
+ }
+
+ private static async Task TryProbeAsync(
+ ISettingsApplicationParticipant participant, SettingsApplicationRequest request, MutationAdmissionLease lease)
+ {
+ try
+ {
+ return await participant.ProbeAsync(request, lease, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (Exception exception) when (!ExceptionGraphClassifier.IsProcessFatal(exception))
+ {
+ return null;
+ }
+ }
+
+ private bool ValidateObservation(SettingsApplicationRequest request, SettingsApplicationObservation? observation, out bool matches)
+ {
+ matches = false;
+ if (observation is null || !Generation.IsSameGeneration(observation.Generation)
+ || observation.BatchId != request.Batch.BatchId || observation.AttemptId != request.Batch.AttemptId
+ || observation.Values.Count != request.Values.Count) { return false; }
+ HashSet seen = [];
+ bool allMatch = true;
+ foreach (SettingValueChange value in observation.Values)
+ {
+ if (value is null || !seen.Add(value.Key) || !request.Values.TryGetValue(value.Key, out SettingValue? desired)) { return false; }
+ SettingNormalizationResult normalized = _registry.Get(value.Key.Value).Normalize(value.Value.CanonicalText);
+ if (!normalized.IsSuccess || !value.Value.Equals(normalized.Value)) { return false; }
+ allMatch &= value.Value.Equals(desired);
+ }
+
+ matches = allMatch;
+ return true;
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs
new file mode 100644
index 00000000..607aea5b
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.Lifetime.cs
@@ -0,0 +1,36 @@
+namespace ClashSharp.ApplicationModel.Settings;
+
+public sealed partial class SettingsAuthoritySession
+{
+ private readonly object _lifetimeLock = new();
+ private Task? _disposal;
+ private bool _closing;
+
+ /// Rejects later commands and drains any started effect and final save before releasing this generation's projection.
+ public ValueTask DisposeAsync()
+ {
+ lock (_lifetimeLock)
+ {
+ Volatile.Write(ref _closing, true);
+ _disposal ??= DrainAsync();
+ return new(_disposal);
+ }
+ }
+
+ private async Task DrainAsync()
+ {
+ await _operationGate.WaitAsync().ConfigureAwait(false);
+ try
+ {
+ Volatile.Write(ref _snapshot, null);
+ }
+ finally
+ {
+ // Queued callers still need to acquire and reject themselves. The managed
+ // semaphore stays valid until their continuations have observed closure.
+ _operationGate.Release();
+ }
+ }
+
+ private void ThrowIfClosing() => ObjectDisposedException.ThrowIf(Volatile.Read(ref _closing), this);
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs
new file mode 100644
index 00000000..975b667e
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySession.cs
@@ -0,0 +1,197 @@
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.ApplicationModel.Mutations;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Serializes asynchronous edits and verified application within one pinned repository lifetime.
+/// The generation owner keeps this session and its repository pinned for each complete call, including participant recovery.
+public sealed partial class SettingsAuthoritySession : IAsyncDisposable
+{
+ private readonly ISettingsRepository _repository;
+ private readonly SettingsRegistry _registry;
+ private readonly MutationAdmissionBarrier _admission;
+ private readonly SettingsEnvelopeEditor _editor;
+ private readonly SettingsApplicationBatchEditor _batches;
+ private readonly SettingsEnvelopeValidator _validator;
+ private readonly TimeProvider _time;
+ private readonly SemaphoreSlim _operationGate = new(1, 1);
+ private SettingsEnvelope? _snapshot;
+
+ /// Creates one session without opening storage, starting tasks, or publishing fallback preferences.
+ /// Repository owned by the immutable generation lifetime.
+ /// Canonical settings definitions.
+ /// Process-wide mutation admission shared with import and shutdown.
+ /// Optional clock for completed observation timestamps.
+ public SettingsAuthoritySession(
+ ISettingsRepository repository, SettingsRegistry registry, MutationAdmissionBarrier admission,
+ TimeProvider? timeProvider = null)
+ {
+ _repository = repository ?? throw new ArgumentNullException(nameof(repository));
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _admission = admission ?? throw new ArgumentNullException(nameof(admission));
+ _editor = new(registry);
+ _batches = new(registry);
+ _validator = new(registry);
+ _time = timeProvider ?? TimeProvider.System;
+ }
+
+ /// Gets the immutable storage owner of this session.
+ public DataGenerationDescriptor Generation => _repository.Generation;
+
+ /// Gets the last verified immutable envelope; uncertain commits invalidate this projection until storage is reread.
+ public SettingsEnvelope Snapshot
+ {
+ get
+ {
+ ThrowIfClosing();
+ return Volatile.Read(ref _snapshot)
+ ?? throw new InvalidOperationException("Settings authority has not been verified for this generation.");
+ }
+ }
+
+ /// Opens an already initialized authority; migration remains owned by the exclusive startup bootstrapper.
+ /// Active caller-owned lease.
+ /// Cancels queued or read-only work.
+ public Task OpenAdmittedAsync(MutationAdmissionLease admissionLease, CancellationToken cancellationToken) =>
+ ExecuteAdmittedAsync(admissionLease, ReadCoreAsync, cancellationToken);
+
+ /// Replaces previous-process evidence with pending observation while preserving blocked probes and existing attempt identities.
+ /// Fresh identity of the exclusively owned startup.
+ /// Exclusive startup lease held until reconciliation finishes.
+ /// Cancels before the repository's atomic publication point.
+ public Task PrepareStartupAdmittedAsync(
+ Guid startupId, MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ _admission.EnsureActiveExclusiveLease(admissionLease);
+ return EditAdmittedAsync(envelope => _batches.ScheduleStartupReconciliation(envelope, startupId), admissionLease, cancellationToken);
+ }
+
+ /// Commits a complete canonical change set using the current durable revision.
+ /// Desired changes, copied before asynchronous admission waiting.
+ /// Stable identity for newly planned application batches.
+ /// Active caller-owned lease.
+ /// Cancels only before the repository's atomic publication point.
+ public Task ChangeAdmittedAsync(
+ IEnumerable changes, Guid transactionId,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(changes);
+ SettingValueChange[] snapshot = changes.ToArray();
+ return EditAdmittedAsync(envelope => _editor.ApplyChanges(envelope, snapshot, transactionId), admissionLease, cancellationToken);
+ }
+
+ /// Reverts selected desired preferences to verified evidence or the registry's explicit fallback while retaining necessary application work.
+ /// Canonical keys copied before asynchronous waiting.
+ /// Stable edit identity.
+ /// Active caller-owned lease.
+ /// Cancels before atomic publication.
+ public Task RevertAdmittedAsync(
+ IEnumerable keys, Guid transactionId,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(keys);
+ SettingKey[] snapshot = keys.ToArray();
+ return EditAdmittedAsync(envelope => _editor.Revert(envelope, snapshot, transactionId), admissionLease, cancellationToken);
+ }
+
+ /// Explicitly retries failed work under a new attempt identity.
+ /// Exact batch.
+ /// Failed attempt being replaced.
+ /// New nonempty attempt identity.
+ /// Active caller-owned lease.
+ /// Cancels before atomic publication.
+ public Task RetryAdmittedAsync(
+ Guid batchId, Guid expectedAttemptId, Guid newAttemptId,
+ MutationAdmissionLease admissionLease, CancellationToken cancellationToken) =>
+ EditAdmittedAsync(envelope => _batches.RetryFailed(envelope, batchId, expectedAttemptId, newAttemptId), admissionLease, cancellationToken);
+
+ private Task EditAdmittedAsync(
+ Func edit, MutationAdmissionLease lease, CancellationToken token) =>
+ ExecuteAdmittedAsync(lease, async waiting =>
+ {
+ SettingsAuthorityResult read = await ReadCoreAsync(waiting).ConfigureAwait(false);
+ return !read.IsSucceeded ? read
+ : await PersistEditAsync(read.Envelope!, edit(read.Envelope!), waiting).ConfigureAwait(false);
+ }, token);
+
+ private Task ExecuteAdmittedAsync(
+ MutationAdmissionLease lease, Func> operation,
+ CancellationToken cancellationToken) => ExecuteAdmittedAsync(lease, operation, honorRevocation: true, cancellationToken);
+
+ private async Task ExecuteAdmittedAsync(
+ MutationAdmissionLease lease, Func> operation, bool honorRevocation,
+ CancellationToken cancellationToken)
+ {
+ ThrowIfClosing();
+ _admission.EnsureActiveLease(lease);
+ using CancellationTokenSource waiting = CancellationTokenSource.CreateLinkedTokenSource(
+ cancellationToken, honorRevocation ? lease.RevocationToken : CancellationToken.None);
+ await _operationGate.WaitAsync(waiting.Token).ConfigureAwait(false);
+ try
+ {
+ ThrowIfClosing();
+ _admission.EnsureActiveLease(lease);
+ waiting.Token.ThrowIfCancellationRequested();
+ return await operation(waiting.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ _operationGate.Release();
+ }
+ }
+
+ private async Task ReadCoreAsync(CancellationToken cancellationToken)
+ {
+ try
+ {
+ SettingsPersistenceResult result = await _repository.OpenAsync(cancellationToken).ConfigureAwait(false);
+ return ObservePersistence(result);
+ }
+ catch
+ {
+ Volatile.Write(ref _snapshot, null);
+ throw;
+ }
+ }
+
+ private async Task PersistEditAsync(
+ SettingsEnvelope source, SettingsEnvelopeEditResult edit, CancellationToken cancellationToken)
+ {
+ if (!edit.IsSuccess) { return new(SettingsAuthorityStatus.Rejected, source, edit.ErrorCode); }
+ if (edit.Outcome == SettingsEnvelopeEditOutcome.NoChange) { return new(SettingsAuthorityStatus.NoChange, source); }
+ try
+ {
+ SettingsPersistenceResult persisted = await _repository.SaveAsync(edit.Envelope, source.EnvelopeRevision, cancellationToken)
+ .ConfigureAwait(false);
+ return ObservePersistence(persisted);
+ }
+ catch
+ {
+ Volatile.Write(ref _snapshot, null);
+ throw;
+ }
+ }
+
+ private SettingsAuthorityResult ObservePersistence(SettingsPersistenceResult result)
+ {
+ if (result.IsSucceeded && result.Envelope is null)
+ {
+ result = SettingsPersistenceResult.Invalid(new("settings.authority.uninitialized", "envelope"));
+ }
+
+ SettingsEnvelope? observed = result.Status is SettingsPersistenceStatus.Succeeded or SettingsPersistenceStatus.Conflict
+ ? result.Envelope : null;
+ if (observed is not null && !_validator.Validate(observed).IsValid)
+ {
+ observed = null;
+ result = SettingsPersistenceResult.Invalid(new("settings.authority.invalid_observation", "envelope"));
+ }
+
+ Volatile.Write(ref _snapshot, observed);
+ if (result.IsSucceeded && observed is not null) { return new(SettingsAuthorityStatus.Succeeded, observed); }
+ return new(SettingsAuthorityStatus.PersistenceFailed, observed,
+ result.Diagnostic?.Code ?? (result.Status == SettingsPersistenceStatus.Conflict
+ ? "settings.authority.conflict" : "settings.authority.uninitialized"), result.Status);
+ }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs
new file mode 100644
index 00000000..0b166a33
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsAuthoritySnapshot.cs
@@ -0,0 +1,23 @@
+using ClashSharp.ApplicationModel.Data;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Captures one immutable authority projection with its exact generation identity.
+public sealed class SettingsAuthoritySnapshot
+{
+ /// Creates a projection from one verified generation and envelope.
+ /// Immutable storage owner of the captured envelope.
+ /// Complete immutable desired, applied, and pending state.
+ public SettingsAuthoritySnapshot(DataGenerationDescriptor generation, SettingsEnvelope envelope)
+ {
+ Generation = generation ?? throw new ArgumentNullException(nameof(generation));
+ Envelope = envelope ?? throw new ArgumentNullException(nameof(envelope));
+ }
+
+ /// Gets the captured generation identity.
+ public DataGenerationDescriptor Generation { get; }
+
+ /// Gets the complete immutable settings state.
+ public SettingsEnvelope Envelope { get; }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs
new file mode 100644
index 00000000..87c26df4
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsGenerationContext.cs
@@ -0,0 +1,35 @@
+using System.Collections.ObjectModel;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Groups the settings session and runtime adapters resolved from one generation-owned service container.
+/// The containing service lifetime owns disposal; participant constructors must not read storage or start effects.
+public sealed class SettingsGenerationContext
+{
+ /// Creates an immutable generation-local participant catalog.
+ /// Settings authority session owned by the same service container.
+ /// At most one runtime adapter for each declared application kind.
+ public SettingsGenerationContext(SettingsAuthoritySession session, IEnumerable participants)
+ {
+ Session = session ?? throw new ArgumentNullException(nameof(session));
+ ArgumentNullException.ThrowIfNull(participants);
+ Dictionary catalog = [];
+ foreach (ISettingsApplicationParticipant participant in participants)
+ {
+ if (participant is null || !Enum.IsDefined(participant.ApplicationKind)
+ || !catalog.TryAdd(participant.ApplicationKind, participant))
+ {
+ throw new ArgumentException("Settings participants must be non-null and unique by declared application kind.", nameof(participants));
+ }
+ }
+
+ Participants = new ReadOnlyDictionary(catalog);
+ }
+
+ /// Gets the generation's sole settings session.
+ public SettingsAuthoritySession Session { get; }
+
+ /// Gets the immutable catalog used for generation-local runtime application.
+ public IReadOnlyDictionary Participants { get; }
+}
diff --git a/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs
new file mode 100644
index 00000000..ed1cded9
--- /dev/null
+++ b/ClashSharp/ClashSharp.Application/Settings/SettingsMigrationPlanner.cs
@@ -0,0 +1,144 @@
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using System.Text;
+using ClashSharp.Model;
+using ClashSharp.Settings;
+
+namespace ClashSharp.ApplicationModel.Settings;
+
+/// Explains a migration decision using only an allowlisted key and stable code.
+/// Canonical preference key.
+/// Stable decision code; source values are never included.
+public sealed record SettingsMigrationDiagnostic(SettingKey Key, string Code);
+
+/// Contains the complete initial envelope and value-free migration decisions.
+/// Validated canonical envelope with pending application coverage.
+/// Immutable key-addressed migration decisions.
+public sealed record SettingsMigrationPlan(SettingsEnvelope Envelope, IReadOnlyList Diagnostics);
+
+/// Converts legacy preferences into a complete canonical authority without asserting runtime effects.
+public sealed class SettingsMigrationPlanner
+{
+ private readonly SettingsRegistry _registry;
+
+ /// Creates a pure planner for the canonical schema.
+ /// Immutable preference definitions and validation rules.
+ public SettingsMigrationPlanner(SettingsRegistry registry) =>
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+
+ /// Creates one revision-one migration with deterministic application identities.
+ /// Immutable allowlisted source.
+ /// Stable caller-owned identity for this migration attempt.
+ public SettingsMigrationPlan CreatePlan(LegacySettingsSnapshot snapshot, Guid migrationId)
+ {
+ ArgumentNullException.ThrowIfNull(snapshot);
+ if (migrationId == Guid.Empty)
+ {
+ throw new ArgumentException("Migration identity cannot be empty.", nameof(migrationId));
+ }
+
+ Dictionary desired = [];
+ Dictionary applied = [];
+ List diagnostics = [];
+ foreach (SettingDefinition definition in _registry.Definitions)
+ {
+ (SettingValue value, string code) = ReadValue(snapshot, definition);
+ desired.Add(definition.Key, new SettingDesiredEntry(value, keyDesiredRevision: 1));
+ applied.Add(definition.Key, SettingAppliedState.Unknown(
+ SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication));
+ diagnostics.Add(new(definition.Key, code));
+ }
+
+ List pending = [];
+ foreach (var group in _registry.Definitions
+ .GroupBy(definition => (definition.ApplicationKind, definition.ApplicationTiming))
+ .OrderBy(group => group.Key.ApplicationTiming)
+ .ThenBy(group => group.Key.ApplicationKind))
+ {
+ long sequence = pending.Count + 1;
+ pending.Add(new SettingsApplicationBatch(
+ CreateIdentity(migrationId, sequence, "batch"),
+ group.Key.ApplicationTiming == SettingApplicationTiming.Restart
+ ? SettingsApplicationBatchKind.Restart
+ : SettingsApplicationBatchKind.LiveReconcile,
+ sequence,
+ CreateIdentity(migrationId, sequence, "attempt"),
+ SettingsApplicationBatchState.Pending,
+ group.Key.ApplicationKind,
+ group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, desired[definition.Key]))));
+ }
+
+ SettingsEnvelope envelope = new(SettingsEnvelope.CurrentSchemaVersion, 1, desired, applied, pending,
+ [new SettingsMigrationRecord(migrationId, 0, SettingsEnvelope.CurrentSchemaVersion, snapshot.SourceHash)]);
+ SettingsEnvelopeValidationResult validation = new SettingsEnvelopeValidator(_registry).Validate(envelope);
+ if (!validation.IsValid)
+ {
+ throw new InvalidOperationException("The generated migration envelope violates the canonical schema.");
+ }
+
+ return new(envelope, new ReadOnlyCollection(diagnostics));
+ }
+
+ private static (SettingValue Value, string Code) ReadValue(LegacySettingsSnapshot source, SettingDefinition definition)
+ {
+ string key = definition.Key.Value;
+ bool present = source.TryGetValue(key, out object? raw);
+ string code = "settings.migration.canonical";
+ if (!present)
+ {
+ foreach (SettingKey aliasKey in definition.Aliases)
+ {
+ if (source.TryGetValue(aliasKey.Value, out raw))
+ {
+ present = true;
+ code = "settings.migration.alias";
+ break;
+ }
+ }
+ }
+
+ if (key == SettingsRegistry.Keys.MainlandChinaFeatureMode.Value)
+ {
+ if (raw is int legacyMode && legacyMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist)
+ {
+ raw = (int)MainlandChinaFeatureMode.FlagTextCompletionAndKeywordFilter;
+ code = "settings.migration.legacy_mode_split";
+ }
+ else if ((!present || raw is not int || !Enum.IsDefined(typeof(MainlandChinaFeatureMode), raw))
+ && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaDisplayEnabled.Value, out object? alias)
+ && alias is bool display)
+ {
+ raw = (int)(display ? MainlandChinaFeatureMode.FlagReplacementAndTextCompletion : MainlandChinaFeatureMode.Disabled);
+ present = true;
+ code = "settings.migration.alias";
+ }
+ }
+ else if (key == SettingsRegistry.Keys.MainlandChinaUrlBlockingEnabled.Value
+ && source.TryGetValue(SettingsRegistry.Keys.MainlandChinaFeatureMode.Value, out object? mode)
+ && mode is int oldMode && oldMode == (int)MainlandChinaFeatureMode.AllIncludingUrlBlacklist)
+ {
+ raw = true;
+ present = true;
+ code = "settings.migration.legacy_mode_split";
+ }
+
+ if (!present)
+ {
+ return (definition.DefaultValue, "settings.migration.default");
+ }
+
+ SettingNormalizationResult normalized = definition.ValueType.IsEnum && raw is int enumValue
+ ? definition.NormalizeValue(Enum.ToObject(definition.ValueType, enumValue))
+ : definition.NormalizeValue(raw);
+ return normalized.IsSuccess
+ ? (normalized.Value!, code)
+ : (definition.SafeFallback, "settings.migration.invalid_fallback");
+ }
+
+ private static Guid CreateIdentity(Guid migrationId, long sequence, string purpose)
+ {
+ byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(
+ FormattableString.Invariant($"clashsharp-settings-migration-v1/{migrationId:N}/{sequence}/{purpose}")));
+ return new Guid(hash.AsSpan(0, 16));
+ }
+}
diff --git a/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs
new file mode 100644
index 00000000..99d33296
--- /dev/null
+++ b/ClashSharp/ClashSharp.Core/Security/ControllerCredentialPolicy.cs
@@ -0,0 +1,18 @@
+namespace ClashSharp.Security;
+
+/// Defines the private App-owned controller credential independently of user preferences.
+public static class ControllerCredentialPolicy
+{
+ /// Checks the canonical 256-bit lowercase hexadecimal representation.
+ /// Untrusted persisted credential; never included in diagnostics.
+ public static bool IsValid(string? secret)
+ {
+ if (secret is not { Length: 64 }) { return false; }
+ foreach (char character in secret)
+ {
+ if (character is not (>= '0' and <= '9') and not (>= 'a' and <= 'f')) { return false; }
+ }
+
+ return true;
+ }
+}
diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs
new file mode 100644
index 00000000..d39cda62
--- /dev/null
+++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.Reconciliation.cs
@@ -0,0 +1,56 @@
+using System.Security.Cryptography;
+using System.Text;
+
+namespace ClashSharp.Settings;
+
+public sealed partial class SettingsApplicationBatchEditor
+{
+ /// Invalidates evidence from a previous process and queues observation without removing blocked probes or changing desired values.
+ /// Verified persisted envelope being opened by a new process.
+ /// Fresh nonempty startup identity used for new application batches.
+ /// Existing running and failed attempts retain their identities and state; failed work still requires explicit retry.
+ public SettingsEnvelopeEditResult ScheduleStartupReconciliation(SettingsEnvelope envelope, Guid startupId)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+ if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); }
+ if (startupId == Guid.Empty) { return Invalid(envelope, "startup_identity"); }
+ SettingKey[] staleEvidence = envelope.Applied.Where(pair => pair.Value.Kind == SettingAppliedStateKind.Verified)
+ .Select(pair => pair.Key).ToArray();
+ if (staleEvidence.Length == 0) { return SettingsEnvelopeEditResult.NoChange(envelope); }
+ if (envelope.EnvelopeRevision == long.MaxValue) { return Invalid(envelope, "revision_exhausted"); }
+
+ Dictionary applied = new(envelope.Applied);
+ HashSet alreadyCovered = [.. envelope.PendingApplications.SelectMany(batch => batch.Entries).Select(entry => entry.Key)];
+ foreach (SettingKey key in staleEvidence)
+ {
+ applied[key] = SettingAppliedState.Unknown(SettingAppliedUnknownReason.NotObserved, SettingAppliedUnknownHandling.QueueApplication);
+ }
+
+ List batches = [.. envelope.PendingApplications];
+ long sequence = batches.Count == 0 ? 0 : batches.Max(batch => batch.CreationSequence);
+ foreach (var group in staleEvidence.Where(key => !alreadyCovered.Contains(key))
+ .Select(key => _registry.Get(key.Value))
+ .GroupBy(definition => (definition.ApplicationTiming, definition.ApplicationKind))
+ .OrderBy(group => group.Key.ApplicationTiming).ThenBy(group => group.Key.ApplicationKind))
+ {
+ if (sequence == long.MaxValue) { return Invalid(envelope, "sequence_exhausted"); }
+ ++sequence;
+ batches.Add(new(
+ CreateStartupIdentity(startupId, sequence, "batch"),
+ group.Key.ApplicationTiming == SettingApplicationTiming.Restart ? SettingsApplicationBatchKind.Restart : SettingsApplicationBatchKind.LiveReconcile,
+ sequence, CreateStartupIdentity(startupId, sequence, "attempt"), SettingsApplicationBatchState.Pending,
+ group.Key.ApplicationKind, group.Select(definition => SettingsApplicationBatchEntry.Create(definition.Key, envelope.Desired[definition.Key]))));
+ }
+
+ SettingsEnvelope next = new(envelope.SchemaVersion, envelope.EnvelopeRevision + 1, envelope.Desired, applied,
+ batches.OrderBy(batch => batch, SettingsApplicationBatchComparer.Instance), envelope.MigrationHistory);
+ return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(envelope, "result_invalid");
+ }
+
+ private static Guid CreateStartupIdentity(Guid startupId, long sequence, string purpose)
+ {
+ byte[] digest = SHA256.HashData(Encoding.UTF8.GetBytes(
+ FormattableString.Invariant($"clashsharp-settings-startup-v1/{startupId:N}/{sequence}/{purpose}")));
+ return new(digest.AsSpan(0, 16));
+ }
+}
diff --git a/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs
new file mode 100644
index 00000000..bf027cca
--- /dev/null
+++ b/ClashSharp/ClashSharp.Core/Settings/SettingsApplicationBatchEditor.cs
@@ -0,0 +1,156 @@
+namespace ClashSharp.Settings;
+
+/// Advances exact application attempts without changing desired values or unrelated batch identities.
+public sealed partial class SettingsApplicationBatchEditor
+{
+ private readonly SettingsRegistry _registry;
+ private readonly SettingsEnvelopeValidator _validator;
+
+ /// Creates a pure lifecycle editor using the canonical schema.
+ /// Immutable setting definitions.
+ public SettingsApplicationBatchEditor(SettingsRegistry registry)
+ {
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+ _validator = new(registry);
+ }
+
+ /// Marks a pending attempt as possibly side-effecting before the participant is invoked.
+ /// Validated source envelope.
+ /// Exact batch to begin.
+ /// Expected current attempt; stale callbacks cannot begin a replacement attempt.
+ public SettingsEnvelopeEditResult BeginAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId)
+ {
+ SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch);
+ if (invalid is not null) { return invalid; }
+ if (batch!.State == SettingsApplicationBatchState.Running) { return SettingsEnvelopeEditResult.NoChange(envelope); }
+ if (batch.State != SettingsApplicationBatchState.Pending) { return Invalid(envelope, "state"); }
+
+ return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Running),
+ InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.NotObserved));
+ }
+
+ /// Retains failed work with unknown effective state; an attempted effect is never reported as rolled back without evidence.
+ /// Validated source envelope.
+ /// Exact running batch.
+ /// Expected current attempt.
+ /// Stable diagnostic chosen by the participant boundary.
+ public SettingsEnvelopeEditResult FailAttempt(SettingsEnvelope envelope, Guid batchId, Guid attemptId, SettingsApplicationError error)
+ {
+ ArgumentNullException.ThrowIfNull(error);
+ SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch);
+ if (invalid is not null) { return invalid; }
+ if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); }
+
+ return Rewrite(envelope, ReplaceState(batch, SettingsApplicationBatchState.Failed, error),
+ InvalidateEvidence(envelope, batch, SettingAppliedUnknownReason.ProbeFailed));
+ }
+
+ /// Removes one running batch only when every independently observed value matches its exact desired revision and hash.
+ /// Validated source envelope.
+ /// Exact running batch.
+ /// Expected current attempt.
+ /// Complete independently obtained canonical values; duplicate or unrelated keys are rejected.
+ /// Runtime probe, mutation verification, or startup reconciliation evidence.
+ /// Nondefault UTC time of observation.
+ public SettingsEnvelopeEditResult CompleteAttempt(
+ SettingsEnvelope envelope,
+ Guid batchId,
+ Guid attemptId,
+ IEnumerable observedValues,
+ SettingAppliedValueSource source,
+ DateTimeOffset observedAt)
+ {
+ ArgumentNullException.ThrowIfNull(observedValues);
+ SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, attemptId, out SettingsApplicationBatch? batch);
+ if (invalid is not null) { return invalid; }
+ if (batch!.State != SettingsApplicationBatchState.Running) { return Invalid(envelope, "state"); }
+ if (source is not (SettingAppliedValueSource.RuntimeProbe or SettingAppliedValueSource.MutationVerification
+ or SettingAppliedValueSource.StartupReconciliation)) { return Invalid(envelope, "observation_source"); }
+ if (observedAt == default || observedAt.Offset != TimeSpan.Zero) { return Invalid(envelope, "observation_time"); }
+
+ Dictionary observed = [];
+ foreach (SettingValueChange change in observedValues)
+ {
+ if (change is null || !observed.TryAdd(change.Key, change.Value)) { return Invalid(envelope, "observation_keys"); }
+ }
+
+ if (observed.Count != batch.Entries.Count) { return Invalid(envelope, "observation_keys"); }
+ Dictionary applied = new(envelope.Applied);
+ foreach (SettingsApplicationBatchEntry entry in batch.Entries)
+ {
+ if (!observed.TryGetValue(entry.Key, out SettingValue? value)) { return Invalid(envelope, "observation_keys"); }
+ SettingDefinition definition = _registry.Get(entry.Key.Value);
+ SettingNormalizationResult normalized = definition.Normalize(value.CanonicalText);
+ if (!normalized.IsSuccess || !value.Equals(normalized.Value)
+ || !value.Equals(envelope.Desired[entry.Key].Value)
+ || SettingsApplicationBatchEntry.ComputeValueHash(value) != entry.ValueHash)
+ {
+ return Invalid(envelope, "observation_mismatch");
+ }
+
+ applied[entry.Key] = SettingAppliedState.Verified(value, source, entry.ValueHash, observedAt);
+ }
+
+ return Rewrite(envelope, replacement: null, applied, batchId);
+ }
+
+ /// Authorizes a failed batch to retry under a new attempt identity while retaining key revisions.
+ /// Validated source envelope.
+ /// Failed batch to retry.
+ /// Identity of the failed attempt being retried.
+ /// Fresh nonempty identity that invalidates previous attempt callbacks.
+ public SettingsEnvelopeEditResult RetryFailed(SettingsEnvelope envelope, Guid batchId, Guid expectedAttemptId, Guid newAttemptId)
+ {
+ SettingsEnvelopeEditResult? invalid = FindAttempt(envelope, batchId, expectedAttemptId, out SettingsApplicationBatch? batch);
+ if (invalid is not null) { return invalid; }
+ if (batch!.State != SettingsApplicationBatchState.Failed) { return Invalid(envelope, "state"); }
+ if (newAttemptId == Guid.Empty || envelope.PendingApplications.Any(item => item.AttemptId == newAttemptId))
+ {
+ return Invalid(envelope, "retry_identity");
+ }
+
+ SettingsApplicationBatch next = new(batch.BatchId, batch.Kind, batch.CreationSequence, newAttemptId,
+ SettingsApplicationBatchState.Pending, batch.ApplicationKind, batch.Entries);
+ return Rewrite(envelope, next, envelope.Applied);
+ }
+
+ private SettingsEnvelopeEditResult? FindAttempt(
+ SettingsEnvelope envelope, Guid batchId, Guid attemptId, out SettingsApplicationBatch? batch)
+ {
+ ArgumentNullException.ThrowIfNull(envelope);
+ batch = null;
+ if (!_validator.Validate(envelope).IsValid) { return Invalid(envelope, "source_invalid"); }
+ batch = envelope.PendingApplications.SingleOrDefault(item => item.BatchId == batchId);
+ return batch is null || batch.AttemptId != attemptId ? Invalid(envelope, "stale_attempt") : null;
+ }
+
+ private static SettingsApplicationBatch ReplaceState(
+ SettingsApplicationBatch batch, SettingsApplicationBatchState state, SettingsApplicationError? error = null) =>
+ new(batch.BatchId, batch.Kind, batch.CreationSequence, batch.AttemptId, state, batch.ApplicationKind, batch.Entries, error);
+
+ private static Dictionary InvalidateEvidence(
+ SettingsEnvelope envelope, SettingsApplicationBatch batch, SettingAppliedUnknownReason reason)
+ {
+ Dictionary result = new(envelope.Applied);
+ foreach (SettingsApplicationBatchEntry entry in batch.Entries)
+ {
+ result[entry.Key] = SettingAppliedState.Unknown(reason, SettingAppliedUnknownHandling.QueueApplication);
+ }
+
+ return result;
+ }
+
+ private SettingsEnvelopeEditResult Rewrite(
+ SettingsEnvelope source, SettingsApplicationBatch? replacement,
+ IEnumerable> applied, Guid? removedBatchId = null)
+ {
+ if (source.EnvelopeRevision == long.MaxValue) { return Invalid(source, "revision_exhausted"); }
+ SettingsEnvelope next = new(source.SchemaVersion, source.EnvelopeRevision + 1, source.Desired, applied,
+ source.PendingApplications.Where(batch => batch.BatchId != removedBatchId)
+ .Select(batch => batch.BatchId == replacement?.BatchId ? replacement! : batch), source.MigrationHistory);
+ return _validator.Validate(next).IsValid ? SettingsEnvelopeEditResult.Updated(next) : Invalid(source, "result_invalid");
+ }
+
+ private static SettingsEnvelopeEditResult Invalid(SettingsEnvelope source, string code) =>
+ SettingsEnvelopeEditResult.Invalid(source, "settings.application." + code);
+}
diff --git a/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs
new file mode 100644
index 00000000..2c8a3c4d
--- /dev/null
+++ b/ClashSharp/ClashSharp.Infrastructure/Security/WindowsControllerCredentialStore.cs
@@ -0,0 +1,36 @@
+using ClashSharp.ApplicationModel.Security;
+using ClashSharp.Security;
+using Windows.Storage;
+
+namespace ClashSharp.Infrastructure.Security;
+
+/// Owns only the existing packaged-app controller credential slot, independently of preferences migration.
+public sealed class WindowsControllerCredentialStore : IControllerCredentialStore
+{
+ internal const string CredentialKey = "MihomoControllerSecret";
+ private readonly Func> _getValues;
+
+ /// Creates the packaged Windows boundary without opening LocalSettings.
+ public WindowsControllerCredentialStore() : this(static () => ApplicationData.Current.LocalSettings.Values) { }
+
+ internal WindowsControllerCredentialStore(Func> getValues) =>
+ _getValues = getValues ?? throw new ArgumentNullException(nameof(getValues));
+
+ ///
+ public bool TryRead(out string? secret)
+ {
+ bool present = _getValues().TryGetValue(CredentialKey, out object? value);
+ secret = value as string;
+ return present;
+ }
+
+ ///
+ public void Write(string secret)
+ {
+ if (!ControllerCredentialPolicy.IsValid(secret)) { throw new ArgumentException("Invalid controller credential shape.", nameof(secret)); }
+ _getValues()[CredentialKey] = secret;
+ }
+
+ ///
+ public void Delete() => _getValues().Remove(CredentialKey);
+}
diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs
index 5a97d9fd..e31c09c1 100644
--- a/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs
+++ b/ClashSharp/ClashSharp.Infrastructure/Settings/JsonSettingsRepository.Read.cs
@@ -43,6 +43,14 @@ await RestorePrimaryAsync(backup.Bytes!, cancellationToken)
if (primary.Kind == SettingsFileReadKind.Missing
&& backup.Kind == SettingsFileReadKind.Missing)
{
+ if (HasQuarantinedEnvelope())
+ {
+ return SettingsPersistenceResult.Corrupt(
+ new SettingsPersistenceDiagnostic(
+ "settings.persistence.quarantined_without_valid_envelope",
+ SettingsDirectoryPath));
+ }
+
return SettingsPersistenceResult.Succeeded();
}
@@ -173,6 +181,26 @@ private void Quarantine(string path)
}
}
+ private bool HasQuarantinedEnvelope()
+ {
+ // Quarantine survives process restart. Its absence from the primary names
+ // must not authorize a new migration over a previously corrupt authority.
+ foreach (string path in Directory.EnumerateFileSystemEntries(SettingsDirectoryPath))
+ {
+ string name = Path.GetFileName(path);
+ foreach (string prefix in new[] { PrimaryFileName + ".corrupt.", BackupFileName + ".corrupt." })
+ {
+ if (name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ && Guid.TryParseExact(name.AsSpan(prefix.Length), "N", out _))
+ {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
private enum SettingsFileReadKind
{
Missing,
diff --git a/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs
new file mode 100644
index 00000000..61cfc00e
--- /dev/null
+++ b/ClashSharp/ClashSharp.Infrastructure/Settings/WindowsLegacySettingsSource.cs
@@ -0,0 +1,34 @@
+using ClashSharp.ApplicationModel.Settings;
+using ClashSharp.Settings;
+using Windows.Storage;
+
+namespace ClashSharp.Infrastructure.Settings;
+
+/// Reads only registered legacy preferences during exclusively owned packaged-app startup.
+/// This adapter never writes LocalSettings and never requests the internal controller credential.
+public sealed class WindowsLegacySettingsSource : ILegacySettingsSource
+{
+ private readonly SettingsRegistry _registry;
+
+ /// Creates a read-only adapter without accessing Windows storage.
+ /// Canonical allowlist and legacy aliases.
+ public WindowsLegacySettingsSource(SettingsRegistry registry) =>
+ _registry = registry ?? throw new ArgumentNullException(nameof(registry));
+
+ ///
+ public Task ReadSnapshotAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var values = ApplicationData.Current.LocalSettings.Values;
+ Dictionary snapshot = new(StringComparer.Ordinal);
+ foreach (string key in _registry.Definitions.SelectMany(
+ definition => new[] { definition.Key.Value }.Concat(definition.Aliases.Select(alias => alias.Value))))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (values.TryGetValue(key, out object? value)) { snapshot.Add(key, value); }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(new LegacySettingsSnapshot(_registry, snapshot));
+ }
+}
diff --git a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs
index a6217bd1..81e454ba 100644
--- a/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs
+++ b/ClashSharp/ClashSharp.Installer.Presentation.Tests/InstallerExecutableContractTests.cs
@@ -221,7 +221,7 @@ public void WindowCloseWaitsForTheActiveRuntimeGeneration()
}
[Fact]
- public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent()
+ public void ShellUsesBrandGreenAsItsAccessibleInstallerAccent()
{
XNamespace presentation = "http://schemas.microsoft.com/winfx/2006/xaml/presentation";
XNamespace x = "http://schemas.microsoft.com/winfx/2006/xaml";
@@ -234,7 +234,7 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent()
XElement accent = Assert.Single(
theme.Descendants(presentation + "Color"),
color => (string?)color.Attribute(x + "Key") == "InstallerAccentColor");
- Assert.Equal("#7355DD", accent.Value.Trim());
+ Assert.Equal("#0C7428", accent.Value.Trim());
Assert.Contains(
theme.Descendants(presentation + "DataTrigger"),
trigger => (string?)trigger.Attribute("Value") == "True"
@@ -249,9 +249,9 @@ public void ShellUsesCSharpPurpleAsItsAccessibleInstallerAccent()
"ClashSharp.Installer",
"Themes",
"InstallerTheme.xaml"));
- Assert.DoesNotContain("#0C7428", themeText, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("#0B7026", themeText, StringComparison.OrdinalIgnoreCase);
- Assert.DoesNotContain("#0A6822", themeText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("#7355DD", themeText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("#8066E8", themeText, StringComparison.OrdinalIgnoreCase);
+ Assert.DoesNotContain("#6042C4", themeText, StringComparison.OrdinalIgnoreCase);
}
[Fact]
diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs
new file mode 100644
index 00000000..15e8d80b
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerCleanupTransactionStoreTests.cs
@@ -0,0 +1,408 @@
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Transactions;
+using ClashSharp.Installer.Windows.Files;
+using ClashSharp.Installer.Windows.Transactions;
+
+namespace ClashSharp.Installer.Windows.Tests;
+
+public sealed class WindowsInstallerCleanupTransactionStoreTests
+{
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task LoadRecoversOnlyTheExactVerifiedUninstall(bool activePresent)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, activePresent ? verified : null);
+ var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified));
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ Assert.Equal(verified, await store.LoadAsync(CancellationToken.None));
+ Assert.Equal(0, inner.SaveCalls);
+ Assert.Equal(0, ledger.SaveCalls);
+ }
+
+ [Theory]
+ [InlineData("transaction")]
+ [InlineData("target")]
+ [InlineData("version")]
+ [InlineData("payload")]
+ public async Task LoadRejectsConflictingActiveAndTerminalIdentity(string field)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, ChangeIdentity(verified, field));
+ var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified));
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None));
+
+ Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode);
+ Assert.Equal(0, inner.ClearCalls);
+ Assert.Equal(0, ledger.SaveCalls);
+ }
+
+ [Fact]
+ public async Task ClearPersistsAndReobservesTerminalBeforeDeletingJournalAndOnlySuppressesThisSession()
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ WindowsInstallerDirectoryLedger owned = OwnedLedger();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, owned);
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None);
+
+ Assert.Equal(["inner.load", "ledger.load", "ledger.save", "ledger.load", "inner.clear", "inner.load", "ledger.load"], events);
+ Assert.Null(inner.Current);
+ Assert.Equal(verified, ledger.Current!.Terminal);
+ Assert.Equal(owned.Directories, ledger.Current.Directories);
+ Assert.Equal(owned.Generation + 1, ledger.Current.Generation);
+ Assert.Null(await store.LoadAsync(CancellationToken.None));
+ var reopened = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+ Assert.Equal(verified, await reopened.LoadAsync(CancellationToken.None));
+ Assert.Equal(0, inner.SaveCalls);
+ Assert.Equal(0, ledger.DeleteCalls);
+ }
+
+ [Fact]
+ public async Task MissingLedgerBecomesTerminalWithoutInventingDirectoryOwnership()
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, null);
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None);
+
+ Assert.NotNull(ledger.Current);
+ Assert.Empty(ledger.Current.Directories);
+ Assert.Equal(verified, ledger.Current.Terminal);
+ Assert.Equal(1, ledger.Current.Generation);
+ }
+
+ [Fact]
+ public async Task ReopenedTerminalCanReverifyAndClearWithoutRecreatingAnOrdinaryJournal()
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, null);
+ var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified));
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ Assert.Equal(verified, await store.SaveAsync(verified.Journal, verified.ContentHash, CancellationToken.None));
+ await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None);
+
+ Assert.Null(await store.LoadAsync(CancellationToken.None));
+ Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None));
+ Assert.Equal(0, inner.SaveCalls);
+ Assert.Equal(0, inner.ClearCalls);
+ Assert.Equal(0, ledger.DeleteCalls);
+ }
+
+ [Theory]
+ [InlineData("missing-hash")]
+ [InlineData("wrong-hash")]
+ [InlineData("transaction")]
+ [InlineData("target")]
+ [InlineData("version")]
+ [InlineData("payload")]
+ [InlineData("phase")]
+ [InlineData("operation")]
+ public async Task TerminalOnlySaveCannotAdoptAnotherTransactionOrRegress(string mutation)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ InstallerTransactionJournal requested = mutation switch
+ {
+ "missing-hash" or "wrong-hash" => verified.Journal,
+ "phase" => verified.Journal with { Phase = InstallerTransactionPhase.PackageCommitted, Generation = 4 },
+ "operation" => verified.Journal with { Operation = InstallerOperation.Install },
+ _ => ChangeIdentity(verified, mutation).Journal,
+ };
+ string? expectedHash = mutation switch { "missing-hash" => null, "wrong-hash" => new string('d', 64), _ => verified.ContentHash };
+ var inner = new MemoryStore(events, null);
+ var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified));
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() =>
+ store.SaveAsync(requested, expectedHash, CancellationToken.None));
+
+ Assert.Equal("installer.transaction.write_conflict", failure.DiagnosticCode);
+ Assert.Equal(0, inner.SaveCalls);
+ Assert.Equal(0, ledger.SaveCalls);
+ Assert.Equal(verified, ledger.Current!.Terminal);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task ClearRejectsWrongIdentityWithoutChangingEitherStore(bool wrongHash)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, null);
+ var ledger = new MemoryLedger(events, OwnedLedger().BeginTerminal(verified));
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.ClearVerifiedAsync(
+ wrongHash ? verified.Journal.TransactionId : new string('d', 64),
+ wrongHash ? new string('d', 64) : verified.ContentHash, CancellationToken.None));
+
+ Assert.Equal("installer.transaction.clear_conflict", failure.DiagnosticCode);
+ Assert.Equal(0, inner.ClearCalls);
+ Assert.Equal(0, ledger.SaveCalls);
+ }
+
+ [Theory]
+ [InlineData("before-terminal", false)]
+ [InlineData("after-terminal", false)]
+ [InlineData("before-clear", false)]
+ [InlineData("after-clear", true)]
+ [InlineData("after-clear-observation", true)]
+ public async Task IoFailurePreservesTheActualDurableCutPointForReopen(string cut, bool journalDeleted)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+ var injected = new IOException("Injected cleanup checkpoint failure.");
+ Action fail = () => throw injected;
+ Inject(cut, inner, ledger, fail);
+
+ IOException actual = await Assert.ThrowsAsync(() =>
+ store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None));
+
+ Assert.Same(injected, actual);
+ Assert.Equal(journalDeleted ? null : verified, inner.Current);
+ Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal);
+ inner.ResetFaults();
+ ledger.ResetFaults();
+ Assert.Equal(verified, await store.LoadAsync(CancellationToken.None));
+ Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None));
+ Assert.Equal(0, ledger.DeleteCalls);
+ }
+
+ [Theory]
+ [InlineData("before-terminal", false)]
+ [InlineData("after-terminal", false)]
+ [InlineData("before-clear", false)]
+ [InlineData("after-clear", true)]
+ [InlineData("after-clear-observation", true)]
+ public async Task CancellationNeverDiscardsTerminalRecovery(string cut, bool journalDeleted)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+ using var cancellation = new CancellationTokenSource();
+ Inject(cut, inner, ledger, () =>
+ {
+ cancellation.Cancel();
+ cancellation.Token.ThrowIfCancellationRequested();
+ });
+
+ await Assert.ThrowsAnyAsync(() =>
+ store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, cancellation.Token));
+
+ Assert.Equal(journalDeleted ? null : verified, inner.Current);
+ Assert.Equal(cut == "before-terminal" ? null : verified, ledger.Current!.Terminal);
+ inner.ResetFaults();
+ ledger.ResetFaults();
+ Assert.Equal(verified, await new WindowsInstallerCleanupTransactionStore(inner, ledger).LoadAsync(CancellationToken.None));
+ Assert.Equal(0, ledger.DeleteCalls);
+ }
+
+ [Fact]
+ public async Task UnobservedTerminalWriteCannotDeleteTheActiveJournal()
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ ledger.AfterSave = () => ledger.Current = null;
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() =>
+ store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None));
+
+ Assert.Equal("installer.directory_ledger.terminal_not_observed", failure.DiagnosticCode);
+ Assert.Equal(verified, inner.Current);
+ Assert.Equal(0, inner.ClearCalls);
+ }
+
+ [Theory]
+ [InlineData(InstallerOperation.Install)]
+ [InlineData(InstallerOperation.Repair)]
+ public async Task OtherOperationsUseTheInnerClearWithoutCreatingTerminalState(InstallerOperation operation)
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified(operation);
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None);
+
+ Assert.Null(await store.LoadAsync(CancellationToken.None));
+ Assert.Null(ledger.Current!.Terminal);
+ Assert.Equal(0, ledger.SaveCalls);
+ Assert.Equal(1, inner.ClearCalls);
+ }
+
+ [Fact]
+ public async Task OrdinarySavePreservesTheOriginalJournalCas()
+ {
+ List events = [];
+ InstallerTransactionSnapshot prepared = InstallerTransactionSnapshot.Create(Verified(InstallerOperation.Install).Journal with
+ {
+ Phase = InstallerTransactionPhase.Prepared,
+ Generation = 1,
+ });
+ var inner = new MemoryStore(events, null);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+
+ Assert.Equal(prepared, await store.SaveAsync(prepared.Journal, null, CancellationToken.None));
+ Assert.Equal(prepared, await store.LoadAsync(CancellationToken.None));
+ await Assert.ThrowsAsync(() => store.SaveAsync(prepared.Journal, null, CancellationToken.None));
+ Assert.Equal(0, ledger.SaveCalls);
+ }
+
+ [Fact]
+ public async Task ClearedSessionDoesNotSuppressAReplacementTerminal()
+ {
+ List events = [];
+ InstallerTransactionSnapshot verified = Verified();
+ var inner = new MemoryStore(events, verified);
+ var ledger = new MemoryLedger(events, OwnedLedger());
+ var store = new WindowsInstallerCleanupTransactionStore(inner, ledger);
+ await store.ClearVerifiedAsync(verified.Journal.TransactionId, verified.ContentHash, CancellationToken.None);
+ ledger.Current = OwnedLedger().BeginTerminal(ChangeIdentity(verified, "transaction"));
+
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() => store.LoadAsync(CancellationToken.None));
+
+ Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode);
+ }
+
+ private static void Inject(string cut, MemoryStore inner, MemoryLedger ledger, Action failure)
+ {
+ switch (cut)
+ {
+ case "before-terminal": ledger.BeforeSave = failure; break;
+ case "after-terminal": ledger.AfterSave = failure; break;
+ case "before-clear": inner.BeforeClear = failure; break;
+ case "after-clear": inner.AfterClear = failure; break;
+ case "after-clear-observation": inner.AfterClear = () => ledger.BeforeLoad = failure; break;
+ default: throw new ArgumentOutOfRangeException(nameof(cut));
+ }
+ }
+
+ private static InstallerTransactionSnapshot Verified(InstallerOperation operation = InstallerOperation.Uninstall) =>
+ InstallerTransactionSnapshot.Create(new InstallerTransactionJournal(InstallerTransactionJournal.CurrentSchema,
+ new string('a', 64), operation, "S-1-5-21-100-200-300-1001", false, "1.0.0.0", new string('b', 64),
+ InstallerTransactionPhase.Verified, 5));
+
+ private static InstallerTransactionSnapshot ChangeIdentity(InstallerTransactionSnapshot state, string field) =>
+ InstallerTransactionSnapshot.Create(field switch
+ {
+ "transaction" => state.Journal with { TransactionId = new string('c', 64) },
+ "target" => state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" },
+ "version" => state.Journal with { ExpectedPackageVersion = "1.0.1.0" },
+ "payload" => state.Journal with { InstallerPayloadSha256 = new string('c', 64) },
+ _ => throw new ArgumentOutOfRangeException(nameof(field)),
+ });
+
+ private static WindowsInstallerDirectoryLedger OwnedLedger() => WindowsInstallerDirectoryLedger.Empty.RecordCreated(
+ InstallerDirectoryRole.InstallerVersion, new WindowsFileIdentity(11, 22));
+
+ private sealed class MemoryStore(List events, InstallerTransactionSnapshot? initial) : IInstallerTransactionStore
+ {
+ internal InstallerTransactionSnapshot? Current { get; set; } = initial;
+ internal int SaveCalls { get; private set; }
+ internal int ClearCalls { get; private set; }
+ internal Action? BeforeClear { get; set; }
+ internal Action? AfterClear { get; set; }
+
+ public Task LoadAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ events.Add("inner.load");
+ return Task.FromResult(Current);
+ }
+
+ public Task SaveAsync(InstallerTransactionJournal journal, string? expectedCurrentHash,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (Current?.ContentHash != expectedCurrentHash)
+ {
+ throw new InstallerProtocolException("installer.transaction.write_conflict");
+ }
+ events.Add("inner.save");
+ SaveCalls++;
+ Current = InstallerTransactionSnapshot.Create(journal);
+ return Task.FromResult(Current);
+ }
+
+ public Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.NotNull(Current);
+ Assert.Equal(Current.Journal.TransactionId, transactionId);
+ Assert.Equal(Current.ContentHash, expectedCurrentHash);
+ events.Add("inner.clear");
+ ClearCalls++;
+ BeforeClear?.Invoke();
+ Current = null;
+ AfterClear?.Invoke();
+ return Task.CompletedTask;
+ }
+
+ internal void ResetFaults() { BeforeClear = null; AfterClear = null; }
+ }
+
+ private sealed class MemoryLedger(List events, WindowsInstallerDirectoryLedger? initial) : IWindowsInstallerDirectoryLedgerPersistence
+ {
+ internal WindowsInstallerDirectoryLedger? Current { get; set; } = initial;
+ internal int SaveCalls { get; private set; }
+ internal int DeleteCalls { get; private set; }
+ internal Action? BeforeLoad { get; set; }
+ internal Action? BeforeSave { get; set; }
+ internal Action? AfterSave { get; set; }
+
+ public Task LoadAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ events.Add("ledger.load");
+ BeforeLoad?.Invoke();
+ return Task.FromResult(Current);
+ }
+
+ public Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired,
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Same(expected, Current);
+ events.Add("ledger.save");
+ SaveCalls++;
+ BeforeSave?.Invoke();
+ Current = desired;
+ AfterSave?.Invoke();
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken)
+ {
+ DeleteCalls++;
+ throw new InvalidOperationException("The transaction store must not delete the terminal ledger.");
+ }
+
+ internal void ResetFaults() { BeforeLoad = null; BeforeSave = null; AfterSave = null; }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs
new file mode 100644
index 00000000..3acbb931
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows.Tests/WindowsInstallerDirectoryLedgerTests.cs
@@ -0,0 +1,466 @@
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json.Nodes;
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Transactions;
+using ClashSharp.Installer.Windows.Files;
+using ClashSharp.Installer.Windows.Transactions;
+
+namespace ClashSharp.Installer.Windows.Tests;
+
+public sealed class WindowsInstallerDirectoryLedgerTests
+{
+ private const string ProgramFiles = @"C:\Program Files";
+ private const string ProgramData = @"C:\ProgramData";
+ private const string TargetSid = "S-1-5-21-100-200-300-1001";
+
+ [Fact]
+ public void RecordCreatedIsImmutableOrderedAndIdempotentForTheExactObject()
+ {
+ WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty;
+ WindowsInstallerDirectoryLedger first = empty.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22));
+ WindowsInstallerDirectoryLedger second = first.RecordCreated(InstallerDirectoryRole.ProgramFilesProduct, new(12, 11));
+
+ Assert.Empty(empty.Directories);
+ Assert.Single(first.Directories);
+ Assert.Equal(2, second.Generation);
+ Assert.Equal([InstallerDirectoryRole.ProgramFilesProduct, InstallerDirectoryRole.AuthorityVersion],
+ second.Directories.Select(entry => entry.Role));
+ Assert.Same(second, second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(12, 22)));
+
+ WindowsInstallerDirectoryLedger replaced = second.RecordCreated(InstallerDirectoryRole.AuthorityVersion, new(99, 33));
+ Assert.Equal(3, replaced.Generation);
+ Assert.Equal(new WindowsFileIdentity(99, 33), replaced.Directories[1].Identity);
+ Assert.Equal(new WindowsFileIdentity(12, 22), second.Directories[1].Identity);
+ Assert.Equal(2, replaced.Directories.Count);
+ }
+
+ [Fact]
+ public void ConstructorCopiesCallerEntriesAndDoesNotSortAnInvalidSequence()
+ {
+ WindowsInstallerOwnedDirectory[] entries = [new(InstallerDirectoryRole.InstallerRoot, new(1, 1))];
+ var ledger = new WindowsInstallerDirectoryLedger(1, entries, null);
+ entries[0] = new(InstallerDirectoryRole.AuthorityRoot, new(2, 2));
+ Assert.Equal(InstallerDirectoryRole.InstallerRoot, ledger.Directories[0].Role);
+ Assert.Throws(() => new WindowsInstallerDirectoryLedger(2,
+ [entries[0], new(InstallerDirectoryRole.ProgramFilesProduct, new(1, 1))], null));
+ Assert.Throws(() => new WindowsInstallerDirectoryLedger(2, [entries[0], entries[0]], null));
+ }
+
+ [Fact]
+ public void InvalidIdentitiesAndGenerationAreRejectedBeforeProducingANewLedger()
+ {
+ WindowsInstallerDirectoryLedger empty = WindowsInstallerDirectoryLedger.Empty;
+ Assert.Throws(() => empty.RecordCreated((InstallerDirectoryRole)99, new(1, 1)));
+ Assert.Throws(() => empty.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 0)));
+ Assert.Throws(() => new WindowsInstallerDirectoryLedger(-1, [], null));
+ Assert.Throws(() => new WindowsInstallerDirectoryLedger(7,
+ Enumerable.Repeat(new WindowsInstallerOwnedDirectory(InstallerDirectoryRole.InstallerRoot, new(1, 1)), 7), null));
+ var maximum = new WindowsInstallerDirectoryLedger(long.MaxValue, [], null);
+ Assert.Throws(() => maximum.RecordCreated(InstallerDirectoryRole.InstallerRoot, new(1, 1)));
+ Assert.Throws(() => maximum.BeginTerminal(Verified()));
+ Assert.Empty(maximum.Directories);
+ Assert.Null(maximum.Terminal);
+ }
+
+ [Fact]
+ public void BeginTerminalIsIdempotentOnlyForTheSameCanonicalVerifiedUninstall()
+ {
+ WindowsInstallerDirectoryLedger created = WindowsInstallerDirectoryLedger.Empty.RecordCreated(
+ InstallerDirectoryRole.InstallerRoot, new(10, 20));
+ InstallerTransactionSnapshot state = Verified();
+ WindowsInstallerDirectoryLedger terminal = created.BeginTerminal(state);
+ Assert.Null(created.Terminal);
+ Assert.Equal(created.Generation + 1, terminal.Generation);
+ Assert.Equal(created.Directories, terminal.Directories);
+ Assert.Same(terminal, terminal.BeginTerminal(InstallerTransactionSnapshot.Create(state.Journal)));
+
+ foreach (InstallerTransactionJournal changed in new[]
+ {
+ state.Journal with { TransactionId = new string('b', 64) },
+ state.Journal with { TargetSid = "S-1-5-21-100-200-300-1002" },
+ state.Journal with { InstallerPayloadSha256 = new string('c', 64) },
+ state.Journal with { ExpectedPackageVersion = "1.0.1.0" },
+ })
+ {
+ InstallerProtocolException failure = Assert.Throws(() =>
+ terminal.BeginTerminal(InstallerTransactionSnapshot.Create(changed)));
+ Assert.Equal("installer.directory_ledger.terminal_identity_mismatch", failure.DiagnosticCode);
+ }
+ }
+
+ [Theory]
+ [InlineData(InstallerOperation.Install, InstallerTransactionPhase.Verified, 5)]
+ [InlineData(InstallerOperation.Repair, InstallerTransactionPhase.Verified, 5)]
+ [InlineData(InstallerOperation.Uninstall, InstallerTransactionPhase.Prepared, 1)]
+ public void TerminalRequiresAnIndependentlyValidVerifiedUninstall(
+ InstallerOperation operation, InstallerTransactionPhase phase, int generation)
+ {
+ InstallerTransactionJournal journal = Verified().Journal with { Operation = operation, Phase = phase, Generation = generation };
+ InstallerProtocolException failure = Assert.Throws(() =>
+ WindowsInstallerDirectoryLedger.Empty.BeginTerminal(InstallerTransactionSnapshot.Create(journal)));
+ Assert.Equal("installer.directory_ledger.terminal_not_verified_uninstall", failure.DiagnosticCode);
+ }
+
+ [Fact]
+ public void CodecRoundTripsAllRolesAndTheExactTerminalSnapshot()
+ {
+ WindowsInstallerDirectoryLedger ledger = CompleteLedger().BeginTerminal(Verified());
+ byte[] serialized = WindowsInstallerDirectoryLedgerCodec.Serialize(ledger);
+ WindowsInstallerDirectoryLedger parsed = WindowsInstallerDirectoryLedgerCodec.Parse(serialized);
+ Assert.Equal(ledger.Generation, parsed.Generation);
+ Assert.Equal(ledger.Directories, parsed.Directories);
+ Assert.Equal(ledger.Terminal, parsed.Terminal);
+ Assert.Equal(serialized, WindowsInstallerDirectoryLedgerCodec.Serialize(parsed));
+ Assert.True(serialized.Length < WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes);
+ }
+
+ [Theory]
+ [InlineData("whitespace")]
+ [InlineData("property-order")]
+ [InlineData("escaping")]
+ public void NoncanonicalBytesAreRejectedBeforeTheyCanPoisonCompareAndSwap(string variation)
+ {
+ byte[] canonical = WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger());
+ string json = Encoding.UTF8.GetString(canonical);
+ string changed;
+ if (variation == "property-order")
+ {
+ var reordered = new JsonObject();
+ foreach (KeyValuePair property in JsonNode.Parse(canonical)!.AsObject().Reverse())
+ {
+ reordered.Add(property.Key, property.Value?.DeepClone());
+ }
+ changed = reordered.ToJsonString();
+ }
+ else
+ {
+ changed = variation == "whitespace" ? " " + json
+ : json.Replace("ProgramFilesProduct", "ProgramFiles\\u0050roduct", StringComparison.Ordinal);
+ }
+
+ // Save(expected) compares Serialize(expected) to the actual file bytes. Accepting a
+ // semantically equivalent, different byte sequence here would strand future updates.
+ Assert.NotEqual(json, changed);
+ Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(Utf8(changed)));
+ }
+
+ [Theory]
+ [InlineData("unknown-root")]
+ [InlineData("missing-root")]
+ [InlineData("duplicate-root")]
+ [InlineData("schema")]
+ [InlineData("generation-string")]
+ [InlineData("negative-generation")]
+ [InlineData("generation-overflow")]
+ [InlineData("null-entries")]
+ [InlineData("unknown-entry")]
+ [InlineData("duplicate-entry-property")]
+ [InlineData("unknown-role")]
+ [InlineData("numeric-role")]
+ [InlineData("wrong-case-role")]
+ [InlineData("zero-id")]
+ [InlineData("negative-id")]
+ [InlineData("id-overflow")]
+ [InlineData("volume-overflow")]
+ [InlineData("duplicate-role")]
+ [InlineData("unsorted-role")]
+ [InlineData("too-many-roles")]
+ [InlineData("trailing-comma")]
+ [InlineData("comment")]
+ [InlineData("non-object")]
+ [InlineData("too-deep")]
+ public void CodecRejectsAmbiguousOrOutOfScopeDocuments(string mutation)
+ {
+ JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize(CompleteLedger()))!.AsObject();
+ JsonArray entries = root["createdDirectories"]!.AsArray();
+ JsonObject first = entries[0]!.AsObject();
+ byte[]? raw = null;
+ switch (mutation)
+ {
+ case "unknown-root": root["extra"] = true; break;
+ case "missing-root": root.Remove("terminal"); break;
+ case "duplicate-root": raw = Utf8(root.ToJsonString().Replace("\"schema\":1", "\"schema\":1,\"schema\":1", StringComparison.Ordinal)); break;
+ case "schema": root["schema"] = 2; break;
+ case "generation-string": root["generation"] = "6"; break;
+ case "negative-generation": root["generation"] = -1; break;
+ case "generation-overflow": root["generation"] = ulong.MaxValue; break;
+ case "null-entries": root["createdDirectories"] = null; break;
+ case "unknown-entry": first["path"] = @"C:\foreign"; break;
+ case "duplicate-entry-property": raw = Utf8(root.ToJsonString().Replace("\"fileIndex\":1", "\"fileIndex\":1,\"fileIndex\":1", StringComparison.Ordinal)); break;
+ case "unknown-role": first["role"] = "ForeignRoot"; break;
+ case "numeric-role": first["role"] = "0"; break;
+ case "wrong-case-role": first["role"] = "programFilesProduct"; break;
+ case "zero-id": first["fileIndex"] = 0; break;
+ case "negative-id": first["fileIndex"] = -1; break;
+ case "id-overflow": first["fileIndex"] = JsonNode.Parse("18446744073709551616"); break;
+ case "volume-overflow": first["volumeSerialNumber"] = ulong.MaxValue; break;
+ case "duplicate-role": entries[1] = first.DeepClone(); break;
+ case "unsorted-role":
+ JsonNode copy = first.DeepClone();
+ entries[0] = entries[1]!.DeepClone();
+ entries[1] = copy;
+ break;
+ case "too-many-roles": entries.Add(first.DeepClone()); break;
+ case "trailing-comma": raw = Utf8(root.ToJsonString()[..^1] + ",}"); break;
+ case "comment": raw = Utf8("/*metadata*/" + root.ToJsonString()); break;
+ case "non-object": raw = Utf8("[]"); break;
+ case "too-deep": root["terminal"] = JsonNode.Parse("{\"nested\":{\"a\":{\"b\":{\"c\":1}}}}"); break;
+ default: throw new ArgumentOutOfRangeException(nameof(mutation));
+ }
+ Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString())));
+ }
+
+ [Theory]
+ [InlineData("wrong-hash")]
+ [InlineData("uppercase-hash")]
+ [InlineData("invalid-base64")]
+ [InlineData("noncanonical-base64")]
+ [InlineData("noncanonical-journal")]
+ [InlineData("invalid-nonce")]
+ [InlineData("uppercase-nonce")]
+ [InlineData("wrong-phase")]
+ [InlineData("wrong-operation")]
+ [InlineData("extra-terminal-property")]
+ [InlineData("duplicate-terminal-property")]
+ public void TerminalBytesMustMatchTheirCanonicalNoncePhaseAndHash(string mutation)
+ {
+ JsonObject root = JsonNode.Parse(WindowsInstallerDirectoryLedgerCodec.Serialize(
+ CompleteLedger().BeginTerminal(Verified())))!.AsObject();
+ JsonObject terminal = root["terminal"]!.AsObject();
+ byte[]? raw = null;
+ switch (mutation)
+ {
+ case "wrong-hash": terminal["contentHash"] = new string('0', 64); break;
+ case "uppercase-hash": terminal["contentHash"] = terminal["contentHash"]!.GetValue().ToUpperInvariant(); break;
+ case "invalid-base64": terminal["journalBase64"] = "%%%"; break;
+ case "noncanonical-base64": terminal["journalBase64"] = " " + terminal["journalBase64"]!.GetValue(); break;
+ case "extra-terminal-property": terminal["path"] = @"C:\foreign"; break;
+ case "duplicate-terminal-property":
+ raw = Utf8(root.ToJsonString().Replace("\"contentHash\":", "\"contentHash\":\"ignored\",\"contentHash\":", StringComparison.Ordinal));
+ break;
+ default:
+ JsonObject journal = JsonNode.Parse(Convert.FromBase64String(terminal["journalBase64"]!.GetValue()))!.AsObject();
+ switch (mutation)
+ {
+ case "invalid-nonce": journal["transactionId"] = new string('a', 63); break;
+ case "uppercase-nonce": journal["transactionId"] = new string('A', 64); break;
+ case "wrong-phase": journal["phase"] = "prepared"; journal["generation"] = 1; break;
+ case "wrong-operation": journal["operation"] = "install"; break;
+ case "noncanonical-journal": break;
+ default: throw new ArgumentOutOfRangeException(nameof(mutation));
+ }
+ byte[] bytes = Utf8((mutation == "noncanonical-journal" ? " " : string.Empty) + journal.ToJsonString());
+ terminal["journalBase64"] = Convert.ToBase64String(bytes);
+ terminal["contentHash"] = Convert.ToHexStringLower(SHA256.HashData(bytes));
+ break;
+ }
+ Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(raw ?? Utf8(root.ToJsonString())));
+ }
+
+ [Fact]
+ public void DocumentSizeBoundaryIsCheckedBeforeJsonParsing()
+ {
+ int maximum = WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes;
+ byte[] padded = Enumerable.Repeat((byte)' ', maximum).ToArray();
+ WindowsInstallerDirectoryLedgerCodec.Serialize(WindowsInstallerDirectoryLedger.Empty).CopyTo(padded, 0);
+ InstallerProtocolException boundaryFailure = Assert.Throws(() =>
+ WindowsInstallerDirectoryLedgerCodec.Parse(padded));
+ Assert.NotEqual("installer.directory_ledger.document_size_invalid", boundaryFailure.DiagnosticCode);
+ foreach (byte[] invalid in new[] { Array.Empty(), new byte[maximum + 1] })
+ {
+ InstallerProtocolException failure = Assert.Throws(() => WindowsInstallerDirectoryLedgerCodec.Parse(invalid));
+ Assert.Equal("installer.directory_ledger.document_size_invalid", failure.DiagnosticCode);
+ }
+ }
+
+ [Fact]
+ public void FixedLayoutKeepsItsLedgerOutsideAllDeletionTargetsAndOrdersChildrenFirst()
+ {
+ var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData);
+ Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), layout.LedgerPath);
+ InstallerDirectoryRole[] order = WindowsInstallerDirectoryCleanupLayout.DeletionOrder;
+ Assert.Equal(6, order.Distinct().Count());
+ foreach (InstallerDirectoryRole role in Enum.GetValues())
+ {
+ string path = layout.GetPath(role);
+ Assert.True(layout.TryGetRole(path.ToUpperInvariant(), out InstallerDirectoryRole observed));
+ Assert.Equal(role, observed);
+ Assert.False(layout.LedgerPath.StartsWith(path + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase));
+ foreach (InstallerDirectoryRole parent in order.Where(parent =>
+ path.StartsWith(layout.GetPath(parent) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)))
+ {
+ Assert.True(Array.IndexOf(order, role) < Array.IndexOf(order, parent));
+ }
+ }
+ Assert.Throws(() => layout.GetPath((InstallerDirectoryRole)99));
+ }
+
+ [Theory]
+ [InlineData(@"C:\ProgramData")]
+ [InlineData(@"C:\ProgramData\ClashSharp-other")]
+ [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\child")]
+ [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\..")]
+ [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2:stream")]
+ [InlineData(@"C:\ProgramData\ClashSharp\Installer\v2\")]
+ [InlineData(@"\\?\C:\ProgramData\ClashSharp\Installer\v2")]
+ public void FixedLayoutDoesNotAuthorizeAncestorSiblingChildOrAliasPaths(string path)
+ {
+ var layout = new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData);
+ Assert.False(layout.TryGetRole(path, out _));
+ }
+
+ [Fact]
+ public async Task PersistenceUsesOnlyTheFixedLedgerAndConfirmsSaveAndDeleteWhileAnchored()
+ {
+ var fixture = new PersistenceFixture();
+ WindowsInstallerDirectoryLedger desired = CompleteLedger();
+ Assert.Null(await fixture.Persistence.LoadAsync(CancellationToken.None));
+ await fixture.Persistence.SaveAsync(null, desired, CancellationToken.None);
+ WindowsInstallerDirectoryLedger? loaded = await fixture.Persistence.LoadAsync(CancellationToken.None);
+ Assert.NotNull(loaded);
+ Assert.Equal(WindowsInstallerDirectoryLedgerCodec.Serialize(desired), WindowsInstallerDirectoryLedgerCodec.Serialize(loaded));
+ await fixture.Persistence.DeleteAsync(desired, CancellationToken.None);
+ Assert.Null(fixture.Files.Bytes);
+ Assert.Equal(0, fixture.ActiveAnchors);
+ Assert.All(fixture.Files.Paths, path => Assert.Equal(Path.Combine(ProgramData, WindowsInstallerDirectoryCleanupLayout.LedgerFileName), path));
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public async Task NativeAcknowledgementWithoutItsPostconditionCannotSucceed(bool deleting)
+ {
+ var fixture = new PersistenceFixture();
+ WindowsInstallerDirectoryLedger desired = CompleteLedger();
+ fixture.Files.IgnoreMutation = true;
+ if (deleting) { fixture.Files.Bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired); }
+ InstallerProtocolException failure = await Assert.ThrowsAsync(() => deleting
+ ? fixture.Persistence.DeleteAsync(desired, CancellationToken.None)
+ : fixture.Persistence.SaveAsync(null, desired, CancellationToken.None));
+ Assert.Equal(deleting ? "installer.directory_ledger.delete_not_observed" : "installer.directory_ledger.write_not_observed", failure.DiagnosticCode);
+ Assert.Equal(0, fixture.ActiveAnchors);
+ }
+
+ [Theory]
+ [InlineData("load")]
+ [InlineData("save")]
+ [InlineData("delete")]
+ public async Task StorageFailuresPropagateWithoutLosingTheAnchorLease(string operation)
+ {
+ var fixture = new PersistenceFixture();
+ var failure = new IOException("Injected metadata I/O failure.");
+ fixture.Files.Failure = failure;
+ IOException observed = await Assert.ThrowsAsync(() => RunPersistenceAsync(fixture, operation));
+ Assert.Same(failure, observed);
+ Assert.Equal(0, fixture.ActiveAnchors);
+ }
+
+ [Fact]
+ public async Task CorruptPersistedBytesAreNotTreatedAsAMissingLedger()
+ {
+ var fixture = new PersistenceFixture();
+ fixture.Files.Bytes = Utf8("{}");
+ await Assert.ThrowsAsync(() => fixture.Persistence.LoadAsync(CancellationToken.None));
+ Assert.Equal(0, fixture.ActiveAnchors);
+ Assert.Equal(Utf8("{}"), fixture.Files.Bytes);
+ }
+
+ [Fact]
+ public async Task PreCancelledOperationsAcquireNoAnchorAndInvokeNoNativeFileWork()
+ {
+ var fixture = new PersistenceFixture();
+ using var cancellation = new CancellationTokenSource();
+ cancellation.Cancel();
+ await Assert.ThrowsAnyAsync(() => fixture.Persistence.LoadAsync(cancellation.Token));
+ await Assert.ThrowsAnyAsync(() => fixture.Persistence.SaveAsync(null, CompleteLedger(), cancellation.Token));
+ await Assert.ThrowsAnyAsync(() => fixture.Persistence.DeleteAsync(CompleteLedger(), cancellation.Token));
+ Assert.Equal(0, fixture.AnchorAcquisitions);
+ Assert.Empty(fixture.Files.Paths);
+ }
+
+ private static Task RunPersistenceAsync(PersistenceFixture fixture, string operation) => operation switch
+ {
+ "load" => fixture.Persistence.LoadAsync(CancellationToken.None),
+ "save" => fixture.Persistence.SaveAsync(null, CompleteLedger(), CancellationToken.None),
+ "delete" => fixture.Persistence.DeleteAsync(CompleteLedger(), CancellationToken.None),
+ _ => throw new ArgumentOutOfRangeException(nameof(operation)),
+ };
+
+ private static byte[] Utf8(string value) => Encoding.UTF8.GetBytes(value);
+
+ private static InstallerTransactionSnapshot Verified() => InstallerTransactionSnapshot.Create(new(
+ InstallerTransactionJournal.CurrentSchema, new string('a', 64), InstallerOperation.Uninstall,
+ TargetSid, false, "1.0.0.0", new string('d', 64), InstallerTransactionPhase.Verified, 5));
+
+ private static WindowsInstallerDirectoryLedger CompleteLedger() => new(6,
+ Enum.GetValues().Select(role => new WindowsInstallerOwnedDirectory(role, new(12, (ulong)role + 1))), null);
+
+ private sealed class PersistenceFixture
+ {
+ internal PersistenceFixture()
+ {
+ Files = new Files(this);
+ Persistence = new(new WindowsInstallerDirectoryCleanupLayout(ProgramFiles, ProgramData), Acquire, Files);
+ }
+
+ internal WindowsInstallerDirectoryLedgerPersistence Persistence { get; }
+ internal Files Files { get; }
+ internal int ActiveAnchors { get; private set; }
+ internal int AnchorAcquisitions { get; private set; }
+
+ private IDisposable Acquire()
+ {
+ ActiveAnchors++;
+ AnchorAcquisitions++;
+ return new Anchor(this);
+ }
+
+ private sealed class Anchor(PersistenceFixture owner) : IDisposable
+ {
+ private bool _disposed;
+ public void Dispose()
+ {
+ if (!_disposed) { _disposed = true; owner.ActiveAnchors--; }
+ }
+ }
+ }
+
+ private sealed class Files(PersistenceFixture fixture) : IWindowsInstallerDirectoryLedgerFileNative
+ {
+ internal byte[]? Bytes { get; set; }
+ internal bool IgnoreMutation { get; set; }
+ internal Exception? Failure { get; set; }
+ internal List Paths { get; } = [];
+
+ public Task ReadAsync(string path, CancellationToken cancellationToken)
+ {
+ Observe(path, cancellationToken);
+ return Task.FromResult(Bytes?.ToArray());
+ }
+
+ public Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken)
+ {
+ Observe(path, cancellationToken);
+ Assert.Equal(Bytes, expected);
+ if (!IgnoreMutation) { Bytes = desired.ToArray(); }
+ return Task.CompletedTask;
+ }
+
+ public Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken)
+ {
+ Observe(path, cancellationToken);
+ Assert.Equal(Bytes, expected);
+ if (!IgnoreMutation) { Bytes = null; }
+ return Task.CompletedTask;
+ }
+
+ private void Observe(string path, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ Assert.Equal(1, fixture.ActiveAnchors);
+ Paths.Add(path);
+ if (Failure is { } failure) { throw failure; }
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs
new file mode 100644
index 00000000..725c51c3
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerCleanupTransactionStore.cs
@@ -0,0 +1,131 @@
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Transactions;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+///
+/// Keeps the exact verified uninstall recoverable while its ordinary journal and owned directories
+/// are finalized. The helper's existing authority lease serializes this session; neither store is owned.
+///
+internal sealed class WindowsInstallerCleanupTransactionStore : IInstallerTransactionStore
+{
+ private readonly IInstallerTransactionStore _inner;
+ private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger;
+ private InstallerTransactionSnapshot? _clearedTerminal;
+
+ internal WindowsInstallerCleanupTransactionStore(IInstallerTransactionStore inner,
+ IWindowsInstallerDirectoryLedgerPersistence ledger)
+ {
+ ArgumentNullException.ThrowIfNull(inner);
+ ArgumentNullException.ThrowIfNull(ledger);
+ _inner = inner;
+ _ledger = ledger;
+ }
+
+ public async Task LoadAsync(CancellationToken cancellationToken)
+ {
+ (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) =
+ await ReadAsync(cancellationToken).ConfigureAwait(false);
+ if (active is not null) { return active; }
+ InstallerTransactionSnapshot? terminal = ledger?.Terminal;
+ // Only this successfully cleared helper session suppresses its exact terminal checkpoint.
+ // A new parent or helper wrapper still sees it until the directory finalizer deletes it.
+ return terminal == _clearedTerminal ? null : terminal;
+ }
+
+ public async Task SaveAsync(InstallerTransactionJournal journal,
+ string? expectedCurrentHash, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(journal);
+ InstallerTransactionSnapshot desired = InstallerTransactionSnapshot.Create(journal);
+ (InstallerTransactionSnapshot? _, WindowsInstallerDirectoryLedger? ledger) =
+ await ReadAsync(cancellationToken).ConfigureAwait(false);
+ if (ledger?.Terminal is { } terminal)
+ {
+ if (desired != terminal
+ || !string.Equals(expectedCurrentHash, terminal.ContentHash, StringComparison.Ordinal)
+ || _clearedTerminal is not null)
+ {
+ throw new InstallerProtocolException("installer.transaction.write_conflict");
+ }
+ // Final verification may acknowledge the same Verified checkpoint. Never resurrect
+ // an ordinary journal or manufacture a Prepared transaction from terminal-only state.
+ return terminal;
+ }
+ if (_clearedTerminal is not null)
+ {
+ throw new InstallerProtocolException("installer.transaction.write_conflict");
+ }
+ return await _inner.SaveAsync(journal, expectedCurrentHash, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task ClearVerifiedAsync(string transactionId, string expectedCurrentHash,
+ CancellationToken cancellationToken)
+ {
+ InstallerProtocolValidation.ValidateLowerHex256(transactionId, "installer.transaction.id_invalid");
+ InstallerProtocolValidation.ValidateLowerHex256(expectedCurrentHash, "installer.transaction.content_hash_invalid");
+ (InstallerTransactionSnapshot? active, WindowsInstallerDirectoryLedger? ledger) =
+ await ReadAsync(cancellationToken).ConfigureAwait(false);
+ InstallerTransactionSnapshot? current = active ?? ledger?.Terminal;
+ if (current is null || current.Journal.Phase != InstallerTransactionPhase.Verified
+ || !string.Equals(current.Journal.TransactionId, transactionId, StringComparison.Ordinal)
+ || !string.Equals(current.ContentHash, expectedCurrentHash, StringComparison.Ordinal))
+ {
+ throw new InstallerProtocolException("installer.transaction.clear_conflict");
+ }
+
+ if (current.Journal.Operation != InstallerOperation.Uninstall)
+ {
+ await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false);
+ return;
+ }
+
+ WindowsInstallerDirectoryLedger pending = (ledger ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(current);
+ await _ledger.SaveAsync(ledger, pending, cancellationToken).ConfigureAwait(false);
+ await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ if (active is not null)
+ {
+ await _inner.ClearVerifiedAsync(transactionId, expectedCurrentHash, cancellationToken).ConfigureAwait(false);
+ }
+
+ InstallerTransactionSnapshot? remaining = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false);
+ remaining?.Validate();
+ if (remaining is not null)
+ {
+ throw new InstallerProtocolException("installer.transaction.clear_not_observed");
+ }
+ await RequireTerminalAsync(pending, cancellationToken).ConfigureAwait(false);
+ cancellationToken.ThrowIfCancellationRequested();
+ _clearedTerminal = current;
+ }
+
+ private async Task<(InstallerTransactionSnapshot? Active, WindowsInstallerDirectoryLedger? Ledger)> ReadAsync(
+ CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ InstallerTransactionSnapshot? active = await _inner.LoadAsync(cancellationToken).ConfigureAwait(false);
+ active?.Validate();
+ WindowsInstallerDirectoryLedger? ledger = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false);
+ ledger?.Validate();
+ if (ledger?.Terminal is { } terminal
+ && ((active is not null && active != terminal)
+ || (_clearedTerminal is not null && _clearedTerminal != terminal)))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("terminal_identity_mismatch");
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ return (active, ledger);
+ }
+
+ private async Task RequireTerminalAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken)
+ {
+ WindowsInstallerDirectoryLedger? observed = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false);
+ observed?.Validate();
+ if (observed is null || observed.Generation != expected.Generation || observed.Terminal != expected.Terminal
+ || !observed.Directories.SequenceEqual(expected.Directories))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("terminal_not_observed");
+ }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs
new file mode 100644
index 00000000..aa8b4adf
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryCleanupLayout.cs
@@ -0,0 +1,54 @@
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Windows.Machines;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+internal sealed class WindowsInstallerDirectoryCleanupLayout
+{
+ internal const string LedgerFileName = "ClashSharp.InstallerDirectories.v1.json";
+ private readonly IReadOnlyDictionary _paths;
+
+ internal WindowsInstallerDirectoryCleanupLayout(string programFiles, string programData)
+ {
+ WindowsMachineDeploymentRoots roots = WindowsMachineDeploymentRoots.Create(programFiles, programData);
+ ProgramData = roots.CommonApplicationDataRoot;
+ LedgerPath = Path.Combine(ProgramData, LedgerFileName);
+ string productData = Path.Combine(ProgramData, "ClashSharp");
+ _paths = new Dictionary
+ {
+ [InstallerDirectoryRole.ProgramFilesProduct] = Path.Combine(roots.ProgramFilesRoot, "ClashSharp"),
+ [InstallerDirectoryRole.ProgramDataProduct] = productData,
+ [InstallerDirectoryRole.InstallerRoot] = Path.Combine(productData, "Installer"),
+ [InstallerDirectoryRole.InstallerVersion] = Path.Combine(productData, "Installer", "v2"),
+ [InstallerDirectoryRole.AuthorityRoot] = Path.Combine(productData, "InstallerAuthority"),
+ [InstallerDirectoryRole.AuthorityVersion] = Path.Combine(productData, "InstallerAuthority", "v1"),
+ };
+ }
+
+ internal string ProgramData { get; }
+ internal string LedgerPath { get; }
+ internal string GetPath(InstallerDirectoryRole role) => _paths.TryGetValue(role, out string? path)
+ ? path : throw WindowsInstallerDirectoryLedger.Failure("role_invalid");
+
+ internal bool TryGetRole(string path, out InstallerDirectoryRole role)
+ {
+ foreach (KeyValuePair entry in _paths)
+ {
+ if (string.Equals(path, entry.Value, StringComparison.OrdinalIgnoreCase)) { role = entry.Key; return true; }
+ }
+ role = default;
+ return false;
+ }
+
+ internal static WindowsInstallerDirectoryCleanupLayout CreateDefault() => new(
+ Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles, Environment.SpecialFolderOption.DoNotVerify),
+ Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData, Environment.SpecialFolderOption.DoNotVerify));
+
+ internal static InstallerDirectoryRole[] DeletionOrder =>
+ [
+ InstallerDirectoryRole.ProgramFilesProduct,
+ InstallerDirectoryRole.InstallerVersion, InstallerDirectoryRole.InstallerRoot,
+ InstallerDirectoryRole.AuthorityVersion, InstallerDirectoryRole.AuthorityRoot,
+ InstallerDirectoryRole.ProgramDataProduct,
+ ];
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs
new file mode 100644
index 00000000..486a5fa7
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedger.cs
@@ -0,0 +1,181 @@
+using System.Buffers;
+using System.Collections.ObjectModel;
+using System.Security.Cryptography;
+using System.Text.Json;
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Transactions;
+using ClashSharp.Installer.Windows.Files;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+internal sealed record WindowsInstallerOwnedDirectory(InstallerDirectoryRole Role, WindowsFileIdentity Identity);
+
+/// Only a positively observed directory creation can add an object to this ledger.
+internal sealed class WindowsInstallerDirectoryLedger
+{
+ internal WindowsInstallerDirectoryLedger(long generation,
+ IEnumerable directories, InstallerTransactionSnapshot? terminal)
+ {
+ ArgumentNullException.ThrowIfNull(directories);
+ Generation = generation;
+ Directories = Array.AsReadOnly(directories.ToArray());
+ Terminal = terminal;
+ Validate();
+ }
+
+ internal long Generation { get; }
+ internal ReadOnlyCollection Directories { get; }
+ internal InstallerTransactionSnapshot? Terminal { get; }
+ internal static WindowsInstallerDirectoryLedger Empty => new(0, [], null);
+
+ internal WindowsInstallerDirectoryLedger RecordCreated(InstallerDirectoryRole role, WindowsFileIdentity identity)
+ {
+ var entry = new WindowsInstallerOwnedDirectory(role, identity);
+ if (Directories.Contains(entry)) { return this; }
+ return new(checked(Generation + 1), Directories.Where(item => item.Role != role)
+ .Append(entry).OrderBy(item => item.Role), Terminal);
+ }
+
+ internal WindowsInstallerDirectoryLedger BeginTerminal(InstallerTransactionSnapshot state)
+ {
+ ArgumentNullException.ThrowIfNull(state);
+ if (Terminal is not null)
+ {
+ if (Terminal != state) { throw Failure("terminal_identity_mismatch"); }
+ return this;
+ }
+ return new(checked(Generation + 1), Directories, state);
+ }
+
+ internal void Validate()
+ {
+ if (Generation < 0 || Directories.Count > 6) { throw Failure("shape_invalid"); }
+ InstallerDirectoryRole? previous = null;
+ foreach (WindowsInstallerOwnedDirectory entry in Directories)
+ {
+ if (entry is null || !Enum.IsDefined(entry.Role) || entry.Identity.FileIndex == 0
+ || previous is { } last && entry.Role <= last) { throw Failure("directory_identity_invalid"); }
+ previous = entry.Role;
+ }
+ if (Terminal is { } state)
+ {
+ state.Validate();
+ if (state.Journal.Operation != InstallerOperation.Uninstall
+ || state.Journal.Phase != InstallerTransactionPhase.Verified)
+ {
+ throw Failure("terminal_not_verified_uninstall");
+ }
+ }
+ }
+
+ internal static InstallerProtocolException Failure(string suffix) => new("installer.directory_ledger." + suffix);
+}
+
+internal static class WindowsInstallerDirectoryLedgerCodec
+{
+ internal const int MaximumDocumentBytes = 4096;
+
+ internal static byte[] Serialize(WindowsInstallerDirectoryLedger ledger)
+ {
+ ArgumentNullException.ThrowIfNull(ledger);
+ ledger.Validate();
+ var buffer = new ArrayBufferWriter();
+ using (var writer = new Utf8JsonWriter(buffer))
+ {
+ writer.WriteStartObject();
+ writer.WriteNumber("schema", 1);
+ writer.WriteNumber("generation", ledger.Generation);
+ writer.WriteStartArray("createdDirectories");
+ foreach (WindowsInstallerOwnedDirectory directory in ledger.Directories)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("role", directory.Role.ToString());
+ writer.WriteNumber("volumeSerialNumber", directory.Identity.VolumeSerialNumber);
+ writer.WriteNumber("fileIndex", directory.Identity.FileIndex);
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ if (ledger.Terminal is not { } terminal) { writer.WriteNull("terminal"); }
+ else
+ {
+ writer.WriteStartObject("terminal");
+ writer.WriteString("journalBase64", Convert.ToBase64String(InstallerTransactionCodec.Serialize(terminal.Journal)));
+ writer.WriteString("contentHash", terminal.ContentHash);
+ writer.WriteEndObject();
+ }
+ writer.WriteEndObject();
+ }
+ byte[] bytes = buffer.WrittenSpan.ToArray();
+ ValidateSize(bytes);
+ return bytes;
+ }
+
+ internal static WindowsInstallerDirectoryLedger Parse(ReadOnlyMemory bytes)
+ {
+ ValidateSize(bytes.Span);
+ try
+ {
+ using JsonDocument document = JsonDocument.Parse(bytes, new JsonDocumentOptions
+ {
+ MaxDepth = 4,
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ });
+ JsonElement root = document.RootElement;
+ ExactObject(root, "schema", "generation", "createdDirectories", "terminal");
+ if (root.GetProperty("schema").GetInt32() != 1) { throw new JsonException(); }
+ var directories = new List();
+ foreach (JsonElement entry in root.GetProperty("createdDirectories").EnumerateArray())
+ {
+ ExactObject(entry, "role", "volumeSerialNumber", "fileIndex");
+ string roleText = entry.GetProperty("role").GetString() ?? throw new JsonException();
+ if (!Enum.TryParse(roleText, ignoreCase: false, out InstallerDirectoryRole role)
+ || !Enum.IsDefined(role) || role.ToString() != roleText) { throw new JsonException(); }
+ directories.Add(new(role, new(entry.GetProperty("volumeSerialNumber").GetUInt32(),
+ entry.GetProperty("fileIndex").GetUInt64())));
+ if (directories.Count > 6) { throw new JsonException(); }
+ }
+ InstallerTransactionSnapshot? terminal = null;
+ JsonElement terminalElement = root.GetProperty("terminal");
+ if (terminalElement.ValueKind != JsonValueKind.Null)
+ {
+ ExactObject(terminalElement, "journalBase64", "contentHash");
+ string encoded = terminalElement.GetProperty("journalBase64").GetString() ?? throw new JsonException();
+ byte[] journalBytes = Convert.FromBase64String(encoded);
+ if (Convert.ToBase64String(journalBytes) != encoded) { throw new JsonException(); }
+ InstallerTransactionJournal journal = InstallerTransactionCodec.Parse(journalBytes);
+ byte[] canonical = InstallerTransactionCodec.Serialize(journal);
+ string contentHash = terminalElement.GetProperty("contentHash").GetString() ?? throw new JsonException();
+ if (!journalBytes.AsSpan().SequenceEqual(canonical)
+ || Convert.ToHexStringLower(SHA256.HashData(canonical)) != contentHash) { throw new JsonException(); }
+ terminal = new(journal, contentHash);
+ }
+ var ledger = new WindowsInstallerDirectoryLedger(root.GetProperty("generation").GetInt64(), directories, terminal);
+ if (!bytes.Span.SequenceEqual(Serialize(ledger)))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("document_noncanonical");
+ }
+ return ledger;
+ }
+ catch (Exception exception) when (exception is JsonException or InvalidOperationException or FormatException or OverflowException)
+ {
+ throw new InstallerProtocolException("installer.directory_ledger.document_invalid", exception);
+ }
+ }
+
+ private static void ExactObject(JsonElement element, params string[] properties)
+ {
+ if (element.ValueKind != JsonValueKind.Object) { throw new JsonException(); }
+ var names = new HashSet(StringComparer.Ordinal);
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ if (!names.Add(property.Name) || !properties.Contains(property.Name, StringComparer.Ordinal)) { throw new JsonException(); }
+ }
+ if (names.Count != properties.Length) { throw new JsonException(); }
+ }
+
+ private static void ValidateSize(ReadOnlySpan bytes)
+ {
+ if (bytes.Length is < 1 or > MaximumDocumentBytes) { throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid"); }
+ }
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs
new file mode 100644
index 00000000..f46a9db2
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryLedgerPersistence.cs
@@ -0,0 +1,355 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Cryptography;
+using System.Security.Principal;
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Windows.Files;
+using ClashSharp.Windows.FileSecurity;
+using Microsoft.Win32.SafeHandles;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+internal interface IWindowsInstallerDirectoryLedgerPersistence
+{
+ Task LoadAsync(CancellationToken cancellationToken);
+ Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired, CancellationToken cancellationToken);
+ Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken);
+}
+
+internal interface IWindowsInstallerDirectoryLedgerFileNative
+{
+ Task ReadAsync(string path, CancellationToken cancellationToken);
+ Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken);
+ Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken);
+}
+
+internal sealed class WindowsInstallerDirectoryLedgerPersistence : IWindowsInstallerDirectoryLedgerPersistence
+{
+ private readonly string _path;
+ private readonly Func _acquireAnchor;
+ private readonly IWindowsInstallerDirectoryLedgerFileNative _files;
+
+ internal WindowsInstallerDirectoryLedgerPersistence(WindowsInstallerDirectoryCleanupLayout layout,
+ Func acquireAnchor, IWindowsInstallerDirectoryLedgerFileNative files)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(acquireAnchor);
+ ArgumentNullException.ThrowIfNull(files);
+ _path = layout.LedgerPath;
+ _acquireAnchor = acquireAnchor;
+ _files = files;
+ }
+
+ internal static WindowsInstallerDirectoryLedgerPersistence CreateDefault()
+ {
+ WindowsInstallerDirectoryCleanupLayout layout = WindowsInstallerDirectoryCleanupLayout.CreateDefault();
+ return new(layout, () => WindowsInstallerDirectoryAnchor.Acquire(layout.ProgramData),
+ WindowsInstallerDirectoryLedgerFileNative.Instance);
+ }
+
+ public async Task LoadAsync(CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ using IDisposable anchor = _acquireAnchor();
+ byte[]? bytes = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false);
+ return bytes is null ? null : WindowsInstallerDirectoryLedgerCodec.Parse(bytes);
+ }
+
+ public async Task SaveAsync(WindowsInstallerDirectoryLedger? expected, WindowsInstallerDirectoryLedger desired,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(desired);
+ cancellationToken.ThrowIfCancellationRequested();
+ using IDisposable anchor = _acquireAnchor();
+ byte[] bytes = WindowsInstallerDirectoryLedgerCodec.Serialize(desired);
+ await _files.PublishAsync(_path, expected is null ? null : WindowsInstallerDirectoryLedgerCodec.Serialize(expected),
+ bytes, cancellationToken).ConfigureAwait(false);
+ byte[]? observed = await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false);
+ if (observed is null || !CryptographicOperations.FixedTimeEquals(bytes, observed))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("write_not_observed");
+ }
+ }
+
+ public async Task DeleteAsync(WindowsInstallerDirectoryLedger expected, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(expected);
+ cancellationToken.ThrowIfCancellationRequested();
+ using IDisposable anchor = _acquireAnchor();
+ await _files.DeleteAsync(_path, WindowsInstallerDirectoryLedgerCodec.Serialize(expected), cancellationToken).ConfigureAwait(false);
+ if (await _files.ReadAsync(_path, cancellationToken).ConfigureAwait(false) is not null)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("delete_not_observed");
+ }
+ }
+}
+
+/// Only the already-existing trusted known-folder chain is pinned by this lease.
+internal sealed class WindowsInstallerDirectoryAnchor : IDisposable
+{
+ private readonly List _leases = [];
+
+ internal static WindowsInstallerDirectoryAnchor Acquire(string directory)
+ {
+ if (!Path.IsPathFullyQualified(directory)
+ || !string.Equals(directory, Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)), StringComparison.OrdinalIgnoreCase))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid");
+ }
+ var owner = new WindowsInstallerDirectoryAnchor();
+ try
+ {
+ string current = Path.GetPathRoot(directory) ?? throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid");
+ owner.Add(current);
+ foreach (string segment in Path.GetRelativePath(current, directory).Split(Path.DirectorySeparatorChar))
+ {
+ if (segment is "." or ".." or "") { throw WindowsInstallerDirectoryLedger.Failure("anchor_path_invalid"); }
+ current = Path.Combine(current, segment);
+ owner.Add(current);
+ }
+ return owner;
+ }
+ catch { owner.Dispose(); throw; }
+ }
+
+ private void Add(string path)
+ {
+ WindowsDirectoryReadLease lease = WindowsDirectoryReadLease.Open(path);
+ _leases.Add(lease);
+ WindowsDirectoryObservation observation = lease.Observe();
+ if (!observation.IsDirectory || observation.IsReparsePoint
+ || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(observation.Security))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("anchor_unsafe");
+ }
+ }
+
+ public void Dispose()
+ {
+ for (int index = _leases.Count - 1; index >= 0; index--) { _leases[index].Dispose(); }
+ _leases.Clear();
+ }
+}
+
+internal static class WindowsInstallerDirectoryLedgerSecurity
+{
+ internal const string UsersSid = "S-1-5-32-545";
+ private const FileSystemRights ReadRights = FileSystemRights.Read | FileSystemRights.Synchronize;
+
+ internal static FileSecurity Create()
+ {
+ var security = new FileSecurity();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+ security.SetOwner(new SecurityIdentifier(WindowsDirectoryAccessPolicy.AdministratorsSid));
+ foreach ((string sid, FileSystemRights rights) in Rules())
+ {
+ security.AddAccessRule(new FileSystemAccessRule(new SecurityIdentifier(sid), rights, AccessControlType.Allow));
+ }
+ return security;
+ }
+
+ internal static void Validate(WindowsDirectorySecuritySnapshot security)
+ {
+ if (!security.HasDacl || !security.DaclProtected || security.OwnerSid != WindowsDirectoryAccessPolicy.AdministratorsSid
+ || security.AccessEntries.Count != 3) { throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid"); }
+ foreach ((string sid, FileSystemRights rights) in Rules())
+ {
+ WindowsDirectoryAce[] entries = security.AccessEntries.Where(entry => entry.Sid == sid).ToArray();
+ if (entries.Length != 1 || entries[0].Kind != WindowsDirectoryAceKind.Allow
+ || entries[0].AccessMask != (int)rights || entries[0].Flags != AceFlags.None || entries[0].IsObjectSpecific)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("file_acl_invalid");
+ }
+ }
+ }
+
+ private static (string Sid, FileSystemRights Rights)[] Rules() =>
+ [
+ (WindowsDirectoryAccessPolicy.LocalSystemSid, FileSystemRights.FullControl),
+ (WindowsDirectoryAccessPolicy.AdministratorsSid, FileSystemRights.FullControl),
+ (UsersSid, ReadRights),
+ ];
+}
+
+/// Fixed ordinary file with a separately validated read-only public metadata policy.
+internal sealed class WindowsInstallerDirectoryLedgerFileNative : IWindowsInstallerDirectoryLedgerFileNative
+{
+ internal static WindowsInstallerDirectoryLedgerFileNative Instance { get; } = new();
+
+ public async Task ReadAsync(string path, CancellationToken cancellationToken)
+ {
+ ValidatePath(path);
+ cancellationToken.ThrowIfCancellationRequested();
+ using SafeFileHandle? handle = OpenIfPresent(path, deletion: false);
+ return handle is null ? null : await ReadHandleAsync(handle, cancellationToken).ConfigureAwait(false);
+ }
+
+ public async Task PublishAsync(string path, byte[]? expected, byte[] desired, CancellationToken cancellationToken)
+ {
+ ValidatePath(path);
+ _ = WindowsInstallerDirectoryLedgerCodec.Parse(desired);
+ cancellationToken.ThrowIfCancellationRequested();
+ // A protected existing object is held open throughout publication. Delete sharing permits
+ // this authority's atomic replacement; its exact DACL and the pinned parent prohibit user replacement.
+ using SafeFileHandle? original = OpenIfPresent(path, deletion: false, allowDeleteSharing: true);
+ byte[]? current = original is null ? null : await ReadHandleAsync(original, cancellationToken).ConfigureAwait(false);
+ RequireExpected(expected, current);
+ WindowsFileIdentity? originalIdentity = original is null ? null : WindowsFileSystemNative.GetOrdinaryFileIdentity(original);
+ string temporary = Path.Combine(Path.GetDirectoryName(path)!, "." + WindowsInstallerDirectoryCleanupLayout.LedgerFileName
+ + "." + Guid.NewGuid().ToString("N") + ".tmp");
+ WindowsFileIdentity? temporaryIdentity = null;
+ bool published = false;
+ try
+ {
+ await using (FileStream stream = new FileInfo(temporary).Create(FileMode.CreateNew, FileSystemRights.FullControl,
+ FileShare.None, 4096, FileOptions.Asynchronous | FileOptions.WriteThrough, WindowsInstallerDirectoryLedgerSecurity.Create()))
+ {
+ ValidateFile(stream.SafeFileHandle);
+ temporaryIdentity = WindowsFileSystemNative.GetOrdinaryFileIdentity(stream.SafeFileHandle);
+ await stream.WriteAsync(desired, cancellationToken).ConfigureAwait(false);
+ await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
+ stream.Flush(flushToDisk: true);
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ using (SafeFileHandle? final = OpenIfPresent(path, deletion: false, allowDeleteSharing: true))
+ {
+ if ((final is null) != (originalIdentity is null)
+ || final is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(final) != originalIdentity)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("publication_target_changed");
+ }
+ if (final is not null)
+ {
+ RequireExpected(expected, await ReadHandleAsync(final, cancellationToken).ConfigureAwait(false));
+ }
+ }
+ // Crucially, a missing target is published WITHOUT REPLACE_EXISTING. ProgramData can
+ // permit unrelated users to create names; a name won in that race must never be overwritten.
+ if (!MoveFileEx(temporary, path, 8U | (originalIdentity is null ? 0U : 1U)))
+ {
+ throw new InstallerStateUncertainException("installer.directory_ledger.publication_uncertain");
+ }
+ published = true;
+ using SafeFileHandle? persisted = OpenIfPresent(path, deletion: false);
+ if (persisted is null || WindowsFileSystemNative.GetOrdinaryFileIdentity(persisted) != temporaryIdentity)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("publication_identity_changed");
+ }
+ }
+ finally
+ {
+ if (!published && temporaryIdentity is { } identity) { TryDeleteTemporary(temporary, identity); }
+ }
+ }
+
+ public async Task DeleteAsync(string path, byte[] expected, CancellationToken cancellationToken)
+ {
+ ValidatePath(path);
+ cancellationToken.ThrowIfCancellationRequested();
+ using SafeFileHandle? file = OpenIfPresent(path, deletion: true);
+ if (file is null) { return; }
+ RequireExpected(expected, await ReadHandleAsync(file, cancellationToken).ConfigureAwait(false));
+ cancellationToken.ThrowIfCancellationRequested();
+ DeleteHandle(file);
+ }
+
+ private static void RequireExpected(byte[]? expected, byte[]? actual)
+ {
+ if (actual is not null) { _ = WindowsInstallerDirectoryLedgerCodec.Parse(actual); }
+ if ((expected is null) != (actual is null)
+ || expected is not null && !CryptographicOperations.FixedTimeEquals(expected, actual!))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("compare_exchange_failed");
+ }
+ }
+
+ private static async Task ReadHandleAsync(SafeFileHandle file, CancellationToken cancellationToken)
+ {
+ long size = RandomAccess.GetLength(file);
+ if (size is < 1 or > WindowsInstallerDirectoryLedgerCodec.MaximumDocumentBytes)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("document_size_invalid");
+ }
+ byte[] bytes = new byte[(int)size];
+ int offset = 0;
+ while (offset < bytes.Length)
+ {
+ int count = await RandomAccess.ReadAsync(file, bytes.AsMemory(offset), offset, cancellationToken).ConfigureAwait(false);
+ if (count == 0) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); }
+ offset += count;
+ }
+ if (RandomAccess.GetLength(file) != size) { throw WindowsInstallerDirectoryLedger.Failure("document_changed"); }
+ return bytes;
+ }
+
+ private static SafeFileHandle? OpenIfPresent(string path, bool deletion, bool allowDeleteSharing = false)
+ {
+ SafeFileHandle handle = CreateFile(path, 0x80000000U | (deletion ? 0x00010000U : 0U),
+ 1U | (allowDeleteSharing ? 4U : 0U), 0, 3, 0x00200000U, 0);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ handle.Dispose();
+ if (error == 2) { return null; }
+ throw new Win32Exception(error);
+ }
+ try { ValidateFile(handle); return handle; }
+ catch { handle.Dispose(); throw; }
+ }
+
+ private static void ValidateFile(SafeFileHandle file)
+ {
+ _ = WindowsFileSystemNative.GetOrdinaryFileIdentity(file);
+ if (WindowsFileSystemNative.GetLinkCount(file) != 1) { throw WindowsInstallerDirectoryLedger.Failure("file_links_invalid"); }
+ WindowsInstallerDirectoryLedgerSecurity.Validate(WindowsDirectoryReadLease.ReadSecuritySnapshot(file));
+ }
+
+ private static void TryDeleteTemporary(string path, WindowsFileIdentity identity)
+ {
+ try
+ {
+ using SafeFileHandle? temporary = OpenIfPresent(path, deletion: true);
+ if (temporary is not null && WindowsFileSystemNative.GetOrdinaryFileIdentity(temporary) == identity)
+ {
+ DeleteHandle(temporary);
+ }
+ }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception or InstallerProtocolException)
+ {
+ // Never adopt or delete a temporary from a different call, or one whose identity changed.
+ }
+ }
+
+ internal static void DeleteHandle(SafeFileHandle handle)
+ {
+ var disposition = new FileDisposition { Delete = 1 };
+ if (!SetFileInformationByHandle(handle, 4, in disposition, 1)) { throw new Win32Exception(Marshal.GetLastPInvokeError()); }
+ }
+
+ private static void ValidatePath(string path)
+ {
+ if (!Path.IsPathFullyQualified(path) || Path.GetFileName(path) != WindowsInstallerDirectoryCleanupLayout.LedgerFileName
+ || !string.Equals(path, Path.GetFullPath(path), StringComparison.OrdinalIgnoreCase))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("path_invalid");
+ }
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct FileDisposition { internal byte Delete; }
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template);
+
+ [DllImport("kernel32.dll", EntryPoint = "MoveFileExW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool MoveFileEx(string oldPath, string newPath, uint flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool SetFileInformationByHandle(SafeFileHandle file, int informationClass, in FileDisposition disposition, uint size);
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs
index 88a15b21..859e1f21 100644
--- a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerDirectoryNative.cs
@@ -9,6 +9,8 @@ public void CreateDirectory(string path, DirectorySecurity security)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
ArgumentNullException.ThrowIfNull(security);
+ // Enable ownership recording together with terminal recovery and finalization; recording
+ // alone leaves durable state that the production uninstall path cannot yet consume.
new DirectoryInfo(path).Create(security);
}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs
new file mode 100644
index 00000000..11c6eb8c
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerEmptyDirectoryFinalizer.cs
@@ -0,0 +1,256 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Transactions;
+using ClashSharp.Installer.Windows.Files;
+using ClashSharp.Windows.FileSecurity;
+using Microsoft.Win32.SafeHandles;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+internal interface IWindowsInstallerDirectoryDeletionLease : IDisposable
+{
+ WindowsFileIdentity Identity { get; }
+ bool IsEmpty { get; }
+ void VerifyOwnedSecurity();
+ void DeleteEmpty();
+}
+
+internal interface IWindowsInstallerEmptyDirectoryNative
+{
+ IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken);
+}
+
+/// Runs after Clear reconciliation, before a success frame, under both session-wide leases.
+internal sealed class WindowsInstallerEmptyDirectoryFinalizer
+{
+ private readonly WindowsInstallerDirectoryCleanupLayout _layout;
+ private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger;
+ private readonly IWindowsInstallerEmptyDirectoryNative _native;
+
+ internal WindowsInstallerEmptyDirectoryFinalizer(WindowsInstallerDirectoryCleanupLayout layout,
+ IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerEmptyDirectoryNative native)
+ {
+ ArgumentNullException.ThrowIfNull(layout);
+ ArgumentNullException.ThrowIfNull(ledger);
+ ArgumentNullException.ThrowIfNull(native);
+ _layout = layout;
+ _ledger = ledger;
+ _native = native;
+ }
+
+ internal static WindowsInstallerEmptyDirectoryFinalizer CreateDefault() => new(
+ WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(),
+ WindowsInstallerEmptyDirectoryNative.Instance);
+
+ internal async Task CompleteAsync(InstallerTransactionSnapshot verified,
+ Func releaseResources, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(verified);
+ ArgumentNullException.ThrowIfNull(releaseResources);
+ verified.Validate();
+ if (verified.Journal.Operation != InstallerOperation.Uninstall || verified.Journal.Phase != InstallerTransactionPhase.Verified)
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("terminal_not_verified_uninstall");
+ }
+ WindowsInstallerDirectoryLedger? before = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false);
+ WindowsInstallerDirectoryLedger prepared = (before ?? WindowsInstallerDirectoryLedger.Empty).BeginTerminal(verified);
+ if (before?.Terminal is null)
+ {
+ // A committed Clear replay may have no ledger, or only newly recreated directory IDs.
+ // Its authoritative native verification already succeeded; arm the same checkpoint again.
+ await _ledger.SaveAsync(before, prepared, cancellationToken).ConfigureAwait(false);
+ }
+ WindowsInstallerDirectoryLedger current = await _ledger.LoadAsync(cancellationToken).ConfigureAwait(false)
+ ?? throw WindowsInstallerDirectoryLedger.Failure("terminal_missing");
+ RequireExact(prepared, current);
+
+ // This releases machine/user certificate persistence and transaction guards. It does not
+ // release exclusive installer authority or the application lease owned by the outer host.
+ await releaseResources().ConfigureAwait(false);
+ var entries = new List(6);
+ foreach (InstallerDirectoryRole role in WindowsInstallerDirectoryCleanupLayout.DeletionOrder)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ WindowsInstallerOwnedDirectory? owned = current.Directories.SingleOrDefault(entry => entry.Role == role);
+ InstallerDirectoryCleanupDisposition disposition = RemoveOne(_layout.GetPath(role), owned, cancellationToken);
+ entries.Add(new(role, disposition));
+ }
+ var report = new InstallerDirectoryCleanupReport(entries.OrderBy(entry => entry.Role));
+ report.Validate();
+ // This is the final durable mutation. Any preceding cancellation, exception or process exit
+ // retains the verified checkpoint outside the directories being removed.
+ await _ledger.DeleteAsync(current, cancellationToken).ConfigureAwait(false);
+ return report;
+ }
+
+ private InstallerDirectoryCleanupDisposition RemoveOne(string path, WindowsInstallerOwnedDirectory? owned,
+ CancellationToken cancellationToken)
+ {
+ Exception? deletionFailure = null;
+ using (IWindowsInstallerDirectoryDeletionLease? directory = _native.Open(path, cancellationToken))
+ {
+ if (directory is null) { return InstallerDirectoryCleanupDisposition.Missing; }
+ if (owned is null || owned.Identity != directory.Identity)
+ {
+ return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership;
+ }
+ directory.VerifyOwnedSecurity();
+ if (!directory.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; }
+ cancellationToken.ThrowIfCancellationRequested();
+ try { directory.DeleteEmpty(); }
+ catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or Win32Exception)
+ {
+ deletionFailure = exception;
+ }
+ }
+ using IWindowsInstallerDirectoryDeletionLease? after = _native.Open(path, cancellationToken);
+ if (after is null) { return InstallerDirectoryCleanupDisposition.Deleted; }
+ if (owned.Identity != after.Identity) { return InstallerDirectoryCleanupDisposition.RetainedUnprovenOwnership; }
+ after.VerifyOwnedSecurity();
+ if (!after.IsEmpty) { return InstallerDirectoryCleanupDisposition.RetainedNonEmpty; }
+ if (deletionFailure is not null)
+ {
+ throw new InstallerProtocolException("installer.directory_ledger.directory_delete_failed", deletionFailure);
+ }
+ throw new InstallerStateUncertainException("installer.directory_ledger.directory_delete_not_observed");
+ }
+
+ private static void RequireExact(WindowsInstallerDirectoryLedger expected, WindowsInstallerDirectoryLedger actual)
+ {
+ if (!WindowsInstallerDirectoryLedgerCodec.Serialize(expected).AsSpan()
+ .SequenceEqual(WindowsInstallerDirectoryLedgerCodec.Serialize(actual)))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("terminal_changed");
+ }
+ }
+}
+
+internal sealed class WindowsInstallerEmptyDirectoryNative : IWindowsInstallerEmptyDirectoryNative
+{
+ internal static WindowsInstallerEmptyDirectoryNative Instance { get; } = new();
+
+ public IWindowsInstallerDirectoryDeletionLease? Open(string path, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ WindowsInstallerDirectoryAnchor? anchor = null;
+ SafeFileHandle? handle = null;
+ try
+ {
+ anchor = WindowsInstallerDirectoryAnchor.Acquire(Path.GetDirectoryName(path)
+ ?? throw WindowsInstallerDirectoryLedger.Failure("directory_path_invalid"));
+ // Read-only observation first. DELETE access is acquired only after ownership is proven.
+ handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ handle.Dispose();
+ handle = null;
+ throw new Win32Exception(error);
+ }
+ WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle);
+ return new Lease(path, handle, anchor, identity);
+ }
+ catch (Win32Exception exception) when (exception.NativeErrorCode is 2 or 3)
+ {
+ handle?.Dispose();
+ anchor?.Dispose();
+ return null;
+ }
+ catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException)
+ {
+ handle?.Dispose();
+ anchor?.Dispose();
+ return null;
+ }
+ catch { handle?.Dispose(); anchor?.Dispose(); throw; }
+ }
+
+ private sealed class Lease(string path, SafeFileHandle handle, WindowsInstallerDirectoryAnchor anchor,
+ WindowsFileIdentity identity) : IWindowsInstallerDirectoryDeletionLease
+ {
+ private SafeFileHandle? _handle = handle;
+ public WindowsFileIdentity Identity => identity;
+
+ public bool IsEmpty
+ {
+ get
+ {
+ ObjectDisposedException.ThrowIf(_handle is null, this);
+ using IEnumerator entries = Directory.EnumerateFileSystemEntries(path).GetEnumerator();
+ return !entries.MoveNext() && !HasNamedDataStream(path);
+ }
+ }
+
+ public void VerifyOwnedSecurity()
+ {
+ ObjectDisposedException.ThrowIf(_handle is null, this);
+ if (WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(_handle) != identity
+ || !WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(_handle)))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("owned_directory_unsafe");
+ }
+ }
+
+ public void DeleteEmpty()
+ {
+ VerifyOwnedSecurity();
+ if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); }
+ // Reopen DELETE while the parent chain remains pinned. The current read handle must be
+ // released because its list access intentionally withholds delete sharing.
+ _handle!.Dispose();
+ _handle = null;
+ SafeFileHandle deleting = CreateFile(path, 0x80010000U, 3, 0, 3, 0x02200000U, 0);
+ if (deleting.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ deleting.Dispose();
+ throw new Win32Exception(error);
+ }
+ _handle = deleting;
+ VerifyOwnedSecurity();
+ if (!IsEmpty) { throw new IOException("The owned directory is no longer empty."); }
+ WindowsInstallerDirectoryLedgerFileNative.DeleteHandle(deleting);
+ }
+
+ public void Dispose()
+ {
+ _handle?.Dispose();
+ _handle = null;
+ anchor.Dispose();
+ }
+ }
+
+ private static bool HasNamedDataStream(string path)
+ {
+ nint search = FindFirstStream(path, 0, out _, 0);
+ if (search != new nint(-1))
+ {
+ _ = FindClose(search);
+ return true;
+ }
+ int error = Marshal.GetLastPInvokeError();
+ if (error == 38) { return false; }
+ throw new Win32Exception(error);
+ }
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct StreamData
+ {
+ internal long StreamSize;
+ [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 296)] internal string StreamName;
+ }
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template);
+
+ [DllImport("kernel32.dll", EntryPoint = "FindFirstStreamW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern nint FindFirstStream(string name, int informationLevel, out StreamData data, uint flags);
+
+ [DllImport("kernel32.dll", SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool FindClose(nint search);
+}
diff --git a/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs
new file mode 100644
index 00000000..51f3d5b8
--- /dev/null
+++ b/ClashSharp/ClashSharp.Installer.Windows/Transactions/WindowsInstallerOwnedDirectoryCreation.cs
@@ -0,0 +1,128 @@
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using ClashSharp.Installer.Contracts;
+using ClashSharp.Installer.Windows.Files;
+using ClashSharp.Windows.FileSecurity;
+using Microsoft.Win32.SafeHandles;
+
+namespace ClashSharp.Installer.Windows.Transactions;
+
+internal interface IWindowsInstallerOwnedDirectoryCreationNative
+{
+ bool CreateDirectory(string path, DirectorySecurity security);
+ IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path);
+}
+
+internal interface IWindowsInstallerCreatedDirectoryLease : IDisposable
+{
+ WindowsFileIdentity Identity { get; }
+}
+
+/// Never adopts an existing directory, including an unrecorded creation from an interrupted call.
+internal sealed class WindowsInstallerOwnedDirectoryCreation
+{
+ private readonly WindowsInstallerDirectoryCleanupLayout _layout;
+ private readonly IWindowsInstallerDirectoryLedgerPersistence _ledger;
+ private readonly IWindowsInstallerOwnedDirectoryCreationNative _native;
+
+ internal WindowsInstallerOwnedDirectoryCreation(WindowsInstallerDirectoryCleanupLayout layout,
+ IWindowsInstallerDirectoryLedgerPersistence ledger, IWindowsInstallerOwnedDirectoryCreationNative native)
+ {
+ _layout = layout;
+ _ledger = ledger;
+ _native = native;
+ }
+
+ internal static WindowsInstallerOwnedDirectoryCreation CreateDefault() => new(
+ WindowsInstallerDirectoryCleanupLayout.CreateDefault(), WindowsInstallerDirectoryLedgerPersistence.CreateDefault(),
+ WindowsInstallerOwnedDirectoryCreationNative.Instance);
+
+ internal void Create(string path, DirectorySecurity security)
+ {
+ ArgumentNullException.ThrowIfNull(security);
+ if (!_layout.TryGetRole(path, out InstallerDirectoryRole role))
+ {
+ // Existing Service/MihomoService and isolated testing roots retain their original behavior.
+ _ = _native.CreateDirectory(path, security);
+ return;
+ }
+ // Reject foreign ledger state before creating anything. This executes only in the elevated
+ // helper's synchronous native directory operation, while it owns exclusive installer authority.
+ WindowsInstallerDirectoryLedger? before = _ledger.LoadAsync(CancellationToken.None).GetAwaiter().GetResult();
+ if (!_native.CreateDirectory(path, security)) { return; }
+ using IWindowsInstallerCreatedDirectoryLease directory = _native.OpenCreatedDirectory(path);
+ WindowsInstallerDirectoryLedger desired = (before ?? WindowsInstallerDirectoryLedger.Empty)
+ .RecordCreated(role, directory.Identity);
+ _ledger.SaveAsync(before, desired, CancellationToken.None).GetAwaiter().GetResult();
+ }
+}
+
+internal sealed class WindowsInstallerOwnedDirectoryCreationNative : IWindowsInstallerOwnedDirectoryCreationNative
+{
+ internal static WindowsInstallerOwnedDirectoryCreationNative Instance { get; } = new();
+
+ public bool CreateDirectory(string path, DirectorySecurity security)
+ {
+ byte[] descriptor = security.GetSecurityDescriptorBinaryForm();
+ GCHandle pinned = GCHandle.Alloc(descriptor, GCHandleType.Pinned);
+ try
+ {
+ var attributes = new SecurityAttributes
+ {
+ Length = Marshal.SizeOf(),
+ Descriptor = pinned.AddrOfPinnedObject(),
+ InheritHandle = 0,
+ };
+ if (CreateDirectoryNative(path, ref attributes)) { return true; }
+ int error = Marshal.GetLastPInvokeError();
+ if (error == 183) { return false; }
+ throw new Win32Exception(error);
+ }
+ finally { pinned.Free(); }
+ }
+
+ public IWindowsInstallerCreatedDirectoryLease OpenCreatedDirectory(string path)
+ {
+ SafeFileHandle handle = CreateFile(path, 0x80000000U, 3, 0, 3, 0x02200000U, 0);
+ if (handle.IsInvalid)
+ {
+ int error = Marshal.GetLastPInvokeError();
+ handle.Dispose();
+ throw new Win32Exception(error);
+ }
+ try
+ {
+ WindowsFileIdentity identity = WindowsFileSystemNative.GetOrdinaryDirectoryIdentity(handle);
+ if (!WindowsDirectoryAccessPolicy.IsTrustedRenameAnchor(WindowsDirectoryReadLease.ReadSecuritySnapshot(handle)))
+ {
+ throw WindowsInstallerDirectoryLedger.Failure("created_directory_unsafe");
+ }
+ return new Lease(handle, identity);
+ }
+ catch { handle.Dispose(); throw; }
+ }
+
+ private sealed class Lease(SafeFileHandle handle, WindowsFileIdentity identity) : IWindowsInstallerCreatedDirectoryLease
+ {
+ public WindowsFileIdentity Identity => identity;
+ public void Dispose() => handle.Dispose();
+ }
+
+ [StructLayout(LayoutKind.Sequential)]
+ private struct SecurityAttributes
+ {
+ internal int Length;
+ internal nint Descriptor;
+ internal int InheritHandle;
+ }
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateDirectoryW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CreateDirectoryNative(string path, ref SecurityAttributes attributes);
+
+ [DllImport("kernel32.dll", EntryPoint = "CreateFileW", CharSet = CharSet.Unicode, SetLastError = true)]
+ [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
+ private static extern SafeFileHandle CreateFile(string name, uint access, uint share, nint security, uint creation, uint flags, nint template);
+}
diff --git a/ClashSharp/ClashSharp.Installer/MainWindow.xaml b/ClashSharp/ClashSharp.Installer/MainWindow.xaml
index fee5645d..a9113345 100644
--- a/ClashSharp/ClashSharp.Installer/MainWindow.xaml
+++ b/ClashSharp/ClashSharp.Installer/MainWindow.xaml
@@ -1,13 +1,13 @@
@@ -25,7 +25,7 @@
-