Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2775afe
feat: add generation-owned settings migration and application lifecycle
Water-Run Sep 8, 2026
a08fc4b
feat: retain generation ownership across complete settings commands
Water-Run Sep 8, 2026
37f4c51
feat: verify startup and sampling settings against runtime state
Water-Run Sep 8, 2026
e9026f8
refactor: separate controller credentials from preference authority
Water-Run Sep 8, 2026
f5a4502
test: require completed startup in packaged Windows smoke
Water-Run Sep 8, 2026
3dbbb4e
docs: complete Sandbox helper help and record native acceptance
Water-Run Sep 8, 2026
9a331ba
feat: apply trigger settings through the owned scheduler
Water-Run Sep 8, 2026
04ded00
feat: expose installed internal settings through a read-only contract
Water-Run Sep 8, 2026
a4f6368
fix: verify applied WinUI accent resources before reporting success
Water-Run Sep 8, 2026
ff0e065
docs: record accent CI and packaged startup acceptance
Water-Run Sep 8, 2026
ea940a1
feat: apply appearance settings through owned UI operations
Water-Run Sep 8, 2026
cb378e5
fix(installer): support server desktops and keep cancellation responsive
Water-Run Sep 12, 2026
10dc4e4
fix(core): validate default startup and retry transient config promotion
Water-Run Sep 12, 2026
4640685
feat(settings): observe network ownership and drain repository operat…
Water-Run Sep 12, 2026
f3b668d
test: locate installer source contracts in Git worktrees
Water-Run Sep 12, 2026
e30c470
docs: record full server startup and installer acceptance
Water-Run Sep 12, 2026
45d2a28
Merge verified main installer fixes into settings development
Water-Run Sep 12, 2026
c5a488f
fix(installer): release transaction observation leases after each read
Water-Run Sep 12, 2026
55e753e
feat(installer): report verified uninstall directory outcomes before …
Water-Run Sep 12, 2026
3b95e93
wip(installer): preserve unfinished directory cleanup at pause
Water-Run Sep 12, 2026
8b520f2
Merge 1.0.0 pause checkpoint into settings development
Water-Run Sep 12, 2026
0508a35
feat(installer): align installer branding with the green core logo
Water-Run Sep 12, 2026
1db8ab9
fix(installer): name the maintenance window ClashSharp manager
Water-Run Sep 22, 2026
655f6f6
Merge remote-tracking branch 'origin/main' into feat/settings-generation
Water-Run Sep 22, 2026
2776a14
Merge branch 'fix/installer-empty-cleanup' into feat/settings-generation
Water-Run Sep 22, 2026
83f3058
fix: validate consolidated development and repair Persian build checks
Water-Run Sep 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
namespace ClashSharp.ApplicationModel.Data;

public sealed partial class DataGenerationManager
{
/// <summary>Resolves a generation-owned service and pins its complete asynchronous operation before transition can retire it.</summary>
/// <typeparam name="TService">Service provided by the scope's owned lifetime through <see cref="IServiceProvider"/>.</typeparam>
/// <typeparam name="TResult">Immutable operation result that can outlive the scope.</typeparam>
/// <param name="operation">Owned operation; it must await all work and must not return a service or live repository handle.</param>
/// <param name="cancellationToken">Cancels acquisition and is passed to the owned operation without abandoning its task.</param>
public async Task<TResult> ExecuteAsync<TService, TResult>(
Func<TService, DataGenerationDescriptor, CancellationToken, Task<TResult>> operation,
CancellationToken cancellationToken) where TService : class
{
ArgumentNullException.ThrowIfNull(operation);
await using DataGenerationLease lease = await AcquireAsync(cancellationToken).ConfigureAwait(false);
TService service = lease.Scope.GetOwnedService<TService>();
return await operation(service, lease.Descriptor, cancellationToken).ConfigureAwait(false);
}

/// <summary>Captures an immutable in-memory projection under a short synchronous generation pin without blocking on asynchronous work.</summary>
/// <typeparam name="TService">Service provided by this generation's owned lifetime.</typeparam>
/// <typeparam name="TResult">Immutable projection that can outlive the scope.</typeparam>
/// <param name="capture">Pure synchronous reader; it must not perform I/O, start tasks, or return a live service or repository.</param>
public TResult ReadSnapshot<TService, TResult>(Func<TService, DataGenerationDescriptor, TResult> capture)
where TService : class
{
ArgumentNullException.ThrowIfNull(capture);
using DataGenerationLease lease = AcquireCore(CancellationToken.None);
return capture(lease.Scope.GetOwnedService<TService>(), lease.Descriptor);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ public void Initialize(
/// <param name="cancellationToken">Cancels acquisition before a lease is granted.</param>
/// <returns>A lease that must cover the complete repository operation.</returns>
public ValueTask<DataGenerationLease> AcquireAsync(CancellationToken cancellationToken)
=> ValueTask.FromResult(AcquireCore(cancellationToken));

private DataGenerationLease AcquireCore(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
lock (_syncLock)
Expand All @@ -93,7 +96,7 @@ public ValueTask<DataGenerationLease> AcquireAsync(CancellationToken cancellatio
_leaseCount++;
}

return ValueTask.FromResult(new DataGenerationLease(this, _currentScope));
return new DataGenerationLease(this, _currentScope);
}
}

Expand Down
13 changes: 12 additions & 1 deletion ClashSharp/ClashSharp.Application/Data/DataGenerationScope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ public sealed class DataGenerationScope : IAsyncDisposable

/// <summary>Initializes a paused scope without starting work or touching the filesystem.</summary>
/// <param name="descriptor">Immutable generation descriptor.</param>
/// <param name="ownedLifetime">Optional composite repository lifetime transferred to this scope.</param>
/// <param name="ownedLifetime">Optional composite repository lifetime transferred to this scope; scoped service access also requires it to implement <see cref="IServiceProvider"/>.</param>
public DataGenerationScope(
DataGenerationDescriptor descriptor,
IAsyncDisposable? ownedLifetime = null)
Expand All @@ -37,6 +37,17 @@ public DataGenerationScopeState State
}
}

