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;
/// Which of the shell's mutually exclusive screens is showing.
internal enum ShellState
{
/// Reading the cache to find out whether this machine is enrolled.
Starting = 0,
/// Nothing is cached. The user has to name a server and sign in, which needs a network.
NeedsServer = 1,
/// Signed in, but the account has no vault key yet.
NeedsEnrollment = 2,
///
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
///
///
/// 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.
///
ShowingRecoveryCode = 3,
/// Enrolled. The passphrase opens the vault, with or without a network.
Locked = 4,
/// Open.
Unlocked = 5,
}
///
/// Which of the unlocked application's screens the nav rail is pointing at.
///
///
///
/// Only meaningful while . The setup and unlock screens are
/// , 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.
///
///
/// 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 docs/design-import-gaps.md: 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. was the other one until M2 built it.
///
///
internal enum ShellScreen
{
/// The host list, which is where the application opens.
Hosts = 0,
/// File transfer over SFTP: two directory panes and a queue.
Transfers = 1,
/// Everything in the vault that is not a host.
Vault = 2,
/// Shared vaults and the people in them. Nothing implements it yet.
Team = 3,
/// Preferences.
Preferences = 4,
/// The host keys this keychain has approved.
///
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into NavRail.axaml as x:Static literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
///
KnownHosts = 5,
/// Importing hosts from the machine's own ~/.ssh/config.
///
/// 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.
///
Import = 6,
/// The saved commands in this keychain.
///
Snippets = 7,
/// What has been connected to, and what has been changed.
///
Logs = 8,
///
/// The phone's hub for everything the bottom bar has no room for.
///
///
///
/// Drawn by the Android head alone. The desktop has a nav rail with room for every destination, so it has
/// nothing to put behind a hub and never sets this; the v2 phone design has four slots and nine places to
/// go, so five of them live one tap deeper. It is a member of the shared enum rather than a phone-local
/// bool because it is a value of like any other — the back arrow on each of those
/// five screens comes here, and a second notion of "where am I" is how the two would disagree.
///
///
///
More = 9,
///
/// Objects in an S3-compatible bucket.
///
///
/// The same screen as over the same , and a
/// separate destination anyway. What differs is only which picker is offered, so the two do not each
/// need a screen — but they do each need a name, because the v2 design puts SFTP and S3 side by side in
/// the hub, and a single "files" entry that silently remembered which kind you last opened would be a
/// destination whose meaning depended on history. Entering either sets
/// ; see .
///
Buckets = 10,
}
///
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
///
///
///
/// Two properties rather than a sixth , 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
/// would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
///
///
internal enum ShellSurface
{
/// The screen named by .
Page = 0,
/// The pane of the tab named by .
Terminal = 1,
}
///
/// The shell: get to an unlocked vault, then hand over to .
///
///
///
/// 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
/// /.well-known/dodossh-configuration, 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.
///
///
/// 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.
///
///
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
private readonly ClientPaths paths;
private readonly ClientCacheFactory caches;
private readonly TerminalWorkspace workspace;
///
/// The concrete store rather than IKnownHostStore, 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.
///
private readonly VaultKnownHostStore knownHosts;
///
/// 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.
///
private readonly IDeviceKeyStore deviceKeys;
private readonly SignInHandler signIn;
///
/// 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.
///
private readonly ResumeHandler? resume;
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
///
/// Built here from the paths rather than taken as a dependency, because it holds preferences and not
/// state: there is nothing for a head to substitute, and a constructor parameter every head would pass
/// the same value to is a parameter that only ever makes the heads longer.
///
private readonly ClientSettingsStore settings;
///
/// 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 .
///
private readonly Func? copyToClipboard;
///
/// Created once and kept for the life of the process, like 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 . The vault
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
///
private readonly TransfersViewModel transfers;
///
/// Where connections are recorded, for as long as a vault is open to record them into.
///
///
/// 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.
///
private readonly ConnectionRecorder connectionLog;
private readonly TeamsViewModel teams;
///
/// The tab standing in for each connection that has been asked for and has not answered yet.
///
///
/// 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.
///
private readonly Dictionary attempts = [];
private IVaultServer? connection;
/// The refresh token last written to the cache, so a rotation is noticed without reading it back.
private string? rememberedToken;
/// Guards against two resume attempts overlapping.
///
/// 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.
///
private bool resuming;
private bool disposed;
///
/// Establishes a connection to a server.
///
///
/// A delegate rather than a direct call to , 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.
///
internal delegate Task SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
///
/// Re-establishes a connection from a remembered sign-in, without a browser.
///
///
/// A delegate for the same reason 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.
///
internal delegate Task ResumeHandler(
Uri serverUrl,
string refreshToken,
CancellationToken cancellationToken);
///
/// 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.
///
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? 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;
this.workspace.FontSizeStepRequested += OnFontSizeStepRequested;
settings = new ClientSettingsStore(paths);
// Read straight away rather than at first use, so the value is right before anything can read it —
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
// as the interface correcting itself.
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
_ = TellRendererTheFontSizeAsync();
}
///
///
/// The page starts at its own default and has no way to know what was stored, so somebody has to tell
/// it — and it cannot be told before its socket exists. Waiting on the renderer is the only ordering
/// available; nothing else knows when the page is there.
///
///
/// Failure is silence on purpose. A launch where no terminal is ever opened still runs this, and a
/// renderer that never attached is not a fault in that case — it is the ordinary shape of a session
/// spent in the keychain. The size is sent again by every change, so nothing is permanently lost.
///
///
private async Task TellRendererTheFontSizeAsync()
{
try
{
await workspace.WaitForRendererAsync(CancellationToken.None).ConfigureAwait(false);
await workspace
.SetFontSizeAsync(TerminalFontSize, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception exception) when (exception is TimeoutException or OperationCanceledException)
{
// No renderer this run. Nothing to tell.
}
}
///
/// Marshalled, because this arrives on the data plane's receive loop — see
/// — and everything it touches is a view model
/// property somebody's interface is bound to.
///
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
[ObservableProperty]
private ShellState state = ShellState.Starting;
[ObservableProperty]
private string statusMessage = "Opening the local cache…";
[ObservableProperty]
private bool isBusy;
/// Whether the unlock screen should offer a gesture instead of the passphrase.
[ObservableProperty]
private bool canUnlockWithDevice;
/// Whether an unlocked vault should offer to register this machine.
[ObservableProperty]
private bool canRegisterDevice;
/// Whether this machine has a device key to withdraw.
[ObservableProperty]
private bool canForgetDevice;
///
/// Whether this machine can neither register a device key nor withdraw one.
///
///
/// 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.
///
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
///
/// The address offered on first launch, before anything is enrolled.
///
///
///
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
/// signing in to the hosted deployment, and typing its address is the only thing standing between
/// them and a working application. A debug build is run from a clone, next to
/// dotnet run --project src/DodoSSH.Api, and shipping the hosted address there would point
/// every development launch at production — which is worse than an inconvenience, since sign-in is
/// the step that enrolls a device.
///
///
/// Note the schemes. http locally is not an oversight: the API's first launch profile — the
/// one a plain dotnet run and the README both select — is plaintext on 5233, and 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 .
///
///
#if DEBUG
internal const string DefaultServerUrl = "http://localhost:5233";
#else
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
#endif
[ObservableProperty]
private string serverUrl = DefaultServerUrl;
[ObservableProperty]
private string passphrase = string.Empty;
[ObservableProperty]
private string confirmPassphrase = string.Empty;
/// Shown once, immediately after enrolling, and never stored anywhere.
[ObservableProperty]
private string? recoveryCode;
[ObservableProperty]
private bool recoveryCodeWrittenDown;
/// Who this machine is enrolled as, readable without the passphrase.
[ObservableProperty]
private string? accountName;
[ObservableProperty]
private VaultViewModel? vault;
/// The approved-host-keys screen, which exists exactly as long as the vault behind it does.
///
/// Assigned from 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.
///
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
///
[ObservableProperty]
private ImportViewModel? importScreen;
///
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
///
[ObservableProperty]
private LogsViewModel? logsScreen;
///
/// The teams screen, which the window binds to whether or not a vault is open.
///
///
/// Not nullable and never replaced, for the reason 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.
///
internal TeamsViewModel Teams => teams;
/// The transfers screen, which the window binds to whether or not a vault is open.
///
/// Not nullable and never replaced, unlike . 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.
///
internal TransfersViewModel Transfers => transfers;
///
/// Shells that were left running when the vault was locked.
///
///
/// Refreshed by , which is where the policy this reports is explained.
///
[ObservableProperty]
private int liveSessionCount;
internal bool HasLiveSessions => LiveSessionCount > 0;
/// The count as a sentence, because a bare number on a lock screen explains nothing.
internal string LiveSessionSummary => LiveSessionCount == 1
? "1 shell is still connected and still running."
: $"{LiveSessionCount} shells are still connected and still running.";
/// Where the embedded browser should navigate.
internal Uri TerminalPageUrl => workspace.PageUrl;
///
/// Types into a terminal on behalf of something that is not the keyboard.
///
///
///
/// 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 TerminalWorkspace.CloseSessionAsync.
///
///
/// 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.
///
///
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory data) =>
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
///
/// How large the terminal draws, in CSS pixels.
///
///
///
/// A font size rather than a zoom, and the difference is the whole design. Zoom scales what is already
/// drawn, so the remote goes on wrapping to a width that is no longer on screen; changing the font size
/// refits the grid and tells the far end how many columns it now has. That is why this is one number
/// owned here and pushed to the renderer, rather than a gesture the page handles alone.
///
///
/// Owned by the shell rather than by the page for two reasons that pull the same way: it has to survive
/// a relaunch, and it has to be reachable from a button on a phone that has no keyboard to press
/// Ctrl+plus with. The page's chords arrive here as steps — see
/// — so both routes end in this property.
///
///
[ObservableProperty]
private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;
/// Whether the terminal could be drawn larger than it is.
internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;
/// Whether the terminal could be drawn smaller than it is.
internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;
/// Draws the terminal one point larger.
[RelayCommand]
private void EnlargeTerminalFont() => StepTerminalFontSize(1);
/// Draws the terminal one point smaller.
[RelayCommand]
private void ShrinkTerminalFont() => StepTerminalFontSize(-1);
/// Puts the terminal back to the size it ships at.
///
/// Worth a command of its own rather than leaving people to count clicks back. A terminal that has been
/// made unreadable is hard to make readable again by eye, which is the state this exists for.
///
[RelayCommand]
private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);
/// Points to move by, or zero to return to the default.
private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);
///
/// One path for every route in — the phone's buttons, the page's chords, and the stored value read at
/// startup — so clamping, persisting and telling the renderer happen once each rather than three times
/// with one of them eventually forgotten.
///
private void ApplyTerminalFontSize(int pixels)
{
var clamped = ClientSettings.ClampTerminalFontSize(pixels);
// Told anyway when nothing moved. A step at the cap is a no-op here, but the page may have been
// reloaded since the last frame — and a renderer at the wrong size is worse than a redundant frame.
TerminalFontSize = clamped;
// Fire and forget: a socket that is not there yet is the ordinary case at startup, and a font size
// is not worth blocking a button handler on.
_ = workspace.SetFontSizeAsync(clamped, CancellationToken.None).AsTask();
// Read-modify-write against the file rather than against a field, so a setting this build does not
// know about — written by a newer one, or by hand — survives this one storing its own.
settings.Write(settings.Read() with { TerminalFontSize = clamped });
}
partial void OnTerminalFontSizeChanged(int value)
{
OnPropertyChanged(nameof(CanEnlargeTerminalFont));
OnPropertyChanged(nameof(CanShrinkTerminalFont));
}
///
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
///
///
/// Forwarded from rather than exposed there directly,
/// because 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.
///
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;
/// Whether the unlock card itself is showing, rather than the confirmation over it.
///
/// Its own property because the markup cannot express IsLocked && !IsConfirmingSignOut,
/// 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.
///
internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
internal bool IsUnlocked => State == ShellState.Unlocked;
/// Whether a connection to the server is currently held.
internal bool IsOnline => connection is not null;
///
/// Whether everything this machine has changed has reached the server.
///
///
///
/// 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.
///
///
/// The middle condition is the one that is easy to leave out, and was. Holding an IVaultServer
/// 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 VaultViewModel.LastSyncFailed.
///
///
/// 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.
///
///
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
/// The same fact as a word, for the titlebar.
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 ----
///
/// Which of the nav rail's screens the page area holds.
///
///
/// This always names a page, even while a terminal is showing over it — see .
/// It is what dismissing a terminal returns to.
///
[ObservableProperty]
private ShellScreen screen;
///
/// Whether the page area is showing rather than a terminal.
///
///
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// IsHostsScreen && IsShowingPages 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 .
///
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
///
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
///
internal bool IsVaultScreen => Screen is ShellScreen.Vault;
///
internal bool IsTeamScreen => Screen is ShellScreen.Team;
///
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
///
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
///
internal bool IsImportScreen => Screen is ShellScreen.Import;
///
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
///
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
///
internal bool IsMoreScreen => Screen is ShellScreen.More;
///
internal bool IsBucketsScreen => Screen is ShellScreen.Buckets;
///
/// Whether the nav rail should light its Hosts entry.
///
///
/// Not the same question as , and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves 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.
///
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
///
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
///
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
///
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
///
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
///
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
///
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
///
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
///
internal bool IsMoreShowing => IsShowingPages && IsMoreScreen;
///
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
///
/// Whether the phone's MORE tab should light.
///
///
/// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of
/// its destinations would be a bar that only ever lights three of its four entries. This is the one
/// place where "which tab" and "which screen" are deliberately not the same question — the other three
/// tabs are each exactly one screen, and this one is six.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this.
///
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences;
///
/// Whether the terminal's WebView may be on screen at this instant.
///
///
///
/// This is an occlusion rule, not a styling one. 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), the quick-connect
/// palette, and the phone's connect sheet.
///
///
/// The sheet is here rather than in , and the palette is not. The
/// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The
/// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it —
/// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the
/// sheet floating over the page underneath. So only the renderer's rectangle is given up.
///
///
/// The terminal and the pages are exclusive, and that is the whole of the rule. They share one
/// rectangle, so exactly one of and this may be true. That is why
/// exists as a single enum rather than as two independent flags a caller could set
/// to the same value.
///
///
/// Not gated on there being a tab. Closing the last tab returns to
/// 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.
///
///
/// Revealing and focusing now happen in the same turn, routinely. 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. NativeControlHost 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 DispatcherPriority.Loaded — see MainWindow.axaml.cs. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
///
///
/// Collapsing is cheap and safe. NativeControlHost 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.
///
///
internal bool IsTerminalShowing =>
IsTerminalSurface && !IsConnectSheetOpen && SelectedTab is { HasSession: true };
///
/// Whether the terminal half of the window is the half being shown, pane or no pane.
///
///
/// Every condition in except the one about there being a session, and it
/// is worth its own name because a tab exists before its session does — see
/// . 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.
///
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
///
/// Whether the card that stands in for a pane is showing.
///
///
///
/// The other half of , 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.
///
///
/// 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 .
///
///
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
///
[ObservableProperty]
private ShellSurface surface;
/// Points the nav rail at a screen.
///
/// 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.
///
[RelayCommand]
private void ShowScreen(ShellScreen target)
{
Screen = target;
Surface = ShellSurface.Page;
}
/// Switches to the terminal surface.
///
///
/// The other half of , 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.
///
///
/// Not gated on there being a tab, for the same reason 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.
///
///
[RelayCommand]
private void ShowTerminal() => Surface = ShellSurface.Terminal;
///
/// Whether the phone's connect menu is open over the terminal.
///
///
///
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
/// own for the reason is: it has to collapse the renderer while it is up. See
/// .
///
///
/// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header
/// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's
/// files, a bucket — need a way back that is not "leave the terminal first and remember what you were
/// doing". The menu is that, and every entry on it is one of the two navigation commands above.
///
///
[ObservableProperty]
private bool isConnectSheetOpen;
/// Raises the connect menu over the terminal.
///
/// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise.
/// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see
/// holding a terminal hidden that nothing would put back.
///
[RelayCommand]
private void OpenConnectSheet()
{
if (!IsTerminalSurface)
{
return;
}
IsConnectSheetOpen = true;
}
/// Lowers the connect menu, leaving the terminal where it was.
///
/// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and
/// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own
/// — see , which is what makes "the sheet is only ever up over a terminal"
/// true of routes nobody wrote it for.
///
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- Open terminals ----
///
/// Every terminal that has been opened this run, in the order they were opened.
///
///
/// 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 exists to
/// admit to. This object is the window's data context for the life of the process, and so is this list.
///
internal ObservableCollection Tabs { get; } = [];
[ObservableProperty]
private TerminalTabViewModel? selectedTab;
internal bool HasTabs => Tabs.Count > 0;
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
///
/// Closes one terminal, ending its shell.
///
///
/// 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.
///
[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 ----
/// Whether the quick-connect palette is open over the window.
///
/// It has to collapse the terminal while it is open — see — which is why
/// this is shell state rather than something a view could hold on its own.
///
[ObservableProperty]
private bool isSearching;
[ObservableProperty]
private string searchText = string.Empty;
///
/// The hosts the palette is offering, best match first.
///
///
/// 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
/// docs/design-import-gaps.md. Offering an empty command list under a box that promised one is
/// worse than a box that promises only what it does.
///
internal ObservableCollection SearchResults { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedSearchResult;
/// Whether the palette has anything to offer.
///
/// A property rather than {Binding !SearchResults.Count} in the markup. Avalonia's ! is a
/// boolean operator: against an int it produces a binding error, IsVisible falls back to
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
///
internal bool HasSearchResults => SearchResults.Count > 0;
/// Opens the palette, or closes it if it is already open.
[RelayCommand]
private void ToggleSearch()
{
if (IsSearching)
{
CloseSearch();
return;
}
if (!IsUnlocked)
{
return;
}
SearchText = string.Empty;
RefreshSearchResults();
IsSearching = true;
}
/// Dismisses the palette without connecting.
[RelayCommand]
private void CloseSearch()
{
IsSearching = false;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
OnPropertyChanged(nameof(HasSearchResults));
}
///
/// Selects the highlighted host and connects to it.
///
///
/// Goes through the vault's own ConnectCommand 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.
///
[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);
}
///
/// 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.
///
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;
}
///
/// Brings the schema up to date and works out which screen to show.
///
///
/// 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.
///
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}";
}
}
/// Discovers the server and runs the browser sign-in.
[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);
}
/// Creates the identity key and the personal vault.
[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);
}
/// Leaves the recovery-code screen, once the user says they have it.
[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.";
}
/// Opens the vault.
[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);
}
/// Opens the vault with this machine's device key instead of the passphrase.
///
/// No Task.Run, 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.
///
[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);
}
///
/// Registers this machine so a later launch can unlock with a gesture.
///
///
/// 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.
///
[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);
}
///
/// Withdraws this machine's device key, here and on the account.
///
///
///
/// 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.
///
///
/// 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.
///
///
[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);
}
///
/// Takes ownership of a freshly opened session, whichever door opened it.
///
///
/// 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.
///
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();
}
///
/// Points the two process-lifetime stores at the session that has just opened.
///
///
/// 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.
///
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);
}
///
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
///
///
///
/// 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.
///
///
/// Resuming needs an unlocked vault, and that is deliberate rather than incidental. 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.
///
///
/// Every failure returns null and stays quiet, with one exception: a provider that refuses 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.
///
///
private async Task 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;
}
}
///
/// Split from 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.
///
private async Task 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;
}
}
///
/// Writes the connection's current refresh token into the vault, if it has changed.
///
///
/// 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.
///
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.
}
}
/// Drops the remembered sign-in, so nothing tries to resume it again.
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.
}
}
///
/// Says something wherever the user is looking.
///
///
/// 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.
///
private void Announce(string message)
{
StatusMessage = message;
if (Vault is { } vault)
{
vault.Status = message;
}
}
///
/// Closes the vault and forgets every key it held. Open shells keep running.
///
///
///
/// Lock is a vault operation, and deliberately not a disconnect. 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.
///
///
/// What "locked" therefore describes. 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 is shown on the unlock screen rather than left to be
/// inferred from a terminal that the lock screen hides.
///
///
/// 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.
///
///
[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 ----
/// Whether the sign-out confirmation is showing.
///
/// 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.
///
[ObservableProperty]
private bool isConfirmingSignOut;
///
/// What signing out costs, on this machine, right now.
///
///
///
/// 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.
///
///
/// 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.
///
///
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.",
};
/// Asks whether the user means it.
[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;
}
/// Thinks better of it.
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
///
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
///
///
///
/// What this does and does not destroy. 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.
///
///
/// Ordered so that a failure cannot leave a half-signed-out machine. 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.
///
///
/// 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.
///
///
[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);
}
///
/// 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.
///
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.
}
}
///
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
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();
}
///
/// 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.
///
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;
}
///
/// 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.
///
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
private AccountProvisioner? Provisioner() =>
connection is null
? null
: new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
///
/// 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.
///
/// Shown while the work runs.
/// The work.
///
/// 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.
///
private async Task RunAsync(
string busyMessage,
Func work,
Func? 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;
}
}
///
///
/// 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.
///
///
/// 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.
///
///
/// 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.
///
///
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;
}
///
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
/// than each remembering to detach.
///
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();
}
///
/// 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.
///
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));
}
///
/// Puts a tab in the strip for a connection that has only just been asked for.
///
///
///
/// 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.
///
///
/// 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.
///
///
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
attempts[e.AttemptId] = tab;
AdoptTab(tab);
}
///
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more.
///
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);
}
///
/// Answers a connection that did not become a session.
///
///
/// 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.
///
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;
}
///
/// Takes a tab into the strip and shows it.
///
///
/// 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.
///
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);
}
}
///
/// 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 for why the last of those is not awaited.
///
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);
}
}
/// Tells the renderer which pane to show.
///
///
/// 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.
///
///
/// 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.
///
///
/// 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.
///
///
private void Activate(TerminalTabViewModel tab)
{
if (!tab.HasSession)
{
return;
}
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
/// Which terminal a snippet would go into right now.
///
/// 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.
///
/// The connections that are open and therefore have no log entry yet.
///
/// 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.
///
private IReadOnlyList 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;
/// Brings one terminal's pane to the front, and shows it.
///
/// 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.
///
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
///
/// Marks a tab dead when its shell ends on its own.
///
///
/// 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.
///
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
RefreshConnectedHosts();
});
///
/// Repaints the host list's status dots from the tab list.
///
///
/// 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.
///
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;
}
}
///
/// 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.
///
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);
}
}
///
/// Goes to the file screen with one of the two kinds of remote offered.
///
///
///
/// The phone splits SFTP and S3 into two destinations over this one view model; the desktop has a
/// single FILES screen with the toggle on it. So the kind is set here, by the thing that navigates,
/// rather than in — which would have made every arrival at
/// force the picker back to hosts, including the desktop's own nav
/// rail arriving at a screen with a bucket already open.
///
///
/// It will not change the kind while something is open. There is one session behind both
/// destinations, so switching the picker under a live one would leave a screen titled S3 listing an
/// SFTP host's files. Refusing and saying so is the honest half of sharing a view model between two
/// destinations; the screen keeps showing what is actually open.
///
///
[RelayCommand]
private void ShowFiles(RemoteKind kind)
{
// Refusing means staying put, not arriving somewhere and saying no. Moving Screen anyway would put
// the S3 entry in the sidebar over a screen still listing an SFTP host — two pieces of chrome
// disagreeing about where you are, which is worse than the navigation simply not happening.
if (Transfers.IsConnected && Transfers.Remote != kind)
{
Transfers.Status = kind is RemoteKind.Bucket
? "An SFTP session is open. Close it before opening a bucket."
: "A bucket is open. Close it before connecting to a host.";
// Still show the screen the open session belongs to, so the message is somewhere it can be
// read — the button that was pressed is in the sidebar, which is on screen either way.
Screen = Transfers.Remote is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
Surface = ShellSurface.Page;
return;
}
Transfers.Remote = kind;
Screen = kind is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers;
Surface = ShellSurface.Page;
}
///
///
/// The one place the connect sheet is lowered by something other than a tap. Every way out of a
/// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a
/// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell
/// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the
/// terminal would draw the sheet again over a rectangle held blank by it.
///
partial void OnSurfaceChanged(ShellSurface value)
{
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
}
RaiseSurfaceState();
}
///
/// Both changes raise the same set, and they have to: and its four siblings
/// read and together, so which of the two moved does not
/// narrow what became stale.
///
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(IsMoreScreen));
OnPropertyChanged(nameof(IsBucketsScreen));
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));
OnPropertyChanged(nameof(IsMoreShowing));
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
RaiseTerminalState();
}
///
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
///
///
/// 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.
///
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();
///
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
///
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// .
///
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();
}