Public Access
Stay signed in, come back online by itself, and let a machine be given up
Three things a machine that has been set up could not do. Unlock now takes Enter, which is the gesture everybody makes after typing a password and which did nothing until they found the button. Signing in survives a relaunch. The refresh token is kept in the local cache, sealed under the vault's own cache key, so a later launch resumes the session through the refresh grant with no browser and nobody present — and because it is sealed under that key, only an unlocked vault can resume it. A locked client therefore cannot reach the server at all, which is a consequence worth stating rather than working around; docs/crypto.md §3.2 records it. Every sync pass asks the shell for a connection rather than reading one captured at unlock, so a laptop that unlocked on a train is online within a minute of finding a network, with nothing pressed. Unlocking itself still never waits on a socket. Signing out empties this machine: the profile, the cached items, the outbox and this machine's device key, with the account's row withdrawn when the server can be reached. It asks first and says what it costs — the outbox count when the vault is open, an admission that it cannot be counted when it is not, and the shells that keep running either way. The vault is on the server and is untouched, which is what makes the same button the only honest answer to a forgotten passphrase, so it is on the unlock screen as well as in preferences. It cannot end the session at the identity provider, and says so. Two defects surfaced on the way. The synchronisation pass that runs when the vault opens never ran at all: the loop is started from inside the unlock command, so the busy flag it yields to was raised by that command — the first sync was a minute late on every launch. And signing in from preferences while unlocked threw an unlock screen over an open vault whose keys were still in memory. The unlock card and the new confirmation live in their own controls because MainWindow cannot be laid out headless, so markup left inside it is markup no test can measure; both are now measured at the window's minimum size in the shapes that grow. What is still unverified is the composed window itself.
This commit is contained in:
@@ -81,7 +81,14 @@ internal sealed partial class DodoSshApp : Application
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
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,10 +115,30 @@ 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;
|
||||
|
||||
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>
|
||||
@@ -132,6 +152,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);
|
||||
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -140,7 +173,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -148,6 +182,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;
|
||||
|
||||
@@ -262,6 +297,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>
|
||||
@@ -642,9 +686,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);
|
||||
}
|
||||
|
||||
@@ -884,7 +942,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
|
||||
@@ -908,9 +966,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>
|
||||
@@ -955,10 +1194,170 @@ 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();
|
||||
|
||||
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()
|
||||
{
|
||||
@@ -1127,6 +1526,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>
|
||||
@@ -1233,8 +1637,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.
|
||||
@@ -1262,6 +1668,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));
|
||||
|
||||
|
||||
@@ -422,6 +422,24 @@ internal sealed record VaultItemRowViewModel(
|
||||
internal bool HasBadge => Badge.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>
|
||||
@@ -438,6 +456,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
|
||||
@@ -456,7 +480,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
|
||||
@@ -1138,21 +1163,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
|
||||
@@ -1163,6 +1194,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>
|
||||
@@ -1199,13 +1242,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 not null && (report.Pulled > 0 || report.Pushed > 0 || report.NeedsAttention))
|
||||
@@ -1277,7 +1345,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))
|
||||
{
|
||||
|
||||
@@ -252,7 +252,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}" />
|
||||
@@ -296,54 +302,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>
|
||||
@@ -209,7 +226,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,55 @@
|
||||
<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>
|
||||
|
||||
<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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user