Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
T
jaap-janandClaude Opus 5 39b7e5620f Say which half of signing in is happening, and which half failed
A sign-in against a production Keycloak was reported as the shell hanging on "Opening your
browser to sign in…" and then, some time later, saying "The server returned 500". Both
halves of that are the message's fault. The browser flow had already succeeded — the
provider authenticated the user, the code came back, the tokens were exchanged — and what
was actually happening was a round trip to the DodoSSH server for the account. The screen
went on describing a browser nobody was waiting for.

The status now moves when the browser half ends, so the wait that follows is attributed to
the server being asked rather than to the browser that has already answered.

The failure gets the same treatment. "The server returned 500" is the API client's phrase
for any server it talks to, and read underneath a sign-in button it is naturally taken as
the sign-in having failed — which sends somebody to their identity provider's logs to find
out why a thing that worked did not work. It now says signing in succeeded, names the host
that failed afterwards, and says the reason is in that server's logs, because this side
cannot know more than that.

Nothing here fixes the 500. It changes which of the two servers the next person goes and
looks at, which was the actual cost of the old message.

Verified against the shell suite, including the case that asserts a failed command leaves
the window enabled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 21:11:38 +02:00

2371 lines
104 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState
{
/// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
Starting = 0,
/// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
NeedsServer = 1,
/// <summary>Signed in, but the account has no vault key yet.</summary>
NeedsEnrollment = 2,
/// <summary>
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
/// </summary>
/// <remarks>
/// A separate state rather than a dismissible banner, because this is the only moment the code exists.
/// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
/// reset by design — so this is the one screen a user must not be able to click past.
/// </remarks>
ShowingRecoveryCode = 3,
/// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
Locked = 4,
/// <summary>Open.</summary>
Unlocked = 5,
}
/// <summary>
/// Which of the unlocked application's screens the nav rail is pointing at.
/// </summary>
/// <remarks>
/// <para>
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
/// </para>
/// </remarks>
internal enum ShellScreen
{
/// <summary>The host list, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
Transfers = 1,
/// <summary>Everything in the vault that is not a host.</summary>
Vault = 2,
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
Team = 3,
/// <summary>Preferences.</summary>
Preferences = 4,
/// <summary>The host keys this keychain has approved.</summary>
/// <remarks>
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
/// </remarks>
KnownHosts = 5,
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
/// <remarks>
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
/// something almost nobody is looking at.
/// </remarks>
Import = 6,
/// <summary>The saved commands in this keychain.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Snippets = 7,
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
}
/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
Page = 0,
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
Terminal = 1,
}
/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
/// <remarks>
/// <para>
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
/// browser sign-in; everything about the identity provider comes from
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
/// unlocks, which is the state the application spends nearly all of its life in.
/// </para>
/// <para>
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
/// </para>
/// </remarks>
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
private readonly ClientPaths paths;
private readonly ClientCacheFactory caches;
private readonly TerminalWorkspace workspace;
/// <remarks>
/// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
/// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
/// it are this state machine's business. The same instance was handed to the connection factory when the
/// application was composed.
/// </remarks>
private readonly VaultKnownHostStore knownHosts;
/// <remarks>
/// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
/// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
/// reports itself unavailable rather than a null this state machine would have to check for.
/// </remarks>
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;
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks>
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
/// </remarks>
private readonly TransfersViewModel transfers;
/// <summary>
/// Where connections are recorded, for as long as a vault is open to record them into.
/// </summary>
/// <remarks>
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
/// </remarks>
private readonly ConnectionRecorder connectionLog;
private readonly TeamsViewModel teams;
/// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet.
/// </summary>
/// <remarks>
/// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
/// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
/// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
/// when the user closes the tab out from under it.
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
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>
/// Establishes a connection to a server.
/// </summary>
/// <remarks>
/// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
/// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
/// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
/// would be the wrong trade.
/// </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
/// and the tests that drive this state machine have no use for it.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null)
{
this.paths = paths;
this.caches = caches;
this.workspace = workspace;
this.knownHosts = knownHosts;
this.deviceKeys = deviceKeys;
this.signIn = signIn;
this.resume = resume;
this.clock = clock;
this.passphraseProfile = passphraseProfile;
this.copyToClipboard = copyToClipboard;
transfers = new TransfersViewModel(sftpSessions, clock);
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
// running after it, and the entry it eventually produces belongs to the vault it was made in.
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
this.workspace.ConnectionLog = connectionLog;
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
// session after unlock, and both go away again on lock. Capturing either would give this screen a
// reference that outlives what it points at — which for a session means holding vault keys past the
// moment locking is supposed to have zeroed them.
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
// list. Detached in DisposeAsync, which is the only point either of them ends.
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
}
[ObservableProperty]
private ShellState state = ShellState.Starting;
[ObservableProperty]
private string statusMessage = "Opening the local cache…";
[ObservableProperty]
private bool isBusy;
/// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
[ObservableProperty]
private bool canUnlockWithDevice;
/// <summary>Whether an unlocked vault should offer to register this machine.</summary>
[ObservableProperty]
private bool canRegisterDevice;
/// <summary>Whether this machine has a device key to withdraw.</summary>
[ObservableProperty]
private bool canForgetDevice;
/// <summary>
/// Whether this machine can neither register a device key nor withdraw one.
/// </summary>
/// <remarks>
/// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
/// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
/// register either — and only the second has something to take back. Both false at once is the one case
/// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
/// screen where the alternative is a section with no controls in it and no explanation.
/// </remarks>
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
/// <remarks>
/// The address <c>dotnet run --project src/DodoSSH.Api</c> actually serves, so the first launch after
/// a clone works without the user having to know a port. This was <c>https://localhost:7217</c>, which
/// is the API's <em>second</em> launch profile: the first is HTTP on 5233 and is the one both the
/// README and a plain <c>dotnet run</c> select, so nothing was listening on 7217. Pointing an HTTPS
/// client at a plaintext port fails as "The SSL connection could not be established", which sends
/// people looking for a certificate problem — see <see cref="ExplainSignInFailure" />. A real
/// deployment is HTTPS behind a proxy and its address is typed over this one; the placeholder in the
/// setup card shows that shape.
/// </remarks>
[ObservableProperty]
private string serverUrl = "http://localhost:5233";
[ObservableProperty]
private string passphrase = string.Empty;
[ObservableProperty]
private string confirmPassphrase = string.Empty;
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
[ObservableProperty]
private string? recoveryCode;
[ObservableProperty]
private bool recoveryCodeWrittenDown;
/// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
[ObservableProperty]
private string? accountName;
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
/// <remarks>
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
/// vault — unlocking, locking and signing out — cannot get out of step with it.
/// </remarks>
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private LogsViewModel? logsScreen;
/// <summary>
/// The teams screen, which the window binds to whether or not a vault is open.
/// </summary>
/// <remarks>
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
/// server rather than a vault, and both of its dependencies are fetched through a function at the
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
/// to rebuild it, and the list it is showing survives both.
/// </remarks>
internal TeamsViewModel Teams => teams;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks>
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
/// property that went null on lock would be a transfer nothing could report on afterwards.
/// </remarks>
internal TransfersViewModel Transfers => transfers;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
/// <remarks>
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
/// </remarks>
[ObservableProperty]
private int liveSessionCount;
internal bool HasLiveSessions => LiveSessionCount > 0;
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
internal string LiveSessionSummary => LiveSessionCount == 1
? "1 shell is still connected and still running."
: $"{LiveSessionCount} shells are still connected and still running.";
/// <summary>Where the embedded browser should navigate.</summary>
internal Uri TerminalPageUrl => workspace.PageUrl;
/// <summary>
/// Types into a terminal on behalf of something that is not the keyboard.
/// </summary>
/// <remarks>
/// <para>
/// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
/// composition-root object and a view has no business holding one — the same reason tabs go through
/// here rather than through <c>TerminalWorkspace.CloseSessionAsync</c>.
/// </para>
/// <para>
/// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
/// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
/// way — it goes from the renderer straight down the socket.
/// </para>
/// </remarks>
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
/// <summary>
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
/// </summary>
/// <remarks>
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
/// each time. This shell is the window's data context for the life of the process, so one
/// subscription is enough.
/// </remarks>
internal event EventHandler? TerminalSessionOpened;
internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;
internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;
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 &amp;&amp; !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>
internal bool IsOnline => connection is not null;
/// <summary>
/// Whether everything this machine has changed has reached the server.
/// </summary>
/// <remarks>
/// <para>
/// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
/// of that claim, and it is deliberately conservative: true only while a connection is held, the last
/// pass actually reached the server, and the outbox is empty.
/// </para>
/// <para>
/// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
/// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
/// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
/// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
/// </para>
/// <para>
/// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
/// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
/// machine can reach the server and has nothing stuck.
/// </para>
/// </remarks>
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
/// <summary>The same fact as a word, for the titlebar.</summary>
internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
{
(false, _, _) => "OFFLINE",
(true, true, _) => "UNREACHABLE",
(true, false, 0) => "SYNCED",
(true, false, 1) => "1 PENDING",
(true, false, var pending) => $"{pending} PENDING",
};
// ---- Which screen is showing ----
/// <summary>
/// Which of the nav rail's screens the page area holds.
/// </summary>
/// <remarks>
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
/// It is what dismissing a terminal returns to.
/// </remarks>
[ObservableProperty]
private ShellScreen screen;
/// <summary>
/// Whether the page area is showing rather than a terminal.
/// </summary>
/// <remarks>
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
/// </remarks>
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsVaultScreen => Screen is ShellScreen.Vault;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsTeamScreen => Screen is ShellScreen.Team;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsImportScreen => Screen is ShellScreen.Import;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
/// at once is one too many.
/// </remarks>
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
/// quick-connect palette.
/// </para>
/// <para>
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
/// to the same value.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
/// </para>
/// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
/// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
/// while this is false; only the bounds are withheld. Removing the control from the tree would not be
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true };
/// <summary>
/// Whether the terminal half of the window is the half being shown, pane or no pane.
/// </summary>
/// <remarks>
/// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
/// is worth its own name because a tab exists before its session does — see
/// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
/// other two say which of the two things that can be in that rectangle is drawn.
/// </remarks>
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <summary>
/// Whether the card that stands in for a pane is showing.
/// </summary>
/// <remarks>
/// <para>
/// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
/// tab either has a session or it does not. It covers both of the states in which it does not — still
/// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
/// </para>
/// <para>
/// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
/// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
/// </para>
/// </remarks>
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
private ShellSurface surface;
/// <summary>Points the nav rail at a screen.</summary>
/// <remarks>
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
/// </remarks>
[RelayCommand]
private void ShowScreen(ShellScreen target)
{
Screen = target;
Surface = ShellSurface.Page;
}
/// <summary>Switches to the terminal surface.</summary>
/// <remarks>
/// <para>
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
/// button, and a button needs a command.
/// </para>
/// <para>
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
private void ShowTerminal() => Surface = ShellSurface.Terminal;
// ---- Open terminals ----
/// <summary>
/// Every terminal that has been opened this run, in the order they were opened.
/// </summary>
/// <remarks>
/// On the shell rather than on the vault, and that follows from the lock policy rather than from
/// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
/// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
/// admit to. This object is the window's data context for the life of the process, and so is this list.
/// </remarks>
internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];
[ObservableProperty]
private TerminalTabViewModel? selectedTab;
internal bool HasTabs => Tabs.Count > 0;
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
/// <summary>
/// Closes one terminal, ending its shell.
/// </summary>
/// <remarks>
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
/// </remarks>
[RelayCommand]
private async Task CloseTabAsync(TerminalTabViewModel tab)
{
if (tab is null)
{
return;
}
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
// way out is repainted as disconnected.
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, which is where the eye already is.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
// leave a visible WebView with no pane in it, which reads as the application having broken.
if (Tabs.Count == 0)
{
Surface = ShellSurface.Page;
}
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
// terminal just went would keep a lit dot until something else happened to move the selection.
RefreshConnectedHosts();
// What the rectangle holds is decided by the selected tab's state, and the line above may well have
// moved the selection from a card to a pane or the other way round.
RaiseTerminalState();
if (!tab.HasSession)
{
// Nothing to end: this tab is a connection that has not happened, or one that never will. The
// attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
// user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
// rather than drops, because a running shell nothing names is worse than a tab that reappears.
foreach (var attemptId in attempts
.Where(entry => ReferenceEquals(entry.Value, tab))
.Select(entry => entry.Key)
.ToArray())
{
attempts.Remove(attemptId);
}
return;
}
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
}
// ---- Quick connect ----
/// <summary>Whether the quick-connect palette is open over the window.</summary>
/// <remarks>
/// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
/// this is shell state rather than something a view could hold on its own.
/// </remarks>
[ObservableProperty]
private bool isSearching;
[ObservableProperty]
private string searchText = string.Empty;
/// <summary>
/// The hosts the palette is offering, best match first.
/// </summary>
/// <remarks>
/// The design's box says "search hosts · run command". Only the first half is here: a command palette
/// needs commands to run, and this application has no snippet or saved-command item type — see
/// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
/// worse than a box that promises only what it does.
/// </remarks>
internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedSearchResult;
/// <summary>Whether the palette has anything to offer.</summary>
/// <remarks>
/// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
/// </remarks>
internal bool HasSearchResults => SearchResults.Count > 0;
/// <summary>Opens the palette, or closes it if it is already open.</summary>
[RelayCommand]
private void ToggleSearch()
{
if (IsSearching)
{
CloseSearch();
return;
}
if (!IsUnlocked)
{
return;
}
SearchText = string.Empty;
RefreshSearchResults();
IsSearching = true;
}
/// <summary>Dismisses the palette without connecting.</summary>
[RelayCommand]
private void CloseSearch()
{
IsSearching = false;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>
/// Selects the highlighted host and connects to it.
/// </summary>
/// <remarks>
/// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
/// palette inherits every refusal that path already makes — a dangling key binding, a host with no
/// username, a host key that has changed. A second connect path would be a second place for those to be
/// forgotten.
/// </remarks>
[RelayCommand]
private async Task ConnectToSearchResultAsync()
{
if (Vault is not { } vault || SelectedSearchResult is not { } row)
{
return;
}
CloseSearch();
// The hosts page, because that is where this connection's questions get asked. An unknown or changed
// host key is answered by a prompt drawn on that page and the palette opens from any screen, so
// connecting from the files screen without this would leave the question behind the screen that asked
// it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not
// need to: a refusal that needs an answer puts the page back, which is where this leaves the screen.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. 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 — so passing this one would read as cancellation plumbing that is not there.
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <remarks>
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
/// that merely contains it, and both come before a match found only in the address. Typing three
/// characters of a name people use daily should not put that host third.
/// </remarks>
private void RefreshSearchResults()
{
SearchResults.Clear();
if (Vault is not { } vault)
{
SelectedSearchResult = null;
return;
}
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
{
SearchResults.Add(host);
}
SelectedSearchResult = SearchResults.FirstOrDefault();
OnPropertyChanged(nameof(HasSearchResults));
}
private static int Rank(HostRowViewModel host, string query)
{
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
{
return 0;
}
if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
{
return 1;
}
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 2
: int.MaxValue;
}
/// <summary>
/// Brings the schema up to date and works out which screen to show.
/// </summary>
/// <remarks>
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still
/// be able to open their vault.
/// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken)
{
try
{
paths.EnsureCreated();
await caches.MigrateAsync(cancellationToken).ConfigureAwait(true);
var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true);
if (profile is null)
{
State = ShellState.NeedsServer;
StatusMessage = "Sign in to a DodoSSH server to set this machine up.";
return;
}
AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject;
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
// Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
// back. Offering the button without the second would prompt for a key that is not there; without
// the first it would prompt for a wrap that is not there. Neither failure is one a user could
// make sense of, so the button simply does not appear.
CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
State = ShellState.NeedsServer;
StatusMessage = $"The local cache could not be opened: {exception.Message}";
}
}
/// <summary>Discovers the server and runs the browser sign-in.</summary>
[RelayCommand]
private async Task SignInAsync(CancellationToken cancellationToken)
{
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
{
StatusMessage = "That is not a valid server URL.";
return;
}
// Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
// URI whose scheme is "localhost" — and then fails much later with something unrelated to the
// actual mistake.
if (url.Scheme is not ("http" or "https"))
{
StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
return;
}
await RunAsync(
"Opening your browser to sign in…",
explain: exception => ExplainSignInFailure(exception, url),
work: async () =>
{
connection?.Dispose();
connection = null;
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
// The browser is finished with, and what follows is a round trip to the DodoSSH server
// that can take a while or fail on its own. Saying so is the difference between a wait
// and a hang: a screen still reading "Opening your browser to sign in…" while the server
// is the thing struggling sends the user back to a browser that did nothing wrong.
StatusMessage = $"Signed in. Asking {url.Host} about your account…";
var outcome = await Provisioner()!
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject;
StatusMessage = outcome.Message;
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);
}
/// <summary>Creates the identity key and the personal vault.</summary>
[RelayCommand]
private async Task EnrollAsync(CancellationToken cancellationToken)
{
if (Provisioner() is not { } provisioner)
{
StatusMessage = "Sign in first.";
return;
}
if (!ValidateNewPassphrase())
{
return;
}
await RunAsync(
"Creating your keychain. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
var outcome = await Task
.Run(
() => provisioner.EnrollAsync(
ServerUrl,
chosen,
Environment.MachineName,
"Personal",
cancellationToken),
cancellationToken)
.ConfigureAwait(true);
ConfirmPassphrase = string.Empty;
RecoveryCode = outcome.RecoveryCode;
RecoveryCodeWrittenDown = false;
StatusMessage = outcome.Message;
// A brand-new account always yields a code. An account someone else already enrolled does
// not, and there is nothing to show.
State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
}).ConfigureAwait(true);
}
/// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
[RelayCommand]
private void ConfirmRecoveryCode()
{
if (!RecoveryCodeWrittenDown)
{
StatusMessage = "Confirm you have written the recovery code down first.";
return;
}
// Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
// model for the rest of the session would undo that.
RecoveryCode = null;
State = ShellState.Locked;
StatusMessage = "Unlock with the passphrase you just chose.";
}
/// <summary>Opens the vault.</summary>
[RelayCommand]
private async Task UnlockAsync(CancellationToken cancellationToken)
{
if (Passphrase.Length == 0)
{
StatusMessage = "Enter your keychain passphrase.";
return;
}
await RunAsync(
"Unlocking…",
async () =>
{
var entered = Passphrase;
// Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
// and would otherwise freeze the window mid-unlock.
var outcome = await Task
.Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
return;
}
Passphrase = string.Empty;
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks>
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
/// does block is a Windows consent dialog which belongs on the UI thread anyway.
/// </remarks>
[RelayCommand]
private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
{
await RunAsync(
"Waiting for Windows…",
async () =>
{
var outcome = await Opener()
.UnlockWithDeviceAsync(deviceKeys, cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
// A declined gesture leaves the passphrase box exactly where it was, which is the whole
// fallback: the user types instead. Nothing about the screen changes but the message.
return;
}
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>
/// Registers this machine so a later launch can unlock with a gesture.
/// </summary>
/// <remarks>
/// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
/// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
/// open session can seal the bundle.
/// </remarks>
[RelayCommand]
private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault || connection is null)
{
StatusMessage = "Sign in first: registering this machine has to reach the server.";
return;
}
await RunAsync(
"Waiting for Windows…",
async () =>
{
var name = Environment.MachineName;
var registered = await vault.Session
.RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken)
.ConfigureAwait(true);
if (!registered)
{
StatusMessage = "This machine has nowhere to keep a device key.";
return;
}
CanRegisterDevice = false;
CanForgetDevice = true;
StatusMessage = $"'{name}' can now unlock without your passphrase.";
}).ConfigureAwait(true);
}
/// <summary>
/// Withdraws this machine's device key, here and on the account.
/// </summary>
/// <remarks>
/// <para>
/// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
/// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
/// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
/// </para>
/// <para>
/// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
/// the useful half always happens — the account being told is the half that can be out of reach.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault)
{
return;
}
await RunAsync(
"Waiting for Windows…",
async () =>
{
var revocation = await vault.Session
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
.ConfigureAwait(true);
CanForgetDevice = false;
// Not re-offered here even though it is now true, because registering probes the TPM and
// this is not the moment to do it: somebody who has just withdrawn a device is not about to
// add one back, and the offer reappears on the next unlock.
StatusMessage = revocation switch
{
DeviceRevocation.Complete =>
"This machine no longer unlocks without your passphrase, and the account no longer "
+ "lists it.",
DeviceRevocation.LocalOnly =>
"This machine no longer unlocks without your passphrase. You are offline, so the "
+ "account still lists it — sign in and withdraw it again to finish.",
_ => "There was no device key on this machine.",
};
}).ConfigureAwait(true);
}
/// <summary>
/// Takes ownership of a freshly opened session, whichever door opened it.
/// </summary>
/// <remarks>
/// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
/// a second copy would be a second chance to get it wrong.
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel(
session,
workspace,
knownHosts,
() => connection,
ReconnectAsync,
copyToClipboard,
connectionLog);
State = ShellState.Unlocked;
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
// has not already registered one. Asked once here rather than recomputed, because the answer
// involves a TPM probe.
CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
// The other side of the same fact, and it needs its own flag rather than the negation of that one:
// "not offered because this machine has no TPM" and "not offered because it is already registered"
// are both !CanRegisterDevice, and only the second has anything to withdraw.
CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null;
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
// 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>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
/// <remarks>
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
/// called by the workspace — so both are attached here rather than constructed per session, and both are
/// released together on every path that closes a vault.
/// </remarks>
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
// The actor is the account that unlocked, which is what makes this an audit record rather than a
// list of events with nobody attached to them.
connectionLog.Open(session, session.Profile.UserId);
}
/// <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>
/// <remarks>
/// <para>
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
/// terminated a running job would be worse than the exposure it removes.
/// </para>
/// <para>
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
/// inferred from a terminal that the lock screen hides.
/// </para>
/// <para>
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
/// warning of this kind.
/// </para>
/// </remarks>
[RelayCommand]
private async Task LockAsync()
{
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen.
knownHosts.Close();
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
// about to be disposed. Tickets already open keep the repository they were opened against, so a
// shell still running closes out into the vault it was actually made in.
connectionLog.Close();
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy.
transfers.Detach();
if (Vault is { } open)
{
Vault = null;
await open.DisposeAsync().ConfigureAwait(true);
}
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 keychain 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();
connectionLog.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 keychain has been deleted; the "
+ "keychain 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()
{
if (disposed)
{
return;
}
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
workspace.ConnectionLog = null;
await connectionLog.DisposeAsync().ConfigureAwait(false);
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it.
await transfers.DisposeAsync().ConfigureAwait(false);
if (Vault is { } open)
{
await open.DisposeAsync().ConfigureAwait(false);
}
connection?.Dispose();
}
/// <remarks>
/// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
/// server-side reset exists. A length floor is a crude check and still the one that matters most.
/// </remarks>
private bool ValidateNewPassphrase()
{
if (Passphrase.Length < 12)
{
StatusMessage = "Use a passphrase of at least 12 characters.";
return false;
}
if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
{
StatusMessage = "The two passphrases do not match.";
return false;
}
return true;
}
/// <remarks>
/// Sync limits come from the server when there is one, so a batch is never larger than this
/// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
/// </remarks>
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
private AccountProvisioner? Provisioner() =>
connection is null
? null
: new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
/// <remarks>
/// Every command funnels through here so the busy flag and the failure message are handled once. A
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
/// </remarks>
/// <param name="busyMessage">Shown while the work runs.</param>
/// <param name="work">The work.</param>
/// <param name="explain">
/// Turns a failure into something a user can act on. Optional, because most failures here already
/// carry their own explanation; the ones that do not are the ones crossing into another process's
/// vocabulary, where the exception describes a symptom and not the mistake.
/// </param>
private async Task RunAsync(
string busyMessage,
Func<Task> work,
Func<Exception, string>? explain = null)
{
if (IsBusy)
{
return;
}
IsBusy = true;
StatusMessage = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
StatusMessage = "Cancelled.";
}
catch (Exception exception)
{
StatusMessage = explain?.Invoke(exception) ?? exception.Message;
}
finally
{
IsBusy = false;
}
}
/// <remarks>
/// <para>
/// Two cases earn a translation rather than the exception's own words, and both are cases where the
/// exception names a symptom belonging to somebody else's process.
/// </para>
/// <para>
/// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be
/// established", which sends people looking for a certificate problem. The scheme is the mistake, and
/// the development stack serves HTTP, so this is the first thing a new user will hit.
/// </para>
/// <para>
/// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already
/// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The
/// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having
/// failed, and the search starts in the wrong place. Naming which server, and saying that its own
/// logs hold the reason, is the whole content of the fix; the client cannot know more than that.
/// </para>
/// </remarks>
private static string ExplainSignInFailure(Exception exception, Uri server)
{
if (exception is DodoSshApiException api && (int)api.StatusCode >= 500)
{
return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering "
+ $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than "
+ "anything to fix here — its own logs carry the reason.";
}
var secureChannelFailed = exception is HttpRequestException
&& exception.GetBaseException() is AuthenticationException;
if (secureChannelFailed && server.Scheme is "https")
{
var plain = new UriBuilder(server) { Scheme = "http" }.Uri;
return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
+ $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
}
return exception.Message;
}
/// <remarks>
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
/// than each remembering to detach.
/// </remarks>
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
{
if (oldValue is not null)
{
oldValue.PropertyChanged -= OnVaultPropertyChanged;
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
// The three connection events are kept while an attempt is still in flight, and that is not an
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
// what holds both, and it outlives every vault — so a connection started just before a lock still
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
// tabs are this object's rather than the vault's. Detaching here would strand that tab on
// "connecting…" for ever and leave the session it eventually opened with nothing in the window
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
// resolves: the vault holds the handler, not the other way round.
if (attempts.Count == 0)
{
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
oldValue.SessionOpened -= OnVaultSessionOpened;
}
}
if (newValue is not null)
{
newValue.ConnectionStarting += OnVaultConnectionStarting;
newValue.ConnectionFailed += OnVaultConnectionFailed;
newValue.SessionOpened += OnVaultSessionOpened;
newValue.PropertyChanged += OnVaultPropertyChanged;
// The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts
// disconnected — so without this the status dots go out once a minute underneath terminals that
// are still open. The rows belong to the vault and the connection state belongs to the shell,
// which is exactly why the shell has to repaint them rather than the vault carrying the flag.
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
}
// Built from the vault and thrown away with it, here rather than at each of the three places a
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
// would keep a disposed vault alive and repaint a screen nobody can reach.
KnownHostsScreen?.Detach();
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
RaiseSyncState();
}
/// <remarks>
/// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox
/// depth, which lives on the vault, and re-raising the shell's two derived properties on every
/// notification a busy vault produces would repaint the titlebar on every keystroke in an editor.
/// </remarks>
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
|| string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
{
RaiseSyncState();
}
}
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) =>
RefreshConnectedHosts();
private void RaiseSyncState()
{
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));
}
/// <summary>
/// Puts a tab in the strip for a connection that has only just been asked for.
/// </summary>
/// <remarks>
/// <para>
/// This is what stops connecting looking like the application having stopped. The tab appears in the same
/// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
/// machine that is asleep is a card that says which machine, rather than a status line under a window
/// that does nothing for thirty seconds.
/// </para>
/// <para>
/// Kept by attempt id rather than by label: several connections can be in flight now that one does not
/// hold the vault, and two of them can perfectly well be to the same host.
/// </para>
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
attempts[e.AttemptId] = tab;
AdoptTab(tab);
}
/// <remarks>
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
// Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
// no pane to name until this line.
Activate(tab);
RefreshConnectedHosts();
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
/// <remarks>
/// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab
/// carrying its reason — connecting no longer holds the window, so the user may be three screens away by
/// now, and the status line they are not looking at is not where a failure should end. A host key
/// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put
/// back where the question is being asked.
/// </remarks>
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
return;
}
if (!e.IsAwaitingAnAnswer)
{
tab.Failed(e.Reason);
RaiseTerminalState();
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
RaiseTabState();
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The screen the question is drawn on, and the page rather than a terminal. A connection can be
// started from the palette on any screen, so without this the prompt would be behind whatever the
// user was looking at, with the connection waiting on an answer they cannot reach.
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
/// <summary>
/// Takes a tab into the strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these steps is decided once.
/// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
/// against a strip that already shows what it is about.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab);
RaiseTabState();
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
// that has one. A tab that is still connecting has none, and selecting it shows the card instead.
SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
if (tab.HasSession)
{
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
}
/// <remarks>
/// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
/// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
/// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
/// </remarks>
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
{
foreach (var tab in Tabs)
{
tab.IsSelected = ReferenceEquals(tab, value);
}
// Which of the two things can be in the terminal's rectangle depends on the selected tab having a
// session, so moving the selection is one of the ways that answer changes. It also repaints the
// strip's active mark, which follows the selection and the surface together.
RaiseTerminalState();
RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
// be a screen keeping the shell alive.
SnippetsScreen?.TargetChanged();
if (value is not null)
{
Activate(value);
}
}
/// <summary>Tells the renderer which pane to show.</summary>
/// <remarks>
/// <para>
/// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
/// </para>
/// <para>
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
/// an already-open socket and returns immediately when there is no renderer.
/// </para>
/// <para>
/// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
/// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
/// the handshake finishes.
/// </para>
/// </remarks>
private void Activate(TerminalTabViewModel tab)
{
if (!tab.HasSession)
{
return;
}
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
/// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
/// say, the most recently opened would send a command somewhere the user is not looking.
/// </remarks>
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
/// <remarks>
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
/// </remarks>
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
];
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
/// <remarks>
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
/// would answer only one of those.
/// </remarks>
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
/// </summary>
/// <remarks>
/// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's
/// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds
/// the scrollback, and the renderer has already written the reason into it.
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
RefreshConnectedHosts();
});
/// <summary>
/// Repaints the host list's status dots from the tab list.
/// </summary>
/// <remarks>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
/// wrong than a dot that goes dark when the vault is reopened.
/// </remarks>
private void RefreshConnectedHosts()
{
if (Vault is not { } vault)
{
return;
}
foreach (var host in vault.Hosts)
{
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
}
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
OnPropertyChanged(nameof(LiveSessionSummary));
}
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
OnPropertyChanged(nameof(IsNeedingServer));
OnPropertyChanged(nameof(IsNeedingEnrollment));
OnPropertyChanged(nameof(IsShowingRecoveryCode));
OnPropertyChanged(nameof(IsLocked));
OnPropertyChanged(nameof(IsAskingForThePassphrase));
OnPropertyChanged(nameof(IsUnlocked));
RaiseTerminalState();
OnPropertyChanged(nameof(SignOutWarning));
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
// the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
}
/// <remarks>
/// Every screen flag, on every change, for the same reason the vault column raises all four of its
/// section flags: a rail lighting the current screen and a body showing it are one fact read from two
/// directions, and raising only the one that became true leaves the old button lit.
/// </remarks>
partial void OnScreenChanged(ShellScreen value)
{
RaiseSurfaceState();
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
if (value is ShellScreen.Logs && LogsScreen is { } logs)
{
_ = logs.RefreshCommand.ExecuteAsync(null);
}
// Teams are read from the server rather than from the vault, so there is nothing to show until
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
// screen most people never open. Fire-and-forget because a property change cannot await, and
// because the view model turns every failure into its own status line rather than throwing.
if (value is ShellScreen.Team)
{
_ = teams.LoadAsync(CancellationToken.None);
}
}
/// <inheritdoc cref="OnScreenChanged" />
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
/// narrow what became stale.
/// </remarks>
private void RaiseSurfaceState()
{
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsVaultScreen));
OnPropertyChanged(nameof(IsTeamScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsImportScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));
OnPropertyChanged(nameof(IsTeamShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
RaiseTerminalState();
}
/// <summary>
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
/// </summary>
/// <remarks>
/// One method for all four, because they are one fact read from four directions: the surface, the
/// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
/// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
/// pointing at something nobody can see.
/// </remarks>
private void RaiseTerminalState()
{
OnPropertyChanged(nameof(IsTerminalSurface));
OnPropertyChanged(nameof(IsTerminalShowing));
OnPropertyChanged(nameof(IsConnectingShowing));
// The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user
// navigated to preferences would be a second "you are here" mark pointing at a terminal that is not
// on screen; see TerminalTabViewModel.IsShowing.
foreach (var tab in Tabs)
{
tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab);
}
}
partial void OnIsSearchingChanged(bool value) => RaiseTerminalState();
/// <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));
partial void OnCanForgetDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
partial void OnSearchTextChanged(string value) => RefreshSearchResults();
}