using CommunityToolkit.Mvvm.ComponentModel; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Shell.ViewModels; /// /// One named part of making a connection, in the order they happen. /// /// /// /// 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. /// /// /// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is /// one switch in VaultViewModel. The numbering is the order and the order is load-bearing: /// compares these values to decide what is already behind it. /// /// internal enum ConnectionStep { /// Waiting for the renderer to attach, before anything is dialled. PreparingTerminal = 0, /// Reaching = 1, /// CheckingHostKey = 2, /// Authenticating = 3, /// OpeningShell = 4, } /// How one step of a connection is getting on. /// /// 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. /// exists only for the row a failure landed on — see /// — and is what turns the list from a progress bar into an /// account of how far the attempt got. /// internal enum ConnectionStepState { /// Not started. Nothing is known about it yet. Pending = 0, /// Happening now. Running = 1, /// Finished, because something after it started. Done = 2, /// Where the attempt stopped. There is no step after this one. Stopped = 3, } /// One row of the connecting card's step list. /// /// A view model per step rather than an index the view compares against, because each row draws its own /// state and an ItemsControl 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. /// internal sealed partial class ConnectionStepViewModel : ObservableObject { internal ConnectionStepViewModel(ConnectionStep step, string caption) { Step = step; Caption = caption; } /// Which step this is. internal ConnectionStep Step { get; } /// What the row says, in the present tense of the thing being waited on. internal string Caption { get; } /// [ObservableProperty] private ConnectionStepState state; /// Whether this step is the one happening now. internal bool IsRunning => State is ConnectionStepState.Running; /// Whether this step finished. internal bool IsDone => State is ConnectionStepState.Done; /// Whether the attempt stopped on this step. internal bool IsStopped => State is ConnectionStepState.Stopped; /// /// The character drawn beside the caption for whichever state this is in. /// /// /// Here rather than in a converter for the reason TransferRowViewModel.StatusWord 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. /// /// 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. /// /// 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)); } } /// /// How far along a tab's connection is. /// /// /// A tab exists before its session does — see — 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 and are both "no /// session" and only one of them is still worth waiting for. /// internal enum TerminalTabState { /// The connection is being made. There is no pane yet. Connecting = 0, /// A session was opened, and the renderer has a pane for it. Open = 1, /// The connection did not happen. There is no pane, and there never will be for this tab. Failed = 2, } /// /// One terminal, as a tab: from the moment connecting starts to the moment the tab is closed. /// /// /// /// 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 TerminalWorkspace, 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. /// /// /// A tab starts before its session does. 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 instead of a pane, and becomes a real terminal /// when 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. /// /// /// Tabs belong to the shell, not to the vault. 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. /// /// internal sealed partial class TerminalTabViewModel : ObservableObject { /// A tab for a connection that is still being made. /// The host's name, as the vault has it. /// Who this will be logged in as, and where. 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; } /// A tab for a session that is already open. /// Identifies this terminal to the renderer. /// The host's name, as the vault has it. /// Who this is logged in as, and where. 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(); } /// /// Identifies this terminal to the renderer, or zero while there is no session. /// /// /// 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. /// is what the window asks rather than this. /// internal uint SessionId { get; private set; } internal string Label { get; } /// /// The keychain item this tab was opened from, or null for a machine reached by typing an address. /// /// /// /// ◆ What the shell matches this tab back to a host row with, because the name will not do it. /// 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 can answer with a different machine that happens to /// share the name. The reconnect under a stopped tab dials whatever that lookup finds, which is /// the one place being approximately right is dialling a stranger. See /// MainWindowViewModel.HostFor. /// /// /// It survives a lock, which the row objects do not. This is the item's sync identity rather than /// an index into the list on screen — see VaultItem<TSecret>.EntityId — 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. /// /// /// 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. /// /// internal Guid? HostId { get; init; } /// The account and endpoint, for the pane header and the status bar. internal string Address { get; } /// /// When the session behind this tab opened, for the status bar's elapsed timer — or null while there is /// none. /// /// /// Set by MainWindowViewModel from its own clock at the same moment it calls /// or constructs a tab that already carries a session id, rather than read here from /// 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. /// internal DateTimeOffset? StartedAt { get; set; } /// /// The negotiated server-to-client cipher, for the status bar — or null while there is no session. /// /// /// Set alongside , 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. /// internal string? Cipher { get; set; } /// The accepted host key's algorithm, e.g. ssh-ed25519 — or null while there is no session. /// See ; set and kept the same way, for the same reason. internal string? HostKeyAlgorithm { get; set; } /// /// The display name of the key or credential that authenticated, or null when a typed password did, or /// null while there is no session. /// /// /// 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. /// internal string? IdentityLabel { get; set; } /// /// How far this connection got, step by step, for the card that stands in for the pane. /// /// /// /// 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. /// /// /// Every row here is reported, not guessed. The states come from /// , 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. /// /// internal IReadOnlyList Steps { get; } /// How many steps are behind the attempt, for the card's track. /// /// Counted rather than stored, and it counts 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. /// internal int StepsDone => Steps.Count(step => step.IsDone); /// How many steps there are, for the card's track. internal int StepCount => Steps.Count; /// [ObservableProperty] private TerminalTabState state; /// /// What this tab has to say for itself while it has no pane. /// /// /// 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. /// [ObservableProperty] private string status; /// /// Whether the shell behind this tab is still running. /// /// /// 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. /// [ObservableProperty] private bool isLive; /// /// Whether this is the tab whose pane is showing. /// /// /// A flag on the tab as well as a selection on the shell, because the strip is an /// ItemsControl of buttons rather than a control that owns a selection — and a button has no /// :selected pseudo-class to style against. The shell writes it; nothing else does. /// [ObservableProperty] private bool isSelected; /// /// Whether this tab is the thing the window is currently showing. /// /// /// Not the same question as , 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 MainWindowViewModel.IsHostsShowing. The shell writes it, from the selection and the surface /// together. /// [ObservableProperty] private bool isShowing; /// /// How the shell behind this tab ended, or null while it has not. /// /// /// 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. /// internal SshSessionEnd? EndReason { get; private set; } /// /// The sentence the shell's end came with, or null while there is none. /// /// /// 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. /// internal string? EndDescription { get; private set; } /// Whether this tab has a session that has stopped, and something to say about it. internal bool HasEnded => State is TerminalTabState.Open && !IsLive; /// /// The sentence the strip under a stopped terminal prints: why a reconnect was refused if one just was, /// and otherwise how the session ended. /// /// /// /// 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. /// /// /// 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 — /// clears it — so a non-empty one on a stopped tab can only be something that /// happened after the shell did. /// /// internal string? EndedDetail => Status is { Length: > 0 } spoken ? spoken : EndDescription; /// Whether this tab's session ended because the connection broke rather than because the shell did. /// /// 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. /// internal bool IsDropped => EndReason is SshSessionEnd.ConnectionLost; /// /// What the status bar says instead of CONNECTED, or null while nothing has ended. /// /// /// /// 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. /// /// /// 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. /// /// internal string? EndedSummary => EndReason switch { SshSessionEnd.ConnectionLost => "DISCONNECTED", SshSessionEnd.RemoteClosed => "SHELL EXITED", _ => null, }; /// /// Whether this tab is in a state where dialling again would mean something. /// /// /// /// 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. /// /// /// It says the tab is willing, not that the shell can do it. 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 /// MainWindowViewModel.CanReconnectSelectedTab. /// /// internal bool CanOfferReconnect => HasEnded || IsFailed; /// Whether there is a pane behind this tab. internal bool HasSession => State is TerminalTabState.Open; /// Whether this tab is still waiting on a connection. internal bool IsConnecting => State is TerminalTabState.Connecting; /// Whether this tab is a connection that never happened. internal bool IsFailed => State is TerminalTabState.Failed; /// /// Records that the connection has reached a named step. /// /// /// /// Everything before 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. /// /// /// 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. /// /// 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)); } /// Takes ownership of the session that has just opened for this tab. 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(); } /// /// Records that the connection this tab was opened for did not happen. /// /// /// 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. /// 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; } /// /// Records that the shell behind this tab has stopped, and how. /// /// Which of the three ways it ended. /// The sentence the pump wrote into the pane, for the detail line. /// /// /// 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 which ending it was, which is the difference between a shell that is /// finished and a machine that is probably still there — see . /// /// /// 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. guards its own reports for the same reason. /// /// 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)); } /// /// Puts this tab back to connecting, for an attempt that is about to be made on the same machine. /// /// /// /// 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. /// /// /// The session id is dropped here, and the pane it named is the caller's to reclaim. 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 MainWindowViewModel.ReconnectTabAsync, 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. /// /// /// 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. /// /// 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)); } /// Marks every step done, for a connection that is no longer being waited on. 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)); } /// /// and 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. /// /// The strip under a stopped terminal reads this as well as the ending; see . partial void OnStatusChanged(string value) => OnPropertyChanged(nameof(EndedDetail)); partial void OnIsLiveChanged(bool value) { OnPropertyChanged(nameof(HasEnded)); OnPropertyChanged(nameof(CanOfferReconnect)); } }