Merge pull request 'Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes' (#22) from claude/audit-followups into main
ci / build and test (push) Failing after 3m10s
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 4m7s

Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
jaap-jan committed 2026-10-02 12:59:55 +00:00
commit b1d4a270d4
78 files changed
+2697 -205

No files matched your search

@@ -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();
} }
} }
} }
+1 -3
View File
@@ -100,9 +100,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)
@@ -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);
}
+17 -19
View File
@@ -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;
@@ -254,26 +255,27 @@ internal sealed class TeamService(
throw new InvalidOperationException("Only the owner of a team can archive it."); throw new InvalidOperationException("Only the owner of a team can archive it.");
} }
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 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)
@@ -343,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)
@@ -724,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)
@@ -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,
+5 -6
View File
@@ -41,13 +41,12 @@ 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>());
// 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();
+72
View File
@@ -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;
});
}
+10 -5
View File
@@ -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>(
+1
View File
@@ -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
+4
View File
@@ -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);
} }
@@ -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,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);
+3
View File
@@ -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);
}
} }
@@ -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;
} }
@@ -122,6 +122,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 +322,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 +364,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();
@@ -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,3*,1.1*,0.9*,1.2*" 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,7 +303,14 @@
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,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0"> <Grid Height="40" Margin="6,0,10,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="2" />
<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 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 width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
@@ -307,7 +327,7 @@
<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"
TextTrimming="CharacterEllipsis" /> TextTrimming="CharacterEllipsis" />
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" <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>
+14 -3
View File
@@ -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}" />
+165 -23
View File
@@ -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,40 +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;
}
// The members dialog on Settings → Vaults, by the same rule: it is drawn over the page, so it goes
// first and settings stays.
else if (e.Key == Key.Escape && viewModel.IsSettingsMode && viewModel.Vaults.IsMembersPanelOpen)
{
viewModel.Vaults.CloseMembersPanelCommand.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>
@@ -307,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;
@@ -365,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;
} }
@@ -380,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"
@@ -70,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}" />
+1 -1
View File
@@ -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">
+13 -1
View File
@@ -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)
{ {
@@ -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" />
@@ -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,
}; };
} }
@@ -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}.";
}
@@ -2133,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();
@@ -2819,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>
@@ -2948,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,
@@ -2978,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
@@ -2995,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>
@@ -3257,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.
@@ -3304,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>
@@ -4516,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
@@ -4996,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)
{ {
@@ -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;
@@ -2289,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()
@@ -2306,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>
@@ -2328,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>
@@ -2393,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)
{ {
@@ -2580,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>
@@ -4154,11 +4163,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;
} }
@@ -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>
@@ -6359,7 +6367,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 +6380,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 +6432,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 +6440,39 @@ 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.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>
@@ -13457,6 +13501,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"/> —
@@ -13552,10 +13606,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,
@@ -468,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();
@@ -476,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 />
+59
View File
@@ -118,6 +118,65 @@ 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>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
+21 -12
View File
@@ -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);
} }
+72 -1
View File
@@ -154,9 +154,10 @@ public sealed class SyncEngine
continue; continue;
} }
await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
foreach (var change in response.Changes) foreach (var change in response.Changes)
{ {
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
report.Pulled++; report.Pulled++;
// Counted apart for the same reason the pushed ones are: a machine reads back the log // Counted apart for the same reason the pushed ones are: a machine reads back the log
@@ -310,6 +311,61 @@ public sealed class SyncEngine
private static string PulledTypes { get; } = private static string PulledTypes { get; } =
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order()); 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,
@@ -483,6 +539,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,
@@ -525,6 +592,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,
@@ -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;
} }
@@ -382,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
@@ -969,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!
+12
View File
@@ -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>
+3
View File
@@ -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,
} }
+9 -1
View File
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql; using Testcontainers.PostgreSql;
using Xunit; using Xunit;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests; namespace DodoSSH.Api.Tests;
@@ -108,9 +109,16 @@ public sealed class ApiFixture : WebApplicationFactory<Program>, IAsyncLifetime
Environment.SetEnvironmentVariable(name, value); Environment.SetEnvironmentVariable(name, value);
} }
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork)); builder.ConfigureTestServices(services =>
{
services.AddSingleton<IRelayNetwork>(RelayNetwork);
services.AddSingleton<IInterceptor>(Commits);
});
} }
/// <summary>Fails a commit on request, so a test can watch the retrying execution strategy at work.</summary>
internal FlakyCommits Commits { get; } = new();
/// <summary> /// <summary>
/// What the relay resolves and dials, in place of the machine's own resolver and sockets. /// What the relay resolves and dials, in place of the machine's own resolver and sockets.
/// </summary> /// </summary>
+43
View File
@@ -0,0 +1,43 @@
using System.Data.Common;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
/// <summary>
/// Fails the next commit the way a connection dropped on the way to the server would, when asked to.
/// </summary>
/// <remarks>
/// Before the commit reaches the database, so the transaction is rolled back — which is what a lost commit
/// looks like from the server's side — and with a <see cref="TimeoutException"/>, which the retrying
/// execution strategy treats as transient. That is the only way to watch a retry happen against a real
/// database, and the retry is the thing worth watching: see <c>FreshAttempts</c>.
/// </remarks>
internal sealed class FlakyCommits : DbTransactionInterceptor
{
private int armed;
private int failed;
/// <summary>How many commits this has failed.</summary>
internal int Failed => Volatile.Read(ref failed);
/// <summary>Makes the next commit, whoever's it is, fail once.</summary>
internal void FailNextCommit() => Interlocked.Exchange(ref armed, 1);
/// <inheritdoc />
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
DbTransaction transaction,
TransactionEventData eventData,
InterceptionResult result,
CancellationToken cancellationToken = default)
{
if (Interlocked.Exchange(ref armed, 0) == 1)
{
Interlocked.Increment(ref failed);
throw new TimeoutException("The commit was lost on its way to the server.");
}
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
}
}
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using System.Net.WebSockets; using System.Net.WebSockets;
using System.Text; using System.Text;
using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts; using DodoSSH.Contracts;
using DodoSSH.Domain; using DodoSSH.Domain;
using DodoSSH.Infrastructure; using DodoSSH.Infrastructure;
@@ -266,6 +267,56 @@ public sealed class RelayEndpointTests(ApiFixture fixture)
.ShouldBe(RelayCloseReason.IdleTimeout); .ShouldBe(RelayCloseReason.IdleTimeout);
} }
/// <remarks>
/// <para>
/// A session is authorised once, when its ticket is issued, and used to run on for as long as it carried
/// bytes — removing somebody from a team left their shells into its machines open for up to the session's
/// maximum lifetime. The announcement that tells their event socket to re-read its vaults now has the
/// relay ask again too.
/// </para>
/// <para>
/// Access is withdrawn here by deleting the vault rather than by a team removal, because what is under
/// test is the relay hearing the announcement and asking the access model again — any way of making the
/// answer change will do, and this one needs no team. The removal path announces through the same call;
/// see <c>TeamService.RemoveMemberAsync</c>.
/// </para>
/// </remarks>
[Fact]
public async Task ASessionWhoseUserLosesAccess_IsClosedWithItsOwnCode()
{
using var target = LoopbackTarget.Echo();
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.8.1");
var ticket = await IssueAsync(subject, hostId);
using var timeout = Timeout();
using var socket = await ConnectAsync(ticket, timeout.Token);
await RoundTripAsync(socket, "still here", timeout.Token);
Guid userId;
await using (var scope = fixture.CreateScope())
{
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
userId = (await database.Users.SingleAsync(u => u.Subject == subject, timeout.Token)).Id;
var vaultId = (await database.Hosts.SingleAsync(h => h.Id == hostId, timeout.Token)).VaultId;
var vault = await database.Vaults.SingleAsync(v => v.Id == vaultId, timeout.Token);
vault.DeletedAtUtc = Now;
await database.SaveChangesAsync(timeout.Token);
scope.ServiceProvider.GetRequiredService<IVaultEventPublisher>().VaultAccessChanged(userId);
}
await ReadUntilCloseAsync(socket, timeout.Token);
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode);
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
.ShouldBe(RelayCloseReason.AccessRevoked);
}
[Fact] [Fact]
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed() public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
{ {
@@ -647,6 +647,38 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner); .Role.ShouldBe(TeamMemberRole.Owner);
} }
/// <remarks>
/// The commit lost on its way to the database, which the execution strategy answers by running the
/// transaction again on the same context. That context still held the two memberships as the failed
/// attempt left them — already owner, already admin, and marked unchanged — so the retry saved nothing,
/// committed, and answered 204 over a team whose roles had not moved. See <c>FreshAttempts</c>.
/// </remarks>
[Fact]
public async Task ATransferWhoseCommitIsLost_IsRetriedFromTheDatabase_AndStillLands()
{
var owner = await EnrolledClientAsync("retry-owner", "rowner@example.com");
await EnrolledClientAsync("retry-successor", "rsuccessor@example.com");
var team = await CreateTeamAsync(owner, "Retried handover");
var entry = await LookupAsync(owner, "rsuccessor@example.com");
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
var failedBefore = fixture.Commits.Failed;
fixture.Commits.FailNextCommit();
var response = await owner.PostContractAsync(
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
fixture.Commits.Failed.ShouldBe(failedBefore + 1, "the first commit has to have been lost for this to mean anything");
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId));
members.Where(row => row.UserId == entry.UserId).ShouldHaveSingleItem().Role.ShouldBe(TeamMemberRole.Owner);
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
}
/// <remarks> /// <remarks>
/// The thing that was impossible before this endpoint existed. An owner could not be removed and /// The thing that was impossible before this endpoint existed. An owner could not be removed and
/// could not be demoted, so somebody who left the company owning a team left it owned by them for /// could not be demoted, so somebody who left the company owning a team left it owned by them for
@@ -0,0 +1,74 @@
using Avalonia.Controls;
using Avalonia.Input;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Storage;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
/// </summary>
/// <remarks>
/// <para>
/// Launch used to leave the keyboard nowhere. Focus only moved on the way out of an unlocked vault, so the
/// passphrase somebody started typing the moment the window appeared went nowhere until they clicked the box,
/// and Enter in the server address did nothing at all.
/// </para>
/// <para>
/// The whole window, shown the way <see cref="WholeWindow"/> describes.
/// </para>
/// </remarks>
public sealed class ClosedVaultFocusTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task AFreshMachine_PutsTheKeyboardInTheServerAddressAndEnterSignsIn()
{
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await using var workspace = WholeWindow.Workspace();
await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.NeedsServer);
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(box);
box.KeyBindings.ShouldContain(binding =>
binding.Gesture == new KeyGesture(Key.Enter) && binding.Command == shell.SignInCommand);
}),
Token);
}
[Fact]
public async Task AnEnrolledMachine_PutsTheKeyboardInThePassphraseBox()
{
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
await using var workspace = WholeWindow.Workspace();
await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.State.ShouldBe(ShellState.Locked);
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
window.FocusManager!.GetFocusedElement()
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
Token);
}
}
@@ -0,0 +1,175 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Headless;
using Avalonia.Input;
using Avalonia.Threading;
using Avalonia.VisualTree;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Session.Tests;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
/// answers.
/// </summary>
/// <remarks>
/// They used to appear with the keyboard wherever it had been, Escape did nothing, and Tab walked the page
/// behind them. The answer they are given here is always the harmless one — see <see cref="SafeAnswerFocus"/>
/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
/// </remarks>
public sealed class ConfirmationKeyboardTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task TheSignOutQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
{
using var caches = await EnrolledAsync();
await using var workspace = WholeWindow.Workspace();
await using var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
shell.SignOutCommand.Execute(null);
Dispatcher.UIThread.RunJobs();
var card = window.GetVisualDescendants().OfType<SignOutCard>().Single(card => card.IsEffectivelyVisible);
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
shell.IsConfirmingSignOut.ShouldBeFalse();
}),
Token);
}
[Fact]
public async Task AnUnknownHostKey_TakesTheKeyboardOnCancel_KeepsTabInside_AndEscapeRefuses()
{
using var caches = await EnrolledAsync();
await using var workspace = WholeWindow.Workspace();
await using var shell = await UnlockedAsync(caches, workspace);
var vault = shell.Vault.ShouldNotBeNull();
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
vault.PendingHostKey = new HostKeyPresentation("web.example", 22, "ssh-ed25519", "SHA256:abc");
Dispatcher.UIThread.RunJobs();
var card = window.FindControl<HostKeyCard>("HostKeyPane").ShouldNotBeNull();
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<Button>();
focused.Content.ShouldBe("CANCEL");
for (var press = 0; press < 4; press++)
{
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
window.FocusManager.GetFocusedElement().ShouldBeAssignableTo<Visual>()
.ShouldNotBeNull().GetVisualAncestors().ShouldContain(card);
}
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
vault.HasPendingHostKey.ShouldBeFalse();
}),
Token);
}
[Fact]
public async Task ADeletionQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
{
using var caches = await EnrolledAsync();
await using var workspace = WholeWindow.Workspace();
await using var shell = await UnlockedAsync(caches, workspace);
var vault = shell.Vault.ShouldNotBeNull();
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
Dispatcher.UIThread.RunJobs();
vault.PendingDeletion = new DeletionRequest(
DeletionTarget.Tag, Guid.CreateVersion7(), "Delete the tag prod?", "It goes.", string.Empty);
Dispatcher.UIThread.RunJobs();
var card = window.GetVisualDescendants().OfType<ConfirmDeleteCard>().Single(card => card.IsEffectivelyVisible);
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
vault.IsConfirmingDeletion.ShouldBeFalse();
shell.IsSettingsMode.ShouldBeTrue();
}),
Token);
}
[Fact]
public async Task EscapeOnTheHostsScreen_PutsTheDrawerAway_LikeItsOwnArrow()
{
using var caches = await EnrolledAsync();
await using var workspace = WholeWindow.Workspace();
await using var shell = await UnlockedAsync(caches, workspace);
var vault = shell.Vault.ShouldNotBeNull();
await LayoutHarness.OnTheUiThreadAsync(
() => WholeWindow.Show(shell, window =>
{
vault.NewHostCommand.Execute(null);
Dispatcher.UIThread.RunJobs();
vault.IsDrawerOpen.ShouldBeTrue();
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
vault.IsDrawerOpen.ShouldBeFalse();
vault.IsEditing.ShouldBeFalse();
}),
Token);
}
private static async Task<ClientCacheFactory> EnrolledAsync()
{
var caches = ClientCacheFactory.ForMemory($"confirm-{Guid.CreateVersion7():N}");
await caches.MigrateAsync(Token);
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
return caches;
}
private static async Task<MainWindowViewModel> UnlockedAsync(ClientCacheFactory caches, TerminalWorkspace workspace)
{
var shell = WholeWindow.Shell(caches, workspace);
await shell.StartAsync(Token);
shell.Passphrase = WholeWindow.Passphrase;
await shell.UnlockCommand.ExecuteAsync(null);
shell.IsUnlocked.ShouldBeTrue();
return shell;
}
}
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
}); });
} }
/// <remarks>
/// The arrows' half of the selection. The cards' <c>SelectedItem</c> is one-way, so a card the keyboard lit
/// used to stay out of the vault — and the drawer's CONNECT and DELETE then acted on the card clicked
/// before it, the mistake <see cref="ARightClickSelectsTheHostUnderThePointer"/> is about.
/// </remarks>
[Fact]
public async Task TheArrowsMoveTheVaultsSelection_AndEnterConnectsToIt()
{
await OnTheBoardAsync((screen, window) =>
{
var first = Row(vault, "prod-db");
var other = Row(vault, "stage-web");
Click(CardFor(screen, first), window);
vault.SelectedHost.ShouldBeSameAs(first);
window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
vault.SelectedHost.ShouldBeSameAs(other, "the card the arrow lit, not the one clicked before it");
vault.ConnectCommand.ExecutionTask.ShouldBeNull();
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
// The command, not a connection: these hosts were saved with nothing to sign in with, so it stops
// at saying so — about the card Enter was pressed on, which is the selection checked above.
vault.ConnectCommand.ExecutionTask.ShouldNotBeNull("Enter on a card is the double-press");
});
}
/// <remarks>
/// The board is where this screen puts the keyboard, and none of the section lists inside it has it until
/// a card has been clicked — so without this an arrow pressed on arriving went nowhere at all.
/// </remarks>
[Fact]
public async Task AnArrowOnTheBoardItselfGoesIntoTheCards()
{
await OnTheBoardAsync((screen, window) =>
{
vault.SelectedHost = null;
screen.Board.Focus();
Dispatcher.UIThread.RunJobs();
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
Dispatcher.UIThread.RunJobs();
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<ListBoxItem>();
vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
});
}
// ---- Helpers ---- // ---- Helpers ----
private static void RightClick(Visual card, Visual window) private static void RightClick(Visual card, Visual window)
@@ -2296,6 +2296,66 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
}, },
Token); Token);
/// <remarks>
/// Three greyed buttons under a list nobody has clicked in read as something broken rather than something
/// waiting. The row appears with a snippet to act on, and goes with it.
/// </remarks>
[Fact]
public async Task TheSnippetButtonsAppearWithASnippetToActOn()
{
await SeedSnippetsAsync();
var snippets = NewSnippetsScreen();
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var screen = new SnippetsScreen { DataContext = snippets };
var window = LayoutHarness.HostAtMinimumSize(
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
try
{
var edit = screen.GetVisualDescendants().OfType<Button>()
.Single(button => button.Content is "Edit");
snippets.Selected = null;
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
edit.IsEffectivelyVisible.ShouldBeFalse("nothing is chosen for it to edit");
snippets.Selected = snippets.Visible.First();
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
edit.IsEffectivelyVisible.ShouldBeTrue();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks>
/// With one vault no row names one, and a fixed VAULT column was a heading over a stripe of nothing
/// between TYPE and USED BY. It is sized to what its rows hold now, and its heading goes with them.
/// </remarks>
[Fact]
public async Task TheKeychainsVaultColumn_TakesNoRoomWithOneVault()
{
await OnTheVaultAsync((screen, _) =>
{
vault.HasVaultItems.ShouldBeTrue("an empty table has no columns to judge");
vault.ShowsVaultColumn.ShouldBeFalse("this suite's keychain has one vault");
var heading = screen.GetVisualDescendants().OfType<TextBlock>()
.Single(block => block.Text is "VAULT");
heading.IsEffectivelyVisible.ShouldBeFalse();
heading.Parent.ShouldBeOfType<Grid>().ColumnDefinitions[3].ActualWidth.ShouldBe(0);
});
}
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) => private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
new( new(
vault, vault,
@@ -250,6 +250,42 @@ public sealed class SessionTabRowTests : IAsyncLifetime
Token); Token);
} }
/// <remarks>
/// A label is whatever the host was called or the address typed into the palette, and nothing bounded it:
/// one long one pushed the "+" past the edge of the strip, behind a scroll bar nobody looks for.
/// </remarks>
[Fact]
public async Task ALongLabelIsTrimmed_AndLeavesThePlusOnTheStrip()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var label = string.Concat(Enumerable.Repeat("a-very-long-host-name-", 12));
shell.Tabs.Add(new TerminalTabViewModel(1, label, "deploy@long.internal:22"));
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
var window = LayoutHarness.HostAtMinimumSize(
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
try
{
var text = strip.GetVisualDescendants().OfType<TextBlock>()
.Single(block => string.Equals(block.Text, label, StringComparison.Ordinal));
text.Bounds.Width.ShouldBeLessThanOrEqualTo(200);
ToolTip.GetTip(text).ShouldBe(label, "the whole of it is still one hover away");
LayoutHarness.Unreachable(window).ShouldBeEmpty();
}
finally
{
window.Close();
}
},
Token);
}
/// <remarks> /// <remarks>
/// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered — /// v5b's tab, unlike the pill it replaced, paints no fill at all until it is either active or hovered —
/// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads /// so "the plus differs from a tab" can no longer be proven against a resting, inactive one, which reads
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
caches.Dispose(); caches.Dispose();
} }
/// <remarks>
/// A double-click on the bar maximises, and a second one restores. It was a <c>DoubleTapped</c> handler
/// beside a press that started a drag, and the drag handed the press to the operating system's move loop,
/// which ate the release a double tap is assembled from — so on Windows the click often did nothing. The
/// press decides now; see <c>CaptionPresses</c>. A single press must still be a drag and nothing else.
/// </remarks>
[Fact]
public async Task DoubleClickingTheBar_MaximisesAndRestores_AndASinglePressDoesNot()
{
await LayoutHarness.OnTheUiThreadAsync(
() =>
{
var bar = new TitleBar { DataContext = shell };
var window = LayoutHarness.HostAtMinimumSize(
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
try
{
// The top edge, past everything the bar draws: the drag surface and nothing else.
var empty = new Point(bar.Bounds.Width * 0.6, 2);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Normal, "one press is a drag");
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Maximized);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.MouseDown(empty, MouseButton.Left);
window.MouseUp(empty, MouseButton.Left);
window.WindowState.ShouldBe(WindowState.Normal);
}
finally
{
window.Close();
}
},
Token);
}
[Fact] [Fact]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette() public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{ {
@@ -0,0 +1,69 @@
using Avalonia.Controls;
using Avalonia.Threading;
using DodoSSH.Client.App.Views;
using DodoSSH.Client.Session;
using DodoSSH.Client.Shell.ViewModels;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Client.Transfer;
using DodoSSH.Crypto;
using NSubstitute;
namespace DodoSSH.Client.App.Layout.Tests;
/// <summary>
/// The real main window over a real shell, for the tests that are about where the keyboard goes.
/// </summary>
/// <remarks>
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says this window cannot be shown here: the
/// terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the tree before
/// the window is shown, which is everything that test is about and nothing these are.
/// </remarks>
internal static class WholeWindow
{
internal const string Passphrase = "a sufficiently long passphrase";
internal static readonly Argon2Profile CheapProfile =
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
internal static TerminalWorkspace Workspace() =>
new(
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
Substitute.For<ISshConnectionFactory>(),
TimeProvider.System);
internal static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
new(
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
caches,
workspace,
new VaultKnownHostStore(),
new UnavailableDeviceKeyStore(),
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
TimeProvider.System,
Substitute.For<ISftpSessionFactory>(),
CheapProfile);
internal static void Show(MainWindowViewModel shell, Action<MainWindow> assert)
{
var window = new MainWindow { DataContext = shell };
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
{
panel.Children.Remove(web);
}
try
{
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
Dispatcher.UIThread.RunJobs();
assert(window);
}
finally
{
window.Close();
}
}
}
@@ -0,0 +1,41 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That the application leaves a record of why it died.
/// </summary>
/// <remarks>
/// An exception nothing caught used to close the window and leave nothing at all behind. Installing the hooks
/// here also installs them in the test process, which is harmless: they only write to this test's own
/// directory, and only when something reaches the top.
/// </remarks>
public sealed class CrashLogTests : IDisposable
{
private readonly string directory =
Path.Combine(Path.GetTempPath(), $"dodossh-crash-{Guid.CreateVersion7():N}");
public void Dispose()
{
if (Directory.Exists(directory))
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void AnExceptionThatReachesTheTop_IsWrittenToTheProfile()
{
var paths = new ClientPaths(directory);
CrashLog.Install(paths);
CrashLog.Write("UI thread", new InvalidOperationException("the thing nobody caught"));
var written = File.ReadAllText(paths.CrashLogFile);
written.ShouldContain("UI thread");
written.ShouldContain("the thing nobody caught");
written.ShouldContain(nameof(InvalidOperationException));
}
}
@@ -0,0 +1,64 @@
using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That unlocking with the device key is described in the words of the platform it runs on.
/// </summary>
/// <remarks>
/// The unlock card offered "USE WINDOWS HELLO" on a Mac and the settings page told a Linux machine it needed a
/// Windows keystore. Each platform's wording is checked for every other platform's names, because that is the
/// mistake: a sentence borrowed from the one platform the feature was written on first.
/// </remarks>
public sealed class DeviceUnlockWordingTests
{
public static TheoryData<string> Platforms => ["Windows", "Mac", "Linux", "Android"];
[Theory]
[MemberData(nameof(Platforms))]
public void EachPlatform_NamesOnlyItsOwnGesture(string platform)
{
var wording = For(platform);
var foreign = new Dictionary<string, string[]>(StringComparer.Ordinal)
{
["Windows"] = ["Touch ID", "Secure Enclave", "fingerprint", "PIN"],
["Mac"] = ["Windows", "TPM", "fingerprint", "PIN"],
["Linux"] = ["Windows", "Touch ID", "Secure Enclave", "fingerprint"],
["Android"] = ["Windows", "TPM", "Touch ID", "Secure Enclave"],
}[platform];
string[] said =
[wording.Name, wording.Button, wording.Wait, wording.Offer, wording.Registered, wording.Unavailable, wording.Tip];
foreach (var sentence in said)
{
sentence.ShouldNotBeNullOrWhiteSpace();
foreach (var name in foreign)
{
sentence.ShouldNotContain(name, Case.Sensitive, $"{platform}: \"{sentence}\"");
}
}
wording.Button.ShouldBe(wording.Button.ToUpperInvariant(), "every button on these screens is in capitals");
}
[Fact]
public void TheWordingInUse_IsThisMachines()
{
var expected = OperatingSystem.IsMacOS() ? DeviceUnlockWording.Mac
: OperatingSystem.IsLinux() ? DeviceUnlockWording.Linux
: DeviceUnlockWording.Windows;
DeviceUnlockWording.ForThisPlatform.ShouldBeSameAs(expected);
}
private static DeviceUnlockWording For(string platform) => platform switch
{
"Windows" => DeviceUnlockWording.Windows,
"Mac" => DeviceUnlockWording.Mac,
"Linux" => DeviceUnlockWording.Linux,
_ => DeviceUnlockWording.Android,
};
}
@@ -0,0 +1,56 @@
using DodoSSH.Client.App.Platform;
using DodoSSH.Client.Session;
using NSubstitute;
namespace DodoSSH.Client.App.Tests;
/// <summary>
/// That the slow probe choosing a device key store runs once, and not on the thread that asked.
/// </summary>
/// <remarks>
/// On Windows the probe creates and deletes a TPM key, and it used to run synchronously on the UI thread at
/// startup and again after every unlock. See <see cref="ProbedDeviceKeyStore"/>.
/// </remarks>
public sealed class ProbedDeviceKeyStoreTests
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
[Fact]
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
{
var asking = Environment.CurrentManagedThreadId;
var probes = 0;
var probedOn = 0;
var inner = Substitute.For<IDeviceKeyStore>();
var store = new ProbedDeviceKeyStore(() =>
{
Interlocked.Increment(ref probes);
probedOn = Environment.CurrentManagedThreadId;
return inner;
});
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
await store.TryLoadAsync(Token);
probes.ShouldBe(1);
probedOn.ShouldNotBe(asking);
// Availability is the choice, not a second probe through the store it chose.
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());
await inner.Received(1).TryLoadAsync(Arg.Any<CancellationToken>());
}
[Fact]
public async Task AMachineWithNoStore_IsUnavailable_AndForgettingIsHarmless()
{
var store = new ProbedDeviceKeyStore(static () => new UnavailableDeviceKeyStore());
(await store.IsAvailableAsync(Token)).ShouldBeFalse();
(await store.TryLoadAsync(Token)).ShouldBeNull();
await store.ForgetAsync(Token);
}
}
@@ -639,6 +639,36 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync"); server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
} }
/// <remarks>
/// Lock pressed while an unlock is still loading. The unlock is held at the point the vault has been handed
/// over and is about to be read — the device-key probe — and the lock lands there. The unlock used to carry
/// on regardless, into a vault the lock had disposed and a shell that no longer held one, and put an
/// exception's message on the lock screen. An earlier lock, before the hand-over, would have left the vault
/// opening behind the lock screen; both are the same check.
/// </remarks>
[Fact]
public async Task LockingWhileAnUnlockIsStillLoading_LeavesItLocked()
{
await EnrolledAndConfirmedAsync();
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
deviceKeys.HoldAvailability = release.Task;
shell.Passphrase = Passphrase;
var unlocking = shell.UnlockCommand.ExecuteAsync(null);
await EventuallyAsync(() => shell.Vault is not null, "the unlock should reach the hand-over and wait there");
await shell.LockCommand.ExecuteAsync(null);
release.SetResult();
await unlocking;
shell.State.ShouldBe(ShellState.Locked);
shell.Vault.ShouldBeNull();
shell.StatusMessage.ShouldBe("Locked.");
}
[Fact] [Fact]
public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo() public async Task AnAutomaticPass_SaysNothingWhenThereIsNothingToDo()
{ {
@@ -656,6 +686,26 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag"); vault.IsBusy.ShouldBeFalse("a background pass must never raise the busy flag");
} }
/// <remarks>
/// A pass that moved nothing used to read the whole keychain back anyway — decrypting every item and
/// rebuilding every list, once a minute. A rebuilt list has new row objects in it, so the row still being
/// the same object is the evidence that nothing was read.
/// </remarks>
[Fact]
public async Task AnAutomaticPassThatMovesNothing_RedrawsNothing()
{
await UnlockedAsync();
var vault = shell.Vault!;
await AddHostAsync(vault, "prod-db");
var row = vault.Hosts.ShouldHaveSingleItem();
await vault.AutoSyncAsync(Token);
vault.Hosts.ShouldHaveSingleItem().ShouldBeSameAs(row);
}
/// <remarks> /// <remarks>
/// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a /// The queue has to be arranged by failing the automatic push first. Written the obvious way — add a
/// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending /// host, then call the pass — this test proved nothing at all: saving pushes, so there was no pending
@@ -2075,6 +2125,15 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
// Still worth offering, because the tab and the machine are both still there — what it is not is an // Still worth offering, because the tab and the machine are both still there — what it is not is an
// alarm. // alarm.
shell.CanReconnectSelectedTab.ShouldBeTrue(); shell.CanReconnectSelectedTab.ShouldBeTrue();
// And the timer stopped with it: how long that shell ran, rather than how long ago it started. The
// shell's clock here is the system's, so the second has to actually pass for the two to differ.
await Task.Delay(TimeSpan.FromSeconds(1.2), Token);
var ran = tab.EndedAt.ShouldNotBeNull() - tab.StartedAt.ShouldNotBeNull();
shell.SessionElapsedText.ShouldBe(string.Create(
CultureInfo.InvariantCulture, $"session {(int)ran.TotalHours:00}:{ran.Minutes:00}:{ran.Seconds:00}"));
} }
/// <remarks> /// <remarks>
@@ -79,6 +79,22 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("Connect to a host first"); transfers.Status.ShouldContain("Connect to a host first");
} }
/// <remarks>
/// A drop landing while another command on this screen was running used to vanish: nothing queued and
/// nothing said. It still queues nothing — the command in flight owns the pane — but it says so.
/// </remarks>
[Fact]
public void DroppingFilesWhileBusy_QueuesNothingAndSaysWhy()
{
Connected();
transfers.IsBusy = true;
transfers.QueueUploads([File("one.txt")]);
Queued().ShouldBeEmpty();
transfers.Status.ShouldContain("Nothing was queued");
}
[Fact] [Fact]
public void DroppingSeveralFiles_QueuesEachOfThem() public void DroppingSeveralFiles_QueuesEachOfThem()
{ {
@@ -53,7 +53,7 @@ public sealed class WindowsDeviceKeyStoreTests
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin); var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
store.ShouldBeOfType<WindowsDeviceKeyStore>(); (await store.ShouldBeOfType<ProbedDeviceKeyStore>().Chosen).ShouldBeOfType<WindowsDeviceKeyStore>();
(await store.IsAvailableAsync(Token)).ShouldBeTrue(); (await store.IsAvailableAsync(Token)).ShouldBeTrue();
} }
@@ -77,9 +77,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported() private static void SkipUnlessSupported()
{ {
var supported = OperatingSystem.IsWindows() var supported = OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported();
&& DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
is WindowsDeviceKeyStore;
if (!supported) if (!supported)
{ {
@@ -25,8 +25,18 @@ internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
internal void Overwrite(byte[] replacement) => key = replacement; internal void Overwrite(byte[] replacement) => key = replacement;
/// <inheritdoc /> /// <inheritdoc />
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) => public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken)
ValueTask.FromResult(IsAvailable); {
if (HoldAvailability is { } hold)
{
await hold.WaitAsync(cancellationToken).ConfigureAwait(false);
}
return IsAvailable;
}
/// <summary>When set, availability is not answered until it completes: an unlock held part-way.</summary>
internal Task? HoldAvailability { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken) public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
@@ -28,6 +28,44 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
{ {
private static CancellationToken Token => TestContext.Current.CancellationToken; private static CancellationToken Token => TestContext.Current.CancellationToken;
/// <remarks>
/// Opening a shell is three round trips to the server, and SSH.NET makes them synchronously. They used to
/// be made on the caller's thread — the UI thread — behind a <c>Task.FromResult</c>, so the task this
/// returned was already finished and the window had stopped painting while it was being made.
/// </remarks>
[Fact]
public async Task OpeningAShell_LeavesTheCallingThreadFree()
{
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
var opening = connection.OpenShellAsync(TerminalSize.Default, Token);
opening.IsCompleted.ShouldBeFalse("three round trips to the server cannot have been made on this thread");
await using var shell = await opening;
}
/// <remarks>
/// The cancellation token used to be read once, before any of those round trips. A caller that gives up
/// now stops waiting at once, and the connection is left able to open the next shell.
/// </remarks>
[Fact]
public async Task AShellGivenUpOn_StopsTheWait_AndTheConnectionOpensAnother()
{
await using var connection = await ConnectTrustedAsync(
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
var opening = connection.OpenShellAsync(TerminalSize.Default, giveUp.Token);
await giveUp.CancelAsync();
await Should.ThrowAsync<OperationCanceledException>(opening);
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
}
[Fact] [Fact]
public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell() public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell()
{ {
@@ -128,6 +128,42 @@ public sealed class CacheStoreTests : IAsyncLifetime
items.ShouldHaveSingleItem().Version.ShouldBe(2); items.ShouldHaveSingleItem().Version.ShouldBe(2);
} }
/// <remarks>
/// A pull's page in one write. What it leaves has to be exactly what the same items saved one at a time
/// would have left: rows that existed replaced, new ones created, and an item given twice ending at the
/// later of the two — a page can carry several changes to the same item.
/// </remarks>
[Fact]
public async Task SavingMany_LeavesWhatSavingThemOneAtATimeWould()
{
var existing = Guid.CreateVersion7();
var fresh = Guid.CreateVersion7();
var twice = Guid.CreateVersion7();
await harness.Items.SaveAsync(Item(existing, version: 1, seed: 1), Token);
await harness.Items.SaveManyAsync(
[
Item(existing, version: 2, changeSequence: 2, seed: 2),
Item(fresh, version: 1, changeSequence: 3, seed: 3),
Item(twice, version: 1, changeSequence: 4, seed: 4),
Item(twice, version: 2, changeSequence: 5, deleted: true),
],
Token);
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, existing, Token))
.ShouldNotBeNull().Version.ShouldBe(2);
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, fresh, Token))
.ShouldNotBeNull().Version.ShouldBe(1);
var last = (await harness.Items.FindAsync(VaultId, SyncEntityType.Host, twice, Token)).ShouldNotBeNull();
last.Version.ShouldBe(2);
last.IsDeleted.ShouldBeTrue();
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).Count.ShouldBe(3);
}
[Fact] [Fact]
public async Task ATombstone_IsHiddenFromTheListButStillFindable() public async Task ATombstone_IsHiddenFromTheListButStillFindable()
{ {
@@ -147,7 +147,8 @@ public sealed class UnpushedDeleteTests
// The ambiguous case, and the reason the attempt count is part of the test rather than just the // The ambiguous case, and the reason the attempt count is part of the test rather than just the
// expected version. A create that was dispatched may have been applied by a server whose answer never // expected version. A create that was dispatched may have been applied by a server whose answer never
// arrived. Dropping it locally would strand a row nobody can see and nobody can delete; a tombstone // arrived. Dropping it locally would strand a row nobody can see and nobody can delete; a tombstone
// that turns out to be unnecessary is refused once and can be dealt with. // that turns out to be unnecessary is refused once, and the refusal is taken as the delete being done —
// see the two tests after this one.
using var harness = await CreateAsync(); using var harness = await CreateAsync();
var laptop = harness.First; var laptop = harness.First;
@@ -166,6 +167,64 @@ public sealed class UnpushedDeleteTests
tombstone.Operation.ShouldBe(SyncOperation.Delete); tombstone.Operation.ShouldBe(SyncOperation.Delete);
} }
/// <remarks>
/// The rest of that story, when the create never did land. The tombstone carries no expected version, and
/// the server refuses it because it has no such item — which is what the delete wanted. It used to be
/// parked as a rejection the user could neither resolve nor dismiss, with a pending count stuck above zero.
/// </remarks>
[Fact]
public async Task ATombstoneForACreateThatNeverLanded_IsDoneWhenTheServerSaysItHasNoSuchItem()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("in-flight"));
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
await laptop.DeleteAsync(entityId);
var report = await laptop.SyncAsync();
report.Parked.ShouldBe(0, "the server not having the item is the delete succeeding");
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
harness.Server.RowCount.ShouldBe(0);
}
/// <remarks>
/// And when it did land, the shortcut above must not be what answers. The pull brings the item back first,
/// at a version the tombstone never knew about, and the engine's ordinary rule for that applies: the delete
/// gives way, the item stays, and the user is told — nothing parked, nothing lost, nothing stuck in the
/// outbox. Deleting it again is then an ordinary delete of an item the server holds.
/// </remarks>
[Fact]
public async Task ATombstoneForACreateThatDidLand_GivesWayToIt_RatherThanBeingTakenAsDone()
{
using var harness = await CreateAsync();
var laptop = harness.First;
var entityId = await laptop.CreateAsync(Host("in-flight"));
// The create reaches the server with its answer lost: the server holds the item while the laptop's own
// row still says "sent, unanswered".
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
harness.Server.ExternalUpsert(entityId, queued.Payload.ShouldNotBeNull(), queued.Fields);
await laptop.DeleteAsync(entityId);
var report = await laptop.SyncAsync();
report.DeletesAbandoned.ShouldBe(1, "the user is told the item came back");
report.Parked.ShouldBe(0);
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
harness.Server.RowCount.ShouldBe(1);
await laptop.DeleteAsync(entityId);
await laptop.SyncAsync();
harness.Server.RowCount.ShouldBe(0, "deleting it again reaches the server");
}
/// <remarks> /// <remarks>
/// The path most likely to meet this in practice, which is why it gets its own test rather than trusting /// The path most likely to meet this in practice, which is why it gets its own test rather than trusting
/// the generic repository: trust is pinned by connecting and withdrawn from the host editor, so trusting a /// the generic repository: trust is pinned by connecting and withdrawn from the host editor, so trusting a
@@ -304,6 +304,36 @@ public sealed class FileTransferQueueTests : IDisposable
finished.Failure.ShouldBe("the host is not reachable"); finished.Failure.ShouldBe("the host is not reachable");
} }
/// <remarks>
/// <para>
/// The pump decides the queue is empty under the lock and only finishes its task after letting go, so a
/// transfer queued in that gap used to find a pump that still looked busy, start none, and sit queued for
/// ever. Queuing each transfer the moment the last one reports finishing is what lands in that gap.
/// </para>
/// <para>
/// A race, so it is run many times rather than arranged once; on the code that had it, a few hundred
/// rounds strand a transfer every time. The session factory refuses at once, which keeps each round to
/// the queue's own bookkeeping — the part the race is in.
/// </para>
/// </remarks>
[Fact]
public async Task ATransferQueuedAsTheLastOneFinishes_StillRuns()
{
await using var queue = new FileTransferQueue(
_ => Task.FromException<IRemoteFileStore>(new IOException("refused at once")),
TimeProvider.System);
for (var round = 0; round < 500; round++)
{
var finished = await RunToCompletionAsync(
queue, TransferDirection.Download, Local($"round-{round}"), Remote($"round-{round}"), 1);
finished.State.ShouldBe(TransferState.Failed, $"round {round}");
}
queue.IsBusy.ShouldBeFalse();
}
private FileTransferQueue NewQueue() => private FileTransferQueue NewQueue() =>
new(_ => Task.FromResult<IRemoteFileStore>(host), TimeProvider.System); new(_ => Task.FromResult<IRemoteFileStore>(host), TimeProvider.System);
@@ -371,7 +401,7 @@ public sealed class FileTransferQueueTests : IDisposable
{ {
start(); start();
return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token); return await finished.Task.WaitAsync(TimeSpan.FromSeconds(10), Token);
} }
finally finally
{ {