Public Access
A tab remembered what its host was called and nothing else, and the reconnect under a stopped tab looked the host up by that name: HostFor took the first row in the keychain whose label matched. Nothing in this application stops two hosts being called the same thing — a team's vault beside a personal one makes it ordinary, and "prod-db" is what the database server gets called in both — so the button dialled whichever namesake sat earliest in the list. A dropped session on one machine came back as a live shell on another, with the tab, the address and the status bar all still naming the first. There was nothing on screen to read the substitution off. The name match was not an oversight; it was an argument, written into the remark on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that opened it, so it "cannot hold an entity id that would still mean anything after a lock". That is not so. EntityId is the item's own sync identity rather than a reference to a row object, so a vault that is locked and opened again rebuilds every row around the same ids. The one handle that was rejected as too fragile to survive a lock is the handle that survives it. So the id now travels with the connection. ConnectionAttemptEventArgs and TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the connect path has had all along for the log; TerminalTabViewModel keeps it; and HostFor matches on it. A host renamed since the tab opened is found too, which a name match by definition cannot. Null is not missing data — it is the whole of what makes a typed-in address different, and it is why such a tab is refused a reconnect rather than offered one that would dial a stranger sharing its name. Three other lookups had grown their own copy of the same match and so had the same fault, where it is read rather than dialled: the green connected dot, which lit on every machine answering to the name and claimed a shell on hosts nobody had connected to; the pinned paths QUICK ACCESS offers, which come from whichever row that same match found; and the sidebar's "Pin folder" and the SFTP tab row, which would have edited and browsed the wrong host. All four now go through HostFor, so "which machine is this tab" has one answer. Both new tests assert on the address actually dialled rather than on anything the tab says about itself. The two rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written against the label would have passed on the day the wrong machine was being opened.
704 lines
32 KiB
C#
704 lines
32 KiB
C#
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using DodoSSH.Client.Ssh;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>
|
|
/// One named part of making a connection, in the order they happen.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <see cref="SshConnectionPhase"/> with one more at the front. The SSH assembly reports four phases and
|
|
/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
|
|
/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
|
|
/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
|
|
/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
|
|
/// </para>
|
|
/// <para>
|
|
/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
|
|
/// one <c>switch</c> in <c>VaultViewModel</c>. The numbering is the order and the order is load-bearing:
|
|
/// <see cref="TerminalTabViewModel.Advance"/> compares these values to decide what is already behind it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal enum ConnectionStep
|
|
{
|
|
/// <summary>Waiting for the renderer to attach, before anything is dialled.</summary>
|
|
PreparingTerminal = 0,
|
|
|
|
/// <inheritdoc cref="SshConnectionPhase.Reaching" />
|
|
Reaching = 1,
|
|
|
|
/// <inheritdoc cref="SshConnectionPhase.CheckingHostKey" />
|
|
CheckingHostKey = 2,
|
|
|
|
/// <inheritdoc cref="SshConnectionPhase.Authenticating" />
|
|
Authenticating = 3,
|
|
|
|
/// <inheritdoc cref="SshConnectionPhase.OpeningShell" />
|
|
OpeningShell = 4,
|
|
}
|
|
|
|
/// <summary>How one step of a connection is getting on.</summary>
|
|
/// <remarks>
|
|
/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
|
|
/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
|
|
/// <see cref="Stopped"/> exists only for the row a failure landed on — see
|
|
/// <see cref="TerminalTabViewModel.Failed"/> — and is what turns the list from a progress bar into an
|
|
/// account of how far the attempt got.
|
|
/// </remarks>
|
|
internal enum ConnectionStepState
|
|
{
|
|
/// <summary>Not started. Nothing is known about it yet.</summary>
|
|
Pending = 0,
|
|
|
|
/// <summary>Happening now.</summary>
|
|
Running = 1,
|
|
|
|
/// <summary>Finished, because something after it started.</summary>
|
|
Done = 2,
|
|
|
|
/// <summary>Where the attempt stopped. There is no step after this one.</summary>
|
|
Stopped = 3,
|
|
}
|
|
|
|
/// <summary>One row of the connecting card's step list.</summary>
|
|
/// <remarks>
|
|
/// A view model per step rather than an index the view compares against, because each row draws its own
|
|
/// state and an <c>ItemsControl</c> has no way to ask "am I before the current one?" — the alternative was a
|
|
/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
|
|
/// </remarks>
|
|
internal sealed partial class ConnectionStepViewModel : ObservableObject
|
|
{
|
|
internal ConnectionStepViewModel(ConnectionStep step, string caption)
|
|
{
|
|
Step = step;
|
|
Caption = caption;
|
|
}
|
|
|
|
/// <summary>Which step this is.</summary>
|
|
internal ConnectionStep Step { get; }
|
|
|
|
/// <summary>What the row says, in the present tense of the thing being waited on.</summary>
|
|
internal string Caption { get; }
|
|
|
|
/// <inheritdoc cref="ConnectionStepState" />
|
|
[ObservableProperty]
|
|
private ConnectionStepState state;
|
|
|
|
/// <summary>Whether this step is the one happening now.</summary>
|
|
internal bool IsRunning => State is ConnectionStepState.Running;
|
|
|
|
/// <summary>Whether this step finished.</summary>
|
|
internal bool IsDone => State is ConnectionStepState.Done;
|
|
|
|
/// <summary>Whether the attempt stopped on this step.</summary>
|
|
internal bool IsStopped => State is ConnectionStepState.Stopped;
|
|
|
|
/// <summary>
|
|
/// The character drawn beside the caption for whichever state this is in.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Here rather than in a converter for the reason <c>TransferRowViewModel.StatusWord</c> is: the mapping
|
|
/// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
|
|
/// reach. The colours stay in the view, where the palette is.
|
|
/// <para>
|
|
/// Four distinguishable shapes rather than one recoloured, because the difference between a step that
|
|
/// finished and a step still running has to survive somebody who cannot tell this design's green from
|
|
/// its amber.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string Mark => State switch
|
|
{
|
|
ConnectionStepState.Done => "✓",
|
|
ConnectionStepState.Running => "●",
|
|
ConnectionStepState.Stopped => "✕",
|
|
_ => "○",
|
|
};
|
|
|
|
partial void OnStateChanged(ConnectionStepState value)
|
|
{
|
|
OnPropertyChanged(nameof(IsRunning));
|
|
OnPropertyChanged(nameof(IsDone));
|
|
OnPropertyChanged(nameof(IsStopped));
|
|
OnPropertyChanged(nameof(Mark));
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// How far along a tab's connection is.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A tab exists before its session does — see <see cref="TerminalTabViewModel"/> — so "is there a pane
|
|
/// behind this" is a question the strip and the window both have to be able to ask. Three states rather
|
|
/// than a nullable session id, because <see cref="Failed"/> and <see cref="Connecting"/> are both "no
|
|
/// session" and only one of them is still worth waiting for.
|
|
/// </remarks>
|
|
internal enum TerminalTabState
|
|
{
|
|
/// <summary>The connection is being made. There is no pane yet.</summary>
|
|
Connecting = 0,
|
|
|
|
/// <summary>A session was opened, and the renderer has a pane for it.</summary>
|
|
Open = 1,
|
|
|
|
/// <summary>The connection did not happen. There is no pane, and there never will be for this tab.</summary>
|
|
Failed = 2,
|
|
}
|
|
|
|
/// <summary>
|
|
/// One terminal, as a tab: from the moment connecting starts to the moment the tab is closed.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// A tab is a session id and a handful of strings — the address, and now the three connection facts the
|
|
/// status bar draws beside CONNECTED. It holds no terminal and owns nothing: the pane, its scrollback and the
|
|
/// shell behind it all live in the renderer and in <c>TerminalWorkspace</c>, and selecting a tab is one frame
|
|
/// telling the page which pane to show. That is what makes tabs cheap here — the expensive object is the
|
|
/// WebView, and there is one of those however many tabs are open.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>A tab starts before its session does.</b> Connecting is a network round trip that can take as long as
|
|
/// a DNS lookup and a handshake take, and the strip is where that is admitted to: the tab appears at the
|
|
/// moment the user asks for it, carrying <see cref="Status"/> instead of a pane, and becomes a real terminal
|
|
/// when <see cref="Opened"/> is called. Nothing about the rest of the application waits for that — which is
|
|
/// the point, because the alternative is a window that does nothing visible for ten seconds.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Tabs belong to the shell, not to the vault.</b> Locking disposes the vault and every key it held, and
|
|
/// deliberately leaves shells running — so a tab list rebuilt per unlock would lose track of sessions that
|
|
/// are still connected, and the unlock screen's count of them would be the only place they appeared. The
|
|
/// shell outlives every lock, and so does this.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class TerminalTabViewModel : ObservableObject
|
|
{
|
|
/// <summary>A tab for a connection that is still being made.</summary>
|
|
/// <param name="label">The host's name, as the vault has it.</param>
|
|
/// <param name="address">Who this will be logged in as, and where.</param>
|
|
internal TerminalTabViewModel(string label, string address)
|
|
{
|
|
Label = label;
|
|
Address = address;
|
|
isLive = false;
|
|
|
|
Steps =
|
|
[
|
|
new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
|
|
new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
|
|
new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
|
|
new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
|
|
new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
|
|
];
|
|
|
|
// The first step is running before anything is awaited, because it is: the tab is created in the
|
|
// same turn as the click and the renderer wait starts immediately after. A list that opened with
|
|
// every row pending would show a connection that had not begun, which is one turn of the dispatcher
|
|
// away from being untrue and is the turn the card is first drawn in.
|
|
status = Steps[0].Caption;
|
|
Steps[0].State = ConnectionStepState.Running;
|
|
}
|
|
|
|
/// <summary>A tab for a session that is already open.</summary>
|
|
/// <param name="sessionId">Identifies this terminal to the renderer.</param>
|
|
/// <param name="label">The host's name, as the vault has it.</param>
|
|
/// <param name="address">Who this is logged in as, and where.</param>
|
|
internal TerminalTabViewModel(uint sessionId, string label, string address)
|
|
: this(label, address)
|
|
{
|
|
SessionId = sessionId;
|
|
state = TerminalTabState.Open;
|
|
status = string.Empty;
|
|
isLive = true;
|
|
|
|
// A session that already exists got through every step by definition, even though this tab watched
|
|
// none of them happen — an adopted session is one whose connecting tab the user closed. The list is
|
|
// never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
|
|
// as a fourth answer to "how far did it get".
|
|
CompleteSteps();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Identifies this terminal to the renderer, or zero while there is no session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Zero is not a session id the workspace ever hands out — it counts from one — so it can stand for
|
|
/// "not connected yet" without a nullable that every caller would have to unwrap. <see cref="HasSession"/>
|
|
/// is what the window asks rather than this.
|
|
/// </remarks>
|
|
internal uint SessionId { get; private set; }
|
|
|
|
internal string Label { get; }
|
|
|
|
/// <summary>
|
|
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
|
|
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
|
|
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
|
|
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
|
|
/// the one place being approximately right is dialling a stranger. See
|
|
/// <c>MainWindowViewModel.HostFor</c>.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
|
|
/// an index into the list on screen — see <c>VaultItem<TSecret>.EntityId</c> — so a vault that is
|
|
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
|
|
/// finds its host again. A rename finds it too, which a name match by definition cannot.
|
|
/// </para>
|
|
/// <para>
|
|
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
|
|
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal Guid? HostId { get; init; }
|
|
|
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
|
internal string Address { get; }
|
|
|
|
/// <summary>
|
|
/// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is
|
|
/// none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Set by <c>MainWindowViewModel</c> from its own clock at the same moment it calls <see cref="Opened"/>
|
|
/// or constructs a tab that already carries a session id, rather than read here from
|
|
/// <see cref="TimeProvider.System"/> directly — a view model with its own notion of "now" is a second
|
|
/// clock the shell's tests would have no way to control. Left on a tab whose session has since ended:
|
|
/// the pane still holds the scrollback, and "how long that shell ran" stays a true fact about it after it
|
|
/// stops being live.
|
|
/// </remarks>
|
|
internal DateTimeOffset? StartedAt { get; set; }
|
|
|
|
/// <summary>
|
|
/// The negotiated server-to-client cipher, for the status bar — or null while there is no session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Set alongside <see cref="StartedAt"/>, from the same event, and kept for the same reason: a dead tab's
|
|
/// pane still holds the scrollback of a session that really did negotiate this cipher, and clearing the
|
|
/// fact when the shell ends would not make it less true of what is on screen.
|
|
/// </remarks>
|
|
internal string? Cipher { get; set; }
|
|
|
|
/// <summary>The accepted host key's algorithm, e.g. <c>ssh-ed25519</c> — or null while there is no session.</summary>
|
|
/// <remarks>See <see cref="Cipher"/>; set and kept the same way, for the same reason.</remarks>
|
|
internal string? HostKeyAlgorithm { get; set; }
|
|
|
|
/// <summary>
|
|
/// The display name of the key or credential that authenticated, or null when a typed password did, or
|
|
/// null while there is no session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Three different reasons collapse to the same null, and that is deliberate: nothing downstream needs to
|
|
/// tell a session with no identity to name apart from a tab that has not opened one yet — both mean the
|
|
/// status bar shows the host-key algorithm alone, with no ` · name` after it.
|
|
/// </remarks>
|
|
internal string? IdentityLabel { get; set; }
|
|
|
|
/// <summary>
|
|
/// How far this connection got, step by step, for the card that stands in for the pane.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Fixed at construction and never added to or removed from — the steps of a connection are known before
|
|
/// it starts, and only their state changes — so a plain array is enough and the view needs no collection
|
|
/// change notification for it.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Every row here is reported, not guessed.</b> The states come from
|
|
/// <see cref="SshConnectionPhase"/>, raised by the handshake itself at the moment each part of it begins.
|
|
/// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
|
|
/// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
|
|
/// indistinguishable from one that had stopped receiving any.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal IReadOnlyList<ConnectionStepViewModel> Steps { get; }
|
|
|
|
/// <summary>How many steps are behind the attempt, for the card's track.</summary>
|
|
/// <remarks>
|
|
/// Counted rather than stored, and it counts <see cref="ConnectionStepState.Done"/> alone: the running
|
|
/// step is deliberately not half a step. The track fills to where the attempt has actually got to and
|
|
/// stops there, which is the same promise the list itself makes.
|
|
/// </remarks>
|
|
internal int StepsDone => Steps.Count(step => step.IsDone);
|
|
|
|
/// <summary>How many steps there are, for the card's track.</summary>
|
|
internal int StepCount => Steps.Count;
|
|
|
|
/// <inheritdoc cref="TerminalTabState" />
|
|
[ObservableProperty]
|
|
private TerminalTabState state;
|
|
|
|
/// <summary>
|
|
/// What this tab has to say for itself while it has no pane.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Empty once a session is open, because from then on the pane speaks for itself — anything written here
|
|
/// would be a second, staler account of what the terminal is already showing.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string status;
|
|
|
|
/// <summary>
|
|
/// Whether the shell behind this tab is still running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Cleared when the workspace says the session ended, never inferred from the tab being closed — closing
|
|
/// a tab removes it, and a removed tab has nothing left to report. A dead tab is kept on purpose: its
|
|
/// pane still holds the scrollback, and the last thing the remote said is usually why the shell ended.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isLive;
|
|
|
|
/// <summary>
|
|
/// Whether this is the tab whose pane is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// A flag on the tab as well as a selection on the shell, because the strip is an
|
|
/// <c>ItemsControl</c> of buttons rather than a control that owns a selection — and a button has no
|
|
/// <c>:selected</c> pseudo-class to style against. The shell writes it; nothing else does.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isSelected;
|
|
|
|
/// <summary>
|
|
/// Whether this tab is the thing the window is currently showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Not the same question as <see cref="IsSelected"/>, and the strip has to ask this one. The selection
|
|
/// survives navigating away — that is what makes the strip a way back to a terminal rather than a way to
|
|
/// lose it — so a tab that stayed lit while preferences filled the window would be a second "you are
|
|
/// here" mark pointing at something nobody can see. The rail's own entries make exactly this distinction;
|
|
/// see <c>MainWindowViewModel.IsHostsShowing</c>. The shell writes it, from the selection and the surface
|
|
/// together.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool isShowing;
|
|
|
|
/// <summary>
|
|
/// How the shell behind this tab ended, or null while it has not.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Null covers three states that need no telling apart here — still connecting, still running, and a
|
|
/// connection that never happened — because the only thing anything asks this is which ending to word,
|
|
/// and none of the three has one.
|
|
/// </remarks>
|
|
internal SshSessionEnd? EndReason { get; private set; }
|
|
|
|
/// <summary>
|
|
/// The sentence the shell's end came with, or null while there is none.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The same string the renderer wrote into the pane, so the detail under a status line and the line in
|
|
/// the scrollback are one sentence rather than two accounts that can drift.
|
|
/// </remarks>
|
|
internal string? EndDescription { get; private set; }
|
|
|
|
/// <summary>Whether this tab has a session that has stopped, and something to say about it.</summary>
|
|
internal bool HasEnded => State is TerminalTabState.Open && !IsLive;
|
|
|
|
/// <summary>
|
|
/// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was,
|
|
/// and otherwise how the session ended.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The refusal wins because it is the newer answer and it is about something the user just did. A tab
|
|
/// whose reconnect was turned down — the machine has left the keychain, the key it was bound to is gone —
|
|
/// would otherwise go on printing how the shell ended, which is true and is not what was just asked.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="Status"/> is where the refusal lands because that is already what this property means:
|
|
/// what the tab has to say for itself. It is empty for the whole of a session's life —
|
|
/// <see cref="Opened"/> clears it — so a non-empty one on a stopped tab can only be something that
|
|
/// happened after the shell did.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription;
|
|
|
|
/// <summary>Whether this tab's session ended because the connection broke rather than because the shell did.</summary>
|
|
/// <remarks>
|
|
/// The tab strip's dot binds this, so the two endings are told apart at a glance in the one place every
|
|
/// terminal is listed. They used to draw the same grey, which made a tab that had been cut off
|
|
/// indistinguishable from one somebody had closed down properly.
|
|
/// </remarks>
|
|
internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost;
|
|
|
|
/// <summary>
|
|
/// What the status bar says instead of CONNECTED, or null while nothing has ended.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Two words rather than one, because the two endings are read completely differently. A shell that
|
|
/// exited is the ordinary end of a day's work and wants no alarm; a connection that broke is a machine
|
|
/// that may well still be there, and is the whole reason the reconnect beside it exists.
|
|
/// </para>
|
|
/// <para>
|
|
/// <see cref="SshSessionEnd.ClosedLocally"/> has no word here on purpose. This client closed the session,
|
|
/// which means a tab is being removed or reconnected — there is no moment at which somebody reads a
|
|
/// status line about it.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string? EndedSummary => EndReason switch
|
|
{
|
|
SshSessionEnd.ConnectionLost => "DISCONNECTED",
|
|
SshSessionEnd.RemoteClosed => "SHELL EXITED",
|
|
_ => null,
|
|
};
|
|
|
|
/// <summary>
|
|
/// Whether this tab is in a state where dialling again would mean something.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// True for a shell that ended however it ended, and for a connection that was refused. All three are a
|
|
/// tab with no live session and a machine it was pointed at — and offering the retry on a refusal is
|
|
/// worth as much as offering it on a drop, because the commonest refusal is a host that was not up yet.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>It says the tab is willing, not that the shell can do it.</b> Whether there is anything to dial —
|
|
/// a keychain entry under this tab's name, and one that does not need a password typed first — is the
|
|
/// shell's question, because this view model has never heard of a vault. See
|
|
/// <c>MainWindowViewModel.CanReconnectSelectedTab</c>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal bool CanOfferReconnect => HasEnded || IsFailed;
|
|
|
|
/// <summary>Whether there is a pane behind this tab.</summary>
|
|
internal bool HasSession => State is TerminalTabState.Open;
|
|
|
|
/// <summary>Whether this tab is still waiting on a connection.</summary>
|
|
internal bool IsConnecting => State is TerminalTabState.Connecting;
|
|
|
|
/// <summary>Whether this tab is a connection that never happened.</summary>
|
|
internal bool IsFailed => State is TerminalTabState.Failed;
|
|
|
|
/// <summary>
|
|
/// Records that the connection has reached a named step.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Everything before <paramref name="step"/> is marked done, because a phase that has begun is proof the
|
|
/// ones before it ended — the handshake is a sequence and there is no way to be at one point in it
|
|
/// without having passed the earlier ones. That is also what covers a step too fast to observe: it is
|
|
/// closed by its successor rather than needing a report of its own.
|
|
/// </para>
|
|
/// <para>
|
|
/// Monotonic, and silently so. A report that has already been passed is ignored rather than rewinding
|
|
/// the list, because the one thing that can produce one is a retry after the host-key question, and a
|
|
/// card that jumped backwards would read as the connection having come undone.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal void Advance(ConnectionStep step)
|
|
{
|
|
if (State is not TerminalTabState.Connecting)
|
|
{
|
|
// Nothing to draw and nothing to correct. A late report from a handshake that has since
|
|
// finished or been given up on is not worth reopening a settled tab for.
|
|
return;
|
|
}
|
|
|
|
var reached = Steps.FirstOrDefault(row => row.Step == step);
|
|
|
|
if (reached is null || reached.IsDone)
|
|
{
|
|
return;
|
|
}
|
|
|
|
foreach (var row in Steps)
|
|
{
|
|
if (row.Step < step)
|
|
{
|
|
row.State = ConnectionStepState.Done;
|
|
}
|
|
else if (row.Step == step)
|
|
{
|
|
row.State = ConnectionStepState.Running;
|
|
}
|
|
}
|
|
|
|
Status = reached.Caption;
|
|
OnPropertyChanged(nameof(StepsDone));
|
|
}
|
|
|
|
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
|
internal void Opened(uint sessionId)
|
|
{
|
|
SessionId = sessionId;
|
|
Status = string.Empty;
|
|
|
|
// Cleared before the flags below raise off them. A tab reconnecting carries one of these from the
|
|
// session that just ended, and a live terminal that still says how the last one stopped is the
|
|
// stale-fact failure this pair exists to remove.
|
|
EndReason = null;
|
|
EndDescription = null;
|
|
|
|
IsLive = true;
|
|
State = TerminalTabState.Open;
|
|
|
|
CompleteSteps();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records that the connection this tab was opened for did not happen.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The tab stays, and that is deliberate: connecting no longer blocks the window, so by the time a
|
|
/// refusal arrives the user is quite likely looking at something else — and a tab that vanished would
|
|
/// take the only account of what went wrong with it. It is closed the way every other tab is.
|
|
/// </remarks>
|
|
internal void Failed(string reason)
|
|
{
|
|
Status = reason;
|
|
IsLive = false;
|
|
|
|
// A refusal is not an ending of the kind Ended records — there was never a session to end — so
|
|
// anything a previous attempt on this tab left behind goes with it. Without this, a reconnect that
|
|
// is itself refused would draw the refusal and the old shell's ending at once.
|
|
EndReason = null;
|
|
EndDescription = null;
|
|
|
|
// Before the state change, so the list is already correct the first time a view asks. The step that
|
|
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
|
|
// got is the most useful thing the card still knows, and it is the difference between "that host is
|
|
// not there" and "that host is there and would not have me".
|
|
foreach (var row in Steps)
|
|
{
|
|
if (row.IsRunning)
|
|
{
|
|
row.State = ConnectionStepState.Stopped;
|
|
}
|
|
}
|
|
|
|
State = TerminalTabState.Failed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Records that the shell behind this tab has stopped, and how.
|
|
/// </summary>
|
|
/// <param name="end">Which of the three ways it ended.</param>
|
|
/// <param name="description">The sentence the pump wrote into the pane, for the detail line.</param>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The tab stays and its pane stays, exactly as before: the last thing the remote said is usually why the
|
|
/// shell ended, and the renderer has already written this same sentence into the scrollback. What is new
|
|
/// is that the tab now knows <em>which</em> ending it was, which is the difference between a shell that is
|
|
/// finished and a machine that is probably still there — see <see cref="CanOfferReconnect"/>.
|
|
/// </para>
|
|
/// <para>
|
|
/// Ignored for a tab that is connecting or failed. A session end can only be about a session, and a late
|
|
/// announcement for one this tab has already been reconnected past would otherwise reach back and mark a
|
|
/// live attempt dead. <see cref="Advance"/> guards its own reports for the same reason.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal void Ended(SshSessionEnd end, string description)
|
|
{
|
|
if (State is not TerminalTabState.Open)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EndReason = end;
|
|
EndDescription = description;
|
|
IsLive = false;
|
|
|
|
OnPropertyChanged(nameof(EndedDetail));
|
|
OnPropertyChanged(nameof(EndedSummary));
|
|
OnPropertyChanged(nameof(EndDescription));
|
|
OnPropertyChanged(nameof(IsDropped));
|
|
}
|
|
|
|
/// <summary>
|
|
/// Puts this tab back to connecting, for an attempt that is about to be made on the same machine.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The tab is reused rather than a second one opened beside it, because reconnecting is what a person
|
|
/// means when they say it: the tab they were working in comes back, in the place it was, still selected.
|
|
/// A new tab would leave the strip holding two entries for one machine, one of them a headstone.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The session id is dropped here, and the pane it named is the caller's to reclaim.</b> Nothing in
|
|
/// this view model can send a frame; what it can do is stop claiming a session that is about to stop
|
|
/// being this tab's. See <c>MainWindowViewModel.ReconnectTabAsync</c>, which closes the old session for
|
|
/// its pane's sake — a pane holds an xterm, its scrollback and a WebGL context, and browsers hand out
|
|
/// about sixteen of the last.
|
|
/// </para>
|
|
/// <para>
|
|
/// The step list is rebuilt to how it looked at the start of the first attempt rather than left as it
|
|
/// was. A list still showing where the last attempt got to would read as progress this one had already
|
|
/// made, which is the one thing the card promises never to do.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal void Reconnecting()
|
|
{
|
|
SessionId = 0;
|
|
EndReason = null;
|
|
EndDescription = null;
|
|
IsLive = false;
|
|
|
|
// Cleared with the session they were facts about. Keeping them would put the previous connection's
|
|
// cipher in the status bar beside a handshake that has not negotiated one yet.
|
|
StartedAt = null;
|
|
Cipher = null;
|
|
HostKeyAlgorithm = null;
|
|
IdentityLabel = null;
|
|
|
|
foreach (var row in Steps)
|
|
{
|
|
row.State = ConnectionStepState.Pending;
|
|
}
|
|
|
|
// The same opening position the constructor sets, and for the same reason: the renderer wait starts
|
|
// in this turn, so a list of five pending rows would show an attempt that had not begun.
|
|
Steps[0].State = ConnectionStepState.Running;
|
|
Status = Steps[0].Caption;
|
|
|
|
State = TerminalTabState.Connecting;
|
|
|
|
OnPropertyChanged(nameof(StepsDone));
|
|
OnPropertyChanged(nameof(EndedDetail));
|
|
OnPropertyChanged(nameof(EndedSummary));
|
|
OnPropertyChanged(nameof(EndDescription));
|
|
OnPropertyChanged(nameof(IsDropped));
|
|
}
|
|
|
|
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
|
|
private void CompleteSteps()
|
|
{
|
|
foreach (var row in Steps)
|
|
{
|
|
row.State = ConnectionStepState.Done;
|
|
}
|
|
|
|
OnPropertyChanged(nameof(StepsDone));
|
|
}
|
|
|
|
partial void OnStateChanged(TerminalTabState value)
|
|
{
|
|
OnPropertyChanged(nameof(HasSession));
|
|
OnPropertyChanged(nameof(IsConnecting));
|
|
OnPropertyChanged(nameof(IsFailed));
|
|
OnPropertyChanged(nameof(HasEnded));
|
|
OnPropertyChanged(nameof(CanOfferReconnect));
|
|
|
|
// Opened and Failed both clear the previous ending and then land here, so this is where a dot that
|
|
// was red for the last session goes back to whatever the new state deserves.
|
|
OnPropertyChanged(nameof(IsDropped));
|
|
}
|
|
|
|
/// <remarks>
|
|
/// <see cref="HasEnded"/> and <see cref="CanOfferReconnect"/> are both partly this flag, and the strip
|
|
/// binds them — so a shell that stops has to move them in the same notification that greys its dot.
|
|
/// </remarks>
|
|
/// <remarks>The strip under a stopped terminal reads this as well as the ending; see <see cref="EndedDetail"/>.</remarks>
|
|
partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail));
|
|
|
|
partial void OnIsLiveChanged(bool value)
|
|
{
|
|
OnPropertyChanged(nameof(HasEnded));
|
|
OnPropertyChanged(nameof(CanOfferReconnect));
|
|
}
|
|
}
|