diff --git a/src/DodoSSH.Api/Features/Events/VaultEventFanOut.cs b/src/DodoSSH.Api/Features/Events/VaultEventFanOut.cs
new file mode 100644
index 0000000..c63aaf6
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Events/VaultEventFanOut.cs
@@ -0,0 +1,24 @@
+using DodoSSH.Api.Features.Relay;
+
+namespace DodoSSH.Api.Features.Events;
+
+///
+/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
+///
+///
+/// What the write paths are given as their . 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.
+///
+internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
+{
+ ///
+ public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
+
+ ///
+ public void VaultAccessChanged(Guid userId)
+ {
+ hub.VaultAccessChanged(userId);
+ _ = relay.AccessChangedAsync(userId);
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
index 8e61083..7bf49c5 100644
--- a/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
+++ b/src/DodoSSH.Api/Features/Identity/EnrollmentService.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using System.Text.Json;
+using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Crypto;
using DodoSSH.Domain;
@@ -106,9 +107,7 @@ internal sealed class EnrollmentService(
byte[] fingerprint,
CancellationToken cancellationToken)
{
- var strategy = database.Database.CreateExecutionStrategy();
-
- return await strategy.ExecuteAsync(async () =>
+ return await database.ExecuteFreshAsync([user], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Features/Relay/RelayAccessWatch.cs b/src/DodoSSH.Api/Features/Relay/RelayAccessWatch.cs
new file mode 100644
index 0000000..72fe818
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Relay/RelayAccessWatch.cs
@@ -0,0 +1,62 @@
+using DodoSSH.Api.Authorization;
+using DodoSSH.Domain.Authorization;
+
+namespace DodoSSH.Api.Features.Relay;
+
+///
+/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
+///
+///
+///
+/// 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.
+///
+///
+/// Told by the same announcement that tells their event socket to re-read its vaults — see
+/// — 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.
+///
+///
+internal sealed class RelayAccessWatch(
+ RelaySessionRegistry sessions,
+ IServiceScopeFactory scopes,
+ ILogger logger)
+{
+ /// Re-checks every session the user has open, in the background.
+ /// The re-check, for a caller that wants to wait for it; the announcement does not.
+ 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 open)
+ {
+ try
+ {
+ var scope = scopes.CreateAsyncScope();
+ await using var _ = scope.ConfigureAwait(false);
+
+ var access = scope.ServiceProvider.GetRequiredService();
+
+ 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);
+ }
+ }
+}
diff --git a/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs b/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs
index d440f87..a964adb 100644
--- a/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs
+++ b/src/DodoSSH.Api/Features/Relay/RelayConnectEndpoint.cs
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
return;
}
- var lease = sessions.TryAdmit(ticket.UserId);
+ var lease = sessions.TryAdmit(ticket.UserId, ticket.VaultId);
try
{
@@ -113,7 +113,7 @@ internal sealed class RelayConnectEndpoint(
return;
}
- await RelayAsync(socket, ticket, ct).ConfigureAwait(false);
+ await RelayAsync(socket, ticket, lease.Revoked, ct).ConfigureAwait(false);
}
finally
{
@@ -239,7 +239,11 @@ internal sealed class RelayConnectEndpoint(
}
/// Dials the target, pipes until something ends it, and writes down what happened.
- 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();
@@ -269,7 +273,7 @@ internal sealed class RelayConnectEndpoint(
{
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(
@@ -338,13 +342,14 @@ internal sealed class RelayConnectEndpoint(
Stream target,
Counters counters,
DateTimeOffset started,
+ CancellationToken revoked,
CancellationToken requestAborted)
{
using var pump = CancellationTokenSource.CreateLinkedTokenSource(requestAborted);
var upstream = ClientToTargetAsync(socket, target, 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 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.
///
///
- private async Task MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
+ private async Task MindAsync(
+ Counters counters,
+ DateTimeOffset started,
+ CancellationToken revoked,
+ CancellationToken cancellationToken)
{
var settings = options.Value;
var hardDeadline = started + settings.MaxSessionDuration;
@@ -527,27 +536,11 @@ internal sealed class RelayConnectEndpoint(
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;
- if (now >= idleDeadline)
+ if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
{
- return new Ending(
- RelayCloseReason.IdleTimeout,
- RelayProtocol.IdleTimeoutCloseCode,
- "This relay session carried nothing for the idle timeout.");
+ return ending;
}
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
// than from whenever the idle clock next came round.
using var wake = drainDeadline is null
- ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, lifetime.ApplicationStopping)
- : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ ? CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked, lifetime.ApplicationStopping)
+ : CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
try
{
@@ -564,7 +557,7 @@ internal sealed class RelayConnectEndpoint(
}
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)
{
@@ -575,6 +568,50 @@ internal sealed class RelayConnectEndpoint(
return null;
}
+ /// Whichever reason to end the session has come due, or null while none has.
+ ///
+ /// 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.
+ ///
+ 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; } =
new(RelayCloseReason.ClientClosed, (int)WebSocketCloseStatus.NormalClosure, string.Empty);
diff --git a/src/DodoSSH.Api/Features/Relay/RelayLog.cs b/src/DodoSSH.Api/Features/Relay/RelayLog.cs
index 3ea1b46..463faf1 100644
--- a/src/DodoSSH.Api/Features/Relay/RelayLog.cs
+++ b/src/DodoSSH.Api/Features/Relay/RelayLog.cs
@@ -91,4 +91,14 @@ internal static partial class RelayLog
Level = LogLevel.Warning,
Message = "Relay session {SessionId} ended but its audit record could not be closed.")]
internal static partial void AuditCloseFailed(ILogger logger, Guid sessionId, Exception exception);
+
+ ///
+ /// 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.
+ ///
+ [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);
}
diff --git a/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs b/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs
index a2b3514..68c2a6d 100644
--- a/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs
+++ b/src/DodoSSH.Api/Features/Relay/RelaySessionRegistry.cs
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions options)
{
private readonly Lock gate = new();
private readonly Dictionary perUser = [];
+ private readonly HashSet open = [];
private int total;
/// How many sessions are open on this node.
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions options)
/// Admits a session, or answers null when a limit is met.
/// A lease that gives the place back when disposed.
- internal Lease? TryAdmit(Guid userId)
+ /// The sessions a user has open now, for re-checking their access.
+ internal IReadOnlyList SessionsOf(Guid userId)
+ {
+ lock (gate)
+ {
+ return [.. open.Where(lease => lease.UserId == userId)];
+ }
+ }
+
+ internal Lease? TryAdmit(Guid userId, Guid vaultId)
{
var settings = options.Value;
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions options)
perUser[userId] = held + 1;
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)
{
+ open.Remove(lease);
total--;
// 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 options)
}
/// One admitted session's place. Idempotent to dispose.
- 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;
+ /// Whose session this is.
+ internal Guid UserId { get; } = userId;
+
+ /// The vault the ticket's host is filed in, which is what the session's access hangs on.
+ internal Guid VaultId { get; } = vaultId;
+
+ /// Cancelled when the session's user loses access to .
+ internal CancellationToken Revoked => revoked.Token;
+
+ /// Ends the session, because its user can no longer reach the vault it was opened for.
+ internal void Revoke()
+ {
+ try
+ {
+ revoked.Cancel();
+ }
+ catch (ObjectDisposedException)
+ {
+ // Already over.
+ }
+ }
+
///
public void Dispose()
{
if (Interlocked.Exchange(ref released, 1) == 0)
{
- registry.Release(userId);
+ registry.Release(this, UserId);
+ revoked.Dispose();
}
}
}
diff --git a/src/DodoSSH.Api/Features/Sync/SyncService.cs b/src/DodoSSH.Api/Features/Sync/SyncService.cs
index 7245aaf..bd59d94 100644
--- a/src/DodoSSH.Api/Features/Sync/SyncService.cs
+++ b/src/DodoSSH.Api/Features/Sync/SyncService.cs
@@ -100,9 +100,7 @@ internal sealed class SyncService(
{
ValidateBatchLimits(request);
- var strategy = database.Database.CreateExecutionStrategy();
-
- return await strategy.ExecuteAsync(async () =>
+ return await database.ExecuteFreshAsync([vault], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Features/Teams/TeamLock.cs b/src/DodoSSH.Api/Features/Teams/TeamLock.cs
new file mode 100644
index 0000000..c4ee3ec
--- /dev/null
+++ b/src/DodoSSH.Api/Features/Teams/TeamLock.cs
@@ -0,0 +1,27 @@
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Features.Teams;
+
+///
+/// Serialises the writes that decide what a team owns, for the life of the transaction.
+///
+///
+///
+/// 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.
+///
+///
+/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
+/// Requires Multiplexing=false on the connection, as that one does.
+///
+///
+internal static class TeamLock
+{
+ internal static Task AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
+ database.Database.ExecuteSqlAsync(
+ $"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
+ cancellationToken);
+}
diff --git a/src/DodoSSH.Api/Features/Teams/TeamService.cs b/src/DodoSSH.Api/Features/Teams/TeamService.cs
index ec952fd..44e8828 100644
--- a/src/DodoSSH.Api/Features/Teams/TeamService.cs
+++ b/src/DodoSSH.Api/Features/Teams/TeamService.cs
@@ -1,5 +1,6 @@
using System.Globalization;
using DodoSSH.Api.Features.Events;
+using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -254,26 +255,27 @@ internal sealed class TeamService(
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 strategy = database.Database.CreateExecutionStrategy();
-
- var archived = await strategy.ExecuteAsync(async () =>
+ var archived = await database.ExecuteFreshAsync([team], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
.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
.Where(m => m.TeamId == team.Id && m.DeletedAtUtc == null)
.ToListAsync(cancellationToken)
@@ -343,9 +345,7 @@ internal sealed class TeamService(
var incoming = await RequireMembershipAsync(teamId, request.UserId, cancellationToken)
.ConfigureAwait(false);
- var strategy = database.Database.CreateExecutionStrategy();
-
- await strategy.ExecuteAsync(async () =>
+ await database.ExecuteFreshAsync([incoming, outgoing], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
@@ -724,9 +724,7 @@ internal sealed class TeamService(
}
var now = clock.GetUtcNow();
- var strategy = database.Database.CreateExecutionStrategy();
-
- var revoked = await strategy.ExecuteAsync(async () =>
+ var revoked = await database.ExecuteFreshAsync([membership], async () =>
{
var transaction = await database.Database
.BeginTransactionAsync(cancellationToken)
diff --git a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
index 3506c01..4587243 100644
--- a/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
+++ b/src/DodoSSH.Api/Features/Teams/VaultGrantService.cs
@@ -1,5 +1,6 @@
using System.Security.Cryptography;
using DodoSSH.Api.Features.Events;
+using DodoSSH.Api.Setup;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -76,19 +77,7 @@ internal sealed class VaultGrantService(
"That vault id is already in use. Generate a new UUIDv7 and retry.");
}
- 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 WriteVaultAsync(user, team, request, name, key, cancellationToken).ConfigureAwait(false);
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
@@ -103,6 +92,55 @@ internal sealed class VaultGrantService(
RekeyRequired: false);
}
+ /// Writes the vault and its first grant, under the team's lock, in one transaction.
+ 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);
+ });
+
+ /// Refuses a vault for a team archived since the request resolved it.
+ 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.");
+ }
+ }
+
/// Adds the vault row and the creator's own grant, in one unit of work.
private void AddVaultWithSelfGrant(
UserAccount user,
diff --git a/src/DodoSSH.Api/Program.cs b/src/DodoSSH.Api/Program.cs
index a1a99dd..fa5bc2e 100644
--- a/src/DodoSSH.Api/Program.cs
+++ b/src/DodoSSH.Api/Program.cs
@@ -41,13 +41,12 @@ builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddSingleton();
-// A singleton, because the sockets it holds outlive the requests that opened them. Registered twice
-// resolving to the same instance: the endpoint needs the whole hub — admit, remove, count — while the
-// write paths that announce a change need only the two methods that announce one, and should not gain
-// a reference to connection management to get them.
+// A singleton, because the sockets it holds outlive the requests that opened them. The endpoint needs the
+// whole hub — admit, remove, count — while the write paths that announce a change need only the two methods
+// that announce one, and get them from a fan-out that also tells the relay: a user who loses access to a
+// vault loses their relay sessions into it too. See VaultEventFanOut and RelayAccessWatch.
builder.Services.AddSingleton();
-builder.Services.AddSingleton(
- provider => provider.GetRequiredService());
+builder.Services.AddSingleton();
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
builder.Services.AddDodoRelay();
diff --git a/src/DodoSSH.Api/Setup/FreshAttempts.cs b/src/DodoSSH.Api/Setup/FreshAttempts.cs
new file mode 100644
index 0000000..30e5f99
--- /dev/null
+++ b/src/DodoSSH.Api/Setup/FreshAttempts.cs
@@ -0,0 +1,72 @@
+using DodoSSH.Infrastructure;
+using Microsoft.EntityFrameworkCore;
+
+namespace DodoSSH.Api.Setup;
+
+///
+/// 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.
+///
+///
+///
+/// The strategy re-runs the delegate on the same context, and the context remembers the attempt that
+/// failed. When the failure is the commit itself — a connection dropped after SaveChanges 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 EnrolledAtUtc that way, and a team hand-over
+/// its two role changes, each reporting success.
+///
+///
+/// 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.
+///
+///
+internal static class FreshAttempts
+{
+ /// Runs under the context's execution strategy, freshly each time.
+ /// The request's context.
+ /// Entities loaded before the transaction that the attempt reads or modifies.
+ /// The transaction, which begins and commits its own.
+ internal static Task ExecuteFreshAsync(
+ this DodoDbContext database,
+ IReadOnlyList carried,
+ Func> 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);
+ });
+ }
+
+ ///
+ internal static Task ExecuteFreshAsync(
+ this DodoDbContext database,
+ IReadOnlyList carried,
+ Func attempt) =>
+ database.ExecuteFreshAsync(
+ carried,
+ async () =>
+ {
+ await attempt().ConfigureAwait(false);
+ return true;
+ });
+}
diff --git a/src/DodoSSH.Api/Setup/Persistence.cs b/src/DodoSSH.Api/Setup/Persistence.cs
index f50d21d..aca0a80 100644
--- a/src/DodoSSH.Api/Setup/Persistence.cs
+++ b/src/DodoSSH.Api/Setup/Persistence.cs
@@ -1,5 +1,6 @@
using DodoSSH.Infrastructure;
using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Options;
using Npgsql;
@@ -29,14 +30,18 @@ internal static class Persistence
?? throw new InvalidOperationException(
$"ConnectionStrings:{ConnectionStringName} is not configured.");
- services.AddDbContext(options => options
+ services.AddDbContext((provider, options) => options
.UseNpgsql(connectionString, npgsql => npgsql
.MigrationsHistoryTable("__EFMigrationsHistory", DodoDbContext.SchemaName)
- // Retry on transient faults only. Note this is safe here because writes go
- // through explicitly-managed transactions rather than relying on the execution
- // strategy to replay ambient ones.
+ // Retry on transient faults only. A write transaction replayed by the strategy runs on
+ // the same context, which still holds what the failed attempt tracked — every such
+ // transaction goes through FreshAttempts.ExecuteFreshAsync, which starts a retry from
+ // the database instead.
.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()));
services.AddHealthChecks()
.AddCheck(
diff --git a/src/DodoSSH.Api/Setup/RelaySetup.cs b/src/DodoSSH.Api/Setup/RelaySetup.cs
index cee1ca0..68ccb22 100644
--- a/src/DodoSSH.Api/Setup/RelaySetup.cs
+++ b/src/DodoSSH.Api/Setup/RelaySetup.cs
@@ -24,6 +24,7 @@ internal static class RelaySetup
// the network seam holds no state.
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
// The application name isolates this deployment's payloads from any other ASP.NET application
diff --git a/src/DodoSSH.Client.App/App.axaml.cs b/src/DodoSSH.Client.App/App.axaml.cs
index b49c046..0849859 100644
--- a/src/DodoSSH.Client.App/App.axaml.cs
+++ b/src/DodoSSH.Client.App/App.axaml.cs
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
{
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();
ShowSomethingWhileComposing(desktop);
}
diff --git a/src/DodoSSH.Client.App/Platform/CrashLog.cs b/src/DodoSSH.Client.App/Platform/CrashLog.cs
new file mode 100644
index 0000000..4168e4f
--- /dev/null
+++ b/src/DodoSSH.Client.App/Platform/CrashLog.cs
@@ -0,0 +1,85 @@
+using DodoSSH.Client.Session;
+
+namespace DodoSSH.Client.App.Platform;
+
+///
+/// Writes down why the application died, in the profile, before it does.
+///
+///
+///
+/// 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.
+///
+///
+/// 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.
+///
+///
+internal static class CrashLog
+{
+ /// Past this, the next entry starts a fresh file.
+ private const long MaxBytes = 1024 * 1024;
+
+ private static readonly Lock Gate = new();
+
+ private static string? file;
+
+ /// Starts recording, for the process's two last-chance hooks.
+ ///
+ /// The UI thread's own hook is added by the application once Avalonia is running — see
+ /// — because the dispatcher must not be touched before the platform
+ /// has set it up.
+ ///
+ 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();
+ };
+ }
+
+ /// The UI thread's hook: records the exception and lets it take the process as before.
+ internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
+
+ /// Appends one entry; never throws.
+ 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.
+ }
+ }
+ }
+}
diff --git a/src/DodoSSH.Client.App/Platform/ProbedDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/ProbedDeviceKeyStore.cs
new file mode 100644
index 0000000..e8c290a
--- /dev/null
+++ b/src/DodoSSH.Client.App/Platform/ProbedDeviceKeyStore.cs
@@ -0,0 +1,62 @@
+using DodoSSH.Client.Session;
+
+namespace DodoSSH.Client.App.Platform;
+
+///
+/// The device key store this machine supports, chosen once and off the UI thread.
+///
+///
+///
+/// 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 — 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.
+///
+///
+/// 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.
+///
+///
+internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
+{
+ private readonly Task chosen;
+
+ /// Starts choosing at once, on the thread pool.
+ internal ProbedDeviceKeyStore(Func choose)
+ {
+ ArgumentNullException.ThrowIfNull(choose);
+ chosen = Task.Run(choose);
+ }
+
+ /// The store the probe settled on, for a test that needs to know which.
+ internal Task Chosen => chosen;
+
+ ///
+ public async ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
+ await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
+
+ ///
+ public async ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken)
+ {
+ var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
+ await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask TryLoadAsync(CancellationToken cancellationToken)
+ {
+ var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
+ return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ public async ValueTask ForgetAsync(CancellationToken cancellationToken)
+ {
+ var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
+ await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
index c88b659..d3f7fb6 100644
--- a/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
+++ b/src/DodoSSH.Client.App/Platform/WindowsDeviceKeyStore.cs
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
///
public static class DesktopDeviceKeyStores
{
- /// The best store this machine supports.
+ /// The best store this machine supports, chosen on the thread pool.
+ ///
+ /// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
+ /// Windows — and runs behind rather than here, which is on the UI
+ /// thread at startup.
+ ///
/// Where a stored key lives.
///
/// 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(askForPin);
+ return new ProbedDeviceKeyStore(() => Choose(paths, askForPin));
+ }
+
+ private static IDeviceKeyStore Choose(
+ ClientPaths paths,
+ Func> askForPin)
+ {
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
{
return new WindowsDeviceKeyStore(paths);
diff --git a/src/DodoSSH.Client.App/Program.cs b/src/DodoSSH.Client.App/Program.cs
index 54c4d15..2d26c22 100644
--- a/src/DodoSSH.Client.App/Program.cs
+++ b/src/DodoSSH.Client.App/Program.cs
@@ -75,6 +75,9 @@ internal static class Program
LaunchLink = link;
+ // After the claim, so a second launch that only hands its link over writes nothing.
+ CrashLog.Install(paths);
+
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
}
diff --git a/src/DodoSSH.Client.App/Views/CaptionPresses.cs b/src/DodoSSH.Client.App/Views/CaptionPresses.cs
new file mode 100644
index 0000000..63ccb7a
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/CaptionPresses.cs
@@ -0,0 +1,54 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Input;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Tells a drawn titlebar's double-click from a press that starts a drag.
+///
+///
+///
+/// Both titlebars used to answer a double-click through DoubleTapped and start a drag on every press.
+/// The two do not mix: BeginMoveDrag 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 and 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.
+///
+///
+/// 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.
+///
+///
+internal sealed class CaptionPresses
+{
+ private ulong lastAt;
+
+ private Point lastWhere;
+
+ private bool waitingForASecond;
+
+ /// Whether this press is the second of a double-click on .
+ 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;
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
index 93b1e01..751141d 100644
--- a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
+++ b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml
@@ -59,7 +59,7 @@
-
+
diff --git a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
index 298eed1..88efb73 100644
--- a/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/ConfirmDeleteCard.axaml.cs
@@ -1,4 +1,5 @@
using Avalonia.Controls;
+using DodoSSH.Client.Shell.ViewModels;
namespace DodoSSH.Client.App.Views;
@@ -12,5 +13,10 @@ namespace DodoSSH.Client.App.Views;
///
internal sealed partial class ConfirmDeleteCard : UserControl
{
- public ConfirmDeleteCard() => InitializeComponent();
+ public ConfirmDeleteCard()
+ {
+ InitializeComponent();
+
+ SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
index e878d3c..f743fcb 100644
--- a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
+++ b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml
@@ -45,7 +45,11 @@
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.
-->
-
+
+
@@ -83,7 +87,7 @@
-
+
@@ -119,7 +123,7 @@
is not an answer, because this half of the control has none to give. Compare the accent button
above, which connects.
-->
-
diff --git a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
index 61a4347..c2b75ac 100644
--- a/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostKeyCard.axaml.cs
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
internal sealed partial class HostKeyCard : UserControl
{
public HostKeyCard() => InitializeComponent();
+
+ /// The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.
+ ///
+ /// Never TRUST AND CONNECT, for the reason 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
+ /// MainWindow.OnHostKeyDecision .
+ ///
+ internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
}
diff --git a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
index aef33aa..794a00a 100644
--- a/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/HostsScreen.axaml.cs
@@ -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.
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
// 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
@@ -315,6 +322,16 @@ internal sealed partial class HostsScreen : UserControl
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)
{
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 .
///
///
+ /// Enter on a card is the double-press, for somebody who got there with the arrows.
+ ///
+ /// Only on a card, for the reason 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.
+ ///
+ 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;
+ }
+
+ /// Writes a selection the arrows made in one section's list back to the vault.
+ ///
+ ///
+ /// The cards' SelectedItem 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.
+ ///
+ ///
+ /// ◆ 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
+ /// has already written the same card, and changes nothing.
+ ///
+ ///
+ 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;
+ }
+
+ /// Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.
+ private bool EnterTheCards(VaultViewModel vault)
+ {
+ var cards = Board.GetVisualDescendants().OfType().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)
{
EndBand();
diff --git a/src/DodoSSH.Client.App/Views/KeychainScreen.axaml b/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
index 05d7eae..11a867d 100644
--- a/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/KeychainScreen.axaml
@@ -256,7 +256,8 @@
-
+
@@ -275,12 +276,24 @@
item this application stores carries a timestamp; see the file-level remark above and
docs/design-import-gaps.md.
-->
-
+
+
+
+
+
+
+
+
-
+
@@ -290,7 +303,14 @@
Margin="8,0,8,10">
-
+
+
+
+
+
+
+
+
+ PlaceholderText="https://dodossh.example">
+
+
+
+
+
@@ -395,8 +400,14 @@
-
-
+
+
+
+
+
+
+
diff --git a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
index cdc73c1..31d8f5f 100644
--- a/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/MainWindow.axaml.cs
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
private bool wasUnlocked;
+ /// Which closed-vault card the keyboard was last sent to, so it is sent once per card.
+ private ShellState? closedState;
+
+ private bool wasAskingAboutHostKey;
+
public MainWindow()
{
InitializeComponent();
@@ -184,6 +189,7 @@ internal sealed partial class MainWindow : Window
{
ShellState.Locked => UnlockPane.PassphraseBox,
ShellState.NeedsServer => ServerUrlBox,
+ ShellState.NeedsEnrollment => NewPassphraseBox,
_ => this,
};
@@ -226,40 +232,96 @@ internal sealed partial class MainWindow : Window
size.Execute(null);
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)
{
Palette.HandleKey(e);
}
- // v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape
- // 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)
+ else if (e.Key == Key.Escape && CloseTheNearestThing(viewModel))
{
- 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;
}
base.OnKeyDown(e);
}
+ /// What Escape closes when the palette is not up: the thing drawn nearest the user.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ 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;
+ }
+
+ /// Escape on a confirmation drawn in place: the sign-out one, or a deletion's.
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
///
/// The text-size chords, when the terminal is not the thing hearing them.
///
@@ -307,6 +369,15 @@ internal sealed partial class MainWindow : Window
Terminal.Source = viewModel.TerminalPageUrl;
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.TerminalFocusRequested += OnTerminalFocusRequested;
@@ -365,6 +436,10 @@ internal sealed partial class MainWindow : Window
OnSelectedTabChanged(viewModel);
break;
+ case nameof(MainWindowViewModel.IsHostKeyDecisionShowing):
+ OnHostKeyDecision(viewModel);
+ break;
+
default:
break;
}
@@ -380,10 +455,77 @@ internal sealed partial class MainWindow : Window
{
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
}
+ else if (!unlocked && viewModel.State != closedState)
+ {
+ FocusClosedVaultCard(viewModel);
+ }
+ closedState = unlocked ? null : viewModel.State;
wasUnlocked = unlocked;
}
+ /// Hands the keyboard to the box on whichever card a closed vault is showing.
+ ///
+ /// 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.
+ ///
+ private void FocusClosedVaultCard(MainWindowViewModel viewModel)
+ {
+ closedState = viewModel.State;
+
+ var home = ClosedVaultKeyboardHome;
+
+ Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
+ }
+
+ /// Gives the host-key card the keyboard when it appears, and gives it back when it goes.
+ ///
+ ///
+ /// 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 rather than Focus() , and
+ /// posted, because the binding that reveals the card has not run yet.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ 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);
+ }
+
///
/// 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,
diff --git a/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs b/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs
new file mode 100644
index 0000000..dae7c53
--- /dev/null
+++ b/src/DodoSSH.Client.App/Views/SafeAnswerFocus.cs
@@ -0,0 +1,81 @@
+using System.ComponentModel;
+using Avalonia.Controls;
+using Avalonia.Threading;
+using Avalonia.VisualTree;
+
+namespace DodoSSH.Client.App.Views;
+
+///
+/// Hands the keyboard to a confirmation's harmless answer when the question appears.
+///
+///
+///
+/// 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.
+///
+///
+/// ◆ The harmless answer, never the one the question is about. 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.
+///
+///
+/// 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 IsEffectivelyVisible 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.
+///
+///
+internal static class SafeAnswerFocus
+{
+ internal static void Follow(Control card, string question, Func 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);
+ }
+ };
+ }
+}
diff --git a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
index 2a63eec..5ddcc8b 100644
--- a/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
+++ b/src/DodoSSH.Client.App/Views/SessionTabRow.axaml
@@ -86,7 +86,14 @@
Classes.connecting="{Binding IsConnecting}"
Classes.dropped="{Binding IsDropped}"
VerticalAlignment="Center" />
-
+
+
+ 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." />
-
+
-
+
+
-
-
+
@@ -97,7 +101,7 @@
-
+
@@ -107,14 +111,7 @@
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
Text="{Binding Description}" />
-
-
-
-
+
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
index 05bba60..f8b3228 100644
--- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml
@@ -26,7 +26,7 @@
-->
+ PointerPressed="OnDrag">
diff --git a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
index d48ec3e..cb0765c 100644
--- a/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/SettingsTitleBar.axaml.cs
@@ -9,6 +9,8 @@ internal sealed partial class SettingsTitleBar : UserControl
{
public SettingsTitleBar() => InitializeComponent();
+ private readonly CaptionPresses presses = new();
+
private Window? Host => TopLevel.GetTopLevel(this) as Window;
/// Left button only, and only on a press nothing inside the bar has already handled.
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
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);
}
@@ -31,6 +42,7 @@ internal sealed partial class SettingsTitleBar : UserControl
}
/// Both the button and a double-click on the bar arrive here, as Windows convention expects.
+ /// The double-click by way of ; see .
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
if (Host is not { } window)
diff --git a/src/DodoSSH.Client.App/Views/SignOutCard.axaml b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
index d9c4343..d2bfcfd 100644
--- a/src/DodoSSH.Client.App/Views/SignOutCard.axaml
+++ b/src/DodoSSH.Client.App/Views/SignOutCard.axaml
@@ -55,7 +55,7 @@
-
+
The sign-out confirmation, shown on the preferences screen and on the unlock screen.
internal sealed partial class SignOutCard : UserControl
{
- public SignOutCard() => InitializeComponent();
+ public SignOutCard()
+ {
+ InitializeComponent();
+
+ SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
+ }
}
diff --git a/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
index 2bcdc33..2b7c821 100644
--- a/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
+++ b/src/DodoSSH.Client.App/Views/SnippetsScreen.axaml
@@ -167,8 +167,12 @@
TextAlignment="Center" MaxWidth="360"
IsVisible="{Binding !HasVisible}" />
+
+ BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml b/src/DodoSSH.Client.App/Views/TitleBar.axaml
index 6dbf537..b01154c 100644
--- a/src/DodoSSH.Client.App/Views/TitleBar.axaml
+++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml
@@ -32,7 +32,7 @@
-->
+ PointerPressed="OnDrag">
diff --git a/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
index 7452211..5276ef1 100644
--- a/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
+++ b/src/DodoSSH.Client.App/Views/TitleBar.axaml.cs
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
}
}
+ private readonly CaptionPresses presses = new();
+
private Window? Host => TopLevel.GetTopLevel(this) as Window;
///
@@ -55,6 +57,15 @@ internal sealed partial class TitleBar : UserControl
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);
}
@@ -68,7 +79,8 @@ internal sealed partial class TitleBar : UserControl
///
/// 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
+ /// rather than DoubleTapped ; see .
///
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
{
diff --git a/src/DodoSSH.Client.App/Views/UnlockCard.axaml b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
index 479a538..a543c87 100644
--- a/src/DodoSSH.Client.App/Views/UnlockCard.axaml
+++ b/src/DodoSSH.Client.App/Views/UnlockCard.axaml
@@ -44,15 +44,15 @@
IsEnabled="{Binding !IsBusy}" />
-
+ ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
diff --git a/src/DodoSSH.Client.Session/ClientPaths.cs b/src/DodoSSH.Client.Session/ClientPaths.cs
index e441592..fff2503 100644
--- a/src/DodoSSH.Client.Session/ClientPaths.cs
+++ b/src/DodoSSH.Client.Session/ClientPaths.cs
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
///
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
+ /// Where the application writes down why it died, when something nothing caught ends it.
+ public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
+
/// Creates the profile directory if it is not there yet.
///
/// Separate from resolving the path, because resolving must never have a side effect: it is read
diff --git a/src/DodoSSH.Client.Session/ServerRelay.cs b/src/DodoSSH.Client.Session/ServerRelay.cs
index c2f44c3..25289e9 100644
--- a/src/DodoSSH.Client.Session/ServerRelay.cs
+++ b/src/DodoSSH.Client.Session/ServerRelay.cs
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IConnectionRoute
RelayProtocol.DurationLimitCloseCode =>
"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,
};
}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/DeviceUnlockWording.cs b/src/DodoSSH.Client.Shell/ViewModels/DeviceUnlockWording.cs
new file mode 100644
index 0000000..a082716
--- /dev/null
+++ b/src/DodoSSH.Client.Shell/ViewModels/DeviceUnlockWording.cs
@@ -0,0 +1,85 @@
+namespace DodoSSH.Client.Shell.ViewModels;
+
+///
+/// What unlocking with this machine's device key is called here, and what it asks the person for.
+///
+///
+///
+/// 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 WindowsDeviceKeyStore , MacDeviceKeyStore and
+/// LinuxDeviceKeyStore — 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.
+///
+///
+/// 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.
+///
+///
+/// What the feature is called, as a settings row's title.
+/// The unlock card's button, in the capitals every button here uses.
+/// The status line while the platform's prompt is up.
+/// What opens the keychain instead of the passphrase, to finish a sentence.
+/// What holds the key, to finish "Registered · …".
+/// What a machine with no device key store lacks, as a sentence of its own.
+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.");
+
+ /// The wording for the platform this process is running on.
+ internal static DeviceUnlockWording ForThisPlatform { get; } =
+ OperatingSystem.IsAndroid() ? Android
+ : OperatingSystem.IsMacOS() ? Mac
+ : OperatingSystem.IsLinux() ? Linux
+ : Windows;
+
+ /// The settings caption for a machine that can register.
+ internal string Offer =>
+ $"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
+
+ /// The settings caption for a machine that has.
+ internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
+
+ /// The settings caption for a machine that cannot.
+ internal string Unavailable =>
+ $"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
+
+ /// The unlock button's tooltip.
+ internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
+}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
index e84e703..e1c588c 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
@@ -2133,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
- await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
- .ConfigureAwait(true);
+ try
+ {
+ 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
- // second means the menu and the screen behind it never disagree, even for a frame.
- await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
+ // The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing
+ // it second means the menu and the screen behind it never disagree, even for a frame.
+ 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();
@@ -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
/// deciding whether the prompt in front of them is the one this application asked for.
///
- private static string GestureWait =>
- OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
+ private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
+
+ /// What unlocking with the device key is called on this platform; the views read it whole.
+ internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
/// Opens the vault with this machine's device key instead of the passphrase.
///
@@ -2948,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
///
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);
// 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.
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,
workspace,
knownHosts,
@@ -2978,9 +3004,30 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// are both !CanRegisterDevice, and only the second has anything to withdraw.
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
- await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
+ await OpenAsync(opened, generation, cancellationToken).ConfigureAwait(true);
+ }
- AfterTheFirstLoad(Vault);
+ /// Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.
+ ///
+ /// 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.
+ ///
+ 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
// 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
// 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.
- Vault.StartAutoSync();
+ opened.StartAutoSync();
+ }
+
+ /// Puts away an unlock that a lock overtook before the vault was handed over.
+ ///
+ /// Everything 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.
+ ///
+ private async Task AbandonAsync(VaultSession session)
+ {
+ knownHosts.Close();
+ connectionLog.Close();
+ visibility = null;
+
+ await session.DisposeAsync().ConfigureAwait(true);
}
/// Everything an unlock does that needs the vault's lists to have been read.
@@ -3257,6 +3318,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
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
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen.
@@ -3304,6 +3369,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[ObservableProperty]
private bool isConfirmingSignOut;
+ /// How many times this shell has been locked, for an unlock to notice it was overtaken.
+ private int locks;
+
///
/// What signing out costs, on this machine, right now.
///
@@ -4516,7 +4584,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
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
// 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;
}
- 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)
{
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
index 120f4bc..67a7ef5 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/TerminalTabViewModel.cs
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
///
internal DateTimeOffset? StartedAt { get; set; }
+ /// When the session behind this tab ended, or null while it has not.
+ ///
+ /// What stops the status bar's timer. It used to go on counting from to now over a
+ /// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
+ /// keeps for — the one thing it no longer said. Stamped by from
+ /// the shell's clock, and cleared whenever a new session takes the tab.
+ ///
+ internal DateTimeOffset? EndedAt { get; private set; }
+
///
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
///
@@ -620,6 +629,7 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
// stale-fact failure this pair exists to remove.
EndReason = null;
EndDescription = null;
+ EndedAt = null;
IsLive = true;
State = TerminalTabState.Open;
@@ -679,13 +689,14 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
/// live attempt dead. guards its own reports for the same reason.
///
///
- internal void Ended(SshSessionEnd end, string description)
+ internal void Ended(SshSessionEnd end, string description, DateTimeOffset at)
{
if (State is not TerminalTabState.Open)
{
return;
}
+ EndedAt ??= at;
EndReason = end;
EndDescription = description;
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
// cipher in the status bar beside a handshake that has not negotiated one yet.
StartedAt = null;
+ EndedAt = null;
Cipher = null;
HostKeyAlgorithm = null;
IdentityLabel = null;
diff --git a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
index ad7058f..80047a9 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/TransfersViewModel.cs
@@ -2289,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
// ---- Moving files ----
+ /// What a transfer asked for while another command is still running is told.
+ private const string StillBusy =
+ "Still working on the last request. Nothing was queued — try again when it has finished.";
+
/// Queues the chosen remote file for download into the local directory showing.
[RelayCommand]
private void Download()
@@ -2306,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
ClearRemoteChecks();
- });
+ },
+ StillBusy);
}
/// Queues the chosen local file for upload into the remote directory showing.
@@ -2328,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
ClearLocalChecks();
- });
+ },
+ StillBusy);
}
/// Unticks everything on the host's side.
@@ -2393,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
internal void QueueUploads(IReadOnlyList paths) =>
_ = RunAsync(
"Working out what to upload…",
- () => QueueUploadsAsync(paths, CancellationToken.None));
+ () => QueueUploadsAsync(paths, CancellationToken.None),
+ StillBusy);
///
internal void QueueDownloads(IReadOnlyList rows) =>
_ = RunAsync(
"Working out what to download…",
- () => QueueDownloadsAsync(rows, CancellationToken.None));
+ () => QueueDownloadsAsync(rows, CancellationToken.None),
+ StillBusy);
internal async Task QueueUploadsAsync(IReadOnlyList paths, CancellationToken cancellationToken)
{
@@ -2580,7 +2588,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
_ = RunAsync(
"Working out what to upload…",
- () => QueueUploadsAsync(paths, CancellationToken.None));
+ () => QueueUploadsAsync(paths, CancellationToken.None),
+ StillBusy);
}
///
@@ -4154,11 +4163,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
/// The same funnel VaultViewModel 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
/// pane permanently disabled.
+ ///
+ /// 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.
+ ///
///
- private async Task RunAsync(string busyMessage, Func work)
+ private async Task RunAsync(string busyMessage, Func work, string? refusedWhileBusy = null)
{
if (IsBusy)
{
+ if (refusedWhileBusy is not null)
+ {
+ Status = refusedWhileBusy;
+ }
+
return;
}
diff --git a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
index b50889b..622ace1 100644
--- a/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
+++ b/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
internal bool HasVaultItems => VaultItems.Count > 0;
+ /// Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.
+ ///
+ /// 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 — and if they ever stop
+ /// agreeing, a heading over nothing is the thing this exists to avoid.
+ ///
+ internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
+
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
///
@@ -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
// 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
// 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.
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);
// 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.
///
///
- private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
+ private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
var before = session.Vaults.Count;
@@ -6423,12 +6440,39 @@ internal sealed partial class VaultViewModel(
if (admitted == 0 && session.Vaults.Count == before)
{
- return;
+ return false;
}
VaultsChanged?.Invoke(this, EventArgs.Empty);
+
+ return true;
}
+ /// Whether a pass could have left the keychain different from the lists on screen.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// ◆ 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 .
+ ///
+ ///
+ private static bool MayHaveChangedTheKeychain(IReadOnlyList 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);
+
///
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
///
@@ -13457,6 +13501,16 @@ internal sealed partial class VaultViewModel(
};
}
+ /// Re-reads what the keychain table says about its rows as a whole.
+ private void RaiseVaultItemFacts()
+ {
+ OnPropertyChanged(nameof(SectionSummary));
+ OnPropertyChanged(nameof(HasVaultItems));
+ OnPropertyChanged(nameof(ShowsVaultColumn));
+ OnPropertyChanged(nameof(TotalItemCount));
+ OnPropertyChanged(nameof(EmptySectionMessage));
+ }
+
/// ── v5b ── Narrows to rows matches.
///
/// Its own method for the reason is out of —
@@ -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.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
- OnPropertyChanged(nameof(SectionSummary));
- OnPropertyChanged(nameof(HasVaultItems));
- OnPropertyChanged(nameof(TotalItemCount));
- OnPropertyChanged(nameof(EmptySectionMessage));
+ RaiseVaultItemFacts();
// 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,
diff --git a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
index c8a5a5e..d56950f 100644
--- a/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
+++ b/src/DodoSSH.Client.Ssh/SshNetConnectionFactory.cs
@@ -468,7 +468,21 @@ internal sealed class SshNetConnection(
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
///
- public Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
+ ///
+ ///
+ /// On the thread pool, because CreateShellStream 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public async Task OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
{
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
// only delays the first byte reaching the screen.
- var shell = client.CreateShellStream(
- "xterm-256color",
- effective.Columns,
- effective.Rows,
- effective.PixelWidth,
- effective.PixelHeight,
- 4096);
+ var opening = Task.Run(
+ () => client.CreateShellStream(
+ "xterm-256color",
+ effective.Columns,
+ effective.Rows,
+ effective.PixelWidth,
+ effective.PixelHeight,
+ 4096),
+ CancellationToken.None);
- return Task.FromResult(new SshNetShellSession(shell, client));
+ try
+ {
+ var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
+
+ return new SshNetShellSession(shell, client);
+ }
+ catch (OperationCanceledException)
+ {
+ _ = CloseWhenItLandsAsync(opening);
+
+ throw;
+ }
+ }
+
+ /// Closes a shell nobody is waiting for any more, or lets its failure go unremarked.
+ ///
+ /// 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.
+ ///
+ private static async Task CloseWhenItLandsAsync(Task 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);
}
///
diff --git a/src/DodoSSH.Client.Storage/ItemStore.cs b/src/DodoSSH.Client.Storage/ItemStore.cs
index ac09465..a08805e 100644
--- a/src/DodoSSH.Client.Storage/ItemStore.cs
+++ b/src/DodoSSH.Client.Storage/ItemStore.cs
@@ -118,6 +118,65 @@ public sealed class ItemStore(
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
+ ///
+ /// Writes the server's version of many items at once, in one transaction, in the order given.
+ ///
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ public async Task SaveManyAsync(IReadOnlyCollection 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()
+ .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);
+ }
+
/// Removes a tombstone whose change has been seen by everything that needed it.
///
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
diff --git a/src/DodoSSH.Client.Sync/ItemReconciler.cs b/src/DodoSSH.Client.Sync/ItemReconciler.cs
index bddd5e9..6daa8f3 100644
--- a/src/DodoSSH.Client.Sync/ItemReconciler.cs
+++ b/src/DodoSSH.Client.Sync/ItemReconciler.cs
@@ -546,16 +546,25 @@ internal static class ItemMirror
Guid vaultId,
SyncChange change,
CancellationToken cancellationToken) =>
- items.SaveAsync(
- new StoredItem(
- vaultId,
- change.EntityType,
- change.EntityId,
- change.Version,
- change.ChangeSequence,
- change.Payload,
- change.PlaintextFields,
- change.Operation == SyncOperation.Delete,
- change.UpdatedAt),
- cancellationToken);
+ items.SaveAsync(ToStored(vaultId, change), cancellationToken);
+
+ /// Mirrors a run of changes in one write; see .
+ internal static Task WriteManyAsync(
+ ItemStore items,
+ Guid vaultId,
+ IReadOnlyList changes,
+ CancellationToken cancellationToken) =>
+ items.SaveManyAsync(changes.Select(change => ToStored(vaultId, change)).ToList(), cancellationToken);
+
+ private static StoredItem ToStored(Guid vaultId, SyncChange change) =>
+ new(
+ vaultId,
+ change.EntityType,
+ change.EntityId,
+ change.Version,
+ change.ChangeSequence,
+ change.Payload,
+ change.PlaintextFields,
+ change.Operation == SyncOperation.Delete,
+ change.UpdatedAt);
}
diff --git a/src/DodoSSH.Client.Sync/SyncEngine.cs b/src/DodoSSH.Client.Sync/SyncEngine.cs
index 281d9c3..7283f4e 100644
--- a/src/DodoSSH.Client.Sync/SyncEngine.cs
+++ b/src/DodoSSH.Client.Sync/SyncEngine.cs
@@ -154,9 +154,10 @@ public sealed class SyncEngine
continue;
}
+ await ApplyPageAsync(vaultId, response.Changes, report, cancellationToken).ConfigureAwait(false);
+
foreach (var change in response.Changes)
{
- await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
report.Pulled++;
// Counted apart for the same reason the pushed ones are: a machine reads back the log
@@ -310,6 +311,61 @@ public sealed class SyncEngine
private static string PulledTypes { get; } =
string.Join(',', ItemKinds.SyncedTypes.Select(type => (int)type).Order());
+ /// Applies one page of pulled changes, in order, with as few writes as that allows.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// ◆ A change that does have an edit waiting goes through 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.
+ ///
+ ///
+ private async Task ApplyPageAsync(
+ Guid vaultId,
+ IReadOnlyList 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();
+
+ 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(
Guid vaultId,
SyncChange change,
@@ -483,6 +539,17 @@ public sealed class SyncEngine
return resealed;
}
+ /// Whether a refused operation is a delete of an item the server never had.
+ ///
+ /// 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.
+ ///
+ private static bool DeletesSomethingNeverCreated(PendingOperation operation) =>
+ operation is { Operation: SyncOperation.Delete, ExpectedVersion: null };
+
/// Whether this answer warrants another push round.
private async Task HandleAsync(
Guid vaultId,
@@ -525,6 +592,10 @@ public sealed class SyncEngine
cancellationToken).ConfigureAwait(false);
return false;
+ case SyncOperationStatus.Invalid when DeletesSomethingNeverCreated(operation):
+ await outbox.CompleteAsync(operation, cancellationToken).ConfigureAwait(false);
+ return false;
+
case SyncOperationStatus.Invalid:
await RejectAsync(
vaultId,
diff --git a/src/DodoSSH.Client.Transfer/FileTransferQueue.cs b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
index d1c2a1b..9fb0476 100644
--- a/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
+++ b/src/DodoSSH.Client.Transfer/FileTransferQueue.cs
@@ -194,6 +194,15 @@ public sealed class FileTransferQueue : IAsyncDisposable
private readonly CancellationTokenSource lifetime = new();
private Task pump = Task.CompletedTask;
+
+ /// Whether a pump owns the queue: set when one is started, cleared by the pump as it finds it empty.
+ ///
+ /// Guarded by , and the reason it exists rather than asking 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.
+ ///
+ private bool pumping;
private bool disposed;
///
@@ -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)
{
@@ -451,13 +469,39 @@ public sealed class FileTransferQueue : IAsyncDisposable
///
private void EnsurePumping()
{
- if (pump.IsCompleted && !disposed)
+ if (!pumping && !disposed)
{
+ pumping = true;
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
}
}
+ ///
+ /// Leaves by one of two doors. The ordinary one is 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.
+ ///
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 PumpUntilEmptyAsync(CancellationToken cancellationToken)
{
while (TakeNext() is { } entry)
{
@@ -489,6 +533,8 @@ public sealed class FileTransferQueue : IAsyncDisposable
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
}
+
+ return true;
}
/// Takes the next queued transfer and marks it running.
@@ -502,6 +548,10 @@ public sealed class FileTransferQueue : IAsyncDisposable
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;
}
diff --git a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
index e45a481..2320a5f 100644
--- a/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
+++ b/src/DodoSSH.Contracts/PublicAPI.Unshipped.txt
@@ -382,6 +382,7 @@ DodoSSH.Contracts.RelayConfiguration.RelayConfiguration(bool Enabled, System.Uri
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.get -> System.Uri?
DodoSSH.Contracts.RelayConfiguration.WebSocketUrl.init -> void
DodoSSH.Contracts.RelaySessionCloseReason
+DodoSSH.Contracts.RelaySessionCloseReason.AccessRevoked = 8 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.ClientClosed = 1 -> DodoSSH.Contracts.RelaySessionCloseReason
DodoSSH.Contracts.RelaySessionCloseReason.DurationLimit = 4 -> 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
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.DurationLimitCloseCode = 4409 -> int
const DodoSSH.Contracts.RelayProtocol.Feature = "relay" -> string!
diff --git a/src/DodoSSH.Contracts/Relay.cs b/src/DodoSSH.Contracts/Relay.cs
index 41e361b..2d9fec2 100644
--- a/src/DodoSSH.Contracts/Relay.cs
+++ b/src/DodoSSH.Contracts/Relay.cs
@@ -70,6 +70,15 @@ public static class RelayProtocol
/// Close code for a session that reached the maximum lifetime this server allows.
public const int DurationLimitCloseCode = 4409;
+
+ ///
+ /// Close code for a session whose user lost access to the host's vault while it was open.
+ ///
+ ///
+ /// Removed from the team, or the vault's key withdrawn. Reconnecting will not help: a new ticket is
+ /// refused for the same reason.
+ ///
+ public const int AccessRevokedCloseCode = 4403;
}
@@ -143,6 +152,9 @@ public enum RelaySessionCloseReason
/// An error ended the session.
Error = 7,
+
+ /// The user lost access to the host's vault while the session was open.
+ AccessRevoked = 8,
}
///
diff --git a/src/DodoSSH.Domain/Relay.cs b/src/DodoSSH.Domain/Relay.cs
index 4df2620..f10260d 100644
--- a/src/DodoSSH.Domain/Relay.cs
+++ b/src/DodoSSH.Domain/Relay.cs
@@ -120,4 +120,7 @@ public enum RelayCloseReason
/// An error ended the session.
Error = 7,
+
+ /// The user lost access to the host's vault while the session was open.
+ AccessRevoked = 8,
}
diff --git a/tests/DodoSSH.Api.Tests/ApiFixture.cs b/tests/DodoSSH.Api.Tests/ApiFixture.cs
index 0376b45..283dfb3 100644
--- a/tests/DodoSSH.Api.Tests/ApiFixture.cs
+++ b/tests/DodoSSH.Api.Tests/ApiFixture.cs
@@ -14,6 +14,7 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using Xunit;
+using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DodoSSH.Api.Tests;
@@ -108,9 +109,16 @@ public sealed class ApiFixture : WebApplicationFactory, IAsyncLifetime
Environment.SetEnvironmentVariable(name, value);
}
- builder.ConfigureTestServices(services => services.AddSingleton(RelayNetwork));
+ builder.ConfigureTestServices(services =>
+ {
+ services.AddSingleton(RelayNetwork);
+ services.AddSingleton(Commits);
+ });
}
+ /// Fails a commit on request, so a test can watch the retrying execution strategy at work.
+ internal FlakyCommits Commits { get; } = new();
+
///
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
///
diff --git a/tests/DodoSSH.Api.Tests/FlakyCommits.cs b/tests/DodoSSH.Api.Tests/FlakyCommits.cs
new file mode 100644
index 0000000..4a35e98
--- /dev/null
+++ b/tests/DodoSSH.Api.Tests/FlakyCommits.cs
@@ -0,0 +1,43 @@
+using System.Data.Common;
+using Microsoft.EntityFrameworkCore.Diagnostics;
+
+namespace DodoSSH.Api.Tests;
+
+///
+/// Fails the next commit the way a connection dropped on the way to the server would, when asked to.
+///
+///
+/// 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 , 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 FreshAttempts .
+///
+internal sealed class FlakyCommits : DbTransactionInterceptor
+{
+ private int armed;
+
+ private int failed;
+
+ /// How many commits this has failed.
+ internal int Failed => Volatile.Read(ref failed);
+
+ /// Makes the next commit, whoever's it is, fail once.
+ internal void FailNextCommit() => Interlocked.Exchange(ref armed, 1);
+
+ ///
+ public override ValueTask 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);
+ }
+}
diff --git a/tests/DodoSSH.Api.Tests/RelayEndpointTests.cs b/tests/DodoSSH.Api.Tests/RelayEndpointTests.cs
index 30dc6ff..ca83b80 100644
--- a/tests/DodoSSH.Api.Tests/RelayEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/RelayEndpointTests.cs
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Text;
+using DodoSSH.Api.Features.Events;
using DodoSSH.Contracts;
using DodoSSH.Domain;
using DodoSSH.Infrastructure;
@@ -266,6 +267,56 @@ public sealed class RelayEndpointTests(ApiFixture fixture)
.ShouldBe(RelayCloseReason.IdleTimeout);
}
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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 TeamService.RemoveMemberAsync .
+ ///
+ ///
+ [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();
+
+ 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().VaultAccessChanged(userId);
+ }
+
+ await ReadUntilCloseAsync(socket, timeout.Token);
+
+ socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode);
+ (await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
+ .ShouldBe(RelayCloseReason.AccessRevoked);
+ }
+
[Fact]
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
{
diff --git a/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs b/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
index 1bcf22a..b9a2f36 100644
--- a/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
+++ b/tests/DodoSSH.Api.Tests/TeamEndpointTests.cs
@@ -647,6 +647,38 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
.Role.ShouldBe(TeamMemberRole.Owner);
}
+ ///
+ /// 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 FreshAttempts .
+ ///
+ [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>(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);
+ }
+
///
/// 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
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
new file mode 100644
index 0000000..4dde15b
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ClosedVaultFocusTests.cs
@@ -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;
+
+///
+/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
+///
+///
+///
+/// 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.
+///
+///
+/// The whole window, shown the way describes.
+///
+///
+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("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("UnlockPane").ShouldNotBeNull().PassphraseBox)),
+ Token);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs
new file mode 100644
index 0000000..5f658f2
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ConfirmationKeyboardTests.cs
@@ -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;
+
+///
+/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
+/// answers.
+///
+///
+/// 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
+/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
+///
+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().Single(card => card.IsEffectivelyVisible);
+
+ window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl("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("HostKeyPane").ShouldNotBeNull();
+ var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType();
+
+ focused.Content.ShouldBe("CANCEL");
+
+ for (var press = 0; press < 4; press++)
+ {
+ window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
+
+ window.FocusManager.GetFocusedElement().ShouldBeAssignableTo()
+ .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().Single(card => card.IsEffectivelyVisible);
+
+ window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl("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 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 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;
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
index 0215839..c8a8424 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/HostGridTests.cs
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
});
}
+ ///
+ /// The arrows' half of the selection. The cards' SelectedItem 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 is about.
+ ///
+ [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");
+ });
+ }
+
+ ///
+ /// 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.
+ ///
+ [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();
+
+ vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
+ });
+ }
+
// ---- Helpers ----
private static void RightClick(Visual card, Visual window)
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
index 777976a..8489a56 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/ScreenLayoutTests.cs
@@ -2296,6 +2296,66 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
},
Token);
+ ///
+ /// 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.
+ ///
+ [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()
+ .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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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()
+ .Single(block => block.Text is "VAULT");
+
+ heading.IsEffectivelyVisible.ShouldBeFalse();
+ heading.Parent.ShouldBeOfType().ColumnDefinitions[3].ActualWidth.ShouldBe(0);
+ });
+ }
+
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
new(
vault,
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs
index 5f5142e..4807047 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/SessionTabRowTests.cs
@@ -250,6 +250,42 @@ public sealed class SessionTabRowTests : IAsyncLifetime
Token);
}
+ ///
+ /// 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.
+ ///
+ [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()
+ .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);
+ }
+
///
/// 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
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs b/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
index f250fbe..18cc411 100644
--- a/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
+++ b/tests/DodoSSH.Client.App.Layout.Tests/TitleBarTests.cs
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
caches.Dispose();
}
+ ///
+ /// A double-click on the bar maximises, and a second one restores. It was a DoubleTapped 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 CaptionPresses . A single press must still be a drag and nothing else.
+ ///
+ [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]
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
{
diff --git a/tests/DodoSSH.Client.App.Layout.Tests/WholeWindow.cs b/tests/DodoSSH.Client.App.Layout.Tests/WholeWindow.cs
new file mode 100644
index 0000000..caa4079
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Layout.Tests/WholeWindow.cs
@@ -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;
+
+///
+/// The real main window over a real shell, for the tests that are about where the keyboard goes.
+///
+///
+/// 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.
+///
+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(StringComparer.Ordinal)),
+ Substitute.For(),
+ 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(),
+ CheapProfile);
+
+ internal static void Show(MainWindowViewModel shell, Action assert)
+ {
+ var window = new MainWindow { DataContext = shell };
+
+ if (window.FindControl("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();
+ }
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/CrashLogTests.cs b/tests/DodoSSH.Client.App.Tests/CrashLogTests.cs
new file mode 100644
index 0000000..23ac65f
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/CrashLogTests.cs
@@ -0,0 +1,41 @@
+using DodoSSH.Client.App.Platform;
+using DodoSSH.Client.Session;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// That the application leaves a record of why it died.
+///
+///
+/// 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.
+///
+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));
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/DeviceUnlockWordingTests.cs b/tests/DodoSSH.Client.App.Tests/DeviceUnlockWordingTests.cs
new file mode 100644
index 0000000..7eb988c
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/DeviceUnlockWordingTests.cs
@@ -0,0 +1,64 @@
+using DodoSSH.Client.Shell.ViewModels;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// That unlocking with the device key is described in the words of the platform it runs on.
+///
+///
+/// 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.
+///
+public sealed class DeviceUnlockWordingTests
+{
+ public static TheoryData Platforms => ["Windows", "Mac", "Linux", "Android"];
+
+ [Theory]
+ [MemberData(nameof(Platforms))]
+ public void EachPlatform_NamesOnlyItsOwnGesture(string platform)
+ {
+ var wording = For(platform);
+
+ var foreign = new Dictionary(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,
+ };
+}
diff --git a/tests/DodoSSH.Client.App.Tests/ProbedDeviceKeyStoreTests.cs b/tests/DodoSSH.Client.App.Tests/ProbedDeviceKeyStoreTests.cs
new file mode 100644
index 0000000..0ea8c0b
--- /dev/null
+++ b/tests/DodoSSH.Client.App.Tests/ProbedDeviceKeyStoreTests.cs
@@ -0,0 +1,56 @@
+using DodoSSH.Client.App.Platform;
+using DodoSSH.Client.Session;
+using NSubstitute;
+
+namespace DodoSSH.Client.App.Tests;
+
+///
+/// That the slow probe choosing a device key store runs once, and not on the thread that asked.
+///
+///
+/// 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 .
+///
+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();
+
+ 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());
+ await inner.Received(1).TryLoadAsync(Arg.Any());
+ }
+
+ [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);
+ }
+}
diff --git a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
index c5445f1..0c0f4e3 100644
--- a/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/ShellFlowTests.cs
@@ -639,6 +639,36 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
}
+ ///
+ /// 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.
+ ///
+ [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]
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");
}
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
///
/// 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
@@ -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
// alarm.
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}"));
}
///
diff --git a/tests/DodoSSH.Client.App.Tests/TransferQueueingTests.cs b/tests/DodoSSH.Client.App.Tests/TransferQueueingTests.cs
index 3417f9b..6d3dd4c 100644
--- a/tests/DodoSSH.Client.App.Tests/TransferQueueingTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/TransferQueueingTests.cs
@@ -79,6 +79,22 @@ public sealed class TransferQueueingTests : IDisposable
transfers.Status.ShouldContain("Connect to a host first");
}
+ ///
+ /// 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.
+ ///
+ [Fact]
+ public void DroppingFilesWhileBusy_QueuesNothingAndSaysWhy()
+ {
+ Connected();
+ transfers.IsBusy = true;
+
+ transfers.QueueUploads([File("one.txt")]);
+
+ Queued().ShouldBeEmpty();
+ transfers.Status.ShouldContain("Nothing was queued");
+ }
+
[Fact]
public void DroppingSeveralFiles_QueuesEachOfThem()
{
diff --git a/tests/DodoSSH.Client.App.Tests/WindowsDeviceKeyStoreTests.cs b/tests/DodoSSH.Client.App.Tests/WindowsDeviceKeyStoreTests.cs
index 18a35c9..3ddb835 100644
--- a/tests/DodoSSH.Client.App.Tests/WindowsDeviceKeyStoreTests.cs
+++ b/tests/DodoSSH.Client.App.Tests/WindowsDeviceKeyStoreTests.cs
@@ -53,7 +53,7 @@ public sealed class WindowsDeviceKeyStoreTests
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
- store.ShouldBeOfType();
+ (await store.ShouldBeOfType().Chosen).ShouldBeOfType();
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
}
@@ -77,9 +77,7 @@ public sealed class WindowsDeviceKeyStoreTests
private static void SkipUnlessSupported()
{
- var supported = OperatingSystem.IsWindows()
- && DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin)
- is WindowsDeviceKeyStore;
+ var supported = OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported();
if (!supported)
{
diff --git a/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs b/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs
index 8f9d808..bec3d7d 100644
--- a/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs
+++ b/tests/DodoSSH.Client.Session.Tests/FakeDeviceKeyStore.cs
@@ -25,8 +25,18 @@ internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
internal void Overwrite(byte[] replacement) => key = replacement;
///
- public ValueTask IsAvailableAsync(CancellationToken cancellationToken) =>
- ValueTask.FromResult(IsAvailable);
+ public async ValueTask IsAvailableAsync(CancellationToken cancellationToken)
+ {
+ if (HoldAvailability is { } hold)
+ {
+ await hold.WaitAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ return IsAvailable;
+ }
+
+ /// When set, availability is not answered until it completes: an unlock held part-way.
+ internal Task? HoldAvailability { get; set; }
///
public ValueTask SaveAsync(ReadOnlyMemory devicePrivateKey, CancellationToken cancellationToken)
diff --git a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
index 02750e2..2b7caf0 100644
--- a/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
+++ b/tests/DodoSSH.Client.Ssh.Tests/KeyAuthenticationTests.cs
@@ -28,6 +28,44 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
{
private static CancellationToken Token => TestContext.Current.CancellationToken;
+ ///
+ /// 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 Task.FromResult , so the task this
+ /// returned was already finished and the window had stopped painting while it was being made.
+ ///
+ [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;
+ }
+
+ ///
+ /// 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.
+ ///
+ [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(opening);
+
+ await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
+ }
+
[Fact]
public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell()
{
diff --git a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
index 9333211..a8bb656 100644
--- a/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
+++ b/tests/DodoSSH.Client.Storage.Tests/CacheStoreTests.cs
@@ -128,6 +128,42 @@ public sealed class CacheStoreTests : IAsyncLifetime
items.ShouldHaveSingleItem().Version.ShouldBe(2);
}
+ ///
+ /// 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.
+ ///
+ [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]
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
{
diff --git a/tests/DodoSSH.Client.Sync.Tests/UnpushedDeleteTests.cs b/tests/DodoSSH.Client.Sync.Tests/UnpushedDeleteTests.cs
index 6c06e7b..6d8f7b2 100644
--- a/tests/DodoSSH.Client.Sync.Tests/UnpushedDeleteTests.cs
+++ b/tests/DodoSSH.Client.Sync.Tests/UnpushedDeleteTests.cs
@@ -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
// 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
- // 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();
var laptop = harness.First;
@@ -166,6 +167,64 @@ public sealed class UnpushedDeleteTests
tombstone.Operation.ShouldBe(SyncOperation.Delete);
}
+ ///
+ /// 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.
+ ///
+ [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);
+ }
+
+ ///
+ /// 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.
+ ///
+ [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");
+ }
+
///
/// 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
diff --git a/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs b/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs
index d6119fd..d77808f 100644
--- a/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs
+++ b/tests/DodoSSH.Client.Transfer.Tests/FileTransferQueueTests.cs
@@ -304,6 +304,36 @@ public sealed class FileTransferQueueTests : IDisposable
finished.Failure.ShouldBe("the host is not reachable");
}
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ ///
+ [Fact]
+ public async Task ATransferQueuedAsTheLastOneFinishes_StillRuns()
+ {
+ await using var queue = new FileTransferQueue(
+ _ => Task.FromException(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() =>
new(_ => Task.FromResult(host), TimeProvider.System);
@@ -371,7 +401,7 @@ public sealed class FileTransferQueueTests : IDisposable
{
start();
- return await finished.Task.WaitAsync(TimeSpan.FromSeconds(30), Token);
+ return await finished.Task.WaitAsync(TimeSpan.FromSeconds(10), Token);
}
finally
{