Public Access
808 lines
36 KiB
C#
808 lines
36 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.ComponentModel;
|
|
using System.Globalization;
|
|
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>When the session behind this tab ended, or null while it has not.</summary>
|
|
/// <remarks>
|
|
/// What stops the status bar's timer. It used to go on counting from <see cref="StartedAt"/> to now over a
|
|
/// shell that had exited an hour earlier, which made "how long that shell ran" — the fact the remark above
|
|
/// keeps <see cref="StartedAt"/> for — the one thing it no longer said. Stamped by <see cref="Ended"/> from
|
|
/// the shell's clock, and cleared whenever a new session takes the tab.
|
|
/// </remarks>
|
|
internal DateTimeOffset? EndedAt { get; private 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;
|
|
|
|
/// <summary>
|
|
/// The host's saved forwards, as they run over this tab's current session.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Replaced whole each time a session opens here — see <see cref="ReplaceForwards"/> — and emptied of
|
|
/// meaning, though not of rows, when it ends: a row that says it stopped with the session is more use
|
|
/// than one that silently disappeared.
|
|
/// </remarks>
|
|
internal ObservableCollection<PortForwardViewModel> Forwards { get; } = [];
|
|
|
|
/// <summary>How many of <see cref="Forwards"/> are listening now.</summary>
|
|
internal int ListeningForwardCount => Forwards.Count(forward => forward.IsListening);
|
|
|
|
/// <summary>Whether any of <see cref="Forwards"/> is listening now.</summary>
|
|
internal bool HasListeningForward => ListeningForwardCount > 0;
|
|
|
|
/// <summary>Whether this tab carries any forwards at all, running or not.</summary>
|
|
/// <remarks>The phone's terminal bar draws its FORWARDS button only for a tab that has some.</remarks>
|
|
internal bool HasForwards => Forwards.Count > 0;
|
|
|
|
/// <summary>Whether any of <see cref="Forwards"/> could not be started.</summary>
|
|
/// <remarks>
|
|
/// The phone's list is a sheet, not a sidebar that is always drawn, so a refusal there would be invisible
|
|
/// until somebody happened to open it. This is what lets the button that opens it say so first.
|
|
/// </remarks>
|
|
internal bool HasFailedForward => Forwards.Any(forward => forward.IsFailed);
|
|
|
|
/// <summary>
|
|
/// The status bar's forward count, or null when nothing is listening and there is nothing to say.
|
|
/// </summary>
|
|
internal string? ForwardsSummary => ListeningForwardCount switch
|
|
{
|
|
0 => null,
|
|
1 => "⇄ 1 FORWARD",
|
|
var count => string.Create(CultureInfo.InvariantCulture, $"⇄ {count} FORWARDS"),
|
|
};
|
|
|
|
/// <summary>Puts a new session's forwards on this tab, ending whatever the last session left.</summary>
|
|
internal void ReplaceForwards(IEnumerable<PortForwardViewModel> forwards)
|
|
{
|
|
EndForwards();
|
|
|
|
foreach (var forward in Forwards)
|
|
{
|
|
forward.PropertyChanged -= OnForwardChanged;
|
|
}
|
|
|
|
Forwards.Clear();
|
|
|
|
foreach (var forward in forwards)
|
|
{
|
|
forward.PropertyChanged += OnForwardChanged;
|
|
Forwards.Add(forward);
|
|
}
|
|
|
|
RaiseForwardCount();
|
|
}
|
|
|
|
/// <summary>Tells every forward its session is over; SSH.NET has already stopped the listeners.</summary>
|
|
private void EndForwards()
|
|
{
|
|
foreach (var forward in Forwards)
|
|
{
|
|
forward.SessionEnded();
|
|
}
|
|
}
|
|
|
|
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
|
|
{
|
|
if (e.PropertyName is nameof(PortForwardViewModel.State))
|
|
{
|
|
RaiseForwardCount();
|
|
}
|
|
}
|
|
|
|
private void RaiseForwardCount()
|
|
{
|
|
OnPropertyChanged(nameof(ListeningForwardCount));
|
|
OnPropertyChanged(nameof(ForwardsSummary));
|
|
OnPropertyChanged(nameof(HasListeningForward));
|
|
OnPropertyChanged(nameof(HasForwards));
|
|
OnPropertyChanged(nameof(HasFailedForward));
|
|
}
|
|
|
|
/// <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;
|
|
EndedAt = 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, DateTimeOffset at)
|
|
{
|
|
if (State is not TerminalTabState.Open)
|
|
{
|
|
return;
|
|
}
|
|
|
|
EndedAt ??= at;
|
|
EndReason = end;
|
|
EndDescription = description;
|
|
IsLive = false;
|
|
|
|
EndForwards();
|
|
|
|
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;
|
|
|
|
// The old session is being closed for its pane's sake, and its forwards go with its connection.
|
|
EndForwards();
|
|
|
|
// 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;
|
|
EndedAt = 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));
|
|
}
|
|
}
|