Public Access
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
858e36279d | ||
|
|
55e5de0ce4 | ||
|
|
8cb1457ca9 | ||
|
|
656a33b4b1 | ||
|
|
41f368ae75 | ||
|
|
24c18205c3 | ||
|
|
8759b12d47 | ||
|
|
7b072bcded | ||
|
|
b1d4a270d4 | ||
|
|
a2362d1e3e | ||
|
|
079a273d46 | ||
|
|
37e9d74938 | ||
|
|
170caa1391 | ||
|
|
965c9a7043 | ||
|
|
ff8a790f6c | ||
|
|
b2516ca7fb | ||
|
|
36fb0817a4 | ||
|
|
cc3d46f78c | ||
|
|
8bee476039 | ||
|
|
441709f1e9 | ||
|
|
873405e591 | ||
|
|
f997e87c5f | ||
|
|
365954c541 | ||
|
|
3ccafdd53b | ||
|
|
22b4d82ba8 | ||
|
|
619f49a35a | ||
|
|
a6d3924e0d | ||
|
|
079914bfb4 | ||
|
|
6c09bdecb6 | ||
|
|
372a7cbdc7 | ||
|
|
a4b81da597 | ||
|
|
0e8fe4fc80 | ||
|
|
2ff9dff560 | ||
|
|
6846f67c20 | ||
|
|
f332d9a56f | ||
|
|
03c8e8589a | ||
|
|
4c7f47c026 | ||
|
|
412c314aec |
No files matched your search
@@ -68,7 +68,15 @@ the Npgsql connection string** — the default; do not enable multiplexing.
|
|||||||
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
visible conflicted copy for opaque ones. **It must never silently drop a key or a host.**
|
||||||
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
|
- Deletes are revisioned tombstones, garbage-collected after 90 days. Sync must therefore be
|
||||||
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
|
able to read tombstones, which is the one place that legitimately bypasses the soft-delete
|
||||||
query filter — guarded by an explicit permission check.
|
query filter — guarded by an explicit permission check. **The collection has since been built**
|
||||||
|
(`TombstoneCollector`), and it costs a machine away for longer than the retention its news of
|
||||||
|
those deletions. So a collection raises the vault's sync horizon, and a cursor from before it is
|
||||||
|
refused. The client then reads from the beginning, and when that read finishes it removes the
|
||||||
|
items it held that the read never mentioned. An item with an unsent local edit is kept: the
|
||||||
|
push finds the server without it, puts it back, and records a `RecreatedAfterCollection`
|
||||||
|
conflict. A cursor carries the horizon it was issued under. Without that, the read from the
|
||||||
|
beginning would be refused on its own early pages, which sit behind the horizon whenever an
|
||||||
|
older item survives.
|
||||||
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
- **An `Infrastructure.Tests` case must prove cursor ordering under N concurrent pushes.**
|
||||||
Without it this ADR's central bug is invisible until production.
|
Without it this ADR's central bug is invisible until production.
|
||||||
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
- Two concurrency mechanisms, deliberately: `version integer` is the client-visible monotonic
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using DodoSSH.Domain;
|
||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Authorization;
|
||||||
|
|
||||||
|
/// <summary>Requires the caller's account to be <see cref="UserStatus.Active"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="UserStatus.Suspended"/> is documented as "sign-in blocked", and nothing blocked it: the identity
|
||||||
|
/// provider still issues tokens for the person, every endpoint resolved them through the current-user context,
|
||||||
|
/// and that context looked an account up by issuer, subject and deletion alone. A suspended account could pull
|
||||||
|
/// and push vaults, take relay tickets and hold the events socket open. This is the check, on both policies, so
|
||||||
|
/// that no endpoint can be reached without it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A connection already open when the account is suspended — a relay tunnel, the events socket — is not
|
||||||
|
/// reached by this; it ends on its own idle and lifetime limits.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ActiveAccountRequirement : IAuthorizationRequirement;
|
||||||
|
|
||||||
|
/// <summary>Satisfies <see cref="ActiveAccountRequirement"/> from the account row.</summary>
|
||||||
|
internal sealed class ActiveAccountHandler(ICurrentUserContext currentUser, IHttpContextAccessor accessor)
|
||||||
|
: AuthorizationHandler<ActiveAccountRequirement>
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task HandleRequirementAsync(
|
||||||
|
AuthorizationHandlerContext context,
|
||||||
|
ActiveAccountRequirement requirement)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
|
|
||||||
|
// The policy's authentication requirement already refuses an anonymous caller with a 401; failing this
|
||||||
|
// as well would turn that into a 403.
|
||||||
|
if (context.User.Identity?.IsAuthenticated != true)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cancellationToken = accessor.HttpContext?.RequestAborted ?? CancellationToken.None;
|
||||||
|
|
||||||
|
var user = await currentUser.GetOrProvisionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (user.Status == UserStatus.Active)
|
||||||
|
{
|
||||||
|
context.Succeed(requirement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,8 +102,14 @@ internal sealed class CurrentUserContext(
|
|||||||
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
/// read-then-write: <c>user_account</c> carries the xmin concurrency token, so two overlapping
|
||||||
/// requests from one user would each read the row, each set the timestamp, and the second would
|
/// requests from one user would each read the row, each set the timestamp, and the second would
|
||||||
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
/// fail on a version that had moved under it. This writes at most one row and cannot conflict.
|
||||||
/// The tracked entity is deliberately left alone — a value up to an hour stale in memory changes
|
/// <para>
|
||||||
/// nothing, and marking it modified would enlist the user row in whatever the request saves next.
|
/// ◆ <b>But the write moves xmin under the tracked entity, so the entity is re-read after it.</b> It is
|
||||||
|
/// not marked modified — that would enlist the user row in whatever the request saves next — and a
|
||||||
|
/// stale timestamp in memory would change nothing on its own. A stale concurrency token does: the next
|
||||||
|
/// save in the same request that touches this user (enrolment stamps <c>EnrolledAtUtc</c>) compared
|
||||||
|
/// against the version from before the update and failed, so enrolling more than an hour after first
|
||||||
|
/// signing in answered 500. Only when a row was actually written, which is once an hour at most.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
private async Task RefreshLastSeenAsync(UserAccount user, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -114,13 +120,18 @@ internal sealed class CurrentUserContext(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.Users
|
var written = await database.Users
|
||||||
.Where(u => u.Id == user.Id
|
.Where(u => u.Id == user.Id
|
||||||
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
&& (u.LastSeenAtUtc == null || u.LastSeenAtUtc < now - LastSeenWindow))
|
||||||
.ExecuteUpdateAsync(
|
.ExecuteUpdateAsync(
|
||||||
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
setters => setters.SetProperty(u => u.LastSeenAtUtc, now),
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (written > 0)
|
||||||
|
{
|
||||||
|
await database.Entry(user).ReloadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
private Task<UserAccount?> FindAsync(string issuer, string subject, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
@@ -80,6 +80,26 @@ internal sealed class DodoAuthorizationResultHandler : IAuthorizationMiddlewareR
|
|||||||
ArgumentNullException.ThrowIfNull(context);
|
ArgumentNullException.ThrowIfNull(context);
|
||||||
ArgumentNullException.ThrowIfNull(authorizeResult);
|
ArgumentNullException.ThrowIfNull(authorizeResult);
|
||||||
|
|
||||||
|
var accountNotActive = authorizeResult.Forbidden
|
||||||
|
&& authorizeResult.AuthorizationFailure is { } refusal
|
||||||
|
&& refusal.FailedRequirements.OfType<ActiveAccountRequirement>().Any();
|
||||||
|
|
||||||
|
if (accountNotActive)
|
||||||
|
{
|
||||||
|
await TypedResults.Problem(
|
||||||
|
detail: "This account has been suspended. Ask whoever runs this DodoSSH server.",
|
||||||
|
statusCode: StatusCodes.Status403Forbidden,
|
||||||
|
type: ProblemCodes.TypeBaseUri + ProblemCodes.AccountNotActive,
|
||||||
|
extensions: new Dictionary<string, object?>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["code"] = ProblemCodes.AccountNotActive,
|
||||||
|
})
|
||||||
|
.ExecuteAsync(context)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var enrollmentMissing = authorizeResult.Forbidden
|
var enrollmentMissing = authorizeResult.Forbidden
|
||||||
&& authorizeResult.AuthorizationFailure is { } failure
|
&& authorizeResult.AuthorizationFailure is { } failure
|
||||||
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
&& failure.FailedRequirements.OfType<EnrolledRequirement>().Any();
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Events;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
|
||||||
|
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
|
||||||
|
/// touch any of the places a removal happens.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void VaultAccessChanged(Guid userId)
|
||||||
|
{
|
||||||
|
hub.VaultAccessChanged(userId);
|
||||||
|
_ = relay.AccessChangedAsync(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Crypto;
|
using DodoSSH.Crypto;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
|
|||||||
byte[] fingerprint,
|
byte[] fingerprint,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([user], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using DodoSSH.Api.Authorization;
|
||||||
|
using DodoSSH.Domain.Authorization;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Relay;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
|
||||||
|
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
|
||||||
|
/// their keys and their vault list away at once, and left any shell they had open through the relay running
|
||||||
|
/// into the team's machines until it went idle.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Told by the same announcement that tells their event socket to re-read its vaults — see
|
||||||
|
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
|
||||||
|
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
|
||||||
|
/// has already committed and answered, and a slow re-check must not hold it.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class RelayAccessWatch(
|
||||||
|
RelaySessionRegistry sessions,
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
ILogger<RelayAccessWatch> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Re-checks every session the user has open, in the background.</summary>
|
||||||
|
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
|
||||||
|
internal Task AccessChangedAsync(Guid userId)
|
||||||
|
{
|
||||||
|
var open = sessions.SessionsOf(userId);
|
||||||
|
|
||||||
|
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
|
||||||
|
|
||||||
|
foreach (var lease in open)
|
||||||
|
{
|
||||||
|
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
|
||||||
|
{
|
||||||
|
lease.Revoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
RelayLog.AccessRecheckFailed(logger, userId, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var lease = sessions.TryAdmit(ticket.UserId);
|
var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
|
await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||||
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
private async Task RelayAsync(
|
||||||
|
WebSocket socket,
|
||||||
|
RelayTicket ticket,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
var started = clock.GetUtcNow();
|
var started = clock.GetUtcNow();
|
||||||
|
|
||||||
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
{
|
{
|
||||||
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
RelayLog.SessionOpened(logger, ticket.SessionId, ticket.UserId, ticket.HostId, sessions.Count);
|
||||||
|
|
||||||
ending = await PumpAsync(socket, target, counters, started, requestAborted).ConfigureAwait(false);
|
ending = await PumpAsync(socket, target, counters, started, revoked, requestAborted).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
RelayLog.SessionClosed(
|
RelayLog.SessionClosed(
|
||||||
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
Stream target,
|
Stream target,
|
||||||
Counters counters,
|
Counters counters,
|
||||||
DateTimeOffset started,
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
CancellationToken requestAborted)
|
CancellationToken requestAborted)
|
||||||
{
|
{
|
||||||
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
|
||||||
|
|
||||||
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
var upstream = ClientToTargetAsync(socket, target, counters, pump.Token);
|
||||||
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
var downstream = TargetToClientAsync(target, socket, counters, pump.Token);
|
||||||
var minding = MindAsync(counters, started, pump.Token);
|
var minding = MindAsync(counters, started, revoked, pump.Token);
|
||||||
|
|
||||||
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
var first = await Task.WhenAny(upstream, downstream, minding).ConfigureAwait(false);
|
||||||
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
var ending = await first.ConfigureAwait(false) ?? new Ending(
|
||||||
@@ -512,7 +517,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
/// count; SSH's own keepalives do, which is how a client that wants an idle shell kept open says so.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
private async Task<Ending?> MindAsync(
|
||||||
|
Counters counters,
|
||||||
|
DateTimeOffset started,
|
||||||
|
CancellationToken revoked,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = options.Value;
|
||||||
var hardDeadline = started + settings.MaxSessionDuration;
|
var hardDeadline = started + settings.MaxSessionDuration;
|
||||||
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
drainDeadline = now + settings.DrainTimeout;
|
drainDeadline = now + settings.DrainTimeout;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (now >= hardDeadline)
|
|
||||||
{
|
|
||||||
return new Ending(
|
|
||||||
RelayCloseReason.DurationLimit,
|
|
||||||
RelayProtocol.DurationLimitCloseCode,
|
|
||||||
"This relay session reached its maximum lifetime.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drainDeadline is { } drain && now >= drain)
|
|
||||||
{
|
|
||||||
return ShuttingDown;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||||
|
|
||||||
if (now >= idleDeadline)
|
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
|
||||||
{
|
{
|
||||||
return new Ending(
|
return ending;
|
||||||
RelayCloseReason.IdleTimeout,
|
|
||||||
RelayProtocol.IdleTimeoutCloseCode,
|
|
||||||
"This relay session carried nothing for the idle timeout.");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||||
@@ -555,8 +548,8 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
// Woken early by the start of a shutdown, so the drain is measured from when it began rather
|
||||||
// than from whenever the idle clock next came round.
|
// than from whenever the idle clock next came round.
|
||||||
using var wake = drainDeadline is null
|
using var wake = drainDeadline is null
|
||||||
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
|
? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
|
||||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
}
|
}
|
||||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
// The shutdown began. Round again to start the drain.
|
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Revocation first, because it is the one reason here that is about the user rather than the session:
|
||||||
|
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
|
||||||
|
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
|
||||||
|
/// </remarks>
|
||||||
|
private static Ending? Due(
|
||||||
|
DateTimeOffset now,
|
||||||
|
bool revoked,
|
||||||
|
DateTimeOffset hardDeadline,
|
||||||
|
DateTimeOffset? drainDeadline,
|
||||||
|
DateTimeOffset idleDeadline)
|
||||||
|
{
|
||||||
|
if (revoked)
|
||||||
|
{
|
||||||
|
return AccessRevoked;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (now >= hardDeadline)
|
||||||
|
{
|
||||||
|
return new Ending(
|
||||||
|
RelayCloseReason.DurationLimit,
|
||||||
|
RelayProtocol.DurationLimitCloseCode,
|
||||||
|
"This relay session reached its maximum lifetime.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drainDeadline is { } drain && now >= drain)
|
||||||
|
{
|
||||||
|
return ShuttingDown;
|
||||||
|
}
|
||||||
|
|
||||||
|
return now >= idleDeadline
|
||||||
|
? new Ending(
|
||||||
|
RelayCloseReason.IdleTimeout,
|
||||||
|
RelayProtocol.IdleTimeoutCloseCode,
|
||||||
|
"This relay session carried nothing for the idle timeout.")
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Ending AccessRevoked { get; } = new(
|
||||||
|
RelayCloseReason.AccessRevoked,
|
||||||
|
RelayProtocol.AccessRevokedCloseCode,
|
||||||
|
"You no longer have access to this host.");
|
||||||
|
|
||||||
private static Ending ClientGone { get; } =
|
private static Ending ClientGone { get; } =
|
||||||
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
|
||||||
|
|
||||||
|
|||||||
@@ -91,4 +91,14 @@ internal static partial class RelayLog
|
|||||||
Level = LogLevel.Warning,
|
Level = LogLevel.Warning,
|
||||||
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
|
||||||
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
|
||||||
|
/// existed, and each still ends at its idle timeout or its lifetime.
|
||||||
|
/// </remarks>
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2309,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
|
||||||
|
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
{
|
{
|
||||||
private readonly Lock gate = new();
|
private readonly Lock gate = new();
|
||||||
private readonly Dictionary<Guid, int> perUser = [];
|
private readonly Dictionary<Guid, int> perUser = [];
|
||||||
|
private readonly HashSet<Lease> open = [];
|
||||||
private int total;
|
private int total;
|
||||||
|
|
||||||
/// <summary>How many sessions are open on this node.</summary>
|
/// <summary>How many sessions are open on this node.</summary>
|
||||||
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
|
|
||||||
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||||
/// <returns>A lease that gives the place back when disposed.</returns>
|
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||||
internal Lease? TryAdmit(Guid userId)
|
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
|
||||||
|
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
return [.. open.Where(lease => lease.UserId == userId)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal Lease? TryAdmit(Guid userId, Guid vaultId)
|
||||||
{
|
{
|
||||||
var settings = options.Value;
|
var settings = options.Value;
|
||||||
|
|
||||||
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
perUser[userId] = held + 1;
|
perUser[userId] = held + 1;
|
||||||
total++;
|
total++;
|
||||||
|
|
||||||
return new Lease(this, userId);
|
var lease = new Lease(this, userId, vaultId);
|
||||||
|
open.Add(lease);
|
||||||
|
|
||||||
|
return lease;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Release(Guid userId)
|
private void Release(Lease lease, Guid userId)
|
||||||
{
|
{
|
||||||
lock (gate)
|
lock (gate)
|
||||||
{
|
{
|
||||||
|
open.Remove(lease);
|
||||||
total--;
|
total--;
|
||||||
|
|
||||||
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
// Removed at zero rather than left behind, so the dictionary is the size of the users with a
|
||||||
@@ -95,16 +109,41 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly CancellationTokenSource revoked = new();
|
||||||
|
|
||||||
private int released;
|
private int released;
|
||||||
|
|
||||||
|
/// <summary>Whose session this is.</summary>
|
||||||
|
internal Guid UserId { get; } = userId;
|
||||||
|
|
||||||
|
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
|
||||||
|
internal Guid VaultId { get; } = vaultId;
|
||||||
|
|
||||||
|
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
|
||||||
|
internal CancellationToken Revoked => revoked.Token;
|
||||||
|
|
||||||
|
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
|
||||||
|
internal void Revoke()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
revoked.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
// Already over.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||||
{
|
{
|
||||||
registry.Release(userId);
|
registry.Release(this, UserId);
|
||||||
|
revoked.Dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,4 +27,16 @@ internal static partial class SyncLog
|
|||||||
int appliedCount,
|
int appliedCount,
|
||||||
int operationCount,
|
int operationCount,
|
||||||
Guid vaultId);
|
Guid vaultId);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2003,
|
||||||
|
Level = LogLevel.Information,
|
||||||
|
Message = "Collected {Count} expired tombstone(s) and superseded change-log entries.")]
|
||||||
|
internal static partial void Collected(ILogger logger, int count);
|
||||||
|
|
||||||
|
[LoggerMessage(
|
||||||
|
EventId = 2004,
|
||||||
|
Level = LogLevel.Warning,
|
||||||
|
Message = "Tombstone collection failed; it will run again at the next interval.")]
|
||||||
|
internal static partial void CollectionFailed(ILogger logger, Exception exception);
|
||||||
}
|
}
|
||||||
@@ -26,12 +26,59 @@ internal sealed class SyncService(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var afterSequence = 0L;
|
var afterSequence = 0L;
|
||||||
|
var issuedUnder = 0L;
|
||||||
if (!string.IsNullOrEmpty(request.Cursor)
|
if (!string.IsNullOrEmpty(request.Cursor)
|
||||||
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence))
|
&& !SyncCursor.TryDecode(cursorKeys.Key, request.Cursor, vault.Id, out afterSequence, out issuedUnder))
|
||||||
{
|
{
|
||||||
throw new InvalidCursorException();
|
throw new InvalidCursorException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read before the changes, for the cursor this read hands out; see the check after it.
|
||||||
|
var horizonBefore = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var (changes, hasMore) = await ReadPageAsync(vault.Id, afterSequence, request, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// ◆ After the read, not before it. The collector deletes a tombstone's entries and raises the horizon
|
||||||
|
// in one transaction, so a read that already missed an entry is followed by a horizon that says so;
|
||||||
|
// checked first, the two could straddle a collection and the deletion would go unnoticed for good.
|
||||||
|
//
|
||||||
|
// A cursor behind the horizon is refused only if it was issued before it. One issued since belongs to
|
||||||
|
// a read from the beginning that started after the collection, and its early pages are behind the
|
||||||
|
// horizon because old, untouched items are; refusing those would send that read back to the start
|
||||||
|
// for ever. See SyncCursor.
|
||||||
|
var horizon = await HorizonAsync(vault.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (afterSequence > 0 && afterSequence < horizon && issuedUnder < horizon)
|
||||||
|
{
|
||||||
|
throw new InvalidCursorException();
|
||||||
|
}
|
||||||
|
|
||||||
|
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
// When nothing came back the cursor must not move, or a concurrent write landing between
|
||||||
|
// this read and the next would be skipped forever.
|
||||||
|
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
||||||
|
|
||||||
|
return new SyncPullResponse(
|
||||||
|
Changes: hydrated,
|
||||||
|
// The horizon from before the read, never the one after: a collection landing between the two
|
||||||
|
// could have taken an entry this page would otherwise have carried, and a cursor claiming the
|
||||||
|
// later horizon would vouch for a client that never heard of it. The earlier one costs at most
|
||||||
|
// a restart.
|
||||||
|
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence, horizonBefore),
|
||||||
|
HasMore: hasMore,
|
||||||
|
ServerTime: clock.GetUtcNow(),
|
||||||
|
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The changes after a position, one page of them, and whether there are more.</summary>
|
||||||
|
private async Task<(List<VaultChange> Changes, bool HasMore)> ReadPageAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
long afterSequence,
|
||||||
|
SyncPullRequest request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
var limit = Math.Clamp(
|
var limit = Math.Clamp(
|
||||||
request.Limit ?? options.DefaultPullLimit,
|
request.Limit ?? options.DefaultPullLimit,
|
||||||
1,
|
1,
|
||||||
@@ -43,7 +90,7 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
// One extra row, so "is there more" needs no second query.
|
// One extra row, so "is there more" needs no second query.
|
||||||
var query = database.VaultChanges
|
var query = database.VaultChanges
|
||||||
.Where(c => c.VaultId == vault.Id && c.Sequence > afterSequence);
|
.Where(c => c.VaultId == vaultId && c.Sequence > afterSequence);
|
||||||
|
|
||||||
if (types is not null)
|
if (types is not null)
|
||||||
{
|
{
|
||||||
@@ -62,18 +109,7 @@ internal sealed class SyncService(
|
|||||||
changes.RemoveAt(changes.Count - 1);
|
changes.RemoveAt(changes.Count - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
var hydrated = await HydrateAsync(vault, changes, cancellationToken).ConfigureAwait(false);
|
return (changes, hasMore);
|
||||||
|
|
||||||
// When nothing came back the cursor must not move, or a concurrent write landing between
|
|
||||||
// this read and the next would be skipped forever.
|
|
||||||
var nextSequence = changes.Count > 0 ? changes[^1].Sequence : afterSequence;
|
|
||||||
|
|
||||||
return new SyncPullResponse(
|
|
||||||
Changes: hydrated,
|
|
||||||
NextCursor: SyncCursor.Encode(cursorKeys.Key, vault.Id, nextSequence),
|
|
||||||
HasMore: hasMore,
|
|
||||||
ServerTime: clock.GetUtcNow(),
|
|
||||||
CurrentKeyGeneration: (uint)vault.KeyGeneration);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -100,9 +136,7 @@ internal sealed class SyncService(
|
|||||||
{
|
{
|
||||||
ValidateBatchLimits(request);
|
ValidateBatchLimits(request);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
return await database.ExecuteFreshAsync([vault], async () =>
|
||||||
|
|
||||||
return await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -536,9 +570,21 @@ internal sealed class SyncService(
|
|||||||
|
|
||||||
/// <summary>Attaches current row state to change-log entries.</summary>
|
/// <summary>Attaches current row state to change-log entries.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
/// A page of changes can mix item types, so rows are loaded per type — one query each, not one per
|
||||||
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
/// change — and then matched back in the log's order. Ordering is the log's, never the load's: a pull
|
||||||
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
/// that returned changes grouped by type would hand the client a sequence its cursor cannot resume from.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>An entry an item has since moved past is left out.</b> The log keeps every revision, but the
|
||||||
|
/// table keeps only the latest ciphertext — so hydrating an old entry paired that entry's revision number
|
||||||
|
/// with the newest payload, and the payload's AAD binds the revision it was sealed at. A client opened it
|
||||||
|
/// at the old number, failed, and parked its own unsent edit to that item as undecryptable. The later
|
||||||
|
/// entry is in this page or a later one, since the item's <c>ChangeSequence</c> is that entry's sequence;
|
||||||
|
/// it carries the same payload under the right number, so nothing is lost by sending it once. Leaving
|
||||||
|
/// the old entries out also stops a fresh device downloading an item once per edit it ever had. The
|
||||||
|
/// cursor is computed from the unfiltered page by the caller, so it still moves past them.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
private async Task<List<Contracts.SyncChange>> HydrateAsync(
|
||||||
Vault vault,
|
Vault vault,
|
||||||
@@ -571,10 +617,13 @@ internal sealed class SyncService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [.. changes.Select(change => Describe(
|
return [.. changes
|
||||||
change,
|
.Where(change => rows.GetValueOrDefault((change.EntityType, change.EntityId)) is not { } row
|
||||||
kinds.GetValueOrDefault(change.EntityType),
|
|| row.ChangeSequence <= change.Sequence)
|
||||||
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
.Select(change => Describe(
|
||||||
|
change,
|
||||||
|
kinds.GetValueOrDefault(change.EntityType),
|
||||||
|
rows.GetValueOrDefault((change.EntityType, change.EntityId))))];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
/// <summary>Turns one change-log entry and its row into what a client receives.</summary>
|
||||||
@@ -609,14 +658,30 @@ internal sealed class SyncService(
|
|||||||
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
/// <summary>The kind behind a change-log entry, or null for a type this server does not synchronise.</summary>
|
||||||
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
private static IItemKind? KindForChange(ChangeEntityType type) => ItemKinds.For(ToWire(type));
|
||||||
|
|
||||||
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
/// <remarks>
|
||||||
await database.VaultChanges
|
/// Never below the vault's horizon: collecting a tombstone can take the newest entries with it, and a
|
||||||
|
/// head reported lower than a cursor the client already holds would rewind it under the horizon and
|
||||||
|
/// cost a full resync for nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<long> CurrentHeadAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var head = await database.VaultChanges
|
||||||
.Where(c => c.VaultId == vaultId)
|
.Where(c => c.VaultId == vaultId)
|
||||||
.OrderByDescending(c => c.Sequence)
|
.OrderByDescending(c => c.Sequence)
|
||||||
.Select(c => c.Sequence)
|
.Select(c => c.Sequence)
|
||||||
.FirstOrDefaultAsync(cancellationToken)
|
.FirstOrDefaultAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return Math.Max(head, await HorizonAsync(vaultId, cancellationToken).ConfigureAwait(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The vault's horizon as the database holds it now, not as the request loaded it.</summary>
|
||||||
|
private Task<long> HorizonAsync(Guid vaultId, CancellationToken cancellationToken) =>
|
||||||
|
database.Vaults
|
||||||
|
.Where(v => v.Id == vaultId)
|
||||||
|
.Select(v => v.SyncHorizon)
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
|
||||||
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
private static SyncPushResult Invalid(SyncPushOperation operation, string detail) =>
|
||||||
new(
|
new(
|
||||||
operation.OperationId,
|
operation.OperationId,
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
using DodoSSH.Api.Setup;
|
||||||
|
using DodoSSH.Domain;
|
||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Sync;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes tombstones past their retention, and change-log entries a later one has superseded.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ADR 0003 promised this and nothing did it: every deletion stayed a row for ever, and every edit stayed a
|
||||||
|
/// change-log entry for ever, so the log grew without bound and a client starting from nothing walked every
|
||||||
|
/// edit ever made to read the items that exist.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Superseded entries go at any time.</b> A pull returns entries after a cursor in order, so a client
|
||||||
|
/// before both an item's old entry and its new one reads the new one, and a client between them already has
|
||||||
|
/// the old one. Nothing reads history: a conflict resolves against the newest entry alone.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Tombstones go after the retention</b>, and taking them is the one change here a client can notice: a
|
||||||
|
/// machine that last synced before the deletion would never hear of it. So each vault records the highest
|
||||||
|
/// sequence collected from it — <see cref="Vault.SyncHorizon"/> — and a pull from a cursor below that is
|
||||||
|
/// refused. The client answers by starting over, and a pull from nothing removes what the server no longer
|
||||||
|
/// has; see the client's <c>SyncEngine</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Per vault under the same advisory lock a push takes, so a collection never interleaves with a write to
|
||||||
|
/// the vault — and the items are read again under it, so one restored since it was found is left alone.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class TombstoneCollector(
|
||||||
|
DodoDbContext database,
|
||||||
|
IOptions<SyncOptions> syncOptions,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<TombstoneCollector> logger)
|
||||||
|
{
|
||||||
|
/// <summary>Collects what is due, once.</summary>
|
||||||
|
/// <returns>How many tombstones and superseded entries went.</returns>
|
||||||
|
internal async Task<int> RunOnceAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var before = clock.GetUtcNow() - TimeSpan.FromDays(syncOptions.Value.TombstoneRetentionDays);
|
||||||
|
|
||||||
|
var collected = 0;
|
||||||
|
|
||||||
|
collected += await CollectAsync(db => db.Hosts, ChangeEntityType.SshHost, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.SshKeys, ChangeEntityType.SshKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Credentials, ChangeEntityType.Credential, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.KnownHostKeys, ChangeEntityType.KnownHostKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.HostGroups, ChangeEntityType.HostGroup, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Tags, ChangeEntityType.Tag, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.Snippets, ChangeEntityType.Snippet, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ConnectionLog, ChangeEntityType.ConnectionLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ActivityLog, ChangeEntityType.ActivityLogEntry, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.ObjectStores, ChangeEntityType.ObjectStore, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
collected += await CollectAsync(db => db.SpelunkKeys, ChangeEntityType.SpelunkKey, before, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
collected += await CompactAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
SyncLog.Collected(logger, collected);
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CollectAsync<T>(
|
||||||
|
Func<DodoDbContext, DbSet<T>> table,
|
||||||
|
ChangeEntityType type,
|
||||||
|
DateTimeOffset before,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class, IVaultItem
|
||||||
|
{
|
||||||
|
var due = await table(database)
|
||||||
|
.Where(item => item.DeletedAtUtc != null && item.DeletedAtUtc < before)
|
||||||
|
.Select(item => new { item.VaultId, item.Id })
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var collected = 0;
|
||||||
|
|
||||||
|
foreach (var vault in due.GroupBy(item => item.VaultId))
|
||||||
|
{
|
||||||
|
var ids = vault.Select(item => item.Id).ToArray();
|
||||||
|
|
||||||
|
collected += await database.ExecuteFreshAsync(
|
||||||
|
[],
|
||||||
|
() => CollectFromVaultAsync(table, type, vault.Key, ids, before, cancellationToken))
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return collected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<int> CollectFromVaultAsync<T>(
|
||||||
|
Func<DodoDbContext, DbSet<T>> table,
|
||||||
|
ChangeEntityType type,
|
||||||
|
Guid vaultId,
|
||||||
|
Guid[] ids,
|
||||||
|
DateTimeOffset before,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
where T : class, IVaultItem
|
||||||
|
{
|
||||||
|
var transaction = await database.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.Database
|
||||||
|
.ExecuteSqlAsync($"SELECT pg_advisory_xact_lock(hashtextextended({vaultId.ToString()}, 0))", cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Read again under the lock: an item restored since it was found is no longer due.
|
||||||
|
var still = await table(database)
|
||||||
|
.Where(item => item.VaultId == vaultId
|
||||||
|
&& ids.Contains(item.Id)
|
||||||
|
&& item.DeletedAtUtc != null
|
||||||
|
&& item.DeletedAtUtc < before)
|
||||||
|
.Select(item => new { item.Id, item.ChangeSequence })
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (still.Count == 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
var going = still.Select(item => item.Id).ToArray();
|
||||||
|
var horizon = still.Max(item => item.ChangeSequence);
|
||||||
|
|
||||||
|
await table(database)
|
||||||
|
.Where(item => item.VaultId == vaultId && going.Contains(item.Id))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.VaultChanges
|
||||||
|
.Where(change => change.VaultId == vaultId && change.EntityType == type && going.Contains(change.EntityId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await database.Vaults
|
||||||
|
.Where(v => v.Id == vaultId && v.SyncHorizon < horizon)
|
||||||
|
.ExecuteUpdateAsync(set => set.SetProperty(v => v.SyncHorizon, horizon), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return still.Count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Removes every change-log entry a later entry for the same item has superseded.</summary>
|
||||||
|
private Task<int> CompactAsync(CancellationToken cancellationToken) =>
|
||||||
|
database.Database.ExecuteSqlRawAsync(
|
||||||
|
"DELETE FROM " + DodoDbContext.SchemaName + ".sync_change AS older "
|
||||||
|
+ "USING " + DodoDbContext.SchemaName + ".sync_change AS newer "
|
||||||
|
+ "WHERE newer.vault_id = older.vault_id "
|
||||||
|
+ "AND newer.entity_type = older.entity_type "
|
||||||
|
+ "AND newer.entity_id = older.entity_id "
|
||||||
|
+ "AND newer.sequence > older.sequence",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Runs the <see cref="TombstoneCollector"/> on a schedule, in a scope of its own each time.</summary>
|
||||||
|
internal sealed class TombstoneCollectionService(
|
||||||
|
IServiceScopeFactory scopes,
|
||||||
|
IOptions<SyncOptions> syncOptions,
|
||||||
|
TimeProvider clock,
|
||||||
|
ILogger<TombstoneCollectionService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var interval = TimeSpan.FromMinutes(syncOptions.Value.TombstoneCollectionIntervalMinutes);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(interval, clock, stoppingToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var scope = scopes.CreateAsyncScope();
|
||||||
|
await using var _ = scope.ConfigureAwait(false);
|
||||||
|
|
||||||
|
await scope.ServiceProvider.GetRequiredService<TombstoneCollector>()
|
||||||
|
.RunOnceAsync(stoppingToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The next run tries again; nothing collected late is harmed by waiting.
|
||||||
|
SyncLog.CollectionFailed(logger, exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -179,8 +179,8 @@ internal sealed class ArchiveTeamEndpoint(ICurrentUserContext currentUser, TeamS
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
// Owner, not admin. An admin the owner promoted must not be able to archive the team out
|
||||||
// from under them — that is the boundary IsOwner exists to draw. Nothing behind this
|
// from under them — that is the boundary IsOwner exists to draw. TeamService.ArchiveAsync
|
||||||
// re-checks it, unlike the transfer below, so this line is the whole of the guard.
|
// checks it again, because deleting a team's last vault archives it too.
|
||||||
if (!access.IsOwner)
|
if (!access.IsOwner)
|
||||||
{
|
{
|
||||||
return Problems.Coded(
|
return Problems.Coded(
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Features.Teams;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||||
|
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||||
|
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||||
|
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||||
|
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class TeamLock
|
||||||
|
{
|
||||||
|
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||||
|
database.Database.ExecuteSqlAsync(
|
||||||
|
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -244,28 +245,37 @@ internal sealed class TeamService(
|
|||||||
Team team,
|
Team team,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(actor);
|
||||||
ArgumentNullException.ThrowIfNull(team);
|
ArgumentNullException.ThrowIfNull(team);
|
||||||
|
|
||||||
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
// Both callers check this first and answer the caller properly; this is what stops a third one that
|
||||||
|
// forgets from letting an admin archive the team out from under its owner.
|
||||||
if (vaultCount > 0)
|
if (!(await ResolveAsync(actor.Id, team.Id, cancellationToken).ConfigureAwait(false)).IsOwner)
|
||||||
{
|
{
|
||||||
throw new TeamNotEmptyException(
|
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||||
string.Create(
|
|
||||||
CultureInfo.InvariantCulture,
|
|
||||||
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var archived = await database.ExecuteFreshAsync([team], async () =>
|
||||||
|
|
||||||
var archived = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
await using var _ = transaction.ConfigureAwait(false);
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Counted under the lock a vault create takes, so one cannot land between this and the archive.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var vaultCount = await CountVaultsAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (vaultCount > 0)
|
||||||
|
{
|
||||||
|
throw new TeamNotEmptyException(
|
||||||
|
string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"This team still owns {vaultCount} vault(s), and archiving it would take them away from everybody holding a key — including you. Delete those vaults first, which asks about each one and withdraws every key to it."));
|
||||||
|
}
|
||||||
|
|
||||||
var memberships = await database.TeamMemberships
|
var memberships = await database.TeamMemberships
|
||||||
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
|
||||||
.ToListAsync(cancellationToken)
|
.ToListAsync(cancellationToken)
|
||||||
@@ -335,9 +345,7 @@ internal sealed class TeamService(
|
|||||||
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
await database.ExecuteFreshAsync([incoming, outgoing], async () =>
|
||||||
|
|
||||||
await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
@@ -716,9 +724,7 @@ internal sealed class TeamService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = clock.GetUtcNow();
|
var now = clock.GetUtcNow();
|
||||||
var strategy = database.Database.CreateExecutionStrategy();
|
var revoked = await database.ExecuteFreshAsync([membership], async () =>
|
||||||
|
|
||||||
var revoked = await strategy.ExecuteAsync(async () =>
|
|
||||||
{
|
{
|
||||||
var transaction = await database.Database
|
var transaction = await database.Database
|
||||||
.BeginTransactionAsync(cancellationToken)
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
|||||||
@@ -191,7 +191,12 @@ internal sealed class DeleteVaultEndpoint(
|
|||||||
{
|
{
|
||||||
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
var orphaned = await grants.DeleteVaultAsync(user, access.Vault!, ct).ConfigureAwait(false);
|
||||||
|
|
||||||
if (orphaned is not null)
|
// Archived only by its owner. Archiving revokes every membership, the owner's included, and an
|
||||||
|
// admin the owner promoted must not be able to do that by deleting vaults one at a time when the
|
||||||
|
// archive endpoint refuses them outright. An admin's delete stands; the empty team is left for
|
||||||
|
// the owner, who can archive it or make a vault in it.
|
||||||
|
if (orphaned is not null
|
||||||
|
&& (await teams.ResolveAsync(user.Id, orphaned.Id, ct).ConfigureAwait(false)).IsOwner)
|
||||||
{
|
{
|
||||||
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
await teams.ArchiveAsync(user, orphaned, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Api.Features.Events;
|
using DodoSSH.Api.Features.Events;
|
||||||
|
using DodoSSH.Api.Setup;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
using DodoSSH.Domain;
|
using DodoSSH.Domain;
|
||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
|
|||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
}
|
}
|
||||||
|
|
||||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
await WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
|
||||||
{
|
|
||||||
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
|
||||||
// the same id, which must not surface as a 500 about a constraint.
|
|
||||||
throw new TeamInvalidException(
|
|
||||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
|
||||||
}
|
|
||||||
|
|
||||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||||
|
|
||||||
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
|
|||||||
RekeyRequired: false);
|
RekeyRequired: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||||
|
private Task WriteVaultAsync(
|
||||||
|
UserAccount user,
|
||||||
|
Team team,
|
||||||
|
CreateTeamVaultRequest request,
|
||||||
|
string name,
|
||||||
|
UserKey key,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
database.ExecuteFreshAsync([team], async () =>
|
||||||
|
{
|
||||||
|
var transaction = await database.Database
|
||||||
|
.BeginTransactionAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
await using var _ = transaction.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||||
|
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception) when (IsUniqueViolation(exception))
|
||||||
|
{
|
||||||
|
// The pre-check covers the ordinary case; this is the race between two creates choosing
|
||||||
|
// the same id, which must not surface as a 500 about a constraint.
|
||||||
|
throw new TeamInvalidException(
|
||||||
|
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||||
|
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var live = await database.Teams
|
||||||
|
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!live)
|
||||||
|
{
|
||||||
|
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||||
private void AddVaultWithSelfGrant(
|
private void AddVaultWithSelfGrant(
|
||||||
UserAccount user,
|
UserAccount user,
|
||||||
|
|||||||
@@ -41,13 +41,16 @@ builder.Services.AddScoped<VaultGrantService>();
|
|||||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||||
|
|
||||||
// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
|
// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
|
||||||
// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
|
// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
|
||||||
// write paths that announce a change need only the two methods that announce one, and should not gain
|
// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
|
||||||
// a reference to connection management to get them.
|
// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
|
||||||
builder.Services.AddSingleton<VaultEventHub>();
|
builder.Services.AddSingleton<VaultEventHub>();
|
||||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||||
provider => provider.GetRequiredService<VaultEventHub>());
|
|
||||||
|
// ADR 0003's tombstone collection, and the compaction of the change log that goes with it.
|
||||||
|
builder.Services.AddScoped<TombstoneCollector>();
|
||||||
|
builder.Services.AddHostedService<TombstoneCollectionService>();
|
||||||
|
|
||||||
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||||
builder.Services.AddDodoRelay();
|
builder.Services.AddDodoRelay();
|
||||||
@@ -55,6 +58,7 @@ builder.Services.AddDodoRelay();
|
|||||||
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
// Scoped rather than the AddAuthorization default of singleton: the handler reads the request's
|
||||||
// DbContext, and a singleton would capture one for the lifetime of the process.
|
// DbContext, and a singleton would capture one for the lifetime of the process.
|
||||||
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
builder.Services.AddScoped<IAuthorizationHandler, EnrolledHandler>();
|
||||||
|
builder.Services.AddScoped<IAuthorizationHandler, ActiveAccountHandler>();
|
||||||
|
|
||||||
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
// Replaces the framework default, which is also registered as a singleton. Last registration wins.
|
||||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, DodoAuthorizationResultHandler>();
|
||||||
|
|||||||
@@ -62,7 +62,14 @@ internal static class Auth
|
|||||||
|
|
||||||
private static void AddDodoPolicies(AuthorizationOptions options)
|
private static void AddDodoPolicies(AuthorizationOptions options)
|
||||||
{
|
{
|
||||||
options.AddPolicy(AuthenticatedPolicy, policy => policy.RequireAuthenticatedUser());
|
// Both policies also require the account to be active. A suspended or deprovisioned account still
|
||||||
|
// holds a token the identity provider will vouch for until it expires — the status is this server's
|
||||||
|
// own decision, so it is this server that has to refuse. See ActiveAccountHandler.
|
||||||
|
options.AddPolicy(
|
||||||
|
AuthenticatedPolicy,
|
||||||
|
policy => policy
|
||||||
|
.RequireAuthenticatedUser()
|
||||||
|
.AddRequirements(new Authorization.ActiveAccountRequirement()));
|
||||||
|
|
||||||
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
// Enrollment state lives in the database, so this is satisfied by EnrolledHandler.
|
||||||
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
// An unmet EnrolledRequirement is rewritten into a ProblemDetails carrying
|
||||||
@@ -72,7 +79,7 @@ internal static class Auth
|
|||||||
EnrolledPolicy,
|
EnrolledPolicy,
|
||||||
policy => policy
|
policy => policy
|
||||||
.RequireAuthenticatedUser()
|
.RequireAuthenticatedUser()
|
||||||
.AddRequirements(new Authorization.EnrolledRequirement()));
|
.AddRequirements(new Authorization.ActiveAccountRequirement(), new Authorization.EnrolledRequirement()));
|
||||||
|
|
||||||
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
// Deny by default: an endpoint without an explicit policy still requires a caller.
|
||||||
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
options.FallbackPolicy = options.GetPolicy(AuthenticatedPolicy);
|
||||||
|
|||||||
@@ -289,6 +289,10 @@ public sealed class SyncOptions
|
|||||||
[Range(1, 3650)]
|
[Range(1, 3650)]
|
||||||
public int TombstoneRetentionDays { get; set; } = 90;
|
public int TombstoneRetentionDays { get; set; } = 90;
|
||||||
|
|
||||||
|
/// <summary>How often the tombstone collector runs, in minutes.</summary>
|
||||||
|
[Range(1, 7 * 24 * 60)]
|
||||||
|
public int TombstoneCollectionIntervalMinutes { get; set; } = 6 * 60;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Key used to sign sync cursors, base64.
|
/// Key used to sign sync cursors, base64.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
using DodoSSH.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DodoSSH.Api.Setup;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
|
||||||
|
/// rather than from what the failed attempt left in the change tracker.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
|
||||||
|
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
|
||||||
|
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
|
||||||
|
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
|
||||||
|
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
|
||||||
|
/// its two role changes, each reporting success.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
|
||||||
|
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
|
||||||
|
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
|
||||||
|
/// before: nothing is cleared or reloaded until something has failed.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class FreshAttempts
|
||||||
|
{
|
||||||
|
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
|
||||||
|
/// <param name="database">The request's context.</param>
|
||||||
|
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
|
||||||
|
/// <param name="attempt">The transaction, which begins and commits its own.</param>
|
||||||
|
internal static Task<T> ExecuteFreshAsync<T>(
|
||||||
|
this DodoDbContext database,
|
||||||
|
IReadOnlyList<object> carried,
|
||||||
|
Func<Task<T>> attempt)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(database);
|
||||||
|
ArgumentNullException.ThrowIfNull(carried);
|
||||||
|
ArgumentNullException.ThrowIfNull(attempt);
|
||||||
|
|
||||||
|
var strategy = database.Database.CreateExecutionStrategy();
|
||||||
|
var attempts = 0;
|
||||||
|
|
||||||
|
return strategy.ExecuteAsync(async () =>
|
||||||
|
{
|
||||||
|
if (attempts++ > 0)
|
||||||
|
{
|
||||||
|
database.ChangeTracker.Clear();
|
||||||
|
|
||||||
|
foreach (var entity in carried)
|
||||||
|
{
|
||||||
|
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return await attempt().ConfigureAwait(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
|
||||||
|
internal static Task ExecuteFreshAsync(
|
||||||
|
this DodoDbContext database,
|
||||||
|
IReadOnlyList<object> carried,
|
||||||
|
Func<Task> attempt) =>
|
||||||
|
database.ExecuteFreshAsync(
|
||||||
|
carried,
|
||||||
|
async () =>
|
||||||
|
{
|
||||||
|
await attempt().ConfigureAwait(false);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using DodoSSH.Infrastructure;
|
using DodoSSH.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
@@ -29,14 +30,18 @@ internal static class Persistence
|
|||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
$"ConnectionStrings:{ConnectionStringName} is not configured.");
|
||||||
|
|
||||||
services.AddDbContext<DodoDbContext>(options => options
|
services.AddDbContext<DodoDbContext>((provider, options) => options
|
||||||
.UseNpgsql(connectionString, npgsql => npgsql
|
.UseNpgsql(connectionString, npgsql => npgsql
|
||||||
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
|
||||||
// Retry on transient faults only. Note this is safe here because writes go
|
// Retry on transient faults only. A write transaction replayed by the strategy runs on
|
||||||
// through explicitly-managed transactions rather than relying on the execution
|
// the same context, which still holds what the failed attempt tracked — every such
|
||||||
// strategy to replay ambient ones.
|
// transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
|
||||||
|
// the database instead.
|
||||||
.EnableRetryOnFailure(3))
|
.EnableRetryOnFailure(3))
|
||||||
.UseSnakeCaseNamingConvention());
|
.UseSnakeCaseNamingConvention()
|
||||||
|
// None in production. The seam a test uses to make a commit fail the way a dropped
|
||||||
|
// connection would, which is the only way to watch a retry happen.
|
||||||
|
.AddInterceptors(provider.GetServices<IInterceptor>()));
|
||||||
|
|
||||||
services.AddHealthChecks()
|
services.AddHealthChecks()
|
||||||
.AddCheck<DatabaseHealthCheck>(
|
.AddCheck<DatabaseHealthCheck>(
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ internal static class RelaySetup
|
|||||||
// the network seam holds no state.
|
// the network seam holds no state.
|
||||||
services.AddSingleton<RelayTicketCodec>();
|
services.AddSingleton<RelayTicketCodec>();
|
||||||
services.AddSingleton<RelaySessionRegistry>();
|
services.AddSingleton<RelaySessionRegistry>();
|
||||||
|
services.AddSingleton<RelayAccessWatch>();
|
||||||
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||||
|
|
||||||
// The application name isolates this deployment's payloads from any other ASP.NET application
|
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
{
|
{
|
||||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||||
{
|
{
|
||||||
|
// Recorded, not handled: see CrashLog for why the process still goes.
|
||||||
|
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
|
||||||
|
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
|
||||||
|
|
||||||
ListenForOpenedLinks();
|
ListenForOpenedLinks();
|
||||||
ShowSomethingWhileComposing(desktop);
|
ShowSomethingWhileComposing(desktop);
|
||||||
}
|
}
|
||||||
@@ -352,39 +356,9 @@ internal sealed partial class DodoSshApp : Application
|
|||||||
copyToClipboard: ClipboardWriter(desktop),
|
copyToClipboard: ClipboardWriter(desktop),
|
||||||
readClipboard: ClipboardReader(desktop),
|
readClipboard: ClipboardReader(desktop),
|
||||||
updates: updates,
|
updates: updates,
|
||||||
openInEditor: OpenInDefaultEditorAsync,
|
openInEditor: LocalEditor.OpenAsync,
|
||||||
watchFile: WatchFile);
|
watchFile: WatchFile);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Hands a local file to whatever this machine opens that kind of file with.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// One of the two halves of editing a remote file in place, and both are this platform's rather than
|
|
||||||
/// the shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch
|
|
||||||
/// Services binding depending on which desktop this is, and watching a file is a platform object with
|
|
||||||
/// its own thread. The shell decides <em>when</em>; see <c>TransfersViewModel.EditRemoteAsync</c>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <c>UseShellExecute</c> is the whole of it, and it is what makes the same three lines work on all
|
|
||||||
/// three desktops: Windows resolves the association, macOS goes through <c>open</c> and Linux through
|
|
||||||
/// <c>xdg-open</c>, each of which .NET reaches for when the flag is set. Without it the path is treated
|
|
||||||
/// as an executable to run, which for a <c>.conf</c> fails and for something else would be worse.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
|
||||||
/// instance and exit at once, so its lifetime says nothing about whether the file is still being
|
|
||||||
/// edited. What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private static Task OpenInDefaultEditorAsync(string path)
|
|
||||||
{
|
|
||||||
using var opened = System.Diagnostics.Process.Start(
|
|
||||||
new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watches one file for saves, until the handle is disposed.
|
/// Watches one file for saves, until the handle is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes down why the application died, in the profile, before it does.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
|
||||||
|
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
|
||||||
|
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
|
||||||
|
/// state it left would be a guess. What it adds is the record.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
|
||||||
|
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
|
||||||
|
/// log, never a reason to fail harder.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class CrashLog
|
||||||
|
{
|
||||||
|
/// <summary>Past this, the next entry starts a fresh file.</summary>
|
||||||
|
private const long MaxBytes = 1024 * 1024;
|
||||||
|
|
||||||
|
private static readonly Lock Gate = new();
|
||||||
|
|
||||||
|
private static string? file;
|
||||||
|
|
||||||
|
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The UI thread's own hook is added by the application once Avalonia is running — see
|
||||||
|
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
|
||||||
|
/// has set it up.
|
||||||
|
/// </remarks>
|
||||||
|
internal static void Install(ClientPaths paths)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
file = paths.CrashLogFile;
|
||||||
|
|
||||||
|
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
|
||||||
|
|
||||||
|
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
|
||||||
|
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
|
||||||
|
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||||
|
{
|
||||||
|
Write("Unobserved task", e.Exception);
|
||||||
|
e.SetObserved();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
|
||||||
|
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
|
||||||
|
|
||||||
|
/// <summary>Appends one entry; never throws.</summary>
|
||||||
|
internal static void Write(string where, Exception? exception)
|
||||||
|
{
|
||||||
|
if (file is not { } path)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
|
||||||
|
|
||||||
|
lock (Gate)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
|
||||||
|
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
|
||||||
|
{
|
||||||
|
File.Delete(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
File.AppendAllText(path, entry);
|
||||||
|
}
|
||||||
|
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
// Best-effort, by design: see the type's remarks.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What a platform key store calls the hardware key behind one profile's device unlock.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>One key per profile, because the key outlives the file and is shared by name.</b> The CNG key and
|
||||||
|
/// the Secure Enclave keychain item are found by name in a store that belongs to the user, not to the
|
||||||
|
/// profile directory. While every profile used the same name, the release build and the nightly — which
|
||||||
|
/// <see cref="ClientPaths.ForChannel"/> keeps apart precisely so neither can damage the other — shared one
|
||||||
|
/// key: signing out of either deleted it, and the other's device unlock silently stopped working.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The default profile keeps the name it has always had</b>, so an existing install's sealed key still
|
||||||
|
/// opens. Every other profile gets that name with a digest of its directory on the end — a digest rather
|
||||||
|
/// than the directory's own name, because a portable copy can live in any folder and two such folders can
|
||||||
|
/// end in the same name.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class DeviceKeyNames
|
||||||
|
{
|
||||||
|
/// <summary>The name of the key behind <paramref name="paths"/>' device unlock.</summary>
|
||||||
|
/// <param name="paths">The profile.</param>
|
||||||
|
/// <param name="legacy">The name every profile used before names were per profile.</param>
|
||||||
|
internal static string For(ClientPaths paths, string legacy)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
|
|
||||||
|
var directory = Normalise(paths.DataDirectory);
|
||||||
|
|
||||||
|
if (string.Equals(directory, Normalise(ClientPaths.Default.DataDirectory), StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return legacy;
|
||||||
|
}
|
||||||
|
|
||||||
|
var digest = SHA256.HashData(Encoding.UTF8.GetBytes(directory.ToUpperInvariant()));
|
||||||
|
|
||||||
|
return $"{legacy}.{Convert.ToHexStringLower(digest.AsSpan(0, 8))}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalise(string directory) =>
|
||||||
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Opens a local copy of a remote file for editing, without running it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One of the two halves of editing a remote file in place, and both are this platform's rather than the
|
||||||
|
/// shell's: what opens a <c>.conf</c> is a registry association, a desktop entry or a Launch Services binding
|
||||||
|
/// depending on which desktop this is. The shell decides <em>when</em>; see
|
||||||
|
/// <c>TransfersViewModel.EditRemoteAsync</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>"Open" is not "edit", and for a file somebody else named the difference is code execution.</b> The
|
||||||
|
/// file comes from a host, and whoever can write to that host chooses its name. Handing <c>deploy.bat</c>,
|
||||||
|
/// <c>notes.hta</c> or <c>run.command</c> to the desktop's default action runs it, on this machine, as this
|
||||||
|
/// user — so a file whose default action is to run is opened with an editor instead. On Windows that is the
|
||||||
|
/// type's own <c>edit</c> verb where it has one (Notepad, for <c>.bat</c>, <c>.cmd</c>, <c>.js</c>,
|
||||||
|
/// <c>.vbs</c>, <c>.ps1</c> and <c>.reg</c>) and Notepad where it has not; on macOS it is <c>open -t</c>,
|
||||||
|
/// which is the default text editor whatever the type. Everything else still goes to its association, which
|
||||||
|
/// is the point of the feature — an <c>nginx.conf</c> should open in whatever this person edits configs with.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Linux is the exception: there is no portable "the text editor", so a <c>.desktop</c> entry — the one type
|
||||||
|
/// <c>xdg-open</c> may launch rather than open — is refused with a sentence instead. A shell script is opened
|
||||||
|
/// by every mainstream desktop's file associations in an editor, not run.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The process is not waited on and is deliberately not kept: an editor may hand off to an already-open
|
||||||
|
/// instance and exit at once, so its lifetime says nothing about whether the file is still being edited.
|
||||||
|
/// What ends an edit session is the user saying so — see the file browser's STOP EDITING.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class LocalEditor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Extensions Windows runs rather than opens, beyond whatever <c>PATHEXT</c> lists on this machine.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <c>PATHEXT</c> covers the console's notion of executable (<c>.exe</c>, <c>.bat</c>, <c>.js</c>,
|
||||||
|
/// <c>.vbs</c>…) and is read as well, because an interpreter's installer may add its own. These are the
|
||||||
|
/// ones Explorer acts on that are not in it: shortcuts, installers, HTML applications, control panel
|
||||||
|
/// items, and the script hosts that installers do not register there.
|
||||||
|
/// </remarks>
|
||||||
|
private static readonly string[] WindowsRunsThese =
|
||||||
|
[
|
||||||
|
".application", ".appref-ms", ".bat", ".chm", ".cmd", ".com", ".cpl", ".diagcab", ".exe", ".hta",
|
||||||
|
".inf", ".jar", ".js", ".jse", ".library-ms", ".lnk", ".msc", ".msi", ".msp", ".pif", ".ps1",
|
||||||
|
".psm1", ".py", ".pyw", ".reg", ".scf", ".scr", ".search-ms", ".settingcontent-ms", ".url",
|
||||||
|
".vbe", ".vbs", ".website", ".ws", ".wsc", ".wsf", ".wsh", ".xll",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Extensions Launch Services runs, or installs, rather than opens.</summary>
|
||||||
|
private static readonly string[] MacRunsThese =
|
||||||
|
[
|
||||||
|
".app", ".command", ".dmg", ".fileloc", ".inetloc", ".jar", ".mpkg", ".pkg", ".scpt", ".terminal",
|
||||||
|
".tool", ".webloc", ".workflow",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// <summary>Opens <paramref name="path"/> for editing.</summary>
|
||||||
|
/// <exception cref="InvalidOperationException">
|
||||||
|
/// This desktop would run the file and has no editor to offer in its place.
|
||||||
|
/// </exception>
|
||||||
|
internal static Task OpenAsync(string path)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
|
||||||
|
using var opened = Process.Start(StartInfoFor(path));
|
||||||
|
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What to start so that <paramref name="path"/> opens in an editor.</summary>
|
||||||
|
internal static ProcessStartInfo StartInfoFor(string path)
|
||||||
|
{
|
||||||
|
if (OperatingSystem.IsWindows() && WouldRunOnWindows(path))
|
||||||
|
{
|
||||||
|
var edit = new ProcessStartInfo(path) { UseShellExecute = true, Verb = "edit" };
|
||||||
|
|
||||||
|
if (edit.Verbs.Contains("edit", StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return edit;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProcessStartInfo("notepad.exe") { UseShellExecute = false, ArgumentList = { path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsMacOS() && HasExtension(path, MacRunsThese))
|
||||||
|
{
|
||||||
|
return new ProcessStartInfo("open") { UseShellExecute = false, ArgumentList = { "-t", path } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (OperatingSystem.IsLinux() && HasExtension(path, [".desktop"]))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"{Path.GetFileName(path)} is a launcher, which this desktop would run rather than open. "
|
||||||
|
+ "Download it and open it in an editor instead.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ProcessStartInfo(path) { UseShellExecute = true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether Windows' default action for this file is to run it.</summary>
|
||||||
|
internal static bool WouldRunOnWindows(string path, string? pathExt = null)
|
||||||
|
{
|
||||||
|
var listed = (pathExt ?? Environment.GetEnvironmentVariable("PATHEXT") ?? string.Empty)
|
||||||
|
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
return HasExtension(path, WindowsRunsThese) || HasExtension(path, listed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the file's last extension is one of these, ignoring case.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Trailing dots and spaces are dropped first, because Windows drops them too: <c>run.bat.</c> is
|
||||||
|
/// <c>run.bat</c> by the time anything acts on it.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool HasExtension(string path, IEnumerable<string> extensions)
|
||||||
|
{
|
||||||
|
var extension = Path.GetExtension(Path.GetFileName(path).TrimEnd('.', ' '));
|
||||||
|
|
||||||
|
return extension.Length > 0
|
||||||
|
&& extensions.Contains(extension, StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,11 +78,21 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
private readonly ClientPaths paths;
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>This profile's own enclave key; see <see cref="DeviceKeyNames"/>.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="KeyTag"/> for the default profile and a tag of its own for every other, so that signing
|
||||||
|
/// out of one installation cannot delete the key another's Touch ID unlock is sealed to. A profile
|
||||||
|
/// registered before this is still sealed to the shared tag and is read from there until it registers
|
||||||
|
/// again; it is never deleted from a profile that does not own it.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly string keyTag;
|
||||||
|
|
||||||
/// <summary>Creates the store.</summary>
|
/// <summary>Creates the store.</summary>
|
||||||
public MacDeviceKeyStore(ClientPaths paths)
|
public MacDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyTag = DeviceKeyNames.For(paths, KeyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -163,7 +173,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var sealedKey = Seal(devicePrivateKey.Span)
|
var sealedKey = Seal(devicePrivateKey.Span, keyTag)
|
||||||
?? throw new InvalidOperationException(
|
?? throw new InvalidOperationException(
|
||||||
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
"The Secure Enclave would not seal the device key. Check IsAvailableAsync before offering to register one.");
|
||||||
|
|
||||||
@@ -184,7 +194,8 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return Unseal(sealedKey);
|
// The shared tag only while this profile has no key of its own, so one unlock is one prompt.
|
||||||
|
return Unseal(sealedKey, !string.Equals(keyTag, KeyTag, StringComparison.Ordinal) && !HasKey(keyTag) ? KeyTag : keyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -202,7 +213,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
// Discarded, and that is deliberate: there is nothing a caller could do about a failure here,
|
||||||
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
// and the file deleted above is the half that decides whether unlock will try at all. A key
|
||||||
// left in the enclave with no ciphertext to open is inert.
|
// left in the enclave with no ciphertext to open is inert.
|
||||||
_ = DeleteKey(symbols, KeyTag);
|
_ = DeleteKey(symbols, keyTag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
@@ -212,7 +223,26 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
/// Silent: it uses only the public half. Null on every failure, and the caller's answer to all of
|
||||||
/// them is the same — do not offer a device unlock.
|
/// them is the same — do not offer a device unlock.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey)
|
/// <summary>Whether a key is filed under this tag, asked without using it and so without a prompt.</summary>
|
||||||
|
private static bool HasKey(string tag)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var scope = new CoreFoundationScope();
|
||||||
|
|
||||||
|
var symbols = MacSymbols.Resolve();
|
||||||
|
|
||||||
|
return symbols.Complete && FindKey(scope, symbols, tag, prompt: null) != IntPtr.Zero;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is DllNotFoundException
|
||||||
|
or EntryPointNotFoundException
|
||||||
|
or BadImageFormatException)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[]? Seal(ReadOnlySpan<byte> devicePrivateKey, string tag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -227,11 +257,11 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
// Created on first use rather than at registration, so that a device key re-registered after
|
// Created on first use rather than at registration, so that a device key re-registered after
|
||||||
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
// a ForgetAsync gets a key again without anything having to notice that it had gone.
|
||||||
var privateKey = FindKey(scope, symbols, KeyTag, prompt: null);
|
var privateKey = FindKey(scope, symbols, tag, prompt: null);
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
privateKey = CreateEnclaveKey(scope, symbols, KeyTag);
|
privateKey = CreateEnclaveKey(scope, symbols, tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
@@ -281,7 +311,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// on a thread that is also expected to draw the window behind it.
|
/// on a thread that is also expected to draw the window behind it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string tag)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -294,7 +324,7 @@ public sealed partial class MacDeviceKeyStore : IDeviceKeyStore
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var privateKey = FindKey(scope, symbols, KeyTag, ConsentPrompt);
|
var privateKey = FindKey(scope, symbols, tag, ConsentPrompt);
|
||||||
|
|
||||||
if (privateKey == IntPtr.Zero)
|
if (privateKey == IntPtr.Zero)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using DodoSSH.Client.Session;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Platform;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The device key store this machine supports, chosen once and off the UI thread.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
|
||||||
|
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
|
||||||
|
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
|
||||||
|
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
|
||||||
|
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
|
||||||
|
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
|
||||||
|
/// lookup.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Available means the choice landed on a real store, which is what the probe that chose it established.
|
||||||
|
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
|
||||||
|
/// since nothing reaches for the device key until somebody is at the unlock card.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
|
||||||
|
{
|
||||||
|
private readonly Task<IDeviceKeyStore> chosen;
|
||||||
|
|
||||||
|
/// <summary>Starts choosing at once, on the thread pool.</summary>
|
||||||
|
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(choose);
|
||||||
|
chosen = Task.Run(choose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
|
||||||
|
internal Task<IDeviceKeyStore> Chosen => chosen;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||||
|
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class DesktopDeviceKeyStores
|
public static class DesktopDeviceKeyStores
|
||||||
{
|
{
|
||||||
/// <summary>The best store this machine supports.</summary>
|
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
|
||||||
|
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
|
||||||
|
/// thread at startup.
|
||||||
|
/// </remarks>
|
||||||
/// <param name="paths">Where a stored key lives.</param>
|
/// <param name="paths">Where a stored key lives.</param>
|
||||||
/// <param name="askForPin">
|
/// <param name="askForPin">
|
||||||
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
/// Shows the Linux PIN prompt, and is never called on the other two platforms — there the platform
|
||||||
@@ -57,6 +62,13 @@ public static class DesktopDeviceKeyStores
|
|||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
ArgumentNullException.ThrowIfNull(askForPin);
|
ArgumentNullException.ThrowIfNull(askForPin);
|
||||||
|
|
||||||
|
return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDeviceKeyStore Choose(
|
||||||
|
ClientPaths paths,
|
||||||
|
Func<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||||
|
{
|
||||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||||
{
|
{
|
||||||
return new WindowsDeviceKeyStore(paths);
|
return new WindowsDeviceKeyStore(paths);
|
||||||
@@ -123,6 +135,10 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
/// rather than failing to open blobs written by a previous build. A device that cannot be opened
|
||||||
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
/// falls back to the passphrase, which is survivable — but silently, and a user would only notice
|
||||||
/// their gesture had stopped working.
|
/// their gesture had stopped working.
|
||||||
|
/// <para>
|
||||||
|
/// This is the default profile's key, and the name every profile shared before keys were per profile;
|
||||||
|
/// see <see cref="DeviceKeyNames"/> and <see cref="keyName"/>.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
private const string KeyName = "DodoSSH.DeviceKey.v1";
|
||||||
|
|
||||||
@@ -133,11 +149,22 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
|
|
||||||
private readonly ClientPaths paths;
|
private readonly ClientPaths paths;
|
||||||
|
|
||||||
|
/// <summary>This profile's own key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="KeyName"/> for the default profile and a name of its own for every other — so signing out
|
||||||
|
/// of the nightly no longer deletes the key the release build's device unlock is sealed to. A profile
|
||||||
|
/// registered before this still has its blob sealed to the shared key, and <see cref="TryLoadAsync"/>
|
||||||
|
/// keeps opening it there until the next registration re-seals it here. It is never deleted from a
|
||||||
|
/// profile that does not own it, which is the half of this that matters.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly string keyName;
|
||||||
|
|
||||||
/// <summary>Creates the store.</summary>
|
/// <summary>Creates the store.</summary>
|
||||||
public WindowsDeviceKeyStore(ClientPaths paths)
|
public WindowsDeviceKeyStore(ClientPaths paths)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(paths);
|
ArgumentNullException.ThrowIfNull(paths);
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
|
keyName = DeviceKeyNames.For(paths, KeyName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -182,7 +209,7 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
ReadOnlyMemory<byte> devicePrivateKey,
|
ReadOnlyMemory<byte> devicePrivateKey,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using var key = OpenOrCreate();
|
using var key = OpenOrCreate(keyName);
|
||||||
using var rsa = new RSACng(key);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
var sealedKey = rsa.Encrypt(devicePrivateKey.Span, RSAEncryptionPadding.OaepSHA256);
|
||||||
@@ -204,7 +231,13 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
var sealedKey = await File.ReadAllBytesAsync(paths.DeviceKeyFile, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return Unseal(sealedKey);
|
// The shared key only while this profile has none of its own: a profile registered before keys were
|
||||||
|
// per profile is still sealed to it. Asking both would show two consent dialogs for one unlock.
|
||||||
|
var sealedTo = !string.Equals(keyName, KeyName, StringComparison.Ordinal) && !CngKey.Exists(keyName, new CngProvider(PlatformProvider))
|
||||||
|
? KeyName
|
||||||
|
: keyName;
|
||||||
|
|
||||||
|
return Unseal(sealedKey, sealedTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
@@ -215,9 +248,11 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
File.Delete(paths.DeviceKeyFile);
|
File.Delete(paths.DeviceKeyFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
// This profile's key and never the shared one, unless this is the profile that owns it. Another
|
||||||
|
// profile's device unlock may still be sealed to it.
|
||||||
|
if (CngKey.Exists(keyName, new CngProvider(PlatformProvider)))
|
||||||
{
|
{
|
||||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
using var key = CngKey.Open(keyName, new CngProvider(PlatformProvider));
|
||||||
key.Delete();
|
key.Delete();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,16 +266,16 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
/// user and all have the same remedy, so none of them are worth telling apart here — see
|
||||||
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
/// <c>UnlockStatus.DeviceKeyUnavailable</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static byte[]? Unseal(byte[] sealedKey)
|
private static byte[]? Unseal(byte[] sealedKey, string name)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!CngKey.Exists(KeyName, new CngProvider(PlatformProvider)))
|
if (!CngKey.Exists(name, new CngProvider(PlatformProvider)))
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
using var key = CngKey.Open(KeyName, new CngProvider(PlatformProvider));
|
using var key = CngKey.Open(name, new CngProvider(PlatformProvider));
|
||||||
using var rsa = new RSACng(key);
|
using var rsa = new RSACng(key);
|
||||||
|
|
||||||
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
return rsa.Decrypt(sealedKey, RSAEncryptionPadding.OaepSHA256);
|
||||||
@@ -256,18 +291,18 @@ public sealed class WindowsDeviceKeyStore : IDeviceKeyStore
|
|||||||
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
/// key rather than ever reconfiguring one: a key created without the policy would decrypt silently, and
|
||||||
/// silently is the one behaviour this whole file exists to prevent.
|
/// silently is the one behaviour this whole file exists to prevent.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static CngKey OpenOrCreate()
|
private static CngKey OpenOrCreate(string name)
|
||||||
{
|
{
|
||||||
var provider = new CngProvider(PlatformProvider);
|
var provider = new CngProvider(PlatformProvider);
|
||||||
|
|
||||||
if (CngKey.Exists(KeyName, provider))
|
if (CngKey.Exists(name, provider))
|
||||||
{
|
{
|
||||||
return CngKey.Open(KeyName, provider);
|
return CngKey.Open(name, provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
return CngKey.Create(
|
return CngKey.Create(
|
||||||
CngAlgorithm.Rsa,
|
CngAlgorithm.Rsa,
|
||||||
KeyName,
|
name,
|
||||||
new CngKeyCreationParameters
|
new CngKeyCreationParameters
|
||||||
{
|
{
|
||||||
Provider = provider,
|
Provider = provider,
|
||||||
|
|||||||
@@ -75,6 +75,9 @@ internal static class Program
|
|||||||
|
|
||||||
LaunchLink = link;
|
LaunchLink = link;
|
||||||
|
|
||||||
|
// After the claim, so a second launch that only hands its link over writes nothing.
|
||||||
|
CrashLog.Install(paths);
|
||||||
|
|
||||||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tells a drawn titlebar's double-click from a press that starts a drag.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
|
||||||
|
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
|
||||||
|
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
|
||||||
|
/// so the second click often never completed one and the window did not maximise. The convention it was
|
||||||
|
/// imitating is one nobody thinks about until it does not work.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
|
||||||
|
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
|
||||||
|
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class CaptionPresses
|
||||||
|
{
|
||||||
|
private ulong lastAt;
|
||||||
|
|
||||||
|
private Point lastWhere;
|
||||||
|
|
||||||
|
private bool waitingForASecond;
|
||||||
|
|
||||||
|
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
|
||||||
|
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
|
||||||
|
{
|
||||||
|
var settings = Application.Current?.PlatformSettings;
|
||||||
|
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
|
||||||
|
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
|
||||||
|
var where = e.GetPosition(bar);
|
||||||
|
|
||||||
|
var second = e.ClickCount == 2
|
||||||
|
|| (waitingForASecond
|
||||||
|
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
|
||||||
|
&& Math.Abs(where.X - lastWhere.X) <= size.Width
|
||||||
|
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
|
||||||
|
|
||||||
|
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
|
||||||
|
// the window straight back.
|
||||||
|
waitingForASecond = !second;
|
||||||
|
lastAt = e.Timestamp;
|
||||||
|
lastWhere = where;
|
||||||
|
|
||||||
|
return second;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||||
{
|
{
|
||||||
public ConfirmDeleteCard() => InitializeComponent();
|
public ConfirmDeleteCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
Width="420" SizeToContent="Height"
|
Width="420" SizeToContent="Height"
|
||||||
CanResize="False" ShowInTaskbar="False"
|
CanResize="False" ShowInTaskbar="False"
|
||||||
WindowStartupLocation="CenterOwner"
|
WindowStartupLocation="CenterOwner"
|
||||||
Background="{StaticResource Surface}"
|
Background="{StaticResource Panel}"
|
||||||
Title="DodoSSH">
|
Title="DodoSSH">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
|
|
||||||
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
<TextBlock Classes="heading" FontSize="15" x:Name="Heading" TextWrapping="Wrap" />
|
||||||
|
|
||||||
<TextBlock Foreground="{StaticResource MutedText}" FontSize="12" TextWrapping="Wrap"
|
<TextBlock Foreground="{StaticResource TextDim}" FontSize="12" TextWrapping="Wrap"
|
||||||
x:Name="Explanation" />
|
x:Name="Explanation" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ public sealed partial class DevicePinWindow : Window
|
|||||||
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
/// <summary>Creates the window. Avalonia's designer needs this; nothing else should call it.</summary>
|
||||||
public DevicePinWindow()
|
public DevicePinWindow()
|
||||||
{
|
{
|
||||||
AvaloniaXamlLoader.Load(this);
|
// InitializeComponent, not AvaloniaXamlLoader.Load: only the generated method assigns the x:Name
|
||||||
|
// fields below, and with Load the first of them was null — so this window threw on construction and
|
||||||
|
// no Linux PIN prompt was ever shown.
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
Accept.Click += OnAccept;
|
Accept.Click += OnAccept;
|
||||||
Cancel.Click += OnCancel;
|
Cancel.Click += OnCancel;
|
||||||
|
|||||||
@@ -253,8 +253,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
<DataTemplate x:DataType="vm:HetznerRowViewModel">
|
||||||
<Border Classes="importrow" CornerRadius="8">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
@@ -290,8 +302,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -45,7 +45,11 @@
|
|||||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||||
-->
|
-->
|
||||||
<Border Background="#CC05050A">
|
<!--
|
||||||
|
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
|
||||||
|
that cannot be answered by clicking away should not be answerable by tabbing away either.
|
||||||
|
-->
|
||||||
|
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||||
@@ -83,7 +87,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||||
Command="{Binding TrustHostKeyCommand}" />
|
Command="{Binding TrustHostKeyCommand}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -119,7 +123,7 @@
|
|||||||
is not an answer, because this half of the control has none to give. Compare the accent button
|
is not an answer, because this half of the control has none to give. Compare the accent button
|
||||||
above, which connects.
|
above, which connects.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||||
Command="{Binding RejectHostKeyCommand}" />
|
Command="{Binding RejectHostKeyCommand}" />
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
|||||||
internal sealed partial class HostKeyCard : UserControl
|
internal sealed partial class HostKeyCard : UserControl
|
||||||
{
|
{
|
||||||
public HostKeyCard() => InitializeComponent();
|
public HostKeyCard() => InitializeComponent();
|
||||||
|
|
||||||
|
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
|
||||||
|
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
|
||||||
|
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
|
||||||
|
/// this one is raised over a terminal that may be holding it natively — see
|
||||||
|
/// <c>MainWindow.OnHostKeyDecision</c>.
|
||||||
|
/// </remarks>
|
||||||
|
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
|
||||||
}
|
}
|
||||||
@@ -230,9 +230,13 @@
|
|||||||
|
|
||||||
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
Ctrl+K is named on it because the palette is the other way to reach a host by typing, and somebody
|
||||||
who has found this box should know about the one that also connects on Enter.
|
who has found this box should know about the one that also connects on Enter.
|
||||||
|
|
||||||
|
One way, and passed back by the code-behind once the typing pauses: every change to the filter
|
||||||
|
rebuilds the board's sections and their cards, and a name typed at speed used to do that once a
|
||||||
|
letter. The view model still filters the moment it is told. See HostsScreen.OnFilterTyped.
|
||||||
-->
|
-->
|
||||||
<Border Grid.Row="1" Margin="26,0,26,16">
|
<Border Grid.Row="1" Margin="26,0,26,16">
|
||||||
<TextBox x:Name="HostFilter" Text="{Binding HostFilter}" Height="40" CornerRadius="10"
|
<TextBox x:Name="HostFilter" Text="{Binding HostFilter, Mode=OneWay}" Height="40" CornerRadius="10"
|
||||||
FontFamily="{StaticResource MonoFont}"
|
FontFamily="{StaticResource MonoFont}"
|
||||||
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
PlaceholderText="Find a host by name, address or note… · Ctrl+K searches and connects" />
|
||||||
</Border>
|
</Border>
|
||||||
@@ -573,8 +577,9 @@
|
|||||||
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
|
SelectedItem="{Binding #Board.((vm:VaultViewModel)DataContext).SelectedSidebarRow, Mode=OneWay}"
|
||||||
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
ToolTip.Tip="Double-press a card for a shell. Ctrl or Shift picks out several, and so does a band dragged across the space between them; right-click any of them for what can be done to the set.">
|
||||||
|
|
||||||
|
<!-- Builds only the cards near the view; see VirtualizingWrapPanel. -->
|
||||||
<ListBox.ItemsPanel>
|
<ListBox.ItemsPanel>
|
||||||
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
|
<ItemsPanelTemplate><views:VirtualizingWrapPanel /></ItemsPanelTemplate>
|
||||||
</ListBox.ItemsPanel>
|
</ListBox.ItemsPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -683,7 +688,7 @@
|
|||||||
TextTrimming never fires and the name would run on past the card's own
|
TextTrimming never fires and the name would run on past the card's own
|
||||||
border instead of trimming. See the longer note this carried in v4.
|
border instead of trimming. See the longer note this carried in v4.
|
||||||
-->
|
-->
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
<TextBlock Grid.Column="0" Text="{Binding Label}" FontSize="17"
|
||||||
FontWeight="Bold" Foreground="{StaticResource Text}"
|
FontWeight="Bold" Foreground="{StaticResource Text}"
|
||||||
TextTrimming="CharacterEllipsis" />
|
TextTrimming="CharacterEllipsis" />
|
||||||
@@ -708,17 +713,27 @@
|
|||||||
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
<Ellipse Classes="dot" Classes.live="{Binding IsConnected}"
|
||||||
Width="9" Height="9" />
|
Width="9" Height="9" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<Border Grid.Column="2" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
<!--
|
||||||
|
"not synced" and its kin sit on the address line. Beside the name, where it
|
||||||
|
used to be, it took seventy of the card's pixels from the one thing a card
|
||||||
|
is read for: "bastion" drew as "bast…", and k8s-worker-a and k8s-worker-b
|
||||||
|
both drew as "k8s…". The address can give the room up — it trims, and the
|
||||||
|
tooltip and the drawer both carry it whole.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
|
||||||
|
<Border Grid.Column="1" Classes="chip warn" Padding="4,0" Margin="6,0,0,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
||||||
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
<TextBlock Text="{Binding Badge}" FontSize="9.5" />
|
||||||
</Border>
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="12.5"
|
|
||||||
Foreground="{StaticResource TextGhost}"
|
|
||||||
TextTrimming="CharacterEllipsis" />
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Which vault this host is in, and only when there is more than one to be
|
Which vault this host is in, and only when there is more than one to be
|
||||||
in. It decides who else can see the host and where an edit goes back to,
|
in. It decides who else can see the host and where an edit goes back to,
|
||||||
@@ -734,8 +749,13 @@
|
|||||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" VerticalAlignment="Bottom"
|
||||||
Margin="0,8,0,0">
|
Margin="0,8,0,0">
|
||||||
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4"
|
<!--
|
||||||
VerticalAlignment="Bottom">
|
A DockPanel rather than a horizontal StackPanel. A horizontal StackPanel
|
||||||
|
measures its children with infinite width, so the tags' WrapPanel below
|
||||||
|
never wrapped and ran past the card's edge; the last child of a DockPanel
|
||||||
|
gets the width that is actually left, and wraps in it.
|
||||||
|
-->
|
||||||
|
<DockPanel Grid.Column="0" VerticalAlignment="Bottom">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The pin badge: how many folders are pinned on this host, reading
|
The pin badge: how many folders are pinned on this host, reading
|
||||||
@@ -743,8 +763,8 @@
|
|||||||
property's own remarks. Only when there is at least one; a badge reading
|
property's own remarks. Only when there is at least one; a badge reading
|
||||||
zero would be a fact nobody asked for.
|
zero would be a fact nobody asked for.
|
||||||
-->
|
-->
|
||||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
<Border DockPanel.Dock="Left" Classes="chip" Height="19" CornerRadius="5"
|
||||||
IsVisible="{Binding HasPins}">
|
Padding="6,2" Margin="0,0,4,4" IsVisible="{Binding HasPins}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||||
@@ -767,12 +787,13 @@
|
|||||||
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
<Border Classes="chip" Height="21" CornerRadius="5" Padding="6,2"
|
||||||
Margin="0,0,4,4">
|
Margin="0,0,4,4">
|
||||||
<TextBlock Text="{Binding}" FontSize="11"
|
<TextBlock Text="{Binding}" FontSize="11"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}"
|
||||||
|
TextTrimming="CharacterEllipsis" />
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
HostRowViewModel.LastConnectedText is filled from the connection log on
|
HostRowViewModel.LastConnectedText is filled from the connection log on
|
||||||
|
|||||||
@@ -106,6 +106,48 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
Board.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
WireTheBand();
|
WireTheBand();
|
||||||
|
|
||||||
|
HostFilter.TextChanged += OnFilterTyped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>How long the find box waits for the typing to pause before the board is rebuilt.</summary>
|
||||||
|
private static readonly TimeSpan FilterPause = TimeSpan.FromMilliseconds(150);
|
||||||
|
|
||||||
|
/// <summary>The wait itself, replaceable so a test can say when a pause is over rather than sleep through one.</summary>
|
||||||
|
internal Func<TimeSpan, Task> Pause { get; set; } = static span => Task.Delay(span);
|
||||||
|
|
||||||
|
/// <summary>How many times the find box has changed, so a wait can tell it is no longer the latest.</summary>
|
||||||
|
private int typed;
|
||||||
|
|
||||||
|
/// <summary>Hands the find box's text to the vault once the typing pauses.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every change to the filter rebuilds the board's sections and every card in them, and a name typed at
|
||||||
|
/// speed used to do that once a letter. Now it is done once, with the whole of what was typed.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A wait rather than a binding delay, and on the dispatcher's own queue: the box is bound one way, so a
|
||||||
|
/// filter the vault sets itself — cleared, or restored — still lands in the box at once, and the
|
||||||
|
/// change it makes to the box comes back here as the same text, which changes nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnFilterTyped(object? sender, TextChangedEventArgs e) =>
|
||||||
|
_ = PassTheFilterOnAsync(HostFilter.Text ?? string.Empty, ++typed);
|
||||||
|
|
||||||
|
private async Task PassTheFilterOnAsync(string text, int keystroke)
|
||||||
|
{
|
||||||
|
await Pause(FilterPause).ConfigureAwait(true);
|
||||||
|
|
||||||
|
// A later letter has its own wait, and it is the one that speaks.
|
||||||
|
if (keystroke != typed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Vault is { } vault && !string.Equals(vault.HostFilter, text, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
vault.HostFilter = text;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
/// <summary>The band's own wiring, and the two keys that go with a set.</summary>
|
||||||
@@ -122,6 +164,13 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||||
Board.KeyDown += OnGridKey;
|
Board.KeyDown += OnGridKey;
|
||||||
|
|
||||||
|
// The arrows' half of the selection, which the pointer's half writes by hand in OnPointerPressed. See
|
||||||
|
// OnCardSelectionChanged for why this is not simply a two-way binding.
|
||||||
|
Board.AddHandler(SelectingItemsControl.SelectionChangedEvent, OnCardSelectionChanged);
|
||||||
|
|
||||||
|
// Tunnelled, because a section's list answers Enter itself and nothing bubbles past it.
|
||||||
|
Board.AddHandler(KeyDownEvent, OnCardEnter, RoutingStrategies.Tunnel);
|
||||||
|
|
||||||
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
// ◆ The band is the scroller's rather than any one section list's, because the space it is dragged
|
||||||
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
// out over is mostly not a list's: a WrapPanel of cards is exactly as tall as its cards, and the gaps
|
||||||
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
// between sections and below the last one belong to the stack around them. Tunnelled for the reason
|
||||||
@@ -315,6 +364,16 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An arrow with the board itself focused — which is where this screen puts the keyboard, see
|
||||||
|
// KeyboardTarget — goes into the cards: onto the selected one, or the first. Otherwise no arrow ever
|
||||||
|
// reaches a section's list, since none of them has the keyboard until a card has been clicked.
|
||||||
|
if (e.Key is Key.Up or Key.Down or Key.Left or Key.Right && ReferenceEquals(e.Source, Board))
|
||||||
|
{
|
||||||
|
e.Handled = EnterTheCards(vault);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
if (e.Key is Key.A && e.KeyModifiers.HasFlag(KeyModifiers.Control) && vault.HasHostBoardEntries)
|
||||||
{
|
{
|
||||||
var order = vault.HostBoardOrder.ToList();
|
var order = vault.HostBoardOrder.ToList();
|
||||||
@@ -347,6 +406,80 @@ internal sealed partial class HostsScreen : UserControl
|
|||||||
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
/// on the empty space means is decided on the release. See <see cref="OnBandReleased"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
|
||||||
|
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
|
||||||
|
/// while cards are ticked, where the selection is not what the set is about.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnCardEnter(object? sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.Key is not Key.Enter
|
||||||
|
|| e.KeyModifiers != KeyModifiers.None
|
||||||
|
|| Vault is not { IsChoosingHosts: false } vault
|
||||||
|
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
|
||||||
|
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
|
||||||
|
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
|
||||||
|
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
|
||||||
|
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
|
||||||
|
/// right-click handler exists to prevent.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
|
||||||
|
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
|
||||||
|
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
|
||||||
|
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (Vault is not { } vault
|
||||||
|
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|
||||||
|
|| ReferenceEquals(row, vault.SelectedHost))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vault.IsChoosingHosts)
|
||||||
|
{
|
||||||
|
vault.ClearHostChoiceCommand.Execute(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
anchor = row;
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
|
||||||
|
private bool EnterTheCards(VaultViewModel vault)
|
||||||
|
{
|
||||||
|
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
|
||||||
|
|
||||||
|
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
|
||||||
|
|
||||||
|
if (card?.DataContext is not HostRowViewModel row)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
vault.SelectedHost = row;
|
||||||
|
anchor = row;
|
||||||
|
|
||||||
|
return card.Focus(NavigationMethod.Directional);
|
||||||
|
}
|
||||||
|
|
||||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||||
{
|
{
|
||||||
EndBand();
|
EndBand();
|
||||||
|
|||||||
@@ -131,8 +131,20 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Virtualised, for an inventory of hundreds of machines: see the transfer queue's own note. -->
|
||||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="12,8">
|
<ItemsControl Grid.Row="1" ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" Margin="12,8" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
<DataTemplate x:DataType="vm:ImportRowViewModel">
|
||||||
<Border Classes="importrow" CornerRadius="8">
|
<Border Classes="importrow" CornerRadius="8">
|
||||||
@@ -161,8 +173,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -256,7 +256,8 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ============ The table ============ -->
|
<!-- ============ The table ============ -->
|
||||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
|
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
|
||||||
|
Grid.IsSharedSizeScope="True">
|
||||||
|
|
||||||
<Border Grid.Row="0" Padding="16,0" Height="44"
|
<Border Grid.Row="0" Padding="16,0" Height="44"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
@@ -275,12 +276,24 @@
|
|||||||
item this application stores carries a timestamp; see the file-level remark above and
|
item this application stores carries a timestamp; see the file-level remark above and
|
||||||
docs/design-import-gaps.md.
|
docs/design-import-gaps.md.
|
||||||
-->
|
-->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="2,2.2*,1*,1*,1.4*" Margin="0,10,16,6"
|
<!--
|
||||||
|
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
|
||||||
|
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Row="1" Margin="0,10,16,6"
|
||||||
IsVisible="{Binding HasVaultItems}">
|
IsVisible="{Binding HasVaultItems}">
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="2" />
|
||||||
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="1.1*" />
|
||||||
|
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||||
|
<ColumnDefinition Width="1.2*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||||
Margin="12,0,8,0" />
|
Margin="12,0,8,0" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||||
|
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
|
||||||
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -290,18 +303,31 @@
|
|||||||
Margin="8,0,8,10">
|
Margin="8,0,8,10">
|
||||||
<ListBox.ItemTemplate>
|
<ListBox.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||||
<Grid ColumnDefinitions="2,2.2*,1*,1*,1.4*" Height="40" Margin="6,0,10,0">
|
<Grid Height="40" Margin="6,0,10,0">
|
||||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" Margin="10,0,8,0"
|
<Grid.ColumnDefinitions>
|
||||||
VerticalAlignment="Center">
|
<ColumnDefinition Width="2" />
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" FontSize="15"
|
<ColumnDefinition Width="3*" />
|
||||||
|
<ColumnDefinition Width="1.1*" />
|
||||||
|
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||||
|
<ColumnDefinition Width="1.2*" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<!--
|
||||||
|
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||||
|
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||||
|
columns beside it.
|
||||||
|
-->
|
||||||
|
<Grid Grid.Column="1" ColumnDefinitions="Auto,*" Margin="10,0,8,0" VerticalAlignment="Center">
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource IconFont}" FontSize="15"
|
||||||
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
Text="{Binding IconGlyph}" Foreground="{StaticResource AccentText}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" Margin="0,0,10,0" />
|
||||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="12.5" FontWeight="Medium"
|
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding Name}" FontSize="12.5"
|
||||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
FontWeight="Medium" Foreground="{StaticResource Text}"
|
||||||
</StackPanel>
|
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding Name}" />
|
||||||
|
</Grid>
|
||||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
TextTrimming="CharacterEllipsis" />
|
||||||
|
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
|
||||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||||
</Border>
|
</Border>
|
||||||
|
|||||||
@@ -383,7 +383,12 @@
|
|||||||
not replayed. See MainWindow.axaml.cs.
|
not replayed. See MainWindow.axaml.cs.
|
||||||
-->
|
-->
|
||||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||||
PlaceholderText="https://dodossh.example" />
|
PlaceholderText="https://dodossh.example">
|
||||||
|
<!-- Enter signs in, as it unlocks on the unlock card. -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
@@ -395,8 +400,14 @@
|
|||||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||||
<TextBlock Classes="hint"
|
<TextBlock Classes="hint"
|
||||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
|
||||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
PasswordChar="•" />
|
||||||
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
|
||||||
|
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||||
|
|||||||
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
|
|||||||
|
|
||||||
private bool wasUnlocked;
|
private bool wasUnlocked;
|
||||||
|
|
||||||
|
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||||
|
private ShellState? closedState;
|
||||||
|
|
||||||
|
private bool wasAskingAboutHostKey;
|
||||||
|
|
||||||
public MainWindow()
|
public MainWindow()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||||
ShellState.NeedsServer => ServerUrlBox,
|
ShellState.NeedsServer => ServerUrlBox,
|
||||||
|
ShellState.NeedsEnrollment => NewPassphraseBox,
|
||||||
_ => this,
|
_ => this,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,33 +232,96 @@ internal sealed partial class MainWindow : Window
|
|||||||
size.Execute(null);
|
size.Execute(null);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
// A host-key decision before the palette, because it is drawn over the palette: Escape is CANCEL or
|
||||||
|
// CLOSE, both of which refuse — never the answer that connects. See HostKeyCard.SafeAnswer.
|
||||||
|
else if (e.Key == Key.Escape && viewModel.IsHostKeyDecisionShowing)
|
||||||
|
{
|
||||||
|
viewModel.Vault?.RejectHostKeyCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
else if (viewModel.IsSearching)
|
else if (viewModel.IsSearching)
|
||||||
{
|
{
|
||||||
Palette.HandleKey(e);
|
Palette.HandleKey(e);
|
||||||
}
|
}
|
||||||
// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
|
else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
|
||||||
// already follows one branch up. Checked after the palette rather than before it: the two states
|
|
||||||
// are mutually exclusive in practice — opening the palette does not enter settings mode and entering
|
|
||||||
// settings does not open the palette — but an Escape while both were somehow true should close the
|
|
||||||
// thing drawn on top, which is the palette.
|
|
||||||
//
|
|
||||||
// v5c: with the importer up, Escape closes only that — the same "closest thing first" rule, and the
|
|
||||||
// same one the titlebar's own back button follows by showing "Back to preferences" rather than
|
|
||||||
// "Back to application" while IsImportOpen is true.
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsImportOpen)
|
|
||||||
{
|
{
|
||||||
viewModel.CloseImportCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
else if (e.Key == Key.Escape && viewModel.IsSettingsMode)
|
|
||||||
{
|
|
||||||
viewModel.LeaveSettingsCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
base.OnKeyDown(e);
|
base.OnKeyDown(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
|
||||||
|
/// over one another. A confirmation before anything, because it is a question in front of whatever page
|
||||||
|
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
|
||||||
|
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
|
||||||
|
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
|
||||||
|
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
|
||||||
|
/// top, which is the palette.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
|
||||||
|
/// back button follows by showing "Back to preferences" rather than "Back to application" while
|
||||||
|
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
|
||||||
|
/// it goes first and settings stays.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
|
||||||
|
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (AnswerNoToTheQuestionShowing(viewModel))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var close = viewModel switch
|
||||||
|
{
|
||||||
|
{ IsImportOpen: true } => viewModel.CloseImportCommand,
|
||||||
|
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
|
||||||
|
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
|
||||||
|
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
|
||||||
|
=> vault.CloseDrawerCommand,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
close?.Execute(null);
|
||||||
|
|
||||||
|
return close is not null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
|
||||||
|
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
|
||||||
|
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
|
||||||
|
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
if (viewModel.IsConfirmingSignOut)
|
||||||
|
{
|
||||||
|
viewModel.CancelSignOutCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
|
||||||
|
{
|
||||||
|
vault.CancelDeleteCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The text-size chords, when the terminal is not the thing hearing them.
|
/// The text-size chords, when the terminal is not the thing hearing them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -300,6 +369,15 @@ internal sealed partial class MainWindow : Window
|
|||||||
Terminal.Source = viewModel.TerminalPageUrl;
|
Terminal.Source = viewModel.TerminalPageUrl;
|
||||||
|
|
||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
closedState = null;
|
||||||
|
wasAskingAboutHostKey = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
|
// The shell may have reached its card before this window was given it, and then no change would
|
||||||
|
// ever say so.
|
||||||
|
if (!viewModel.IsUnlocked)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
||||||
@@ -358,6 +436,10 @@ internal sealed partial class MainWindow : Window
|
|||||||
OnSelectedTabChanged(viewModel);
|
OnSelectedTabChanged(viewModel);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
|
||||||
|
OnHostKeyDecision(viewModel);
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -373,10 +455,77 @@ internal sealed partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||||
}
|
}
|
||||||
|
else if (!unlocked && viewModel.State != closedState)
|
||||||
|
{
|
||||||
|
FocusClosedVaultCard(viewModel);
|
||||||
|
}
|
||||||
|
|
||||||
|
closedState = unlocked ? null : viewModel.State;
|
||||||
wasUnlocked = unlocked;
|
wasUnlocked = unlocked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
|
||||||
|
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
|
||||||
|
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
|
||||||
|
/// raised for every state change, and the state does not change while they type. Posted, because the card
|
||||||
|
/// is revealed by a binding the same change has not reached yet.
|
||||||
|
/// </remarks>
|
||||||
|
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
closedState = viewModel.State;
|
||||||
|
|
||||||
|
var home = ClosedVaultKeyboardHome;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
|
||||||
|
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
|
||||||
|
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
||||||
|
/// posted, because the binding that reveals the card has not run yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
|
||||||
|
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
|
||||||
|
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private void OnHostKeyDecision(MainWindowViewModel viewModel)
|
||||||
|
{
|
||||||
|
var asking = viewModel.IsHostKeyDecisionShowing;
|
||||||
|
|
||||||
|
if (asking == wasAskingAboutHostKey)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wasAskingAboutHostKey = asking;
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asking)
|
||||||
|
{
|
||||||
|
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OnShowingSomethingElse(viewModel);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
/// Closing only. Opening also has to move the keyboard — the palette is a text box somebody is expected
|
||||||
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
/// to start typing into immediately — but the palette does that for itself when it becomes visible,
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using System.ComponentModel;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
|
||||||
|
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
|
||||||
|
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
|
||||||
|
/// to say no.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
|
||||||
|
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
|
||||||
|
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
|
||||||
|
/// destroy something without reading the sentence in between.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Each card follows its own question rather than the window following all of them, because the same card is
|
||||||
|
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
|
||||||
|
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
|
||||||
|
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
|
||||||
|
/// for good would leave one handler on the long-lived view model per visit.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal static class SafeAnswerFocus
|
||||||
|
{
|
||||||
|
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
|
||||||
|
{
|
||||||
|
INotifyPropertyChanged? watched = null;
|
||||||
|
|
||||||
|
void OnChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Dispatcher.UIThread.Post(
|
||||||
|
() =>
|
||||||
|
{
|
||||||
|
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
|
||||||
|
{
|
||||||
|
answer.Focus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
|
||||||
|
void Watch(INotifyPropertyChanged? next)
|
||||||
|
{
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged -= OnChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
watched = next;
|
||||||
|
|
||||||
|
if (watched is not null)
|
||||||
|
{
|
||||||
|
watched.PropertyChanged += OnChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
card.DetachedFromVisualTree += (_, _) => Watch(null);
|
||||||
|
card.DataContextChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
if (card.IsAttachedToVisualTree())
|
||||||
|
{
|
||||||
|
Watch(card.DataContext as INotifyPropertyChanged);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -86,7 +86,14 @@
|
|||||||
Classes.connecting="{Binding IsConnecting}"
|
Classes.connecting="{Binding IsConnecting}"
|
||||||
Classes.dropped="{Binding IsDropped}"
|
Classes.dropped="{Binding IsDropped}"
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
<!--
|
||||||
|
Bounded. A label is whatever the host was called, or the address typed into the palette,
|
||||||
|
and one long one used to push every tab after it and the "+" past the strip's edge. The
|
||||||
|
whole of it is on the label's own tooltip; the tab's is the address, as before.
|
||||||
|
-->
|
||||||
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
|
||||||
|
MaxWidth="200" TextTrimming="CharacterEllipsis"
|
||||||
|
ToolTip.Tip="{Binding Label}" />
|
||||||
|
|
||||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||||
VerticalAlignment="Center"
|
VerticalAlignment="Center"
|
||||||
|
|||||||
@@ -136,7 +136,7 @@
|
|||||||
<Border Classes="settingscard">
|
<Border Classes="settingscard">
|
||||||
<StackPanel Margin="24,20" Spacing="10">
|
<StackPanel Margin="24,20" Spacing="10">
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
|||||||
@@ -46,19 +46,21 @@
|
|||||||
<TextBlock Classes="settingstitle" Text="Security" />
|
<TextBlock Classes="settingstitle" Text="Security" />
|
||||||
|
|
||||||
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
<Border Classes="settingscard" Margin="0,26,0,0" Padding="20,20">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="16">
|
<!-- A Grid, so the caption is measured against the card's width and wraps inside it. -->
|
||||||
<Border Width="44" Height="44" CornerRadius="12" Background="#103A2F">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Border Grid.Column="0" Width="44" Height="44" CornerRadius="12" Background="#103A2F"
|
||||||
|
Margin="0,0,16,0" VerticalAlignment="Top">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="20"
|
||||||
Foreground="{StaticResource Live}"
|
Foreground="{StaticResource Live}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||||
</Border>
|
</Border>
|
||||||
<StackPanel Spacing="6" VerticalAlignment="Center">
|
<StackPanel Grid.Column="1" Spacing="6" VerticalAlignment="Center">
|
||||||
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
<TextBlock Text="End-to-end encrypted" FontSize="15" FontWeight="Bold" LetterSpacing="-0.2"
|
||||||
Foreground="{StaticResource Text}" />
|
Foreground="{StaticResource Text}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption" TextWrapping="Wrap"
|
||||||
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
Text="Every vault item is sealed on your devices. The server and the relay both store ciphertext and timestamps only — neither can read a single field." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
<TextBlock Classes="settingssection" Text="UNLOCKING" />
|
||||||
@@ -68,15 +70,15 @@
|
|||||||
<Border Classes="settingsrow last">
|
<Border Classes="settingsrow last">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||||
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
|
<TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
|
Text="{Binding DeviceUnlock.Offer}"
|
||||||
IsVisible="{Binding CanRegisterDevice}" />
|
IsVisible="{Binding CanRegisterDevice}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
|
Text="{Binding DeviceUnlock.Registered}"
|
||||||
IsVisible="{Binding CanForgetDevice}" />
|
IsVisible="{Binding CanForgetDevice}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
Text="{Binding DeviceUnlock.Unavailable}"
|
||||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
||||||
|
|||||||
@@ -81,11 +81,15 @@
|
|||||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
|
||||||
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
|
<!--
|
||||||
|
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
|
||||||
|
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
|
||||||
|
the column was a heading over nothing on every row.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
|
||||||
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
||||||
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
||||||
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
|
<TextBlock Grid.Column="2" Classes="label" Text=" " />
|
||||||
<TextBlock Grid.Column="3" Classes="label" Text=" " />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
||||||
@@ -97,7 +101,7 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TagRowViewModel">
|
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||||
<Border Classes="settingsrow last" Padding="20,10">
|
<Border Classes="settingsrow last" Padding="20,10">
|
||||||
<Grid ColumnDefinitions="1.4*,*,*,Auto">
|
<Grid ColumnDefinitions="1.4*,2*,Auto">
|
||||||
|
|
||||||
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||||
<TextBlock Text="{Binding Label}" />
|
<TextBlock Text="{Binding Label}" />
|
||||||
@@ -107,14 +111,7 @@
|
|||||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
Text="{Binding Description}" />
|
Text="{Binding Description}" />
|
||||||
|
|
||||||
<!--
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||||
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
|
|
||||||
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
|
|
||||||
so there is no second vault this column could honestly name.
|
|
||||||
-->
|
|
||||||
<TextBlock Grid.Column="2" />
|
|
||||||
|
|
||||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
|
||||||
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
||||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
||||||
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
{
|
{
|
||||||
public SettingsTitleBar() => InitializeComponent();
|
public SettingsTitleBar() => InitializeComponent();
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
||||||
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decided on the press, before the drag would take it: see CaptionPresses.
|
||||||
|
if (presses.IsSecond(e, this))
|
||||||
|
{
|
||||||
|
OnToggleMaximised(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Host?.BeginMoveDrag(e);
|
Host?.BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
||||||
|
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
|
||||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (Host is not { } window)
|
if (Host is not { } window)
|
||||||
|
|||||||
@@ -55,7 +55,7 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Views;
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||||
internal sealed partial class SignOutCard : UserControl
|
internal sealed partial class SignOutCard : UserControl
|
||||||
{
|
{
|
||||||
public SignOutCard() => InitializeComponent();
|
public SignOutCard()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
|
||||||
|
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -167,8 +167,12 @@
|
|||||||
TextAlignment="Center" MaxWidth="360"
|
TextAlignment="Center" MaxWidth="360"
|
||||||
IsVisible="{Binding !HasVisible}" />
|
IsVisible="{Binding !HasVisible}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
|
||||||
|
something broken rather than something waiting, and the pane on the right already says what to do.
|
||||||
|
-->
|
||||||
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
||||||
BorderThickness="0,1,0,0">
|
BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
||||||
IsEnabled="{Binding HasSelection}" />
|
IsEnabled="{Binding HasSelection}" />
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
PointerPressed="OnDrag">
|
||||||
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly CaptionPresses presses = new();
|
||||||
|
|
||||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decided on the press, before the drag would take it: see CaptionPresses.
|
||||||
|
if (presses.IsSecond(e, this))
|
||||||
|
{
|
||||||
|
OnToggleMaximised(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
Host?.BeginMoveDrag(e);
|
Host?.BeginMoveDrag(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
|
|||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
||||||
/// the one people reach for without thinking about it.
|
/// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
|
||||||
|
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1190,8 +1190,24 @@
|
|||||||
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
Text="{Binding Status}" IsVisible="{Binding IsConnected}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="2">
|
<!--
|
||||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
Virtualised: a folder download queues a row per file, and a stack panel inside an outer scroller
|
||||||
|
laid out and drew every one of them, thousands deep. The scroller is the control's own so the panel
|
||||||
|
knows the viewport it has to fill and realises only those rows.
|
||||||
|
-->
|
||||||
|
<ItemsControl Grid.Row="2" ItemsSource="{Binding Transfers}">
|
||||||
|
<ItemsControl.Template>
|
||||||
|
<ControlTemplate>
|
||||||
|
<ScrollViewer>
|
||||||
|
<ItemsPresenter Name="PART_ItemsPresenter" ItemsPanel="{TemplateBinding ItemsPanel}" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</ControlTemplate>
|
||||||
|
</ItemsControl.Template>
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<VirtualizingStackPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||||
<!--
|
<!--
|
||||||
@@ -1239,7 +1255,6 @@
|
|||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
<Button Grid.Row="3" Classes="ghost" Content="CLEAR FINISHED" Margin="0,10,0,0"
|
||||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}" />
|
||||||
|
|||||||
@@ -44,15 +44,15 @@
|
|||||||
IsEnabled="{Binding !IsBusy}" />
|
IsEnabled="{Binding !IsBusy}" />
|
||||||
<!--
|
<!--
|
||||||
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
||||||
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
|
key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
|
||||||
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
||||||
fallback, it is the ordinary way in.
|
fallback, it is the ordinary way in.
|
||||||
-->
|
-->
|
||||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
<Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
|
||||||
Command="{Binding UnlockWithDeviceCommand}"
|
Command="{Binding UnlockWithDeviceCommand}"
|
||||||
IsEnabled="{Binding !IsBusy}"
|
IsEnabled="{Binding !IsBusy}"
|
||||||
IsVisible="{Binding CanUnlockWithDevice}"
|
IsVisible="{Binding CanUnlockWithDevice}"
|
||||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||||
|
|||||||
@@ -0,0 +1,383 @@
|
|||||||
|
using System.Collections.Specialized;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Layout;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A wrap panel that builds only the items near the part of it on screen.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The hosts board is a list per section, each laid out in rows of cards. It was a plain
|
||||||
|
/// <see cref="WrapPanel"/>, which builds, measures and draws every card on the board whether it is in view or
|
||||||
|
/// not — some twenty-five controls a card, so a keychain of three hundred hosts took seconds to open and to
|
||||||
|
/// redraw after a filter was cleared, and a thousand took several times that. Avalonia has no virtualising
|
||||||
|
/// wrap panel of its own; this is one, for items of a fixed width.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>Widths are the first item's; heights are per row.</b> A card's width is set by its style, so the
|
||||||
|
/// number of columns is arithmetic. Its height is not quite — a card wearing many tags grows a line — so each
|
||||||
|
/// row's height is the tallest card built in it, and a row not yet built is assumed to be as tall as the last
|
||||||
|
/// one measured. When a row turns out different the rows below it move, which is the ordinary price of
|
||||||
|
/// estimating, and is only ever seen while scrolling into rows nobody has looked at yet.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>What is on screen is read from the effective viewport</b>, which is the part of this panel the
|
||||||
|
/// scroller around the whole board shows — the section's own list never scrolls, so its own scroller has
|
||||||
|
/// nothing to say. A section scrolled out of view builds nothing at all past the one card measured for size.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Everything else a list does is left to the list. Containers come from its generator and are prepared by
|
||||||
|
/// it, so selection, the card template, the context menu and the band all work on the containers that exist.
|
||||||
|
/// A container holding the keyboard is never put away while it does, so arrowing past the edge of the view
|
||||||
|
/// does not drop the focus on the floor.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class VirtualizingWrapPanel : VirtualizingPanel
|
||||||
|
{
|
||||||
|
/// <summary>How far past the visible part, above and below, items are built ahead of being scrolled to.</summary>
|
||||||
|
private const double Overscan = 240;
|
||||||
|
|
||||||
|
/// <summary>What a row is assumed to be before anything in the panel has been measured.</summary>
|
||||||
|
private const double FallbackRowHeight = 124;
|
||||||
|
|
||||||
|
private readonly Dictionary<int, Control> realized = [];
|
||||||
|
|
||||||
|
private readonly Dictionary<object, Stack<Control>> recycled = [];
|
||||||
|
|
||||||
|
/// <summary>The pool each container this panel made goes back to; items that are their own containers are not in it.</summary>
|
||||||
|
private readonly Dictionary<Control, object> poolOf = [];
|
||||||
|
|
||||||
|
private readonly Dictionary<int, double> rowHeights = [];
|
||||||
|
|
||||||
|
private Rect viewport;
|
||||||
|
|
||||||
|
private bool viewportKnown;
|
||||||
|
|
||||||
|
private Size itemSize;
|
||||||
|
|
||||||
|
private int columns = 1;
|
||||||
|
|
||||||
|
/// <summary>An item asked to be scrolled to, kept built until a measure has seen it in range.</summary>
|
||||||
|
private int? pinned;
|
||||||
|
|
||||||
|
public VirtualizingWrapPanel()
|
||||||
|
{
|
||||||
|
EffectiveViewportChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (viewportKnown && e.EffectiveViewport == viewport)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
viewport = e.EffectiveViewport;
|
||||||
|
viewportKnown = true;
|
||||||
|
InvalidateMeasure();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Size MeasureOverride(Size availableSize)
|
||||||
|
{
|
||||||
|
var items = Items;
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
RecycleAllExcept(static _ => false);
|
||||||
|
return default;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itemSize == default)
|
||||||
|
{
|
||||||
|
var first = RealizedOrNew(0);
|
||||||
|
first.Measure(Size.Infinity);
|
||||||
|
itemSize = first.DesiredSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
var width = double.IsInfinity(availableSize.Width)
|
||||||
|
? itemSize.Width * items.Count
|
||||||
|
: availableSize.Width;
|
||||||
|
|
||||||
|
var nowColumns = Math.Max(1, (int)Math.Floor(width / Math.Max(1, itemSize.Width)));
|
||||||
|
|
||||||
|
if (nowColumns != columns)
|
||||||
|
{
|
||||||
|
columns = nowColumns;
|
||||||
|
rowHeights.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tops = RowTops(items.Count);
|
||||||
|
var (firstIndex, lastIndex) = VisibleRange(tops, items.Count);
|
||||||
|
|
||||||
|
RecycleAllExcept(index =>
|
||||||
|
(index >= firstIndex && index <= lastIndex)
|
||||||
|
|| index == pinned
|
||||||
|
|| realized[index].IsKeyboardFocusWithin);
|
||||||
|
|
||||||
|
for (var index = firstIndex; index <= lastIndex; index++)
|
||||||
|
{
|
||||||
|
var container = RealizedOrNew(index);
|
||||||
|
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
|
||||||
|
|
||||||
|
var row = index / columns;
|
||||||
|
rowHeights[row] = Math.Max(rowHeights.GetValueOrDefault(row), container.DesiredSize.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pinned is { } kept && kept >= firstIndex && kept <= lastIndex)
|
||||||
|
{
|
||||||
|
pinned = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
tops = RowTops(items.Count);
|
||||||
|
|
||||||
|
return new Size(Math.Min(width, columns * itemSize.Width), tops[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Size ArrangeOverride(Size finalSize)
|
||||||
|
{
|
||||||
|
var tops = RowTops(Items.Count);
|
||||||
|
|
||||||
|
foreach (var (index, container) in realized)
|
||||||
|
{
|
||||||
|
container.Arrange(ItemRect(index, tops));
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void OnItemsChanged(IReadOnlyList<object?> items, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
base.OnItemsChanged(items, e);
|
||||||
|
|
||||||
|
// Every change is answered as a reset. The board swaps its lists in whole, so an incremental answer
|
||||||
|
// would be code for a case that does not happen; and indexes, row heights and the pinned item all
|
||||||
|
// describe a list that is no longer the one being shown.
|
||||||
|
RecycleAllExcept(static _ => false);
|
||||||
|
rowHeights.Clear();
|
||||||
|
pinned = null;
|
||||||
|
InvalidateMeasure();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Control? ScrollIntoView(int index)
|
||||||
|
{
|
||||||
|
if (index < 0 || index >= Items.Count)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = RealizedOrNew(index);
|
||||||
|
container.Measure(new Size(itemSize.Width, double.PositiveInfinity));
|
||||||
|
container.Arrange(ItemRect(index, RowTops(Items.Count)));
|
||||||
|
|
||||||
|
pinned = index;
|
||||||
|
container.BringIntoView();
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override Control? ContainerFromIndex(int index) => realized.GetValueOrDefault(index);
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override int IndexFromContainer(Control container)
|
||||||
|
{
|
||||||
|
foreach (var (index, held) in realized)
|
||||||
|
{
|
||||||
|
if (ReferenceEquals(held, container))
|
||||||
|
{
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override IEnumerable<Control>? GetRealizedContainers() => realized.Values;
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>
|
||||||
|
/// Left and right step one card, up and down one row of them — the movements the plain wrap panel's own
|
||||||
|
/// navigation offered, so the board's arrows do what they did, with the card stepped to built on demand.
|
||||||
|
/// </remarks>
|
||||||
|
protected override IInputElement? GetControl(NavigationDirection direction, IInputElement? from, bool wrap)
|
||||||
|
{
|
||||||
|
var count = Items.Count;
|
||||||
|
|
||||||
|
if (count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var current = from is Control control ? IndexFromContainer(control) : -1;
|
||||||
|
|
||||||
|
var target = direction switch
|
||||||
|
{
|
||||||
|
NavigationDirection.First => 0,
|
||||||
|
NavigationDirection.Last => count - 1,
|
||||||
|
NavigationDirection.Left or NavigationDirection.Previous => current - 1,
|
||||||
|
NavigationDirection.Right or NavigationDirection.Next => current + 1,
|
||||||
|
NavigationDirection.Up => current - columns,
|
||||||
|
NavigationDirection.Down => current + columns,
|
||||||
|
_ => -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (current < 0 && direction is not (NavigationDirection.First or NavigationDirection.Last))
|
||||||
|
{
|
||||||
|
target = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (target < 0 || target >= count)
|
||||||
|
{
|
||||||
|
if (!wrap)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
target = (target % count + count) % count;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ScrollIntoView(target);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The top of every row, and one more entry past the last row for the total height.</summary>
|
||||||
|
private double[] RowTops(int count)
|
||||||
|
{
|
||||||
|
var rows = (count + columns - 1) / columns;
|
||||||
|
var tops = new double[rows + 1];
|
||||||
|
var estimate = itemSize.Height > 0 ? itemSize.Height : FallbackRowHeight;
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
tops[row + 1] = tops[row] + rowHeights.GetValueOrDefault(row, estimate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return tops;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The first and last item in the rows that overlap the visible part, with the overscan.</summary>
|
||||||
|
private (int First, int Last) VisibleRange(double[] tops, int count)
|
||||||
|
{
|
||||||
|
// Before the first viewport arrives, a screenful from the top: the panel has to measure to something.
|
||||||
|
var top = viewportKnown ? viewport.Top - Overscan : 0;
|
||||||
|
var bottom = viewportKnown ? viewport.Bottom + Overscan : 1200;
|
||||||
|
|
||||||
|
var rows = tops.Length - 1;
|
||||||
|
var firstRow = -1;
|
||||||
|
var lastRow = -1;
|
||||||
|
|
||||||
|
for (var row = 0; row < rows; row++)
|
||||||
|
{
|
||||||
|
if (tops[row + 1] < top)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tops[row] > bottom)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstRow < 0)
|
||||||
|
{
|
||||||
|
firstRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRow = row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstRow < 0
|
||||||
|
? (0, -1)
|
||||||
|
: (firstRow * columns, Math.Min(count - 1, ((lastRow + 1) * columns) - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Rect ItemRect(int index, double[] tops)
|
||||||
|
{
|
||||||
|
var row = index / columns;
|
||||||
|
var column = index % columns;
|
||||||
|
var height = row + 1 < tops.Length ? tops[row + 1] - tops[row] : itemSize.Height;
|
||||||
|
|
||||||
|
return new Rect(column * itemSize.Width, tops[Math.Min(row, tops.Length - 1)], itemSize.Width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The container for an item, built or brought back from the pool if it is not already up.</summary>
|
||||||
|
private Control RealizedOrNew(int index)
|
||||||
|
{
|
||||||
|
if (realized.TryGetValue(index, out var existing))
|
||||||
|
{
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var generator = ItemContainerGenerator!;
|
||||||
|
var item = Items[index];
|
||||||
|
Control container;
|
||||||
|
|
||||||
|
if (generator.NeedsContainer(item, index, out var recycleKey))
|
||||||
|
{
|
||||||
|
var key = recycleKey ?? typeof(object);
|
||||||
|
|
||||||
|
if (recycled.TryGetValue(key, out var pool) && pool.TryPop(out var reused))
|
||||||
|
{
|
||||||
|
container = reused;
|
||||||
|
container.IsVisible = true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
container = generator.CreateContainer(item, index, recycleKey);
|
||||||
|
poolOf[container] = key;
|
||||||
|
AddInternalChild(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
generator.PrepareItemContainer(container, item, index);
|
||||||
|
realized[index] = container;
|
||||||
|
generator.ItemContainerPrepared(container, item, index);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// An item that is its own container: built by whoever made the item, and never pooled.
|
||||||
|
container = (Control)item!;
|
||||||
|
AddInternalChild(container);
|
||||||
|
realized[index] = container;
|
||||||
|
}
|
||||||
|
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts away every built container the predicate does not keep.</summary>
|
||||||
|
private void RecycleAllExcept(Func<int, bool> keep)
|
||||||
|
{
|
||||||
|
foreach (var index in realized.Keys.ToList())
|
||||||
|
{
|
||||||
|
if (keep(index))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = realized[index];
|
||||||
|
realized.Remove(index);
|
||||||
|
|
||||||
|
if (poolOf.TryGetValue(container, out var key))
|
||||||
|
{
|
||||||
|
ItemContainerGenerator!.ClearItemContainer(container);
|
||||||
|
container.IsVisible = false;
|
||||||
|
|
||||||
|
if (!recycled.TryGetValue(key, out var pool))
|
||||||
|
{
|
||||||
|
recycled[key] = pool = new Stack<Control>();
|
||||||
|
}
|
||||||
|
|
||||||
|
pool.Push(container);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RemoveInternalChild(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -283,6 +283,9 @@ internal sealed class S3FileStore : IRemoteFileStore
|
|||||||
/// have cost.
|
/// have cost.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
|
/// <inheritdoc />
|
||||||
|
public bool CanWriteFromAnOffset => false;
|
||||||
|
|
||||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(path);
|
ArgumentNullException.ThrowIfNull(path);
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||||
|
|
||||||
|
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
|
||||||
|
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
|
||||||
|
|
||||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IConnectionRoute
|
|||||||
RelayProtocol.DurationLimitCloseCode =>
|
RelayProtocol.DurationLimitCloseCode =>
|
||||||
"The server relay closed the connection: it reached the longest session the server allows.",
|
"The server relay closed the connection: it reached the longest session the server allows.",
|
||||||
|
|
||||||
|
RelayProtocol.AccessRevokedCloseCode =>
|
||||||
|
"The server relay closed the connection: you no longer have access to this host's vault.",
|
||||||
|
|
||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -145,6 +145,26 @@ public sealed class VaultKnownHostStore : IKnownHostStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
/// <remarks>From the snapshot, like <see cref="FindAsync"/>, and for the same reason.</remarks>
|
||||||
|
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||||
|
.Where(pin =>
|
||||||
|
KnownHostIdentity.Comparer.Equals(pin.Secret.Host, host) && pin.Secret.Port == port)
|
||||||
|
.Select(pin => new HostKeyPresentation(
|
||||||
|
pin.Secret.Host, pin.Secret.Port, pin.Secret.Algorithm, pin.Secret.Fingerprint))
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// The vault is not open, so there is nowhere to record trust.
|
/// The vault is not open, so there is nowhere to record trust.
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// What unlocking with this machine's device key is called here, and what it asks the person for.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The words used to be Windows's everywhere: "USE WINDOWS HELLO" on a Mac, "Waiting for Windows…" over a
|
||||||
|
/// Linux PIN prompt, and a settings row that told a MacBook it needed a TPM. Each desktop has its own device
|
||||||
|
/// key store with its own gesture — see <c>WindowsDeviceKeyStore</c>, <c>MacDeviceKeyStore</c> and
|
||||||
|
/// <c>LinuxDeviceKeyStore</c> — and a button has to name the one the person is about to be shown, or it reads
|
||||||
|
/// as a feature for some other machine.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// One record rather than a property per sentence on the shell, so that the five places these words appear
|
||||||
|
/// cannot disagree about which platform they are on.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Name">What the feature is called, as a settings row's title.</param>
|
||||||
|
/// <param name="Button">The unlock card's button, in the capitals every button here uses.</param>
|
||||||
|
/// <param name="Wait">The status line while the platform's prompt is up.</param>
|
||||||
|
/// <param name="Gesture">What opens the keychain instead of the passphrase, to finish a sentence.</param>
|
||||||
|
/// <param name="Keeper">What holds the key, to finish "Registered · …".</param>
|
||||||
|
/// <param name="Needs">What a machine with no device key store lacks, as a sentence of its own.</param>
|
||||||
|
internal sealed record DeviceUnlockWording(
|
||||||
|
string Name,
|
||||||
|
string Button,
|
||||||
|
string Wait,
|
||||||
|
string Gesture,
|
||||||
|
string Keeper,
|
||||||
|
string Needs)
|
||||||
|
{
|
||||||
|
internal static DeviceUnlockWording Windows { get; } = new(
|
||||||
|
"Windows Hello on this machine",
|
||||||
|
"USE WINDOWS HELLO",
|
||||||
|
"Waiting for Windows…",
|
||||||
|
"a Windows confirmation",
|
||||||
|
"the TPM holds a device key",
|
||||||
|
"That needs a TPM and a Windows keystore willing to release the key.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Mac { get; } = new(
|
||||||
|
"Touch ID on this Mac",
|
||||||
|
"USE TOUCH ID",
|
||||||
|
"Waiting for Touch ID…",
|
||||||
|
"Touch ID or your login password",
|
||||||
|
"the Secure Enclave holds a device key",
|
||||||
|
"That needs a Mac with a Secure Enclave and a login password set.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Linux { get; } = new(
|
||||||
|
"A device PIN on this machine",
|
||||||
|
"USE DEVICE PIN",
|
||||||
|
"Waiting for your PIN…",
|
||||||
|
"a PIN the TPM checks",
|
||||||
|
"the TPM holds a device key",
|
||||||
|
"That needs a TPM 2.0 this account is allowed to use.");
|
||||||
|
|
||||||
|
internal static DeviceUnlockWording Android { get; } = new(
|
||||||
|
"Fingerprint unlock on this phone",
|
||||||
|
"USE FINGERPRINT",
|
||||||
|
"Waiting for your fingerprint…",
|
||||||
|
"your fingerprint",
|
||||||
|
"the phone's keystore holds a device key",
|
||||||
|
"That needs a screen lock and a keystore willing to release the key.");
|
||||||
|
|
||||||
|
/// <summary>The wording for the platform this process is running on.</summary>
|
||||||
|
internal static DeviceUnlockWording ForThisPlatform { get; } =
|
||||||
|
OperatingSystem.IsAndroid() ? Android
|
||||||
|
: OperatingSystem.IsMacOS() ? Mac
|
||||||
|
: OperatingSystem.IsLinux() ? Linux
|
||||||
|
: Windows;
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that can register.</summary>
|
||||||
|
internal string Offer =>
|
||||||
|
$"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that has.</summary>
|
||||||
|
internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
|
||||||
|
|
||||||
|
/// <summary>The settings caption for a machine that cannot.</summary>
|
||||||
|
internal string Unavailable =>
|
||||||
|
$"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
|
||||||
|
|
||||||
|
/// <summary>The unlock button's tooltip.</summary>
|
||||||
|
internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
|
||||||
|
}
|
||||||
@@ -566,7 +566,7 @@ internal sealed partial class HetznerImportViewModel(
|
|||||||
// and visible rather than harmless and confusing.
|
// and visible rather than harmless and confusing.
|
||||||
Refresh(resetSelection: true);
|
Refresh(resetSelection: true);
|
||||||
|
|
||||||
Status = WhatWasStored(outcome.Hosts);
|
Status = WhatWasStored(outcome.Hosts) + outcome.Caveats;
|
||||||
|
|
||||||
Step = HetznerImportStep.Done;
|
Step = HetznerImportStep.Done;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,10 +22,27 @@ namespace DodoSSH.Client.Shell.ViewModels;
|
|||||||
/// </param>
|
/// </param>
|
||||||
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
||||||
|
|
||||||
/// <summary>What an import stored.</summary>
|
/// <summary>What an import stored, and what it did not.</summary>
|
||||||
/// <param name="Hosts">Hosts written.</param>
|
/// <param name="Hosts">Hosts written.</param>
|
||||||
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
||||||
internal sealed record ImportOutcome(int Hosts, int Keys);
|
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
||||||
|
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
||||||
|
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
||||||
|
{
|
||||||
|
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
||||||
|
internal string Caveats =>
|
||||||
|
(Refused switch
|
||||||
|
{
|
||||||
|
0 => string.Empty,
|
||||||
|
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
||||||
|
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
||||||
|
})
|
||||||
|
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -409,7 +426,7 @@ internal sealed partial class ImportViewModel(
|
|||||||
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
||||||
};
|
};
|
||||||
|
|
||||||
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}";
|
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -1795,6 +1795,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ActiveSettingsPage = null;
|
ActiveSettingsPage = null;
|
||||||
IsImportOpen = false;
|
IsImportOpen = false;
|
||||||
IsHetznerImportOpen = false;
|
IsHetznerImportOpen = false;
|
||||||
|
|
||||||
|
// Drawn over Settings → Vaults. Left open, it was still up the next time Settings opened, over
|
||||||
|
// whichever page that was.
|
||||||
|
vaults.IsMembersPanelOpen = false;
|
||||||
Screen = settingsReturnScreen;
|
Screen = settingsReturnScreen;
|
||||||
Surface = settingsReturnSurface;
|
Surface = settingsReturnSurface;
|
||||||
}
|
}
|
||||||
@@ -2129,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
try
|
||||||
.ConfigureAwait(true);
|
{
|
||||||
|
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
|
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
|
||||||
// second means the menu and the screen behind it never disagree, even for a frame.
|
// it second means the menu and the screen behind it never disagree, even for a frame.
|
||||||
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// A command's exception is rethrown on the UI thread, where nothing catches it — so a cache that
|
||||||
|
// could not be written to, or a vault locked halfway through, used to close the application. The
|
||||||
|
// switches are redrawn from what is actually stored, so the menu does not claim the change.
|
||||||
|
RebuildVaultToggles();
|
||||||
|
StatusMessage = $"Could not change which vaults are shown: {exception.Message}";
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
RebuildVaultToggles();
|
RebuildVaultToggles();
|
||||||
|
|
||||||
@@ -2815,8 +2832,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
|
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
|
||||||
/// deciding whether the prompt in front of them is the one this application asked for.
|
/// deciding whether the prompt in front of them is the one this application asked for.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string GestureWait =>
|
private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
|
||||||
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
|
|
||||||
|
/// <summary>What unlocking with the device key is called on this platform; the views read it whole.</summary>
|
||||||
|
internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
|
||||||
|
|
||||||
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2944,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// ◆ Which lock this unlock started under. Every await below is a moment the user can press Lock, and
|
||||||
|
// LockAsync can only close what has been handed over by then — so an unlock still attaching its
|
||||||
|
// stores used to finish after the lock and open the vault behind the lock screen.
|
||||||
|
var generation = locks;
|
||||||
|
|
||||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
|
||||||
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
// to "everything shown", because a vault somebody set aside last week should still be set aside.
|
||||||
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Vault = new VaultViewModel(
|
if (generation != locks)
|
||||||
|
{
|
||||||
|
await AbandonAsync(session).ConfigureAwait(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var opened = Vault = new VaultViewModel(
|
||||||
session,
|
session,
|
||||||
workspace,
|
workspace,
|
||||||
knownHosts,
|
knownHosts,
|
||||||
@@ -2974,9 +3004,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// are both !CanRegisterDevice, and only the second has anything to withdraw.
|
// are both !CanRegisterDevice, and only the second has anything to withdraw.
|
||||||
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
|
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
|
||||||
|
|
||||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
await OpenAsync(opened, generation, cancellationToken).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
|
||||||
AfterTheFirstLoad(Vault);
|
/// <summary>Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// From here the vault belongs to the shell, so a lock closes it itself: there is nothing to undo, only
|
||||||
|
/// nothing more to do to a vault that is gone. Before these checks, a lock pressed while the first load
|
||||||
|
/// ran left this to carry on into a disposed session and a vault the shell no longer held.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task OpenAsync(VaultViewModel opened, int generation, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (generation != locks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await opened.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (generation != locks)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
AfterTheFirstLoad(opened);
|
||||||
|
|
||||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||||
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
|
||||||
@@ -2991,7 +3042,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
// and unlocking must never wait on one. Everything the unlock screen promises about working
|
||||||
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
|
||||||
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
// the titlebar says OFFLINE until the round trip this starts has an answer.
|
||||||
Vault.StartAutoSync();
|
opened.StartAutoSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Puts away an unlock that a lock overtook before the vault was handed over.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Everything <see cref="LockAsync"/> would have closed had it been here to close: the stores attached to
|
||||||
|
/// this session, the switches read from it, and the session itself, whose keys nothing else now holds.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task AbandonAsync(VaultSession session)
|
||||||
|
{
|
||||||
|
knownHosts.Close();
|
||||||
|
connectionLog.Close();
|
||||||
|
visibility = null;
|
||||||
|
|
||||||
|
await session.DisposeAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
|
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
|
||||||
@@ -3253,6 +3318,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task LockAsync()
|
private async Task LockAsync()
|
||||||
{
|
{
|
||||||
|
// Counted first, so an unlock still on its way in sees it at its next step and stands down rather than
|
||||||
|
// opening the vault after this has run. See AdoptAsync.
|
||||||
|
locks++;
|
||||||
|
|
||||||
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
|
||||||
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
|
||||||
// reappearing behind a lock screen.
|
// reappearing behind a lock screen.
|
||||||
@@ -3300,6 +3369,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private bool isConfirmingSignOut;
|
private bool isConfirmingSignOut;
|
||||||
|
|
||||||
|
/// <summary>How many times this shell has been locked, for an unlock to notice it was overtaken.</summary>
|
||||||
|
private int locks;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// What signing out costs, on this machine, right now.
|
/// What signing out costs, on this machine, right now.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4512,7 +4584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
{
|
{
|
||||||
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
|
||||||
{
|
{
|
||||||
tab.Ended(e.Outcome.End, e.Outcome.Description);
|
tab.Ended(e.Outcome.End, e.Outcome.Description, clock.GetUtcNow());
|
||||||
|
|
||||||
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
// Only for the tab being looked at, which is what these three already ask. The rectangle does not
|
||||||
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
// change — a dead tab keeps its pane and its scrollback — but the status bar under it does, and
|
||||||
@@ -4992,7 +5064,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var elapsed = clock.GetUtcNow() - started;
|
// Up to the end, for a shell that has one: how long it ran, not how long ago it started.
|
||||||
|
var until = Surface is ShellSurface.Terminal && SelectedTab?.EndedAt is { } ended
|
||||||
|
? ended
|
||||||
|
: clock.GetUtcNow();
|
||||||
|
|
||||||
|
var elapsed = until - started;
|
||||||
|
|
||||||
if (elapsed < TimeSpan.Zero)
|
if (elapsed < TimeSpan.Zero)
|
||||||
{
|
{
|
||||||
@@ -5107,6 +5184,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
Screen = ShellScreen.Hosts;
|
Screen = ShellScreen.Hosts;
|
||||||
Surface = ShellSurface.Page;
|
Surface = ShellSurface.Page;
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Settings is a mode of the unlocked window, and it brings its own title bar while the main one
|
||||||
|
// is hidden. Locking or signing out from it — LOCK NOW is on Preferences, sign-out on Account —
|
||||||
|
// left the window in settings mode under the lock card: no title bar, so nothing to drag, minimise
|
||||||
|
// or close, and an unlock that came back to Settings rather than to Hosts.
|
||||||
|
LeaveSettings();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Collections.Specialized;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// An observable list whose whole contents can be swapped and announced once.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// For the lists rebuilt wholesale from the vault. They used to be emptied with <c>Clear</c> and refilled an
|
||||||
|
/// <c>Add</c> at a time, which announced every row separately — and two other view models listen to the host
|
||||||
|
/// list and redo their own work on every announcement: the transfers screen refilled its host picker, and the
|
||||||
|
/// shell walked every host against every tab. A reload of n hosts was n of each, so roughly n² rows touched;
|
||||||
|
/// three hundred hosts came to some ninety thousand notifications per reload.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// One <see cref="NotifyCollectionChangedAction.Reset"/> instead, which is also exactly what <c>Clear</c> has
|
||||||
|
/// always said first, so a listener that coped with the old sequence copes with this. Nothing listening to
|
||||||
|
/// these lists reads the items an announcement carries; they all re-read the list.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed class ReplaceableCollection<T> : ObservableCollection<T>
|
||||||
|
{
|
||||||
|
/// <summary>Makes <paramref name="items"/> the whole of the list, and says so once.</summary>
|
||||||
|
internal void ReplaceAll(IEnumerable<T> items)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
||||||
|
CheckReentrancy();
|
||||||
|
|
||||||
|
Items.Clear();
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
Items.Add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||||
|
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||||
|
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal DateTimeOffset? StartedAt { get; set; }
|
internal DateTimeOffset? StartedAt { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When the session behind this tab ended, or null while it has not.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
|
||||||
|
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
|
||||||
|
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
|
||||||
|
/// the shell's clock, and cleared whenever a new session takes the tab.
|
||||||
|
/// </remarks>
|
||||||
|
internal DateTimeOffset? EndedAt { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
// stale-fact failure this pair exists to remove.
|
// stale-fact failure this pair exists to remove.
|
||||||
EndReason = null;
|
EndReason = null;
|
||||||
EndDescription = null;
|
EndDescription = null;
|
||||||
|
EndedAt = null;
|
||||||
|
|
||||||
IsLive = true;
|
IsLive = true;
|
||||||
State = TerminalTabState.Open;
|
State = TerminalTabState.Open;
|
||||||
@@ -679,13 +689,14 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal void Ended(SshSessionEnd end, string description)
|
internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
|
||||||
{
|
{
|
||||||
if (State is not TerminalTabState.Open)
|
if (State is not TerminalTabState.Open)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EndedAt ??= at;
|
||||||
EndReason = end;
|
EndReason = end;
|
||||||
EndDescription = description;
|
EndDescription = description;
|
||||||
IsLive = false;
|
IsLive = false;
|
||||||
@@ -733,6 +744,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
||||||
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
||||||
StartedAt = null;
|
StartedAt = null;
|
||||||
|
EndedAt = null;
|
||||||
Cipher = null;
|
Cipher = null;
|
||||||
HostKeyAlgorithm = null;
|
HostKeyAlgorithm = null;
|
||||||
IdentityLabel = null;
|
IdentityLabel = null;
|
||||||
|
|||||||
@@ -867,18 +867,35 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
||||||
/// be a second decrypted copy of a secret for no gain.
|
/// be a second decrypted copy of a secret for no gain.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private HostRowViewModel? selectedHost;
|
private HostRowViewModel? selectedHost;
|
||||||
|
|
||||||
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
||||||
/// <inheritdoc cref="Hosts" path="/remarks" />
|
/// <inheritdoc cref="Hosts" path="/remarks" />
|
||||||
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
internal ReplaceableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private ObjectStoreRowViewModel? selectedBucket;
|
private ObjectStoreRowViewModel? selectedBucket;
|
||||||
|
|
||||||
|
/// <summary>The host and bucket somebody chose, as opposed to whatever the pickers show this instant.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <see cref="RefreshHosts"/> re-finds the selection by id, and it runs on every change to the vault's
|
||||||
|
/// lists — including the <c>Clear()</c> a reload starts with, at which point there is nothing to re-find
|
||||||
|
/// and the picker's own selection becomes null. Re-finding from that null on the next <c>Add</c> moved the
|
||||||
|
/// picker to the first host on every sync pass, a minute after somebody chose another one, and CONNECT then
|
||||||
|
/// dialled the first host with the password typed for theirs. So the choice is remembered here, written
|
||||||
|
/// only when a person changes the picker, and is what every refresh looks for.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? chosenHost;
|
||||||
|
|
||||||
|
/// <inheritdoc cref="chosenHost" path="/remarks" />
|
||||||
|
private Guid? chosenBucket;
|
||||||
|
|
||||||
|
/// <summary>Whether a selection change is <see cref="RefreshHosts"/> putting things back, not a choice.</summary>
|
||||||
|
private bool refreshingPickers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Whether there is a bucket to open at all.
|
/// Whether there is a bucket to open at all.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -969,6 +986,17 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string typedPassword = string.Empty;
|
private string typedPassword = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>The host showing when <see cref="TypedPassword"/> was typed, which is the only one it is for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The picker can move without anybody touching it — a host deleted elsewhere falls back to the first —
|
||||||
|
/// and the box does not empty when it does, because it cannot tell that from a reload refilling the list.
|
||||||
|
/// So the password is checked against this at the moment it would be sent instead, and never goes to a
|
||||||
|
/// host it was not typed for.
|
||||||
|
/// </remarks>
|
||||||
|
private Guid? typedPasswordFor;
|
||||||
|
|
||||||
|
partial void OnTypedPasswordChanged(string value) => typedPasswordFor = SelectedHost?.EntityId;
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
/// It opens saying what the state is rather than what to do about it, and that is a v3 change the
|
||||||
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
|
/// desktop forced. "Choose a host and connect to browse its files" was this line for two versions, and
|
||||||
@@ -1780,8 +1808,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var password = typedPasswordFor == row.EntityId ? TypedPassword : string.Empty;
|
||||||
|
|
||||||
if (!open.TryBuildConnectionRequest(
|
if (!open.TryBuildConnectionRequest(
|
||||||
row.Host, TypedPassword, out var request, out var identityLabel, out var refusal))
|
row.Host, password, out var request, out var identityLabel, out var refusal))
|
||||||
{
|
{
|
||||||
Status = refusal;
|
Status = refusal;
|
||||||
return;
|
return;
|
||||||
@@ -2259,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
// ---- Moving files ----
|
// ---- Moving files ----
|
||||||
|
|
||||||
|
/// <summary>What a transfer asked for while another command is still running is told.</summary>
|
||||||
|
private const string StillBusy =
|
||||||
|
"Still working on the last request. Nothing was queued — try again when it has finished.";
|
||||||
|
|
||||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void Download()
|
private void Download()
|
||||||
@@ -2276,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
|
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
ClearRemoteChecks();
|
ClearRemoteChecks();
|
||||||
});
|
},
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||||
@@ -2298,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
|
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
|
||||||
|
|
||||||
ClearLocalChecks();
|
ClearLocalChecks();
|
||||||
});
|
},
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Unticks everything on the host's side.</summary>
|
/// <summary>Unticks everything on the host's side.</summary>
|
||||||
@@ -2363,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
internal void QueueUploads(IReadOnlyList<string> paths) =>
|
internal void QueueUploads(IReadOnlyList<string> paths) =>
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to upload…",
|
"Working out what to upload…",
|
||||||
() => QueueUploadsAsync(paths, CancellationToken.None));
|
() => QueueUploadsAsync(paths, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
|
|
||||||
/// <inheritdoc cref="QueueUploads" />
|
/// <inheritdoc cref="QueueUploads" />
|
||||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
|
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to download…",
|
"Working out what to download…",
|
||||||
() => QueueDownloadsAsync(rows, CancellationToken.None));
|
() => QueueDownloadsAsync(rows, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
|
|
||||||
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
internal async Task QueueUploadsAsync(IReadOnlyList<string> paths, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -2550,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
_ = RunAsync(
|
_ = RunAsync(
|
||||||
"Working out what to upload…",
|
"Working out what to upload…",
|
||||||
() => QueueUploadsAsync(paths, CancellationToken.None));
|
() => QueueUploadsAsync(paths, CancellationToken.None),
|
||||||
|
StillBusy);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2677,9 +2716,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
|
if (LocalDirectory.ChildPath(LocalPath, row.Name) is not { } local)
|
||||||
|
{
|
||||||
|
plan.Unsafe++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (row.IsFile)
|
if (row.IsFile)
|
||||||
{
|
{
|
||||||
plan.Downloads.Add((Path.Combine(LocalPath, row.Name), row.Entry));
|
plan.Downloads.Add((local, row.Entry));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2695,11 +2740,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
await WalkRemoteAsync(
|
await WalkRemoteAsync(
|
||||||
row.FullPath,
|
row.FullPath,
|
||||||
Path.Combine(LocalPath, row.Name),
|
local,
|
||||||
plan,
|
plan,
|
||||||
cancellationToken).ConfigureAwait(true);
|
cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EnqueueDownloads(plan);
|
||||||
|
|
||||||
|
Status = Describe(plan, "download into", LocalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes the directories a download plan needs here, then hands its files to the queue.</summary>
|
||||||
|
private void EnqueueDownloads(TransferPlan plan)
|
||||||
|
{
|
||||||
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
foreach (var directory in plan.LocalDirectories.OrderBy(each => each.Length))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -2719,8 +2772,6 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
|
|
||||||
plan.Queued++;
|
plan.Queued++;
|
||||||
}
|
}
|
||||||
|
|
||||||
Status = Describe(plan, "download into", LocalPath);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
/// <summary>Adds every file under a remote directory to the plan, with where each will land here.</summary>
|
||||||
@@ -2774,30 +2825,48 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (entry.Kind)
|
PlanRemoteEntry(plan, pending, entry, toDirectory);
|
||||||
{
|
|
||||||
case SftpEntryKind.File:
|
|
||||||
plan.Downloads.Add((Path.Combine(toDirectory, entry.Name), entry));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SftpEntryKind.Directory:
|
|
||||||
pending.Enqueue((entry.FullPath, Path.Combine(toDirectory, entry.Name)));
|
|
||||||
break;
|
|
||||||
|
|
||||||
case SftpEntryKind.SymbolicLink:
|
|
||||||
plan.Links++;
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to
|
|
||||||
// create, and counting it as a refusal would overstate what went wrong.
|
|
||||||
plan.Skipped++;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Adds one entry of a remote listing to a download plan.</summary>
|
||||||
|
private static void PlanRemoteEntry(
|
||||||
|
TransferPlan plan,
|
||||||
|
Queue<(string Remote, string Local)> pending,
|
||||||
|
SftpEntry entry,
|
||||||
|
string toDirectory)
|
||||||
|
{
|
||||||
|
var here = entry.Kind is SftpEntryKind.File or SftpEntryKind.Directory
|
||||||
|
? LocalDirectory.ChildPath(toDirectory, entry.Name)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
switch (entry.Kind)
|
||||||
|
{
|
||||||
|
case SftpEntryKind.File or SftpEntryKind.Directory when here is null:
|
||||||
|
plan.Unsafe++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.File:
|
||||||
|
plan.Downloads.Add((here!, entry));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.Directory:
|
||||||
|
pending.Enqueue((entry.FullPath, here!));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SftpEntryKind.SymbolicLink:
|
||||||
|
plan.Links++;
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// A socket, a device, a pipe. There is nothing to copy and no local equivalent to create, and
|
||||||
|
// counting it as a refusal would overstate what went wrong.
|
||||||
|
plan.Skipped++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
/// <summary>What an upload of this local file should carry to the host with it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -2893,6 +2962,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
/// <summary>Sockets, devices and pipes, which have nothing to copy.</summary>
|
||||||
public int Skipped { get; set; }
|
public int Skipped { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Remote names that would land outside the directory they were meant for.</summary>
|
||||||
|
/// <remarks>See <see cref="LocalDirectory.ChildPath"/>.</remarks>
|
||||||
|
public int Unsafe { get; set; }
|
||||||
|
|
||||||
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
/// <summary>Whether the walk stopped at <see cref="Ceiling"/> rather than at the end of the tree.</summary>
|
||||||
public bool Truncated { get; set; }
|
public bool Truncated { get; set; }
|
||||||
|
|
||||||
@@ -2937,6 +3010,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
said += $" {other} not a file.";
|
said += $" {other} not a file.";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (plan.Unsafe > 0)
|
||||||
|
{
|
||||||
|
var named = plan.Unsafe == 1 ? "1 name points outside the folder and was" : $"{plan.Unsafe} names point outside the folder and were";
|
||||||
|
said += $" {named} left on the host.";
|
||||||
|
}
|
||||||
|
|
||||||
if (plan.Truncated)
|
if (plan.Truncated)
|
||||||
{
|
{
|
||||||
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
said += " The walk stopped at 5000 files; queue the rest a folder at a time.";
|
||||||
@@ -3162,13 +3241,18 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
$"Fetching {row.Name}…",
|
$"Fetching {row.Name}…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
var copy = Path.Combine(
|
var folder = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
"dodossh-edits",
|
"dodossh-edits",
|
||||||
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture),
|
Guid.CreateVersion7().ToString("N", CultureInfo.InvariantCulture));
|
||||||
row.Name);
|
|
||||||
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(copy)!);
|
if (LocalDirectory.ChildPath(folder, row.Name) is not { } copy)
|
||||||
|
{
|
||||||
|
Status = $"{row.Name} is not a name this machine can open as a file.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Directory.CreateDirectory(folder);
|
||||||
|
|
||||||
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
await FetchAsync(row.FullPath, copy, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
@@ -3902,7 +3986,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshHosts();
|
/// <remarks>
|
||||||
|
/// Only the picker the list feeds. Refilling both on either list's change made every reload refill the host
|
||||||
|
/// picker once for the hosts and again for the buckets.
|
||||||
|
/// </remarks>
|
||||||
|
private void OnVaultListChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (vault is { } open && ReferenceEquals(sender, open.ObjectStores))
|
||||||
|
{
|
||||||
|
RefreshPickers(hosts: false, buckets: true);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
RefreshPickers(hosts: true, buckets: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuilds both pickers from the vault's lists.
|
/// Rebuilds both pickers from the vault's lists.
|
||||||
@@ -3920,33 +4018,39 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// connected to.
|
/// connected to.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RefreshHosts()
|
private void RefreshHosts() => RefreshPickers(hosts: true, buckets: true);
|
||||||
|
|
||||||
|
private void RefreshPickers(bool hosts, bool buckets)
|
||||||
{
|
{
|
||||||
var host = SelectedHost?.EntityId;
|
refreshingPickers = true;
|
||||||
var bucket = SelectedBucket?.EntityId;
|
|
||||||
|
|
||||||
Hosts.Clear();
|
try
|
||||||
|
|
||||||
if (vault is not { } open)
|
|
||||||
{
|
{
|
||||||
return;
|
if (vault is not { } open)
|
||||||
}
|
{
|
||||||
|
Hosts.ReplaceAll([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var row in open.Hosts)
|
if (hosts)
|
||||||
|
{
|
||||||
|
Hosts.ReplaceAll(open.Hosts);
|
||||||
|
|
||||||
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == chosenHost) ?? Hosts.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buckets)
|
||||||
|
{
|
||||||
|
Buckets.ReplaceAll(open.ObjectStores);
|
||||||
|
|
||||||
|
SelectedBucket =
|
||||||
|
Buckets.FirstOrDefault(row => row.EntityId == chosenBucket) ?? Buckets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
Hosts.Add(row);
|
refreshingPickers = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == host) ?? Hosts.FirstOrDefault();
|
|
||||||
|
|
||||||
Buckets.Clear();
|
|
||||||
|
|
||||||
foreach (var row in open.ObjectStores)
|
|
||||||
{
|
|
||||||
Buckets.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedBucket = Buckets.FirstOrDefault(row => row.EntityId == bucket) ?? Buckets.FirstOrDefault();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||||
@@ -4073,11 +4177,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||||
/// pane permanently disabled.
|
/// pane permanently disabled.
|
||||||
|
/// <para>
|
||||||
|
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
|
||||||
|
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
|
||||||
|
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
|
||||||
{
|
{
|
||||||
if (IsBusy)
|
if (IsBusy)
|
||||||
{
|
{
|
||||||
|
if (refusedWhileBusy is not null)
|
||||||
|
{
|
||||||
|
Status = refusedWhileBusy;
|
||||||
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4106,8 +4220,30 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
|||||||
await SettleRemoteListingAsync().ConfigureAwait(true);
|
await SettleRemoteListingAsync().ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
partial void OnSelectedHostChanged(HostRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (!refreshingPickers)
|
||||||
|
{
|
||||||
|
chosenHost = value?.EntityId;
|
||||||
|
|
||||||
|
// A person moving to another host gets an empty box rather than one still showing a password
|
||||||
|
// that, by typedPasswordFor, would no longer be sent.
|
||||||
|
if (value is not null && typedPasswordFor is not null && value.EntityId != typedPasswordFor)
|
||||||
|
{
|
||||||
|
TypedPassword = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnSelectedBucketChanged(ObjectStoreRowViewModel? value)
|
||||||
|
{
|
||||||
|
if (!refreshingPickers)
|
||||||
|
{
|
||||||
|
chosenBucket = value?.EntityId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||||
|
|||||||
@@ -1663,7 +1663,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// host key list checks itself against, so a filter applied here would change what the application can
|
/// host key list checks itself against, so a filter applied here would change what the application can
|
||||||
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||||
|
|
||||||
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1677,7 +1677,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
internal ReplaceableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||||||
|
|
||||||
/// <summary>Whether the grid has anything to draw.</summary>
|
/// <summary>Whether the grid has anything to draw.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1891,7 +1891,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// groupless keychain is one flat grid and nothing above it.
|
/// groupless keychain is one flat grid and nothing above it.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
|
internal ReplaceableCollection<HostSectionViewModel> HostSections { get; } = [];
|
||||||
|
|
||||||
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
|
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2003,7 +2003,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
||||||
|
|
||||||
/// <summary>The buckets to show, unpushed local state included.</summary>
|
/// <summary>The buckets to show, unpushed local state included.</summary>
|
||||||
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
internal ReplaceableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
||||||
|
|
||||||
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
internal bool HasVaultItems => VaultItems.Count > 0;
|
internal bool HasVaultItems => VaultItems.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read off the rows rather than off how many vaults there are, because a row's badge is what the column
|
||||||
|
/// holds: the two agree today — see <see cref="VaultItemRowViewModel.VaultBadge"/> — and if they ever stop
|
||||||
|
/// agreeing, a heading over nothing is the thing this exists to avoid.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
|
||||||
|
|
||||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -4706,18 +4714,14 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
|
var (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Hosts.Clear();
|
|
||||||
|
|
||||||
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
||||||
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
||||||
// apart, which is why the row carries the name rather than the list deduplicating.
|
// apart, which is why the row carries the name rather than the list deduplicating. Swapped in whole,
|
||||||
foreach (var host in rows
|
// and announced once: see ReplaceableCollection for what a row at a time cost.
|
||||||
|
Hosts.ReplaceAll(rows
|
||||||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||||||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||||||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
.ThenBy(row => row.Label, StringComparer.CurrentCulture));
|
||||||
{
|
|
||||||
Hosts.Add(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedHost = SelectionAfterReload(selectedId);
|
SelectedHost = SelectionAfterReload(selectedId);
|
||||||
ApplyTheChosenHosts();
|
ApplyTheChosenHosts();
|
||||||
@@ -4808,13 +4812,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
var selectedId = SelectedObjectStore?.EntityId;
|
var selectedId = SelectedObjectStore?.EntityId;
|
||||||
|
|
||||||
ObjectStores.Clear();
|
ObjectStores.ReplaceAll(listing.Items
|
||||||
|
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)
|
||||||
foreach (var store in listing.Items
|
.Select(store => new ObjectStoreRowViewModel(store)));
|
||||||
.OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture))
|
|
||||||
{
|
|
||||||
ObjectStores.Add(new ObjectStoreRowViewModel(store));
|
|
||||||
}
|
|
||||||
|
|
||||||
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
|
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId);
|
||||||
|
|
||||||
@@ -5380,12 +5380,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// a walk up the group tree, and this is the map that walk is made against.
|
// a walk up the group tree, and this is the map that walk is made against.
|
||||||
var parents = EffectiveParents();
|
var parents = EffectiveParents();
|
||||||
|
|
||||||
VisibleHosts.Clear();
|
VisibleHosts.ReplaceAll(Hosts.Where(host => Matches(host, parents)));
|
||||||
|
|
||||||
foreach (var host in Hosts.Where(host => Matches(host, parents)))
|
|
||||||
{
|
|
||||||
VisibleHosts.Add(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
RebuildSidebarRows();
|
RebuildSidebarRows();
|
||||||
|
|
||||||
@@ -5395,7 +5390,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// property directly, and a host that is not on screen is not one any of them should act on.
|
// property directly, and a host that is not on screen is not one any of them should act on.
|
||||||
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
||||||
|
|
||||||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the reset
|
||||||
// above is a null that has to be overwritten rather than read.
|
// above is a null that has to be overwritten rather than read.
|
||||||
SelectedSidebarRow = SelectedHost;
|
SelectedSidebarRow = SelectedHost;
|
||||||
|
|
||||||
@@ -5465,12 +5460,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void RebuildHostSections()
|
private void RebuildHostSections()
|
||||||
{
|
{
|
||||||
HostSections.Clear();
|
HostSections.ReplaceAll(BuildHostSections());
|
||||||
|
|
||||||
foreach (var built in BuildHostSections())
|
|
||||||
{
|
|
||||||
HostSections.Add(built);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Computed rather than stored, and none of the four has a change notification of its own — the same
|
// Computed rather than stored, and none of the four has a change notification of its own — the same
|
||||||
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
|
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
|
||||||
@@ -6359,7 +6349,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
||||||
// other order would show a newly shared vault as an empty one until the minute after.
|
// other order would show a newly shared vault as an empty one until the minute after.
|
||||||
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
|
var vaultsChanged = await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Every vault this session can read, not only the one new items are filed into. A team's
|
// Every vault this session can read, not only the one new items are filed into. A team's
|
||||||
// vault that never synced would show its hosts exactly once — at the unlock that first
|
// vault that never synced would show its hosts exactly once — at the unlock that first
|
||||||
@@ -6372,6 +6362,15 @@ internal sealed partial class VaultViewModel(
|
|||||||
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
||||||
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
||||||
|
|
||||||
|
if (!vaultsChanged && !MayHaveChangedTheKeychain(report))
|
||||||
|
{
|
||||||
|
// Nothing to redraw, so nothing is decrypted: see MayHaveChangedTheKeychain. The one thing a
|
||||||
|
// rebuild did that still has to happen is the ago-text on each card, which is a minute older.
|
||||||
|
RestringLastConnected();
|
||||||
|
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
||||||
@@ -6415,7 +6414,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
|
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
private async Task<bool> AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var before = session.Vaults.Count;
|
var before = session.Vaults.Count;
|
||||||
|
|
||||||
@@ -6423,12 +6422,41 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (admitted == 0 && session.Vaults.Count == before)
|
if (admitted == 0 && session.Vaults.Count == before)
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
VaultsChanged?.Invoke(this, EventArgs.Empty);
|
VaultsChanged?.Invoke(this, EventArgs.Empty);
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a pass could have left the keychain different from the lists on screen.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Every pass used to end by reading the whole keychain back — decrypting every host, key, password,
|
||||||
|
/// bucket and snippet and rebuilding every list from them — once a minute, whether or not a single item
|
||||||
|
/// had moved. Almost every pass moves nothing, so almost all of that was spent redrawing what was already
|
||||||
|
/// drawn.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ Conservative on purpose. A failed vault counts as changed, because a pull can apply a page and then
|
||||||
|
/// fail on the next one, and a report with no counts is no evidence that nothing landed. Log entries do
|
||||||
|
/// not count — a machine reads back the ones it just wrote, and nothing on these lists is drawn from the
|
||||||
|
/// log; see <see cref="SyncReport.PulledItems"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static bool MayHaveChangedTheKeychain(IReadOnlyList<VaultSyncReport> report) =>
|
||||||
|
report.Any(vault => vault.Report is not { } pass
|
||||||
|
|| pass.PulledItems > 0
|
||||||
|
|| pass.PushedItems > 0
|
||||||
|
|| pass.Merged > 0
|
||||||
|
|| pass.Resurrected > 0
|
||||||
|
|| pass.Recreated > 0
|
||||||
|
|| pass.Pruned > 0
|
||||||
|
|| pass.DeletesAbandoned > 0
|
||||||
|
|| pass.Parked > 0
|
||||||
|
|| pass.ResyncedFromStart);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
|
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -8838,53 +8866,94 @@ internal sealed partial class VaultViewModel(
|
|||||||
ArgumentNullException.ThrowIfNull(requests);
|
ArgumentNullException.ThrowIfNull(requests);
|
||||||
|
|
||||||
var target = vaultId ?? session.ActiveVaultId;
|
var target = vaultId ?? session.ActiveVaultId;
|
||||||
var hosts = 0;
|
var tally = new ImportTally();
|
||||||
var keys = 0;
|
var ran = false;
|
||||||
var refused = 0;
|
string? failure = null;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
|
||||||
async () =>
|
async () =>
|
||||||
{
|
{
|
||||||
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
ran = true;
|
||||||
// front: the second host naming a file has to land on the id the first one produced, and
|
|
||||||
// nothing is reloaded until the batch finishes.
|
|
||||||
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var request in requests)
|
try
|
||||||
{
|
{
|
||||||
if (!request.Host.TryValidate(out _))
|
await ImportEachAsync(requests, target, tally, cancellationToken).ConfigureAwait(true);
|
||||||
{
|
}
|
||||||
refused++;
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
continue;
|
and not OperationCanceledException)
|
||||||
}
|
{
|
||||||
|
// Reported, and the hosts written before it are still reloaded below: they are in the
|
||||||
var (keyId, created) = await BindImportedKeyAsync(
|
// keychain whether or not the rest followed, and the screen has to say both.
|
||||||
request, bound, target, cancellationToken)
|
failure = exception.Message;
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
if (created)
|
|
||||||
{
|
|
||||||
keys++;
|
|
||||||
}
|
|
||||||
|
|
||||||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
|
||||||
|
|
||||||
await session.Hosts
|
|
||||||
.CreateAsync(target, host, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
hosts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = DescribeImport(hosts, keys, refused);
|
Status = DescribeImport(tally.Hosts, tally.Keys, tally.Refused)
|
||||||
|
+ (failure is null ? string.Empty : $" The import stopped early: {failure}");
|
||||||
}).ConfigureAwait(true);
|
}).ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (!ran)
|
||||||
|
{
|
||||||
|
// RunAsync returns at once while anything else is running — a sync pass, a save — and the
|
||||||
|
// screen asking would otherwise have said it imported nothing as though that were the result.
|
||||||
|
failure = "the keychain was busy with something else. Nothing was imported; try again.";
|
||||||
|
}
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
return new ImportOutcome(hosts, keys);
|
return new ImportOutcome(tally.Hosts, tally.Keys, tally.Refused, failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes each host of an import, and any key it brought, counting as it goes.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Counted into <paramref name="tally"/> rather than returned, so that when a write fails partway the
|
||||||
|
/// caller still knows how many landed before it — which is what it has to tell the person.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ImportEachAsync(
|
||||||
|
IReadOnlyList<ImportedHostRequest> batch,
|
||||||
|
Guid into,
|
||||||
|
ImportTally tally,
|
||||||
|
CancellationToken token)
|
||||||
|
{
|
||||||
|
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
|
||||||
|
// front: the second host naming a file has to land on the id the first one produced, and nothing
|
||||||
|
// is reloaded until the batch finishes.
|
||||||
|
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var request in batch)
|
||||||
|
{
|
||||||
|
if (!request.Host.TryValidate(out _))
|
||||||
|
{
|
||||||
|
tally.Refused++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (keyId, created) = await BindImportedKeyAsync(request, bound, into, token)
|
||||||
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (created)
|
||||||
|
{
|
||||||
|
tally.Keys++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||||||
|
|
||||||
|
await session.Hosts.CreateAsync(into, host, token).ConfigureAwait(true);
|
||||||
|
|
||||||
|
tally.Hosts++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What one import has written so far.</summary>
|
||||||
|
private sealed class ImportTally
|
||||||
|
{
|
||||||
|
internal int Hosts { get; set; }
|
||||||
|
|
||||||
|
internal int Keys { get; set; }
|
||||||
|
|
||||||
|
internal int Refused { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
|
||||||
@@ -13092,13 +13161,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
+ "the beginning. "
|
+ "the beginning. "
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
|
// Not a matter for attention — the server had already deleted these, and the machine is only catching
|
||||||
|
// up — but said, because items going from the list unannounced looks like losing them.
|
||||||
|
var pruned = report.Pruned > 0
|
||||||
|
? $" {report.Pruned} item(s) deleted elsewhere while this machine was away were removed."
|
||||||
|
: string.Empty;
|
||||||
|
|
||||||
if (!report.NeedsAttention)
|
if (!report.NeedsAttention)
|
||||||
{
|
{
|
||||||
return replayed + (report.Pulled == 0 && report.Pushed == 0
|
return replayed + (report.Pulled == 0 && report.Pushed == 0 && report.Pruned == 0
|
||||||
? "Already up to date."
|
? "Already up to date."
|
||||||
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
|
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.") + pruned;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var notes = AttentionNotes(report);
|
||||||
|
|
||||||
|
return replayed + "Synchronised, but: " + string.Join("; ", notes) + "." + pruned;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>What a pass that needs attention says, one clause per thing.</summary>
|
||||||
|
private static List<string> AttentionNotes(SyncReport report)
|
||||||
|
{
|
||||||
var notes = new List<string>();
|
var notes = new List<string>();
|
||||||
|
|
||||||
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
|
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
|
||||||
@@ -13108,6 +13191,11 @@ internal sealed partial class VaultViewModel(
|
|||||||
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
|
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (report.Recreated > 0)
|
||||||
|
{
|
||||||
|
notes.Add($"{report.Recreated} item(s) deleted elsewhere long ago were put back, with this machine's changes");
|
||||||
|
}
|
||||||
|
|
||||||
if (report.DeletesAbandoned > 0)
|
if (report.DeletesAbandoned > 0)
|
||||||
{
|
{
|
||||||
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
|
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
|
||||||
@@ -13133,7 +13221,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
+ "before you can see anything written since");
|
+ "before you can see anything written since");
|
||||||
}
|
}
|
||||||
|
|
||||||
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
|
return notes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||||
@@ -13416,6 +13504,16 @@ internal sealed partial class VaultViewModel(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Re-reads what the keychain table says about its rows as a whole.</summary>
|
||||||
|
private void RaiseVaultItemFacts()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(SectionSummary));
|
||||||
|
OnPropertyChanged(nameof(HasVaultItems));
|
||||||
|
OnPropertyChanged(nameof(ShowsVaultColumn));
|
||||||
|
OnPropertyChanged(nameof(TotalItemCount));
|
||||||
|
OnPropertyChanged(nameof(EmptySectionMessage));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||||||
@@ -13511,10 +13609,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||||
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
||||||
|
|
||||||
OnPropertyChanged(nameof(SectionSummary));
|
RaiseVaultItemFacts();
|
||||||
OnPropertyChanged(nameof(HasVaultItems));
|
|
||||||
OnPropertyChanged(nameof(TotalItemCount));
|
|
||||||
OnPropertyChanged(nameof(EmptySectionMessage));
|
|
||||||
|
|
||||||
// A reload replaces every row object, and the selection is restored by id — so the setter above may
|
// A reload replaces every row object, and the selection is restored by id — so the setter above may
|
||||||
// not have fired even though the row this answers about is a different instance. Asked again here,
|
// not have fired even though the row this answers about is a different instance. Asked again here,
|
||||||
|
|||||||
@@ -1639,11 +1639,32 @@ internal sealed partial class VaultsViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||||
// from a list box, which has no cancellation token and no way to await. Wrapped here rather than
|
// from a list box, which has no cancellation token and no way to await.
|
||||||
// inside the read, because the read is also awaited from inside a reload that already holds the
|
//
|
||||||
// busy gate — and a second RunAsync there would find the gate held by its own caller and skip the
|
// ◆ Not through RunAsync. Its busy gate returns at once while anything else runs — the first load,
|
||||||
// members list entirely.
|
// a share, a reload — so a vault chosen in that moment never had its people read, and the panel
|
||||||
_ = RunAsync(() => LoadSelectedAsync(CancellationToken.None));
|
// went on showing the previous vault's under the new one's name, with SHARE and REVOKE acting on
|
||||||
|
// that mixture. The read does not need the gate: LoadSelectedAsync bumps the selection generation,
|
||||||
|
// and every answer for an earlier selection is discarded by it.
|
||||||
|
_ = LoadSelectionAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Reads the newly selected vault's people, turning a failure into a sentence.</summary>
|
||||||
|
private async Task LoadSelectionAsync()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await LoadSelectedAsync(CancellationToken.None).ConfigureAwait(true);
|
||||||
|
}
|
||||||
|
catch (DodoSshApiException exception)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||||
|
and not OperationCanceledException)
|
||||||
|
{
|
||||||
|
Status = exception.Message;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnPendingActionChanged(VaultActionRequest? value)
|
partial void OnPendingActionChanged(VaultActionRequest? value)
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ public interface IKnownHostStore
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
|
ValueTask<string?> FindAsync(string host, int port, string algorithm, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Returns every key pinned for one endpoint, whatever the algorithm.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>What stops a pin being sidestepped by changing algorithm.</b> <see cref="FindAsync"/> answers
|
||||||
|
/// per algorithm, so on its own a host pinned for <c>ssh-ed25519</c> that is answered by somebody offering
|
||||||
|
/// only <c>ecdsa-sha2-nistp256</c> looks like a host never seen before — and "never seen before" is a
|
||||||
|
/// question the user is asked, where "changed" is a refusal. Knowing the endpoint has pins at all is what
|
||||||
|
/// lets the connection factory tell the two apart; see <c>SshNetConnectionFactory</c>.
|
||||||
|
/// </para>
|
||||||
|
/// <para>The same rule as <see cref="FindAsync"/>: no I/O.</para>
|
||||||
|
/// </remarks>
|
||||||
|
ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Records a host key as trusted.</summary>
|
/// <summary>Records a host key as trusted.</summary>
|
||||||
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
|
ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken);
|
||||||
|
|
||||||
@@ -156,6 +172,22 @@ public sealed class InMemoryKnownHostStore : IKnownHostStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public ValueTask<IReadOnlyList<HostKeyPresentation>> FindForEndpointAsync(
|
||||||
|
string host,
|
||||||
|
int port,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
IReadOnlyList<HostKeyPresentation> found = pins.Values
|
||||||
|
.Where(pin => KnownHostIdentity.Comparer.Equals(pin.Host, host) && pin.Port == port)
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
return ValueTask.FromResult(found);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
|
public ValueTask TrustAsync(HostKeyPresentation presentation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -523,6 +523,14 @@ public interface IRemoteFileStore : IAsyncDisposable
|
|||||||
/// <param name="cancellationToken">Abandons the open.</param>
|
/// <param name="cancellationToken">Abandons the open.</param>
|
||||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
/// <summary>Whether <see cref="OpenWriteAsync"/> can start anywhere but the beginning.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// True for SFTP, which is a file system. False for a bucket, where an object is written whole: an
|
||||||
|
/// interrupted upload there starts again rather than resuming, and the transfer queue reads this to know
|
||||||
|
/// — asking for an offset would be refused on every retry, leaving a row that can never finish.
|
||||||
|
/// </remarks>
|
||||||
|
bool CanWriteFromAnOffset => true;
|
||||||
|
|
||||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
IProgress<SshConnectionPhase>? progress,
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
var endpointPins = await knownHosts
|
||||||
|
.FindForEndpointAsync(request.Host, request.Port, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
PreferPinnedAlgorithms(client.ConnectionInfo, endpointPins);
|
||||||
|
|
||||||
|
var gate = new HostKeyGate(knownHosts, request, endpointPins, progress, cancellationToken);
|
||||||
|
|
||||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||||
|
|
||||||
@@ -217,17 +223,73 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
return gate;
|
return gate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Offers the host key algorithms this endpoint has pins for ahead of every other.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The server takes the first algorithm in the client's list that it also supports, so this is what
|
||||||
|
/// makes a server that still holds its pinned key negotiate that key — rather than, after it gained an
|
||||||
|
/// <c>ed25519</c> key alongside the <c>rsa</c> one that was pinned, a key nobody has approved. Without it
|
||||||
|
/// the gate below would have to choose between refusing a legitimate server and prompting for one that
|
||||||
|
/// may not be, which is exactly the choice it must not be left with.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Reordered rather than restricted, so a host whose pinned keys are all gone still completes the key
|
||||||
|
/// exchange and reaches the gate, which reports it as a changed key with both fingerprints. Restricting
|
||||||
|
/// would fail the exchange with "no matching host key algorithm", which is true and tells nobody what
|
||||||
|
/// happened. Cleared and refilled because that is the only way a dictionary promises an order.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private static void PreferPinnedAlgorithms(
|
||||||
|
ConnectionInfo info,
|
||||||
|
IReadOnlyList<HostKeyPresentation> pins)
|
||||||
|
{
|
||||||
|
if (pins.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var pinned = pins
|
||||||
|
.Select(pin => pin.Algorithm)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var ordered = info.HostKeyAlgorithms
|
||||||
|
.OrderBy(algorithm => pinned.Contains(algorithm.Key) ? 0 : 1)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
info.HostKeyAlgorithms.Clear();
|
||||||
|
|
||||||
|
foreach (var algorithm in ordered)
|
||||||
|
{
|
||||||
|
info.HostKeyAlgorithms.Add(algorithm.Key, algorithm.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
/// Decides host key trust during the handshake, and remembers enough to explain a refusal.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// Separate from the connect method because the decision is the security-relevant part and reads
|
/// Separate from the connect method because the decision is the security-relevant part and reads
|
||||||
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
/// better on its own: pinned and equal accepts, pinned and different refuses as a mismatch,
|
||||||
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
/// unpinned refuses as unknown. There is no fourth branch, and there is no prompt.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>"Pinned" means pinned for this endpoint, not for this algorithm.</b> Pins are stored per
|
||||||
|
/// algorithm, and an endpoint whose pins are all for other algorithms used to fall through to "unknown"
|
||||||
|
/// — so somebody in the middle who simply offered a different key type got the first-use question
|
||||||
|
/// instead of the hard refusal a pin exists to produce. Now a key under an algorithm with no pin of its
|
||||||
|
/// own is accepted only if it is the same key as one that is pinned (an RSA key is one key under
|
||||||
|
/// <c>rsa-sha2-256</c> and <c>rsa-sha2-512</c>, and its fingerprint says so), and is otherwise a
|
||||||
|
/// mismatch against the endpoint's pin. <see cref="PreferPinnedAlgorithms"/> is what keeps that from
|
||||||
|
/// refusing a server that still has its pinned key.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private sealed class HostKeyGate(
|
private sealed class HostKeyGate(
|
||||||
IKnownHostStore knownHosts,
|
IKnownHostStore knownHosts,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
|
IReadOnlyList<HostKeyPresentation> endpointPins,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
IProgress<SshConnectionPhase>? progress,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -264,13 +326,21 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
.GetAwaiter()
|
.GetAwaiter()
|
||||||
.GetResult();
|
.GetResult();
|
||||||
|
|
||||||
if (pinned is null)
|
if (pinned is null && endpointPins.Count == 0)
|
||||||
{
|
{
|
||||||
// Refused, not prompted. The caller decides, off the handshake thread.
|
// Refused, not prompted. The caller decides, off the handshake thread.
|
||||||
e.CanTrust = false;
|
e.CanTrust = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Nothing pinned under this algorithm, but the endpoint has pins: the same key under another
|
||||||
|
// algorithm's name if any pin's fingerprint is this one, and otherwise a changed key, measured
|
||||||
|
// against the endpoint's first pin. See the class remarks.
|
||||||
|
pinned ??= endpointPins
|
||||||
|
.FirstOrDefault(pin => SshHostKeyFingerprint.Equal(pin.Fingerprint, presentation.Fingerprint))
|
||||||
|
?.Fingerprint
|
||||||
|
?? endpointPins[0].Fingerprint;
|
||||||
|
|
||||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||||
mismatch = !matches;
|
mismatch = !matches;
|
||||||
e.CanTrust = matches;
|
e.CanTrust = matches;
|
||||||
@@ -398,7 +468,21 @@ internal sealed class SshNetConnection(
|
|||||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
|
||||||
|
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
|
||||||
|
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
|
||||||
|
/// painting for as long as they took, and the cancellation token it was given was read once, before any
|
||||||
|
/// of them.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
|
||||||
|
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
|
||||||
|
/// channel on the server.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
@@ -406,15 +490,53 @@ internal sealed class SshNetConnection(
|
|||||||
|
|
||||||
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
|
// 4 KiB read buffer inside SSH.NET. Output is coalesced a layer up, so a larger buffer here
|
||||||
// only delays the first byte reaching the screen.
|
// only delays the first byte reaching the screen.
|
||||||
var shell = client.CreateShellStream(
|
var opening = Task.Run(
|
||||||
"xterm-256color",
|
() => client.CreateShellStream(
|
||||||
effective.Columns,
|
"xterm-256color",
|
||||||
effective.Rows,
|
effective.Columns,
|
||||||
effective.PixelWidth,
|
effective.Rows,
|
||||||
effective.PixelHeight,
|
effective.PixelWidth,
|
||||||
4096);
|
effective.PixelHeight,
|
||||||
|
4096),
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
try
|
||||||
|
{
|
||||||
|
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
return new SshNetShellSession(shell, client);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_ = CloseWhenItLandsAsync(opening);
|
||||||
|
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
|
||||||
|
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
|
||||||
|
/// for a channel or a connection that went away, named so that anything else still surfaces.
|
||||||
|
/// </remarks>
|
||||||
|
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
|
||||||
|
{
|
||||||
|
ShellStream shell;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
shell = await opening.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is SshException
|
||||||
|
or System.Net.Sockets.SocketException
|
||||||
|
or ObjectDisposedException
|
||||||
|
or InvalidOperationException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await shell.DisposeAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -240,6 +240,15 @@ internal sealed class CachedItemRow
|
|||||||
public bool IsDeleted { get; set; }
|
public bool IsDeleted { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The last full pull that saw this item on the server; null if none has since this was kept.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The server collects tombstones after their retention (ADR 0003), so a machine away longer than that
|
||||||
|
/// is never told of some deletions: it is refused, starts over, and pulls everything that still exists.
|
||||||
|
/// What the pull from nothing did not mark is what the server no longer has. See
|
||||||
|
/// <c>ItemStore.SweepAsync</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public long? SweepMark { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -350,6 +359,31 @@ internal sealed class SyncStateRow
|
|||||||
/// decide which edit wins.
|
/// decide which edit wins.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public long ServerTimeSkewMs { get; set; }
|
public long ServerTimeSkewMs { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The item types <see cref="Cursor"/> was read under, as this build names them; null before this was kept.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A pull asks for the types this build can apply, and the cursor it gets back is past every change it
|
||||||
|
/// read — including the changes of types it did not ask for. So a build that adds a type to that list
|
||||||
|
/// and carries on from the old cursor never sees the items of that type made before it arrived. Kept
|
||||||
|
/// beside the cursor so a change of list starts the pull over; see <c>SyncEngine.PullAsync</c>.
|
||||||
|
/// </remarks>
|
||||||
|
public string? PulledTypes { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The mark of the pull from nothing in progress, or null when none is.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Kept here rather than in memory because a pull from nothing can take several passes — the pages per
|
||||||
|
/// pass are capped — and what it has seen has to survive between them. See <see cref="CachedItemRow.SweepMark"/>.
|
||||||
|
/// </remarks>
|
||||||
|
public long? SweepMark { get; set; }
|
||||||
|
|
||||||
|
/// <summary>The highest change sequence that pull has read so far.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The sweep's upper bound: an item written here later than this — one this machine pushed while the
|
||||||
|
/// pull was under way — is newer than anything the pull has read, and not evidence of anything.
|
||||||
|
/// </remarks>
|
||||||
|
public long SweepSeenUpTo { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
|
/// <summary>Something the merge had to override, or an item that could not be processed.</summary>
|
||||||
|
|||||||
@@ -118,6 +118,153 @@ public sealed class ItemStore(
|
|||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Writes the server's version of many items at once, in one transaction, in the order given.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <see cref="SaveAsync"/> for a page of pulled changes. A pull used to write each change on its own —
|
||||||
|
/// a context, a read, a commit — so a first sync of a keychain with a few thousand items was a few
|
||||||
|
/// thousand SQLite transactions, each one a sync to disk. This is one read for the rows that exist
|
||||||
|
/// already and one commit for the lot.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The same blind overwrite, and the same answer when one item appears more than once: they are applied
|
||||||
|
/// in order to the same row, so the last one given is the one kept — exactly what writing them one by one
|
||||||
|
/// would have left.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public async Task SaveManyAsync(IReadOnlyCollection<StoredItem> items, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(items);
|
||||||
|
|
||||||
|
if (items.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vaultIds = items.Select(item => item.VaultId).Distinct().ToList();
|
||||||
|
var entityIds = items.Select(item => item.EntityId).Distinct().ToList();
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var existing = await context.Set<CachedItemRow>()
|
||||||
|
.Where(r => vaultIds.Contains(r.VaultId) && entityIds.Contains(r.EntityId))
|
||||||
|
.ToListAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
var rows = existing.ToDictionary(row => (row.VaultId, row.EntityType, row.EntityId));
|
||||||
|
|
||||||
|
foreach (var item in items)
|
||||||
|
{
|
||||||
|
if (!rows.TryGetValue((item.VaultId, item.EntityType, item.EntityId), out var row))
|
||||||
|
{
|
||||||
|
row = new CachedItemRow
|
||||||
|
{
|
||||||
|
VaultId = item.VaultId,
|
||||||
|
EntityType = item.EntityType,
|
||||||
|
EntityId = item.EntityId,
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Add(row);
|
||||||
|
rows.Add((item.VaultId, item.EntityType, item.EntityId), row);
|
||||||
|
}
|
||||||
|
|
||||||
|
Apply(row, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Marks the items a pull from nothing has just read as ones the server still has.</summary>
|
||||||
|
/// <remarks>One update per type in the page; see <see cref="SweepAsync"/>.</remarks>
|
||||||
|
public async Task MarkSeenAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
long mark,
|
||||||
|
IReadOnlyCollection<(SyncEntityType Type, Guid Id)> seen,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(seen);
|
||||||
|
|
||||||
|
if (seen.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
foreach (var group in seen.GroupBy(item => item.Type))
|
||||||
|
{
|
||||||
|
var type = group.Key;
|
||||||
|
var ids = group.Select(item => item.Id).Distinct().ToList();
|
||||||
|
|
||||||
|
await context.Set<CachedItemRow>()
|
||||||
|
.Where(row => row.VaultId == vaultId && row.EntityType == type && ids.Contains(row.EntityId))
|
||||||
|
.ExecuteUpdateAsync(set => set.SetProperty(row => row.SweepMark, mark), cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>The highest change sequence held for a vault, or zero for none.</summary>
|
||||||
|
/// <remarks>Where a pull from nothing starts its sweep's upper bound; see <see cref="SweepAsync"/>.</remarks>
|
||||||
|
public async Task<long> HighestSequenceAsync(Guid vaultId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return await context.Set<CachedItemRow>()
|
||||||
|
.Where(row => row.VaultId == vaultId)
|
||||||
|
.MaxAsync(row => (long?)row.ChangeSequence, cancellationToken)
|
||||||
|
.ConfigureAwait(false) ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Removes the items a completed pull from nothing did not see, which are the ones the server no longer has.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The other half of the server's tombstone collection. A machine that missed a deletion for longer than
|
||||||
|
/// the retention is refused its cursor and pulls everything again — and everything again does not include
|
||||||
|
/// the item, so its copy here would otherwise last for ever, and its next edit would push it back.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ Two kinds of row are not evidence and are kept. One newer than <paramref name="seenUpTo"/> was
|
||||||
|
/// written by this machine's own push during the pull: the bound is the higher of what the cache held
|
||||||
|
/// when the pull began and what it has read since, and the server's head is past both, so a push lands
|
||||||
|
/// above it. One with a change still waiting to go is the user's work: it is
|
||||||
|
/// left for the push, which finds the server without the item and says so — see
|
||||||
|
/// <c>SyncEngine.RetryAsCreateAsync</c> — rather than being thrown away here unseen.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <returns>How many items went.</returns>
|
||||||
|
public async Task<int> SweepAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
IReadOnlyCollection<SyncEntityType> types,
|
||||||
|
long mark,
|
||||||
|
long seenUpTo,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(types);
|
||||||
|
|
||||||
|
var context = contexts.CreateDbContext();
|
||||||
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
// Only the types the pull asked for: a row of any other type was never going to be marked.
|
||||||
|
return await context.Set<CachedItemRow>()
|
||||||
|
.Where(row => row.VaultId == vaultId
|
||||||
|
&& types.Contains(row.EntityType)
|
||||||
|
&& (row.SweepMark == null || row.SweepMark != mark)
|
||||||
|
&& row.ChangeSequence <= seenUpTo
|
||||||
|
&& !context.Set<OutboxRow>().Any(pending =>
|
||||||
|
pending.VaultId == row.VaultId
|
||||||
|
&& pending.EntityType == row.EntityType
|
||||||
|
&& pending.EntityId == row.EntityId))
|
||||||
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
|
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
|
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
|
||||||
|
|||||||
+469
@@ -0,0 +1,469 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClientCacheContext))]
|
||||||
|
[Migration("20261002104920_AddSyncStatePulledTypes")]
|
||||||
|
partial class AddSyncStatePulledTypes
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasName("pk_item");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "ChangeSequence")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||||
|
|
||||||
|
b.ToTable("item", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "KeyGeneration")
|
||||||
|
.HasName("pk_vault_key_wrap");
|
||||||
|
|
||||||
|
b.ToTable("vault_key_wrap", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Hidden")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("hidden");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPersonal")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_personal");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Permissions")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("permissions");
|
||||||
|
|
||||||
|
b.Property<bool>("RekeyRequired")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("rekey_required");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TeamId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("team_id");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedVaultKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_vault_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_vault");
|
||||||
|
|
||||||
|
b.ToTable("vault", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("acknowledged");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Detail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("detail");
|
||||||
|
|
||||||
|
b.Property<long>("DetectedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("detected_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_conflict");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "Acknowledged")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.ToTable("conflict", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Sequence")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sequence");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<byte?>("AncestorAadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_aad_version");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AncestorDataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ancestor_data_key_id");
|
||||||
|
|
||||||
|
b.Property<uint?>("AncestorKeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorPayload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_protected_fields");
|
||||||
|
|
||||||
|
b.Property<int?>("AncestorVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_wrapped_data_key");
|
||||||
|
|
||||||
|
b.Property<int>("Attempts")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("attempts");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ExpectedVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("expected_version");
|
||||||
|
|
||||||
|
b.Property<bool>("IsParked")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_parked");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_error");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<Guid>("OperationId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("operation_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("QueuedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("queued_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("Sequence")
|
||||||
|
.HasName("pk_outbox");
|
||||||
|
|
||||||
|
b.HasIndex("OperationId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_operation_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||||
|
|
||||||
|
b.ToTable("outbox", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("SealedRefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("sealed_refresh_token");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_remembered_sign_in");
|
||||||
|
|
||||||
|
b.ToTable("remembered_sign_in", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<string>("Cursor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("cursor");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPulledAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pulled_at_utc");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPushedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<string>("PulledTypes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pulled_types");
|
||||||
|
|
||||||
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
b.ToTable("sync_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeviceId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("device_wrapped_private_key");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("issuer");
|
||||||
|
|
||||||
|
b.Property<string>("KdfAlgorithm")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("kdf_algorithm");
|
||||||
|
|
||||||
|
b.Property<int>("KdfMemoryKibibytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_memory_kibibytes");
|
||||||
|
|
||||||
|
b.Property<int>("KdfParallelism")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_parallelism");
|
||||||
|
|
||||||
|
b.Property<int>("KdfPasses")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_passes");
|
||||||
|
|
||||||
|
b.Property<byte[]>("KdfSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("kdf_salt");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("ServerUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("server_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subject");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedPrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_private_key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_unlock_material");
|
||||||
|
|
||||||
|
b.ToTable("unlock_material", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSyncStatePulledTypes : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "pulled_types",
|
||||||
|
table: "sync_state",
|
||||||
|
type: "TEXT",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "pulled_types",
|
||||||
|
table: "sync_state");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+481
@@ -0,0 +1,481 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using DodoSSH.Client.Storage;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClientCacheContext))]
|
||||||
|
[Migration("20261002152108_AddSweepMarks")]
|
||||||
|
partial class AddSweepMarks
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<long>("ChangeSequence")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("change_sequence");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDeleted")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_deleted");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<int>("Version")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasName("pk_item");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "ChangeSequence")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType")
|
||||||
|
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||||
|
|
||||||
|
b.ToTable("item", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultKeyWrapRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId", "KeyGeneration")
|
||||||
|
.HasName("pk_vault_key_wrap");
|
||||||
|
|
||||||
|
b.ToTable("vault_key_wrap", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<bool>("Hidden")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("hidden");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPersonal")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_personal");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("Permissions")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("permissions");
|
||||||
|
|
||||||
|
b.Property<bool>("RekeyRequired")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("rekey_required");
|
||||||
|
|
||||||
|
b.Property<Guid?>("TeamId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("team_id");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedVaultKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_vault_key");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_vault");
|
||||||
|
|
||||||
|
b.ToTable("vault", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Acknowledged")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("acknowledged");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Detail")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("detail");
|
||||||
|
|
||||||
|
b.Property<long>("DetectedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("detected_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int>("Kind")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kind");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_conflict");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "Acknowledged")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.ToTable("conflict", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<long>("Sequence")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sequence");
|
||||||
|
|
||||||
|
b.Property<byte>("AadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("aad_version");
|
||||||
|
|
||||||
|
b.Property<byte?>("AncestorAadVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_aad_version");
|
||||||
|
|
||||||
|
b.Property<Guid?>("AncestorDataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("ancestor_data_key_id");
|
||||||
|
|
||||||
|
b.Property<uint?>("AncestorKeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_key_generation");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorPayload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_protected_fields");
|
||||||
|
|
||||||
|
b.Property<int?>("AncestorVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("ancestor_version");
|
||||||
|
|
||||||
|
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("ancestor_wrapped_data_key");
|
||||||
|
|
||||||
|
b.Property<int>("Attempts")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("attempts");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DataKeyId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("data_key_id");
|
||||||
|
|
||||||
|
b.Property<Guid>("EntityId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("entity_id");
|
||||||
|
|
||||||
|
b.Property<int>("EntityType")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("entity_type");
|
||||||
|
|
||||||
|
b.Property<int?>("ExpectedVersion")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("expected_version");
|
||||||
|
|
||||||
|
b.Property<bool>("IsParked")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("is_parked");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("LastError")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_error");
|
||||||
|
|
||||||
|
b.Property<int>("Operation")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("operation");
|
||||||
|
|
||||||
|
b.Property<Guid>("OperationId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("operation_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Payload")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("payload");
|
||||||
|
|
||||||
|
b.Property<byte[]>("ProtectedFields")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long>("QueuedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("queued_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedDataKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_data_key");
|
||||||
|
|
||||||
|
b.HasKey("Sequence")
|
||||||
|
.HasName("pk_outbox");
|
||||||
|
|
||||||
|
b.HasIndex("OperationId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_operation_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||||
|
.IsUnique()
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||||
|
|
||||||
|
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||||
|
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||||
|
|
||||||
|
b.ToTable("outbox", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("SealedRefreshToken")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("sealed_refresh_token");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_remembered_sign_in");
|
||||||
|
|
||||||
|
b.ToTable("remembered_sign_in", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("VaultId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("vault_id");
|
||||||
|
|
||||||
|
b.Property<string>("Cursor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("cursor");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPulledAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pulled_at_utc");
|
||||||
|
|
||||||
|
b.Property<long?>("LastPushedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<string>("PulledTypes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pulled_types");
|
||||||
|
|
||||||
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
|
b.Property<long>("SweepSeenUpTo")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_seen_up_to");
|
||||||
|
|
||||||
|
b.HasKey("VaultId")
|
||||||
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
b.ToTable("sync_state", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<Guid?>("DeviceId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("device_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("device_wrapped_private_key");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("display_name");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("email");
|
||||||
|
|
||||||
|
b.Property<string>("Issuer")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("issuer");
|
||||||
|
|
||||||
|
b.Property<string>("KdfAlgorithm")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("kdf_algorithm");
|
||||||
|
|
||||||
|
b.Property<int>("KdfMemoryKibibytes")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_memory_kibibytes");
|
||||||
|
|
||||||
|
b.Property<int>("KdfParallelism")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_parallelism");
|
||||||
|
|
||||||
|
b.Property<int>("KdfPasses")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("kdf_passes");
|
||||||
|
|
||||||
|
b.Property<byte[]>("KdfSalt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("kdf_salt");
|
||||||
|
|
||||||
|
b.Property<uint>("KeyGeneration")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("key_generation");
|
||||||
|
|
||||||
|
b.Property<string>("ServerUrl")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("server_url");
|
||||||
|
|
||||||
|
b.Property<string>("Subject")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("subject");
|
||||||
|
|
||||||
|
b.Property<long>("UpdatedAtUtc")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("updated_at_utc");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("user_id");
|
||||||
|
|
||||||
|
b.Property<byte[]>("WrappedPrivateKey")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("BLOB")
|
||||||
|
.HasColumnName("wrapped_private_key");
|
||||||
|
|
||||||
|
b.HasKey("Id")
|
||||||
|
.HasName("pk_unlock_material");
|
||||||
|
|
||||||
|
b.ToTable("unlock_material", null, t =>
|
||||||
|
{
|
||||||
|
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Storage.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddSweepMarks : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "sweep_mark",
|
||||||
|
table: "sync_state",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "sweep_seen_up_to",
|
||||||
|
table: "sync_state",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "sweep_mark",
|
||||||
|
table: "item",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sweep_mark",
|
||||||
|
table: "sync_state");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sweep_seen_up_to",
|
||||||
|
table: "sync_state");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "sweep_mark",
|
||||||
|
table: "item");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,10 @@ namespace DodoSSH.Client.Storage.Migrations
|
|||||||
.HasColumnType("BLOB")
|
.HasColumnType("BLOB")
|
||||||
.HasColumnName("protected_fields");
|
.HasColumnName("protected_fields");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
b.Property<long>("UpdatedAtUtc")
|
b.Property<long>("UpdatedAtUtc")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("updated_at_utc");
|
.HasColumnName("updated_at_utc");
|
||||||
@@ -362,10 +366,22 @@ namespace DodoSSH.Client.Storage.Migrations
|
|||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("last_pushed_at_utc");
|
.HasColumnName("last_pushed_at_utc");
|
||||||
|
|
||||||
|
b.Property<string>("PulledTypes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("pulled_types");
|
||||||
|
|
||||||
b.Property<long>("ServerTimeSkewMs")
|
b.Property<long>("ServerTimeSkewMs")
|
||||||
.HasColumnType("INTEGER")
|
.HasColumnType("INTEGER")
|
||||||
.HasColumnName("server_time_skew_ms");
|
.HasColumnName("server_time_skew_ms");
|
||||||
|
|
||||||
|
b.Property<long?>("SweepMark")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_mark");
|
||||||
|
|
||||||
|
b.Property<long>("SweepSeenUpTo")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sweep_seen_up_to");
|
||||||
|
|
||||||
b.HasKey("VaultId")
|
b.HasKey("VaultId")
|
||||||
.HasName("pk_sync_state");
|
.HasName("pk_sync_state");
|
||||||
|
|
||||||
|
|||||||
@@ -44,6 +44,14 @@ public sealed record QueuedChange(
|
|||||||
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
|
/// ancestor that is this client's own earlier edit. That merge finds no disagreement, so it converges
|
||||||
/// on the newest state with nothing for the user to arbitrate.
|
/// on the newest state with nothing for the user to arbitrate.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>Which is only true if an answer is applied to the operation it answers.</b> A push reads a row,
|
||||||
|
/// sends it, and acts on the reply some time later; a user who saves in between coalesces a new state into
|
||||||
|
/// that same row. So <see cref="CompleteAsync"/>, <see cref="ReviseAsync"/> and <see cref="ParkAsync"/>
|
||||||
|
/// take the operation that was sent and do nothing when the row has since become a different one. Keyed by
|
||||||
|
/// sequence alone, an acknowledgement of the earlier state deleted the row holding the later one, and the
|
||||||
|
/// edit was gone with nothing to say so.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class OutboxStore(
|
public sealed class OutboxStore(
|
||||||
IDbContextFactory<ClientCacheContext> contexts,
|
IDbContextFactory<ClientCacheContext> contexts,
|
||||||
@@ -116,7 +124,7 @@ public sealed class OutboxStore(
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public async Task<PendingOperation?> ReviseAsync(
|
public async Task<PendingOperation?> ReviseAsync(
|
||||||
long sequence,
|
PendingOperation revising,
|
||||||
SyncOperation operation,
|
SyncOperation operation,
|
||||||
int? expectedVersion,
|
int? expectedVersion,
|
||||||
EncryptedPayload? payload,
|
EncryptedPayload? payload,
|
||||||
@@ -127,12 +135,18 @@ public sealed class OutboxStore(
|
|||||||
var context = contexts.CreateDbContext();
|
var context = contexts.CreateDbContext();
|
||||||
await using var scope = context.ConfigureAwait(false);
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
|
ArgumentNullException.ThrowIfNull(revising);
|
||||||
|
|
||||||
var row = await context.Set<OutboxRow>()
|
var row = await context.Set<OutboxRow>()
|
||||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
.SingleOrDefaultAsync(
|
||||||
|
r => r.Sequence == revising.Sequence && r.OperationId == revising.OperationId,
|
||||||
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (row is null)
|
if (row is null)
|
||||||
{
|
{
|
||||||
|
// Completed, or replaced by a newer edit since it was read. Either way the merge was of a state
|
||||||
|
// that is no longer the one queued, and the next push will meet the server with the current one.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,16 +247,19 @@ public sealed class OutboxStore(
|
|||||||
|
|
||||||
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
|
/// <summary>Records that an operation has been sent, so a repeated failure can be noticed.</summary>
|
||||||
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
|
public Task MarkDispatchedAsync(long sequence, CancellationToken cancellationToken) =>
|
||||||
UpdateAsync(sequence, row => row.Attempts++, cancellationToken);
|
UpdateAsync(sequence, operationId: null, row => row.Attempts++, cancellationToken);
|
||||||
|
|
||||||
/// <summary>Removes an operation the server accepted.</summary>
|
/// <summary>Removes an operation the server accepted, unless a newer edit has replaced it since.</summary>
|
||||||
public async Task<bool> CompleteAsync(long sequence, CancellationToken cancellationToken)
|
/// <returns>Whether a row was removed.</returns>
|
||||||
|
public async Task<bool> CompleteAsync(PendingOperation operation, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(operation);
|
||||||
|
|
||||||
var context = contexts.CreateDbContext();
|
var context = contexts.CreateDbContext();
|
||||||
await using var scope = context.ConfigureAwait(false);
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
var removed = await context.Set<OutboxRow>()
|
var removed = await context.Set<OutboxRow>()
|
||||||
.Where(r => r.Sequence == sequence)
|
.Where(r => r.Sequence == operation.Sequence && r.OperationId == operation.OperationId)
|
||||||
.ExecuteDeleteAsync(cancellationToken)
|
.ExecuteDeleteAsync(cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -257,9 +274,10 @@ public sealed class OutboxStore(
|
|||||||
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
|
/// invalid, or the caller no longer having permission. Retrying either would spin forever and, far
|
||||||
/// worse, would block every change queued behind it in a vault the user can still write to.
|
/// worse, would block every change queued behind it in a vault the user can still write to.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public Task ParkAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
public Task ParkAsync(PendingOperation operation, string reason, CancellationToken cancellationToken) =>
|
||||||
UpdateAsync(
|
UpdateAsync(
|
||||||
sequence,
|
operation.Sequence,
|
||||||
|
operation.OperationId,
|
||||||
row =>
|
row =>
|
||||||
{
|
{
|
||||||
row.IsParked = true;
|
row.IsParked = true;
|
||||||
@@ -269,7 +287,7 @@ public sealed class OutboxStore(
|
|||||||
|
|
||||||
/// <summary>Records a transient failure without parking the operation.</summary>
|
/// <summary>Records a transient failure without parking the operation.</summary>
|
||||||
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
public Task RecordFailureAsync(long sequence, string reason, CancellationToken cancellationToken) =>
|
||||||
UpdateAsync(sequence, row => row.LastError = reason, cancellationToken);
|
UpdateAsync(sequence, operationId: null, row => row.LastError = reason, cancellationToken);
|
||||||
|
|
||||||
private static Task<OutboxRow?> FindRowAsync(
|
private static Task<OutboxRow?> FindRowAsync(
|
||||||
ClientCacheContext context,
|
ClientCacheContext context,
|
||||||
@@ -302,6 +320,7 @@ public sealed class OutboxStore(
|
|||||||
|
|
||||||
private async Task UpdateAsync(
|
private async Task UpdateAsync(
|
||||||
long sequence,
|
long sequence,
|
||||||
|
Guid? operationId,
|
||||||
Action<OutboxRow> mutate,
|
Action<OutboxRow> mutate,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -309,7 +328,9 @@ public sealed class OutboxStore(
|
|||||||
await using var scope = context.ConfigureAwait(false);
|
await using var scope = context.ConfigureAwait(false);
|
||||||
|
|
||||||
var row = await context.Set<OutboxRow>()
|
var row = await context.Set<OutboxRow>()
|
||||||
.SingleOrDefaultAsync(r => r.Sequence == sequence, cancellationToken)
|
.SingleOrDefaultAsync(
|
||||||
|
r => r.Sequence == sequence && (operationId == null || r.OperationId == operationId),
|
||||||
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
if (row is null)
|
if (row is null)
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ public enum ConflictKind
|
|||||||
|
|
||||||
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
|
/// <summary>The server refused the operation outright. Retrying will not help.</summary>
|
||||||
Rejected = 6,
|
Rejected = 6,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Someone else deleted an item this machine had edited, so long ago that the server had already let go
|
||||||
|
/// of the deletion. The edit was sent as the item again, under its own name and id.
|
||||||
|
/// </summary>
|
||||||
|
RecreatedAfterCollection = 7,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What an offline unlock needs.</summary>
|
/// <summary>What an offline unlock needs.</summary>
|
||||||
@@ -183,13 +189,17 @@ public sealed record PendingOperation(
|
|||||||
/// <param name="LastPulledAt">When the last pull completed.</param>
|
/// <param name="LastPulledAt">When the last pull completed.</param>
|
||||||
/// <param name="LastPushedAt">When the last push completed.</param>
|
/// <param name="LastPushedAt">When the last push completed.</param>
|
||||||
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
|
/// <param name="ServerTimeSkewMs">Observed clock difference, recorded and never acted on.</param>
|
||||||
|
/// <param name="PulledTypes">The item types the cursor was read under; see <c>SyncStateRow.PulledTypes</c>.</param>
|
||||||
public sealed record StoredSyncState(
|
public sealed record StoredSyncState(
|
||||||
Guid VaultId,
|
Guid VaultId,
|
||||||
string? Cursor,
|
string? Cursor,
|
||||||
uint KeyGeneration,
|
uint KeyGeneration,
|
||||||
DateTimeOffset? LastPulledAt = null,
|
DateTimeOffset? LastPulledAt = null,
|
||||||
DateTimeOffset? LastPushedAt = null,
|
DateTimeOffset? LastPushedAt = null,
|
||||||
long ServerTimeSkewMs = 0);
|
long ServerTimeSkewMs = 0,
|
||||||
|
string? PulledTypes = null,
|
||||||
|
long? SweepMark = null,
|
||||||
|
long SweepSeenUpTo = 0);
|
||||||
|
|
||||||
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
|
/// <summary>Something the merge overrode, or an item that could not be processed.</summary>
|
||||||
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
|
/// <param name="Id">This record's own id, which its sealed detail is bound to.</param>
|
||||||
|
|||||||
@@ -46,7 +46,10 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
|||||||
row.KeyGeneration,
|
row.KeyGeneration,
|
||||||
row.LastPulledAtUtc,
|
row.LastPulledAtUtc,
|
||||||
row.LastPushedAtUtc,
|
row.LastPushedAtUtc,
|
||||||
row.ServerTimeSkewMs);
|
row.ServerTimeSkewMs,
|
||||||
|
row.PulledTypes,
|
||||||
|
row.SweepMark,
|
||||||
|
row.SweepSeenUpTo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Records a vault's position.</summary>
|
/// <summary>Records a vault's position.</summary>
|
||||||
@@ -72,6 +75,9 @@ public sealed class SyncStateStore(IDbContextFactory<ClientCacheContext> context
|
|||||||
row.LastPulledAtUtc = state.LastPulledAt;
|
row.LastPulledAtUtc = state.LastPulledAt;
|
||||||
row.LastPushedAtUtc = state.LastPushedAt;
|
row.LastPushedAtUtc = state.LastPushedAt;
|
||||||
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
row.ServerTimeSkewMs = state.ServerTimeSkewMs;
|
||||||
|
row.PulledTypes = state.PulledTypes;
|
||||||
|
row.SweepMark = state.SweepMark;
|
||||||
|
row.SweepSeenUpTo = state.SweepSeenUpTo;
|
||||||
|
|
||||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
{
|
{
|
||||||
return pending.Operation == SyncOperation.Delete
|
return pending.Operation == SyncOperation.Delete
|
||||||
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
|
// Both sides deleted it. Nothing to arbitrate and nothing to tell the user.
|
||||||
? outbox.CompleteAsync(pending.Sequence, cancellationToken)
|
? outbox.CompleteAsync(pending, cancellationToken)
|
||||||
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
|
: ResurrectAsync(vaultId, remote, pending, report, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +166,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await outbox.ReviseAsync(
|
await outbox.ReviseAsync(
|
||||||
pending.Sequence,
|
pending,
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
expectedVersion: null,
|
expectedVersion: null,
|
||||||
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
|
kind.Seal(local.Secret, vaultKey.Span, pending.EntityId, generation, itemVersion: 1),
|
||||||
@@ -212,7 +212,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
{
|
{
|
||||||
// Field for field the same item: this is our own create coming back. Nothing to do but stop
|
// Field for field the same item: this is our own create coming back. Nothing to do but stop
|
||||||
// trying to send it again.
|
// trying to send it again.
|
||||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +362,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Now the tombstone can stand.
|
// Now the tombstone can stand.
|
||||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await conflicts.RecordAsync(
|
await conflicts.RecordAsync(
|
||||||
vaultId,
|
vaultId,
|
||||||
@@ -385,7 +385,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
SyncReportBuilder report,
|
SyncReportBuilder report,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await conflicts.RecordAsync(
|
await conflicts.RecordAsync(
|
||||||
vaultId,
|
vaultId,
|
||||||
@@ -413,7 +413,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
var nextVersion = SyncVersions.NextVersion(remote.Version);
|
var nextVersion = SyncVersions.NextVersion(remote.Version);
|
||||||
|
|
||||||
await outbox.ReviseAsync(
|
await outbox.ReviseAsync(
|
||||||
pending.Sequence,
|
pending,
|
||||||
SyncOperation.Upsert,
|
SyncOperation.Upsert,
|
||||||
expectedVersion: remote.Version,
|
expectedVersion: remote.Version,
|
||||||
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
|
kind.Seal(secret, vaultKey.Span, remote.EntityId, generation, nextVersion),
|
||||||
@@ -486,7 +486,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await outbox.ParkAsync(
|
await outbox.ParkAsync(
|
||||||
pending.Sequence,
|
pending,
|
||||||
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
|
$"Written by a newer version of DodoSSH; update before editing this {kind.Noun}.",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -512,7 +512,7 @@ internal sealed class ItemReconciler<TSecret>(
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await outbox.ParkAsync(
|
await outbox.ParkAsync(
|
||||||
pending.Sequence,
|
pending,
|
||||||
$"The local or the server copy of this {kind.Noun} could not be decrypted.",
|
$"The local or the server copy of this {kind.Noun} could not be decrypted.",
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -546,16 +546,25 @@ internal static class ItemMirror
|
|||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
SyncChange change,
|
SyncChange change,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken) =>
|
||||||
items.SaveAsync(
|
items.SaveAsync(ToStored(vaultId, change), cancellationToken);
|
||||||
new StoredItem(
|
|
||||||
vaultId,
|
/// <summary>Mirrors a run of changes in one write; see <see cref="ItemStore.SaveManyAsync"/>.</summary>
|
||||||
change.EntityType,
|
internal static Task WriteManyAsync(
|
||||||
change.EntityId,
|
ItemStore items,
|
||||||
change.Version,
|
Guid vaultId,
|
||||||
change.ChangeSequence,
|
IReadOnlyList<SyncChange> changes,
|
||||||
change.Payload,
|
CancellationToken cancellationToken) =>
|
||||||
change.PlaintextFields,
|
items.SaveManyAsync(changes.Select(change => ToStored(vaultId, change)).ToList(), cancellationToken);
|
||||||
change.Operation == SyncOperation.Delete,
|
|
||||||
change.UpdatedAt),
|
private static StoredItem ToStored(Guid vaultId, SyncChange change) =>
|
||||||
cancellationToken);
|
new(
|
||||||
|
vaultId,
|
||||||
|
change.EntityType,
|
||||||
|
change.EntityId,
|
||||||
|
change.Version,
|
||||||
|
change.ChangeSequence,
|
||||||
|
change.Payload,
|
||||||
|
change.PlaintextFields,
|
||||||
|
change.Operation == SyncOperation.Delete,
|
||||||
|
change.UpdatedAt);
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.Runtime.InteropServices;
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Security.Cryptography;
|
||||||
using DodoSSH.Client.Api;
|
using DodoSSH.Client.Api;
|
||||||
using DodoSSH.Client.Storage;
|
using DodoSSH.Client.Storage;
|
||||||
using DodoSSH.Contracts;
|
using DodoSSH.Contracts;
|
||||||
@@ -127,7 +128,7 @@ public sealed class SyncEngine
|
|||||||
SyncReportBuilder report,
|
SyncReportBuilder report,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
var state = await ReadStateAsync(vaultId, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// At most one restart per pull. A server that rejects the cursor it has just issued is not
|
// At most one restart per pull. A server that rejects the cursor it has just issued is not
|
||||||
// telling this client anything it can act on, and replaying the whole log against it would turn
|
// telling this client anything it can act on, and replaying the whole log against it would turn
|
||||||
@@ -154,23 +155,13 @@ public sealed class SyncEngine
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var change in response.Changes)
|
await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
|
||||||
{
|
CountPulled(response.Changes, report);
|
||||||
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
|
|
||||||
report.Pulled++;
|
|
||||||
|
|
||||||
// Counted apart for the same reason the pushed ones are: a machine reads back the log
|
|
||||||
// entries it just wrote, so a pass that "pulled five changes" may have carried nothing
|
|
||||||
// anybody did. See SyncReport.PulledItems.
|
|
||||||
if (IsLog(change.EntityType))
|
|
||||||
{
|
|
||||||
report.PulledLogEntries++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
|
var advanced = !string.Equals(state.Cursor, response.NextCursor, StringComparison.Ordinal);
|
||||||
|
|
||||||
state = Record(vaultId, state, response, report);
|
state = Record(vaultId, state, response, report);
|
||||||
|
state = await CarrySweepAsync(vaultId, state, response, report, cancellationToken).ConfigureAwait(false);
|
||||||
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
await syncState.SaveAsync(state, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
if (!response.HasMore)
|
if (!response.HasMore)
|
||||||
@@ -187,15 +178,22 @@ public sealed class SyncEngine
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private static void CountPulled(IReadOnlyList<SyncChange> changes, SyncReportBuilder report)
|
||||||
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
{
|
||||||
/// </summary>
|
foreach (var change in changes)
|
||||||
/// <remarks>
|
{
|
||||||
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
report.Pulled++;
|
||||||
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
|
||||||
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
// Counted apart for the same reason the pushed ones are: a machine reads back the log entries it
|
||||||
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
// just wrote, so a pass that "pulled five changes" may have carried nothing anybody did. See
|
||||||
/// </remarks>
|
// SyncReport.PulledItems.
|
||||||
|
if (IsLog(change.EntityType))
|
||||||
|
{
|
||||||
|
report.PulledLogEntries++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Whether a change is one the machine wrote about itself rather than one somebody made.</summary>
|
/// <summary>Whether a change is one the machine wrote about itself rather than one somebody made.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed,
|
/// Used only for reporting. Log entries sync exactly like every other item — they are pulled, pushed,
|
||||||
@@ -205,10 +203,46 @@ public sealed class SyncEngine
|
|||||||
private static bool IsLog(SyncEntityType type) =>
|
private static bool IsLog(SyncEntityType type) =>
|
||||||
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
|
type is SyncEntityType.ConnectionLogEntry or SyncEntityType.ActivityLogEntry;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
||||||
|
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
||||||
|
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
||||||
|
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
||||||
|
/// </remarks>
|
||||||
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
|
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
|
||||||
!string.IsNullOrEmpty(cursor)
|
!string.IsNullOrEmpty(cursor)
|
||||||
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
|
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>Where the pull should carry on from, which is the start when the types it reads have changed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A cursor read under a different list of types is past changes of the types it did not ask for, so an
|
||||||
|
/// upgrade that adds one — SpelunkKey, ObjectStore — would never receive the items of that type made
|
||||||
|
/// before it. Starting over once is the only way to see them; applying is idempotent, which is what makes
|
||||||
|
/// the restart for a refused cursor safe as well. A state from before the list was kept has none and
|
||||||
|
/// starts over once for the same reason: it may already have missed some.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<StoredSyncState> ReadStateAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (state.Cursor is not null && !string.Equals(state.PulledTypes, PulledTypes, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pull from nothing that is not a restart — the vault's first, or a cache reset — sweeps as well.
|
||||||
|
// There is usually nothing to remove; the one time there is, it is the same case.
|
||||||
|
return state is { Cursor: null, SweepMark: null }
|
||||||
|
? await SweepingAsync(state, cancellationToken).ConfigureAwait(false)
|
||||||
|
: state;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forgets this vault's position and reads the log again from the beginning.
|
/// Forgets this vault's position and reads the log again from the beginning.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -233,12 +267,10 @@ public sealed class SyncEngine
|
|||||||
/// cache itself is the thing in doubt.
|
/// cache itself is the thing in doubt.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server
|
/// A replay does not carry a deletion the server has collected — <c>DodoOptions.TombstoneRetentionDays</c>
|
||||||
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a
|
/// — and that is the commonest refusal of all: a machine away longer than the retention. The rows it no
|
||||||
/// replay no longer carries a deletion older than the retention window. A machine that missed such a
|
/// longer hears about are the ones the replay does not mark, and are swept when it ends; see
|
||||||
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row
|
/// <see cref="CarrySweepAsync"/>.
|
||||||
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
|
|
||||||
/// guess.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
|
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
|
||||||
@@ -250,7 +282,7 @@ public sealed class SyncEngine
|
|||||||
SyncReportBuilder report,
|
SyncReportBuilder report,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var restarted = state with { Cursor = null };
|
var restarted = await SweepingAsync(state with { Cursor = null }, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
|
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
@@ -259,6 +291,68 @@ public sealed class SyncEngine
|
|||||||
return restarted;
|
return restarted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The state with a sweep begun: a new mark, and the bound set from what the cache holds now.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The mark is random rather than the time, because a sweep must never share one with the last: rows that
|
||||||
|
/// one marked would count as seen by this one. A clock can stand still, or go back.
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<StoredSyncState> SweepingAsync(StoredSyncState state, CancellationToken cancellationToken) =>
|
||||||
|
state with
|
||||||
|
{
|
||||||
|
SweepMark = BitConverter.ToInt64(RandomNumberGenerator.GetBytes(sizeof(long))),
|
||||||
|
SweepSeenUpTo = await items.HighestSequenceAsync(state.VaultId, cancellationToken).ConfigureAwait(false),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks what a pull from nothing has read, and at its end removes what it never did.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The server forgets a deletion once it is past its retention. A machine that was away longer is
|
||||||
|
/// refused its cursor and reads everything again — and everything again is the items that still exist,
|
||||||
|
/// with nothing said about the ones that went. Without this their rows stayed here for good, and the
|
||||||
|
/// next edit of one put it back on the server for everybody.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A pull from nothing can take more than one pass, so the mark is in the stored state, not in memory.
|
||||||
|
/// Every pull from nothing sweeps — the first one ever as well, where there is nothing to remove. What
|
||||||
|
/// is kept, and why, is <see cref="ItemStore.SweepAsync"/>'s to say.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task<StoredSyncState> CarrySweepAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
StoredSyncState state,
|
||||||
|
SyncPullResponse response,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (state.SweepMark is not { } mark)
|
||||||
|
{
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
var read = response.Changes.Where(change => reconcilers.ContainsKey(change.EntityType)).ToList();
|
||||||
|
|
||||||
|
await items.MarkSeenAsync(
|
||||||
|
vaultId,
|
||||||
|
mark,
|
||||||
|
read.Select(change => (change.EntityType, change.EntityId)).ToList(),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
var seenUpTo = read.Aggregate(state.SweepSeenUpTo, (highest, change) => Math.Max(highest, change.ChangeSequence));
|
||||||
|
|
||||||
|
if (response.HasMore)
|
||||||
|
{
|
||||||
|
return state with { SweepSeenUpTo = seenUpTo };
|
||||||
|
}
|
||||||
|
|
||||||
|
report.Pruned += await items
|
||||||
|
.SweepAsync(vaultId, ItemKinds.SyncedTypes, mark, seenUpTo, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
return state with { SweepMark = null, SweepSeenUpTo = 0 };
|
||||||
|
}
|
||||||
|
|
||||||
private StoredSyncState Record(
|
private StoredSyncState Record(
|
||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
StoredSyncState state,
|
StoredSyncState state,
|
||||||
@@ -282,9 +376,69 @@ public sealed class SyncEngine
|
|||||||
KeyGeneration = response.CurrentKeyGeneration,
|
KeyGeneration = response.CurrentKeyGeneration,
|
||||||
LastPulledAt = now,
|
LastPulledAt = now,
|
||||||
ServerTimeSkewMs = skew,
|
ServerTimeSkewMs = skew,
|
||||||
|
PulledTypes = PulledTypes,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The types this build pulls, in a form a stored state can be compared against.</summary>
|
||||||
|
private static string PulledTypes { get; } =
|
||||||
|
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
|
||||||
|
|
||||||
|
/// <summary>Applies one page of pulled changes, in order, with as few writes as that allows.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// Each change used to cost a write to mirror it and a read to look for a local edit of the same item —
|
||||||
|
/// two or more SQLite transactions apiece, which on a first sync of a large keychain was most of the time
|
||||||
|
/// the sync took. The outbox is read once per page instead, and every run of changes with no local edit
|
||||||
|
/// waiting is mirrored in a single write.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ A change that does have an edit waiting goes through <see cref="ApplyAsync"/> exactly as before, with
|
||||||
|
/// the run in front of it written first: the reconciler reads the mirror and the outbox as they stand at
|
||||||
|
/// that change, and both are what the one-by-one loop would have left them as. The one-read snapshot only
|
||||||
|
/// decides which path a change takes; the path that matters re-reads the outbox for itself.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task ApplyPageAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
IReadOnlyList<SyncChange> changes,
|
||||||
|
SyncReportBuilder report,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (changes.Count == 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var waiting = (await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false))
|
||||||
|
.Select(pending => (pending.EntityType, pending.EntityId))
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var run = new List<SyncChange>();
|
||||||
|
|
||||||
|
foreach (var change in changes)
|
||||||
|
{
|
||||||
|
if (!reconcilers.ContainsKey(change.EntityType))
|
||||||
|
{
|
||||||
|
// See ApplyAsync: a type this build cannot sync is neither mirrored nor reconciled.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!waiting.Contains((change.EntityType, change.EntityId)))
|
||||||
|
{
|
||||||
|
run.Add(change);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
|
||||||
|
run.Clear();
|
||||||
|
|
||||||
|
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
private async Task ApplyAsync(
|
private async Task ApplyAsync(
|
||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
SyncChange change,
|
SyncChange change,
|
||||||
@@ -446,7 +600,7 @@ public sealed class SyncEngine
|
|||||||
}
|
}
|
||||||
|
|
||||||
await outbox.ReviseAsync(
|
await outbox.ReviseAsync(
|
||||||
operation.Sequence,
|
operation,
|
||||||
operation.Operation,
|
operation.Operation,
|
||||||
operation.ExpectedVersion,
|
operation.ExpectedVersion,
|
||||||
resealed,
|
resealed,
|
||||||
@@ -458,6 +612,17 @@ public sealed class SyncEngine
|
|||||||
return resealed;
|
return resealed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether a refused operation is a delete of an item the server never had.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A delete queued over a create that never reached the server carries no expected version, and the
|
||||||
|
/// server's only reason to refuse one of those is that it has no such item — which is the state the delete
|
||||||
|
/// asked for, so it is done. It used to be parked as a rejection: a "conflict" about an item nobody else
|
||||||
|
/// had ever seen, which the user could neither resolve nor dismiss. A create that did land is no exception
|
||||||
|
/// to this — the server has the item then, and applies the delete.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool DeletesSomethingNeverCreated(PendingOperation operation) =>
|
||||||
|
operation is { Operation: SyncOperation.Delete, ExpectedVersion: null };
|
||||||
|
|
||||||
/// <returns>Whether this answer warrants another push round.</returns>
|
/// <returns>Whether this answer warrants another push round.</returns>
|
||||||
private async Task<bool> HandleAsync(
|
private async Task<bool> HandleAsync(
|
||||||
Guid vaultId,
|
Guid vaultId,
|
||||||
@@ -500,6 +665,10 @@ public sealed class SyncEngine
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
case SyncOperationStatus.Invalid when DeletesSomethingNeverCreated(operation):
|
||||||
|
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||||
|
return false;
|
||||||
|
|
||||||
case SyncOperationStatus.Invalid:
|
case SyncOperationStatus.Invalid:
|
||||||
await RejectAsync(
|
await RejectAsync(
|
||||||
vaultId,
|
vaultId,
|
||||||
@@ -551,7 +720,76 @@ public sealed class SyncEngine
|
|||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
var completed = await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (!completed && canMirror && !isDelete)
|
||||||
|
{
|
||||||
|
await RebaseOntoAcceptedAsync(vaultId, operation, version, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Moves an edit made while <paramref name="accepted"/> was in flight onto the version that push produced.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The user saved again between the push being read and its answer arriving, so the row now holds a
|
||||||
|
/// later state branched from the same base as the one the server just took. Left alone it is still the
|
||||||
|
/// user's edit and still goes — but it goes expecting the old version, comes back <c>Conflict</c>, and is
|
||||||
|
/// merged against an ancestor that predates the save the server already has, which makes every field
|
||||||
|
/// changed twice look like a disagreement with somebody else. Its true parent is the state just accepted,
|
||||||
|
/// so that becomes its ancestor and expected version, and its payload is re-sealed at the version it
|
||||||
|
/// will now produce, because the AAD binds that.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Only for a row still branched from the same base. One that is not has been revised by something else
|
||||||
|
/// since, and the ordinary conflict path is the one that knows what to do with it; so is a row whose
|
||||||
|
/// payload this session cannot re-seal.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private async Task RebaseOntoAcceptedAsync(
|
||||||
|
Guid vaultId,
|
||||||
|
PendingOperation accepted,
|
||||||
|
int version,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var later = await outbox
|
||||||
|
.FindAsync(vaultId, accepted.EntityType, accepted.EntityId, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
if (later is null || later.ExpectedVersion != accepted.ExpectedVersion || accepted.Payload is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EncryptedPayload? payload = null;
|
||||||
|
|
||||||
|
if (later.Payload is { } queued)
|
||||||
|
{
|
||||||
|
payload = PayloadReseal.TryReseal(
|
||||||
|
keyring,
|
||||||
|
vaultId,
|
||||||
|
later.EntityType,
|
||||||
|
later.EntityId,
|
||||||
|
queued,
|
||||||
|
openAtVersion: SyncVersions.NextVersion(later.ExpectedVersion),
|
||||||
|
sealAtVersion: SyncVersions.NextVersion(version));
|
||||||
|
|
||||||
|
if (payload is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await outbox.ReviseAsync(
|
||||||
|
later,
|
||||||
|
later.Operation,
|
||||||
|
version,
|
||||||
|
payload,
|
||||||
|
later.Fields,
|
||||||
|
new StoredAncestor(version, accepted.Payload, accepted.Fields),
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> ResolveAsync(
|
private async Task<bool> ResolveAsync(
|
||||||
@@ -617,7 +855,7 @@ public sealed class SyncEngine
|
|||||||
if (operation.Operation == SyncOperation.Delete)
|
if (operation.Operation == SyncOperation.Delete)
|
||||||
{
|
{
|
||||||
// Nothing there to delete, so the intent is already satisfied.
|
// Nothing there to delete, so the intent is already satisfied.
|
||||||
await outbox.CompleteAsync(operation.Sequence, cancellationToken).ConfigureAwait(false);
|
await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -629,6 +867,26 @@ public sealed class SyncEngine
|
|||||||
|
|
||||||
if (failure is null)
|
if (failure is null)
|
||||||
{
|
{
|
||||||
|
// An edit of a version this machine once saw means the server had the item and has since let go
|
||||||
|
// of it: deleted elsewhere, and the deletion collected past its retention before this machine
|
||||||
|
// heard of it. The edit is the user's work and goes back, but not silently — somebody deleted
|
||||||
|
// this on purpose. A create has no expected version and is the ordinary case.
|
||||||
|
if (operation.ExpectedVersion is not null)
|
||||||
|
{
|
||||||
|
await conflicts.RecordAsync(
|
||||||
|
vaultId,
|
||||||
|
operation.EntityType,
|
||||||
|
operation.EntityId,
|
||||||
|
ConflictKind.RecreatedAfterCollection,
|
||||||
|
ConflictDetails.Encode(
|
||||||
|
"This was deleted elsewhere while this machine had unsaved changes to it, long enough ago "
|
||||||
|
+ "that the server no longer remembered the deletion. The changes were kept and the item "
|
||||||
|
+ "was put back."),
|
||||||
|
cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
report.Recreated++;
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -645,7 +903,7 @@ public sealed class SyncEngine
|
|||||||
SyncReportBuilder report,
|
SyncReportBuilder report,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await outbox.ParkAsync(operation.Sequence, reason, cancellationToken).ConfigureAwait(false);
|
await outbox.ParkAsync(operation, reason, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
await conflicts.RecordAsync(
|
await conflicts.RecordAsync(
|
||||||
vaultId,
|
vaultId,
|
||||||
|
|||||||
@@ -84,6 +84,17 @@ public sealed record SyncOptions
|
|||||||
/// changed anything, which is otherwise the sort of thing that looks like a fault.
|
/// changed anything, which is otherwise the sort of thing that looks like a fault.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="PushedLogEntries">How many of <paramref name="Pushed"/> were log entries.</param>
|
||||||
|
/// <param name="PulledLogEntries">How many of <paramref name="Pulled"/> were log entries.</param>
|
||||||
|
/// <param name="Recreated">
|
||||||
|
/// Items this machine had edited that somebody else had deleted so long ago the server no longer
|
||||||
|
/// remembered it. The edit was kept and the item sent again under its own name — and the user is told,
|
||||||
|
/// because the deletion was somebody's decision.
|
||||||
|
/// </param>
|
||||||
|
/// <param name="Pruned">
|
||||||
|
/// Items removed from this machine because a read from the beginning found the server no longer has them:
|
||||||
|
/// deleted elsewhere while this machine was away for longer than the server keeps deletions.
|
||||||
|
/// </param>
|
||||||
public sealed record SyncReport(
|
public sealed record SyncReport(
|
||||||
Guid VaultId,
|
Guid VaultId,
|
||||||
int Pulled,
|
int Pulled,
|
||||||
@@ -99,11 +110,13 @@ public sealed record SyncReport(
|
|||||||
bool RoundsExhausted,
|
bool RoundsExhausted,
|
||||||
bool ResyncedFromStart,
|
bool ResyncedFromStart,
|
||||||
int PushedLogEntries = 0,
|
int PushedLogEntries = 0,
|
||||||
int PulledLogEntries = 0)
|
int PulledLogEntries = 0,
|
||||||
|
int Recreated = 0,
|
||||||
|
int Pruned = 0)
|
||||||
{
|
{
|
||||||
/// <summary>Whether anything happened that a user should be told about.</summary>
|
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||||
public bool NeedsAttention =>
|
public bool NeedsAttention =>
|
||||||
Resurrected > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
Resurrected > 0 || Recreated > 0 || DeletesAbandoned > 0 || Parked > 0 || Unreadable > 0 || RekeyRequired;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Operations the server accepted that were somebody's own work rather than a log entry.
|
/// Operations the server accepted that were somebody's own work rather than a log entry.
|
||||||
@@ -160,6 +173,10 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
|||||||
|
|
||||||
internal bool ResyncedFromStart { get; set; }
|
internal bool ResyncedFromStart { get; set; }
|
||||||
|
|
||||||
|
internal int Recreated { get; set; }
|
||||||
|
|
||||||
|
internal int Pruned { get; set; }
|
||||||
|
|
||||||
internal SyncReport Build() =>
|
internal SyncReport Build() =>
|
||||||
new(
|
new(
|
||||||
vaultId,
|
vaultId,
|
||||||
@@ -176,7 +193,9 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
|||||||
RoundsExhausted,
|
RoundsExhausted,
|
||||||
ResyncedFromStart,
|
ResyncedFromStart,
|
||||||
PushedLogEntries,
|
PushedLogEntries,
|
||||||
PulledLogEntries);
|
PulledLogEntries,
|
||||||
|
Recreated,
|
||||||
|
Pruned);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||||
|
|||||||
@@ -327,7 +327,7 @@ internal sealed class VaultItemRepository<TSecret>(
|
|||||||
|
|
||||||
if (pending is not null && NeverReachedTheServer(pending))
|
if (pending is not null && NeverReachedTheServer(pending))
|
||||||
{
|
{
|
||||||
await outbox.CompleteAsync(pending.Sequence, cancellationToken).ConfigureAwait(false);
|
await outbox.CompleteAsync(pending, cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
// Recorded even though nothing goes to the server. Somebody created an item and then removed it,
|
// Recorded even though nothing goes to the server. Somebody created an item and then removed it,
|
||||||
// which is two things they did — and a log that showed only the create would describe a keychain
|
// which is two things they did — and a log that showed only the create would describe a keychain
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
private const int MaximumRequestBytes = 16 * 1024;
|
private const int MaximumRequestBytes = 16 * 1024;
|
||||||
private const int ReceiveBufferBytes = 64 * 1024;
|
private const int ReceiveBufferBytes = 64 * 1024;
|
||||||
|
|
||||||
|
/// <summary>The largest message from the page this host will reassemble.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// A paste is one input frame however long it is, and so is a copied selection, so the receive buffer
|
||||||
|
/// is the size of a read and not the size of a message. Sixteen megabytes is far beyond anything a
|
||||||
|
/// person pastes into a shell on purpose and still small enough that a page sending garbage cannot make
|
||||||
|
/// this process hold an unbounded amount of it. A message beyond it is dropped whole — never its tail,
|
||||||
|
/// which would be parsed as a frame whose first bytes are somebody's paste.
|
||||||
|
/// </remarks>
|
||||||
|
private const int MaximumMessageBytes = 16 * 1024 * 1024;
|
||||||
|
|
||||||
private readonly TcpListener listener;
|
private readonly TcpListener listener;
|
||||||
private readonly ITerminalAssetProvider assets;
|
private readonly ITerminalAssetProvider assets;
|
||||||
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
|
private readonly Dictionary<uint, TerminalSessionPump> pumps = [];
|
||||||
@@ -451,6 +461,11 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
var buffer = new byte[ReceiveBufferBytes];
|
var buffer = new byte[ReceiveBufferBytes];
|
||||||
|
|
||||||
|
// Grown only when a message spans more than one read, and kept for the next one that does.
|
||||||
|
var message = Array.Empty<byte>();
|
||||||
|
var length = 0;
|
||||||
|
var oversized = false;
|
||||||
|
|
||||||
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
while (webSocket.State == WebSocketState.Open && !cancellationToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
var result = await webSocket
|
var result = await webSocket
|
||||||
@@ -462,14 +477,73 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!result.EndOfMessage)
|
// The common case — a keystroke, an acknowledgement — is one read, and goes straight through.
|
||||||
|
if (result.EndOfMessage && length == 0 && !oversized)
|
||||||
{
|
{
|
||||||
// A frame larger than the receive buffer. Terminal input and acknowledgements are tiny,
|
DispatchSafely(buffer.AsSpan(0, result.Count));
|
||||||
// so this is either a bug or something hostile; dropping is the safe response.
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
Dispatch(buffer.AsSpan(0, result.Count));
|
if (!oversized && length + result.Count <= MaximumMessageBytes)
|
||||||
|
{
|
||||||
|
if (length + result.Count > message.Length)
|
||||||
|
{
|
||||||
|
Array.Resize(ref message, Math.Min(MaximumMessageBytes, Math.Max(message.Length * 2, length + result.Count)));
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer.AsSpan(0, result.Count).CopyTo(message.AsSpan(length));
|
||||||
|
length += result.Count;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
oversized = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.EndOfMessage)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!oversized)
|
||||||
|
{
|
||||||
|
DispatchSafely(message.AsSpan(0, length));
|
||||||
|
}
|
||||||
|
|
||||||
|
length = 0;
|
||||||
|
oversized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Dispatches one frame, so that what goes wrong with one session stays with that session.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every session shares this socket, and an exception escaping the receive loop closes it — so a resize
|
||||||
|
/// that reached a shell SSH.NET had already disposed, which is an ordinary moment at the end of every
|
||||||
|
/// session, used to drop every other tab to "reconnecting" and lose the output sent meanwhile. A frame
|
||||||
|
/// that cannot be acted on is a frame not acted on; the session it was for reports its own ending.
|
||||||
|
/// </remarks>
|
||||||
|
private void DispatchSafely(ReadOnlySpan<byte> frame)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Dispatch(frame);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException and not OperationCanceledException)
|
||||||
|
{
|
||||||
|
// See the remarks.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Writes one input frame, without letting a session that is going away fault anything.</summary>
|
||||||
|
private async Task WriteInputAsync(TerminalSessionPump pump, byte[] input)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await pump.WriteInputAsync(input, lifetime.Token).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||||
|
{
|
||||||
|
// The shell ended or the connection dropped between the keystroke and the write. The session's
|
||||||
|
// own read loop reports that; a keystroke that had nowhere to go has nothing to add.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +576,7 @@ public sealed class TerminalDataPlane : ITerminalTransport, IAsyncDisposable
|
|||||||
case TerminalClientOpcode.Input:
|
case TerminalClientOpcode.Input:
|
||||||
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
|
// Fire and forget: a blocked write must not stall acknowledgements for other sessions
|
||||||
// sharing this socket.
|
// sharing this socket.
|
||||||
_ = pump.WriteInputAsync(payload.ToArray(), lifetime.Token).AsTask();
|
_ = WriteInputAsync(pump, payload.ToArray());
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case TerminalClientOpcode.Acknowledge:
|
case TerminalClientOpcode.Acknowledge:
|
||||||
|
|||||||
@@ -107,6 +107,19 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
private readonly OutputBacklog backlog;
|
private readonly OutputBacklog backlog;
|
||||||
|
|
||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
|
/// <summary>One write to the remote at a time, in the order they were asked for.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The data plane hands each input frame over without waiting for the one before it — a blocked write
|
||||||
|
/// must not hold up the acknowledgements of every other session on the socket — so without this, two
|
||||||
|
/// frames' writes run at once. SSH.NET's <c>ShellStream</c> takes no lock around its write buffer, and its
|
||||||
|
/// flush sends what is there and then empties it by length: bytes added mid-flush are discarded unsent.
|
||||||
|
/// A snippet's RUN is its text and then a carriage return as two frames, which is exactly the shape that lost or
|
||||||
|
/// reordered keystrokes. Waiters on a <see cref="SemaphoreSlim"/> are released in the order they arrived,
|
||||||
|
/// and the data plane calls this from one receive loop, so arrival order is the order typed.
|
||||||
|
/// </remarks>
|
||||||
|
private readonly SemaphoreSlim inputGate = new(1, 1);
|
||||||
|
|
||||||
private int disposed;
|
private int disposed;
|
||||||
|
|
||||||
/// <param name="sessionId">Identifies this session in every frame.</param>
|
/// <param name="sessionId">Identifies this session in every frame.</param>
|
||||||
@@ -267,9 +280,20 @@ public sealed class TerminalSessionPump : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Forwards keystrokes to the remote.</summary>
|
/// <summary>Forwards keystrokes to the remote, after any still being written.</summary>
|
||||||
public ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken) =>
|
public async ValueTask WriteInputAsync(ReadOnlyMemory<byte> data, CancellationToken cancellationToken)
|
||||||
session.WriteAsync(data, cancellationToken);
|
{
|
||||||
|
await inputGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await session.WriteAsync(data, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
inputGate.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Tells the remote the terminal was resized.</summary>
|
/// <summary>Tells the remote the terminal was resized.</summary>
|
||||||
public void Resize(TerminalSize size) => session.Resize(size);
|
public void Resize(TerminalSize size) => session.Resize(size);
|
||||||
|
|||||||
@@ -194,6 +194,15 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
private readonly CancellationTokenSource lifetime = new();
|
private readonly CancellationTokenSource lifetime = new();
|
||||||
|
|
||||||
private Task pump = Task.CompletedTask;
|
private Task pump = Task.CompletedTask;
|
||||||
|
|
||||||
|
/// <summary>Whether a pump owns the queue: set when one is started, cleared by the pump as it finds it empty.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Guarded by <see cref="gate"/>, and the reason it exists rather than asking <see cref="pump"/> whether it
|
||||||
|
/// has finished. The pump decides it is done under the gate, but its task is only marked complete after it
|
||||||
|
/// has let go — so a transfer queued in between found a pump that was still "running", started none, and
|
||||||
|
/// stayed queued for good. This is cleared in the same locked step that found nothing to take.
|
||||||
|
/// </remarks>
|
||||||
|
private bool pumping;
|
||||||
private bool disposed;
|
private bool disposed;
|
||||||
|
|
||||||
/// <param name="sessions">
|
/// <param name="sessions">
|
||||||
@@ -329,7 +338,16 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
running?.Cancel();
|
// Outside the gate, because cancelling runs the token's callbacks on this thread — but that leaves a
|
||||||
|
// moment in which the run finishes and disposes the source it was read from. A disposed source is a
|
||||||
|
// transfer that is already over, so there is nothing left to cancel and nothing to report.
|
||||||
|
try
|
||||||
|
{
|
||||||
|
running?.Cancel();
|
||||||
|
}
|
||||||
|
catch (ObjectDisposedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
if (cancelled is not null)
|
if (cancelled is not null)
|
||||||
{
|
{
|
||||||
@@ -451,13 +469,39 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void EnsurePumping()
|
private void EnsurePumping()
|
||||||
{
|
{
|
||||||
if (pump.IsCompleted && !disposed)
|
if (!pumping && !disposed)
|
||||||
{
|
{
|
||||||
|
pumping = true;
|
||||||
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Leaves by one of two doors. The ordinary one is <see cref="TakeNext"/> finding the queue empty, which
|
||||||
|
/// hands the queue back in the same locked step. Anything else — a fault escaping a row — hands it back
|
||||||
|
/// on the way out, so the next transfer queued starts a pump rather than waiting on one that is gone.
|
||||||
|
/// </remarks>
|
||||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var drained = false;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
drained = await PumpUntilEmptyAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (!drained)
|
||||||
|
{
|
||||||
|
lock (gate)
|
||||||
|
{
|
||||||
|
pumping = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> PumpUntilEmptyAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (TakeNext() is { } entry)
|
while (TakeNext() is { } entry)
|
||||||
{
|
{
|
||||||
@@ -489,6 +533,8 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
|
|
||||||
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
||||||
@@ -502,6 +548,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
|
|
||||||
if (next is null)
|
if (next is null)
|
||||||
{
|
{
|
||||||
|
// In the same locked step that found nothing, so a transfer queued from here on starts a
|
||||||
|
// pump of its own. See pumping.
|
||||||
|
pumping = false;
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -701,7 +751,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
|||||||
|
|
||||||
var part = destination + PartSuffix;
|
var part = destination + PartSuffix;
|
||||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
||||||
var offset = ResumableLength(entry, existing);
|
|
||||||
|
// A bucket writes an object whole, so a part left by a failed attempt is started over rather than
|
||||||
|
// resumed — and replaced, since writing an object replaces it.
|
||||||
|
var offset = session.CanWriteFromAnOffset ? ResumableLength(entry, existing) : 0;
|
||||||
|
|
||||||
if (offset == 0 && existing is not null)
|
if (offset == 0 && existing is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,6 +94,49 @@ public static class LocalDirectory
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Where a file another machine named should land inside <paramref name="directory"/>, or null when that
|
||||||
|
/// name cannot be a file directly inside it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// ◆ <b>The name is the remote's, and the remote is not trusted to have chosen one that stays put.</b>
|
||||||
|
/// A POSIX file name may contain a backslash, a colon or a leading drive letter, and on Windows any of
|
||||||
|
/// those turns <c>Path.Combine(directory, name)</c> into somewhere else: <c>..\..\AppData\…\Startup\x.bat</c>
|
||||||
|
/// climbs out, and <c>C:\…</c> is rooted, so <see cref="Path.Combine(string, string)"/> drops the
|
||||||
|
/// directory altogether. An S3 key is worse, because <c>../</c> is an ordinary prefix there on every
|
||||||
|
/// platform. Whoever can write a name into a shared directory or bucket would otherwise choose where a
|
||||||
|
/// download writes on this machine.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Characters this platform cannot put in a file name are replaced rather than refused, so a Linux file
|
||||||
|
/// called <c>backup:2026-10-02</c> still downloads to Windows, as <c>backup_2026-10-02</c>. What is
|
||||||
|
/// refused is a name that is nothing but a reference to here or above. The final check resolves the path
|
||||||
|
/// and requires its parent to be the directory, which also catches what Windows does to a trailing dot
|
||||||
|
/// or space — <c>.. </c> is <c>..</c> by the time it reaches the disk.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
public static string? ChildPath(string directory, string name)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(directory);
|
||||||
|
ArgumentNullException.ThrowIfNull(name);
|
||||||
|
|
||||||
|
var cleaned = string.Join('_', name.Split(Path.GetInvalidFileNameChars()));
|
||||||
|
|
||||||
|
if (cleaned.Trim() is "" or "." or "..")
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory));
|
||||||
|
var child = Path.GetFullPath(Path.Combine(root, cleaned));
|
||||||
|
|
||||||
|
return string.Equals(Path.GetDirectoryName(child), root, StringComparison.Ordinal)
|
||||||
|
&& !string.Equals(child, root, StringComparison.Ordinal)
|
||||||
|
? child
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ public static class ProblemCodes
|
|||||||
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
|
/// <summary>The caller has not yet enrolled a public key, so no vault is reachable.</summary>
|
||||||
public const string EnrollmentRequired = "enrollment-required";
|
public const string EnrollmentRequired = "enrollment-required";
|
||||||
|
|
||||||
|
/// <summary>The caller's account has been suspended or deprovisioned by an operator.</summary>
|
||||||
|
public const string AccountNotActive = "account-not-active";
|
||||||
|
|
||||||
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
|
/// <summary>Enrollment was attempted for a user who already holds a different current key.</summary>
|
||||||
public const string AlreadyEnrolled = "already-enrolled";
|
public const string AlreadyEnrolled = "already-enrolled";
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#nullable enable
|
#nullable enable
|
||||||
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
|
const DodoSSH.Contracts.ProblemCodes.AlreadyEnrolled = "already-enrolled" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
|
const DodoSSH.Contracts.ProblemCodes.ClientTooOld = "client-too-old" -> string!
|
||||||
|
const DodoSSH.Contracts.ProblemCodes.AccountNotActive = "account-not-active" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
|
const DodoSSH.Contracts.ProblemCodes.EnrollmentRequired = "enrollment-required" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
|
const DodoSSH.Contracts.ProblemCodes.EventsUnavailable = "events-unavailable" -> string!
|
||||||
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
|
const DodoSSH.Contracts.ProblemCodes.Forbidden = "forbidden" -> string!
|
||||||
@@ -381,6 +382,7 @@ DodoSSH.Contracts.RelayConfiguration.RelayConfiguration(bool Enabled, System.Uri
|
|||||||
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri?
|
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri?
|
||||||
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void
|
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void
|
||||||
DodoSSH.Contracts.RelaySessionCloseReason
|
DodoSSH.Contracts.RelaySessionCloseReason
|
||||||
|
DodoSSH.Contracts.RelaySessionCloseReason.AccessRevoked = 8 -> DodoSSH.Contracts.RelaySessionCloseReason
|
||||||
DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason
|
DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason
|
||||||
DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> DodoSSH.Contracts.RelaySessionCloseReason
|
DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> DodoSSH.Contracts.RelaySessionCloseReason
|
||||||
DodoSSH.Contracts.RelaySessionCloseReason.Error = 7 -> DodoSSH.Contracts.RelaySessionCloseReason
|
DodoSSH.Contracts.RelaySessionCloseReason.Error = 7 -> DodoSSH.Contracts.RelaySessionCloseReason
|
||||||
@@ -968,6 +970,7 @@ static DodoSSH.Contracts.VaultKeyWrap.operator ==(DodoSSH.Contracts.VaultKeyWrap
|
|||||||
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
static DodoSSH.Contracts.VaultSummary.operator !=(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||||
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
static DodoSSH.Contracts.VaultSummary.operator ==(DodoSSH.Contracts.VaultSummary? left, DodoSSH.Contracts.VaultSummary? right) -> bool
|
||||||
const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string!
|
const DodoSSH.Contracts.ProblemCodes.RelayUnavailable = "relay-unavailable" -> string!
|
||||||
|
const DodoSSH.Contracts.RelayProtocol.AccessRevokedCloseCode = 4403 -> int
|
||||||
const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string!
|
const DodoSSH.Contracts.RelayProtocol.ConnectPath = "/api/v1/relay/connect" -> string!
|
||||||
const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int
|
const DodoSSH.Contracts.RelayProtocol.DurationLimitCloseCode = 4409 -> int
|
||||||
const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
|
const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
|
||||||
|
|||||||
@@ -70,6 +70,15 @@ public static class RelayProtocol
|
|||||||
|
|
||||||
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
|
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
|
||||||
public const int DurationLimitCloseCode = 4409;
|
public const int DurationLimitCloseCode = 4409;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Close code for a session whose user lost access to the host's vault while it was open.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Removed from the team, or the vault's key withdrawn. Reconnecting will not help: a new ticket is
|
||||||
|
/// refused for the same reason.
|
||||||
|
/// </remarks>
|
||||||
|
public const int AccessRevokedCloseCode = 4403;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -143,6 +152,9 @@ public enum RelaySessionCloseReason
|
|||||||
|
|
||||||
/// <summary>An error ended the session.</summary>
|
/// <summary>An error ended the session.</summary>
|
||||||
Error = 7,
|
Error = 7,
|
||||||
|
|
||||||
|
/// <summary>The user lost access to the host's vault while the session was open.</summary>
|
||||||
|
AccessRevoked = 8,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -120,4 +120,7 @@ public enum RelayCloseReason
|
|||||||
|
|
||||||
/// <summary>An error ended the session.</summary>
|
/// <summary>An error ended the session.</summary>
|
||||||
Error = 7,
|
Error = 7,
|
||||||
|
|
||||||
|
/// <summary>The user lost access to the host's vault while the session was open.</summary>
|
||||||
|
AccessRevoked = 8,
|
||||||
}
|
}
|
||||||
@@ -19,10 +19,21 @@ namespace DodoSSH.Domain.Sync;
|
|||||||
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
|
/// The cursor also carries its vault id, so a legitimately-issued cursor for one vault cannot be
|
||||||
/// replayed against another. See ADR 0003.
|
/// replayed against another. See ADR 0003.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// And the vault's sync horizon when it was issued — the last deletion the server had forgotten. A cursor
|
||||||
|
/// behind the horizon is refused when it is older than the collection, because its holder may still have the
|
||||||
|
/// deleted item; it is fine when it is newer, because then it belongs to a read from the beginning that
|
||||||
|
/// started after the collection, whose early pages are behind the horizon by nature. Position alone cannot
|
||||||
|
/// tell those two apart. A cursor issued under no horizon keeps the first format, so a server from before
|
||||||
|
/// this still reads it.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class SyncCursor
|
public static class SyncCursor
|
||||||
{
|
{
|
||||||
private const string Version = "v1";
|
private const string Version = "v1";
|
||||||
|
|
||||||
|
/// <summary>The format that carries a horizon as well.</summary>
|
||||||
|
private const string HorizonVersion = "v2";
|
||||||
private const char Separator = '|';
|
private const char Separator = '|';
|
||||||
|
|
||||||
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
|
/// <summary>Length of the truncated HMAC tag. 128 bits is ample for a non-secret position.</summary>
|
||||||
@@ -37,7 +48,17 @@ public static class SyncCursor
|
|||||||
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
||||||
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
||||||
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
||||||
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence)
|
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence) =>
|
||||||
|
Encode(signingKey, vaultId, sequence, horizon: 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Encodes a position, with the sync horizon it was read under.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="signingKey">Deployment cursor signing key, at least 32 bytes.</param>
|
||||||
|
/// <param name="vaultId">Vault the cursor belongs to.</param>
|
||||||
|
/// <param name="sequence">Last change sequence the client has consumed.</param>
|
||||||
|
/// <param name="horizon">The vault's sync horizon before the read that produced this position.</param>
|
||||||
|
public static string Encode(ReadOnlySpan<byte> signingKey, Guid vaultId, long sequence, long horizon)
|
||||||
{
|
{
|
||||||
RequireKey(signingKey);
|
RequireKey(signingKey);
|
||||||
|
|
||||||
@@ -46,7 +67,12 @@ public static class SyncCursor
|
|||||||
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
|
throw new ArgumentOutOfRangeException(nameof(sequence), sequence, "Sequence must not be negative.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence));
|
if (horizon < 0)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(nameof(horizon), horizon, "Horizon must not be negative.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = Encoding.UTF8.GetBytes(FormatPayload(vaultId, sequence, horizon));
|
||||||
|
|
||||||
var buffer = new byte[payload.Length + TagLength];
|
var buffer = new byte[payload.Length + TagLength];
|
||||||
payload.CopyTo(buffer, 0);
|
payload.CopyTo(buffer, 0);
|
||||||
@@ -70,10 +96,27 @@ public static class SyncCursor
|
|||||||
ReadOnlySpan<byte> signingKey,
|
ReadOnlySpan<byte> signingKey,
|
||||||
string? cursor,
|
string? cursor,
|
||||||
Guid expectedVaultId,
|
Guid expectedVaultId,
|
||||||
out long sequence)
|
out long sequence) =>
|
||||||
|
TryDecode(signingKey, cursor, expectedVaultId, out sequence, out _);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Decodes and verifies a cursor, with the horizon it was issued under — zero for one issued under none.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="signingKey">Deployment cursor signing key.</param>
|
||||||
|
/// <param name="cursor">The cursor to verify.</param>
|
||||||
|
/// <param name="expectedVaultId">Vault the request is scoped to.</param>
|
||||||
|
/// <param name="sequence">The decoded sequence, when verification succeeds.</param>
|
||||||
|
/// <param name="horizon">The decoded horizon, when verification succeeds.</param>
|
||||||
|
public static bool TryDecode(
|
||||||
|
ReadOnlySpan<byte> signingKey,
|
||||||
|
string? cursor,
|
||||||
|
Guid expectedVaultId,
|
||||||
|
out long sequence,
|
||||||
|
out long horizon)
|
||||||
{
|
{
|
||||||
RequireKey(signingKey);
|
RequireKey(signingKey);
|
||||||
sequence = 0;
|
sequence = 0;
|
||||||
|
horizon = 0;
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(cursor))
|
if (string.IsNullOrEmpty(cursor))
|
||||||
{
|
{
|
||||||
@@ -97,17 +140,26 @@ public static class SyncCursor
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return TryParsePayload(payload, expectedVaultId, out sequence);
|
return TryParsePayload(payload, expectedVaultId, out sequence, out horizon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FormatPayload(Guid vaultId, long sequence) =>
|
private static string FormatPayload(Guid vaultId, long sequence, long horizon) =>
|
||||||
string.Create(
|
horizon == 0
|
||||||
CultureInfo.InvariantCulture,
|
? string.Create(
|
||||||
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}");
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{Version}{Separator}{vaultId:D}{Separator}{sequence}")
|
||||||
|
: string.Create(
|
||||||
|
CultureInfo.InvariantCulture,
|
||||||
|
$"{HorizonVersion}{Separator}{vaultId:D}{Separator}{sequence}{Separator}{horizon}");
|
||||||
|
|
||||||
private static bool TryParsePayload(ReadOnlySpan<byte> payload, Guid expectedVaultId, out long sequence)
|
private static bool TryParsePayload(
|
||||||
|
ReadOnlySpan<byte> payload,
|
||||||
|
Guid expectedVaultId,
|
||||||
|
out long sequence,
|
||||||
|
out long horizon)
|
||||||
{
|
{
|
||||||
sequence = 0;
|
sequence = 0;
|
||||||
|
horizon = 0;
|
||||||
|
|
||||||
string text;
|
string text;
|
||||||
try
|
try
|
||||||
@@ -122,12 +174,14 @@ public static class SyncCursor
|
|||||||
// Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
|
// Tagged, so the shape is ours — but parse defensively anyway, because a key rotation
|
||||||
// could make an old format verify against a new expectation.
|
// could make an old format verify against a new expectation.
|
||||||
var parts = text.Split(Separator);
|
var parts = text.Split(Separator);
|
||||||
if (parts.Length != 3)
|
var expectedParts = parts[0] switch
|
||||||
{
|
{
|
||||||
return false;
|
Version => 3,
|
||||||
}
|
HorizonVersion => 4,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
|
||||||
if (!string.Equals(parts[0], Version, StringComparison.Ordinal))
|
if (parts.Length != expectedParts)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -137,8 +191,13 @@ public static class SyncCursor
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence)
|
if (!long.TryParse(parts[2], NumberStyles.None, CultureInfo.InvariantCulture, out sequence))
|
||||||
&& sequence >= 0;
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return expectedParts == 3
|
||||||
|
|| long.TryParse(parts[3], NumberStyles.None, CultureInfo.InvariantCulture, out horizon);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ComputeTag(
|
private static void ComputeTag(
|
||||||
|
|||||||
Loaded 100 of 142 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user