Public Access
Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s
ci / build and test (push) Failing after 2s
Three files conflicted, and two of the resolutions are more than a choice of side. QuickConnectTests had both branches fixing the same build break — main's M2 merge left the shell's constructor with an ISftpSessionFactory nobody passed. Main's version wins because it carries a comment saying why the palette never needs a session. VaultSession's conflict is adjacent edits: main added the remembered sign-in members and this branch changed SyncAsync's summary from "the active vault" to "one vault". Both kept. VaultViewModel is the one that matters. Main taught the background pass to report a sync that had to start over, on the grounds that a machine which silently re-read a whole vault has had something happen to it; this branch turned a pass into one report per readable vault. Taking either side alone would have lost the other, so ResyncedFromStart is now one of the conditions IsWorthReporting checks, per vault. Merging also broke something neither branch could have caught alone, and the build would not have said a word. SyncOnceAsync cleared LastSyncFailed unconditionally, which was right while a pass was one vault and a failure was an exception that never reached that line. A failure is now a report — one unreachable team vault must not stop the others syncing — so the flag was being cleared over a vault that had just failed, lighting the titlebar SYNCED. It is computed from the report instead, in the one place both callers go through, so the manual command gets it as well as the loop. The background pass still swallows the message and keeps the fact, which is what AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to. Two comments the auto-merge left describing a world with one vault in it: the SCOPES rail's, which said team vaults are refused by the access service, and the host sidebar's "One heading, for one vault".
This commit is contained in:
@@ -86,7 +86,14 @@ internal sealed partial class DodoSshApp : Application
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
TimeProvider.System,
|
||||
connections);
|
||||
connections,
|
||||
passphraseProfile: null,
|
||||
|
||||
// The other half of signing in: a refresh grant, no browser, and nobody present. It is what
|
||||
// makes a launch after the first one arrive online rather than merely enrolled.
|
||||
resume: async (url, refreshToken, cancellationToken) => await ServerConnection
|
||||
.ResumeAsync(url, refreshToken, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false));
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -115,6 +115,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly IDeviceKeyStore deviceKeys;
|
||||
|
||||
private readonly SignInHandler signIn;
|
||||
|
||||
/// <remarks>
|
||||
/// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
|
||||
/// only be online because somebody signed in during this run, which is what every test that asserts
|
||||
/// offline behaviour relies on.
|
||||
/// </remarks>
|
||||
private readonly ResumeHandler? resume;
|
||||
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
@@ -129,6 +137,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
private string? rememberedToken;
|
||||
|
||||
/// <summary>Guards against two resume attempts overlapping.</summary>
|
||||
/// <remarks>
|
||||
/// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
|
||||
/// the Sync button — and what has to be prevented is a second attempt starting while the first is
|
||||
/// waiting on a token endpoint, not a data race.
|
||||
/// </remarks>
|
||||
private bool resuming;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -142,6 +162,19 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Re-establishes a connection from a remembered sign-in, without a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
|
||||
/// and a token endpoint, and making that the only way to reach this state machine would put the whole
|
||||
/// "comes back online by itself" behaviour out of reach of a test.
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> ResumeHandler(
|
||||
Uri serverUrl,
|
||||
string refreshToken,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <param name="sftpSessions">
|
||||
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
|
||||
/// application — one type implements both — and a separate parameter because it is a separate capability
|
||||
@@ -156,7 +189,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -164,6 +198,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.knownHosts = knownHosts;
|
||||
this.deviceKeys = deviceKeys;
|
||||
this.signIn = signIn;
|
||||
this.resume = resume;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
|
||||
@@ -305,6 +340,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
internal bool IsLocked => State == ShellState.Locked;
|
||||
|
||||
/// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
|
||||
/// <remarks>
|
||||
/// Its own property because the markup cannot express <c>IsLocked && !IsConfirmingSignOut</c>,
|
||||
/// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
|
||||
/// window guarantees at its minimum size, so putting a second question underneath it would push
|
||||
/// buttons off a screen with nothing to scroll.
|
||||
/// </remarks>
|
||||
internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
|
||||
|
||||
internal bool IsUnlocked => State == ShellState.Unlocked;
|
||||
|
||||
/// <summary>Whether a connection to the server is currently held.</summary>
|
||||
@@ -685,9 +729,23 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
|
||||
StatusMessage = outcome.Message;
|
||||
|
||||
State = outcome.Status == ProvisionStatus.EnrollmentRequired
|
||||
? ShellState.NeedsEnrollment
|
||||
: ShellState.Locked;
|
||||
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
|
||||
{
|
||||
State = ShellState.NeedsEnrollment;
|
||||
return;
|
||||
}
|
||||
|
||||
// An unlocked vault stays unlocked. This command is reachable from the preferences screen
|
||||
// of a running application — it is how somebody whose sign-in expired gets back online —
|
||||
// and moving the state machine to Locked there would throw an unlock screen over an open
|
||||
// vault whose keys are still in memory, which is neither locked nor honest.
|
||||
if (IsUnlocked)
|
||||
{
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
State = ShellState.Locked;
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -927,7 +985,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
throw;
|
||||
}
|
||||
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection);
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -955,9 +1013,190 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// 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
|
||||
// observable collections single-threaded.
|
||||
//
|
||||
// Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
|
||||
// server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
|
||||
// this unlock followed a sign-in or a cold launch on a train.
|
||||
//
|
||||
// Deliberately not awaited here, and not done before this point either. Resuming is a discovery
|
||||
// call and a token exchange — a network round trip, and on an unreachable network a slow one —
|
||||
// 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
|
||||
/// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
|
||||
/// and a machine that comes back from a closed lid is online again within a minute of having a
|
||||
/// network — with nothing pressed and no browser opened.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
|
||||
/// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
|
||||
/// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
|
||||
/// any more than it can read the hosts.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
|
||||
/// token is not a transient condition and will refuse it again once a minute forever, so that one is
|
||||
/// said out loud and the token is dropped.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection is { } held)
|
||||
{
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return held;
|
||||
}
|
||||
|
||||
if (resume is not { } handler || resuming || Vault is not { } vault)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
resuming = true;
|
||||
|
||||
try
|
||||
{
|
||||
return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
resuming = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
|
||||
/// things rather than one long one. Everything about why this behaves as it does is up there.
|
||||
/// </remarks>
|
||||
private async Task<IVaultServer?> ResumeAsync(
|
||||
VaultViewModel vault,
|
||||
ResumeHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var token = await vault.Session
|
||||
.ReadRememberedSignInAsync(cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (token is null
|
||||
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
connection = resumed;
|
||||
rememberedToken = token;
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
// The refresh that just happened may have rotated the token, and the rotated one is the only
|
||||
// one the next launch can use.
|
||||
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
return resumed;
|
||||
}
|
||||
catch (OidcException exception)
|
||||
{
|
||||
// The provider answered and said no: the session was revoked, or the token was rotated and
|
||||
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
|
||||
// the same answer, so the token goes and the user is told the one thing that fixes it.
|
||||
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
|
||||
+ "Sign in again from Preferences to start syncing.");
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Everything else is a machine with no network, a server that is down, or a vault that was
|
||||
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
|
||||
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the connection's current refresh token into the vault, if it has changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Called on every pass rather than driven by an event, because providers rotate the token inside a
|
||||
/// refresh that happens on whatever thread an API call was made from — and a value read once a minute
|
||||
/// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
|
||||
/// the next launch and nothing else, which is not worth interrupting anybody over.
|
||||
/// </remarks>
|
||||
private async Task RememberSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection?.RefreshToken is not { } token
|
||||
|| Vault is not { } vault
|
||||
|| string.Equals(token, rememberedToken, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
|
||||
rememberedToken = token;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Left unremembered. The application is signed in for this run either way.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
|
||||
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
rememberedToken = null;
|
||||
|
||||
if (Vault is not { } vault)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// A cache that will not take the deletion is one the next launch will fail to resume from and
|
||||
// then delete itself. Nothing here is worth a message.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Says something wherever the user is looking.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
|
||||
/// a message about the connection, which is the shell's business but only interesting while somebody
|
||||
/// is using an open vault, has to go to both or it is invisible half the time.
|
||||
/// </remarks>
|
||||
private void Announce(string message)
|
||||
{
|
||||
StatusMessage = message;
|
||||
|
||||
if (Vault is { } vault)
|
||||
{
|
||||
vault.Status = message;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the vault and forgets every key it held. Open shells keep running.
|
||||
/// </summary>
|
||||
@@ -1007,10 +1246,177 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
|
||||
// the same card is offered with a warning it can no longer count.
|
||||
IsConfirmingSignOut = false;
|
||||
|
||||
State = ShellState.Locked;
|
||||
StatusMessage = "Locked.";
|
||||
}
|
||||
|
||||
// ---- Signing out ----
|
||||
|
||||
/// <summary>Whether the sign-out confirmation is showing.</summary>
|
||||
/// <remarks>
|
||||
/// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
|
||||
/// action in the application that destroys something a user cannot get back from here — an unpushed
|
||||
/// change — and it has to be able to say what is about to go before it goes.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isConfirmingSignOut;
|
||||
|
||||
/// <summary>
|
||||
/// What signing out costs, on this machine, right now.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The count is the part worth having. Everything else in the vault is on the server and comes back
|
||||
/// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
|
||||
/// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
|
||||
/// the honest form of the same warning rather than a zero it has not earned.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
|
||||
{
|
||||
(false, _) =>
|
||||
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
||||
+ "counted from here, because the vault is locked.",
|
||||
(true, 0) =>
|
||||
"Everything this machine has changed has reached the server, so nothing will be lost.",
|
||||
(true, 1) =>
|
||||
"1 change has not reached the server yet and will be lost. Sync first to keep it.",
|
||||
(true, var pending) =>
|
||||
$"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
|
||||
};
|
||||
|
||||
/// <summary>Asks whether the user means it.</summary>
|
||||
[RelayCommand]
|
||||
private void SignOut()
|
||||
{
|
||||
// Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
|
||||
// does not close a shell any more than locking does, and a screen that sends somebody back to
|
||||
// "connect to your server" while their upgrade is still running should say so.
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
|
||||
IsConfirmingSignOut = true;
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelSignOut() => IsConfirmingSignOut = false;
|
||||
|
||||
/// <summary>
|
||||
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
|
||||
/// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
|
||||
/// here and on the account. The vault itself is on the server and is untouched, which is what makes
|
||||
/// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
|
||||
/// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
|
||||
/// while there is still a session and a connection to withdraw it through; the vault is closed before
|
||||
/// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
|
||||
/// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It does not end the session at the identity provider — there is no back channel to it from here,
|
||||
/// and pretending otherwise would be the sort of claim this project writes down instead of implying.
|
||||
/// The refresh token this machine held is dropped and never used again; the provider's own session
|
||||
/// outlives it, which is what the preferences screen says out loud.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await RunAsync(
|
||||
"Signing out…",
|
||||
async () =>
|
||||
{
|
||||
IsConfirmingSignOut = false;
|
||||
|
||||
await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// As Lock does, and before the session it reads from goes.
|
||||
knownHosts.Close();
|
||||
|
||||
// The same detach locking does, and the same reasoning carried one step further: the host
|
||||
// rows go because the vault behind them is about to be disposed, and the session and its
|
||||
// queue stay because a transfer in flight is somebody's work. Signing out is the strongest
|
||||
// thing this application does to itself and it still does not destroy that, for exactly the
|
||||
// reason it does not close a shell — quitting DodoSSH is what ends both.
|
||||
transfers.Detach();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
connection?.Dispose();
|
||||
connection = null;
|
||||
rememberedToken = null;
|
||||
|
||||
await caches.ResetAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LiveSessionCount = workspace.LiveSessionCount;
|
||||
|
||||
AccountName = null;
|
||||
Passphrase = string.Empty;
|
||||
ConfirmPassphrase = string.Empty;
|
||||
RecoveryCode = null;
|
||||
RecoveryCodeWrittenDown = false;
|
||||
CanUnlockWithDevice = false;
|
||||
CanRegisterDevice = false;
|
||||
CanForgetDevice = false;
|
||||
|
||||
State = ShellState.NeedsServer;
|
||||
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
|
||||
+ "itself is untouched. Sign in to set this machine up again.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
|
||||
/// half that stops the account listing a machine whose key is about to be deleted — and a server that
|
||||
/// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
|
||||
/// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
|
||||
/// because the profile holding the wrap is emptied a moment later.
|
||||
/// </remarks>
|
||||
private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Vault is { } vault)
|
||||
{
|
||||
await vault.Session
|
||||
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
|
||||
return;
|
||||
}
|
||||
|
||||
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Nothing to report: the wipe below is what signing out actually is.
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -1184,6 +1590,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
OnPropertyChanged(nameof(IsFullySynced));
|
||||
OnPropertyChanged(nameof(SyncLabel));
|
||||
|
||||
// The same fact from a third direction: what signing out would cost is the outbox depth, and a
|
||||
// confirmation card left showing a count from before the last pass would be quoting a number that
|
||||
// has since been sent.
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -1290,8 +1701,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsNeedingEnrollment));
|
||||
OnPropertyChanged(nameof(IsShowingRecoveryCode));
|
||||
OnPropertyChanged(nameof(IsLocked));
|
||||
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
||||
OnPropertyChanged(nameof(IsUnlocked));
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
OnPropertyChanged(nameof(SignOutWarning));
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
@@ -1328,6 +1741,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
partial void OnIsSearchingChanged(bool value) => OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
|
||||
/// <remarks>
|
||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||
/// <see cref="IsAskingForThePassphrase"/>.
|
||||
/// </remarks>
|
||||
partial void OnIsConfirmingSignOutChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(IsAskingForThePassphrase));
|
||||
|
||||
partial void OnCanRegisterDeviceChanged(bool value) =>
|
||||
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
|
||||
|
||||
|
||||
@@ -37,6 +37,12 @@ internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
|
||||
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
||||
internal string Permissions => entry.Permissions;
|
||||
|
||||
/// <summary>Whether the row is a file with an execute bit, which the NAME column colours for.</summary>
|
||||
internal bool IsExecutable => entry.IsExecutable;
|
||||
|
||||
/// <summary>Whether the row is a file anyone may write to, which the PERMS column colours for.</summary>
|
||||
internal bool IsWorldWritable => entry.IsWorldWritable;
|
||||
}
|
||||
|
||||
/// <summary>One local file or directory, as a row.</summary>
|
||||
@@ -174,6 +180,40 @@ internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) :
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the host that has been asked about and not yet agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one deletion in this application that nothing can walk back. A vault item is a tombstone against a
|
||||
/// copy the server still holds until the pass lands; a file on somebody's host is bytes, and this screen
|
||||
/// has no wastebasket to put them in.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It carries the full path rather than only the name, because the name is the half that does not identify
|
||||
/// anything: <c>config</c> in the directory that was showing a moment ago and <c>config</c> in the one
|
||||
/// showing now look identical in a confirmation, and only one of them is the file somebody meant.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Name">What the row was called.</param>
|
||||
/// <param name="FullPath">Where it is, which is what the question actually promises to delete.</param>
|
||||
/// <param name="IsDirectory">Whether it is a directory, which the host treats differently.</param>
|
||||
internal sealed record RemoteDeletionRequest(string Name, string FullPath, bool IsDirectory)
|
||||
{
|
||||
/// <summary>The question, naming the kind because the two behave differently.</summary>
|
||||
internal string Question => IsDirectory
|
||||
? $"Delete the directory '{Name}' on the host?"
|
||||
: $"Delete '{Name}' on the host?";
|
||||
|
||||
/// <summary>What it costs, which is everything: there is no copy here and no undo there.</summary>
|
||||
internal string Consequence => IsDirectory
|
||||
? "It is removed on the host itself. The host refuses a directory that still has anything in it, so "
|
||||
+ "this either removes an empty one or fails — and if it goes, it is gone: nothing here keeps a "
|
||||
+ "copy and there is no undo."
|
||||
: "It is removed on the host itself. Nothing here keeps a copy, the folder on this machine is not "
|
||||
+ "touched, and there is no undo.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
@@ -284,6 +324,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
/// <summary>The deletion on the host that has been asked about, or null when none has.</summary>
|
||||
[ObservableProperty]
|
||||
private RemoteDeletionRequest? pendingRemoteDeletion;
|
||||
|
||||
internal bool IsConfirmingRemoteDeletion => PendingRemoteDeletion is not null;
|
||||
|
||||
/// <summary>Whether the pane's DELETE is live.</summary>
|
||||
/// <remarks>
|
||||
/// Off while its own question is up, so a second press cannot arm a second one behind the card — and
|
||||
/// disabled rather than hidden, because this button sits in a row of three and a gap where it was would
|
||||
/// move UP and REFRESH out from under the pointer.
|
||||
/// </remarks>
|
||||
internal bool CanDeleteRemote => IsConnected && !IsConfirmingRemoteDeletion;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -672,15 +726,16 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// Asks whether the chosen remote file, or empty directory, should go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// Deleting on the host is offered because the queue refuses to overwrite: without a way to remove what
|
||||
/// is in the way, "that file is already there" would be a dead end. It is asked about first because of
|
||||
/// what it is — the only thing this application destroys that neither the server nor this machine has a
|
||||
/// copy of.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
private void DeleteRemote()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
@@ -688,18 +743,44 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = new RemoteDeletionRequest(row.Name, row.FullPath, !row.IsFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes what was agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It acts on the path the question named rather than on the selection,
|
||||
/// which is what makes the question a promise: nothing between asking and answering can point it
|
||||
/// somewhere else.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingRemoteDeletion is not { } request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
$"Deleting {request.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
await RequireSession().DeleteAsync(request.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
Status = $"Deleted {request.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelDeleteRemote() => PendingRemoteDeletion = null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@@ -919,11 +1000,28 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
/// <remarks>
|
||||
/// Any change to the selection takes the question away, which is stricter than the vault's rule and can
|
||||
/// afford to be: this list is refilled only by a navigation or a refresh somebody asked for, so there is
|
||||
/// no background pass to pull a card out from under a reader. Listing and disconnecting both null the
|
||||
/// selection, so this one hook covers all three ways the answer could stop being about what was asked.
|
||||
/// </remarks>
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
PendingRemoteDeletion = null;
|
||||
}
|
||||
|
||||
partial void OnPendingRemoteDeletionChanged(RemoteDeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingRemoteDeletion));
|
||||
OnPropertyChanged(nameof(CanDeleteRemote));
|
||||
}
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
|
||||
@@ -494,6 +494,73 @@ internal sealed record VaultItemRowViewModel(
|
||||
internal bool HasBadge => Badge.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||||
internal enum DeletionTarget
|
||||
{
|
||||
/// <summary>A host, from the sidebar beside the terminal.</summary>
|
||||
Host,
|
||||
|
||||
/// <summary>An SSH key, from the vault screen.</summary>
|
||||
Key,
|
||||
|
||||
/// <summary>A stored password, from the vault screen.</summary>
|
||||
Credential,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A deletion that has been asked for and not yet agreed to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
|
||||
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
|
||||
/// below are <em>computed</em>: how many hosts authenticate with the key about to go, whether a terminal is
|
||||
/// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that
|
||||
/// only said "are you sure?" would be a click to train people out of.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It carries the item's id rather than pointing at the selection, so that whatever moves the selection
|
||||
/// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an
|
||||
/// agreement about one item into the deletion of another.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="Target">Which list to delete from.</param>
|
||||
/// <param name="EntityId">The item the question is about.</param>
|
||||
/// <param name="Question">The question itself, naming the item.</param>
|
||||
/// <param name="Consequence">Where it goes, and how far.</param>
|
||||
/// <param name="Usage">
|
||||
/// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or
|
||||
/// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares.
|
||||
/// </param>
|
||||
internal sealed record DeletionRequest(
|
||||
DeletionTarget Target,
|
||||
Guid EntityId,
|
||||
string Question,
|
||||
string Consequence,
|
||||
string Usage)
|
||||
{
|
||||
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
|
||||
internal bool HasUsage => Usage.Length > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online, if it can be.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
|
||||
/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
|
||||
/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
|
||||
/// of reaching a network, with nothing pressed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
|
||||
/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
|
||||
/// locally and queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
||||
/// </summary>
|
||||
@@ -510,6 +577,12 @@ internal sealed record VaultItemRowViewModel(
|
||||
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
|
||||
/// shell for a connection rather than reading one it was handed at unlock — see
|
||||
/// <see cref="ServerReconnectHandler" /> — so a machine that unlocked with no network comes online by
|
||||
/// itself once it has one, and a sign-in survives a restart without a browser opening.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Everything a connection needs is in the vault.</b> Keys, passwords and host key trust are all synced
|
||||
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
|
||||
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
|
||||
@@ -528,7 +601,8 @@ internal sealed partial class VaultViewModel(
|
||||
VaultSession session,
|
||||
TerminalWorkspace workspace,
|
||||
VaultKnownHostStore knownHosts,
|
||||
Func<IVaultServer?> connection) : ObservableObject, IAsyncDisposable
|
||||
Func<IVaultServer?> connection,
|
||||
ServerReconnectHandler? reconnect = null) : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
/// <remarks>
|
||||
/// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the
|
||||
@@ -958,6 +1032,32 @@ internal sealed partial class VaultViewModel(
|
||||
/// <summary>The credential being edited, or null when creating.</summary>
|
||||
private Guid? editingCredentialId;
|
||||
|
||||
// ---- Deleting ----
|
||||
|
||||
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
|
||||
/// <remarks>
|
||||
/// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
|
||||
/// looking at two cards, and this application only ever has one selected item per screen to aim a
|
||||
/// question at.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private DeletionRequest? pendingDeletion;
|
||||
|
||||
internal bool IsConfirmingDeletion => PendingDeletion is not null;
|
||||
|
||||
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
|
||||
/// <remarks>
|
||||
/// Its own property because the markup cannot express <c>!IsEditing && !IsConfirmingDeletion</c>,
|
||||
/// and because both halves are the same rule: the question about deleting a host takes the place of the
|
||||
/// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is
|
||||
/// on screen.
|
||||
/// </remarks>
|
||||
internal bool ShowsHostActions => !IsEditing && !IsConfirmingDeletion;
|
||||
|
||||
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
|
||||
/// <inheritdoc cref="ShowsHostActions" />
|
||||
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion;
|
||||
|
||||
// ---- Connecting ----
|
||||
|
||||
/// <remarks>
|
||||
@@ -1227,7 +1327,7 @@ internal sealed partial class VaultViewModel(
|
||||
/// <returns>How many keys would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||||
/// <see cref="DeleteKeyAsync" /> acts on, and a list that picked a row on every background sync would aim
|
||||
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
|
||||
/// that button at a key nobody chose.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||||
@@ -1266,9 +1366,9 @@ internal sealed partial class VaultViewModel(
|
||||
/// <returns>How many credentials would not decrypt.</returns>
|
||||
/// <remarks>
|
||||
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
||||
/// as the key list and matters more here. <see cref="DeleteCredentialAsync" /> acts on the selection, so a
|
||||
/// list that fell back to its first row would put a one-click deletion of somebody's password behind a
|
||||
/// button they never aimed.
|
||||
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
||||
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
||||
/// password nobody chose.
|
||||
/// </remarks>
|
||||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -1364,21 +1464,27 @@ internal sealed partial class VaultViewModel(
|
||||
private static string Endpoint(string host, int port) =>
|
||||
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
|
||||
|
||||
/// <summary>Runs a synchronisation pass, if there is a server to talk to.</summary>
|
||||
/// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
|
||||
/// <remarks>
|
||||
/// The offline branch is inside <see cref="RunAsync" /> rather than in front of it, because getting
|
||||
/// online is now part of what this button does: resuming a remembered sign-in is a network round trip
|
||||
/// and belongs under the same busy flag as the pass it leads to.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent after you sign in.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Synchronising…",
|
||||
async () =>
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
||||
+ "can reach the server again.";
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Null means a background pass held the gate. Saying so beats reporting a sync that this
|
||||
@@ -1389,6 +1495,18 @@ internal sealed partial class VaultViewModel(
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds a server to sync against, getting this machine online if it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The handler is asked even when a connection is already held, which looks redundant and is not: the
|
||||
/// shell is the thing that persists the refresh token so a later launch can resume, and identity
|
||||
/// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
|
||||
/// sign-in current without an event and without this view model knowing what a token is.
|
||||
/// </remarks>
|
||||
private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
|
||||
reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Starts syncing in the background until the vault is disposed.
|
||||
/// </summary>
|
||||
@@ -1425,13 +1543,38 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy || connection() is not { } server)
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One background synchronisation pass, run whether or not a command is in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The same quiet pass as <see cref="AutoSyncAsync" /> without the one thing that made it useless at
|
||||
/// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
|
||||
/// timed pass yields to is raised by the very command that opened the vault — and the pass on open
|
||||
/// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Yielding is right for every later pass, because by then a busy flag means a person is doing
|
||||
/// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (report is null)
|
||||
@@ -1439,16 +1582,11 @@ internal sealed partial class VaultViewModel(
|
||||
return;
|
||||
}
|
||||
|
||||
// A vault that failed now arrives as a report rather than as an exception, because one
|
||||
// unreachable team vault must not stop the others syncing. It still has to be treated the way
|
||||
// the catch below treats a total failure: the fact recorded, the message swallowed. Otherwise
|
||||
// a laptop with a lid shut all afternoon replaces whatever the user was reading, once a
|
||||
// minute, with the name of a vault it could not reach.
|
||||
if (report.Any(vault => !vault.Succeeded))
|
||||
{
|
||||
LastSyncFailed = true;
|
||||
}
|
||||
|
||||
// A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
|
||||
// gets the treatment the catch below gives a total failure, the fact kept and the message
|
||||
// swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
|
||||
// reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
|
||||
// names the vault and the reason, because somebody who pressed it is waiting for an answer.
|
||||
if (IsWorthReporting(report))
|
||||
{
|
||||
Status = Describe(report);
|
||||
@@ -1491,7 +1629,11 @@ internal sealed partial class VaultViewModel(
|
||||
// pulled it — and then quietly stop, which reads as the feature not working.
|
||||
var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
LastSyncFailed = false;
|
||||
// Not unconditionally false, which it was while a pass was one vault and a failure was an
|
||||
// exception. A failure is now a report — one unreachable team vault must not stop the others
|
||||
// syncing — so clearing the flag here regardless would light the titlebar green over a vault
|
||||
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
||||
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
@@ -1523,7 +1665,14 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
// A pass on open, before the first tick. A vault edited on another machine should be current by
|
||||
// the time the user has finished reading the list, not a minute afterwards.
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
//
|
||||
// Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
|
||||
// inside the unlock command, so the busy flag that pass yields to is raised by the very command
|
||||
// that opened the vault — and the pass on open therefore never ran at all. It was a silent
|
||||
// no-op that put the first synchronisation a full minute after unlock, on the launch where
|
||||
// being current matters most. The later passes keep the check: by then, a busy flag means a
|
||||
// user is doing something.
|
||||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
@@ -1643,26 +1792,23 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes whatever the selected row is.</summary>
|
||||
/// <summary>Asks about deleting whatever the selected row is.</summary>
|
||||
/// <remarks>
|
||||
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
||||
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
||||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteSelectedItemAsync()
|
||||
private void DeleteSelectedItem()
|
||||
{
|
||||
switch (SelectedVaultItem?.Kind)
|
||||
{
|
||||
// Null rather than a token, and deliberately: a [RelayCommand] over a method whose only
|
||||
// parameter is a CancellationToken generates ExecuteAsync(object? parameter) that ignores the
|
||||
// argument and supplies a token from its own source. Passing one would read as plumbing.
|
||||
case VaultItemKind.Key:
|
||||
await DeleteKeyCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
DeleteKeyCommand.Execute(null);
|
||||
break;
|
||||
|
||||
case VaultItemKind.Credential:
|
||||
await DeleteCredentialCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
DeleteCredentialCommand.Execute(null);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -1728,15 +1874,42 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the selected host.</summary>
|
||||
/// <summary>Asks whether the selected host should go.</summary>
|
||||
/// <remarks>
|
||||
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||||
/// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
|
||||
/// deleting a machine they are still working on should know that is what they have done.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteHostAsync(CancellationToken cancellationToken)
|
||||
private void DeleteHost()
|
||||
{
|
||||
if (SelectedHost is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Host,
|
||||
row.EntityId,
|
||||
$"Delete the host '{row.Label}'?",
|
||||
HowFarADeletionGoes("The host and everything saved about it"),
|
||||
row.IsConnected
|
||||
? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
|
||||
+ "nothing will reopen it afterwards."
|
||||
: string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
|
||||
private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
{
|
||||
// Gone between the question and the answer — a sync that pulled somebody else's deletion is the
|
||||
// realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
|
||||
Status = "That host is no longer here, so nothing was deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -1857,15 +2030,39 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the selected key.</summary>
|
||||
/// <summary>Asks whether the selected key should go.</summary>
|
||||
/// <remarks>
|
||||
/// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
|
||||
/// why the question says so. What it does not say is that the key is gone from the machines it was
|
||||
/// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
|
||||
/// server is not something this application has ever written to.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteKeyAsync(CancellationToken cancellationToken)
|
||||
private void DeleteKey()
|
||||
{
|
||||
if (SelectedKey is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Key,
|
||||
row.EntityId,
|
||||
$"Delete the SSH key '{row.Label}'?",
|
||||
HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
|
||||
+ " If this key is not on disk anywhere else, this is the only copy.",
|
||||
HostsBoundTo(host => host.SshKeyId, row.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
||||
private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
{
|
||||
Status = "That key is no longer here, so nothing was deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -1984,15 +2181,32 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the selected credential.</summary>
|
||||
/// <summary>Asks whether the selected credential should go.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteCredentialAsync(CancellationToken cancellationToken)
|
||||
private void DeleteCredential()
|
||||
{
|
||||
if (SelectedCredential is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = new DeletionRequest(
|
||||
DeletionTarget.Credential,
|
||||
row.EntityId,
|
||||
$"Delete the password '{row.Label}'?",
|
||||
HowFarADeletionGoes("The password and the account saved with it"),
|
||||
HostsBoundTo(host => host.CredentialId, row.EntityId));
|
||||
}
|
||||
|
||||
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
||||
private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||||
{
|
||||
Status = "That password is no longer here, so nothing was deleted.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Deleting…",
|
||||
async () =>
|
||||
@@ -2008,6 +2222,92 @@ internal sealed partial class VaultViewModel(
|
||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Carries out the deletion that was asked about.</summary>
|
||||
/// <remarks>
|
||||
/// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a
|
||||
/// second press during a slow round trip has nothing left to agree to.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingDeletion is not { } request)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PendingDeletion = null;
|
||||
|
||||
switch (request.Target)
|
||||
{
|
||||
case DeletionTarget.Host:
|
||||
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
case DeletionTarget.Key:
|
||||
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
case DeletionTarget.Credential:
|
||||
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Thinks better of it.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelDelete() => PendingDeletion = null;
|
||||
|
||||
/// <summary>
|
||||
/// Where a deleted item goes, and how far.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
|
||||
/// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
|
||||
/// credential because it leaked should be told which of those two they have just done.
|
||||
/// </remarks>
|
||||
private string HowFarADeletionGoes(string what) => connection() is null
|
||||
? $"{what} goes from this machine now, and from your other machines once this one is online again. "
|
||||
+ "There is no undo."
|
||||
: $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
|
||||
+ "There is no undo.";
|
||||
|
||||
/// <summary>
|
||||
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Counted rather than warned about in general terms. The number is the difference between a sentence
|
||||
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
|
||||
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a
|
||||
/// typed password — see <see cref="TryBuildAuthentication" />.
|
||||
/// </remarks>
|
||||
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId)
|
||||
{
|
||||
var bound = Hosts
|
||||
.Where(row => binding(row.Host) == entityId)
|
||||
.Select(row => row.Label)
|
||||
.ToArray();
|
||||
|
||||
if (bound.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
// Three names and a count past that, because this is read in a 244-pixel column and a vault with
|
||||
// twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
|
||||
var named = bound.Length <= 3
|
||||
? string.Join(", ", bound)
|
||||
: $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";
|
||||
|
||||
return bound.Length == 1
|
||||
? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
|
||||
+ "password."
|
||||
: $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
|
||||
+ "fall back to a typed password.";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Withdraws trust from the selected pin's endpoint.
|
||||
/// </summary>
|
||||
@@ -2677,7 +2977,14 @@ internal sealed partial class VaultViewModel(
|
||||
/// </remarks>
|
||||
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
|
||||
reports.Any(vault => vault.Succeeded
|
||||
&& (vault.Report!.Pulled > 0 || vault.Report.Pushed > 0 || vault.Report.NeedsAttention));
|
||||
&& (vault.Report!.Pulled > 0
|
||||
|| vault.Report.Pushed > 0
|
||||
|| vault.Report.NeedsAttention
|
||||
|
||||
// A pass that had to start over says so even when it pulled nothing, which is the one
|
||||
// place this rule is broken deliberately. A machine that silently re-read a whole vault
|
||||
// has had something happen to it, and the alternative is that nobody ever finds out.
|
||||
|| vault.Report.ResyncedFromStart));
|
||||
|
||||
/// <remarks>
|
||||
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
|
||||
@@ -2728,11 +3035,19 @@ internal sealed partial class VaultViewModel(
|
||||
|
||||
private static string Describe(SyncReport report)
|
||||
{
|
||||
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass
|
||||
// reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong;
|
||||
// this is what actually happened, and it needs nothing from the reader.
|
||||
var replayed = report.ResyncedFromStart
|
||||
? "The server no longer recognised this machine's position, so the vault was read again from "
|
||||
+ "the beginning. "
|
||||
: string.Empty;
|
||||
|
||||
if (!report.NeedsAttention)
|
||||
{
|
||||
return report.Pulled == 0 && report.Pushed == 0
|
||||
return replayed + (report.Pulled == 0 && report.Pushed == 0
|
||||
? "Already up to date."
|
||||
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.";
|
||||
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
|
||||
}
|
||||
|
||||
var notes = new List<string>();
|
||||
@@ -2764,7 +3079,7 @@ internal sealed partial class VaultViewModel(
|
||||
notes.Add("this vault was rekeyed and your access needs re-issuing");
|
||||
}
|
||||
|
||||
return "Synchronised, but: " + string.Join("; ", notes) + ".";
|
||||
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
|
||||
}
|
||||
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
@@ -2799,6 +3114,33 @@ internal sealed partial class VaultViewModel(
|
||||
{
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||||
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
||||
}
|
||||
|
||||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||||
OnPropertyChanged(nameof(ShowsHostActions));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes the question away when the selection it was asked about has moved on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Compared by entity id rather than by row, and that is the whole point of the method. A reload
|
||||
/// replaces every row object in the list, so a background pass a minute after the question would
|
||||
/// otherwise take the card away from under somebody still reading it — while a click onto a different
|
||||
/// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
|
||||
/// nobody is looking at any more.
|
||||
/// </remarks>
|
||||
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
|
||||
{
|
||||
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
|
||||
{
|
||||
PendingDeletion = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
@@ -2886,6 +3228,11 @@ internal sealed partial class VaultViewModel(
|
||||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||||
OnPropertyChanged(nameof(SelectedItemIsPin));
|
||||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||||
OnPropertyChanged(nameof(ShowsItemActions));
|
||||
|
||||
// Both kinds this table can delete, because one selection covers both lists.
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||||
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||||
|
||||
switch (value?.Kind)
|
||||
{
|
||||
@@ -2936,8 +3283,34 @@ internal sealed partial class VaultViewModel(
|
||||
/// flips back in every path that closes one, so this notification always observes the pair in a
|
||||
/// consistent state.
|
||||
/// </remarks>
|
||||
partial void OnIsEditingChanged(bool value) =>
|
||||
partial void OnIsEditingChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanForgetHostKey));
|
||||
OnPropertyChanged(nameof(ShowsHostActions));
|
||||
|
||||
DisarmOnceAnEditorIsOpen(value);
|
||||
}
|
||||
|
||||
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||
|
||||
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||||
|
||||
/// <summary>
|
||||
/// Takes the question away when an editor opens over the pane it was asked in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
|
||||
/// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
|
||||
/// rule for both, rather than a guard on the three commands that would have to be remembered by the
|
||||
/// fourth.
|
||||
/// </remarks>
|
||||
private void DisarmOnceAnEditorIsOpen(bool opened)
|
||||
{
|
||||
if (opened)
|
||||
{
|
||||
PendingDeletion = null;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.ConfirmDeleteCard"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
<!--
|
||||
The question in front of deleting something in the vault.
|
||||
|
||||
One control used in two places — the host sidebar, where it takes the place of the row of buttons that
|
||||
opened it, and the vault screen's detail pane, where it takes the place of EDIT and DELETE. The two
|
||||
moments are different and what has to be said is not, which is why this is a shared control rather than
|
||||
two blocks that would drift apart. The sign-out confirmation is the same arrangement, for the same
|
||||
reason; see SignOutCard.
|
||||
|
||||
A bare StackPanel and not a card, because the two hosts frame it themselves: the sidebar puts it in the
|
||||
strip along its bottom edge, and the vault screen in a column that scrolls.
|
||||
|
||||
Everything it says is something the view model can answer. The question names the item, the consequence
|
||||
knows whether this machine can push a tombstone yet, and the line in the box is a count of the hosts
|
||||
that actually authenticate with the thing about to go — see VaultViewModel.HostsBoundTo. A confirmation
|
||||
that only asked "are you sure?" would be a click to train people out of.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="8">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||
Text="{Binding PendingDeletion.Question}" />
|
||||
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||
Text="{Binding PendingDeletion.Consequence}" />
|
||||
|
||||
<!--
|
||||
What else in this vault leans on it. In a box of its own because it is the line that changes the
|
||||
answer: everything above is true of every deletion, and this is about the one being made.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="8,6"
|
||||
IsVisible="{Binding PendingDeletion.HasUsage, FallbackValue=False}">
|
||||
<TextBlock Foreground="{StaticResource Info}" FontSize="11" TextWrapping="Wrap"
|
||||
Text="{Binding PendingDeletion.Usage}" />
|
||||
</Border>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The question in front of deleting a host, a key or a password.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>VaultViewModel</c>, in both of the places it is shown, so every binding in the
|
||||
/// markup is a property of the vault. See <see cref="HostSidebar"/> and <see cref="VaultScreen"/>.
|
||||
/// </remarks>
|
||||
internal sealed partial class ConfirmDeleteCard : UserControl
|
||||
{
|
||||
public ConfirmDeleteCard() => InitializeComponent();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.HostSidebar"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
@@ -35,8 +36,10 @@
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
One heading, for one vault. The chevron folds the list away; the count is the collection's own, so it
|
||||
follows the filter without a second number to keep in step.
|
||||
One heading, which names the vault while there is one and says ALL VAULTS once a team's is readable
|
||||
too — a heading that went on naming the personal vault over a list containing a team's hosts would be
|
||||
a quiet lie, so the rows carry the vault name instead. The chevron folds the list away; the count is
|
||||
the collection's own, so it follows the filter without a second number to keep in step.
|
||||
-->
|
||||
<Button Grid.Row="1" Classes="flat grouphead" Command="{Binding ToggleHostsCommand}"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch">
|
||||
@@ -183,7 +186,7 @@
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="4" Padding="10,8" BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding !IsEditing}">
|
||||
IsVisible="{Binding ShowsHostActions}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="+ NEW HOST" Command="{Binding NewHostCommand}" />
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedHostCommand}" />
|
||||
@@ -191,6 +194,19 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The question DELETE asks, in the place the buttons were rather than under them. This strip is at the
|
||||
bottom edge of a column whose middle is a list that has already taken every spare pixel, so a second
|
||||
block below the first would push its own buttons off the window — the same reasoning that swaps the
|
||||
unlock card for the sign-out card rather than stacking them. Swapping also means DELETE cannot be
|
||||
pressed again while its own question is up; see VaultViewModel.ShowsHostActions.
|
||||
-->
|
||||
<Border Grid.Row="4" Padding="10,8" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,1,0,0"
|
||||
IsVisible="{Binding IsConfirmingDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
@@ -11,7 +13,29 @@ namespace DodoSSH.Client.App.Views;
|
||||
/// </remarks>
|
||||
internal sealed partial class HostSidebar : UserControl
|
||||
{
|
||||
public HostSidebar() => InitializeComponent();
|
||||
public HostSidebar()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, which is how
|
||||
// the transfers screen opens a directory too. Double-clicking a machine to get a shell on it is what
|
||||
// every other client of this kind does, and the CONNECT button stays: it is the one that has the
|
||||
// password box beside it, and a host that asks for a password still needs it typed first.
|
||||
HostList.DoubleTapped += OnHostActivated;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, as the transfers screen's is: the command reports its own failures onto the status
|
||||
/// line — an unknown host key, a refused password — and awaiting it here would mean an event handler
|
||||
/// returning a task nothing observes.
|
||||
/// </remarks>
|
||||
private void OnHostActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is VaultViewModel vault)
|
||||
{
|
||||
_ = vault.ConnectCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard should land when the terminal hands it back.
|
||||
|
||||
@@ -240,7 +240,13 @@
|
||||
<TextBlock Classes="heading" Text="Connect to your server" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
||||
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
||||
<!--
|
||||
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
|
||||
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
|
||||
not replayed. See MainWindow.axaml.cs.
|
||||
-->
|
||||
<TextBox x:Name="ServerUrlBox" Text="{Binding ServerUrl}"
|
||||
PlaceholderText="https://dodossh.example" />
|
||||
<Button Classes="accent" Content="SIGN IN WITH YOUR BROWSER" Command="{Binding SignInCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
@@ -284,54 +290,22 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
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 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"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
<!--
|
||||
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
|
||||
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
|
||||
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
|
||||
<views:UnlockCard x:Name="UnlockPane" />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
<!--
|
||||
The confirmation, in place of the unlock card rather than under it: the card is already close
|
||||
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
|
||||
worse than one that shows one question at a time.
|
||||
-->
|
||||
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
@@ -74,6 +74,23 @@ internal sealed partial class MainWindow : Window
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard belongs once the vault is no longer open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two ways out of an unlocked vault, and they land on different screens: locking shows the passphrase
|
||||
/// box, and signing out empties this machine and goes back to asking for a server. Both collapse the
|
||||
/// controls the keyboard was on, and <c>Focus()</c> on a collapsed control is a no-op that is not
|
||||
/// replayed when it is revealed — so a fixed target would leave whoever signed out with a window that
|
||||
/// swallows every keystroke until they click something.
|
||||
/// </remarks>
|
||||
private IInputElement ClosedVaultKeyboardHome => shell?.State switch
|
||||
{
|
||||
ShellState.Locked => UnlockPane.PassphraseBox,
|
||||
ShellState.NeedsServer => ServerUrlBox,
|
||||
_ => this,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The shortcuts the window owns.
|
||||
/// </summary>
|
||||
@@ -166,7 +183,7 @@ internal sealed partial class MainWindow : Window
|
||||
// change, and reacting to all of them would move focus during setup and sign-in.
|
||||
if (wasUnlocked && !unlocked)
|
||||
{
|
||||
ReleaseKeyboardTo(UnlockPassphrase);
|
||||
ReleaseKeyboardTo(ClosedVaultKeyboardHome);
|
||||
}
|
||||
|
||||
wasUnlocked = unlocked;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
@@ -76,18 +77,49 @@
|
||||
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open, so nothing depends on this being pressed." />
|
||||
Text="Runs a pass now. One runs on its own when the vault opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
|
||||
</StackPanel>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
|
||||
IsVisible="{Binding !IsOnline}"
|
||||
ToolTip.Tip="Opens your browser. Syncing needs a connection; everything else works without one." />
|
||||
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
|
||||
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" />
|
||||
|
||||
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="11" Margin="0,8,0,0"
|
||||
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
|
||||
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
|
||||
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="12"
|
||||
FontWeight="Medium" />
|
||||
<TextBlock Classes="hint" FontSize="10"
|
||||
Text="Deletes this machine's copy of the vault and withdraws its device key, so it goes back to knowing nothing. The vault stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
|
||||
</StackPanel>
|
||||
<!--
|
||||
Hidden rather than disabled while the confirmation is up, because the card below carries the
|
||||
button that actually does it and two sign-out buttons on one screen is one too many.
|
||||
-->
|
||||
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
|
||||
Command="{Binding SignOutCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding !IsConfirmingSignOut}" />
|
||||
</Grid>
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
|
||||
IsVisible="{Binding IsConfirmingSignOut}">
|
||||
<views:SignOutCard />
|
||||
</Border>
|
||||
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
|
||||
|
||||
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="13" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
|
||||
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.SignOutCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The sign-out confirmation.
|
||||
|
||||
One control used in two places — the preferences screen, where somebody is leaving a machine on
|
||||
purpose, and the unlock screen, where somebody has forgotten their passphrase and this is the only way
|
||||
forward. The two moments are different and the warning is not, which is why this is a shared control
|
||||
rather than two blocks that would drift apart.
|
||||
|
||||
It is a bare StackPanel and not a card: the two hosts frame it differently, because a card that centres
|
||||
itself is right over a lock screen and wrong halfway down a scrolling column of preferences.
|
||||
|
||||
Everything it says is something the state machine can actually answer. The count comes from the outbox,
|
||||
the shell count from the workspace, and the sentence about the identity provider is there because
|
||||
nothing here can end that session — see MainWindowViewModel.ConfirmSignOutAsync.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="10">
|
||||
|
||||
<TextBlock Classes="heading" FontSize="15" Text="Sign out of this machine?" />
|
||||
|
||||
<TextBlock Text="{Binding SignOutWarning}" Foreground="{StaticResource WarnText}"
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This deletes this machine's copy of the vault — the profile, the cached hosts, keys and passwords, and this machine's device key. Your vault is on the server and is not touched: signing in again brings it all back." />
|
||||
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Signing out does not close them, exactly as locking does not. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The other thing that outlives signing out, on the same reasoning and worth its own line because it is
|
||||
a connection this machine holds rather than a window it shows: a transfer in flight authenticated
|
||||
before any of this and keeps writing into its part file afterwards.
|
||||
-->
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Transfers.IsConnected, FallbackValue=False}"
|
||||
Text="A file-transfer session is open on this machine. Signing out does not close it either — it goes when DodoSSH does." />
|
||||
|
||||
<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}" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" FontSize="10" TextWrapping="Wrap"
|
||||
Text="Your session at the identity provider is not ended by this — DodoSSH has no way to end it — so on a machine that is not yours, sign out there too." />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -23,6 +23,16 @@
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
|
||||
Two further colours come from the mode, and they are split across the two columns on purpose: NAME says
|
||||
what a row is, PERMS says what is notable about how it is set. So an executable is green in NAME —
|
||||
"live, yours, something that runs" — while a file anyone may write to is amber in PERMS, over the
|
||||
characters that actually say so. The two never compete for one TextBlock, which is what lets a
|
||||
world-writable executable show both facts instead of one winning an argument.
|
||||
|
||||
Both are files only; see SftpEntry, which will not read a mode off a symbolic link or a directory.
|
||||
Rendering `-rwxrwxrwx` in two colours at once is not something this list can do, so amber over the whole
|
||||
string is the compromise: the eye lands on the column, and the string itself is the detail.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
@@ -30,6 +40,25 @@
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.exec">
|
||||
<Setter Property="Foreground" Value="{StaticResource Accent}" />
|
||||
</Style>
|
||||
|
||||
<!--
|
||||
Faint by default, as this column has always been: a mode is there so its absence would be noticed. It
|
||||
steps up to amber only when it has something to say, which is the whole reason the default is quiet.
|
||||
-->
|
||||
<Style Selector="TextBlock.perms">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||
</Style>
|
||||
<!--
|
||||
Warn rather than WarnText, which is the muted amber a warning card writes its sentences in. At 9.5px
|
||||
against TextFaint that one is a shade, not a signal, and a marker nobody notices is the same as no
|
||||
marker at all.
|
||||
-->
|
||||
<Style Selector="TextBlock.perms.loose">
|
||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
@@ -197,7 +226,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
@@ -209,12 +238,40 @@
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
IsEnabled="{Binding CanDeleteRemote}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<!--
|
||||
The question DELETE asks. Under the button rather than over the pane, so the row it is about is
|
||||
still on screen and still selected while it is being answered — and it names the full path rather
|
||||
than the file, because a name is the half that does not identify anything.
|
||||
|
||||
This is the strongest warning on any of these screens, and deliberately: everything else this
|
||||
application deletes is a tombstone against a copy the server still has, and a file on somebody's
|
||||
host is bytes with nothing behind them.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding IsConfirmingRemoteDeletion}">
|
||||
<StackPanel Spacing="7">
|
||||
<TextBlock Classes="heading" FontSize="13" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Question}" />
|
||||
<SelectableTextBlock Classes="mono" FontSize="10.5" TextWrapping="Wrap"
|
||||
Foreground="{StaticResource Danger}"
|
||||
Text="{Binding PendingRemoteDeletion.FullPath}" />
|
||||
<TextBlock Foreground="{StaticResource WarnText}" FontSize="11" TextWrapping="Wrap"
|
||||
Text="{Binding PendingRemoteDeletion.Consequence}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="danger" Content="DELETE ON THE HOST"
|
||||
Command="{Binding ConfirmDeleteRemoteCommand}" IsEnabled="{Binding !IsBusy}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelDeleteRemoteCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -249,34 +306,35 @@
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<Grid Grid.Row="3" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
<ListBox Grid.Row="4" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Classes.exec="{Binding IsExecutable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono perms" Classes.loose="{Binding IsWorldWritable}"
|
||||
Text="{Binding Permissions}" FontSize="9.5" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
||||
<StackPanel Grid.Row="4" Spacing="10" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasRemoteEntries}">
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.UnlockCard"
|
||||
x:DataType="vm:MainWindowViewModel">
|
||||
|
||||
<!--
|
||||
The unlock screen's contents.
|
||||
|
||||
Extracted from MainWindow.axaml so that it can be laid out and looked at: MainWindow cannot be shown in
|
||||
the headless session at all — WebView2's adapter refuses its thread, which LayoutHarnessTests pins — so
|
||||
markup that stays inside that file is markup no test can measure. This card is the one on the screen
|
||||
with the least room to spare and the most conditional content: a disclosure about shells left running,
|
||||
and a way out for a forgotten passphrase, both of which appear underneath a form that already fills most
|
||||
of the height the window guarantees.
|
||||
|
||||
A bare StackPanel rather than a card, because the card is the frame MainWindow puts around it.
|
||||
-->
|
||||
|
||||
<StackPanel Spacing="12">
|
||||
|
||||
<TextBlock Classes="heading" Text="Unlock your vault" />
|
||||
<TextBlock Text="{Binding AccountName}" Foreground="{StaticResource Info}" />
|
||||
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly, and reached from the window through
|
||||
PassphraseBox. The terminal's native child window keeps Win32 focus when it is collapsed, so without
|
||||
that this box would show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
|
||||
Enter unlocks. A KeyBinding on the box rather than a handler on the window, because this is a property
|
||||
of the control the passphrase is typed into and not of the shell's state: the keyboard is put here on
|
||||
every lock, so the one gesture everybody makes after typing a password reaches the command this box
|
||||
exists for. A single-line TextBox does not handle Enter itself, so nothing is being fought over.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding UnlockCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
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
|
||||
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"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed while
|
||||
locked, so a shell left running is invisible as well as unstopped — and a screen saying "Unlock your
|
||||
vault" over a machine that still holds authenticated SSH channels is exactly the kind of half-truth
|
||||
this project writes down instead of implying. Visible only when there is something to disclose, so an
|
||||
ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The way out of a forgotten passphrase, and the only one there is. Nothing can recover a passphrase —
|
||||
there is no server-side reset by design — so the honest offer is to reset this machine and sign in
|
||||
again, which costs whatever this machine had not yet pushed and nothing else. Stated here rather than
|
||||
left to be discovered, because somebody stuck on this screen has no other route and quitting the
|
||||
application does not help.
|
||||
-->
|
||||
<Border Height="1" Background="{StaticResource BorderSubtle}" />
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
|
||||
Text="Forgotten your passphrase? Nothing can recover it — not even whoever runs the server. What you can do is reset this machine and sign in again; the vault is on the server and comes back." />
|
||||
<Button Classes="ghost" Content="RESET THIS MACHINE"
|
||||
Command="{Binding SignOutCommand}" HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,19 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>The unlock screen's contents.</summary>
|
||||
internal sealed partial class UnlockCard : UserControl
|
||||
{
|
||||
public UnlockCard() => InitializeComponent();
|
||||
|
||||
/// <summary>
|
||||
/// Where the keyboard goes when the vault is locked.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Exposed the way <see cref="HostSidebar.KeyboardTarget"/> is, and for the same reason: the window
|
||||
/// owns the focus policy — it has to take the keyboard off the terminal's native child window first —
|
||||
/// and the control it hands it to belongs to whichever screen is showing.
|
||||
/// </remarks>
|
||||
internal TextBox PassphraseBox => UnlockPassphrase;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.VaultScreen"
|
||||
x:DataType="vm:VaultViewModel">
|
||||
|
||||
@@ -17,9 +18,10 @@
|
||||
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
|
||||
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
|
||||
|
||||
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
|
||||
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
|
||||
access service, so a rail with three entries would be showing two vaults nothing can open.
|
||||
The SCOPES rail below the categories is the vault list. Since M3 it genuinely has more than one entry
|
||||
when somebody is in a team — but it is still not a selector, because every table on this screen already
|
||||
spans every vault this session holds a key for and each row names its own. What it carries instead is
|
||||
the one vault question with an answer: where a new item is filed.
|
||||
-->
|
||||
|
||||
<Grid ColumnDefinitions="176,*,244">
|
||||
@@ -237,11 +239,23 @@
|
||||
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
|
||||
IsVisible="{Binding SelectedItemIsEditable}">
|
||||
IsVisible="{Binding ShowsItemActions}">
|
||||
<Button Classes="ghost" Content="EDIT" Command="{Binding EditSelectedItemCommand}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
The question DELETE asks, in the place those two buttons were. Here rather than over the
|
||||
screen, because this pane is where the item being deleted is described: the name, the kind and
|
||||
what is stored are all still on screen above it, which is most of what somebody checks before
|
||||
answering. See ConfirmDeleteCard.
|
||||
-->
|
||||
<Border Background="{StaticResource DangerWash}" BorderBrush="{StaticResource DangerSoft}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10" Margin="0,14,0,0"
|
||||
IsVisible="{Binding IsConfirmingDeletion}">
|
||||
<views:ConfirmDeleteCard />
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
|
||||
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
|
||||
|
||||
@@ -41,32 +41,49 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
private readonly SemaphoreSlim gate = new(1, 1);
|
||||
private TokenSet tokens = initial;
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this provider currently holds, or null when none was granted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read rather than raised as an event, because the one caller — the shell, persisting it so a later
|
||||
/// launch can resume — has a moment of its own to do that in and no interest in the instant a
|
||||
/// rotation happens. A volatile read of a reference the refresh path replaces wholesale: the value is
|
||||
/// either the old set or the new one, never a half-written one.
|
||||
/// </remarks>
|
||||
internal string? RefreshToken => Volatile.Read(ref tokens).RefreshToken;
|
||||
|
||||
public async ValueTask<string> GetAccessTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
var current = Volatile.Read(ref tokens);
|
||||
|
||||
if (!current.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
return current.AccessToken;
|
||||
}
|
||||
|
||||
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
if (!tokens.NeedsRefresh(clock))
|
||||
current = Volatile.Read(ref tokens);
|
||||
|
||||
if (!current.NeedsRefresh(clock))
|
||||
{
|
||||
return tokens.AccessToken;
|
||||
return current.AccessToken;
|
||||
}
|
||||
|
||||
if (tokens.RefreshToken is null)
|
||||
if (current.RefreshToken is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The access token has expired and no refresh token was granted. Sign in again.");
|
||||
}
|
||||
|
||||
tokens = await oidc.RefreshAsync(tokens.RefreshToken, cancellationToken)
|
||||
var refreshed = await oidc.RefreshAsync(current.RefreshToken, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return tokens.AccessToken;
|
||||
Volatile.Write(ref tokens, refreshed);
|
||||
|
||||
return refreshed.AccessToken;
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -77,6 +94,26 @@ internal sealed class RefreshingAccessTokenProvider(
|
||||
public void Dispose() => gate.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refuses to open anything, for the flows that must never reach a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <see cref="ServerConnection.ResumeAsync"/> uses only the refresh grant, which needs no user agent —
|
||||
/// but <see cref="OidcClient"/> takes a launcher in its constructor because its other two flows do. This
|
||||
/// makes "a resume never opens a browser" a property of the object rather than of the code path, so a
|
||||
/// future call that wandered into an interactive flow would fail loudly here instead of surprising
|
||||
/// somebody with a sign-in page that opened by itself.
|
||||
/// </remarks>
|
||||
internal sealed class NoBrowserLauncher : IBrowserLauncher
|
||||
{
|
||||
internal static NoBrowserLauncher Instance { get; } = new();
|
||||
|
||||
public Task OpenAsync(Uri url, CancellationToken cancellationToken) =>
|
||||
throw new InvalidOperationException(
|
||||
"This connection was resumed from a remembered sign-in and must not open a browser. "
|
||||
+ "Signing in interactively is something the user asks for.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What a signed-in server offers, as everything above the session layer needs it.
|
||||
/// </summary>
|
||||
@@ -118,6 +155,18 @@ public interface IVaultServer : IDisposable
|
||||
|
||||
/// <summary>Sync tuning derived from what this server actually accepts.</summary>
|
||||
SyncOptions SyncOptions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The refresh token this connection holds right now, or null when the provider granted none.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// On the interface because remembering it is what lets a later launch come back online without a
|
||||
/// browser, and the thing doing the remembering — the shell — must not have to know whether it is
|
||||
/// holding a real connection or a test's stand-in. It changes over the life of a connection: a
|
||||
/// provider that rotates hands back a new one on every refresh, so a caller that persists this has
|
||||
/// to re-read it rather than cache it.
|
||||
/// </remarks>
|
||||
string? RefreshToken { get; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -207,6 +256,9 @@ public sealed class ServerConnection : IVaultServer
|
||||
MaxOperationsPerPush = Math.Clamp(Meta.MaxOperationsPerPush, 1, 500),
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? RefreshToken => tokens.RefreshToken;
|
||||
|
||||
/// <summary>
|
||||
/// Discovers the server, signs the user in through their browser, and returns the connection.
|
||||
/// </summary>
|
||||
@@ -257,6 +309,77 @@ public sealed class ServerConnection : IVaultServer
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-establishes a connection from a remembered refresh token, with no browser and no user present.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The difference between an application that is signed in and one that merely was. Without this, a
|
||||
/// machine that has been set up is offline from launch until somebody goes and presses a button —
|
||||
/// which means the sync loop, the outbox and a colleague's changes all wait on an action nobody has a
|
||||
/// reason to take.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Discovery runs again rather than being cached, because the client is deliberately configured by the
|
||||
/// server: the authority, the client id and the scopes are read from
|
||||
/// <c>/.well-known/dodossh-configuration</c> at every connection, so a deployment that moves its
|
||||
/// identity provider does not leave every client pinned to the old one.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It fails rather than falling back when the token has been revoked or has expired, and that is the
|
||||
/// point of passing a launcher that refuses: a resume must never quietly become an interactive
|
||||
/// sign-in, which from a user's side is a browser window that opens on its own. The caller's answer to
|
||||
/// a failure is to stay offline and forget the token.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="serverUrl">The server this profile is enrolled against.</param>
|
||||
/// <param name="refreshToken">The remembered token.</param>
|
||||
/// <param name="clock">Time source, for token expiry.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public static async Task<ServerConnection> ResumeAsync(
|
||||
Uri serverUrl,
|
||||
string refreshToken,
|
||||
TimeProvider clock,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(serverUrl);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
|
||||
ArgumentNullException.ThrowIfNull(clock);
|
||||
|
||||
var transport = new HttpClient { BaseAddress = serverUrl };
|
||||
|
||||
try
|
||||
{
|
||||
var discovery = new DodoSshApiClient(transport, UnavailableAccessTokenProvider.Instance);
|
||||
|
||||
var configuration = await discovery.GetConfigurationAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var meta = await discovery.GetMetaAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var oidc = new OidcClient(
|
||||
transport, NoBrowserLauncher.Instance, clock, BuildOidcOptions(configuration));
|
||||
|
||||
var tokenSet = await oidc.RefreshAsync(refreshToken, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var refreshing = new RefreshingAccessTokenProvider(oidc, tokenSet, clock);
|
||||
|
||||
return new ServerConnection(
|
||||
serverUrl,
|
||||
transport,
|
||||
configuration,
|
||||
meta,
|
||||
oidc,
|
||||
refreshing,
|
||||
new DodoSshApiClient(transport, refreshing));
|
||||
}
|
||||
catch
|
||||
{
|
||||
transport.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
@@ -96,6 +96,7 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
Conflicts = new ConflictStore(caches, protector, clock);
|
||||
Vault = new VaultStore(caches, clock);
|
||||
Unlock = new UnlockStore(caches, clock);
|
||||
SignIn = new RememberedSignInStore(caches, protector, profile.UserId, clock);
|
||||
Hosts = new HostRepository(Items, Outbox, keyring);
|
||||
SshKeys = new SshKeyRepository(Items, Outbox, keyring);
|
||||
Credentials = new CredentialRepository(Items, Outbox, keyring);
|
||||
@@ -169,6 +170,51 @@ public sealed partial class VaultSession : IAsyncDisposable
|
||||
/// </remarks>
|
||||
internal UnlockStore Unlock { get; }
|
||||
|
||||
/// <remarks>
|
||||
/// Only reachable from an open session, which is the point rather than an accident of where it was
|
||||
/// put: the token is sealed under this session's cache key, so a locked machine cannot read it and
|
||||
/// therefore cannot reach the server at all. See <c>RememberedSignInStore</c>.
|
||||
/// </remarks>
|
||||
internal RememberedSignInStore SignIn { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Remembers the sign-in this machine currently holds, so a later launch can resume it.
|
||||
/// </summary>
|
||||
/// <param name="refreshToken">
|
||||
/// The refresh token the connection holds <em>now</em>. Providers rotate these, so a caller that
|
||||
/// notices a change has to call this again — the value is not a constant for the life of a sign-in.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
public Task RememberSignInAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.SaveAsync(refreshToken, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the sign-in this machine may resume, or null when there is none to resume.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null covers three situations that are one situation from the caller's side — nothing was ever
|
||||
/// remembered, the record was written under a different identity, or its tag no longer verifies — and
|
||||
/// the answer to all three is the same: sign in through the browser.
|
||||
/// </remarks>
|
||||
public Task<string?> ReadRememberedSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.ReadAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered sign-in.</summary>
|
||||
public Task ForgetSignInAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
return SignIn.ForgetAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Runs one synchronisation pass over one vault.</summary>
|
||||
/// <param name="api">The transport. Supplied per call because a session outlives any one connection.</param>
|
||||
/// <param name="vaultId">The vault to sync.</param>
|
||||
|
||||
@@ -51,6 +51,32 @@ public sealed record SftpEntry(
|
||||
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
||||
/// </remarks>
|
||||
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
||||
|
||||
/// <summary>Whether this is a file somebody can run.</summary>
|
||||
/// <remarks>
|
||||
/// Files only. On a directory the execute bit means "may be searched", which is true of very nearly every
|
||||
/// directory on a host — a listing that marked them all would be marking nothing.
|
||||
/// </remarks>
|
||||
public bool IsExecutable => Kind is SftpEntryKind.File && PosixMode.HasAnyExecuteBit(Permissions);
|
||||
|
||||
/// <summary>
|
||||
/// Whether this is a file any account on the host may write to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Files only, and for two separate reasons. A symbolic link is <c>lrwxrwxrwx</c> by convention on every
|
||||
/// system that has one, and its mode governs nothing: what may be written is the target, whose own mode
|
||||
/// this listing did not fetch. And a directory that everyone may write to is the ordinary arrangement for
|
||||
/// <c>/tmp</c>, made safe by the sticky bit — which <see cref="PosixMode"/> does not render, so flagging
|
||||
/// the directory would be warning about the half of the mode that is on screen while the half that
|
||||
/// answers the warning is not.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is not a claim that writing is dangerous, only that the mode says something a reader of that column
|
||||
/// would want to have noticed.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public bool IsWorldWritable => Kind is SftpEntryKind.File && PosixMode.IsWorldWritable(Permissions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -106,6 +132,27 @@ public static class PosixMode
|
||||
triple[2] = execute ? 'x' : '-';
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether any of the three execute bits is set.</summary>
|
||||
public static bool HasAnyExecuteBit(string mode) =>
|
||||
At(mode, OwnerExecute) == 'x' || At(mode, GroupExecute) == 'x' || At(mode, OthersExecute) == 'x';
|
||||
|
||||
/// <summary>Whether the others triple carries the write bit.</summary>
|
||||
public static bool IsWorldWritable(string mode) => At(mode, OthersWrite) == 'w';
|
||||
|
||||
private const int OwnerExecute = 3;
|
||||
private const int GroupExecute = 6;
|
||||
private const int OthersWrite = 8;
|
||||
private const int OthersExecute = 9;
|
||||
|
||||
/// <remarks>
|
||||
/// Reading back what <see cref="Format"/> wrote, rather than carrying the nine booleans through
|
||||
/// <see cref="SftpEntry"/> as well. The alternative is a second representation of one fact, and the two
|
||||
/// disagreeing is the failure this avoids — a row coloured for a bit the column beside it does not show.
|
||||
/// Anything that is not a mode this type wrote answers false rather than throwing: these questions decide
|
||||
/// a colour, and a listing is not worth failing over one.
|
||||
/// </remarks>
|
||||
private static char At(string mode, int index) => mode.Length == 10 ? mode[index] : '-';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -78,6 +78,37 @@ internal sealed class UnlockMaterialRow
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A single row, like <see cref="UnlockMaterialRow"/> and for the same reason: one cache holds one
|
||||
/// account.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The token is sealed under the LocalCacheKey, which is the whole point of storing it here.</b> A
|
||||
/// refresh token is a long-lived credential for the account — not for the vault, which nothing but the
|
||||
/// passphrase opens — so a copy of this file lifted off a stolen laptop must not be one. Sealing it under
|
||||
/// a key that exists only while the vault is unlocked means the sign-in can only be resumed by somebody
|
||||
/// who has already opened the vault, which is exactly the moment the application wants it: unlock, then
|
||||
/// come back online by itself. It also means a locked machine cannot reach the server at all, which is a
|
||||
/// consequence worth stating rather than a limitation to work around.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class RememberedSignInRow
|
||||
{
|
||||
/// <summary>The only legal primary key.</summary>
|
||||
internal const int SingletonId = 1;
|
||||
|
||||
public int Id { get; set; } = SingletonId;
|
||||
|
||||
/// <summary>The refresh token, sealed under the LocalCacheKey. Ciphertext.</summary>
|
||||
public byte[] SealedRefreshToken { get; set; } = [];
|
||||
|
||||
public DateTimeOffset UpdatedAtUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A vault the user can reach, with the grant that opens it.</summary>
|
||||
/// <remarks>
|
||||
/// Cached so the vault list and the key needed to decrypt it are both available offline. The name is
|
||||
|
||||
@@ -73,6 +73,7 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
ArgumentNullException.ThrowIfNull(modelBuilder);
|
||||
|
||||
ConfigureUnlockMaterial(modelBuilder);
|
||||
ConfigureRememberedSignIn(modelBuilder);
|
||||
ConfigureVaults(modelBuilder);
|
||||
ConfigureItems(modelBuilder);
|
||||
ConfigureOutbox(modelBuilder);
|
||||
@@ -102,6 +103,26 @@ public sealed class ClientCacheContext(DbContextOptions<ClientCacheContext> opti
|
||||
entity.Property(row => row.KdfSalt).IsRequired();
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// A table of its own rather than two more columns on <c>unlock_material</c>, because the two rows have
|
||||
/// opposite lifetimes: the unlock material is what makes this machine work offline and must survive
|
||||
/// everything short of a reset, while a remembered sign-in is dropped the moment the server stops
|
||||
/// accepting it. Deleting one must never be able to take the other with it.
|
||||
/// </remarks>
|
||||
private static void ConfigureRememberedSignIn(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<RememberedSignInRow>(entity =>
|
||||
{
|
||||
entity.ToTable(
|
||||
"remembered_sign_in",
|
||||
table => table.HasCheckConstraint(
|
||||
"ck_remembered_sign_in_singleton",
|
||||
$"id = {RememberedSignInRow.SingletonId}"));
|
||||
|
||||
entity.HasKey(row => row.Id);
|
||||
entity.Property(row => row.Id).ValueGeneratedNever();
|
||||
entity.Property(row => row.SealedRefreshToken).IsRequired();
|
||||
});
|
||||
|
||||
private static void ConfigureVaults(ModelBuilder modelBuilder) =>
|
||||
modelBuilder.Entity<CachedVaultRow>(entity =>
|
||||
{
|
||||
|
||||
@@ -123,6 +123,59 @@ public sealed class ClientCacheFactory : IDbContextFactory<ClientCacheContext>,
|
||||
await context.Database.MigrateAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties the cache: every row of every table, and the pages they were written on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// What signing out means on disk. The profile, the wrapped bundle, the item mirror, the outbox and
|
||||
/// the conflict log all go; the schema stays, so the application is usable again immediately and does
|
||||
/// not have to be restarted to be set up afresh.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Emptied rather than deleted, and then vacuumed.</b> Deleting the file is the obvious move and is
|
||||
/// worse here: the database is in WAL mode, so it is three files rather than one — a routine that
|
||||
/// removes <c>cache.db</c> and leaves <c>-wal</c> behind loses to a checkpoint that puts some of it
|
||||
/// back — and on Windows the pooled connections hold the file open, so the delete fails outright while
|
||||
/// the application is running. The <c>VACUUM</c> is the half that makes this a wipe rather than a
|
||||
/// hide: SQLite marks deleted pages free without overwriting them, so ciphertext and sealed records
|
||||
/// would otherwise stay legible in the file until something happened to reuse the page.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The migrations history is deliberately left alone. It describes the shape of the tables, not the
|
||||
/// user, and clearing it would make the next launch try to apply every migration to a schema that
|
||||
/// already has them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task ResetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
// Taken from the model rather than listed here, so a table added later is emptied by a sign-out
|
||||
// without anyone having to remember this method exists.
|
||||
var tables = context.Model.GetEntityTypes()
|
||||
.Select(entityType => entityType.GetTableName())
|
||||
.OfType<string>()
|
||||
.Distinct(StringComparer.Ordinal);
|
||||
|
||||
foreach (var table in tables)
|
||||
{
|
||||
// A table name cannot be a parameter, so it is quoted rather than bound. The value comes from
|
||||
// this assembly's own model metadata and never from input; the doubling is what keeps that
|
||||
// true of a name somebody eventually writes with a quote in it.
|
||||
var sql = string.Concat("DELETE FROM \"", table.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
|
||||
|
||||
// EF1002 and CA2100 both describe interpolating a value into SQL, which is what the two lines
|
||||
// above are; the value is the one thing here that cannot come from a user.
|
||||
#pragma warning disable EF1002, CA2100
|
||||
await context.Database.ExecuteSqlRawAsync(sql, cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore EF1002, CA2100
|
||||
}
|
||||
|
||||
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DodoSSH.Client.Storage;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClientCacheContext))]
|
||||
[Migration("20260731082424_AddRememberedSignIn")]
|
||||
partial class AddRememberedSignIn
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedItemRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<long>("ChangeSequence")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("change_sequence");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_deleted");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<int>("Version")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("version");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("VaultId", "EntityType", "EntityId")
|
||||
.HasName("pk_item");
|
||||
|
||||
b.HasIndex("VaultId", "ChangeSequence")
|
||||
.HasDatabaseName("ix_item_vault_id_change_sequence");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType")
|
||||
.HasDatabaseName("ix_item_vault_id_entity_type");
|
||||
|
||||
b.ToTable("item", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.CachedVaultRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<bool>("IsPersonal")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_personal");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("Permissions")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("permissions");
|
||||
|
||||
b.Property<bool>("RekeyRequired")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("rekey_required");
|
||||
|
||||
b.Property<Guid?>("TeamId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("team_id");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<byte[]>("WrappedVaultKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_vault_key");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_vault");
|
||||
|
||||
b.ToTable("vault", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.ConflictRow", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Acknowledged")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("acknowledged");
|
||||
|
||||
b.Property<byte[]>("Detail")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("detail");
|
||||
|
||||
b.Property<long>("DetectedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("detected_at_utc");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int>("Kind")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kind");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_conflict");
|
||||
|
||||
b.HasIndex("VaultId", "Acknowledged")
|
||||
.HasDatabaseName("ix_conflict_vault_id_acknowledged");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.HasDatabaseName("ix_conflict_vault_id_entity_type_entity_id");
|
||||
|
||||
b.ToTable("conflict", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.OutboxRow", b =>
|
||||
{
|
||||
b.Property<long>("Sequence")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sequence");
|
||||
|
||||
b.Property<byte>("AadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("aad_version");
|
||||
|
||||
b.Property<byte?>("AncestorAadVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_aad_version");
|
||||
|
||||
b.Property<Guid?>("AncestorDataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ancestor_data_key_id");
|
||||
|
||||
b.Property<uint?>("AncestorKeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_key_generation");
|
||||
|
||||
b.Property<byte[]>("AncestorPayload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_payload");
|
||||
|
||||
b.Property<byte[]>("AncestorProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_protected_fields");
|
||||
|
||||
b.Property<int?>("AncestorVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("ancestor_version");
|
||||
|
||||
b.Property<byte[]>("AncestorWrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("ancestor_wrapped_data_key");
|
||||
|
||||
b.Property<int>("Attempts")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("attempts");
|
||||
|
||||
b.Property<Guid?>("DataKeyId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("data_key_id");
|
||||
|
||||
b.Property<Guid>("EntityId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("entity_id");
|
||||
|
||||
b.Property<int>("EntityType")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("entity_type");
|
||||
|
||||
b.Property<int?>("ExpectedVersion")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("expected_version");
|
||||
|
||||
b.Property<bool>("IsParked")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("is_parked");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_error");
|
||||
|
||||
b.Property<int>("Operation")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("operation");
|
||||
|
||||
b.Property<Guid>("OperationId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("operation_id");
|
||||
|
||||
b.Property<byte[]>("Payload")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("payload");
|
||||
|
||||
b.Property<byte[]>("ProtectedFields")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("protected_fields");
|
||||
|
||||
b.Property<long>("QueuedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("queued_at_utc");
|
||||
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<byte[]>("WrappedDataKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_data_key");
|
||||
|
||||
b.HasKey("Sequence")
|
||||
.HasName("pk_outbox");
|
||||
|
||||
b.HasIndex("OperationId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_operation_id");
|
||||
|
||||
b.HasIndex("VaultId", "EntityType", "EntityId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_outbox_vault_id_entity_type_entity_id");
|
||||
|
||||
b.HasIndex("VaultId", "IsParked", "Sequence")
|
||||
.HasDatabaseName("ix_outbox_vault_id_is_parked_sequence");
|
||||
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("vault_id");
|
||||
|
||||
b.Property<string>("Cursor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cursor");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<long?>("LastPulledAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pulled_at_utc");
|
||||
|
||||
b.Property<long?>("LastPushedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("last_pushed_at_utc");
|
||||
|
||||
b.Property<long>("ServerTimeSkewMs")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("server_time_skew_ms");
|
||||
|
||||
b.HasKey("VaultId")
|
||||
.HasName("pk_sync_state");
|
||||
|
||||
b.ToTable("sync_state", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.UnlockMaterialRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid?>("DeviceId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("device_id");
|
||||
|
||||
b.Property<byte[]>("DeviceWrappedPrivateKey")
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("device_wrapped_private_key");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("display_name");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("email");
|
||||
|
||||
b.Property<string>("Issuer")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("issuer");
|
||||
|
||||
b.Property<string>("KdfAlgorithm")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("kdf_algorithm");
|
||||
|
||||
b.Property<int>("KdfMemoryKibibytes")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_memory_kibibytes");
|
||||
|
||||
b.Property<int>("KdfParallelism")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_parallelism");
|
||||
|
||||
b.Property<int>("KdfPasses")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("kdf_passes");
|
||||
|
||||
b.Property<byte[]>("KdfSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("kdf_salt");
|
||||
|
||||
b.Property<uint>("KeyGeneration")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("key_generation");
|
||||
|
||||
b.Property<string>("ServerUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("server_url");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subject");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("user_id");
|
||||
|
||||
b.Property<byte[]>("WrappedPrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("wrapped_private_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_unlock_material");
|
||||
|
||||
b.ToTable("unlock_material", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_unlock_material_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DodoSSH.Client.Storage.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRememberedSignIn : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "remembered_sign_in",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
sealed_refresh_token = table.Column<byte[]>(type: "BLOB", nullable: false),
|
||||
updated_at_utc = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("pk_remembered_sign_in", x => x.id);
|
||||
table.CheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "remembered_sign_in");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -291,6 +291,30 @@ namespace DodoSSH.Client.Storage.Migrations
|
||||
b.ToTable("outbox", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.RememberedSignInRow", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<byte[]>("SealedRefreshToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB")
|
||||
.HasColumnName("sealed_refresh_token");
|
||||
|
||||
b.Property<long>("UpdatedAtUtc")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("updated_at_utc");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_remembered_sign_in");
|
||||
|
||||
b.ToTable("remembered_sign_in", null, t =>
|
||||
{
|
||||
t.HasCheckConstraint("ck_remembered_sign_in_singleton", "id = 1");
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DodoSSH.Client.Storage.SyncStateRow", b =>
|
||||
{
|
||||
b.Property<Guid>("VaultId")
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using DodoSSH.Crypto;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DodoSSH.Client.Storage;
|
||||
|
||||
/// <summary>
|
||||
/// The sign-in this machine may resume without opening a browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// One refresh token, sealed under the LocalCacheKey and bound to the user it belongs to. Everything about
|
||||
/// why it is sealed rather than stored — and what a locked machine therefore cannot do — is on
|
||||
/// <see cref="RememberedSignInRow"/>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The server it belongs to is deliberately not recorded here: <c>unlock_material</c> already holds it, and
|
||||
/// two copies of one fact is two facts that can disagree. A cache holds one account and one server.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class RememberedSignInStore(
|
||||
IDbContextFactory<ClientCacheContext> contexts,
|
||||
LocalCacheProtector protector,
|
||||
Guid userId,
|
||||
TimeProvider clock)
|
||||
{
|
||||
/// <summary>Remembers a refresh token, replacing whatever was there.</summary>
|
||||
/// <remarks>
|
||||
/// Called again whenever the provider rotates the token. Keeping the one this client first received
|
||||
/// would leave a rotating provider refusing the next launch, which is the failure that reads as "the
|
||||
/// application randomly signs me out".
|
||||
/// </remarks>
|
||||
public async Task SaveAsync(string refreshToken, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(refreshToken);
|
||||
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
row = new RememberedSignInRow();
|
||||
context.Add(row);
|
||||
}
|
||||
|
||||
var bytes = Encoding.UTF8.GetBytes(refreshToken);
|
||||
|
||||
try
|
||||
{
|
||||
row.SealedRefreshToken = protector.Protect(
|
||||
CryptoSpec.AadResourceType.User, userId, bytes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// The managed copy this method made, not the string it was handed — see the remark on
|
||||
// ReadAsync for what a .NET string does and does not allow here.
|
||||
CryptographicOperations.ZeroMemory(bytes);
|
||||
}
|
||||
|
||||
row.UpdatedAtUtc = clock.GetUtcNow();
|
||||
|
||||
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the remembered token, or null when there is none this key can open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Null rather than an exception for a record that will not open, on the same reasoning as
|
||||
/// <see cref="LocalCacheProtector.TryUnprotect"/>: a cache written under a different identity is an
|
||||
/// ordinary situation and the answer is to sign in again, not to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It comes back as a <see cref="string"/>, which cannot be wiped. That is the same bargain the private
|
||||
/// key and password editors already make — every HTTP client on the way to the token endpoint wants a
|
||||
/// string — and pretending otherwise with a <c>SecureString</c> would buy nothing this process's memory
|
||||
/// does not already give away.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public async Task<string?> ReadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
var row = await context.Set<RememberedSignInRow>()
|
||||
.AsNoTracking()
|
||||
.SingleOrDefaultAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (row is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var opened = protector.TryUnprotect(
|
||||
CryptoSpec.AadResourceType.User, userId, row.SealedRefreshToken);
|
||||
|
||||
return opened is null ? null : Encoding.UTF8.GetString(opened);
|
||||
}
|
||||
|
||||
/// <summary>Forgets the remembered sign-in, so the next launch has to use a browser.</summary>
|
||||
/// <remarks>
|
||||
/// Used when the provider refuses the token — a revoked session, a rotation this machine missed — as
|
||||
/// well as when the user signs out. Keeping a token that has already been refused would mean retrying
|
||||
/// it once a minute for the life of the profile.
|
||||
/// </remarks>
|
||||
public async Task ForgetAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var context = contexts.CreateDbContext();
|
||||
await using var scope = context.ConfigureAwait(false);
|
||||
|
||||
await context.Set<RememberedSignInRow>()
|
||||
.ExecuteDeleteAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
@@ -129,12 +129,30 @@ public sealed class SyncEngine
|
||||
{
|
||||
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// At most one restart per pull. A server that rejects the cursor it has just issued is not
|
||||
// telling this client anything it can act on, and replaying the whole log against it would turn
|
||||
// one broken deployment into an unbounded amount of work.
|
||||
var alreadyStartedOver = false;
|
||||
|
||||
for (var page = 0; page < options.MaxPullPages; page++)
|
||||
{
|
||||
var response = await api.SyncPullAsync(
|
||||
vaultId,
|
||||
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
SyncPullResponse response;
|
||||
|
||||
try
|
||||
{
|
||||
response = await api.SyncPullAsync(
|
||||
vaultId,
|
||||
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
when (!alreadyStartedOver && WasTheCursorRefused(exception, state.Cursor))
|
||||
{
|
||||
alreadyStartedOver = true;
|
||||
state = await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var change in response.Changes)
|
||||
{
|
||||
@@ -161,6 +179,69 @@ public sealed class SyncEngine
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
|
||||
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
|
||||
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
|
||||
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
|
||||
/// </remarks>
|
||||
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
|
||||
!string.IsNullOrEmpty(cursor)
|
||||
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Forgets this vault's position and reads the log again from the beginning.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A cursor the server will not accept is not a transient failure. Every later pass reads the same
|
||||
/// stored cursor and is refused the same way, so a vault that met one stayed there for good — pulling
|
||||
/// nothing, and pushing nothing either, because the pass threw before it reached the outbox. The user
|
||||
/// saw a 400 saying to resync from the beginning and had no way to do it. This is that way.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The causes are all on the far side: a rotated cursor signing key, a vault served from a restored
|
||||
/// database whose sequences no longer reach that far, a cache copied between machines. None of them is
|
||||
/// something the person at the keyboard did, and none of them is something they could act on if asked.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The mirror is deliberately kept.</b> Replaying from the beginning rewrites every row the server
|
||||
/// still has — applying a change is a blind overwrite — so the re-pull repairs the mirror as it goes.
|
||||
/// Clearing it first would claim more than the evidence supports: the position was refused, not the
|
||||
/// contents, and a machine that loses its connection halfway through the replay would be left with
|
||||
/// less than it started with. <c>SyncStateStore.ResetAsync</c> is the heavier remedy, for when the
|
||||
/// cache itself is the thing in doubt.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server
|
||||
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a
|
||||
/// replay no longer carries a deletion older than the retention window. A machine that missed such a
|
||||
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row
|
||||
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
|
||||
/// guess.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
|
||||
/// the beginning as well, rather than meeting the same refusal again.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task<StoredSyncState> StartOverAsync(
|
||||
StoredSyncState state,
|
||||
SyncReportBuilder report,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var restarted = state with { Cursor = null };
|
||||
|
||||
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
report.ResyncedFromStart = true;
|
||||
|
||||
return restarted;
|
||||
}
|
||||
|
||||
private StoredSyncState Record(
|
||||
Guid vaultId,
|
||||
StoredSyncState state,
|
||||
|
||||
@@ -75,6 +75,15 @@ public sealed record SyncOptions
|
||||
/// True when the push loop hit <see cref="SyncOptions.MaxPushRounds"/> with work still outstanding. Not
|
||||
/// a failure: the next pass continues from here.
|
||||
/// </param>
|
||||
/// <param name="ResyncedFromStart">
|
||||
/// True when the server refused this machine's stored position and the whole log was read again from the
|
||||
/// beginning.
|
||||
/// <para>
|
||||
/// Deliberately absent from <see cref="NeedsAttention"/>. Nothing is outstanding and nothing was lost —
|
||||
/// the pass recovered on its own — but it explains a sync that pulled the entire vault on a day nobody
|
||||
/// changed anything, which is otherwise the sort of thing that looks like a fault.
|
||||
/// </para>
|
||||
/// </param>
|
||||
public sealed record SyncReport(
|
||||
Guid VaultId,
|
||||
int Pulled,
|
||||
@@ -87,7 +96,8 @@ public sealed record SyncReport(
|
||||
uint ServerKeyGeneration,
|
||||
bool RekeyRequired,
|
||||
long ServerTimeSkewMs,
|
||||
bool RoundsExhausted)
|
||||
bool RoundsExhausted,
|
||||
bool ResyncedFromStart)
|
||||
{
|
||||
/// <summary>Whether anything happened that a user should be told about.</summary>
|
||||
public bool NeedsAttention =>
|
||||
@@ -119,6 +129,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
|
||||
internal bool RoundsExhausted { get; set; }
|
||||
|
||||
internal bool ResyncedFromStart { get; set; }
|
||||
|
||||
internal SyncReport Build() =>
|
||||
new(
|
||||
vaultId,
|
||||
@@ -132,7 +144,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
|
||||
ServerKeyGeneration,
|
||||
RekeyRequired,
|
||||
ServerTimeSkewMs,
|
||||
RoundsExhausted);
|
||||
RoundsExhausted,
|
||||
ResyncedFromStart);
|
||||
}
|
||||
|
||||
/// <summary>The record written to the conflict log when a merge had to override something.</summary>
|
||||
|
||||
Reference in New Issue
Block a user