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
|
||||
|
||||
Reference in New Issue
Block a user