Public Access
The connecting card set its status string once, when the tab was created, and never touched it again. Every connection therefore looked identical from the outside: one three seconds into a key exchange, one waiting out a fifteen-second timeout against a machine that is asleep, and one that had hung all drew the same "connecting…". The card now draws the five steps of getting there, each lit at the moment the handshake reports reaching it, over an amber track that fills as they finish. ◆ NOTHING ON THE LIST IS INVENTED. Every row changes state because a layer below it said so, at the instant the thing it names actually began. That is the whole reason it is worth showing, and it is why most of this commit is plumbing rather than XAML: there was no progress reporting anywhere in the stack to hook a step list onto, and a card animating plausible progress would have been indistinguishable from one that had stopped receiving any. SshConnectionPhase names four phases and deliberately not more. SSH.NET runs the entire handshake inside one ConnectAsync and raises exactly one event from the middle of it — HostKeyReceived, once the key exchange has produced a key to show — so that event is the only interior moment there is to report. Everything before it is Reaching and everything after it is Authenticating. A fifth phase in that assembly would have to be a timer, so there is not one. OpeningShell is reported by TerminalWorkspace instead, because that is where it happens: the factory's work ends with an authenticated connection, and asking for a pseudo-terminal on one is a separate round trip. The SFTP path passes null — a second connection opened behind an already-open shell has nobody watching a step list for it. The card's fifth step, "Starting the terminal", is the renderer wait and lives in the shell rather than in the SSH assembly, which has never heard of a renderer. On the first connection after a cold start it is a real wait with a real failure mode of its own — a missing WebView2 runtime — so a list that began at "reaching the host" would leave the one wait most likely to hang unnamed. Amber for the step in flight, and that follows the palette's rule rather than bending it. Green is what is true and purple is what you can press; a step still happening is neither, and it is exactly the caveat-worth-reading that amber exists for. Steps behind it go green as they become true. Nothing animates, which is the argument TransfersScreen.axaml already makes for its own track, reaching a screen with far more reason to want a spinner: a spinner is furniture invented to fill a state nobody measured, and these states are measured, so the track fills to what has finished and then waits there. A refusal keeps the step it stopped on, in red, with the ones behind it still green. That is the half a progress bar could not do, and it is the difference between "that host is not there" and "that host is there and would not have me" — a question the reason sentence alone frequently does not settle. The strip's dot goes amber while a tab is connecting, on both heads. It was grey, and so is a tab whose shell has exited: the two states in that strip with the least in common, one worth waiting for and one over. PhoneShell's own comment already recorded half of this — the dot stopped being green before anything had answered — and this is the other half. Progress is raised inline rather than through System.Progress<T>, which captures whatever synchronisation context it was constructed on and posts to it. That reads like a convenience and is really a second place the marshalling decision gets made: silently, differently under a test with no context, and out of order with respect to the failure that follows a phase. The shell marshals once, in one handler, through a new optional post parameter on MainWindowViewModel — the same seam TransfersViewModel already uses, and for the reason its own remark gives. The three Dispatcher.UIThread.Post calls that predate it are the ones this suite's comments record as out of reach; they are left alone rather than swept in here. Both heads draw the list. They differ in one place: Phone.axaml's mono class sets a colour and a size along with the family, so the caption rule names its own family instead of composing the two and asking two rules for one Foreground. The desktop's mono sets the family alone, which is why ConnectingCard does compose them. Each head also gains SHOW LOGS beside the button that gives up — the step list is this attempt and the log is every other one, which is what a connection taking too long actually raises. Seven tests, and the two that matter most run against the container rather than a fake: a real handshake reports its phases in order, and a host-key refusal never claims to have authenticated. A fake asserting what it was written to assert would have established nothing about either. The rest cover the tab advancing while the connection is gated, the step a refusal stops on, and a phase reported after the user has given up on the tab. 1,861 tests, none failing. The Android head's layout is not verified by anything. It compiles, and compiled bindings mean every new binding path resolves, but that project is not in DodoSSH.slnx, there is no test project for it and no device here — so unlike the desktop card, whose shapes the layout harness measures, these rows have not been drawn. Vertical fit is reasoned, not observed.
4588 lines
216 KiB
C#
4588 lines
216 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;
|
|
|
|
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
|
private readonly Action<Action> post;
|
|
|
|
/// <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>
|
|
/// <param name="post">
|
|
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
|
|
/// dispatcher, which is the answer in every real head.
|
|
/// <para>
|
|
/// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for exactly the reason
|
|
/// <c>TransfersViewModel</c>'s is one — see the remark there. It is process-wide and belongs to whichever
|
|
/// thread touched it first, so a suite that runs with no window has no way to drain it and no way to
|
|
/// know whose it is. This one exists because connection phases are reported from the handshake's own
|
|
/// thread, which is the first thing in this class that has to cross onto the UI thread and also has to
|
|
/// be assertable: the three <c>Dispatcher.UIThread.Post</c> calls that predate it are the ones this
|
|
/// suite's own comments record as out of reach, and they are left alone rather than swept in here.
|
|
/// </para>
|
|
/// </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,
|
|
Action<Action>? post = null)
|
|
{
|
|
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
|
|
|
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 && !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 && 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 — announced once the close below has fully drained
|
|
// — finds no tab to mark dead and does nothing here. 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 four 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.ConnectionProgress -= OnVaultConnectionProgress;
|
|
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
|
oldValue.SessionOpened -= OnVaultSessionOpened;
|
|
}
|
|
}
|
|
|
|
if (newValue is not null)
|
|
{
|
|
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
|
newValue.ConnectionProgress += OnVaultConnectionProgress;
|
|
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>
|
|
/// Moves a connecting tab's step list on, from the handshake's own report.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The one place the phases raised by <c>VaultViewModel.ConnectionProgress</c> are marshalled, and the
|
|
/// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
|
|
/// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
|
|
/// </para>
|
|
/// <para>
|
|
/// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
|
|
/// on this thread — the first is reported before the handshake has yielded at all — and a
|
|
/// <c>CheckAccess</c> fast path for them would buy one dispatcher turn on a card that is up for seconds,
|
|
/// at the price of the two orderings existing at once and only one of them being the one a test runs.
|
|
/// </para>
|
|
/// <para>
|
|
/// A step that arrives after the attempt has settled is harmless and needs no guard here:
|
|
/// <see cref="TerminalTabViewModel.Advance"/> ignores anything reported to a tab that is no longer
|
|
/// connecting, which is what a posted phase landing behind its own <see cref="OnVaultSessionOpened"/>
|
|
/// looks like.
|
|
/// </para>
|
|
/// <para>
|
|
/// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
|
|
/// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
|
|
/// and its session is still adopted if it opens.
|
|
/// </para>
|
|
/// </remarks>
|
|
private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
|
|
{
|
|
if (attempts.TryGetValue(e.AttemptId, out var tab))
|
|
{
|
|
tab.Advance(e.Step);
|
|
}
|
|
});
|
|
|
|
/// <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 && 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();
|
|
}
|