internal TService GetOwnedService<TService>() 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;
}

/// <summary>Disposes an unclaimed staged scope; claimed scopes remain owner-controlled.</summary>
public ValueTask DisposeAsync()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
namespace ClashSharp.ApplicationModel.Data;

/// <summary>Retires a repository only after every accepted operation has completed its full storage work.</summary>
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;

/// <summary>Creates a lifetime without accessing storage or accepting any operations.</summary>
/// <param name="repository">Repository identified by a rejection after retirement.</param>
public RepositoryOperationLifetime(object repository)
{
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
}

/// <summary>Accepts one operation whose lease must span all asynchronous work and compensation.</summary>
/// <returns>An idempotent lease released when the complete operation leaves the repository.</returns>
public IDisposable Enter()
{
lock (_gate)
{
ObjectDisposedException.ThrowIf(_retired, _repository);
_operations++;
return new Operation(this);
}
}

/// <summary>Rejects new operations immediately and asynchronously waits for accepted operations to drain.</summary>
/// <returns>The shared completion of repository retirement.</returns>
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();
}
}
125 changes: 125 additions & 0 deletions ClashSharp/ClashSharp.Application/Presentation/OwnedUiDispatcher.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
using ClashSharp.ApplicationModel.Diagnostics;

namespace ClashSharp.ApplicationModel.Presentation;

/// <summary>Owns queued synchronous UI operations until they finish or are revoked before execution.</summary>
/// <remarks>The containing scope retires this owner before releasing its window and generation.</remarks>
public sealed class OwnedUiDispatcher : IAsyncDisposable
{
private readonly object _gate = new();
private readonly Func<bool> _hasThreadAccess;
private readonly Func<Action, bool> _tryEnqueue;
private readonly HashSet<Operation> _operations = [];
private readonly CancellationTokenRegistration _windowLifetime;
private bool _closed;
private Task? _retirement;

/// <summary>Creates a dispatcher boundary without accessing the platform or scheduling work.</summary>
/// <param name="hasThreadAccess">Reports access to the owning UI thread.</param>
/// <param name="tryEnqueue">Queues one callback; rejection never permits a fallback on another thread.</param>
/// <param name="windowLifetime">Revoked when the window can no longer execute queued work.</param>
public OwnedUiDispatcher(Func<bool> hasThreadAccess, Func<Action, bool> 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);
}

/// <summary>Waits for the actual callback, including after cancellation or a lost enqueue reply.</summary>
/// <typeparam name="T">The immutable result captured on the UI thread.</typeparam>
/// <param name="action">Synchronous work that must not launch unowned asynchronous operations.</param>
/// <param name="cancellationToken">Cancels before the callback starts effects.</param>
/// <returns>The callback's result or original failure.</returns>
public Task<T> InvokeAsync<T>(Func<T> action, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(action);
cancellationToken.ThrowIfCancellationRequested();
QueuedOperation<T> 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;
}

/// <summary>Rejects new and unstarted work, then drains every callback that has begun execution.</summary>
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<T>(OwnedUiDispatcher owner, Func<T> action, CancellationToken cancellationToken) : Operation
{
private readonly TaskCompletionSource<T> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private int _state;
public Task<T> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
namespace ClashSharp.ApplicationModel.Security;

/// <summary>Reports an unavailable credential using a stable code without carrying private storage values.</summary>
public sealed class ControllerCredentialException : InvalidOperationException
{
/// <summary>Creates a value-free credential failure.</summary>
/// <param name="code">Stable diagnostic code.</param>
public ControllerCredentialException(string code) : base(code)
{
ArgumentException.ThrowIfNullOrWhiteSpace(code);
Code = code;
}

/// <summary>Gets the stable failure code.</summary>
public string Code { get; }
}
Loading