Public Access
Audit follow-ups: keyboard, wording, performance, transfer, sync, retry and relay fixes #22
No files matched your search
@@ -0,0 +1,24 @@
|
||||
using DodoSSH.Api.Features.Relay;
|
||||
|
||||
namespace DodoSSH.Api.Features.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Announces a change to everything on this node that acts on one: the event sockets, and the relay.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// What the write paths are given as their <see cref="IVaultEventPublisher"/>. They announce a change once and
|
||||
/// do not know who listens, which is the point: the relay learning about removals from teams did not have to
|
||||
/// touch any of the places a removal happens.
|
||||
/// </remarks>
|
||||
internal sealed class VaultEventFanOut(VaultEventHub hub, RelayAccessWatch relay) : IVaultEventPublisher
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void VaultChanged(Guid vaultId, long sequence) => hub.VaultChanged(vaultId, sequence);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void VaultAccessChanged(Guid userId)
|
||||
{
|
||||
hub.VaultAccessChanged(userId);
|
||||
_ = relay.AccessChangedAsync(userId);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.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)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using DodoSSH.Api.Authorization;
|
||||
using DodoSSH.Domain.Authorization;
|
||||
|
||||
namespace DodoSSH.Api.Features.Relay;
|
||||
|
||||
/// <summary>
|
||||
/// Ends a user's relay sessions to vaults they can no longer reach, when their access changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A relay session is authorised once, when its ticket is issued, and used to run on for as long as it
|
||||
/// carried bytes — up to the maximum lifetime, twelve hours by default. Removing somebody from a team took
|
||||
/// their keys and their vault list away at once, and left any shell they had open through the relay running
|
||||
/// into the team's machines until it went idle.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Told by the same announcement that tells their event socket to re-read its vaults — see
|
||||
/// <see cref="Events.IVaultEventPublisher.VaultAccessChanged"/> — and it asks the same question the ticket
|
||||
/// endpoint asked, against the database as it now stands. Off the announcing request, because that request
|
||||
/// has already committed and answered, and a slow re-check must not hold it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RelayAccessWatch(
|
||||
RelaySessionRegistry sessions,
|
||||
IServiceScopeFactory scopes,
|
||||
ILogger<RelayAccessWatch> logger)
|
||||
{
|
||||
/// <summary>Re-checks every session the user has open, in the background.</summary>
|
||||
/// <returns>The re-check, for a caller that wants to wait for it; the announcement does not.</returns>
|
||||
internal Task AccessChangedAsync(Guid userId)
|
||||
{
|
||||
var open = sessions.SessionsOf(userId);
|
||||
|
||||
return open.Count == 0 ? Task.CompletedTask : Task.Run(() => RecheckAsync(userId, open));
|
||||
}
|
||||
|
||||
private async Task RecheckAsync(Guid userId, IReadOnlyList<RelaySessionRegistry.Lease> open)
|
||||
{
|
||||
try
|
||||
{
|
||||
var scope = scopes.CreateAsyncScope();
|
||||
await using var _ = scope.ConfigureAwait(false);
|
||||
|
||||
var access = scope.ServiceProvider.GetRequiredService<IVaultAccessService>();
|
||||
|
||||
foreach (var lease in open)
|
||||
{
|
||||
var resolved = await access.ResolveAsync(userId, lease.VaultId, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!resolved.Granted || !resolved.Permissions.HasFlag(PermissionFlags.Connect))
|
||||
{
|
||||
lease.Revoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
RelayLog.AccessRecheckFailed(logger, userId, exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -83,7 +83,7 @@ internal sealed class RelayConnectEndpoint(
|
||||
return;
|
||||
}
|
||||
|
||||
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(
|
||||
}
|
||||
|
||||
/// <summary>Dials the target, pipes until something ends it, and writes down what happened.</summary>
|
||||
private async Task RelayAsync(WebSocket socket, RelayTicket ticket, CancellationToken requestAborted)
|
||||
private async Task RelayAsync(
|
||||
WebSocket socket,
|
||||
RelayTicket ticket,
|
||||
CancellationToken revoked,
|
||||
CancellationToken requestAborted)
|
||||
{
|
||||
var started = clock.GetUtcNow();
|
||||
|
||||
@@ -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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<Ending?> MindAsync(Counters counters, DateTimeOffset started, CancellationToken cancellationToken)
|
||||
private async Task<Ending?> MindAsync(
|
||||
Counters counters,
|
||||
DateTimeOffset started,
|
||||
CancellationToken revoked,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var settings = options.Value;
|
||||
var hardDeadline = started + settings.MaxSessionDuration;
|
||||
@@ -527,6 +536,56 @@ internal sealed class RelayConnectEndpoint(
|
||||
drainDeadline = now + settings.DrainTimeout;
|
||||
}
|
||||
|
||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||
|
||||
if (Due(now, revoked.IsCancellationRequested, hardDeadline, drainDeadline, idleDeadline) is { } ending)
|
||||
{
|
||||
return ending;
|
||||
}
|
||||
|
||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||
|
||||
// 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, revoked, lifetime.ApplicationStopping)
|
||||
: CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, revoked);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The shutdown began, or access was withdrawn. Round again to act on whichever it was.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Whichever reason to end the session has come due, or null while none has.</summary>
|
||||
/// <remarks>
|
||||
/// Revocation first, because it is the one reason here that is about the user rather than the session:
|
||||
/// they were removed from the team, or the vault was taken from them, after this session was admitted.
|
||||
/// It used to run on for as long as it carried bytes, up to the maximum lifetime.
|
||||
/// </remarks>
|
||||
private static Ending? Due(
|
||||
DateTimeOffset now,
|
||||
bool revoked,
|
||||
DateTimeOffset hardDeadline,
|
||||
DateTimeOffset? drainDeadline,
|
||||
DateTimeOffset idleDeadline)
|
||||
{
|
||||
if (revoked)
|
||||
{
|
||||
return AccessRevoked;
|
||||
}
|
||||
|
||||
if (now >= hardDeadline)
|
||||
{
|
||||
return new Ending(
|
||||
@@ -540,40 +599,18 @@ internal sealed class RelayConnectEndpoint(
|
||||
return ShuttingDown;
|
||||
}
|
||||
|
||||
var idleDeadline = counters.LastActivity + settings.IdleTimeout;
|
||||
|
||||
if (now >= idleDeadline)
|
||||
{
|
||||
return new Ending(
|
||||
return now >= idleDeadline
|
||||
? new Ending(
|
||||
RelayCloseReason.IdleTimeout,
|
||||
RelayProtocol.IdleTimeoutCloseCode,
|
||||
"This relay session carried nothing for the idle timeout.");
|
||||
"This relay session carried nothing for the idle timeout.")
|
||||
: null;
|
||||
}
|
||||
|
||||
var next = new[] { hardDeadline, idleDeadline, drainDeadline ?? DateTimeOffset.MaxValue }.Min();
|
||||
|
||||
// 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);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(next - now, clock, wake.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The shutdown began. Round again to start the drain.
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
/// <remarks>
|
||||
/// A warning: the sessions it was checking stay open, which is how they all behaved before the check
|
||||
/// existed, and each still ends at its idle timeout or its lifetime.
|
||||
/// </remarks>
|
||||
[LoggerMessage(
|
||||
EventId = 2309,
|
||||
Level = LogLevel.Warning,
|
||||
Message = "Re-checking relay access for user {UserId} after an access change failed.")]
|
||||
internal static partial void AccessRecheckFailed(ILogger logger, Guid userId, Exception exception);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
||||
{
|
||||
private readonly Lock gate = new();
|
||||
private readonly Dictionary<Guid, int> perUser = [];
|
||||
private readonly HashSet<Lease> open = [];
|
||||
private int total;
|
||||
|
||||
/// <summary>How many sessions are open on this node.</summary>
|
||||
@@ -55,7 +56,16 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> options)
|
||||
|
||||
/// <summary>Admits a session, or answers null when a limit is met.</summary>
|
||||
/// <returns>A lease that gives the place back when disposed.</returns>
|
||||
internal Lease? TryAdmit(Guid userId)
|
||||
/// <summary>The sessions a user has open now, for re-checking their access.</summary>
|
||||
internal IReadOnlyList<Lease> SessionsOf(Guid userId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. open.Where(lease => lease.UserId == userId)];
|
||||
}
|
||||
}
|
||||
|
||||
internal Lease? TryAdmit(Guid userId, Guid vaultId)
|
||||
{
|
||||
var settings = options.Value;
|
||||
|
||||
@@ -71,14 +81,18 @@ internal sealed class RelaySessionRegistry(IOptions<RelayOptions> 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<RelayOptions> options)
|
||||
}
|
||||
|
||||
/// <summary>One admitted session's place. Idempotent to dispose.</summary>
|
||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId) : IDisposable
|
||||
internal sealed class Lease(RelaySessionRegistry registry, Guid userId, Guid vaultId) : IDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource revoked = new();
|
||||
|
||||
private int released;
|
||||
|
||||
/// <summary>Whose session this is.</summary>
|
||||
internal Guid UserId { get; } = userId;
|
||||
|
||||
/// <summary>The vault the ticket's host is filed in, which is what the session's access hangs on.</summary>
|
||||
internal Guid VaultId { get; } = vaultId;
|
||||
|
||||
/// <summary>Cancelled when the session's user loses access to <see cref="VaultId"/>.</summary>
|
||||
internal CancellationToken Revoked => revoked.Token;
|
||||
|
||||
/// <summary>Ends the session, because its user can no longer reach the vault it was opened for.</summary>
|
||||
internal void Revoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
revoked.Cancel();
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Already over.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (Interlocked.Exchange(ref released, 1) == 0)
|
||||
{
|
||||
registry.Release(userId);
|
||||
registry.Release(this, UserId);
|
||||
revoked.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Features.Teams;
|
||||
|
||||
/// <summary>
|
||||
/// Serialises the writes that decide what a team owns, for the life of the transaction.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Archiving a team refuses while it owns a vault, and creating a vault refuses on an archived team — but each
|
||||
/// used to check outside any transaction the other could see, so the two could pass each other: the archive
|
||||
/// counted no vaults, a create landed a moment later, and the team was archived with a live vault in it that
|
||||
/// nobody could reach. Both now take this lock inside their transaction and check again under it.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same advisory lock the sync push takes per vault, keyed apart so a team and a vault never share one.
|
||||
/// Requires <c>Multiplexing=false</c> on the connection, as that one does.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class TeamLock
|
||||
{
|
||||
internal static Task<int> AcquireAsync(DodoDbContext database, Guid teamId, CancellationToken cancellationToken) =>
|
||||
database.Database.ExecuteSqlAsync(
|
||||
$"SELECT pg_advisory_xact_lock(hashtextextended({"team:" + teamId.ToString()}, 0))",
|
||||
cancellationToken);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Globalization;
|
||||
using DodoSSH.Api.Features.Events;
|
||||
using DodoSSH.Api.Setup;
|
||||
using DodoSSH.Contracts;
|
||||
using DodoSSH.Domain;
|
||||
using DodoSSH.Infrastructure;
|
||||
@@ -254,6 +255,17 @@ internal sealed class TeamService(
|
||||
throw new InvalidOperationException("Only the owner of a team can archive it.");
|
||||
}
|
||||
|
||||
var now = clock.GetUtcNow();
|
||||
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)
|
||||
@@ -264,16 +276,6 @@ internal sealed class TeamService(
|
||||
$"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 transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
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)
|
||||
|
||||
@@ -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,6 +77,40 @@ internal sealed class VaultGrantService(
|
||||
"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);
|
||||
|
||||
return new VaultSummary(
|
||||
VaultId: request.VaultId,
|
||||
Name: name,
|
||||
IsPersonal: false,
|
||||
TeamId: team.Id,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 0,
|
||||
WrappedVaultKey: request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
}
|
||||
|
||||
/// <summary>Writes the vault and its first grant, under the team's lock, in one transaction.</summary>
|
||||
private Task WriteVaultAsync(
|
||||
UserAccount user,
|
||||
Team team,
|
||||
CreateTeamVaultRequest request,
|
||||
string name,
|
||||
UserKey key,
|
||||
CancellationToken cancellationToken) =>
|
||||
database.ExecuteFreshAsync([team], async () =>
|
||||
{
|
||||
var transaction = await database.Database
|
||||
.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var _ = transaction.ConfigureAwait(false);
|
||||
|
||||
// Under the lock an archive takes, and the team re-read under it: see TeamLock.
|
||||
await TeamLock.AcquireAsync(database, team.Id, cancellationToken).ConfigureAwait(false);
|
||||
await RequireTeamStillLiveAsync(team.Id, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
AddVaultWithSelfGrant(user, team, request, name, key, clock.GetUtcNow());
|
||||
|
||||
try
|
||||
@@ -90,17 +125,20 @@ internal sealed class VaultGrantService(
|
||||
"That vault id is already in use. Generate a new UUIDv7 and retry.");
|
||||
}
|
||||
|
||||
TeamLog.TeamVaultCreated(logger, request.VaultId, team.Id, user.Id);
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
});
|
||||
|
||||
return new VaultSummary(
|
||||
VaultId: request.VaultId,
|
||||
Name: name,
|
||||
IsPersonal: false,
|
||||
TeamId: team.Id,
|
||||
KeyGeneration: 1,
|
||||
Permissions: 0,
|
||||
WrappedVaultKey: request.WrappedVaultKey,
|
||||
RekeyRequired: false);
|
||||
/// <summary>Refuses a vault for a team archived since the request resolved it.</summary>
|
||||
private async Task RequireTeamStillLiveAsync(Guid teamId, CancellationToken cancellationToken)
|
||||
{
|
||||
var live = await database.Teams
|
||||
.AnyAsync(t => t.Id == teamId && t.DeletedAtUtc == null, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!live)
|
||||
{
|
||||
throw new TeamInvalidException("This team has been archived, so it can no longer own a new vault.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Adds the vault row and the creator's own grant, in one unit of work.</summary>
|
||||
|
||||
@@ -41,13 +41,12 @@ builder.Services.AddScoped<VaultGrantService>();
|
||||
builder.Services.AddScoped<IIdentityBindingVerifier, IdentityBindingVerifier>();
|
||||
builder.Services.AddSingleton<ICursorKeyProvider, CursorKeyProvider>();
|
||||
|
||||
// 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<VaultEventHub>();
|
||||
builder.Services.AddSingleton<IVaultEventPublisher>(
|
||||
provider => provider.GetRequiredService<VaultEventHub>());
|
||||
builder.Services.AddSingleton<IVaultEventPublisher, VaultEventFanOut>();
|
||||
|
||||
// The relay's services, its ticket key ring, and a shutdown long enough for its drain. See ADR 0004.
|
||||
builder.Services.AddDodoRelay();
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Api.Setup;
|
||||
|
||||
/// <summary>
|
||||
/// Runs a write transaction under the retrying execution strategy so that a retry starts from the database
|
||||
/// rather than from what the failed attempt left in the change tracker.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>The strategy re-runs the delegate on the same context, and the context remembers the attempt that
|
||||
/// failed.</b> When the failure is the commit itself — a connection dropped after <c>SaveChanges</c> had
|
||||
/// already succeeded — every entity that attempt wrote is marked unchanged, holding values the database
|
||||
/// rolled back. The retry then assigns the same values again, change detection sees nothing to save, and
|
||||
/// the transaction commits without them. Enrolment lost <c>EnrolledAtUtc</c> that way, and a team hand-over
|
||||
/// its two role changes, each reporting success.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So a retry clears the tracker, and reloads each entity the delegate was handed from outside it — the ones
|
||||
/// it modifies or reads but did not load itself — so they are tracked again with what the database actually
|
||||
/// holds and the delegate's own assignments register as changes. The first attempt runs exactly as it did
|
||||
/// before: nothing is cleared or reloaded until something has failed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class FreshAttempts
|
||||
{
|
||||
/// <summary>Runs <paramref name="attempt"/> under the context's execution strategy, freshly each time.</summary>
|
||||
/// <param name="database">The request's context.</param>
|
||||
/// <param name="carried">Entities loaded before the transaction that the attempt reads or modifies.</param>
|
||||
/// <param name="attempt">The transaction, which begins and commits its own.</param>
|
||||
internal static Task<T> ExecuteFreshAsync<T>(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task<T>> attempt)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(database);
|
||||
ArgumentNullException.ThrowIfNull(carried);
|
||||
ArgumentNullException.ThrowIfNull(attempt);
|
||||
|
||||
var strategy = database.Database.CreateExecutionStrategy();
|
||||
var attempts = 0;
|
||||
|
||||
return strategy.ExecuteAsync(async () =>
|
||||
{
|
||||
if (attempts++ > 0)
|
||||
{
|
||||
database.ChangeTracker.Clear();
|
||||
|
||||
foreach (var entity in carried)
|
||||
{
|
||||
await database.Entry(entity).ReloadAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
return await attempt().ConfigureAwait(false);
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ExecuteFreshAsync{T}" />
|
||||
internal static Task ExecuteFreshAsync(
|
||||
this DodoDbContext database,
|
||||
IReadOnlyList<object> carried,
|
||||
Func<Task> attempt) =>
|
||||
database.ExecuteFreshAsync(
|
||||
carried,
|
||||
async () =>
|
||||
{
|
||||
await attempt().ConfigureAwait(false);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using DodoSSH.Infrastructure;
|
||||
using 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<DodoDbContext>(options => options
|
||||
services.AddDbContext<DodoDbContext>((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<IInterceptor>()));
|
||||
|
||||
services.AddHealthChecks()
|
||||
.AddCheck<DatabaseHealthCheck>(
|
||||
|
||||
@@ -24,6 +24,7 @@ internal static class RelaySetup
|
||||
// the network seam holds no state.
|
||||
services.AddSingleton<RelayTicketCodec>();
|
||||
services.AddSingleton<RelaySessionRegistry>();
|
||||
services.AddSingleton<RelayAccessWatch>();
|
||||
services.AddSingleton<IRelayNetwork, SystemRelayNetwork>();
|
||||
|
||||
// The application name isolates this deployment's payloads from any other ASP.NET application
|
||||
|
||||
@@ -48,6 +48,10 @@ internal sealed partial class DodoSshApp : Application
|
||||
{
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
// Recorded, not handled: see CrashLog for why the process still goes.
|
||||
Avalonia.Threading.Dispatcher.UIThread.UnhandledException +=
|
||||
(_, e) => CrashLog.RecordFromTheUiThread(e.Exception);
|
||||
|
||||
ListenForOpenedLinks();
|
||||
ShowSomethingWhileComposing(desktop);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// Writes down why the application died, in the profile, before it does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// An exception nothing caught used to close the window and leave nothing behind — no file, no event, no
|
||||
/// sentence — so the only report anybody could make was "it closed". This does not keep the process alive:
|
||||
/// an exception that reached the top is one no code was written to recover from, and carrying on in whatever
|
||||
/// state it left would be a guess. What it adds is the record.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Bounded, and appended to rather than rewritten, so a run of crashes keeps its history without the file
|
||||
/// growing for ever. Every write is best-effort: a profile that cannot be written to is a reason to lose the
|
||||
/// log, never a reason to fail harder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class CrashLog
|
||||
{
|
||||
/// <summary>Past this, the next entry starts a fresh file.</summary>
|
||||
private const long MaxBytes = 1024 * 1024;
|
||||
|
||||
private static readonly Lock Gate = new();
|
||||
|
||||
private static string? file;
|
||||
|
||||
/// <summary>Starts recording, for the process's two last-chance hooks.</summary>
|
||||
/// <remarks>
|
||||
/// The UI thread's own hook is added by the application once Avalonia is running — see
|
||||
/// <see cref="RecordFromTheUiThread"/> — because the dispatcher must not be touched before the platform
|
||||
/// has set it up.
|
||||
/// </remarks>
|
||||
internal static void Install(ClientPaths paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
file = paths.CrashLogFile;
|
||||
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) => Write("Unhandled", e.ExceptionObject as Exception);
|
||||
|
||||
// A fire-and-forget task that faulted. It does not end the process, which is exactly why it needs
|
||||
// writing down: the work it was doing simply never happened, and nothing else would ever say so.
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
Write("Unobserved task", e.Exception);
|
||||
e.SetObserved();
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The UI thread's hook: records the exception and lets it take the process as before.</summary>
|
||||
internal static void RecordFromTheUiThread(Exception exception) => Write("UI thread", exception);
|
||||
|
||||
/// <summary>Appends one entry; never throws.</summary>
|
||||
internal static void Write(string where, Exception? exception)
|
||||
{
|
||||
if (file is not { } path)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = $"{TimeProvider.System.GetUtcNow():O} {where}{Environment.NewLine}{exception}{Environment.NewLine}{Environment.NewLine}";
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||
|
||||
if (File.Exists(path) && new FileInfo(path).Length > MaxBytes)
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
|
||||
File.AppendAllText(path, entry);
|
||||
}
|
||||
catch (Exception failure) when (failure is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// Best-effort, by design: see the type's remarks.
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Platform;
|
||||
|
||||
/// <summary>
|
||||
/// The device key store this machine supports, chosen once and off the UI thread.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Choosing a store means probing for it, and on Windows the probe is creating an RSA-2048 key inside the TPM
|
||||
/// and deleting it again — see <see cref="WindowsDeviceKeyStore.IsSupported"/> — which is a slow device doing
|
||||
/// real work. It used to run synchronously on the UI thread three times over: once to choose the store at
|
||||
/// startup, again when the unlock card asked whether to offer the gesture, and again after every unlock when
|
||||
/// the security page asked whether to offer registering. A machine does not gain or lose a TPM while this
|
||||
/// process runs, so the answer is worked out once, on the thread pool, and every question after it is a
|
||||
/// lookup.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Available means the choice landed on a real store, which is what the probe that chose it established.
|
||||
/// Everything else waits for the choice and hands over to it — in practice it has long since finished,
|
||||
/// since nothing reaches for the device key until somebody is at the unlock card.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class ProbedDeviceKeyStore : IDeviceKeyStore
|
||||
{
|
||||
private readonly Task<IDeviceKeyStore> chosen;
|
||||
|
||||
/// <summary>Starts choosing at once, on the thread pool.</summary>
|
||||
internal ProbedDeviceKeyStore(Func<IDeviceKeyStore> choose)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(choose);
|
||||
chosen = Task.Run(choose);
|
||||
}
|
||||
|
||||
/// <summary>The store the probe settled on, for a test that needs to know which.</summary>
|
||||
internal Task<IDeviceKeyStore> Chosen => chosen;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
await chosen.WaitAsync(cancellationToken).ConfigureAwait(false) is not UnavailableDeviceKeyStore;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await store.SaveAsync(devicePrivateKey, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask<byte[]?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
return await store.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var store = await chosen.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
await store.ForgetAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,12 @@ namespace DodoSSH.Client.App.Platform;
|
||||
/// </remarks>
|
||||
public static class DesktopDeviceKeyStores
|
||||
{
|
||||
/// <summary>The best store this machine supports.</summary>
|
||||
/// <summary>The best store this machine supports, chosen on the thread pool.</summary>
|
||||
/// <remarks>
|
||||
/// Returns at once: the probing that decides between them is slow — a TPM key created and deleted on
|
||||
/// Windows — and runs behind <see cref="ProbedDeviceKeyStore"/> rather than here, which is on the UI
|
||||
/// thread at startup.
|
||||
/// </remarks>
|
||||
/// <param name="paths">Where a stored key lives.</param>
|
||||
/// <param name="askForPin">
|
||||
/// 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<DevicePinPurpose, CancellationToken, ValueTask<string?>> askForPin)
|
||||
{
|
||||
if (OperatingSystem.IsWindows() && WindowsDeviceKeyStore.IsSupported())
|
||||
{
|
||||
return new WindowsDeviceKeyStore(paths);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Tells a drawn titlebar's double-click from a press that starts a drag.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Both titlebars used to answer a double-click through <c>DoubleTapped</c> and start a drag on every press.
|
||||
/// The two do not mix: <c>BeginMoveDrag</c> hands the press to the operating system's own move loop, which
|
||||
/// eats the release — and a double tap is a gesture Avalonia assembles from presses <em>and</em> releases,
|
||||
/// so the second click often never completed one and the window did not maximise. The convention it was
|
||||
/// imitating is one nobody thinks about until it does not work.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// So the press decides, before the drag begins: a second press inside the platform's double-click time and
|
||||
/// distance is the maximise, and does not start a drag at all. The click count is honoured where the platform
|
||||
/// reports one, and the time and distance are measured here for where the move loop kept it from counting.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class CaptionPresses
|
||||
{
|
||||
private ulong lastAt;
|
||||
|
||||
private Point lastWhere;
|
||||
|
||||
private bool waitingForASecond;
|
||||
|
||||
/// <summary>Whether this press is the second of a double-click on <paramref name="bar"/>.</summary>
|
||||
internal bool IsSecond(PointerPressedEventArgs e, Visual bar)
|
||||
{
|
||||
var settings = Application.Current?.PlatformSettings;
|
||||
var time = settings?.GetDoubleTapTime(e.Pointer.Type) ?? TimeSpan.FromMilliseconds(500);
|
||||
var size = settings?.GetDoubleTapSize(e.Pointer.Type) ?? new Size(4, 4);
|
||||
var where = e.GetPosition(bar);
|
||||
|
||||
var second = e.ClickCount == 2
|
||||
|| (waitingForASecond
|
||||
&& e.Timestamp - lastAt <= (ulong)time.TotalMilliseconds
|
||||
&& Math.Abs(where.X - lastWhere.X) <= size.Width
|
||||
&& Math.Abs(where.Y - lastWhere.Y) <= size.Height);
|
||||
|
||||
// A double-click spends both presses: a third that follows quickly starts again rather than toggling
|
||||
// the window straight back.
|
||||
waitingForASecond = !second;
|
||||
lastAt = e.Timestamp;
|
||||
lastWhere = where;
|
||||
|
||||
return second;
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -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;
|
||||
/// </remarks>
|
||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||
{
|
||||
public ConfirmDeleteCard() => InitializeComponent();
|
||||
public ConfirmDeleteCard()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SafeAnswerFocus.Follow(this, nameof(VaultViewModel.PendingDeletion), () => CancelButton);
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,11 @@
|
||||
80% of Canvas, written out because a scrim is a brush with an alpha and the palette holds no alpha
|
||||
variant of a surface; the pre-multiplied ones there are accent washes.
|
||||
-->
|
||||
<Border Background="#CC05050A">
|
||||
<!--
|
||||
Tab cycles inside the card rather than walking the page under the wash, which it used to: a question
|
||||
that cannot be answered by clicking away should not be answerable by tabbing away either.
|
||||
-->
|
||||
<Border Background="#CC05050A" KeyboardNavigation.TabNavigation="Cycle">
|
||||
<Panel>
|
||||
|
||||
<!-- ============ UNKNOWN HOST KEY ============ -->
|
||||
@@ -83,7 +87,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
@@ -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.
|
||||
-->
|
||||
<Button Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
<Button x:Name="CloseButton" Classes="ghost" Content="CLOSE" HorizontalAlignment="Left"
|
||||
Command="{Binding RejectHostKeyCommand}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -17,4 +17,14 @@ namespace DodoSSH.Client.App.Views;
|
||||
internal sealed partial class HostKeyCard : UserControl
|
||||
{
|
||||
public HostKeyCard() => InitializeComponent();
|
||||
|
||||
/// <summary>The answer the keyboard is given when the card appears: CANCEL, or CLOSE for a changed key.</summary>
|
||||
/// <remarks>
|
||||
/// Never TRUST AND CONNECT, for the reason <see cref="SafeAnswerFocus"/> gives about every confirmation
|
||||
/// and with more force here: an Enter meant for whatever was focused before the card appeared must not
|
||||
/// be what pins a key nobody has compared. The window moves the keyboard rather than the card, because
|
||||
/// this one is raised over a terminal that may be holding it natively — see
|
||||
/// <c>MainWindow.OnHostKeyDecision</c>.
|
||||
/// </remarks>
|
||||
internal Control SafeAnswer => CancelButton.IsEffectivelyVisible ? CancelButton : CloseButton;
|
||||
}
|
||||
@@ -122,6 +122,13 @@ internal sealed partial class HostsScreen : UserControl
|
||||
// touched; see KeyboardTarget for the other half of who has the keyboard on this screen.
|
||||
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 <see cref="OnBandReleased"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>Enter on a card is the double-press, for somebody who got there with the arrows.</summary>
|
||||
/// <remarks>
|
||||
/// Only on a card, for the reason <see cref="OnHostActivated"/> gives: Enter with the board itself focused
|
||||
/// would connect to whatever was selected before, which is not something anyone is looking at. And not
|
||||
/// while cards are ticked, where the selection is not what the set is about.
|
||||
/// </remarks>
|
||||
private void OnCardEnter(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is not Key.Enter
|
||||
|| e.KeyModifiers != KeyModifiers.None
|
||||
|| Vault is not { IsChoosingHosts: false } vault
|
||||
|| RowUnder(e.Source) is not HostRowViewModel row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
vault.SelectedHost = row;
|
||||
|
||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>Writes a selection the arrows made in one section's list back to the vault.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The cards' <c>SelectedItem</c> is one-way, for the reason the markup gives: several lists rebuilt
|
||||
/// independently would each null the vault's selection on every filter keystroke and every sync pass. So
|
||||
/// the pointer writes it by hand, and until this nothing wrote it for the arrows — a card lit by the
|
||||
/// keyboard was not the one the drawer's CONNECT and DELETE then acted on, which is the same mistake the
|
||||
/// right-click handler exists to prevent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ Only from the list that has the keyboard, and only for a card. A rebuild's reset arrives as a change
|
||||
/// to nothing, and the binding's own refills of the lists nobody is in arrive without the keyboard — both
|
||||
/// are exactly the writes the one-way binding keeps out, and they stay out. A click arrives here too, after
|
||||
/// <see cref="OnPointerPressed"/> has already written the same card, and changes nothing.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnCardSelectionChanged(object? sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (Vault is not { } vault
|
||||
|| e.Source is not ListBox { IsKeyboardFocusWithin: true, SelectedItem: HostRowViewModel row }
|
||||
|| ReferenceEquals(row, vault.SelectedHost))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (vault.IsChoosingHosts)
|
||||
{
|
||||
vault.ClearHostChoiceCommand.Execute(null);
|
||||
}
|
||||
|
||||
anchor = row;
|
||||
vault.SelectedHost = row;
|
||||
}
|
||||
|
||||
/// <summary>Hands the keyboard from the board to a card: the selected one if it is drawn, else the first.</summary>
|
||||
private bool EnterTheCards(VaultViewModel vault)
|
||||
{
|
||||
var cards = Board.GetVisualDescendants().OfType<ListBoxItem>().Where(card => card.IsEffectivelyVisible).ToList();
|
||||
|
||||
var card = cards.Find(card => ReferenceEquals(card.DataContext, vault.SelectedHost)) ?? cards.FirstOrDefault();
|
||||
|
||||
if (card?.DataContext is not HostRowViewModel row)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
vault.SelectedHost = row;
|
||||
anchor = row;
|
||||
|
||||
return card.Focus(NavigationMethod.Directional);
|
||||
}
|
||||
|
||||
private void OnBandPressed(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
EndBand();
|
||||
|
||||
@@ -256,7 +256,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- ============ The table ============ -->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}">
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,*" Background="{StaticResource Pane}"
|
||||
Grid.IsSharedSizeScope="True">
|
||||
|
||||
<Border Grid.Row="0" Padding="16,0" Height="44"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
@@ -275,12 +276,24 @@
|
||||
item this application stores carries a timestamp; see the file-level remark above and
|
||||
docs/design-import-gaps.md.
|
||||
-->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Margin="0,10,16,6"
|
||||
<!--
|
||||
VAULT is sized to what is in it, and shared between this heading and every row so the two agree:
|
||||
with one vault no row names one, and a fixed column was a heading over a stripe of nothing.
|
||||
-->
|
||||
<Grid Grid.Row="1" Margin="0,10,16,6"
|
||||
IsVisible="{Binding HasVaultItems}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="1.1*" />
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||
<ColumnDefinition Width="1.2*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="TYPE" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="VAULT" FontSize="9.5" LetterSpacing="1"
|
||||
Margin="0,0,16,0" IsVisible="{Binding ShowsVaultColumn}" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="USED BY" FontSize="9.5" LetterSpacing="1" />
|
||||
</Grid>
|
||||
|
||||
@@ -290,7 +303,14 @@
|
||||
Margin="8,0,8,10">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:VaultItemRowViewModel">
|
||||
<Grid ColumnDefinitions="2,3*,1.1*,0.9*,1.2*" Height="40" Margin="6,0,10,0">
|
||||
<Grid Height="40" Margin="6,0,10,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="2" />
|
||||
<ColumnDefinition Width="3*" />
|
||||
<ColumnDefinition Width="1.1*" />
|
||||
<ColumnDefinition Width="Auto" SharedSizeGroup="KeychainVault" />
|
||||
<ColumnDefinition Width="1.2*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<!--
|
||||
A Grid rather than a horizontal StackPanel: the StackPanel measured the name with infinite
|
||||
width, so TextTrimming never fired and a long name printed over the TYPE and USED BY
|
||||
@@ -307,7 +327,7 @@
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Type}" FontSize="10.5"
|
||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left"
|
||||
<Border Grid.Column="3" Classes="chip" HorizontalAlignment="Left" Margin="0,0,16,0"
|
||||
VerticalAlignment="Center" IsVisible="{Binding HasVaultBadge}">
|
||||
<TextBlock Text="{Binding VaultBadge}" FontSize="10" />
|
||||
</Border>
|
||||
|
||||
@@ -383,7 +383,12 @@
|
||||
not replayed. See MainWindow.axaml.cs.
|
||||
-->
|
||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example" />
|
||||
PlaceholderText="https://dodossh.example">
|
||||
<!-- Enter signs in, as it unlocks on the unlock card. -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding SignInCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
@@ -395,8 +400,14 @@
|
||||
<TextBlock Classes="heading" Text="Choose a keychain passphrase" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<TextBox x:Name="NewPassphraseBox" Text="{Binding Passphrase}" PlaceholderText="passphrase"
|
||||
PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•">
|
||||
<!-- On the second box only: Enter in the first moves nowhere useful, Enter here is "done". -->
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding EnrollCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
|
||||
@@ -33,6 +33,11 @@ internal sealed partial class MainWindow : Window
|
||||
|
||||
private bool wasUnlocked;
|
||||
|
||||
/// <summary>Which closed-vault card the keyboard was last sent to, so it is sent once per card.</summary>
|
||||
private ShellState? closedState;
|
||||
|
||||
private bool wasAskingAboutHostKey;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>What Escape closes when the palette is not up: the thing drawn nearest the user.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One rule throughout — closest thing first — so the order of these arms is the order things are drawn
|
||||
/// over one another. A confirmation before anything, because it is a question in front of whatever page
|
||||
/// it is on, including a settings page: Escape on the sign-out question cancels it and stays in settings.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// v5c: Escape leaves settings mode, the same full-window-state idiom the palette's own Escape already
|
||||
/// follows in the caller. Checked after the palette rather than before it: the two states are mutually
|
||||
/// exclusive in practice — opening the palette does not enter settings mode and entering settings does
|
||||
/// not open the palette — but an Escape while both were somehow true should close the thing drawn on
|
||||
/// top, which is the palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// v5c: with the importer up, Escape closes only that — the same rule, and the same one the titlebar's own
|
||||
/// back button follows by showing "Back to preferences" rather than "Back to application" while
|
||||
/// IsImportOpen is true. The members dialog on Settings → Vaults likewise: it is drawn over the page, so
|
||||
/// it goes first and settings stays.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Last, because it is the furthest thing back: the hosts drawer, which Escape puts away exactly as its
|
||||
/// own arrow does — an open editor abandoned, the selection kept. See VaultViewModel.CloseDrawer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool CloseTheNearestThing(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (AnswerNoToTheQuestionShowing(viewModel))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var close = viewModel switch
|
||||
{
|
||||
{ IsImportOpen: true } => viewModel.CloseImportCommand,
|
||||
{ IsSettingsMode: true, Vaults.IsMembersPanelOpen: true } => viewModel.Vaults.CloseMembersPanelCommand,
|
||||
{ IsSettingsMode: true } => viewModel.LeaveSettingsCommand,
|
||||
{ IsUnlocked: true, IsTerminalShowing: false, Screen: ShellScreen.Hosts, Vault: { IsDrawerOpen: true } vault }
|
||||
=> vault.CloseDrawerCommand,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
close?.Execute(null);
|
||||
|
||||
return close is not null;
|
||||
}
|
||||
|
||||
/// <summary>Escape on a confirmation drawn in place: the sign-out one, or a deletion's.</summary>
|
||||
/// <remarks>
|
||||
/// Both answers it gives are CANCEL, which is the only thing a key with no label is allowed to mean on a
|
||||
/// question about destroying something. A deletion question left armed on a screen somebody navigated away
|
||||
/// from is cancelled by it too, which is harmless — it is the same answer the question would get if it
|
||||
/// were on screen — and cheaper than deciding which of the six places that card is drawn is showing.
|
||||
/// </remarks>
|
||||
private static bool AnswerNoToTheQuestionShowing(MainWindowViewModel viewModel)
|
||||
{
|
||||
if (viewModel.IsConfirmingSignOut)
|
||||
{
|
||||
viewModel.CancelSignOutCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (viewModel is { IsUnlocked: true, IsTerminalShowing: false, Vault: { IsConfirmingDeletion: true } vault })
|
||||
{
|
||||
vault.CancelDeleteCommand.Execute(null);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The text-size chords, when the terminal is not the thing hearing them.
|
||||
/// </summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/// <summary>Hands the keyboard to the box on whichever card a closed vault is showing.</summary>
|
||||
/// <remarks>
|
||||
/// For arriving at a card other than by locking — launch landing on the unlock card, a sign-in finishing
|
||||
/// on the enrolment one — which used to leave the passphrase somebody started typing going nowhere. Once
|
||||
/// per card, which is what keeps it from moving the keyboard while somebody is mid-sign-in: IsUnlocked is
|
||||
/// raised for every state change, and the state does not change while they type. Posted, because the card
|
||||
/// is revealed by a binding the same change has not reached yet.
|
||||
/// </remarks>
|
||||
private void FocusClosedVaultCard(MainWindowViewModel viewModel)
|
||||
{
|
||||
closedState = viewModel.State;
|
||||
|
||||
var home = ClosedVaultKeyboardHome;
|
||||
|
||||
Dispatcher.UIThread.Post(() => home.Focus(), DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <summary>Gives the host-key card the keyboard when it appears, and gives it back when it goes.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// It used to appear with the keyboard wherever it had been — often inside the terminal it covers, whose
|
||||
/// native window goes on holding it after the WebView is collapsed, so the card took no keys at all and
|
||||
/// Tab walked the page under it. Hence <see cref="ReleaseKeyboardTo"/> rather than <c>Focus()</c>, and
|
||||
/// posted, because the binding that reveals the card has not run yet.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Latched, because the flag is re-raised with every change to the terminal's state and the keyboard must
|
||||
/// move once per question, not once per raise. On the way out the keyboard goes where the window would
|
||||
/// put it for whatever is now showing; a connection that went ahead moves it again when its terminal opens.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void OnHostKeyDecision(MainWindowViewModel viewModel)
|
||||
{
|
||||
var asking = viewModel.IsHostKeyDecisionShowing;
|
||||
|
||||
if (asking == wasAskingAboutHostKey)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
wasAskingAboutHostKey = asking;
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (shell != viewModel || viewModel.IsHostKeyDecisionShowing != asking)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (asking)
|
||||
{
|
||||
ReleaseKeyboardTo(HostKeyPane.SafeAnswer);
|
||||
}
|
||||
else
|
||||
{
|
||||
OnShowingSomethingElse(viewModel);
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.ComponentModel;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// Hands the keyboard to a confirmation's harmless answer when the question appears.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For the questions drawn in place on a page — the deletion and the sign-out confirmations — which used to
|
||||
/// appear with the keyboard still on the button that asked them, or nowhere. Tab then walked the page behind
|
||||
/// the question, Enter did nothing useful, and somebody working from the keyboard had to reach for the mouse
|
||||
/// to say no.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ <b>The harmless answer, never the one the question is about.</b> Enter on a focused button presses it,
|
||||
/// so the button that takes the keyboard is the one an accidental Enter is allowed to press: CANCEL. Putting it
|
||||
/// on DELETE or SIGN OUT AND DELETE would make two keystrokes — the one that asked and the one after — a way to
|
||||
/// destroy something without reading the sentence in between.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Each card follows its own question rather than the window following all of them, because the same card is
|
||||
/// drawn in several places and only the one somebody can see should take the keyboard — which the posted
|
||||
/// check on <c>IsEffectivelyVisible</c> decides, after the binding that reveals it has run. Subscribed only
|
||||
/// while the card is in the tree: settings pages are rebuilt as they are visited, and a card that subscribed
|
||||
/// for good would leave one handler on the long-lived view model per visit.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class SafeAnswerFocus
|
||||
{
|
||||
internal static void Follow(Control card, string question, Func<Control> safeAnswer)
|
||||
{
|
||||
INotifyPropertyChanged? watched = null;
|
||||
|
||||
void OnChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (!string.Equals(e.PropertyName, question, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.Post(
|
||||
() =>
|
||||
{
|
||||
if (safeAnswer() is { IsEffectivelyVisible: true } answer)
|
||||
{
|
||||
answer.Focus();
|
||||
}
|
||||
},
|
||||
DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
void Watch(INotifyPropertyChanged? next)
|
||||
{
|
||||
if (watched is not null)
|
||||
{
|
||||
watched.PropertyChanged -= OnChanged;
|
||||
}
|
||||
|
||||
watched = next;
|
||||
|
||||
if (watched is not null)
|
||||
{
|
||||
watched.PropertyChanged += OnChanged;
|
||||
}
|
||||
}
|
||||
|
||||
card.AttachedToVisualTree += (_, _) => Watch(card.DataContext as INotifyPropertyChanged);
|
||||
card.DetachedFromVisualTree += (_, _) => Watch(null);
|
||||
card.DataContextChanged += (_, _) =>
|
||||
{
|
||||
if (card.IsAttachedToVisualTree())
|
||||
{
|
||||
Watch(card.DataContext as INotifyPropertyChanged);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -86,7 +86,14 @@
|
||||
Classes.connecting="{Binding IsConnecting}"
|
||||
Classes.dropped="{Binding IsDropped}"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||
<!--
|
||||
Bounded. A label is whatever the host was called, or the address typed into the palette,
|
||||
and one long one used to push every tab after it and the "+" past the strip's edge. The
|
||||
whole of it is on the label's own tooltip; the tab's is the address, as before.
|
||||
-->
|
||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center"
|
||||
MaxWidth="200" TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Label}" />
|
||||
|
||||
<Button Classes="flat close inline" Width="16" Height="16" Padding="0"
|
||||
VerticalAlignment="Center"
|
||||
|
||||
@@ -136,7 +136,7 @@
|
||||
<Border Classes="settingscard">
|
||||
<StackPanel Margin="24,20" Spacing="10">
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Unlocking with Windows Hello moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||
Text="Unlocking with this machine's device key moved to Security — it registers this machine rather than a preference of this screen's, and Security is where the rest of this machine's trust facts live." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Terminal font, cursor and scrollback — the renderer hard-codes those three. Text size above is the one that is not." />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
|
||||
@@ -70,15 +70,15 @@
|
||||
<Border Classes="settingsrow last">
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<StackPanel Grid.Column="0" Spacing="6" Margin="0,0,16,0" MaxWidth="640">
|
||||
<TextBlock Classes="settingsrowtitle" Text="Windows Hello on this machine" />
|
||||
<TextBlock Classes="settingsrowtitle" Text="{Binding DeviceUnlock.Name}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working."
|
||||
Text="{Binding DeviceUnlock.Offer}"
|
||||
IsVisible="{Binding CanRegisterDevice}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="Registered · the TPM holds a device key that can open the keychain with a Windows confirmation."
|
||||
Text="{Binding DeviceUnlock.Registered}"
|
||||
IsVisible="{Binding CanForgetDevice}" />
|
||||
<TextBlock Classes="settingsrowcaption"
|
||||
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
|
||||
Text="{Binding DeviceUnlock.Unavailable}"
|
||||
IsVisible="{Binding HasNoDeviceKeyOption}" />
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="accent" Height="38" Content="REGISTER"
|
||||
|
||||
@@ -81,11 +81,15 @@
|
||||
IsVisible="{Binding Vault, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||
<StackPanel>
|
||||
|
||||
<Grid ColumnDefinitions="1.4*,*,*,Auto" Margin="20,14,20,10">
|
||||
<!--
|
||||
No VAULT column, although the design draws one: TagRowViewModel does not carry which vault a tag
|
||||
lives in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark), so
|
||||
the column was a heading over nothing on every row.
|
||||
-->
|
||||
<Grid ColumnDefinitions="1.4*,2*,Auto" Margin="20,14,20,10">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TAG" />
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="USED BY" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="VAULT" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text=" " />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text=" " />
|
||||
</Grid>
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Vault.Tags}">
|
||||
@@ -97,7 +101,7 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TagRowViewModel">
|
||||
<Border Classes="settingsrow last" Padding="20,10">
|
||||
<Grid ColumnDefinitions="1.4*,*,*,Auto">
|
||||
<Grid ColumnDefinitions="1.4*,2*,Auto">
|
||||
|
||||
<Border Grid.Column="0" Classes="chip" HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding Label}" />
|
||||
@@ -107,14 +111,7 @@
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="{Binding Description}" />
|
||||
|
||||
<!--
|
||||
No vault chip here — unlike a group, TagRowViewModel does not carry which vault it lives
|
||||
in (tags are read from the active vault alone; see VaultViewModel.NewTag's own remark),
|
||||
so there is no second vault this column could honestly name.
|
||||
-->
|
||||
<TextBlock Grid.Column="2" />
|
||||
|
||||
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8" VerticalAlignment="Center">
|
||||
<Button Classes="paneicon" Width="30" Height="30" FontSize="13"
|
||||
Command="{Binding #Root.((vm:MainWindowViewModel)DataContext).Vault.EditTagRowCommand}"
|
||||
CommandParameter="{Binding}" ToolTip.Tip="Rename this tag.">
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
-->
|
||||
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||
PointerPressed="OnDrag">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="26,0,20,0">
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Left button only, and only on a press nothing inside the bar has already handled.</summary>
|
||||
@@ -19,6 +21,15 @@ internal sealed partial class SettingsTitleBar : UserControl
|
||||
return;
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
/// <summary>Both the button and a double-click on the bar arrive here, as Windows convention expects.</summary>
|
||||
/// <remarks>The double-click by way of <see cref="OnDrag"/>; see <see cref="CaptionPresses"/>.</remarks>
|
||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (Host is not { } window)
|
||||
|
||||
@@ -55,7 +55,7 @@
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="SIGN OUT AND DELETE"
|
||||
Command="{Binding ConfirmSignOutCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||
<Button x:Name="CancelButton" Classes="ghost" Content="CANCEL" Command="{Binding CancelSignOutCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The sign-out confirmation, shown on the preferences screen and on the unlock screen.</summary>
|
||||
internal sealed partial class SignOutCard : UserControl
|
||||
{
|
||||
public SignOutCard() => InitializeComponent();
|
||||
public SignOutCard()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
SafeAnswerFocus.Follow(this, nameof(MainWindowViewModel.IsConfirmingSignOut), () => CancelButton);
|
||||
}
|
||||
}
|
||||
@@ -167,8 +167,12 @@
|
||||
TextAlignment="Center" MaxWidth="360"
|
||||
IsVisible="{Binding !HasVisible}" />
|
||||
|
||||
<!--
|
||||
Only with a snippet chosen. Three greyed buttons under a list nobody has clicked in read as
|
||||
something broken rather than something waiting, and the pane on the right already says what to do.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="16,10" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,1,0,0">
|
||||
BorderThickness="0,1,0,0" IsVisible="{Binding HasSelection}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="ghost" Content="Edit" Command="{Binding EditCommand}"
|
||||
IsEnabled="{Binding HasSelection}" />
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
-->
|
||||
|
||||
<Border Height="53" Background="{StaticResource DeepChrome}"
|
||||
PointerPressed="OnDrag" DoubleTapped="OnToggleMaximised">
|
||||
PointerPressed="OnDrag">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="24,0,20,0">
|
||||
|
||||
|
||||
@@ -41,6 +41,8 @@ internal sealed partial class TitleBar : UserControl
|
||||
}
|
||||
}
|
||||
|
||||
private readonly CaptionPresses presses = new();
|
||||
|
||||
private Window? Host => TopLevel.GetTopLevel(this) as Window;
|
||||
|
||||
/// <remarks>
|
||||
@@ -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
|
||||
|
||||
/// <remarks>
|
||||
/// Both the button and a double-click on the bar arrive here, which is the convention Windows sets and
|
||||
/// the one people reach for without thinking about it.
|
||||
/// the one people reach for without thinking about it. The double-click comes from <see cref="OnDrag"/>
|
||||
/// rather than <c>DoubleTapped</c>; see <see cref="CaptionPresses"/>.
|
||||
/// </remarks>
|
||||
private void OnToggleMaximised(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -44,15 +44,15 @@
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to release the
|
||||
key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a machine that never
|
||||
key. Absent rather than disabled, because a greyed-out "Use Touch ID" on a machine that never
|
||||
had it invites the reading that something is broken — and the passphrase box beside it is not a
|
||||
fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
<Button Classes="ghost" Content="{Binding DeviceUnlock.Button}"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the keychain with this machine's device key. Windows will ask you to confirm." />
|
||||
ToolTip.Tip="{Binding DeviceUnlock.Tip}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
|
||||
@@ -122,6 +122,9 @@ public sealed record ClientPaths(string DataDirectory)
|
||||
/// </remarks>
|
||||
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
||||
|
||||
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
|
||||
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
|
||||
|
||||
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
||||
/// <remarks>
|
||||
/// Separate from resolving the path, because resolving must never have a side effect: it is read
|
||||
|
||||
@@ -80,6 +80,9 @@ public sealed class RelayRoute : IConnectionRoute
|
||||
RelayProtocol.DurationLimitCloseCode =>
|
||||
"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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// What unlocking with this machine's device key is called here, and what it asks the person for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The words used to be Windows's everywhere: "USE WINDOWS HELLO" on a Mac, "Waiting for Windows…" over a
|
||||
/// Linux PIN prompt, and a settings row that told a MacBook it needed a TPM. Each desktop has its own device
|
||||
/// key store with its own gesture — see <c>WindowsDeviceKeyStore</c>, <c>MacDeviceKeyStore</c> and
|
||||
/// <c>LinuxDeviceKeyStore</c> — and a button has to name the one the person is about to be shown, or it reads
|
||||
/// as a feature for some other machine.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One record rather than a property per sentence on the shell, so that the five places these words appear
|
||||
/// cannot disagree about which platform they are on.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">What the feature is called, as a settings row's title.</param>
|
||||
/// <param name="Button">The unlock card's button, in the capitals every button here uses.</param>
|
||||
/// <param name="Wait">The status line while the platform's prompt is up.</param>
|
||||
/// <param name="Gesture">What opens the keychain instead of the passphrase, to finish a sentence.</param>
|
||||
/// <param name="Keeper">What holds the key, to finish "Registered · …".</param>
|
||||
/// <param name="Needs">What a machine with no device key store lacks, as a sentence of its own.</param>
|
||||
internal sealed record DeviceUnlockWording(
|
||||
string Name,
|
||||
string Button,
|
||||
string Wait,
|
||||
string Gesture,
|
||||
string Keeper,
|
||||
string Needs)
|
||||
{
|
||||
internal static DeviceUnlockWording Windows { get; } = new(
|
||||
"Windows Hello on this machine",
|
||||
"USE WINDOWS HELLO",
|
||||
"Waiting for Windows…",
|
||||
"a Windows confirmation",
|
||||
"the TPM holds a device key",
|
||||
"That needs a TPM and a Windows keystore willing to release the key.");
|
||||
|
||||
internal static DeviceUnlockWording Mac { get; } = new(
|
||||
"Touch ID on this Mac",
|
||||
"USE TOUCH ID",
|
||||
"Waiting for Touch ID…",
|
||||
"Touch ID or your login password",
|
||||
"the Secure Enclave holds a device key",
|
||||
"That needs a Mac with a Secure Enclave and a login password set.");
|
||||
|
||||
internal static DeviceUnlockWording Linux { get; } = new(
|
||||
"A device PIN on this machine",
|
||||
"USE DEVICE PIN",
|
||||
"Waiting for your PIN…",
|
||||
"a PIN the TPM checks",
|
||||
"the TPM holds a device key",
|
||||
"That needs a TPM 2.0 this account is allowed to use.");
|
||||
|
||||
internal static DeviceUnlockWording Android { get; } = new(
|
||||
"Fingerprint unlock on this phone",
|
||||
"USE FINGERPRINT",
|
||||
"Waiting for your fingerprint…",
|
||||
"your fingerprint",
|
||||
"the phone's keystore holds a device key",
|
||||
"That needs a screen lock and a keystore willing to release the key.");
|
||||
|
||||
/// <summary>The wording for the platform this process is running on.</summary>
|
||||
internal static DeviceUnlockWording ForThisPlatform { get; } =
|
||||
OperatingSystem.IsAndroid() ? Android
|
||||
: OperatingSystem.IsMacOS() ? Mac
|
||||
: OperatingSystem.IsLinux() ? Linux
|
||||
: Windows;
|
||||
|
||||
/// <summary>The settings caption for a machine that can register.</summary>
|
||||
internal string Offer =>
|
||||
$"Registers this machine so a later launch can open the keychain with {Gesture} instead of your passphrase. Your passphrase keeps working.";
|
||||
|
||||
/// <summary>The settings caption for a machine that has.</summary>
|
||||
internal string Registered => $"Registered · {Keeper} that can open the keychain with {Gesture}.";
|
||||
|
||||
/// <summary>The settings caption for a machine that cannot.</summary>
|
||||
internal string Unavailable =>
|
||||
$"This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. {Needs}";
|
||||
|
||||
/// <summary>The unlock button's tooltip.</summary>
|
||||
internal string Tip => $"Opens the keychain with this machine's device key, after {Gesture}.";
|
||||
}
|
||||
@@ -2133,12 +2133,25 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
return;
|
||||
}
|
||||
|
||||
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.
|
||||
// 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.
|
||||
/// </remarks>
|
||||
private static string GestureWait =>
|
||||
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
|
||||
private static string GestureWait => DeviceUnlockWording.ForThisPlatform.Wait;
|
||||
|
||||
/// <summary>What unlocking with the device key is called on this platform; the views read it whole.</summary>
|
||||
internal DeviceUnlockWording DeviceUnlock { get; } = DeviceUnlockWording.ForThisPlatform;
|
||||
|
||||
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
|
||||
/// <remarks>
|
||||
@@ -2948,13 +2963,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
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);
|
||||
/// <summary>Loads the vault just handed over, and starts it syncing — unless a lock has closed it since.</summary>
|
||||
/// <remarks>
|
||||
/// From here the vault belongs to the shell, so a lock closes it itself: there is nothing to undo, only
|
||||
/// nothing more to do to a vault that is gone. Before these checks, a lock pressed while the first load
|
||||
/// ran left this to carry on into a disposed session and a vault the shell no longer held.
|
||||
/// </remarks>
|
||||
private async Task OpenAsync(VaultViewModel opened, int generation, CancellationToken cancellationToken)
|
||||
{
|
||||
if (generation != locks)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await opened.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (generation != locks)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AfterTheFirstLoad(opened);
|
||||
|
||||
// After the first load, so the list is on screen before anything talks to a server. The loop is
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <summary>Puts away an unlock that a lock overtook before the vault was handed over.</summary>
|
||||
/// <remarks>
|
||||
/// Everything <see cref="LockAsync"/> would have closed had it been here to close: the stores attached to
|
||||
/// this session, the switches read from it, and the session itself, whose keys nothing else now holds.
|
||||
/// </remarks>
|
||||
private async Task AbandonAsync(VaultSession session)
|
||||
{
|
||||
knownHosts.Close();
|
||||
connectionLog.Close();
|
||||
visibility = null;
|
||||
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Everything an unlock does that needs the vault's lists to have been read.</summary>
|
||||
@@ -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;
|
||||
|
||||
/// <summary>How many times this shell has been locked, for an unlock to notice it was overtaken.</summary>
|
||||
private int locks;
|
||||
|
||||
/// <summary>
|
||||
/// What signing out costs, on this machine, right now.
|
||||
/// </summary>
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -274,6 +274,15 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? StartedAt { get; set; }
|
||||
|
||||
/// <summary>When the session behind this tab ended, or null while it has not.</summary>
|
||||
/// <remarks>
|
||||
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
|
||||
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
|
||||
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
|
||||
/// the shell's clock, and cleared whenever a new session takes the tab.
|
||||
/// </remarks>
|
||||
internal DateTimeOffset? EndedAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
||||
/// </summary>
|
||||
@@ -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. <see cref="Advance"/> guards its own reports for the same reason.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
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;
|
||||
|
||||
@@ -2289,6 +2289,10 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
// ---- Moving files ----
|
||||
|
||||
/// <summary>What a transfer asked for while another command is still running is told.</summary>
|
||||
private const string StillBusy =
|
||||
"Still working on the last request. Nothing was queued — try again when it has finished.";
|
||||
|
||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
@@ -2306,7 +2310,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await QueueDownloadsAsync(rows, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
ClearRemoteChecks();
|
||||
});
|
||||
},
|
||||
StillBusy);
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
@@ -2328,7 +2333,8 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await QueueUploadsAsync(paths, CancellationToken.None).ConfigureAwait(true);
|
||||
|
||||
ClearLocalChecks();
|
||||
});
|
||||
},
|
||||
StillBusy);
|
||||
}
|
||||
|
||||
/// <summary>Unticks everything on the host's side.</summary>
|
||||
@@ -2393,13 +2399,15 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
internal void QueueUploads(IReadOnlyList<string> paths) =>
|
||||
_ = RunAsync(
|
||||
"Working out what to upload…",
|
||||
() => QueueUploadsAsync(paths, CancellationToken.None));
|
||||
() => QueueUploadsAsync(paths, CancellationToken.None),
|
||||
StillBusy);
|
||||
|
||||
/// <inheritdoc cref="QueueUploads" />
|
||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows) =>
|
||||
_ = RunAsync(
|
||||
"Working out what to download…",
|
||||
() => QueueDownloadsAsync(rows, CancellationToken.None));
|
||||
() => QueueDownloadsAsync(rows, CancellationToken.None),
|
||||
StillBusy);
|
||||
|
||||
internal async Task QueueUploadsAsync(IReadOnlyList<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -4154,11 +4163,21 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||
/// pane permanently disabled.
|
||||
/// <para>
|
||||
/// <paramref name="refusedWhileBusy"/> is for the commands somebody can fire while another is running —
|
||||
/// a drop, or the download and upload buttons pressed twice — which used to vanish without a word: the
|
||||
/// files were not queued and nothing said so. A command given one says why it did nothing instead.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work, string? refusedWhileBusy = null)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
if (refusedWhileBusy is not null)
|
||||
{
|
||||
Status = refusedWhileBusy;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2404,6 +2404,14 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
internal bool HasVaultItems => VaultItems.Count > 0;
|
||||
|
||||
/// <summary>Whether any row in the keychain table names a vault, which is whether its VAULT column has a heading.</summary>
|
||||
/// <remarks>
|
||||
/// Read off the rows rather than off how many vaults there are, because a row's badge is what the column
|
||||
/// holds: the two agree today — see <see cref="VaultItemRowViewModel.VaultBadge"/> — and if they ever stop
|
||||
/// agreeing, a heading over nothing is the thing this exists to avoid.
|
||||
/// </remarks>
|
||||
internal bool ShowsVaultColumn => VaultItems.Any(row => row.HasVaultBadge);
|
||||
|
||||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||||
|
||||
/// <summary>
|
||||
@@ -6359,7 +6367,7 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
||||
// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
|
||||
/// <summary>Whether a pass could have left the keychain different from the lists on screen.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Every pass used to end by reading the whole keychain back — decrypting every host, key, password,
|
||||
/// bucket and snippet and rebuilding every list from them — once a minute, whether or not a single item
|
||||
/// had moved. Almost every pass moves nothing, so almost all of that was spent redrawing what was already
|
||||
/// drawn.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ Conservative on purpose. A failed vault counts as changed, because a pull can apply a page and then
|
||||
/// fail on the next one, and a report with no counts is no evidence that nothing landed. Log entries do
|
||||
/// not count — a machine reads back the ones it just wrote, and nothing on these lists is drawn from the
|
||||
/// log; see <see cref="SyncReport.PulledItems"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private static bool MayHaveChangedTheKeychain(IReadOnlyList<VaultSyncReport> report) =>
|
||||
report.Any(vault => vault.Report is not { } pass
|
||||
|| pass.PulledItems > 0
|
||||
|| pass.PushedItems > 0
|
||||
|| pass.Merged > 0
|
||||
|| pass.Resurrected > 0
|
||||
|| pass.DeletesAbandoned > 0
|
||||
|| pass.Parked > 0
|
||||
|| pass.ResyncedFromStart);
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
|
||||
/// </summary>
|
||||
@@ -13457,6 +13501,16 @@ internal sealed partial class VaultViewModel(
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Re-reads what the keychain table says about its rows as a whole.</summary>
|
||||
private void RaiseVaultItemFacts()
|
||||
{
|
||||
OnPropertyChanged(nameof(SectionSummary));
|
||||
OnPropertyChanged(nameof(HasVaultItems));
|
||||
OnPropertyChanged(nameof(ShowsVaultColumn));
|
||||
OnPropertyChanged(nameof(TotalItemCount));
|
||||
OnPropertyChanged(nameof(EmptySectionMessage));
|
||||
}
|
||||
|
||||
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||||
/// <remarks>
|
||||
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||||
@@ -13552,10 +13606,7 @@ internal sealed partial class VaultViewModel(
|
||||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||||
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,
|
||||
|
||||
@@ -468,7 +468,21 @@ internal sealed class SshNetConnection(
|
||||
public string Cipher { get; } = client.ConnectionInfo.CurrentServerEncryption ?? string.Empty;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// On the thread pool, because <c>CreateShellStream</c> is synchronous and is three round trips to the
|
||||
/// server — open a channel, ask for a terminal, ask for a shell — and this used to make them on whichever
|
||||
/// thread called it, which is the UI thread. On a distant or overloaded server the window stopped
|
||||
/// painting for as long as they took, and the cancellation token it was given was read once, before any
|
||||
/// of them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// SSH.NET's call cannot itself be cancelled, so a cancellation stops the wait rather than the call. A
|
||||
/// shell that arrives after nobody is waiting for it is closed as it lands rather than left holding a
|
||||
/// channel on the server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
@@ -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(
|
||||
var opening = Task.Run(
|
||||
() => client.CreateShellStream(
|
||||
"xterm-256color",
|
||||
effective.Columns,
|
||||
effective.Rows,
|
||||
effective.PixelWidth,
|
||||
effective.PixelHeight,
|
||||
4096);
|
||||
4096),
|
||||
CancellationToken.None);
|
||||
|
||||
return Task.FromResult<ISshShellSession>(new SshNetShellSession(shell, client));
|
||||
try
|
||||
{
|
||||
var shell = await opening.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return new SshNetShellSession(shell, client);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_ = CloseWhenItLandsAsync(opening);
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Closes a shell nobody is waiting for any more, or lets its failure go unremarked.</summary>
|
||||
/// <remarks>
|
||||
/// A shell that failed to open after its caller gave up has nobody to tell, and the connection it was
|
||||
/// on reports its own death through the session that owns it. The failures are the ones SSH.NET raises
|
||||
/// for a channel or a connection that went away, named so that anything else still surfaces.
|
||||
/// </remarks>
|
||||
private static async Task CloseWhenItLandsAsync(Task<ShellStream> opening)
|
||||
{
|
||||
ShellStream shell;
|
||||
|
||||
try
|
||||
{
|
||||
shell = await opening.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is SshException
|
||||
or System.Net.Sockets.SocketException
|
||||
or ObjectDisposedException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await shell.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
|
||||
@@ -118,6 +118,65 @@ public sealed class ItemStore(
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the server's version of many items at once, in one transaction, in the order given.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="SaveAsync"/> for a page of pulled changes. A pull used to write each change on its own —
|
||||
/// a context, a read, a commit — so a first sync of a keychain with a few thousand items was a few
|
||||
/// thousand SQLite transactions, each one a sync to disk. This is one read for the rows that exist
|
||||
/// already and one commit for the lot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The same blind overwrite, and the same answer when one item appears more than once: they are applied
|
||||
/// in order to the same row, so the last one given is the one kept — exactly what writing them one by one
|
||||
/// would have left.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task SaveManyAsync(IReadOnlyCollection<StoredItem> items, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
if (items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var vaultIds = items.Select(item => item.VaultId).Distinct().ToList();
|
||||
var entityIds = items.Select(item => item.EntityId).Distinct().ToList();
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var existing = await context.Set<CachedItemRow>()
|
||||
.Where(r => vaultIds.Contains(r.VaultId) && entityIds.Contains(r.EntityId))
|
||||
.ToListAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var rows = existing.ToDictionary(row => (row.VaultId, row.EntityType, row.EntityId));
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (!rows.TryGetValue((item.VaultId, item.EntityType, item.EntityId), out var row))
|
||||
{
|
||||
row = new CachedItemRow
|
||||
{
|
||||
VaultId = item.VaultId,
|
||||
EntityType = item.EntityType,
|
||||
EntityId = item.EntityId,
|
||||
};
|
||||
|
||||
context.Add(row);
|
||||
rows.Add((item.VaultId, item.EntityType, item.EntityId), row);
|
||||
}
|
||||
|
||||
Apply(row, item);
|
||||
}
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>Removes a tombstone whose change has been seen by everything that needed it.</summary>
|
||||
/// <remarks>
|
||||
/// Only ever called for a row that is already a tombstone. Collecting a live item here would make
|
||||
|
||||
@@ -546,8 +546,18 @@ internal static class ItemMirror
|
||||
Guid vaultId,
|
||||
SyncChange change,
|
||||
CancellationToken cancellationToken) =>
|
||||
items.SaveAsync(
|
||||
new StoredItem(
|
||||
items.SaveAsync(ToStored(vaultId, change), cancellationToken);
|
||||
|
||||
/// <summary>Mirrors a run of changes in one write; see <see cref="ItemStore.SaveManyAsync"/>.</summary>
|
||||
internal static Task WriteManyAsync(
|
||||
ItemStore items,
|
||||
Guid vaultId,
|
||||
IReadOnlyList<SyncChange> 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,
|
||||
@@ -556,6 +566,5 @@ internal static class ItemMirror
|
||||
change.Payload,
|
||||
change.PlaintextFields,
|
||||
change.Operation == SyncOperation.Delete,
|
||||
change.UpdatedAt),
|
||||
cancellationToken);
|
||||
change.UpdatedAt);
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
/// <summary>Applies one page of pulled changes, in order, with as few writes as that allows.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Each change used to cost a write to mirror it and a read to look for a local edit of the same item —
|
||||
/// two or more SQLite transactions apiece, which on a first sync of a large keychain was most of the time
|
||||
/// the sync took. The outbox is read once per page instead, and every run of changes with no local edit
|
||||
/// waiting is mirrored in a single write.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// ◆ A change that does have an edit waiting goes through <see cref="ApplyAsync"/> exactly as before, with
|
||||
/// the run in front of it written first: the reconciler reads the mirror and the outbox as they stand at
|
||||
/// that change, and both are what the one-by-one loop would have left them as. The one-read snapshot only
|
||||
/// decides which path a change takes; the path that matters re-reads the outbox for itself.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task ApplyPageAsync(
|
||||
Guid vaultId,
|
||||
IReadOnlyList<SyncChange> changes,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (changes.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var waiting = (await outbox.ListAllAsync(vaultId, cancellationToken).ConfigureAwait(false))
|
||||
.Select(pending => (pending.EntityType, pending.EntityId))
|
||||
.ToHashSet();
|
||||
|
||||
var run = new List<SyncChange>();
|
||||
|
||||
foreach (var change in changes)
|
||||
{
|
||||
if (!reconcilers.ContainsKey(change.EntityType))
|
||||
{
|
||||
// See ApplyAsync: a type this build cannot sync is neither mirrored nor reconciled.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!waiting.Contains((change.EntityType, change.EntityId)))
|
||||
{
|
||||
run.Add(change);
|
||||
continue;
|
||||
}
|
||||
|
||||
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
|
||||
run.Clear();
|
||||
|
||||
await ApplyAsync(vaultId, change, report, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await ItemMirror.WriteManyAsync(items, vaultId, run, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task ApplyAsync(
|
||||
Guid vaultId,
|
||||
SyncChange change,
|
||||
@@ -483,6 +539,17 @@ public sealed class SyncEngine
|
||||
return resealed;
|
||||
}
|
||||
|
||||
/// <summary>Whether a refused operation is a delete of an item the server never had.</summary>
|
||||
/// <remarks>
|
||||
/// A delete queued over a create that never reached the server carries no expected version, and the
|
||||
/// server's only reason to refuse one of those is that it has no such item — which is the state the delete
|
||||
/// asked for, so it is done. It used to be parked as a rejection: a "conflict" about an item nobody else
|
||||
/// had ever seen, which the user could neither resolve nor dismiss. A create that did land is no exception
|
||||
/// to this — the server has the item then, and applies the delete.
|
||||
/// </remarks>
|
||||
private static bool DeletesSomethingNeverCreated(PendingOperation operation) =>
|
||||
operation is { Operation: SyncOperation.Delete, ExpectedVersion: null };
|
||||
|
||||
/// <returns>Whether this answer warrants another push round.</returns>
|
||||
private async Task<bool> 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,
|
||||
|
||||
@@ -194,6 +194,15 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task pump = Task.CompletedTask;
|
||||
|
||||
/// <summary>Whether a pump owns the queue: set when one is started, cleared by the pump as it finds it empty.</summary>
|
||||
/// <remarks>
|
||||
/// Guarded by <see cref="gate"/>, and the reason it exists rather than asking <see cref="pump"/> whether it
|
||||
/// has finished. The pump decides it is done under the gate, but its task is only marked complete after it
|
||||
/// has let go — so a transfer queued in between found a pump that was still "running", started none, and
|
||||
/// stayed queued for good. This is cleared in the same locked step that found nothing to take.
|
||||
/// </remarks>
|
||||
private bool pumping;
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="sessions">
|
||||
@@ -329,7 +338,16 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
/// </remarks>
|
||||
private void EnsurePumping()
|
||||
{
|
||||
if (pump.IsCompleted && !disposed)
|
||||
if (!pumping && !disposed)
|
||||
{
|
||||
pumping = true;
|
||||
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Leaves by one of two doors. The ordinary one is <see cref="TakeNext"/> finding the queue empty, which
|
||||
/// hands the queue back in the same locked step. Anything else — a fault escaping a row — hands it back
|
||||
/// on the way out, so the next transfer queued starts a pump rather than waiting on one that is gone.
|
||||
/// </remarks>
|
||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var drained = false;
|
||||
|
||||
try
|
||||
{
|
||||
drained = await PumpUntilEmptyAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!drained)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
pumping = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> PumpUntilEmptyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (TakeNext() is { } entry)
|
||||
{
|
||||
@@ -489,6 +533,8 @@ public sealed class FileTransferQueue : IAsyncDisposable
|
||||
|
||||
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
@@ -70,6 +70,15 @@ public static class RelayProtocol
|
||||
|
||||
/// <summary>Close code for a session that reached the maximum lifetime this server allows.</summary>
|
||||
public const int DurationLimitCloseCode = 4409;
|
||||
|
||||
/// <summary>
|
||||
/// Close code for a session whose user lost access to the host's vault while it was open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Removed from the team, or the vault's key withdrawn. Reconnecting will not help: a new ticket is
|
||||
/// refused for the same reason.
|
||||
/// </remarks>
|
||||
public const int AccessRevokedCloseCode = 4403;
|
||||
}
|
||||
|
||||
|
||||
@@ -143,6 +152,9 @@ public enum RelaySessionCloseReason
|
||||
|
||||
/// <summary>An error ended the session.</summary>
|
||||
Error = 7,
|
||||
|
||||
/// <summary>The user lost access to the host's vault while the session was open.</summary>
|
||||
AccessRevoked = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -120,4 +120,7 @@ public enum RelayCloseReason
|
||||
|
||||
/// <summary>An error ended the session.</summary>
|
||||
Error = 7,
|
||||
|
||||
/// <summary>The user lost access to the host's vault while the session was open.</summary>
|
||||
AccessRevoked = 8,
|
||||
}
|
||||
@@ -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<Program>, IAsyncLifetime
|
||||
Environment.SetEnvironmentVariable(name, value);
|
||||
}
|
||||
|
||||
builder.ConfigureTestServices(services => services.AddSingleton<IRelayNetwork>(RelayNetwork));
|
||||
builder.ConfigureTestServices(services =>
|
||||
{
|
||||
services.AddSingleton<IRelayNetwork>(RelayNetwork);
|
||||
services.AddSingleton<IInterceptor>(Commits);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Fails a commit on request, so a test can watch the retrying execution strategy at work.</summary>
|
||||
internal FlakyCommits Commits { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// What the relay resolves and dials, in place of the machine's own resolver and sockets.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Data.Common;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
|
||||
namespace DodoSSH.Api.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Fails the next commit the way a connection dropped on the way to the server would, when asked to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Before the commit reaches the database, so the transaction is rolled back — which is what a lost commit
|
||||
/// looks like from the server's side — and with a <see cref="TimeoutException"/>, which the retrying
|
||||
/// execution strategy treats as transient. That is the only way to watch a retry happen against a real
|
||||
/// database, and the retry is the thing worth watching: see <c>FreshAttempts</c>.
|
||||
/// </remarks>
|
||||
internal sealed class FlakyCommits : DbTransactionInterceptor
|
||||
{
|
||||
private int armed;
|
||||
|
||||
private int failed;
|
||||
|
||||
/// <summary>How many commits this has failed.</summary>
|
||||
internal int Failed => Volatile.Read(ref failed);
|
||||
|
||||
/// <summary>Makes the next commit, whoever's it is, fail once.</summary>
|
||||
internal void FailNextCommit() => Interlocked.Exchange(ref armed, 1);
|
||||
|
||||
/// <inheritdoc />
|
||||
public override ValueTask<InterceptionResult> TransactionCommittingAsync(
|
||||
DbTransaction transaction,
|
||||
TransactionEventData eventData,
|
||||
InterceptionResult result,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (Interlocked.Exchange(ref armed, 0) == 1)
|
||||
{
|
||||
Interlocked.Increment(ref failed);
|
||||
|
||||
throw new TimeoutException("The commit was lost on its way to the server.");
|
||||
}
|
||||
|
||||
return base.TransactionCommittingAsync(transaction, eventData, result, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A session is authorised once, when its ticket is issued, and used to run on for as long as it carried
|
||||
/// bytes — removing somebody from a team left their shells into its machines open for up to the session's
|
||||
/// maximum lifetime. The announcement that tells their event socket to re-read its vaults now has the
|
||||
/// relay ask again too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Access is withdrawn here by deleting the vault rather than by a team removal, because what is under
|
||||
/// test is the relay hearing the announcement and asking the access model again — any way of making the
|
||||
/// answer change will do, and this one needs no team. The removal path announces through the same call;
|
||||
/// see <c>TeamService.RemoveMemberAsync</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ASessionWhoseUserLosesAccess_IsClosedWithItsOwnCode()
|
||||
{
|
||||
using var target = LoopbackTarget.Echo();
|
||||
var (subject, hostId) = await SeedRoutedHostAsync(target, "10.40.8.1");
|
||||
var ticket = await IssueAsync(subject, hostId);
|
||||
|
||||
using var timeout = Timeout();
|
||||
using var socket = await ConnectAsync(ticket, timeout.Token);
|
||||
|
||||
await RoundTripAsync(socket, "still here", timeout.Token);
|
||||
|
||||
Guid userId;
|
||||
|
||||
await using (var scope = fixture.CreateScope())
|
||||
{
|
||||
var database = scope.ServiceProvider.GetRequiredService<DodoDbContext>();
|
||||
|
||||
userId = (await database.Users.SingleAsync(u => u.Subject == subject, timeout.Token)).Id;
|
||||
|
||||
var vaultId = (await database.Hosts.SingleAsync(h => h.Id == hostId, timeout.Token)).VaultId;
|
||||
var vault = await database.Vaults.SingleAsync(v => v.Id == vaultId, timeout.Token);
|
||||
|
||||
vault.DeletedAtUtc = Now;
|
||||
await database.SaveChangesAsync(timeout.Token);
|
||||
|
||||
scope.ServiceProvider.GetRequiredService<IVaultEventPublisher>().VaultAccessChanged(userId);
|
||||
}
|
||||
|
||||
await ReadUntilCloseAsync(socket, timeout.Token);
|
||||
|
||||
socket.CloseStatus.ShouldBe((WebSocketCloseStatus)RelayProtocol.AccessRevokedCloseCode);
|
||||
(await WaitForClosedAuditAsync(ticket.SessionId, timeout.Token)).CloseReason
|
||||
.ShouldBe(RelayCloseReason.AccessRevoked);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AUserAtTheCap_IsRefusedATicket_AndASocketOverItIsClosed()
|
||||
{
|
||||
|
||||
@@ -647,6 +647,38 @@ public sealed class TeamEndpointTests(ApiFixture fixture)
|
||||
.Role.ShouldBe(TeamMemberRole.Owner);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The commit lost on its way to the database, which the execution strategy answers by running the
|
||||
/// transaction again on the same context. That context still held the two memberships as the failed
|
||||
/// attempt left them — already owner, already admin, and marked unchanged — so the retry saved nothing,
|
||||
/// committed, and answered 204 over a team whose roles had not moved. See <c>FreshAttempts</c>.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATransferWhoseCommitIsLost_IsRetriedFromTheDatabase_AndStillLands()
|
||||
{
|
||||
var owner = await EnrolledClientAsync("retry-owner", "rowner@example.com");
|
||||
await EnrolledClientAsync("retry-successor", "rsuccessor@example.com");
|
||||
|
||||
var team = await CreateTeamAsync(owner, "Retried handover");
|
||||
var entry = await LookupAsync(owner, "rsuccessor@example.com");
|
||||
|
||||
await AddMemberAsync(owner, team.TeamId, entry.UserId, TeamMemberRole.Member);
|
||||
|
||||
var failedBefore = fixture.Commits.Failed;
|
||||
fixture.Commits.FailNextCommit();
|
||||
|
||||
var response = await owner.PostContractAsync(
|
||||
OwnerUrl(team.TeamId), new TransferTeamOwnershipRequest(entry.UserId));
|
||||
|
||||
response.StatusCode.ShouldBe(HttpStatusCode.NoContent);
|
||||
fixture.Commits.Failed.ShouldBe(failedBefore + 1, "the first commit has to have been lost for this to mean anything");
|
||||
|
||||
var members = await ReadAsync<IReadOnlyList<TeamMemberSummary>>(owner, MembersUrl(team.TeamId));
|
||||
|
||||
members.Where(row => row.UserId == entry.UserId).ShouldHaveSingleItem().Role.ShouldBe(TeamMemberRole.Owner);
|
||||
members.Count(row => row.Role == TeamMemberRole.Owner).ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Storage;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard on the cards a closed vault shows: where it is when the card appears, and what Enter does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Launch used to leave the keyboard nowhere. Focus only moved on the way out of an unlocked vault, so the
|
||||
/// passphrase somebody started typing the moment the window appeared went nowhere until they clicked the box,
|
||||
/// and Enter in the server address did nothing at all.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The whole window, shown the way <see cref="WholeWindow"/> describes.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class ClosedVaultFocusTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task AFreshMachine_PutsTheKeyboardInTheServerAddressAndEnterSignsIn()
|
||||
{
|
||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
shell.State.ShouldBe(ShellState.NeedsServer);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
var box = window.FindControl<TextBox>("ServerUrlBox").ShouldNotBeNull();
|
||||
|
||||
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(box);
|
||||
|
||||
box.KeyBindings.ShouldContain(binding =>
|
||||
binding.Gesture == new KeyGesture(Key.Enter) && binding.Command == shell.SignInCommand);
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnEnrolledMachine_PutsTheKeyboardInThePassphraseBox()
|
||||
{
|
||||
using var caches = ClientCacheFactory.ForMemory($"focus-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
shell.State.ShouldBe(ShellState.Locked);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
window.FocusManager!.GetFocusedElement()
|
||||
.ShouldBeSameAs(window.FindControl<UnlockCard>("UnlockPane").ShouldNotBeNull().PassphraseBox)),
|
||||
Token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Headless;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Session.Tests;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The keyboard on the questions the window asks over itself: where it is when one appears, and what Escape
|
||||
/// answers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// They used to appear with the keyboard wherever it had been, Escape did nothing, and Tab walked the page
|
||||
/// behind them. The answer they are given here is always the harmless one — see <see cref="SafeAnswerFocus"/>
|
||||
/// — and that is what each test pins: not merely that focus moved, but which button it moved to.
|
||||
/// </remarks>
|
||||
public sealed class ConfirmationKeyboardTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task TheSignOutQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
shell.SignOutCommand.Execute(null);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.GetVisualDescendants().OfType<SignOutCard>().Single(card => card.IsEffectivelyVisible);
|
||||
|
||||
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
shell.IsConfirmingSignOut.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnknownHostKey_TakesTheKeyboardOnCancel_KeepsTabInside_AndEscapeRefuses()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
vault.PendingHostKey = new HostKeyPresentation("web.example", 22, "ssh-ed25519", "SHA256:abc");
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.FindControl<HostKeyCard>("HostKeyPane").ShouldNotBeNull();
|
||||
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<Button>();
|
||||
|
||||
focused.Content.ShouldBe("CANCEL");
|
||||
|
||||
for (var press = 0; press < 4; press++)
|
||||
{
|
||||
window.KeyPressQwerty(PhysicalKey.Tab, RawInputModifiers.None);
|
||||
|
||||
window.FocusManager.GetFocusedElement().ShouldBeAssignableTo<Visual>()
|
||||
.ShouldNotBeNull().GetVisualAncestors().ShouldContain(card);
|
||||
}
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.HasPendingHostKey.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADeletionQuestion_TakesTheKeyboardOnCancel_AndEscapeSaysNo()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
shell.EnterSettingsCommand.Execute(SettingsPage.Tags);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Tag, Guid.CreateVersion7(), "Delete the tag prod?", "It goes.", string.Empty);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var card = window.GetVisualDescendants().OfType<ConfirmDeleteCard>().Single(card => card.IsEffectivelyVisible);
|
||||
|
||||
window.FocusManager!.GetFocusedElement().ShouldBeSameAs(card.FindControl<Button>("CancelButton"));
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsConfirmingDeletion.ShouldBeFalse();
|
||||
shell.IsSettingsMode.ShouldBeTrue();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EscapeOnTheHostsScreen_PutsTheDrawerAway_LikeItsOwnArrow()
|
||||
{
|
||||
using var caches = await EnrolledAsync();
|
||||
|
||||
await using var workspace = WholeWindow.Workspace();
|
||||
await using var shell = await UnlockedAsync(caches, workspace);
|
||||
|
||||
var vault = shell.Vault.ShouldNotBeNull();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() => WholeWindow.Show(shell, window =>
|
||||
{
|
||||
vault.NewHostCommand.Execute(null);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.IsDrawerOpen.ShouldBeTrue();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Escape, RawInputModifiers.None);
|
||||
|
||||
vault.IsDrawerOpen.ShouldBeFalse();
|
||||
vault.IsEditing.ShouldBeFalse();
|
||||
}),
|
||||
Token);
|
||||
}
|
||||
|
||||
private static async Task<ClientCacheFactory> EnrolledAsync()
|
||||
{
|
||||
var caches = ClientCacheFactory.ForMemory($"confirm-{Guid.CreateVersion7():N}");
|
||||
await caches.MigrateAsync(Token);
|
||||
|
||||
await new AccountProvisioner(new FakeAccountServer(), new StubKeyBinding(), caches, TimeProvider.System, WholeWindow.CheapProfile)
|
||||
.EnrollAsync("https://dodossh.example", WholeWindow.Passphrase, "laptop", "Personal", Token);
|
||||
|
||||
return caches;
|
||||
}
|
||||
|
||||
private static async Task<MainWindowViewModel> UnlockedAsync(ClientCacheFactory caches, TerminalWorkspace workspace)
|
||||
{
|
||||
var shell = WholeWindow.Shell(caches, workspace);
|
||||
|
||||
await shell.StartAsync(Token);
|
||||
|
||||
shell.Passphrase = WholeWindow.Passphrase;
|
||||
await shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
shell.IsUnlocked.ShouldBeTrue();
|
||||
|
||||
return shell;
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,61 @@ public sealed class HostGridTests : IAsyncLifetime
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The arrows' half of the selection. The cards' <c>SelectedItem</c> is one-way, so a card the keyboard lit
|
||||
/// used to stay out of the vault — and the drawer's CONNECT and DELETE then acted on the card clicked
|
||||
/// before it, the mistake <see cref="ARightClickSelectsTheHostUnderThePointer"/> is about.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheArrowsMoveTheVaultsSelection_AndEnterConnectsToIt()
|
||||
{
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
var first = Row(vault, "prod-db");
|
||||
var other = Row(vault, "stage-web");
|
||||
|
||||
Click(CardFor(screen, first), window);
|
||||
vault.SelectedHost.ShouldBeSameAs(first);
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowRight, RawInputModifiers.None);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
vault.SelectedHost.ShouldBeSameAs(other, "the card the arrow lit, not the one clicked before it");
|
||||
|
||||
vault.ConnectCommand.ExecutionTask.ShouldBeNull();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.Enter, RawInputModifiers.None);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
// The command, not a connection: these hosts were saved with nothing to sign in with, so it stops
|
||||
// at saying so — about the card Enter was pressed on, which is the selection checked above.
|
||||
vault.ConnectCommand.ExecutionTask.ShouldNotBeNull("Enter on a card is the double-press");
|
||||
});
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The board is where this screen puts the keyboard, and none of the section lists inside it has it until
|
||||
/// a card has been clicked — so without this an arrow pressed on arriving went nowhere at all.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnArrowOnTheBoardItselfGoesIntoTheCards()
|
||||
{
|
||||
await OnTheBoardAsync((screen, window) =>
|
||||
{
|
||||
vault.SelectedHost = null;
|
||||
|
||||
screen.Board.Focus();
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
window.KeyPressQwerty(PhysicalKey.ArrowDown, RawInputModifiers.None);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
var focused = window.FocusManager!.GetFocusedElement().ShouldBeOfType<ListBoxItem>();
|
||||
|
||||
vault.SelectedHost.ShouldNotBeNull().ShouldBeSameAs(focused.DataContext);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
private static void RightClick(Visual card, Visual window)
|
||||
|
||||
@@ -2296,6 +2296,66 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
||||
},
|
||||
Token);
|
||||
|
||||
/// <remarks>
|
||||
/// Three greyed buttons under a list nobody has clicked in read as something broken rather than something
|
||||
/// waiting. The row appears with a snippet to act on, and goes with it.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheSnippetButtonsAppearWithASnippetToActOn()
|
||||
{
|
||||
await SeedSnippetsAsync();
|
||||
|
||||
var snippets = NewSnippetsScreen();
|
||||
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var screen = new SnippetsScreen { DataContext = snippets };
|
||||
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
screen, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var edit = screen.GetVisualDescendants().OfType<Button>()
|
||||
.Single(button => button.Content is "Edit");
|
||||
|
||||
snippets.Selected = null;
|
||||
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
edit.IsEffectivelyVisible.ShouldBeFalse("nothing is chosen for it to edit");
|
||||
|
||||
snippets.Selected = snippets.Visible.First();
|
||||
LayoutHarness.Settle(window, LayoutHarness.ScreenWidth, LayoutHarness.ScreenHeight);
|
||||
edit.IsEffectivelyVisible.ShouldBeTrue();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// With one vault no row names one, and a fixed VAULT column was a heading over a stripe of nothing
|
||||
/// between TYPE and USED BY. It is sized to what its rows hold now, and its heading goes with them.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task TheKeychainsVaultColumn_TakesNoRoomWithOneVault()
|
||||
{
|
||||
await OnTheVaultAsync((screen, _) =>
|
||||
{
|
||||
vault.HasVaultItems.ShouldBeTrue("an empty table has no columns to judge");
|
||||
vault.ShowsVaultColumn.ShouldBeFalse("this suite's keychain has one vault");
|
||||
|
||||
var heading = screen.GetVisualDescendants().OfType<TextBlock>()
|
||||
.Single(block => block.Text is "VAULT");
|
||||
|
||||
heading.IsEffectivelyVisible.ShouldBeFalse();
|
||||
heading.Parent.ShouldBeOfType<Grid>().ColumnDefinitions[3].ActualWidth.ShouldBe(0);
|
||||
});
|
||||
}
|
||||
|
||||
private SnippetsViewModel NewSnippetsScreen(InsertTarget? target = null) =>
|
||||
new(
|
||||
vault,
|
||||
|
||||
@@ -250,6 +250,42 @@ public sealed class SessionTabRowTests : IAsyncLifetime
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A label is whatever the host was called or the address typed into the palette, and nothing bounded it:
|
||||
/// one long one pushed the "+" past the edge of the strip, behind a scroll bar nobody looks for.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ALongLabelIsTrimmed_AndLeavesThePlusOnTheStrip()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var label = string.Concat(Enumerable.Repeat("a-very-long-host-name-", 12));
|
||||
|
||||
shell.Tabs.Add(new TerminalTabViewModel(1, label, "deploy@long.internal:22"));
|
||||
|
||||
var strip = new SessionTabRow { DataContext = shell, TabCommand = shell.SelectTabCommand };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
strip, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
|
||||
try
|
||||
{
|
||||
var text = strip.GetVisualDescendants().OfType<TextBlock>()
|
||||
.Single(block => string.Equals(block.Text, label, StringComparison.Ordinal));
|
||||
|
||||
text.Bounds.Width.ShouldBeLessThanOrEqualTo(200);
|
||||
ToolTip.GetTip(text).ShouldBe(label, "the whole of it is still one hover away");
|
||||
|
||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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
|
||||
|
||||
@@ -66,6 +66,52 @@ public sealed class TitleBarTests : IAsyncLifetime
|
||||
caches.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A double-click on the bar maximises, and a second one restores. It was a <c>DoubleTapped</c> handler
|
||||
/// beside a press that started a drag, and the drag handed the press to the operating system's move loop,
|
||||
/// which ate the release a double tap is assembled from — so on Windows the click often did nothing. The
|
||||
/// press decides now; see <c>CaptionPresses</c>. A single press must still be a drag and nothing else.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task DoubleClickingTheBar_MaximisesAndRestores_AndASinglePressDoesNot()
|
||||
{
|
||||
await LayoutHarness.OnTheUiThreadAsync(
|
||||
() =>
|
||||
{
|
||||
var bar = new TitleBar { DataContext = shell };
|
||||
var window = LayoutHarness.HostAtMinimumSize(
|
||||
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
||||
|
||||
try
|
||||
{
|
||||
// The top edge, past everything the bar draws: the drag surface and nothing else.
|
||||
var empty = new Point(bar.Bounds.Width * 0.6, 2);
|
||||
|
||||
window.MouseDown(empty, MouseButton.Left);
|
||||
window.MouseUp(empty, MouseButton.Left);
|
||||
|
||||
window.WindowState.ShouldBe(WindowState.Normal, "one press is a drag");
|
||||
|
||||
window.MouseDown(empty, MouseButton.Left);
|
||||
window.MouseUp(empty, MouseButton.Left);
|
||||
|
||||
window.WindowState.ShouldBe(WindowState.Maximized);
|
||||
|
||||
window.MouseDown(empty, MouseButton.Left);
|
||||
window.MouseUp(empty, MouseButton.Left);
|
||||
window.MouseDown(empty, MouseButton.Left);
|
||||
window.MouseUp(empty, MouseButton.Left);
|
||||
|
||||
window.WindowState.ShouldBe(WindowState.Normal);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
},
|
||||
Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ClickingTheSearchPill_OpensTheQuickConnectPalette()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DodoSSH.Client.App.Views;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Client.Transfer;
|
||||
using DodoSSH.Crypto;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Layout.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// The real main window over a real shell, for the tests that are about where the keyboard goes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="LayoutHarnessTests.WhyTheWindowItselfIsNeverShown"/> says this window cannot be shown here: the
|
||||
/// terminal's WebView refuses this thread's apartment when it is attached. It is taken out of the tree before
|
||||
/// the window is shown, which is everything that test is about and nothing these are.
|
||||
/// </remarks>
|
||||
internal static class WholeWindow
|
||||
{
|
||||
internal const string Passphrase = "a sufficiently long passphrase";
|
||||
|
||||
internal static readonly Argon2Profile CheapProfile =
|
||||
Argon2Profile.FromStoredParameters(memoryKibibytes: 8 * 1024, passes: 1, parallelism: 1);
|
||||
|
||||
internal static TerminalWorkspace Workspace() =>
|
||||
new(
|
||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
||||
Substitute.For<ISshConnectionFactory>(),
|
||||
TimeProvider.System);
|
||||
|
||||
internal static MainWindowViewModel Shell(ClientCacheFactory caches, TerminalWorkspace workspace) =>
|
||||
new(
|
||||
new ClientPaths(Path.Combine(Path.GetTempPath(), $"dodossh-focus-{Guid.CreateVersion7():N}")),
|
||||
caches,
|
||||
workspace,
|
||||
new VaultKnownHostStore(),
|
||||
new UnavailableDeviceKeyStore(),
|
||||
static (_, _) => throw new InvalidOperationException("A layout test has no network."),
|
||||
TimeProvider.System,
|
||||
Substitute.For<ISftpSessionFactory>(),
|
||||
CheapProfile);
|
||||
|
||||
internal static void Show(MainWindowViewModel shell, Action<MainWindow> assert)
|
||||
{
|
||||
var window = new MainWindow { DataContext = shell };
|
||||
|
||||
if (window.FindControl<NativeWebView>("Terminal") is { Parent: Panel panel } web)
|
||||
{
|
||||
panel.Children.Remove(web);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
||||
Dispatcher.UIThread.RunJobs();
|
||||
|
||||
assert(window);
|
||||
}
|
||||
finally
|
||||
{
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// That the application leaves a record of why it died.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// An exception nothing caught used to close the window and leave nothing at all behind. Installing the hooks
|
||||
/// here also installs them in the test process, which is harmless: they only write to this test's own
|
||||
/// directory, and only when something reaches the top.
|
||||
/// </remarks>
|
||||
public sealed class CrashLogTests : IDisposable
|
||||
{
|
||||
private readonly string directory =
|
||||
Path.Combine(Path.GetTempPath(), $"dodossh-crash-{Guid.CreateVersion7():N}");
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(directory))
|
||||
{
|
||||
Directory.Delete(directory, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AnExceptionThatReachesTheTop_IsWrittenToTheProfile()
|
||||
{
|
||||
var paths = new ClientPaths(directory);
|
||||
|
||||
CrashLog.Install(paths);
|
||||
CrashLog.Write("UI thread", new InvalidOperationException("the thing nobody caught"));
|
||||
|
||||
var written = File.ReadAllText(paths.CrashLogFile);
|
||||
|
||||
written.ShouldContain("UI thread");
|
||||
written.ShouldContain("the thing nobody caught");
|
||||
written.ShouldContain(nameof(InvalidOperationException));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// That unlocking with the device key is described in the words of the platform it runs on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The unlock card offered "USE WINDOWS HELLO" on a Mac and the settings page told a Linux machine it needed a
|
||||
/// Windows keystore. Each platform's wording is checked for every other platform's names, because that is the
|
||||
/// mistake: a sentence borrowed from the one platform the feature was written on first.
|
||||
/// </remarks>
|
||||
public sealed class DeviceUnlockWordingTests
|
||||
{
|
||||
public static TheoryData<string> Platforms => ["Windows", "Mac", "Linux", "Android"];
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(Platforms))]
|
||||
public void EachPlatform_NamesOnlyItsOwnGesture(string platform)
|
||||
{
|
||||
var wording = For(platform);
|
||||
|
||||
var foreign = new Dictionary<string, string[]>(StringComparer.Ordinal)
|
||||
{
|
||||
["Windows"] = ["Touch ID", "Secure Enclave", "fingerprint", "PIN"],
|
||||
["Mac"] = ["Windows", "TPM", "fingerprint", "PIN"],
|
||||
["Linux"] = ["Windows", "Touch ID", "Secure Enclave", "fingerprint"],
|
||||
["Android"] = ["Windows", "TPM", "Touch ID", "Secure Enclave"],
|
||||
}[platform];
|
||||
|
||||
string[] said =
|
||||
[wording.Name, wording.Button, wording.Wait, wording.Offer, wording.Registered, wording.Unavailable, wording.Tip];
|
||||
|
||||
foreach (var sentence in said)
|
||||
{
|
||||
sentence.ShouldNotBeNullOrWhiteSpace();
|
||||
|
||||
foreach (var name in foreign)
|
||||
{
|
||||
sentence.ShouldNotContain(name, Case.Sensitive, $"{platform}: \"{sentence}\"");
|
||||
}
|
||||
}
|
||||
|
||||
wording.Button.ShouldBe(wording.Button.ToUpperInvariant(), "every button on these screens is in capitals");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheWordingInUse_IsThisMachines()
|
||||
{
|
||||
var expected = OperatingSystem.IsMacOS() ? DeviceUnlockWording.Mac
|
||||
: OperatingSystem.IsLinux() ? DeviceUnlockWording.Linux
|
||||
: DeviceUnlockWording.Windows;
|
||||
|
||||
DeviceUnlockWording.ForThisPlatform.ShouldBeSameAs(expected);
|
||||
}
|
||||
|
||||
private static DeviceUnlockWording For(string platform) => platform switch
|
||||
{
|
||||
"Windows" => DeviceUnlockWording.Windows,
|
||||
"Mac" => DeviceUnlockWording.Mac,
|
||||
"Linux" => DeviceUnlockWording.Linux,
|
||||
_ => DeviceUnlockWording.Android,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using DodoSSH.Client.App.Platform;
|
||||
using DodoSSH.Client.Session;
|
||||
using NSubstitute;
|
||||
|
||||
namespace DodoSSH.Client.App.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// That the slow probe choosing a device key store runs once, and not on the thread that asked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On Windows the probe creates and deletes a TPM key, and it used to run synchronously on the UI thread at
|
||||
/// startup and again after every unlock. See <see cref="ProbedDeviceKeyStore"/>.
|
||||
/// </remarks>
|
||||
public sealed class ProbedDeviceKeyStoreTests
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
[Fact]
|
||||
public async Task TheChoiceIsMadeOnce_OffTheCallersThread()
|
||||
{
|
||||
var asking = Environment.CurrentManagedThreadId;
|
||||
var probes = 0;
|
||||
var probedOn = 0;
|
||||
|
||||
var inner = Substitute.For<IDeviceKeyStore>();
|
||||
|
||||
var store = new ProbedDeviceKeyStore(() =>
|
||||
{
|
||||
Interlocked.Increment(ref probes);
|
||||
probedOn = Environment.CurrentManagedThreadId;
|
||||
return inner;
|
||||
});
|
||||
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeTrue();
|
||||
await store.TryLoadAsync(Token);
|
||||
|
||||
probes.ShouldBe(1);
|
||||
probedOn.ShouldNotBe(asking);
|
||||
|
||||
// Availability is the choice, not a second probe through the store it chose.
|
||||
await inner.DidNotReceive().IsAvailableAsync(Arg.Any<CancellationToken>());
|
||||
await inner.Received(1).TryLoadAsync(Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AMachineWithNoStore_IsUnavailable_AndForgettingIsHarmless()
|
||||
{
|
||||
var store = new ProbedDeviceKeyStore(static () => new UnavailableDeviceKeyStore());
|
||||
|
||||
(await store.IsAvailableAsync(Token)).ShouldBeFalse();
|
||||
(await store.TryLoadAsync(Token)).ShouldBeNull();
|
||||
|
||||
await store.ForgetAsync(Token);
|
||||
}
|
||||
}
|
||||
@@ -639,6 +639,36 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
server.LiveRowCount.ShouldBe(1, "a save should reach the server without pressing Sync");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Lock pressed while an unlock is still loading. The unlock is held at the point the vault has been handed
|
||||
/// over and is about to be read — the device-key probe — and the lock lands there. The unlock used to carry
|
||||
/// on regardless, into a vault the lock had disposed and a shell that no longer held one, and put an
|
||||
/// exception's message on the lock screen. An earlier lock, before the hand-over, would have left the vault
|
||||
/// opening behind the lock screen; both are the same check.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task LockingWhileAnUnlockIsStillLoading_LeavesItLocked()
|
||||
{
|
||||
await EnrolledAndConfirmedAsync();
|
||||
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
deviceKeys.HoldAvailability = release.Task;
|
||||
|
||||
shell.Passphrase = Passphrase;
|
||||
var unlocking = shell.UnlockCommand.ExecuteAsync(null);
|
||||
|
||||
await EventuallyAsync(() => shell.Vault is not null, "the unlock should reach the hand-over and wait there");
|
||||
|
||||
await shell.LockCommand.ExecuteAsync(null);
|
||||
|
||||
release.SetResult();
|
||||
await unlocking;
|
||||
|
||||
shell.State.ShouldBe(ShellState.Locked);
|
||||
shell.Vault.ShouldBeNull();
|
||||
shell.StatusMessage.ShouldBe("Locked.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
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");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A pass that moved nothing used to read the whole keychain back anyway — decrypting every item and
|
||||
/// rebuilding every list, once a minute. A rebuilt list has new row objects in it, so the row still being
|
||||
/// the same object is the evidence that nothing was read.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AnAutomaticPassThatMovesNothing_RedrawsNothing()
|
||||
{
|
||||
await UnlockedAsync();
|
||||
var vault = shell.Vault!;
|
||||
|
||||
await AddHostAsync(vault, "prod-db");
|
||||
|
||||
var row = vault.Hosts.ShouldHaveSingleItem();
|
||||
|
||||
await vault.AutoSyncAsync(Token);
|
||||
|
||||
vault.Hosts.ShouldHaveSingleItem().ShouldBeSameAs(row);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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}"));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
|
||||
@@ -79,6 +79,22 @@ public sealed class TransferQueueingTests : IDisposable
|
||||
transfers.Status.ShouldContain("Connect to a host first");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A drop landing while another command on this screen was running used to vanish: nothing queued and
|
||||
/// nothing said. It still queues nothing — the command in flight owns the pane — but it says so.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void DroppingFilesWhileBusy_QueuesNothingAndSaysWhy()
|
||||
{
|
||||
Connected();
|
||||
transfers.IsBusy = true;
|
||||
|
||||
transfers.QueueUploads([File("one.txt")]);
|
||||
|
||||
Queued().ShouldBeEmpty();
|
||||
transfers.Status.ShouldContain("Nothing was queued");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DroppingSeveralFiles_QueuesEachOfThem()
|
||||
{
|
||||
|
||||
@@ -53,7 +53,7 @@ public sealed class WindowsDeviceKeyStoreTests
|
||||
|
||||
var store = DesktopDeviceKeyStores.ForThisMachine(new ClientPaths(Path.GetTempPath()), NoPin);
|
||||
|
||||
store.ShouldBeOfType<WindowsDeviceKeyStore>();
|
||||
(await store.ShouldBeOfType<ProbedDeviceKeyStore>().Chosen).ShouldBeOfType<WindowsDeviceKeyStore>();
|
||||
(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)
|
||||
{
|
||||
|
||||
@@ -25,8 +25,18 @@ internal sealed class FakeDeviceKeyStore : IDeviceKeyStore
|
||||
internal void Overwrite(byte[] replacement) => key = replacement;
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken) =>
|
||||
ValueTask.FromResult(IsAvailable);
|
||||
public async ValueTask<bool> IsAvailableAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (HoldAvailability is { } hold)
|
||||
{
|
||||
await hold.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return IsAvailable;
|
||||
}
|
||||
|
||||
/// <summary>When set, availability is not answered until it completes: an unlock held part-way.</summary>
|
||||
internal Task? HoldAvailability { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask SaveAsync(ReadOnlyMemory<byte> devicePrivateKey, CancellationToken cancellationToken)
|
||||
|
||||
@@ -28,6 +28,44 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
||||
{
|
||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
||||
|
||||
/// <remarks>
|
||||
/// Opening a shell is three round trips to the server, and SSH.NET makes them synchronously. They used to
|
||||
/// be made on the caller's thread — the UI thread — behind a <c>Task.FromResult</c>, so the task this
|
||||
/// returned was already finished and the window had stopped painting while it was being made.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task OpeningAShell_LeavesTheCallingThreadFree()
|
||||
{
|
||||
await using var connection = await ConnectTrustedAsync(
|
||||
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
|
||||
|
||||
var opening = connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||
|
||||
opening.IsCompleted.ShouldBeFalse("three round trips to the server cannot have been made on this thread");
|
||||
|
||||
await using var shell = await opening;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The cancellation token used to be read once, before any of those round trips. A caller that gives up
|
||||
/// now stops waiting at once, and the connection is left able to open the next shell.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task AShellGivenUpOn_StopsTheWait_AndTheConnectionOpensAnother()
|
||||
{
|
||||
await using var connection = await ConnectTrustedAsync(
|
||||
new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null));
|
||||
|
||||
using var giveUp = CancellationTokenSource.CreateLinkedTokenSource(Token);
|
||||
|
||||
var opening = connection.OpenShellAsync(TerminalSize.Default, giveUp.Token);
|
||||
await giveUp.CancelAsync();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(opening);
|
||||
|
||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AKeyHeldAsBytes_AuthenticatesAndOpensAShell()
|
||||
{
|
||||
|
||||
@@ -128,6 +128,42 @@ public sealed class CacheStoreTests : IAsyncLifetime
|
||||
items.ShouldHaveSingleItem().Version.ShouldBe(2);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// A pull's page in one write. What it leaves has to be exactly what the same items saved one at a time
|
||||
/// would have left: rows that existed replaced, new ones created, and an item given twice ending at the
|
||||
/// later of the two — a page can carry several changes to the same item.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task SavingMany_LeavesWhatSavingThemOneAtATimeWould()
|
||||
{
|
||||
var existing = Guid.CreateVersion7();
|
||||
var fresh = Guid.CreateVersion7();
|
||||
var twice = Guid.CreateVersion7();
|
||||
|
||||
await harness.Items.SaveAsync(Item(existing, version: 1, seed: 1), Token);
|
||||
|
||||
await harness.Items.SaveManyAsync(
|
||||
[
|
||||
Item(existing, version: 2, changeSequence: 2, seed: 2),
|
||||
Item(fresh, version: 1, changeSequence: 3, seed: 3),
|
||||
Item(twice, version: 1, changeSequence: 4, seed: 4),
|
||||
Item(twice, version: 2, changeSequence: 5, deleted: true),
|
||||
],
|
||||
Token);
|
||||
|
||||
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, existing, Token))
|
||||
.ShouldNotBeNull().Version.ShouldBe(2);
|
||||
|
||||
(await harness.Items.FindAsync(VaultId, SyncEntityType.Host, fresh, Token))
|
||||
.ShouldNotBeNull().Version.ShouldBe(1);
|
||||
|
||||
var last = (await harness.Items.FindAsync(VaultId, SyncEntityType.Host, twice, Token)).ShouldNotBeNull();
|
||||
last.Version.ShouldBe(2);
|
||||
last.IsDeleted.ShouldBeTrue();
|
||||
|
||||
(await harness.Items.ListAsync(VaultId, SyncEntityType.Host, true, Token)).Count.ShouldBe(3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ATombstone_IsHiddenFromTheListButStillFindable()
|
||||
{
|
||||
|
||||
@@ -147,7 +147,8 @@ public sealed class UnpushedDeleteTests
|
||||
// The ambiguous case, and the reason the attempt count is part of the test rather than just the
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The rest of that story, when the create never did land. The tombstone carries no expected version, and
|
||||
/// the server refuses it because it has no such item — which is what the delete wanted. It used to be
|
||||
/// parked as a rejection the user could neither resolve nor dismiss, with a pending count stuck above zero.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATombstoneForACreateThatNeverLanded_IsDoneWhenTheServerSaysItHasNoSuchItem()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
var laptop = harness.First;
|
||||
|
||||
var entityId = await laptop.CreateAsync(Host("in-flight"));
|
||||
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
|
||||
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||
|
||||
await laptop.DeleteAsync(entityId);
|
||||
|
||||
var report = await laptop.SyncAsync();
|
||||
|
||||
report.Parked.ShouldBe(0, "the server not having the item is the delete succeeding");
|
||||
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
|
||||
harness.Server.RowCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// And when it did land, the shortcut above must not be what answers. The pull brings the item back first,
|
||||
/// at a version the tombstone never knew about, and the engine's ordinary rule for that applies: the delete
|
||||
/// gives way, the item stays, and the user is told — nothing parked, nothing lost, nothing stuck in the
|
||||
/// outbox. Deleting it again is then an ordinary delete of an item the server holds.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATombstoneForACreateThatDidLand_GivesWayToIt_RatherThanBeingTakenAsDone()
|
||||
{
|
||||
using var harness = await CreateAsync();
|
||||
var laptop = harness.First;
|
||||
|
||||
var entityId = await laptop.CreateAsync(Host("in-flight"));
|
||||
|
||||
// The create reaches the server with its answer lost: the server holds the item while the laptop's own
|
||||
// row still says "sent, unanswered".
|
||||
var queued = (await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldHaveSingleItem();
|
||||
await laptop.Outbox.MarkDispatchedAsync(queued.Sequence, Token);
|
||||
harness.Server.ExternalUpsert(entityId, queued.Payload.ShouldNotBeNull(), queued.Fields);
|
||||
|
||||
await laptop.DeleteAsync(entityId);
|
||||
var report = await laptop.SyncAsync();
|
||||
|
||||
report.DeletesAbandoned.ShouldBe(1, "the user is told the item came back");
|
||||
report.Parked.ShouldBe(0);
|
||||
(await laptop.Outbox.ListAllAsync(VaultId, Token)).ShouldBeEmpty();
|
||||
harness.Server.RowCount.ShouldBe(1);
|
||||
|
||||
await laptop.DeleteAsync(entityId);
|
||||
await laptop.SyncAsync();
|
||||
|
||||
harness.Server.RowCount.ShouldBe(0, "deleting it again reaches the server");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// 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
|
||||
|
||||
@@ -304,6 +304,36 @@ public sealed class FileTransferQueueTests : IDisposable
|
||||
finished.Failure.ShouldBe("the host is not reachable");
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The pump decides the queue is empty under the lock and only finishes its task after letting go, so a
|
||||
/// transfer queued in that gap used to find a pump that still looked busy, start none, and sit queued for
|
||||
/// ever. Queuing each transfer the moment the last one reports finishing is what lands in that gap.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A race, so it is run many times rather than arranged once; on the code that had it, a few hundred
|
||||
/// rounds strand a transfer every time. The session factory refuses at once, which keeps each round to
|
||||
/// the queue's own bookkeeping — the part the race is in.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public async Task ATransferQueuedAsTheLastOneFinishes_StillRuns()
|
||||
{
|
||||
await using var queue = new FileTransferQueue(
|
||||
_ => Task.FromException<IRemoteFileStore>(new IOException("refused at once")),
|
||||
TimeProvider.System);
|
||||
|
||||
for (var round = 0; round < 500; round++)
|
||||
{
|
||||
var finished = await RunToCompletionAsync(
|
||||
queue, TransferDirection.Download, Local($"round-{round}"), Remote($"round-{round}"), 1);
|
||||
|
||||
finished.State.ShouldBe(TransferState.Failed, $"round {round}");
|
||||
}
|
||||
|
||||
queue.IsBusy.ShouldBeFalse();
|
||||
}
|
||||
|
||||
private FileTransferQueue NewQueue() =>
|
||||
new(_ => Task.FromResult<IRemoteFileStore>(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
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user