Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/MainWindowViewModel.cs
T
jaap-jan aaff81272a Teach the page and the shell to put a reattached view back together
The page's socket now retries itself forever with backoff — a dropped
socket is an ordinary event on a phone, not the end of the terminal's
life — and createSession is idempotent, so a replay landing on a pane
that survived changes nothing. A replay creating a pane that did not
survive writes one dim line saying the earlier output stayed on the
host, because that is the truth about a reloaded page's scrollback.

The shell answers RendererReattached with the two things only it owns:
the font size, and which tab is active.
2026-08-09 10:54:38 +02:00

4532 lines
212 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Security.Authentication;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Api;
using DodoSSH.Client.Auth;
using DodoSSH.Client.Import;
using DodoSSH.Client.ObjectStore;
using DodoSSH.Client.Session;
using DodoSSH.Client.Ssh;
using DodoSSH.Client.Storage;
using DodoSSH.Client.Terminal;
using DodoSSH.Crypto;
namespace DodoSSH.Client.Shell.ViewModels;
/// <summary>One vault, as a switch in the rail's user popover.</summary>
/// <remarks>
/// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip —
/// see <c>NavRail.axaml</c> — and this record moved with it, unchanged. A record rebuilt per change rather
/// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt
/// whenever anything about the vault list moves, and a row with a settable property would be a second copy
/// of a fact the cache already holds.
/// </remarks>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
/// <param name="IsShown">Whether its items are currently drawn.</param>
internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPersonal, bool IsShown)
{
/// <summary>What the switch says.</summary>
/// <remarks>
/// A shared vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker
/// version of the same reason: two vaults may hold a host with the same label, and which vault a switch
/// is about is the only thing that tells the two switches apart.
/// </remarks>
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
/// <summary>The one letter the rail's popover draws in this vault's own initial square.</summary>
/// <remarks>
/// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one
/// would be inventing a fact rather than reading one — see the remark in <c>NavRail.axaml</c>. The
/// letter is the honest half of the same badge.
/// </remarks>
internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?";
/// <summary>Whether this vault can be switched off.</summary>
/// <remarks>
/// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from,
/// the one the group and tag editors write to, and the fallback the save-target picker lands on — so
/// switching it off would empty half the application rather than filter it. It is still drawn, ticked,
/// because a vault missing from a list of vaults reads as something having gone wrong.
/// </remarks>
internal bool CanHide => !IsPersonal;
}
/// <summary>Which of the shell's mutually exclusive screens is showing.</summary>
internal enum ShellState
{
/// <summary>Reading the cache to find out whether this machine is enrolled.</summary>
Starting = 0,
/// <summary>Nothing is cached. The user has to name a server and sign in, which needs a network.</summary>
NeedsServer = 1,
/// <summary>Signed in, but the account has no vault key yet.</summary>
NeedsEnrollment = 2,
/// <summary>
/// Showing the recovery code, and refusing to move on until the user confirms they have it.
/// </summary>
/// <remarks>
/// A separate state rather than a dismissible banner, because this is the only moment the code exists.
/// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side
/// reset by design — so this is the one screen a user must not be able to click past.
/// </remarks>
ShowingRecoveryCode = 3,
/// <summary>Enrolled. The passphrase opens the vault, with or without a network.</summary>
Locked = 4,
/// <summary>Open.</summary>
Unlocked = 5,
}
/// <summary>
/// Which of the unlocked application's screens the nav rail is pointing at.
/// </summary>
/// <remarks>
/// <para>
/// Only meaningful while <see cref="ShellState.Unlocked"/>. The setup and unlock screens are
/// <see cref="ShellState"/>, and the two are deliberately different things: one is how far through getting
/// in you are, the other is what you are looking at once you are.
/// </para>
/// <para>
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
/// </para>
/// </remarks>
internal enum ShellScreen
{
/// <summary>The host list, which is where the application opens.</summary>
Hosts = 0,
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
Transfers = 1,
/// <summary>Everything in the open vault that is not a host: keys, passwords, buckets, tags.</summary>
/// <remarks>
/// Named for what the rail calls it rather than for the vault it reads, which is what it was called
/// when <see cref="Vaults"/> arrived beside it. Two members a letter apart, one meaning "one vault's
/// contents" and the other "the vaults themselves", is a pair somebody eventually gets the wrong way
/// round.
/// </remarks>
Keychain = 2,
/// <summary>The vaults themselves and the people in them. Both heads draw it.</summary>
/// <remarks>
/// Was <c>Team</c>, and the value is unchanged with it: the screen is the same destination, and these
/// numbers are written into <c>NavRail.axaml</c> as <c>x:Static</c> literals. What changed is what the
/// screen is about — see <see cref="VaultsViewModel"/>.
/// </remarks>
Vaults = 3,
/// <summary>Preferences.</summary>
Preferences = 4,
/// <summary>The host keys this keychain has approved.</summary>
/// <remarks>
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
/// silent change to what every one of those means.
/// </remarks>
KnownHosts = 5,
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
/// <remarks>
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
/// something almost nobody is looking at.
/// </remarks>
Import = 6,
/// <summary>The saved commands in this keychain.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Snippets = 7,
/// <summary>What has been connected to, and what has been changed.</summary>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
Logs = 8,
/// <summary>
/// The phone's hub for everything the bottom bar has no room for.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="Screen"/> 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.
/// </para>
/// <inheritdoc cref="KnownHosts" path="/remarks" />
/// </remarks>
More = 9,
/// <summary>
/// Objects in an S3-compatible bucket.
/// </summary>
/// <remarks>
/// The same screen as <see cref="Transfers"/> over the same <see cref="TransfersViewModel"/>, 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 cref="TransfersViewModel.Remote"/>; see <see cref="OnScreenChanged"/>.
/// </remarks>
Buckets = 10,
}
/// <summary>
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
/// </summary>
/// <remarks>
/// <para>
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
/// with one half hidden.
/// </para>
/// </remarks>
internal enum ShellSurface
{
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
Page = 0,
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
Terminal = 1,
}
/// <summary>
/// Which page the settings mode is showing, while <see cref="MainWindowViewModel.ActiveSettingsPage"/> is
/// not null.
/// </summary>
/// <remarks>
/// <para>
/// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page
/// area with its own — see <c>SettingsView.axaml</c> and the settings-mode remark on
/// <see cref="MainWindowViewModel.ActiveSettingsPage"/>. This is a second, orthogonal notion of "where am I"
/// from <see cref="ShellScreen"/>, not a replacement for it: <see cref="Preferences"/> and <see cref="Vaults"/>
/// still set <see cref="MainWindowViewModel.Screen"/> to the matching <see cref="ShellScreen"/> member, so
/// every existing binding and test that asks "is the screen Preferences" keeps its answer. <see cref="General"/>,
/// <see cref="Account"/> and <see cref="Security"/> have no <see cref="ShellScreen"/> counterpart — nothing
/// outside settings mode ever asked "which one of these three am I on" before this wave existed.
/// </para>
/// <para>
/// <b>v5c-2: Groups and Tags joined.</b> The design's rail lists them beside Security and Preferences; v5c-1
/// omitted both from <c>SettingsView.axaml</c> rather than building a placeholder for either, and this wave
/// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a
/// <see cref="ShellScreen"/> counterpart: managing groups and tags has never been its own screen before this,
/// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding
/// for either to keep in step with.
/// </para>
/// </remarks>
internal enum SettingsPage
{
/// <summary>Updates, and the refused items from the design's General page, as an essay.</summary>
General = 0,
/// <summary>The vaults themselves and the people in them — the existing <see cref="ShellScreen.Vaults"/> screen.</summary>
Vaults = 1,
/// <summary>The signed-in profile, the sign-in fact, and signing out of this machine.</summary>
Account = 2,
/// <summary>The end-to-end explainer, Windows Hello, and approved host keys.</summary>
Security = 3,
/// <summary>This machine's terminal and keychain settings — the existing <see cref="ShellScreen.Preferences"/> screen.</summary>
Preferences = 4,
/// <summary>Every group, and the hosts filed under each — the existing group commands, given their own page.</summary>
Groups = 5,
/// <summary>Every tag, and how many hosts wear each — the existing tag commands, given their own page.</summary>
Tags = 6,
}
/// <summary>
/// The shell: get to an unlocked vault, then hand over to <see cref="VaultViewModel"/>.
/// </summary>
/// <remarks>
/// <para>
/// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one
/// browser sign-in; everything about the identity provider comes from
/// <c>/.well-known/dodossh-configuration</c>, so the user never configures an authority or a client id.
/// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone
/// unlocks, which is the state the application spends nearly all of its life in.
/// </para>
/// <para>
/// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU,
/// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it.
/// </para>
/// </remarks>
internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable
{
private readonly ClientPaths paths;
private readonly ClientCacheFactory caches;
private readonly TerminalWorkspace workspace;
/// <remarks>
/// The concrete store rather than <c>IKnownHostStore</c>, because this is where its lifecycle belongs:
/// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting
/// it are this state machine's business. The same instance was handed to the connection factory when the
/// application was composed.
/// </remarks>
private readonly VaultKnownHostStore knownHosts;
/// <remarks>
/// Whatever this machine can keep a device key in, chosen once at composition. An interface because the
/// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that
/// reports itself unavailable rather than a null this state machine would have to check for.
/// </remarks>
private readonly IDeviceKeyStore deviceKeys;
private readonly SignInHandler signIn;
/// <remarks>
/// Optional, and null is not merely "not configured": a shell with no resume handler is one that can
/// only be online because somebody signed in during this run, which is what every test that asserts
/// offline behaviour relies on.
/// </remarks>
private readonly ResumeHandler? resume;
private readonly TimeProvider clock;
private readonly Argon2Profile? passphraseProfile;
/// <summary>What this machine is called — on the account, and on every log entry it writes.</summary>
/// <remarks>
/// From the head rather than from <see cref="Environment.MachineName"/>, because that property answers
/// <c>localhost</c> on Android and would make every phone in an account indistinguishable from every
/// other one — in the device list a user revokes from, and in the log they read to find out which
/// machine opened a shell. The desktop passes nothing and keeps the machine name; a phone knows its own
/// model and nothing in this assembly can ask for it, because <c>Android.OS.Build</c> is not reachable
/// from a <c>net10.0</c> library. See docs/android-port.md §7.
/// </remarks>
private readonly string deviceName;
/// <remarks>
/// 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.
/// </remarks>
private readonly ClientSettingsStore settings;
/// <remarks>
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
/// </remarks>
private readonly Func<string, Task>? copyToClipboard;
/// <remarks>
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
/// </remarks>
private readonly TransfersViewModel transfers;
/// <summary>
/// Where connections are recorded, for as long as a vault is open to record them into.
/// </summary>
/// <remarks>
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
/// </remarks>
private readonly ConnectionRecorder connectionLog;
private readonly VaultsViewModel vaults;
/// <summary>
/// Where newer builds come from, and how far one has got.
/// </summary>
/// <remarks>
/// A process-lifetime object like <see cref="transfers"/>, and for a reason that is its own rather than
/// borrowed: this one outlives a lock because the release channel is not the vault.
/// </remarks>
private readonly UpdateViewModel updateScreen;
/// <summary>
/// The tab standing in for each connection that has been asked for and has not answered yet.
/// </summary>
/// <remarks>
/// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two
/// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives
/// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and
/// when the user closes the tab out from under it.
/// </remarks>
private readonly Dictionary<Guid, TerminalTabViewModel> attempts = [];
/// <summary>How often the hosts screen's ago-text is restrung while it is visible.</summary>
/// <remarks>
/// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording
/// ever names, so a tick any faster would restring a fact that has not changed in any word it prints.
/// </remarks>
private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1);
/// <summary>
/// Whether the hosts screen was showing the last time <see cref="UpdateLastConnectedVisibility"/> ran.
/// </summary>
/// <remarks>
/// The transition is what matters, not the level — see that method. Without this, every unrelated
/// screen or surface change would re-read the connection log for no reason, which is exactly the
/// background re-read <c>LogsViewModel.cs</c> argues a connection log must never be put on.
/// </remarks>
private bool wasHostsScreenShowing;
/// <summary>The loop <see cref="StartLastConnectedTick"/> started, or null while the hosts screen is not showing.</summary>
private CancellationTokenSource? lastConnectedTick;
/// <summary>
/// The loop that restrings <see cref="SessionElapsedText"/> once a minute, for as long as this shell runs.
/// </summary>
/// <remarks>
/// Unlike <see cref="lastConnectedTick"/>, this one is not started and stopped as a screen comes and goes
/// — it runs for the shell's whole life, the same way <see cref="workspace"/> does. Gating it on
/// <see cref="IsTerminalSurface"/>/<see cref="IsTransfersShowing"/> would save one restring a minute while
/// on some other screen, at the cost of the same start/stop bookkeeping <see cref="StartLastConnectedTick"/>
/// needs the hosts screen for — and <see cref="SessionElapsedText"/> is already re-read on every state
/// change worth reacting to immediately; see <see cref="RaiseSessionState"/>. This loop only catches the
/// case nothing else does: sitting still on a connected screen while a minute passes.
/// </remarks>
private readonly CancellationTokenSource sessionElapsedTick = new();
private IVaultServer? connection;
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
private string? rememberedToken;
/// <summary>Guards against two resume attempts overlapping.</summary>
/// <remarks>
/// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and
/// the Sync button — and what has to be prevented is a second attempt starting while the first is
/// waiting on a token endpoint, not a data race.
/// </remarks>
private bool resuming;
private bool disposed;
/// <summary>
/// Where <see cref="LeaveSettings"/> goes back to — captured once, on the turn settings mode is
/// entered, and not touched again until it is left.
/// </summary>
/// <remarks>
/// Not re-captured on every <see cref="EnterSettings"/> call, which is what makes switching pages inside
/// settings mode (Preferences, then Security, then Account) still come back to the one screen the user
/// was actually on beforehand rather than to whichever settings page they last visited.
/// </remarks>
private ShellScreen settingsReturnScreen;
/// <inheritdoc cref="settingsReturnScreen" />
private ShellSurface settingsReturnSurface;
/// <summary>
/// Establishes a connection to a server.
/// </summary>
/// <remarks>
/// A delegate rather than a direct call to <see cref="ServerConnection.SignInAsync"/>, so this whole
/// state machine can be driven by a test against an in-memory server. Sign-in is the one step that
/// genuinely needs a browser and a network, and letting it be the reason nothing else is testable
/// would be the wrong trade.
/// </remarks>
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
/// <summary>
/// Re-establishes a connection from a remembered sign-in, without a browser.
/// </summary>
/// <remarks>
/// A delegate for the same reason <see cref="SignInHandler"/> is one: a real resume needs discovery
/// and a token endpoint, and making that the only way to reach this state machine would put the whole
/// "comes back online by itself" behaviour out of reach of a test.
/// </remarks>
internal delegate Task<IVaultServer> ResumeHandler(
Uri serverUrl,
string refreshToken,
CancellationToken cancellationToken);
/// <param name="sftpSessions">
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
/// application — one type implements both — and a separate parameter because it is a separate capability
/// and the tests that drive this state machine have no use for it.
/// </param>
/// <param name="deviceName">
/// What to call this machine. Optional, and the default is right for every head that runs on a desktop
/// operating system — see the field it is kept in for the one that it is not right for.
/// </param>
/// <param name="updates">
/// Where newer builds of this client come from. Optional, and the default is a channel that reports
/// itself unavailable — which is a deliberate difference from <paramref name="deviceKeys"/>, which every
/// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
/// the Android head's distribution separately, so it must never acquire one by accident.
/// </param>
internal MainWindowViewModel(
ClientPaths paths,
ClientCacheFactory caches,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
IDeviceKeyStore deviceKeys,
SignInHandler signIn,
TimeProvider clock,
ISftpSessionFactory sftpSessions,
Argon2Profile? passphraseProfile = null,
ResumeHandler? resume = null,
Func<string, Task>? copyToClipboard = null,
string? deviceName = null,
IUpdateChannel? updates = 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;
// Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a
// blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail
// to register for a reason no message could explain.
this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName;
// The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the
// keychain screen, which is a different tab and two clicks away from somebody who has gone to S3
// to add one — so the screen that needs a bucket is given a way to reach the screen that makes one.
transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket);
// 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, this.deviceName);
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.
// The third argument is how a vault made over there reaches the lists and the menu over here: both
// are built from the session's vault list, and neither would otherwise learn that it had grown until
// something else happened to rebuild them.
vaults = new VaultsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync);
// 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;
this.workspace.RendererReattached += OnRendererReattached;
settings = new ClientSettingsStore(paths);
updateScreen = CreateUpdateScreen(updates);
// 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();
StartSessionShellTracking();
}
/// <summary>
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
/// </summary>
/// <remarks>
/// The subscription is narrow on purpose: <see cref="SessionAddress"/> and <see cref="IsSessionConnected"/>
/// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from
/// <see cref="TransfersViewModel"/>, and neither used to be read from outside that screen at all — see
/// <see cref="OnTransfersPropertyChanged"/>. The tick restrings <see cref="SessionElapsedText"/> once a
/// minute for the shell's whole life; see the remark on <see cref="sessionElapsedTick"/> for why it is not
/// started and stopped with a screen the way <see cref="StartLastConnectedTick"/> is.
/// </remarks>
private void StartSessionShellTracking()
{
transfers.PropertyChanged += OnTransfersPropertyChanged;
_ = RunSessionElapsedTickAsync(sessionElapsedTick.Token);
}
/// <summary>
/// Builds the updater, kept for the life of the process like the workspace and the transfer queue.
/// </summary>
/// <remarks>
/// A method rather than four more lines in the constructor, because the restart delegate needs a
/// paragraph of its own and the constructor is already at the length the analyzers allow.
/// </remarks>
private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates)
{
// The channel is captured rather than reached through the view model, which keeps the restart
// delegate free of a reference to the object it is being handed to.
var channel = updates ?? new UnavailableUpdateChannel();
return new UpdateViewModel(
channel,
settings,
clock,
() => workspace.LiveSessionCount,
// Everything this application does on the way out, and only then the swap. Applying an update
// ends the process, and disposing this view model is what zeroes the identity keys, the vault
// keys and the cache key — so the other order would leave them sitting in a memory image the
// installer is about to write over, and would abandon a transfer still writing to a part file.
//
// ◆ Unless applying does not end the process, which is the phone. There the swap is a request
// to the platform's own installer and the answer may be no, so tearing everything down first
// would answer "not now" with a locked keychain and every shell closed — a punishment for
// declining an update. Nothing is zeroed in that case, and nothing needs to be while the
// process is still the one holding it; when the install is agreed to, Android ends the process
// outright. See IUpdateChannel.ApplyingEndsTheProcess.
restart: async update =>
{
if (channel.ApplyingEndsTheProcess)
{
await DisposeAsync().ConfigureAwait(true);
}
channel.ApplyAndRestart(update);
});
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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.
}
}
/// <remarks>
/// Marshalled, because this arrives on the data plane's receive loop — see
/// <see cref="TerminalWorkspace.FontSizeStepRequested"/> — and everything it touches is a view model
/// property somebody's interface is bound to.
/// </remarks>
private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) =>
Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step));
/// <remarks>
/// <para>
/// Marshalled for the same reason as <see cref="OnFontSizeStepRequested"/>: this arrives on the data
/// plane's socket-accept thread, and both properties it reads here — <see cref="TerminalFontSize"/> and
/// <see cref="SelectedTab"/> — are bound to by the interface.
/// </para>
/// <para>
/// <see cref="TerminalWorkspace.RendererReattached"/> fires once the workspace has replayed what it
/// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know;
/// they live here, so this is the other half of putting a reattached page back the way it was. The size
/// is sent exactly as <see cref="TellRendererTheFontSizeAsync"/> sends it at startup, because nothing
/// has changed — the page has merely forgotten, and this is only a reminder.
/// </para>
/// </remarks>
private void OnRendererReattached(object? sender, EventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
_ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask();
if (SelectedTab is { } tab)
{
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
});
[ObservableProperty]
private ShellState state = ShellState.Starting;
[ObservableProperty]
private string statusMessage = "Opening the local cache…";
[ObservableProperty]
private bool isBusy;
/// <summary>Whether the unlock screen should offer a gesture instead of the passphrase.</summary>
[ObservableProperty]
private bool canUnlockWithDevice;
/// <summary>Whether an unlocked vault should offer to register this machine.</summary>
[ObservableProperty]
private bool canRegisterDevice;
/// <summary>Whether this machine has a device key to withdraw.</summary>
[ObservableProperty]
private bool canForgetDevice;
/// <summary>
/// Whether this machine can neither register a device key nor withdraw one.
/// </summary>
/// <remarks>
/// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are
/// independent: a machine with no TPM cannot register, and a machine already registered has nothing to
/// register either — and only the second has something to take back. Both false at once is the one case
/// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences
/// screen where the alternative is a section with no controls in it and no explanation.
/// </remarks>
internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice;
/// <summary>
/// The address offered on first launch, before anything is enrolled.
/// </summary>
/// <remarks>
/// <para>
/// One default for every build. The hosted deployment is what all but a handful of launches are
/// aiming at, and typing its address is the only thing standing between an installed application and
/// a working one. Running against a clone means replacing this with
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
/// the one a plain <c>dotnet run</c> 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 <see cref="ExplainSignInFailure" />.
/// </para>
/// <para>
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
/// production by accident. That protection is gone: a debug build now offers the hosted address like
/// any other, and the first sign-in accepted unread lands there.
/// </para>
/// </remarks>
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
[ObservableProperty]
private string serverUrl = DefaultServerUrl;
[ObservableProperty]
private string passphrase = string.Empty;
[ObservableProperty]
private string confirmPassphrase = string.Empty;
/// <summary>Shown once, immediately after enrolling, and never stored anywhere.</summary>
[ObservableProperty]
private string? recoveryCode;
[ObservableProperty]
private bool recoveryCodeWrittenDown;
/// <summary>Who this machine is enrolled as, readable without the passphrase.</summary>
[ObservableProperty]
private string? accountName;
/// <summary>
/// The signed-in account's own email, when the server sent one — for the rail's user popover.
/// </summary>
/// <remarks>
/// A second field rather than a way to pull it back out of <see cref="AccountName"/>, which folds
/// <c>DisplayName ?? Email ?? Subject</c> into one string and forgets which of the three it kept.
/// Wherever <see cref="AccountName"/> is set from a profile or a <c>MeResponse</c>, this is set from the
/// same object's own <c>Email</c> alongside it — so it is null exactly when the server has not sent one,
/// never invented from the subject or the display name the way a naive fallback would.
/// </remarks>
[ObservableProperty]
private string? email;
/// <summary>
/// The OIDC issuer this account signs in through, when this machine has one cached — for the Account
/// settings page's SIGN-IN row.
/// </summary>
/// <remarks>
/// v5c: <c>MeResponse.Issuer</c> was already being cached into <c>StoredUnlockMaterial.Issuer</c> by
/// <see cref="AccountProvisioner"/>, for no reader — nothing before this wave surfaced it. Set from the
/// same two places <see cref="AccountName"/> and <see cref="Email"/> are, in <see cref="AdoptIdentity"/>,
/// so the three can never drift out of step with which account is actually signed in.
/// </remarks>
[ObservableProperty]
private string? issuer;
/// <summary>Two letters for the rail's avatar circle, read off the signed-in display name.</summary>
/// <remarks>
/// The first letter of the first two words in <see cref="AccountName"/> — which is already
/// <c>DisplayName ?? Email ?? Subject</c>, so an account with no display name still yields two letters
/// out of its email's local part or its subject rather than a blank circle. Never padded past what the
/// name itself holds: a one-word name gets one letter rather than a second one invented to fill the
/// mock's own two-letter shape.
/// </remarks>
internal string AvatarInitials
{
get
{
if (string.IsNullOrWhiteSpace(AccountName))
{
return string.Empty;
}
var words = AccountName.Split(
[' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries);
return words switch
{
[] => string.Empty,
[var only] => only[..1].ToUpperInvariant(),
[var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(),
};
}
}
partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials));
/// <summary>
/// Sets <see cref="AccountName"/> and <see cref="Email"/> from one profile, in one place.
/// </summary>
/// <remarks>
/// Both the cached-profile read in <see cref="StartAsync"/> and the browser sign-in in
/// <see cref="SignInAsync"/> land here rather than repeating the same two assignments, which is what
/// kept them from drifting apart the day one of the two calls gained <see cref="Email"/> and the other
/// did not.
/// </remarks>
private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null)
{
AccountName = displayName ?? emailAddress ?? subject;
Email = emailAddress;
Issuer = issuer;
}
[ObservableProperty]
private VaultViewModel? vault;
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
/// <remarks>
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
/// vault — unlocking, locking and signing out — cannot get out of step with it.
/// </remarks>
[ObservableProperty]
private KnownHostsViewModel? knownHostsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private ImportViewModel? importScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private SnippetsViewModel? snippetsScreen;
/// <inheritdoc cref="KnownHostsScreen" />
[ObservableProperty]
private LogsViewModel? logsScreen;
/// <summary>
/// The vaults screen, which the window binds to whether or not a vault is open.
/// </summary>
/// <remarks>
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: 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.
/// <para>
/// Distinct from <see cref="Vault"/>, which is one vault's <em>contents</em> — the hosts, keys and
/// passwords the rail's other screens draw. This one is the vaults themselves and the people in them.
/// </para>
/// </remarks>
internal VaultsViewModel Vaults => vaults;
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
/// <remarks>
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
/// property that went null on lock would be a transfer nothing could report on afterwards.
/// </remarks>
internal TransfersViewModel Transfers => transfers;
/// <summary>Where newer builds come from, which the window binds whether or not a vault is open.</summary>
/// <remarks>
/// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell
/// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left
/// locked overnight is exactly the one that will have found an update by morning.
/// </remarks>
internal UpdateViewModel Updates => updateScreen;
/// <summary>
/// Shells that were left running when the vault was locked.
/// </summary>
/// <remarks>
/// Refreshed by <see cref="LockAsync"/>, which is where the policy this reports is explained.
/// </remarks>
[ObservableProperty]
private int liveSessionCount;
internal bool HasLiveSessions => LiveSessionCount > 0;
/// <summary>The count as a sentence, because a bare number on a lock screen explains nothing.</summary>
internal string LiveSessionSummary => LiveSessionCount == 1
? "1 shell is still connected and still running."
: $"{LiveSessionCount} shells are still connected and still running.";
/// <summary>Where the embedded browser should navigate.</summary>
internal Uri TerminalPageUrl => workspace.PageUrl;
/// <summary>
/// Types into a terminal on behalf of something that is not the keyboard.
/// </summary>
/// <remarks>
/// <para>
/// Exposed on the shell rather than reached through the workspace directly, because the workspace is a
/// composition-root object and a view has no business holding one — the same reason tabs go through
/// here rather than through <c>TerminalWorkspace.CloseSessionAsync</c>.
/// </para>
/// <para>
/// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or
/// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this
/// way — it goes from the renderer straight down the socket.
/// </para>
/// </remarks>
internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory<byte> data) =>
workspace.SendInputAsync(sessionId, data, CancellationToken.None);
/// <summary>
/// How large the terminal draws, in CSS pixels.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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
/// <see cref="TerminalFontSizeStepEventArgs"/> — so both routes end in this property.
/// </para>
/// </remarks>
[ObservableProperty]
private int terminalFontSize = ClientSettings.DefaultTerminalFontSize;
/// <summary>Whether the terminal could be drawn larger than it is.</summary>
internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize;
/// <summary>Whether the terminal could be drawn smaller than it is.</summary>
internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize;
/// <summary>Draws the terminal one point larger.</summary>
[RelayCommand]
private void EnlargeTerminalFont() => StepTerminalFontSize(1);
/// <summary>Draws the terminal one point smaller.</summary>
[RelayCommand]
private void ShrinkTerminalFont() => StepTerminalFontSize(-1);
/// <summary>Puts the terminal back to the size it ships at.</summary>
/// <remarks>
/// 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.
/// </remarks>
[RelayCommand]
private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize);
/// <param name="step">Points to move by, or zero to return to the default.</param>
private void StepTerminalFontSize(int step) => ApplyTerminalFontSize(
step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step);
/// <remarks>
/// 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.
/// </remarks>
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));
}
/// <summary>
/// Raised when a terminal session opens, so the view can hand the terminal the keyboard.
/// </summary>
/// <remarks>
/// Forwarded from <see cref="VaultViewModel.SessionOpened"/> rather than exposed there directly,
/// because <see cref="Vault"/> is replaced on every unlock and the view would have to re-subscribe
/// each time. This shell is the window's data context for the life of the process, so one
/// subscription is enough.
/// </remarks>
internal event EventHandler? TerminalSessionOpened;
internal bool IsStarting => State == ShellState.Starting;
internal bool IsNeedingServer => State == ShellState.NeedsServer;
internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment;
internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode;
internal bool IsLocked => State == ShellState.Locked;
/// <summary>Whether the unlock card itself is showing, rather than the confirmation over it.</summary>
/// <remarks>
/// Its own property because the markup cannot express <c>IsLocked &amp;&amp; !IsConfirmingSignOut</c>,
/// and the two cards genuinely swap rather than stack: the unlock card is already near the height the
/// window guarantees at its minimum size, so putting a second question underneath it would push
/// buttons off a screen with nothing to scroll.
/// </remarks>
internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut;
internal bool IsUnlocked => State == ShellState.Unlocked;
/// <summary>Whether a connection to the server is currently held.</summary>
internal bool IsOnline => connection is not null;
/// <summary>
/// Whether everything this machine has changed has reached the server.
/// </summary>
/// <remarks>
/// <para>
/// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version
/// of that claim, and it is deliberately conservative: true only while a connection is held, the last
/// pass actually reached the server, and the outbox is empty.
/// </para>
/// <para>
/// The middle condition is the one that is easy to leave out, and was. Holding an <c>IVaultServer</c>
/// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a
/// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely
/// the shape of a green light that is lying. See <c>VaultViewModel.LastSyncFailed</c>.
/// </para>
/// <para>
/// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a
/// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this
/// machine can reach the server and has nothing stuck.
/// </para>
/// </remarks>
internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false };
/// <summary>The same fact as a word, for the titlebar.</summary>
internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch
{
(false, _, _) => "OFFLINE",
(true, true, _) => "UNREACHABLE",
(true, false, 0) => "SYNCED",
(true, false, 1) => "1 PENDING",
(true, false, var pending) => $"{pending} PENDING",
};
// ---- Which screen is showing ----
/// <summary>
/// Which of the nav rail's screens the page area holds.
/// </summary>
/// <remarks>
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
/// It is what dismissing a terminal returns to.
/// </remarks>
[ObservableProperty]
private ShellScreen screen;
/// <summary>
/// Whether the page area is showing rather than a terminal.
/// </summary>
/// <remarks>
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
/// <c>IsHostsScreen &amp;&amp; IsShowingPages</c> in a binding, so the alternative is five compound
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
/// </remarks>
internal bool IsShowingPages => Surface is ShellSurface.Page;
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsTransfersScreen => Screen is ShellScreen.Transfers;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKeychainScreen => Screen is ShellScreen.Keychain;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsVaultsScreen => Screen is ShellScreen.Vaults;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsMoreScreen => Screen is ShellScreen.More;
/// <inheritdoc cref="IsHostsScreen" />
internal bool IsBucketsScreen => Screen is ShellScreen.Buckets;
/// <summary>
/// Whether the nav rail should light its Hosts entry.
/// </summary>
/// <remarks>
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
/// at once is one too many.
/// </remarks>
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKeychainShowing => IsShowingPages && IsKeychainScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsVaultsShowing => IsShowingPages && IsVaultsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsMoreShowing => IsShowingPages && IsMoreScreen;
/// <inheritdoc cref="IsHostsShowing" />
internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen;
/// <summary>
/// Whether the phone's SETTINGS tab should light.
/// </summary>
/// <remarks>
/// 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 two of its three entries. This is the one
/// place where "which tab" and "which screen" are deliberately not the same question — the other two
/// tabs are each exactly one thing, and this one is seven.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this. <see cref="ShellScreen.Vaults"/> is in it for the same reason and no
/// other: the desktop has a rail entry for it and the phone reaches it through the hub, so a
/// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own
/// header back over a screen that already has one.
///
/// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
/// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
/// session moves between, and the keychain is not one of those — hosts and connections are what
/// somebody opens the application to do, and keys, credentials and tags are what they go and manage
/// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
/// the two heads deliberately arrange differently, after the hub itself.
/// </remarks>
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Vaults or ShellScreen.Keychain;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is an occlusion rule, not a styling one.</b> The WebView is a native child window on Windows,
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
/// locked vault (the unlock card), the page area (every screen uses the full width), the quick-connect
/// palette, the phone's connect sheet, and the host-key decision.
/// </para>
/// <para>
/// <b>The host-key decision is the newest of them, and the one that made it a rule rather than a list.</b>
/// It is drawn over whatever is on screen at the moment a handshake asks the question — including an open
/// terminal, because a second connection can be being made while a first one is being typed into. Without
/// this condition the two buttons that answer the most safety-critical question in the product would be
/// sliced at the WebView's left edge and take no clicks. See <see cref="IsHostKeyDecisionShowing"/>.
/// </para>
/// <para>
/// <b>The sheet is here rather than in <see cref="IsTerminalSurface"/>, and the palette is not.</b> 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.
/// </para>
/// <para>
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
/// to the same value.
/// </para>
/// <para>
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
/// Avalonia draws and nothing occludes.
/// </para>
/// <para>
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
/// answered here, and it cannot be: this property has no way to know when layout ran.
/// </para>
/// <para>
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
/// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect
/// while this is false; only the bounds are withheld. Removing the control from the tree would not be
/// safe — that detaches it and destroys the whole WebView2 process tree.
/// </para>
/// </remarks>
internal bool IsTerminalShowing =>
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
/// <summary>
/// Whether a host key is waiting to be judged, over whatever the user is looking at.
/// </summary>
/// <remarks>
/// <para>
/// <b>It is asked of the shell rather than of a screen because the answer decides an occlusion, and
/// because the question is no longer tied to a screen.</b> The prompt used to be a banner at the top of
/// the hosts screen, and the shell navigated there before letting the vault raise it — which is how a
/// machine typed into the phone's own connect box came to be judged on a list it is deliberately not on.
/// Both heads now draw the decision over whichever surface was showing when the handshake stopped, so
/// there is nowhere it can be asked from that it cannot be answered on, and nothing has to move.
/// </para>
/// <para>
/// Two states, one flag, and the views keep them apart: an unknown key is a decision with two buttons and
/// a changed one is a refusal with no way forward. What they share is that both cover the rectangle the
/// terminal would be in. See <c>HostKeyCard.axaml</c> on the desktop and <c>HostKeySheet.axaml</c> on the
/// phone.
/// </para>
/// <para>
/// The transfers screen's own copy of this question is not here, and that is not an omission: file
/// transfer's prompt is drawn inside that screen, which is a page, so it occludes nothing.
/// </para>
/// </remarks>
internal bool IsHostKeyDecisionShowing =>
IsUnlocked && Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: true };
/// <summary>
/// Whether the terminal half of the window is the half being shown, pane or no pane.
/// </summary>
/// <remarks>
/// Every condition in <see cref="IsTerminalShowing"/> except the one about there being a session, and it
/// is worth its own name because a tab exists before its session does — see
/// <see cref="TerminalTabViewModel"/>. This is what "the user is looking at the terminal" means; the
/// other two say which of the two things that can be in that rectangle is drawn.
/// </remarks>
internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
/// <summary>
/// Whether the card that stands in for a pane is showing.
/// </summary>
/// <remarks>
/// <para>
/// The other half of <see cref="IsTerminalShowing"/>, and exclusive with it by construction: a selected
/// tab either has a session or it does not. It covers both of the states in which it does not — still
/// connecting, and failed — because both are a tab with something to say and nothing to draw it in.
/// </para>
/// <para>
/// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the
/// terminal off rather than merely draw over it. See <see cref="IsTerminalShowing"/>.
/// </para>
/// </remarks>
internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false };
/// <inheritdoc cref="ShellSurface" />
[ObservableProperty]
private ShellSurface surface;
/// <summary>Points the nav rail at a screen.</summary>
/// <remarks>
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
/// </remarks>
[RelayCommand]
private void ShowScreen(ShellScreen target)
{
// v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either
// of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant
// to land in settings mode rather than on the bare screen the design retired. Redirecting here,
// rather than at every caller, is what makes that true without hunting down every existing call.
if (target is ShellScreen.Preferences)
{
EnterSettings(SettingsPage.Preferences);
return;
}
if (target is ShellScreen.Vaults)
{
EnterSettings(SettingsPage.Vaults);
return;
}
// v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on
// Preferences, and what changes underneath it is the content column and the titlebar's own back
// label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport.
if (target is ShellScreen.Import)
{
OpenImport();
return;
}
// Any other screen leaves settings mode outright rather than restoring whatever was remembered on
// the way in — the caller named a destination, and that destination wins over "go back".
ActiveSettingsPage = null;
Screen = target;
Surface = ShellSurface.Page;
}
/// <summary>
/// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column —
/// see <c>SettingsView.axaml</c>. Not null exactly while that chrome, rather than the ordinary titlebar
/// and nav rail, is what <c>MainWindow.axaml</c> draws.
/// </summary>
/// <remarks>
/// A second notion of "where am I" from <see cref="Screen"/> rather than a replacement for it — see the
/// remark on <see cref="SettingsPage"/>. Two of its five members, <see cref="SettingsPage.Preferences"/>
/// and <see cref="SettingsPage.Vaults"/>, keep <see cref="Screen"/> in step with the matching
/// <see cref="ShellScreen"/> member so every binding and test written against that screen before this
/// mode existed keeps working; the other three have nothing to keep in step with.
/// </remarks>
[ObservableProperty]
private SettingsPage? activeSettingsPage;
/// <summary>Whether the settings chrome, rather than the ordinary one, is what the window is drawing.</summary>
internal bool IsSettingsMode => ActiveSettingsPage is not null;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups;
/// <inheritdoc cref="IsSettingsMode" />
internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags;
/// <summary>
/// Whether the importer is showing over the Preferences page, inside settings mode.
/// </summary>
/// <remarks>
/// A flag layered on top of <see cref="ActiveSettingsPage"/> rather than a <see cref="SettingsPage"/>
/// member of its own — Import.dc.html draws <c>SettingsNav</c> lit on Preferences the whole time the
/// importer is up, which this makes true for free: <see cref="ActiveSettingsPage"/> never leaves
/// <see cref="SettingsPage.Preferences"/>, so <see cref="IsSettingsPreferencesPage"/> and the nav row it
/// drives stay exactly as they were. What moves is only the content column, via
/// <see cref="IsSettingsPreferencesContentShowing"/>, and the titlebar's own back label — see
/// <c>SettingsTitleBar.axaml</c>.
/// </remarks>
[ObservableProperty]
private bool isImportOpen;
/// <summary>
/// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's
/// content column shows.
/// </summary>
internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen;
partial void OnActiveSettingsPageChanged(SettingsPage? value)
{
OnPropertyChanged(nameof(IsSettingsMode));
OnPropertyChanged(nameof(IsSettingsGeneralPage));
OnPropertyChanged(nameof(IsSettingsVaultsPage));
OnPropertyChanged(nameof(IsSettingsAccountPage));
OnPropertyChanged(nameof(IsSettingsSecurityPage));
OnPropertyChanged(nameof(IsSettingsPreferencesPage));
OnPropertyChanged(nameof(IsSettingsGroupsPage));
OnPropertyChanged(nameof(IsSettingsTagsPage));
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
}
partial void OnIsImportOpenChanged(bool value) =>
OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing));
/// <summary>Enters settings mode on a page, remembering where "Back to application" returns to.</summary>
/// <remarks>
/// The return screen is captured only on the way in from outside settings mode — see
/// <see cref="settingsReturnScreen"/> — so switching between settings pages, which calls this
/// repeatedly, cannot overwrite it with another settings page.
/// <para>
/// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is
/// a request for that page, not for whatever was drawn over it the last time settings mode was up.
/// </para>
/// </remarks>
[RelayCommand]
private void EnterSettings(SettingsPage page)
{
if (ActiveSettingsPage is null)
{
settingsReturnScreen = Screen;
settingsReturnSurface = Surface;
}
ActiveSettingsPage = page;
IsImportOpen = false;
Screen = page switch
{
SettingsPage.Preferences => ShellScreen.Preferences,
SettingsPage.Vaults => ShellScreen.Vaults,
_ => Screen,
};
Surface = ShellSurface.Page;
}
/// <summary>
/// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and
/// <see cref="ShowScreen"/>'s translation of <see cref="ShellScreen.Import"/> for every other caller.
/// </summary>
private void OpenImport()
{
EnterSettings(SettingsPage.Preferences);
IsImportOpen = true;
}
/// <summary>"Back to preferences": closes the importer without leaving settings mode.</summary>
[RelayCommand]
private void CloseImport() => IsImportOpen = false;
/// <summary>"Back to application": leaves settings mode for wherever it was entered from.</summary>
[RelayCommand]
private void LeaveSettings()
{
if (ActiveSettingsPage is null)
{
return;
}
ActiveSettingsPage = null;
IsImportOpen = false;
Screen = settingsReturnScreen;
Surface = settingsReturnSurface;
}
/// <summary>Switches to the terminal surface.</summary>
/// <remarks>
/// <para>
/// The other half of <see cref="ShowScreenCommand"/>, and it exists because the phone's bottom bar
/// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a
/// session or clicking a tab — because its tab strip is always on screen and is itself the way back.
/// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a
/// button, and a button needs a command.
/// </para>
/// <para>
/// Not gated on there being a tab, for the same reason <see cref="IsTerminalShowing"/> is not: closing
/// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal
/// screen carries an empty state anyway, which is worth being able to reach deliberately.
/// </para>
/// </remarks>
[RelayCommand]
private void ShowTerminal()
{
Surface = ShellSurface.Terminal;
// Only with nothing open, because that is the only state in which they are drawn — the surface shows
// the sessions otherwise. Not awaited, for the reason the logs screen's own load is not: navigating
// must not block on a read, and the list appears under the box the moment it arrives.
if (!HasTabs)
{
_ = RefreshRecentConnectionsAsync();
}
}
/// <summary>
/// The machines most recently connected to, for the Connections screen to offer when nothing is open.
/// </summary>
/// <remarks>
/// <para>
/// Deduplicated by address, because this is a list of places rather than of events: connecting to one
/// box nine times in a morning is nine entries in the log and one thing worth offering here. The log
/// screen shows every one of them; that is what a log is for and this is not one.
/// </para>
/// <para>
/// Capped, and the cap is not about memory. What makes this list useful is that the machine somebody
/// wants is visible without scrolling, above a keyboard, under the box they would otherwise be typing
/// into. Twenty rows would push the box off the screen and be a worse version of the log.
/// </para>
/// </remarks>
internal ObservableCollection<ConnectionLogRowViewModel> RecentConnections { get; } = [];
internal bool HasRecentConnections => RecentConnections.Count > 0;
/// <summary>How many machines the Connections screen offers.</summary>
private const int RecentConnectionLimit = 6;
/// <summary>Re-reads the connection log and takes the most recent distinct machines from it.</summary>
/// <remarks>
/// Failures are swallowed, and that is the same call the logs screen makes for the same reason: this is
/// a convenience under a box that works without it. A screen whose whole purpose is to let somebody
/// connect should not lead with a decryption error about a list of things they connected to yesterday.
/// </remarks>
private async Task RefreshRecentConnectionsAsync()
{
if (LogsScreen is not { } logs)
{
return;
}
try
{
await logs.ReloadConnectionsAsync(CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
return;
}
RecentConnections.Clear();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var row in logs.Connections)
{
// The live ones are skipped rather than filtered later: a connection that is open right now has
// a tab, and a tab means this list is not on screen at all. Leaving them in would only matter in
// the one state where it cannot be seen, which is a rule that would be wrong the moment that
// stopped being true.
if (row.IsLive || !seen.Add(row.Address))
{
continue;
}
RecentConnections.Add(row);
if (RecentConnections.Count == RecentConnectionLimit)
{
break;
}
}
OnPropertyChanged(nameof(HasRecentConnections));
}
/// <summary>Goes back to a machine that has been connected to before.</summary>
/// <remarks>
/// <para>
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
/// path that had to answer all of that again.
/// </para>
/// <para>
/// ◆ <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
/// </para>
/// <para>
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
/// manual path, and a field that filled itself in would be claiming otherwise.
/// </para>
/// <para>
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
/// out — arriving at the hosts screen with nothing selected and no explanation.
/// </para>
/// </remarks>
[RelayCommand]
private void ConnectToRecent(ConnectionLogRowViewModel row)
{
if (row is null || Vault is not { } vault)
{
return;
}
if (row.HostId is { } hostId
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
&& vault.IsVaultShown(known.VaultId))
{
vault.AskAboutHostCommand.Execute(known);
ShowScreen(ShellScreen.Hosts);
return;
}
vault.ManualTarget = row.Address;
vault.ManualStatus = string.Empty;
}
// ---- The rail's own page grouping ----
/// <summary>
/// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3.
/// </summary>
/// <remarks>
/// <para>
/// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and
/// its two siblings — <see cref="IsTransfersShowing"/> and <see cref="IsBucketsShowing"/> — lit one of
/// three tabs, and the rail was drawn only under this one; see the file history for that version of
/// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head
/// now, and the rail is permanent furniture beside every one of the three — but the partition this
/// answers is still real and still asked in three places: <see cref="MainWindowViewModel.IsSshShowing"/>
/// reads it under a new name for the switcher, the rail's mode-dependent first row reads
/// <see cref="FirstRailItemLabel"/> which is built from the same three flags, and this one is still what
/// the rail's own six rows below the switcher use to know a rail screen is the one on the page.
/// </para>
/// <para>
/// <b>Not <see cref="IsVaultsShowing"/>, which is one of the six screens this covers.</b> The two are
/// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one
/// is "a rail screen is showing, rather than SFTP, S3 or a terminal".
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages the rail's own rows reach, as opposed to SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
// ---- The rail's segmented switcher and its mode-dependent first entry ----
//
// v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a
// segmented control the design draws at the rail's head — see NavRail.axaml. What used to be
// IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three
// segments and one rail row instead, and the partition is the same one: exactly one of "a page under
// the rail's own list", "the files screen" and "the buckets screen" is ever true.
/// <summary>Whether the switcher's SSH segment is lit, and the rail's default "mode".</summary>
/// <remarks>
/// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not
/// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is
/// the complement of the other two rather than a read of <see cref="ShellSurface"/> on its own, so a
/// page under the rail's list and an open terminal both light this segment, exactly as <c>IsVaultsTab</c>
/// used to treat both as "not SFTP, not S3".
/// </remarks>
internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing;
/// <summary>The rail's first entry, which the design calls "mode-dependent" rather than fixed.</summary>
/// <remarks>
/// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read
/// straight off the same three flags the switcher above lights, so the row and the segment can never
/// name two different modes. See <see cref="FirstRailItemIcon"/> and <see cref="ShowFirstRailItem"/>
/// for the matching glyph and the command the row runs.
/// </remarks>
internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal";
/// <summary>
/// The glyph beside <see cref="FirstRailItemLabel"/>, by Material Icons codepoint — see
/// <c>Palette.axaml</c>'s remark on <c>IconFont</c> for why this codebase spells glyphs that way.
/// </remarks>
internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : "";
/// <summary>Runs whichever of the three the row is currently naming.</summary>
/// <remarks>
/// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the
/// row itself already reads the same three flags <see cref="ShowFiles"/> and <see cref="ShowTerminal"/>
/// answer to, so asking again here would be a second place those three facts could disagree.
/// </remarks>
[RelayCommand]
private void ShowFirstRailItem()
{
if (IsBucketsShowing)
{
ShowFiles(RemoteKind.Bucket);
}
else if (IsTransfersShowing)
{
ShowFiles(RemoteKind.Host);
}
else
{
ShowTerminal();
}
}
// ---- Which vaults this window is showing ----
/// <summary>
/// This machine's preferences about which vaults are drawn, or null while nothing is open.
/// </summary>
/// <remarks>
/// Held here rather than inside <see cref="VaultViewModel"/> because the menu that changes it — the
/// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and
/// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session
/// opened, so it cannot outlive the session any more than the keyring can.
/// </remarks>
private VaultVisibility? visibility;
/// <summary>
/// One switch per readable vault, for the rail's user popover.
/// </summary>
/// <remarks>
/// Somebody in four teams does not want four teams' machines in front of them all day. The switches are
/// per window and per machine, and what they change is what is drawn — see <see cref="VaultVisibility"/>
/// for the things they deliberately do not change.
/// </remarks>
internal ObservableCollection<VaultToggleViewModel> VaultToggles { get; } = [];
/// <summary>Whether the menu has anything to offer.</summary>
/// <remarks>
/// One vault is the ordinary case — somebody who has never joined a team — and a menu holding a single
/// switch that cannot be moved is a menu that answers nothing. The New vault entry is still worth
/// having, so this hides the list rather than the flyout.
/// </remarks>
internal bool HasVaultSwitches => VaultToggles.Count > 1;
/// <summary>Refills the switches from the vaults this session can read.</summary>
/// <remarks>
/// The readable ones, not every known one: a vault whose grant awaits re-wrap has nothing that would
/// decrypt, so a switch for it would do nothing and say so to nobody. Personal first, then by name,
/// which is the order every other vault list in the application uses.
/// </remarks>
private void RebuildVaultToggles()
{
VaultToggles.Clear();
if (Vault is { } open && visibility is { } preferences)
{
foreach (var readable in open.Session.ReadableVaults
.OrderByDescending(row => row.IsPersonal)
.ThenBy(row => row.Name, StringComparer.CurrentCulture))
{
VaultToggles.Add(new VaultToggleViewModel(
readable.VaultId,
readable.Name,
readable.IsPersonal,
preferences.IsShown(readable.VaultId)));
}
}
OnPropertyChanged(nameof(HasVaultSwitches));
}
/// <summary>Shows or stops showing one vault's items.</summary>
/// <remarks>
/// <para>
/// The personal vault is drawn in the menu, ticked, and cannot be switched off — see
/// <see cref="VaultToggleViewModel.CanHide"/>. Leaving it out of the list would read as a bug, and
/// letting it be switched off would empty the snippet, log and bucket screens at once, since all three
/// are read from the active vault alone.
/// </para>
/// <para>
/// Refuses to switch off the last one that is showing. In practice the rule above already makes that
/// unreachable; it is here for the session whose personal grant is unreadable, where the alternative is
/// an application that looks broken and gives no clue which menu broke it.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ToggleVaultAsync(VaultToggleViewModel? row)
{
if (row is null || Vault is not { } open || visibility is not { } preferences)
{
return;
}
if (!row.CanHide)
{
StatusMessage =
"Your personal vault is always shown. Everything filed nowhere else lives in it.";
return;
}
var hiding = row.IsShown;
if (hiding && VaultToggles.Count(toggle => toggle.IsShown) <= 1)
{
StatusMessage = "At least one vault has to be showing.";
return;
}
await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None)
.ConfigureAwait(true);
// The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it
// second means the menu and the screen behind it never disagree, even for a frame.
await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true);
RebuildVaultToggles();
StatusMessage = hiding
? $"'{row.Name}' is no longer shown. It still syncs, and hosts that authenticate with its keys "
+ "still connect."
: $"'{row.Name}' is showing again.";
}
/// <summary>Redraws everything built from the session's vault list.</summary>
/// <remarks>
/// Handed to the teams screen, which is where a vault gets made. The switches come from that list and
/// so does every host, key and pin on the vault screens, so both are a vault out of date the moment one
/// is created — and neither is on screen at that point, which is exactly why nothing would have noticed.
/// </remarks>
private async Task OnVaultsChangedAsync(CancellationToken cancellationToken)
{
if (Vault is { } open)
{
await open.RefreshVaultsAsync(cancellationToken).ConfigureAwait(true);
}
RebuildVaultToggles();
}
/// <summary>
/// Goes to the vaults screen with the new-vault form open.
/// </summary>
/// <remarks>
/// The screen a vault is made on is the one that shows vaults — where the people, the roles and the key
/// holders already are, which is the next thing anybody making a shared vault wants. The form asks for a
/// name and nothing else; see <c>VaultsViewModel.CreateVaultAsync</c> for the membership list that is
/// made behind it.
/// </remarks>
[RelayCommand]
private void ShowNewVault()
{
ShowScreen(ShellScreen.Vaults);
vaults.NewVaultCommand.Execute(null);
}
/// <summary>
/// Goes to the keychain with the bucket editor open.
/// </summary>
/// <remarks>
/// <para>
/// The same shape as <see cref="ShowNewVault"/> and for the same reason: the thing being made lives on
/// one screen, and the moment somebody wants it happens on another. Here the two are a whole tab apart —
/// S3 is where a bucket is used and the keychain is where its keys are kept — which is what made the S3
/// screen's empty picker read as an application that could not add one at all.
/// </para>
/// <para>
/// Silent when the vault is locked, which is a state the button behind this is not reachable in: the
/// screen it sits on is inside the unlocked half of the window.
/// </para>
/// </remarks>
private void ShowNewBucket()
{
ShowScreen(ShellScreen.Keychain);
Vault?.NewObjectStoreCommand.Execute(null);
}
// ---- The phone's connect menu ----
/// <summary>
/// Whether the phone's connect menu is open over the terminal.
/// </summary>
/// <remarks>
/// <para>
/// Drawn by the Android head alone, and shell state rather than something that view could hold on its
/// own for the reason <see cref="IsSearching"/> is: it has to collapse the renderer while it is up. See
/// <see cref="IsTerminalShowing"/>.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isConnectSheetOpen;
/// <summary>Raises the connect menu over the terminal.</summary>
/// <remarks>
/// 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.
/// </remarks>
[RelayCommand]
private void OpenConnectSheet()
{
if (!IsTerminalSurface)
{
return;
}
IsConnectSheetOpen = true;
}
/// <summary>Lowers the connect menu, leaving the terminal where it was.</summary>
/// <remarks>
/// 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 <see cref="OnSurfaceChanged"/>, which is what makes "the sheet is only ever up over a terminal"
/// true of routes nobody wrote it for.
/// </remarks>
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- Open terminals ----
/// <summary>
/// Every terminal that has been opened this run, in the order they were opened.
/// </summary>
/// <remarks>
/// On the shell rather than on the vault, and that follows from the lock policy rather than from
/// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would
/// lose sessions that are still connected — the very sessions <see cref="LiveSessionCount"/> exists to
/// admit to. This object is the window's data context for the life of the process, and so is this list.
/// </remarks>
internal ObservableCollection<TerminalTabViewModel> Tabs { get; } = [];
[ObservableProperty]
private TerminalTabViewModel? selectedTab;
internal bool HasTabs => Tabs.Count > 0;
private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs));
/// <summary>
/// Closes one terminal, ending its shell.
/// </summary>
/// <remarks>
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
/// </remarks>
[RelayCommand]
private async Task CloseTabAsync(TerminalTabViewModel tab)
{
if (tab is null)
{
return;
}
// Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to
// mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its
// way out is repainted as disconnected.
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, which is where the eye already is.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
// leave a visible WebView with no pane in it, which reads as the application having broken.
if (Tabs.Count == 0)
{
Surface = ShellSurface.Page;
}
RaiseTabState();
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
// terminal just went would keep a lit dot until something else happened to move the selection.
RefreshConnectedHosts();
// What the rectangle holds is decided by the selected tab's state, and the line above may well have
// moved the selection from a card to a pane or the other way round.
RaiseTerminalState();
if (!tab.HasSession)
{
// Nothing to end: this tab is a connection that has not happened, or one that never will. The
// attempt is forgotten so a handshake still in flight does not come back and reopen a tab the
// user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts
// rather than drops, because a running shell nothing names is worse than a tab that reappears.
foreach (var attemptId in attempts
.Where(entry => ReferenceEquals(entry.Value, tab))
.Select(entry => entry.Key)
.ToArray())
{
attempts.Remove(attemptId);
}
return;
}
await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true);
}
// ---- Quick connect ----
/// <summary>Whether the quick-connect palette is open over the window.</summary>
/// <remarks>
/// It has to collapse the terminal while it is open — see <see cref="IsTerminalShowing"/> — which is why
/// this is shell state rather than something a view could hold on its own.
/// </remarks>
[ObservableProperty]
private bool isSearching;
[ObservableProperty]
private string searchText = string.Empty;
/// <summary>
/// The hosts the palette is offering, best match first.
/// </summary>
/// <remarks>
/// The design's box says "search hosts · run command". Only the first half is here: a command palette
/// needs commands to run, and this application has no snippet or saved-command item type — see
/// <c>docs/design-import-gaps.md</c>. Offering an empty command list under a box that promised one is
/// worse than a box that promises only what it does.
/// </remarks>
internal ObservableCollection<HostRowViewModel> SearchResults { get; } = [];
[ObservableProperty]
private HostRowViewModel? selectedSearchResult;
/// <summary>Whether the palette has anything to offer.</summary>
/// <remarks>
/// A property rather than <c>{Binding !SearchResults.Count}</c> in the markup. Avalonia's <c>!</c> is a
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
/// its default of true, and "No host matches that" is shown permanently — under a list of matches.
/// </remarks>
internal bool HasSearchResults => SearchResults.Count > 0;
/// <summary>Opens the palette, or closes it if it is already open.</summary>
[RelayCommand]
private void ToggleSearch()
{
if (IsSearching)
{
CloseSearch();
return;
}
if (!IsUnlocked)
{
return;
}
SearchText = string.Empty;
RefreshSearchResults();
IsSearching = true;
}
/// <summary>Dismisses the palette without connecting.</summary>
[RelayCommand]
private void CloseSearch()
{
IsSearching = false;
SearchText = string.Empty;
SearchResults.Clear();
SelectedSearchResult = null;
OnPropertyChanged(nameof(HasSearchResults));
}
/// <summary>
/// Selects the highlighted host and connects to it.
/// </summary>
/// <remarks>
/// Goes through the vault's own <c>ConnectCommand</c> rather than opening a session directly, so the
/// palette inherits every refusal that path already makes — a dangling key binding, a host with no
/// username, a host key that has changed. A second connect path would be a second place for those to be
/// forgotten.
/// </remarks>
[RelayCommand]
private async Task ConnectToSearchResultAsync()
{
if (Vault is not { } vault || SelectedSearchResult is not { } row)
{
return;
}
CloseSearch();
// ◆ IT USED TO GO TO THE HOSTS PAGE FIRST, and the reason it did no longer exists. An unknown or
// changed host key was answered by a prompt drawn on that page, and the palette opens from any screen,
// so connecting from the files screen without the jump would have left the question behind the screen
// that asked it. Both heads draw the decision over the surface now — see IsHostKeyDecisionShowing —
// and the other thing this connection can say before it dials, a refusal on Vault.Status, is in the
// status bar, which is a row of the window rather than part of a screen.
//
// So the palette leaves the user where they were, which is the whole point of a palette: Ctrl+K over a
// transfer that is still running should not cost the transfer's screen.
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
// generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its
// own source — so passing this one would read as cancellation plumbing that is not there.
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <remarks>
/// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one
/// that merely contains it, and both come before a match found only in the address. Typing three
/// characters of a name people use daily should not put that host third.
/// </remarks>
private void RefreshSearchResults()
{
SearchResults.Clear();
if (Vault is not { } vault)
{
SelectedSearchResult = null;
return;
}
var query = SearchText.Trim();
var matches = query.Length == 0
? vault.Hosts.AsEnumerable()
: vault.Hosts
.Select(host => (host, rank: Rank(host, query)))
.Where(candidate => candidate.rank < int.MaxValue)
.OrderBy(candidate => candidate.rank)
.ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture)
.Select(candidate => candidate.host);
foreach (var host in matches.Take(8))
{
SearchResults.Add(host);
}
SelectedSearchResult = SearchResults.FirstOrDefault();
OnPropertyChanged(nameof(HasSearchResults));
}
private static int Rank(HostRowViewModel host, string query)
{
if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase))
{
return 0;
}
if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase))
{
return 1;
}
return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase)
? 2
: int.MaxValue;
}
/// <summary>
/// Brings the schema up to date and works out which screen to show.
/// </summary>
/// <remarks>
/// Migrating happens before unlock and touches no encrypted content — only the shape of the tables.
/// That is the point of migrating rather than recreating: a user who upgrades while offline must still
/// be able to open their vault.
/// </remarks>
internal async Task StartAsync(CancellationToken cancellationToken)
{
// Before anything that can return early, and outside the try: looking for a newer build does not
// depend on there being a profile, a server or a vault, and a machine that never gets past the setup
// screen is still one that should not be running a build with a hole in it. Start() is a no-op on a
// copy that cannot replace itself.
updateScreen.Start();
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;
}
AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer);
ServerUrl = profile.ServerUrl;
State = ShellState.Locked;
StatusMessage = $"Enrolled against {profile.ServerUrl}.";
// Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key
// back. Offering the button without the second would prompt for a key that is not there; without
// the first it would prompt for a wrap that is not there. Neither failure is one a user could
// make sense of, so the button simply does not appear.
CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null
&& await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
State = ShellState.NeedsServer;
StatusMessage = $"The local cache could not be opened: {exception.Message}";
}
}
/// <summary>Discovers the server and runs the browser sign-in.</summary>
[RelayCommand]
private async Task SignInAsync(CancellationToken cancellationToken)
{
if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url))
{
StatusMessage = "That is not a valid server URL.";
return;
}
// Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute
// URI whose scheme is "localhost" — and then fails much later with something unrelated to the
// actual mistake.
if (url.Scheme is not ("http" or "https"))
{
StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:.";
return;
}
await RunAsync(
"Opening your browser to sign in…",
explain: exception => ExplainSignInFailure(exception, url),
work: async () =>
{
connection?.Dispose();
connection = null;
connection = await signIn(url, cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
// The browser is finished with, and what follows is a round trip to the DodoSSH server
// that can take a while or fail on its own. Saying so is the difference between a wait
// and a hang: a screen still reading "Opening your browser to sign in…" while the server
// is the thing struggling sends the user back to a browser that did nothing wrong.
StatusMessage = $"Signed in. Asking {url.Host} about your account…";
var outcome = await Provisioner()!
.RefreshAsync(ServerUrl, cancellationToken)
.ConfigureAwait(true);
AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer);
StatusMessage = outcome.Message;
if (outcome.Status == ProvisionStatus.EnrollmentRequired)
{
State = ShellState.NeedsEnrollment;
return;
}
// An unlocked vault stays unlocked. This command is reachable from the preferences screen
// of a running application — it is how somebody whose sign-in expired gets back online —
// and moving the state machine to Locked there would throw an unlock screen over an open
// vault whose keys are still in memory, which is neither locked nor honest.
if (IsUnlocked)
{
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
return;
}
State = ShellState.Locked;
}).ConfigureAwait(true);
}
/// <summary>Creates the identity key and the personal vault.</summary>
[RelayCommand]
private async Task EnrollAsync(CancellationToken cancellationToken)
{
if (Provisioner() is not { } provisioner)
{
StatusMessage = "Sign in first.";
return;
}
if (!ValidateNewPassphrase())
{
return;
}
await RunAsync(
"Creating your keychain. This deliberately takes a moment…",
async () =>
{
var chosen = Passphrase;
var outcome = await Task
.Run(
() => provisioner.EnrollAsync(
ServerUrl,
chosen,
deviceName,
"Personal",
cancellationToken),
cancellationToken)
.ConfigureAwait(true);
ConfirmPassphrase = string.Empty;
RecoveryCode = outcome.RecoveryCode;
RecoveryCodeWrittenDown = false;
StatusMessage = outcome.Message;
// A brand-new account always yields a code. An account someone else already enrolled does
// not, and there is nothing to show.
State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode;
}).ConfigureAwait(true);
}
/// <summary>
/// Puts the recovery code on the clipboard.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>The one secret in this application that is deliberately offered to the clipboard, and the
/// contrast with <c>VaultViewModel.CopyPublicKeyAsync</c> is the whole argument.</b> There, copying the
/// <em>private</em> key is refused outright, because installing a key means pasting the public half and
/// the private one has no business leaving the vault. Here there is no better route: the code exists for
/// one screen, is stored nowhere, and has to reach a password manager — so the clipboard is the intended
/// destination rather than a way around the design.
/// </para>
/// <para>
/// Both screens already made the code selectable, and both said why: a person who cannot get it out of
/// the box photographs the screen, and a screenshot is a far worse home for it than a clipboard. This is
/// that argument finished. Selecting a monospaced, letter-spaced string with a thumb is the version of
/// "possible" that people give up on.
/// </para>
/// <para>
/// It says what it did, including the case where there is nothing to say it to — a machine with no
/// clipboard has to be told so rather than left with a button that appears to do nothing, which is the
/// same rule the keychain's copy already follows. And the sentence names what has to happen next,
/// because a clipboard is not somewhere a recovery code may stay: this screen is the only moment it
/// exists, and the next thing copied replaces it.
/// </para>
/// </remarks>
[RelayCommand]
private async Task CopyRecoveryCodeAsync()
{
if (RecoveryCode is not { Length: > 0 } code)
{
return;
}
if (copyToClipboard is null)
{
StatusMessage = "This machine has no clipboard. Select the code and copy it by hand.";
return;
}
await copyToClipboard(code).ConfigureAwait(true);
StatusMessage = "Copied. Paste it into your password manager now — this screen is the only place "
+ "it exists, and the next thing you copy replaces it.";
}
/// <summary>Leaves the recovery-code screen, once the user says they have it.</summary>
[RelayCommand]
private void ConfirmRecoveryCode()
{
if (!RecoveryCodeWrittenDown)
{
StatusMessage = "Confirm you have written the recovery code down first.";
return;
}
// Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view
// model for the rest of the session would undo that.
RecoveryCode = null;
State = ShellState.Locked;
StatusMessage = "Unlock with the passphrase you just chose.";
}
/// <summary>Opens the vault.</summary>
[RelayCommand]
private async Task UnlockAsync(CancellationToken cancellationToken)
{
if (Passphrase.Length == 0)
{
StatusMessage = "Enter your keychain passphrase.";
return;
}
await RunAsync(
"Unlocking…",
async () =>
{
var entered = Passphrase;
// Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU
// and would otherwise freeze the window mid-unlock.
var outcome = await Task
.Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
return;
}
Passphrase = string.Empty;
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>What the status line says while the platform's own consent dialogue is up.</summary>
/// <remarks>
/// A runtime check rather than a constructor parameter, unlike the device name beside it, and the
/// difference between the two is why: a device name is a fact about one handset that only the head can
/// read, whereas which dialogue appears is a fact about the platform this assembly is running on, and a
/// value every Android head would pass identically is a parameter that only makes the heads longer.
/// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while
/// deciding whether the prompt in front of them is the one this application asked for.
/// </remarks>
private static string GestureWait =>
OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…";
/// <summary>Opens the vault with this machine's device key instead of the passphrase.</summary>
/// <remarks>
/// No <c>Task.Run</c>, unlike the passphrase path: there is no Argon2 to pay for here, and the work that
/// does block is a Windows consent dialog which belongs on the UI thread anyway.
/// </remarks>
[RelayCommand]
private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken)
{
await RunAsync(
GestureWait,
async () =>
{
var outcome = await Opener()
.UnlockWithDeviceAsync(deviceKeys, cancellationToken)
.ConfigureAwait(true);
StatusMessage = outcome.Message;
if (!outcome.IsUnlocked)
{
// A declined gesture leaves the passphrase box exactly where it was, which is the whole
// fallback: the user types instead. Nothing about the screen changes but the message.
return;
}
await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true);
}).ConfigureAwait(true);
}
/// <summary>
/// Registers this machine so a later launch can unlock with a gesture.
/// </summary>
/// <remarks>
/// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be
/// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an
/// open session can seal the bundle.
/// </remarks>
[RelayCommand]
private async Task RegisterDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault || connection is null)
{
StatusMessage = "Sign in first: registering this machine has to reach the server.";
return;
}
await RunAsync(
GestureWait,
async () =>
{
var registered = await vault.Session
.RegisterDeviceAsync(connection.Account, deviceKeys, deviceName, cancellationToken)
.ConfigureAwait(true);
if (!registered)
{
StatusMessage = "This machine has nowhere to keep a device key.";
return;
}
CanRegisterDevice = false;
CanForgetDevice = true;
// Named rather than "this machine", because the account lists several and this is the
// sentence that says which one just gained the ability to open the vault.
StatusMessage = $"'{deviceName}' can now unlock without your passphrase.";
}).ConfigureAwait(true);
}
/// <summary>
/// Withdraws this machine's device key, here and on the account.
/// </summary>
/// <remarks>
/// <para>
/// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one
/// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the
/// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded.
/// </para>
/// <para>
/// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so
/// the useful half always happens — the account being told is the half that can be out of reach.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ForgetDeviceAsync(CancellationToken cancellationToken)
{
if (Vault is not { } vault)
{
return;
}
await RunAsync(
GestureWait,
async () =>
{
var revocation = await vault.Session
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
.ConfigureAwait(true);
CanForgetDevice = false;
// Not re-offered here even though it is now true, because registering probes the TPM and
// this is not the moment to do it: somebody who has just withdrawn a device is not about to
// add one back, and the offer reappears on the next unlock.
StatusMessage = revocation switch
{
DeviceRevocation.Complete =>
"This machine no longer unlocks without your passphrase, and the account no longer "
+ "lists it.",
DeviceRevocation.LocalOnly =>
"This machine no longer unlocks without your passphrase. You are offline, so the "
+ "account still lists it — sign in and withdraw it again to finish.",
_ => "There was no device key on this machine.",
};
}).ConfigureAwait(true);
}
/// <summary>
/// Takes ownership of a freshly opened session, whichever door opened it.
/// </summary>
/// <remarks>
/// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and
/// a second copy would be a second chance to get it wrong.
/// </remarks>
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
{
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
// Before the vault view model, because that is what reads it — and read at all rather than defaulted
// to "everything shown", because a vault somebody set aside last week should still be set aside.
visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true);
Vault = new VaultViewModel(
session,
workspace,
knownHosts,
() => connection,
ReconnectAsync,
copyToClipboard,
connectionLog,
visibility);
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 the switches are built from the vaults the session admitted and the
// keyring is filled during it — before, and a machine with a team vault would come up with one
// switch until something else rebuilt them.
RebuildVaultToggles();
// After the load, because what the transfers screen takes from the vault is the host list and an
// empty one would leave its picker blank until the next unlock.
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
// running while the vault was closed, so some of these hosts are connected before their rows are a
// second old.
RefreshConnectedHosts();
// After the first load, so the list is on screen before anything talks to a server. The loop is
// started from the UI thread deliberately: every pass resumes here, which is what keeps the
// observable collections single-threaded.
//
// Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a
// server, and that is where a remembered sign-in is resumed. Nothing here has to know whether
// this unlock followed a sign-in or a cold launch on a train.
//
// Deliberately not awaited here, and not done before this point either. Resuming is a discovery
// call and a token exchange — a network round trip, and on an unreachable network a slow one —
// and unlocking must never wait on one. Everything the unlock screen promises about working
// offline stops being true the moment the passphrase leads to a socket. So the vault opens, and
// the titlebar says OFFLINE until the round trip this starts has an answer.
Vault.StartAutoSync();
}
/// <summary>
/// Points the two process-lifetime stores at the session that has just opened.
/// </summary>
/// <remarks>
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
/// called by the workspace — so both are attached here rather than constructed per session, and both are
/// released together on every path that closes a vault.
/// </remarks>
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
{
// Before the vault view model, so the first connection after an unlock already knows which host keys
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
// keeps it off the SSH handshake thread.
try
{
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
}
catch
{
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
// session is vault keys left in memory for the life of the process, which is precisely what
// unlocking must be able to undo.
await session.DisposeAsync().ConfigureAwait(true);
throw;
}
// The actor is the account that unlocked, which is what makes this an audit record rather than a
// list of events with nobody attached to them.
connectionLog.Open(session, session.Profile.UserId);
}
/// <summary>
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
/// </summary>
/// <remarks>
/// <para>
/// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design:
/// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one,
/// and a machine that comes back from a closed lid is online again within a minute of having a
/// network — with nothing pressed and no browser opened.
/// </para>
/// <para>
/// <b>Resuming needs an unlocked vault, and that is deliberate rather than incidental.</b> The
/// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after
/// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account
/// any more than it can read the hosts.
/// </para>
/// <para>
/// Every failure returns null and stays quiet, with one exception: a provider that <em>refuses</em> the
/// token is not a transient condition and will refuse it again once a minute forever, so that one is
/// said out loud and the token is dropped.
/// </para>
/// </remarks>
private async Task<IVaultServer?> ReconnectAsync(CancellationToken cancellationToken)
{
if (connection is { } held)
{
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
return held;
}
if (resume is not { } handler || resuming || Vault is not { } vault)
{
return null;
}
resuming = true;
try
{
return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true);
}
finally
{
resuming = false;
}
}
/// <remarks>
/// Split from <see cref="ReconnectAsync"/> only so the guard, the flag and the attempt are three short
/// things rather than one long one. Everything about why this behaves as it does is up there.
/// </remarks>
private async Task<IVaultServer?> ResumeAsync(
VaultViewModel vault,
ResumeHandler handler,
CancellationToken cancellationToken)
{
try
{
var token = await vault.Session
.ReadRememberedSignInAsync(cancellationToken)
.ConfigureAwait(true);
if (token is null
|| !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server))
{
return null;
}
var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true);
connection = resumed;
rememberedToken = token;
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
// The refresh that just happened may have rotated the token, and the rotated one is the only
// one the next launch can use.
await RememberSignInAsync(cancellationToken).ConfigureAwait(true);
return resumed;
}
catch (OidcException exception)
{
// The provider answered and said no: the session was revoked, or the token was rotated and
// this machine kept the old one. Retrying costs a round trip a minute and can only ever get
// the same answer, so the token goes and the user is told the one thing that fixes it.
await ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} "
+ "Sign in again from Preferences to start syncing.");
return null;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Everything else is a machine with no network, a server that is down, or a vault that was
// locked mid-attempt — all of which are ordinary and all of which resolve themselves. The
// titlebar already says OFFLINE; a socket error once a minute would say nothing more.
return null;
}
}
/// <summary>
/// Writes the connection's current refresh token into the vault, if it has changed.
/// </summary>
/// <remarks>
/// Called on every pass rather than driven by an event, because providers rotate the token inside a
/// refresh that happens on whatever thread an API call was made from — and a value read once a minute
/// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on
/// the next launch and nothing else, which is not worth interrupting anybody over.
/// </remarks>
private async Task RememberSignInAsync(CancellationToken cancellationToken)
{
if (connection?.RefreshToken is not { } token
|| Vault is not { } vault
|| string.Equals(token, rememberedToken, StringComparison.Ordinal))
{
return;
}
try
{
await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true);
rememberedToken = token;
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Left unremembered. The application is signed in for this run either way.
}
}
/// <summary>Drops the remembered sign-in, so nothing tries to resume it again.</summary>
private async Task ForgetSignInAsync(CancellationToken cancellationToken)
{
rememberedToken = null;
if (Vault is not { } vault)
{
return;
}
try
{
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// A cache that will not take the deletion is one the next launch will fail to resume from and
// then delete itself. Nothing here is worth a message.
}
}
/// <summary>
/// Says something wherever the user is looking.
/// </summary>
/// <remarks>
/// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so
/// a message about the connection, which is the shell's business but only interesting while somebody
/// is using an open vault, has to go to both or it is invisible half the time.
/// </remarks>
private void Announce(string message)
{
StatusMessage = message;
if (Vault is { } vault)
{
vault.Status = message;
}
}
/// <summary>
/// Closes the vault and forgets every key it held. Open shells keep running.
/// </summary>
/// <remarks>
/// <para>
/// <b>Lock is a vault operation, and deliberately not a disconnect.</b> The reason a person locks is
/// that they are walking away from the machine, which is exactly the moment a long upgrade, build or
/// transfer is most likely to be in flight — so killing every shell would make Lock a button that
/// destroys work, and the predictable response is to stop using it and leave the vault open instead.
/// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that
/// terminated a running job would be worse than the exposure it removes.
/// </para>
/// <para>
/// <b>What "locked" therefore describes.</b> Disposing the vault zeroes the identity keys, the vault
/// keys and the cache key, so nothing on disk can be read without the passphrase again. It says
/// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time
/// needs no vault key to keep running, and the credential it used was already spent. Locking cannot
/// retroactively un-authorise a session any more than revocation can — the same honest limit the
/// README records for a removed team member. So a locked DodoSSH still holds open, authenticated
/// channels, and <see cref="LiveSessionCount"/> is shown on the unlock screen rather than left to be
/// inferred from a terminal that the lock screen hides.
/// </para>
/// <para>
/// The count is a snapshot taken here. While locked it can only fall — opening a session needs the
/// vault — so a stale value over-reports and never under-reports, which is the safe direction for a
/// warning of this kind.
/// </para>
/// </remarks>
[RelayCommand]
private async Task LockAsync()
{
// First, and before the session it read from goes: a synchronisation pass may be in flight, and it
// ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins
// reappearing behind a lock screen.
knownHosts.Close();
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
// about to be disposed. Tickets already open keep the repository they were opened against, so a
// shell still running closes out into the vault it was actually made in.
connectionLog.Close();
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
// holding references to them. What it does not give up is its connection or its queue — a transfer
// in flight is exactly the work this method exists not to destroy.
transfers.Detach();
if (Vault is { } open)
{
Vault = null;
await open.DisposeAsync().ConfigureAwait(true);
}
// With the session, because it was read out of that session's cache. Keeping it would be a set of
// switches describing vaults nothing can open, offered on a lock screen.
visibility = null;
RebuildVaultToggles();
LiveSessionCount = workspace.LiveSessionCount;
// A confirmation armed on the preferences screen must not survive onto the unlock screen, where
// the same card is offered with a warning it can no longer count.
IsConfirmingSignOut = false;
State = ShellState.Locked;
StatusMessage = "Locked.";
}
// ---- Signing out ----
/// <summary>Whether the sign-out confirmation is showing.</summary>
/// <remarks>
/// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one
/// action in the application that destroys something a user cannot get back from here — an unpushed
/// change — and it has to be able to say what is about to go before it goes.
/// </remarks>
[ObservableProperty]
private bool isConfirmingSignOut;
/// <summary>
/// What signing out costs, on this machine, right now.
/// </summary>
/// <remarks>
/// <para>
/// The count is the part worth having. Everything else in the vault is on the server and comes back
/// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and
/// "your changes will be lost" without a number leaves somebody guessing whether it means theirs.
/// </para>
/// <para>
/// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets
/// the honest form of the same warning rather than a zero it has not earned.
/// </para>
/// </remarks>
internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch
{
(false, _) =>
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
+ "counted from here, because the keychain is locked.",
(true, 0) =>
"Everything this machine has changed has reached the server, so nothing will be lost.",
(true, 1) =>
"1 change has not reached the server yet and will be lost. Sync first to keep it.",
(true, var pending) =>
$"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.",
};
/// <summary>Asks whether the user means it.</summary>
[RelayCommand]
private void SignOut()
{
// Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out
// does not close a shell any more than locking does, and a screen that sends somebody back to
// "connect to your server" while their upgrade is still running should say so.
LiveSessionCount = workspace.LiveSessionCount;
OnPropertyChanged(nameof(SignOutWarning));
IsConfirmingSignOut = true;
}
/// <summary>Thinks better of it.</summary>
[RelayCommand]
private void CancelSignOut() => IsConfirmingSignOut = false;
/// <summary>
/// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever
/// the window is showing.
/// </summary>
/// <remarks>
/// <see cref="SignOut"/> only arms <see cref="IsConfirmingSignOut"/>; the confirmation itself is drawn
/// inline on the Account settings page while the vault is unlocked — see <c>SettingsAccountPage.axaml</c>
/// — and nowhere else, because <c>MainWindow.axaml</c>'s own copy of <c>SignOutCard</c> is inside the
/// setup half of the window, which is hidden the whole time this one is reachable. Calling
/// <see cref="SignOut"/> straight from the popover on, say, the hosts screen would arm the flag with
/// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is
/// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see
/// <see cref="ShowNewVault"/>.
/// <para>
/// v5c: went to <c>ShellScreen.Preferences</c> before this wave, because that bare screen was the only
/// place the confirmation card could be seen. It moved to the Account settings page with the card — see
/// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row
/// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home.
/// </para>
/// </remarks>
[RelayCommand]
private void SignOutFromPopover()
{
EnterSettings(SettingsPage.Account);
SignOut();
}
/// <summary>
/// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything.
/// </summary>
/// <remarks>
/// <para>
/// <b>What this does and does not destroy.</b> It empties the local cache — the profile, the wrapped
/// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key
/// here and on the account. The vault itself is on the server and is untouched, which is what makes
/// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset
/// this machine and sign in again, and the only thing they lose is what this machine had not yet sent.
/// </para>
/// <para>
/// <b>Ordered so that a failure cannot leave a half-signed-out machine.</b> The device is withdrawn
/// while there is still a session and a connection to withdraw it through; the vault is closed before
/// the cache under it is emptied; and the cache is emptied last, because it is the step that makes
/// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot.
/// </para>
/// <para>
/// It does not end the session at the identity provider — there is no back channel to it from here,
/// and pretending otherwise would be the sort of claim this project writes down instead of implying.
/// The refresh token this machine held is dropped and never used again; the provider's own session
/// outlives it, which is what the preferences screen says out loud.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmSignOutAsync(CancellationToken cancellationToken)
{
await RunAsync(
"Signing out…",
async () =>
{
IsConfirmingSignOut = false;
await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true);
// As Lock does, and before the session it reads from goes.
knownHosts.Close();
connectionLog.Close();
// The same detach locking does, and the same reasoning carried one step further: the host
// rows go because the vault behind them is about to be disposed, and the session and its
// queue stay because a transfer in flight is somebody's work. Signing out is the strongest
// thing this application does to itself and it still does not destroy that, for exactly the
// reason it does not close a shell — quitting DodoSSH is what ends both.
transfers.Detach();
if (Vault is { } open)
{
Vault = null;
await open.DisposeAsync().ConfigureAwait(true);
}
// With the session, as on lock — and here the cache it came from is about to be deleted
// outright, so the switches would be describing vaults this machine no longer has a row for.
visibility = null;
RebuildVaultToggles();
connection?.Dispose();
connection = null;
rememberedToken = null;
await caches.ResetAsync(cancellationToken).ConfigureAwait(true);
LiveSessionCount = workspace.LiveSessionCount;
AccountName = null;
Email = null;
Issuer = null;
Passphrase = string.Empty;
ConfirmPassphrase = string.Empty;
RecoveryCode = null;
RecoveryCodeWrittenDown = false;
CanUnlockWithDevice = false;
CanRegisterDevice = false;
CanForgetDevice = false;
State = ShellState.NeedsServer;
OnPropertyChanged(nameof(IsOnline));
RaiseSyncState();
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
+ "keychain itself is untouched. Sign in to set this machine up again.";
}).ConfigureAwait(true);
}
/// <remarks>
/// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the
/// half that stops the account listing a machine whose key is about to be deleted — and a server that
/// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen
/// they asked to leave. The half that decides whether this machine can let itself in happens anyway,
/// because the profile holding the wrap is emptied a moment later.
/// </remarks>
private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken)
{
try
{
if (Vault is { } vault)
{
await vault.Session
.ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken)
.ConfigureAwait(true);
await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true);
return;
}
await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Nothing to report: the wipe below is what signing out actually is.
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (disposed)
{
return;
}
disposed = true;
workspace.SessionEnded -= OnWorkspaceSessionEnded;
workspace.FontSizeStepRequested -= OnFontSizeStepRequested;
workspace.RendererReattached -= OnRendererReattached;
transfers.PropertyChanged -= OnTransfersPropertyChanged;
// Stopped here rather than left to the process exiting with it: the loop holds no vault key and
// nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this
// object would otherwise leak.
StopLastConnectedTick();
// The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather
// than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends.
await sessionElapsedTick.CancelAsync().ConfigureAwait(false);
sessionElapsedTick.Dispose();
// Early, and it only cancels a timer and waits for a pass in flight. It has to come before the
// vault because the restart path disposes this whole object and then applies the update — so a
// check still running would be writing into a view model the process is about to replace.
await updateScreen.DisposeAsync().ConfigureAwait(false);
knownHosts.Close();
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
workspace.ConnectionLog = null;
await connectionLog.DisposeAsync().ConfigureAwait(false);
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
// that reached it.
await transfers.DisposeAsync().ConfigureAwait(false);
if (Vault is { } open)
{
await open.DisposeAsync().ConfigureAwait(false);
}
connection?.Dispose();
}
/// <remarks>
/// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no
/// server-side reset exists. A length floor is a crude check and still the one that matters most.
/// </remarks>
private bool ValidateNewPassphrase()
{
if (Passphrase.Length < 12)
{
StatusMessage = "Use a passphrase of at least 12 characters.";
return false;
}
if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal))
{
StatusMessage = "The two passphrases do not match.";
return false;
}
return true;
}
/// <remarks>
/// Sync limits come from the server when there is one, so a batch is never larger than this
/// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway.
/// </remarks>
private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions);
private AccountProvisioner? Provisioner() =>
connection is null
? null
: new AccountProvisioner(
connection.Account, connection.KeyBinding, caches, clock, passphraseProfile);
/// <remarks>
/// Every command funnels through here so the busy flag and the failure message are handled once. A
/// command that forgot either would leave the window permanently disabled or silently doing nothing.
/// </remarks>
/// <param name="busyMessage">Shown while the work runs.</param>
/// <param name="work">The work.</param>
/// <param name="explain">
/// Turns a failure into something a user can act on. Optional, because most failures here already
/// carry their own explanation; the ones that do not are the ones crossing into another process's
/// vocabulary, where the exception describes a symptom and not the mistake.
/// </param>
private async Task RunAsync(
string busyMessage,
Func<Task> work,
Func<Exception, string>? explain = null)
{
if (IsBusy)
{
return;
}
IsBusy = true;
StatusMessage = busyMessage;
try
{
await work().ConfigureAwait(true);
}
catch (OperationCanceledException)
{
StatusMessage = "Cancelled.";
}
catch (Exception exception)
{
StatusMessage = explain?.Invoke(exception) ?? exception.Message;
}
finally
{
IsBusy = false;
}
}
/// <remarks>
/// <para>
/// Two cases earn a translation rather than the exception's own words, and both are cases where the
/// exception names a symptom belonging to somebody else's process.
/// </para>
/// <para>
/// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be
/// established", which sends people looking for a certificate problem. The scheme is the mistake, and
/// the development stack serves HTTP, so this is the first thing a new user will hit.
/// </para>
/// <para>
/// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already
/// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The
/// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having
/// failed, and the search starts in the wrong place. Naming which server, and saying that its own
/// logs hold the reason, is the whole content of the fix; the client cannot know more than that.
/// </para>
/// </remarks>
private static string ExplainSignInFailure(Exception exception, Uri server)
{
if (exception is DodoSshApiException api && (int)api.StatusCode >= 500)
{
return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering "
+ $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than "
+ "anything to fix here — its own logs carry the reason.";
}
var secureChannelFailed = exception is HttpRequestException
&& exception.GetBaseException() is AuthenticationException;
if (secureChannelFailed && server.Scheme is "https")
{
var plain = new UriBuilder(server) { Scheme = "http" }.Uri;
return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a "
+ $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}.";
}
return exception.Message;
}
/// <remarks>
/// One place for the subscription, so unlocking, locking and disposing all route through it rather
/// than each remembering to detach.
/// </remarks>
partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue)
{
if (oldValue is not null)
{
oldValue.PropertyChanged -= OnVaultPropertyChanged;
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
// The three connection events are kept while an attempt is still in flight, and that is not an
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
// what holds both, and it outlives every vault — so a connection started just before a lock still
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
// tabs are this object's rather than the vault's. Detaching here would strand that tab on
// "connecting…" for ever and leave the session it eventually opened with nothing in the window
// naming it, and so no way to close it. The subscription dies with the vault once the attempt
// resolves: the vault holds the handler, not the other way round.
oldValue.VaultsChanged -= OnVaultsAdmitted;
oldValue.FilesRequested -= OnVaultFilesRequested;
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;
// A vault somebody shared arrives on a synchronisation pass rather than through anything the
// user pressed here — so the menu that lists them is rebuilt from the event rather than at the
// end of a command, which is the one place a newly admitted vault has no command to be at the
// end of.
newValue.VaultsChanged += OnVaultsAdmitted;
// ◆ The phone's action bar asking for a host's files rather than a shell on it. An event because
// the screen it leads to is this object's and the transfers view model behind it is a sibling of
// the vault rather than a part of it; which machine is the vault's business, because a host is a
// decrypted item. See OnVaultFilesRequested.
newValue.FilesRequested += OnVaultFilesRequested;
// 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();
// v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below
// — KnownHostsViewModel has no business knowing ShellScreen exists.
KnownHostsScreen = newValue is null
? null
: new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain));
// v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page
// beneath it without ImportViewModel knowing anything about settings mode — the same reasoning
// VaultViewModel's copyToClipboard delegate is built on.
ImportScreen = newValue is null
? null
: new ImportViewModel(newValue, new SshConfigLocator(), CloseImport);
SnippetsScreen?.Detach();
SnippetsScreen = newValue is null
? null
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
// Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name
// and the account and endpoint dialled — and a lock that left them on the shell would be a list of
// where somebody works, still on screen and still readable, after the thing that decrypted it was
// disposed and every key it held was zeroed.
RecentConnections.Clear();
OnPropertyChanged(nameof(HasRecentConnections));
RaiseSyncState();
// The host-key decision is a property of the vault, so swapping the vault out is one of the ways it
// stops being on screen — locking with a question still up is the case, and it is reachable: a
// handshake that stopped on an unknown key does not hold the window, so the lock button is live
// behind the card asking about it.
RaiseTerminalState();
}
/// <remarks>
/// <para>
/// Named properties are 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 derived properties on every notification a
/// busy vault produces would repaint the titlebar on every keystroke in an editor.
/// </para>
/// <para>
/// The two host-key flags are here because the decision the vault raises collapses this shell's WebView —
/// see <see cref="IsHostKeyDecisionShowing"/>. It is a property rather than an event because it is a
/// state the vault is in and not a moment: it is entered by a refused handshake, left by either answer,
/// and cleared outright by forgetting a pin from a host's editor, which is a third caller that has
/// nothing to do with connecting.
/// </para>
/// </remarks>
private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal)
|| string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal))
{
RaiseSyncState();
}
if (string.Equals(
e.PropertyName, nameof(VaultViewModel.HasPendingHostKey), StringComparison.Ordinal)
|| string.Equals(
e.PropertyName, nameof(VaultViewModel.HasHostKeyMismatch), StringComparison.Ordinal))
{
RaiseTerminalState();
}
}
/// <remarks>
/// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts
/// with neither, and this is the one place both know a rebuild just happened. No log read here — see
/// <see cref="VaultViewModel.RestringLastConnected"/> — only the timestamps already on hand, written
/// onto whichever row objects exist now.
/// </remarks>
private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RefreshConnectedHosts();
Vault?.RestringLastConnected();
}
private void RaiseSyncState()
{
OnPropertyChanged(nameof(IsFullySynced));
OnPropertyChanged(nameof(SyncLabel));
// The same fact from a third direction: what signing out would cost is the outbox depth, and a
// confirmation card left showing a count from before the last pass would be quoting a number that
// has since been sent.
OnPropertyChanged(nameof(SignOutWarning));
}
/// <summary>
/// Puts a tab in the strip for a connection that has only just been asked for.
/// </summary>
/// <remarks>
/// <para>
/// This is what stops connecting looking like the application having stopped. The tab appears in the same
/// turn as the click, carrying its own status, and the window switches to it — so a handshake against a
/// machine that is asleep is a card that says which machine, rather than a status line under a window
/// that does nothing for thirty seconds.
/// </para>
/// <para>
/// Kept by attempt id rather than by label: several connections can be in flight now that one does not
/// hold the vault, and two of them can perfectly well be to the same host.
/// </para>
/// </remarks>
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
{
var tab = new TerminalTabViewModel(e.Label, e.Address);
attempts[e.AttemptId] = tab;
AdoptTab(tab);
}
/// <summary>
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
/// </summary>
/// <remarks>
/// The vault itself is already on every screen by the time this runs — the pass that admitted it
/// reloaded the lists — so this is only the one thing the vault view model has no way to reach: the
/// switches in the tab strip, which are built here from the session's vault list.
/// </remarks>
private void OnVaultsAdmitted(object? sender, EventArgs e) => RebuildVaultToggles();
/// <remarks>
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
/// them and nothing more.
/// </remarks>
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
// No tab was opened for this attempt, which means the user closed the connecting tab while the
// handshake was still running. The session is real and has to be adopted rather than dropped:
// dropping it would leave a shell running with nothing in the window naming it.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{
StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
IdentityLabel = e.IdentityLabel,
};
AdoptTab(adopted);
RefreshConnectedHosts();
return;
}
tab.Opened(e.SessionId);
// From this moment, not from when the tab first appeared — connecting is not open, and the session
// shell's elapsed timer is about a shell that is actually running.
tab.StartedAt = clock.GetUtcNow();
tab.Cipher = NullIfEmpty(e.Cipher);
tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm);
tab.IdentityLabel = e.IdentityLabel;
// The pane exists from this moment, so what the rectangle should hold has changed — the card goes and
// the WebView comes back. Only for the tab being looked at, which is what these flags already ask.
RaiseTerminalState();
// Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was
// no pane to name until this line.
Activate(tab);
RefreshConnectedHosts();
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Answers a connection that did not become a session.
/// </summary>
/// <remarks>
/// <para>
/// 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: nothing has gone wrong and there is nothing to report, so the tab goes and the
/// decision takes its place until it is answered.
/// </para>
/// <para>
/// <b>It used to move the window, and that was the wrong half of the problem to solve.</b> The prompt was
/// a banner on the hosts screen, so this navigated there — <c>Screen = Hosts</c>, <c>Surface = Page</c> —
/// on the reasoning that a connection can be started from the palette on any screen and a question behind
/// the screen somebody is looking at is a question nobody can answer. True, and answered the wrong way
/// round: what it did to the one connection that has no host at all was to judge a machine typed into the
/// phone's connect box on a list it is deliberately not on, after taking the box away.
/// </para>
/// <para>
/// Both heads now draw the decision over whatever is showing, so nothing has to move and the palette's
/// case is covered without a special one. What is left here is the tab, and the surface it was on stays as
/// it is — for the phone that surface <em>is</em> the connect box, and it is what the user comes back to
/// whichever way they answer.
/// </para>
/// </remarks>
private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e)
{
if (!attempts.Remove(e.AttemptId, out var tab))
{
return;
}
if (!e.IsAwaitingAnAnswer)
{
tab.Failed(e.Reason);
RaiseTerminalState();
return;
}
var index = Tabs.IndexOf(tab);
Tabs.Remove(tab);
RaiseTabState();
if (ReferenceEquals(SelectedTab, tab))
{
// The neighbour, preferring the one on the left, exactly as closing a tab by hand does.
SelectedTab = Tabs.Count == 0
? null
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
}
// Nothing else. The decision is drawn over the surface the user is on rather than on a screen they
// have to be taken to — see the remark — and the neighbour selected above is what that surface shows
// once the question is answered. Its WebView is collapsed while the card is up, which is the one
// thing a card in that rectangle cannot do for itself; see IsHostKeyDecisionShowing.
RaiseTerminalState();
}
/// <summary>
/// Takes a tab into the strip and shows it.
/// </summary>
/// <remarks>
/// One method rather than one per way of opening a session, so the order of these steps is decided once.
/// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs
/// against a strip that already shows what it is about.
/// </remarks>
private void AdoptTab(TerminalTabViewModel tab)
{
Tabs.Add(tab);
RaiseTabState();
// Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab
// that has one. A tab that is still connecting has none, and selecting it shows the card instead.
SelectedTab = tab;
// The surface, but deliberately not the screen. A session opened from the files screen shows its
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
// clicking away comes back to the transfer that is presumably still running.
Surface = ShellSurface.Terminal;
if (tab.HasSession)
{
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
}
}
/// <remarks>
/// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the
/// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the
/// renderer. See <see cref="Activate"/> for why the last of those is not awaited.
/// </remarks>
partial void OnSelectedTabChanged(TerminalTabViewModel? value)
{
foreach (var tab in Tabs)
{
tab.IsSelected = ReferenceEquals(tab, value);
}
// Which of the two things can be in the terminal's rectangle depends on the selected tab having a
// session, so moving the selection is one of the ways that answer changes. It also repaints the
// strip's active mark, which follows the selection and the surface together.
RaiseTerminalState();
RefreshConnectedHosts();
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
// be a screen keeping the shell alive.
SnippetsScreen?.TargetChanged();
if (value is not null)
{
Activate(value);
}
}
/// <summary>Tells the renderer which pane to show.</summary>
/// <remarks>
/// <para>
/// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a
/// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs
/// one wrong pane until the next click; blocking the setter would cost the tab strip.
/// </para>
/// <para>
/// The workspace's own token is not available here, so this passes none. The send is a single frame on
/// an already-open socket and returns immediately when there is no renderer.
/// </para>
/// <para>
/// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer
/// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until
/// the handshake finishes.
/// </para>
/// </remarks>
private void Activate(TerminalTabViewModel tab)
{
if (!tab.HasSession)
{
return;
}
_ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask();
}
/// <summary>Which terminal a snippet would go into right now.</summary>
/// <remarks>
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
/// say, the most recently opened would send a command somewhere the user is not looking.
/// </remarks>
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
/// <remarks>
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
/// </remarks>
private IReadOnlyList<LiveConnection> LiveConnections() =>
[
.. connectionLog.Open().Select(open => new LiveConnection(
open.HostLabel, open.Address, open.StartedAt, deviceName)),
];
private InsertTarget CurrentInsertTarget() =>
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
/// <remarks>
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
/// would answer only one of those.
/// </remarks>
[RelayCommand]
private void SelectTab(TerminalTabViewModel tab)
{
SelectedTab = tab;
Surface = ShellSurface.Terminal;
}
/// <summary>
/// Marks a tab dead when its shell ends on its own.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Also where a session ending re-reads the connection log</b>, per decision 4: the host just closed
/// is the one whose ago-text is about to stop being suppressed by <see cref="HostRowViewModel.IsConnected"/>,
/// and it deserves "just now" rather than whatever it last said. <see cref="ConnectionRecorder.Closed"/>
/// runs before this event does, but its own write is queued onto a background task rather than made
/// inline — see its remarks — so a read landing before that write drains shows the previous entry
/// instead. It self-heals on the next activation or the next session end, which is judged an acceptable
/// gap rather than worth blocking this handler on the recorder's queue.
/// </para>
/// </remarks>
private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) =>
Dispatcher.UIThread.Post(() =>
{
if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab)
{
tab.IsLive = false;
}
RefreshConnectedHosts();
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
});
/// <summary>
/// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
/// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one
/// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call
/// sites for the list of moments that counts as.
/// </para>
/// </remarks>
private void RefreshConnectedHosts()
{
if (Vault is not { } vault)
{
ActiveTabPinnedPaths.Clear();
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
RaiseSessionState();
return;
}
HostRowViewModel? connectedActiveHost = null;
foreach (var host in vault.Hosts)
{
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
// The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
{
connectedActiveHost = host;
}
}
ActiveTabPinnedPaths.Clear();
if (connectedActiveHost is not null)
{
foreach (var path in connectedActiveHost.Host.PinnedPaths)
{
ActiveTabPinnedPaths.Add(path);
}
}
OnPropertyChanged(nameof(HasActiveTabPinnedPaths));
RaiseSessionState();
}
/// <summary>
/// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section.
/// </summary>
/// <remarks>
/// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is
/// not connected — see <see cref="RefreshConnectedHosts"/>, which is the one place this is filled. Kept
/// under this name and this shape across v5b, which moved its one reader from a chip strip above the
/// terminal to the session sidebar beside it — the fact and the command that acts on it did not change,
/// only where they are drawn.
/// </remarks>
internal ObservableCollection<string> ActiveTabPinnedPaths { get; } = [];
/// <summary>Whether the active tab's host has anything for QUICK ACCESS to draw.</summary>
internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0;
/// <summary>
/// Whether the v5b session sidebar draws at all.
/// </summary>
/// <remarks>
/// <para>
/// Wider than the old pin strip's own gate, which was <c>IsTerminalSurface &amp;&amp; HasActiveTabPinnedPaths</c>
/// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own
/// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned
/// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus":
/// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's
/// own "hides when no session is active".
/// </para>
/// <para>
/// The SFTP half reads <see cref="TransfersViewModel.IsConnected"/> rather than <see cref="SelectedTab"/>,
/// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on
/// <see cref="ActiveTabPinnedPaths"/>. That is a real seam: browsing a host on SFTP without ever having
/// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows
/// come from the tab list rather than from whichever host SFTP is connected to. Reusing
/// <c>OpenPinnedPathCommand</c> unchanged, as asked, is what this trades for a second pins source.
/// </para>
/// </remarks>
internal bool ShowsQuickAccessSidebar =>
(IsTerminalSurface && SelectedTab is not null)
|| (IsTransfersShowing && Transfers.IsConnected);
/// <summary>
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
/// </summary>
/// <remarks>
/// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click.
/// </remarks>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
await GoToHostFilesAsync(host, path).ConfigureAwait(true);
}
// ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ----
/// <summary>
/// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row.
/// </summary>
/// <remarks>
/// The same shape as <see cref="ShowNewBucket"/>: land on the screen the new item belongs to, then run
/// that screen's own "start one" command, rather than opening the editor from here and hoping the screen
/// underneath it agrees what it is editing.
/// </remarks>
[RelayCommand]
private void AddSnippetFromSidebar()
{
ShowScreen(ShellScreen.Snippets);
SnippetsScreen?.NewCommand.Execute(null);
}
/// <summary>
/// Types a sidebar SNIPS row into the terminal the terminal surface is showing.
/// </summary>
/// <remarks>
/// <para>
/// Selects the row on <see cref="SnippetsScreen"/> and runs its own <c>InsertCommand</c> rather than
/// writing to the renderer directly — that command already carries the whole safety story the snippets
/// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one
/// that runs. A second insert path here would be a second place that story could go stale.
/// </para>
/// <para>
/// <see cref="SnippetsViewModel.CanInsert"/> reads <see cref="CurrentInsertTarget"/>, which is
/// <see cref="SelectedTab"/> — the sidebar only appears on the terminal surface with a tab selected, so
/// this is ordinarily available. It can still be a tab that is still connecting, which has no session to
/// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to
/// that one gap, the same as clicking a row with nothing selected would otherwise be.
/// </para>
/// </remarks>
[RelayCommand]
private async Task InsertSnippetAsync(SnippetRowViewModel snip)
{
if (SnippetsScreen is not { } screen || snip is null)
{
return;
}
screen.Selected = snip;
if (screen.CanInsert)
{
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
return;
}
ShowScreen(ShellScreen.Snippets);
}
/// <summary>
/// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row.
/// </summary>
/// <remarks>
/// The closest honest affordance rather than a new one: this application has no way to open the host
/// editor already scrolled to one card inside it, so what this does is what a person reaching for the same
/// goal from the hosts screen already does — select the host and press EDIT. <see cref="VaultViewModel.EditSelectedHostCommand"/>
/// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches;
/// see <c>App.axaml</c>'s <c>Border.section</c> remark for that column's own QUICK ACCESS heading.
/// </remarks>
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
ShowScreen(ShellScreen.Hosts);
vault.SelectedHost = host;
vault.EditSelectedHostCommand.Execute(null);
}
/// <summary>
/// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host.
/// </summary>
/// <remarks>
/// <para>
/// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application
/// has no such architecture, and building one is out of this wave's scope. What it has instead is
/// <see cref="GoToHostFilesAsync"/> — the same "Browse files" plumbing a pin click and the hosts screen's
/// own action already use — so a click on the SFTP tab row honestly does the one thing this application
/// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to
/// it and land the remote pane there.
/// </para>
/// <para>
/// <see cref="SelectedTab"/> is set here too, ahead of the navigation, which is what lets the SFTP tab
/// row mark its active tab with the same <see cref="TerminalTabViewModel.IsSelected"/> flag the terminal
/// row's own active mark already reads — see <c>SessionTabRow.axaml</c>. It is also what keeps the
/// sidebar's QUICK ACCESS in step: that list is keyed to <see cref="SelectedTab"/>, on both surfaces, so
/// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows.
/// </para>
/// </remarks>
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{
return;
}
SelectedTab = tab;
await GoToHostFilesAsync(host, null).ConfigureAwait(true);
}
/// <summary>
/// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open.
/// </summary>
/// <remarks>
/// The mirror of <see cref="SelectFilesHostAsync"/> and named in the notes as the other of the two
/// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction".
/// Goes through <see cref="VaultViewModel.ConnectCommand"/> exactly as the quick-connect palette's own
/// <see cref="ConnectToSearchResultAsync"/> does, rather than reusing an existing tab: SFTP's connection is
/// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new
/// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher.
/// </remarks>
[RelayCommand]
private async Task OpenTerminalForFilesHostAsync()
{
if (Vault is not { } vault || Transfers.SelectedHost is not { } row)
{
return;
}
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
}
/// <summary>
/// The account and endpoint the session shell's header and status bar are about right now, or null when
/// neither surface has one.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
/// </remarks>
internal string? SessionAddress => Surface switch
{
ShellSurface.Terminal => SelectedTab?.Address,
_ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null,
_ => null,
};
/// <summary>
/// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither
/// surface has a session — or when the one it has has nothing to report.
/// </summary>
/// <remarks>
/// One property reading whichever surface is showing, for the same reason <see cref="SessionAddress"/>
/// is: <c>SessionStatusBar.axaml</c> is the same markup on both surfaces. The terminal's is
/// <see cref="TerminalTabViewModel.Cipher"/>; SFTP's is <see cref="TransfersViewModel.ConnectedCipher"/>.
/// </remarks>
internal string? SessionCipher => Surface switch
{
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher),
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher),
_ => null,
};
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — printed as negotiated, not shortened.</summary>
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.HostKeyAlgorithm"/> or <see cref="TransfersViewModel.ConnectedHostKeyAlgorithm"/>.</remarks>
internal string? SessionHostKeyAlgorithm => Surface switch
{
ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm),
_ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm),
_ => null,
};
/// <summary>
/// The display name of the key or credential that authenticated, or null when a typed password did or
/// nothing is connected.
/// </summary>
/// <remarks>See <see cref="SessionCipher"/>; resolved the same way, off <see cref="TerminalTabViewModel.IdentityLabel"/> or <see cref="TransfersViewModel.ConnectedIdentityLabel"/>.</remarks>
internal string? SessionIdentityLabel => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IdentityLabel,
_ when IsTransfersShowing => Transfers.ConnectedIdentityLabel,
_ => null,
};
/// <summary>
/// The status bar's one run of text for the host key and the identity that authenticated — the design's
/// <c>ed25519 · acme-deploy-key</c> — or null while <see cref="SessionHostKeyAlgorithm"/> is.
/// </summary>
/// <remarks>
/// Composed here rather than in the view, so <c>SessionStatusBar.axaml</c> binds one <c>TextBlock</c> to
/// one string instead of assembling a separator between two bindings that can each be absent on their own.
/// The algorithm prints exactly as negotiated — <c>ssh-ed25519</c>, not the design's shortened
/// <c>ed25519</c> — because trimming it would be a cosmetic claim about a string this shell has no
/// business editing. A typed-password session has no item to name, so it shows the algorithm alone with
/// no <c>·</c> — there being nothing after the dot would be a punctuation mark standing in for the fact
/// that was never real.
/// </remarks>
internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm
? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm
: null;
/// <summary>Null for an empty string, unchanged otherwise.</summary>
/// <remarks>
/// <see cref="ISshConnection.Cipher"/> and <see cref="HostKeyPresentation.Algorithm"/> are non-nullable
/// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely
/// could not be read back (see <c>TerminalWorkspace.GetSessionFacts</c>) hands over <see cref="string.Empty"/>
/// rather than null, and the status bar's collapse bindings only know how to ask about null.
/// </remarks>
private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value;
/// <summary>Whether the session the header and status bar are describing is actually open.</summary>
/// <remarks>
/// Not the same question as <see cref="SessionAddress"/> being non-null on the terminal surface: a tab
/// that is still connecting has an address — it is what the connecting card names — but no live shell
/// behind it yet, and "CONNECTED" would be a claim <see cref="TerminalTabViewModel.IsLive"/> has not made.
/// </remarks>
internal bool IsSessionConnected => Surface switch
{
ShellSurface.Terminal => SelectedTab?.IsLive is true,
_ when IsTransfersShowing => Transfers.IsConnected,
_ => false,
};
/// <summary>
/// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed.
/// </summary>
/// <remarks>
/// Restrung on every state change worth reacting to at once — see <see cref="RaiseSessionState"/> — and
/// once a minute besides, by <see cref="RunSessionElapsedTickAsync"/>, for the case where nothing else
/// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung
/// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather
/// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale
/// between two ticks, exactly as "1 min ago" already is elsewhere in this shell.
/// </remarks>
internal string? SessionElapsedText
{
get
{
var startedAt = Surface switch
{
ShellSurface.Terminal => SelectedTab?.StartedAt,
_ when IsTransfersShowing => Transfers.ConnectedStartedAt,
_ => null,
};
if (startedAt is not { } started)
{
return null;
}
var elapsed = clock.GetUtcNow() - started;
if (elapsed < TimeSpan.Zero)
{
// The clock this ran on and the clock the session opened on can disagree by a hair when both
// are TimeProvider.System, which is close enough to "now" that a negative span is rounding
// rather than a session that has not started yet.
elapsed = TimeSpan.Zero;
}
return string.Create(
CultureInfo.InvariantCulture,
$"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}");
}
}
/// <summary>Re-reads the three facts the session shell's header, status bar and tab rows depend on.</summary>
/// <remarks>
/// Its own method rather than three more lines folded into <see cref="RaiseTerminalState"/> and
/// <see cref="RaiseSurfaceState"/>, because the SFTP tab row and header need it too and neither of those
/// two methods otherwise has anything to do with <see cref="TransfersViewModel"/>.
/// </remarks>
private void RaiseSessionState()
{
OnPropertyChanged(nameof(SessionAddress));
OnPropertyChanged(nameof(IsSessionConnected));
OnPropertyChanged(nameof(SessionElapsedText));
OnPropertyChanged(nameof(SessionCipher));
OnPropertyChanged(nameof(SessionHostKeyAlgorithm));
OnPropertyChanged(nameof(SessionIdentityLabel));
OnPropertyChanged(nameof(SessionIdentityText));
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
}
/// <remarks>
/// The narrow subscription <see cref="RaiseSessionState"/>'s own remark on the shell's constructor
/// promises: three properties this screen exposes, each already raised through <c>ObservableObject</c>,
/// picked out by name rather than repainting on every change <see cref="TransfersViewModel"/> makes —
/// the transfer queue's own rows tick several times a second while a download runs, and none of that is a
/// fact the header, the status bar or the SFTP tab row's active mark reads.
/// </remarks>
private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(TransfersViewModel.SelectedHost)
or nameof(TransfersViewModel.IsConnected)
or nameof(TransfersViewModel.Remote)
or nameof(TransfersViewModel.ConnectedTo)
or nameof(TransfersViewModel.ConnectedCipher)
or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm)
or nameof(TransfersViewModel.ConnectedIdentityLabel))
{
RaiseSessionState();
}
}
/// <summary>Restrings <see cref="SessionElapsedText"/> once a minute, for the shell's whole life.</summary>
/// <remarks>See the remark on <see cref="sessionElapsedTick"/> for why this loop is not gated on a screen.</remarks>
private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
OnPropertyChanged(nameof(SessionElapsedText));
}
}
catch (OperationCanceledException)
{
// The shell is closing; see DisposeAsync.
}
}
partial void OnLiveSessionCountChanged(int value)
{
OnPropertyChanged(nameof(HasLiveSessions));
OnPropertyChanged(nameof(LiveSessionSummary));
}
partial void OnStateChanged(ShellState value)
{
OnPropertyChanged(nameof(IsStarting));
OnPropertyChanged(nameof(IsNeedingServer));
OnPropertyChanged(nameof(IsNeedingEnrollment));
OnPropertyChanged(nameof(IsShowingRecoveryCode));
OnPropertyChanged(nameof(IsLocked));
OnPropertyChanged(nameof(IsAskingForThePassphrase));
OnPropertyChanged(nameof(IsUnlocked));
RaiseTerminalState();
OnPropertyChanged(nameof(SignOutWarning));
RaiseSyncState();
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
// the application would not be what "unlocked" looks like.
if (value is ShellState.Unlocked)
{
Screen = ShellScreen.Hosts;
Surface = ShellSurface.Page;
}
}
/// <remarks>
/// Every screen flag, on every change, for the same reason the vault column raises all four of its
/// section flags: a rail lighting the current screen and a body showing it are one fact read from two
/// directions, and raising only the one that became true leaves the old button lit.
/// </remarks>
partial void OnScreenChanged(ShellScreen value)
{
RaiseSurfaceState();
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
if (value is ShellScreen.Logs && LogsScreen is { } logs)
{
_ = logs.RefreshCommand.ExecuteAsync(null);
}
// Who is in each vault is read from the server rather than from the vault itself, 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.Vaults)
{
_ = vaults.LoadAsync(CancellationToken.None);
}
UpdateLastConnectedVisibility();
}
/// <summary>
/// Goes to the file screen with one of the two kinds of remote offered.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="OnScreenChanged"/> — which would have made every arrival at
/// <see cref="ShellScreen.Transfers"/> force the picker back to hosts, including the desktop's own nav
/// rail arriving at a screen with a bucket already open.
/// </para>
/// <para>
/// <b>It will not change the kind while something is open.</b> 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.
/// </para>
/// </remarks>
[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;
}
/// <summary>
/// Takes the hosts screen to the files screen, on the host it asked about.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>"Connect via SFTP", from the phone's action bar.</b> It was reachable before only by going to the
/// files screen and choosing the machine again out of a picker — which meant naming a host twice, the
/// second time on a screen that had no idea one had already been chosen.
/// </para>
/// <para>
/// It goes through <see cref="ShowFiles"/> rather than setting the screen itself, so the one refusal
/// there — a bucket already open — is made once and made here too. The host is chosen after that call,
/// because arriving is what clears the picker.
/// </para>
/// <para>
/// <b>It navigates and then connects, and the two are separate on purpose.</b> A host wanting a typed
/// password cannot be dialled from a list, so that case opens the picker with the machine already chosen
/// and the box beside it — the same branch the tap on the hosts screen makes, and for the same reason.
/// Everything else connects, and its failures land on the files screen's own status line, which is where
/// somebody who has just arrived there is looking.
/// </para>
/// <para>
/// The row is re-found in the transfers screen's own list rather than used directly. That list is a copy
/// rebuilt from the vault's — see <c>TransfersViewModel.Hosts</c> — and its picker binds to rows in it,
/// so handing it the vault's object would select nothing.
/// </para>
/// </remarks>
private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) =>
_ = GoToHostFilesAsync(e.Host, null);
/// <summary>
/// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins.
/// </summary>
/// <remarks>
/// <para>
/// The shared body behind <see cref="OnVaultFilesRequested"/> and <see cref="OpenPinnedPathAsync"/>: which
/// machine and where to land once it is open are the only two things that differ between "Browse files"
/// and a click on the pin strip, and both are parameters here.
/// </para>
/// <para>
/// <b>A host asking for a typed password still only opens the picker.</b> <paramref name="path"/> is not
/// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files
/// already had before there was a path to carry, and closing it would mean holding a pending navigation
/// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin
/// that has to be clicked again once the session is open.
/// </para>
/// <para>
/// <b>It reconnects rather than checking whether this host is already open.</b> The existing "Browse
/// files" flow — <see cref="TransfersViewModel.ConnectCommand"/> — never asked, and reusing it here
/// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files
/// does not have. See <c>hosts-v5-design-spec.md</c>: SFTP is a second authenticated connection, every
/// time it is opened, by design.
/// </para>
/// </remarks>
private async Task GoToHostFilesAsync(HostRowViewModel host, string? path)
{
ShowFiles(RemoteKind.Host);
if (Screen is not ShellScreen.Transfers)
{
// The refusal above stood: a bucket is open, and its message is on screen. Choosing a host under
// it would leave the picker pointing at a machine nothing is going to dial.
return;
}
Transfers.SelectedHost =
Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId);
if (Transfers.SelectedHost is null)
{
return;
}
if (Transfers.SelectedHostAsksForAPassword)
{
Transfers.BeginChoosingRemoteCommand.Execute(null);
Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT.";
return;
}
await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true);
if (path is not null && Transfers.IsConnected)
{
await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true);
}
}
/// <inheritdoc cref="OnScreenChanged" />
/// <remarks>
/// <b>The one place the connect sheet is lowered by something other than a tap.</b> 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.
/// </remarks>
partial void OnSurfaceChanged(ShellSurface value)
{
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
}
RaiseSurfaceState();
UpdateLastConnectedVisibility();
}
/// <summary>
/// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it.
/// </summary>
/// <remarks>
/// <para>
/// Called from both <see cref="OnScreenChanged"/> and <see cref="OnSurfaceChanged"/>, because either one
/// alone can be what makes <see cref="IsHostsShowing"/> flip: <see cref="ShowScreenCommand"/> moves both
/// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface,
/// and switching groups on the hosts screen itself moves neither. <see cref="wasHostsScreenShowing"/> is
/// what turns two call sites into one decision rather than two chances to double the work.
/// </para>
/// <para>
/// The read this starts is the one <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already argues
/// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the
/// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait
/// on a decrypt pass over its log.
/// </para>
/// </remarks>
private void UpdateLastConnectedVisibility()
{
var showing = IsHostsShowing;
if (showing == wasHostsScreenShowing)
{
return;
}
wasHostsScreenShowing = showing;
if (showing)
{
_ = Vault?.RefreshLastConnectedAsync(CancellationToken.None);
StartLastConnectedTick();
}
else
{
StopLastConnectedTick();
}
}
/// <summary>
/// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible.
/// </summary>
/// <remarks>
/// <b>No log read here or in <see cref="RunLastConnectedTickAsync"/>.</b> The tick only turns the
/// timestamps <see cref="VaultViewModel.RefreshLastConnectedAsync"/> already read into new words, through
/// <see cref="VaultViewModel.RestringLastConnected"/> — reading the connection log itself on a timer is
/// exactly what <c>LogsViewModel.cs</c> argues that log must never be put on.
/// </remarks>
private void StartLastConnectedTick()
{
if (lastConnectedTick is not null)
{
return;
}
var cts = new CancellationTokenSource();
lastConnectedTick = cts;
_ = RunLastConnectedTickAsync(cts.Token);
}
/// <summary>Stops the loop <see cref="StartLastConnectedTick"/> began, if one is running.</summary>
/// <remarks>
/// Cancelled rather than merely forgotten, so the loop's own <c>PeriodicTimer</c> wait unblocks and the
/// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at.
/// </remarks>
private void StopLastConnectedTick()
{
if (lastConnectedTick is not { } cts)
{
return;
}
lastConnectedTick = null;
cts.Cancel();
cts.Dispose();
}
private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(LastConnectedTickInterval, clock);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
{
Vault?.RestringLastConnected();
}
}
catch (OperationCanceledException)
{
// The hosts screen navigated away, or the shell is closing.
}
}
/// <remarks>
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
/// narrow what became stale.
/// </remarks>
private void RaiseSurfaceState()
{
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and
// IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see
// OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated
// off a terminal tab to a page screen, even though the property itself would answer correctly the
// next time anything else asked it.
RaiseSessionState();
OnPropertyChanged(nameof(IsHostsScreen));
OnPropertyChanged(nameof(IsTransfersScreen));
OnPropertyChanged(nameof(IsKeychainScreen));
OnPropertyChanged(nameof(IsVaultsScreen));
OnPropertyChanged(nameof(IsPreferencesScreen));
OnPropertyChanged(nameof(IsKnownHostsScreen));
OnPropertyChanged(nameof(IsSnippetsScreen));
OnPropertyChanged(nameof(IsLogsScreen));
OnPropertyChanged(nameof(IsMoreScreen));
OnPropertyChanged(nameof(IsBucketsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsVaultsTab));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsKeychainShowing));
OnPropertyChanged(nameof(IsVaultsShowing));
OnPropertyChanged(nameof(IsPreferencesShowing));
OnPropertyChanged(nameof(IsKnownHostsShowing));
OnPropertyChanged(nameof(IsSnippetsShowing));
OnPropertyChanged(nameof(IsLogsShowing));
OnPropertyChanged(nameof(IsMoreShowing));
OnPropertyChanged(nameof(IsBucketsShowing));
OnPropertyChanged(nameof(IsMoreSurface));
// The rail's switcher and its mode-dependent first row read the same three flags above, so
// whatever moved them has to repaint these too — see the remarks on each.
OnPropertyChanged(nameof(IsSshShowing));
OnPropertyChanged(nameof(FirstRailItemLabel));
OnPropertyChanged(nameof(FirstRailItemIcon));
RaiseTerminalState();
}
/// <summary>
/// Re-reads what the terminal's rectangle should hold, and which tab is lit.
/// </summary>
/// <remarks>
/// One method for all four, because they are one fact read from four directions: the surface, the
/// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn —
/// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up
/// pointing at something nobody can see.
/// </remarks>
private void RaiseTerminalState()
{
OnPropertyChanged(nameof(IsTerminalSurface));
OnPropertyChanged(nameof(IsTerminalShowing));
OnPropertyChanged(nameof(IsConnectingShowing));
OnPropertyChanged(nameof(IsHostKeyDecisionShowing));
// ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything
// that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a
// page reached by clicking away from a terminal tab.
RaiseSessionState();
// 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();
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// <see cref="IsAskingForThePassphrase"/>.
/// </remarks>
partial void OnIsConfirmingSignOutChanged(bool value) =>
OnPropertyChanged(nameof(IsAskingForThePassphrase));
partial void OnCanRegisterDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
partial void OnCanForgetDeviceChanged(bool value) =>
OnPropertyChanged(nameof(HasNoDeviceKeyOption));
partial void OnSearchTextChanged(string value) => RefreshSearchResults();
}