using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Security.Authentication; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Auth; using DodoSSH.Client.Import; using DodoSSH.Client.ObjectStore; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; namespace DodoSSH.Client.Shell.ViewModels; /// Which of the shell's mutually exclusive screens is showing. internal enum ShellState { /// Reading the cache to find out whether this machine is enrolled. Starting = 0, /// Nothing is cached. The user has to name a server and sign in, which needs a network. NeedsServer = 1, /// Signed in, but the account has no vault key yet. NeedsEnrollment = 2, /// /// Showing the recovery code, and refusing to move on until the user confirms they have it. /// /// /// A separate state rather than a dismissible banner, because this is the only moment the code exists. /// Losing it along with the passphrase means the vault is unrecoverable and there is no server-side /// reset by design — so this is the one screen a user must not be able to click past. /// ShowingRecoveryCode = 3, /// Enrolled. The passphrase opens the vault, with or without a network. Locked = 4, /// Open. Unlocked = 5, } /// /// Which of the unlocked application's screens the nav rail is pointing at. /// /// /// /// Only meaningful while . The setup and unlock screens are /// , and the two are deliberately different things: one is how far through getting /// in you are, the other is what you are looking at once you are. /// /// /// is in this list without anything behind it, which is stated on the screen itself /// rather than hidden by dropping it from the rail. See docs/design-import-gaps.md: teams are M3, and /// a rail that quietly had four entries would make its eventual arrival look like a new product rather than /// a milestone. was the other one until M2 built it. /// /// internal enum ShellScreen { /// The host list, which is where the application opens. Hosts = 0, /// File transfer over SFTP: two directory panes and a queue. Transfers = 1, /// Everything in the vault that is not a host. Vault = 2, /// Shared vaults and the people in them. Nothing implements it yet. Team = 3, /// Preferences. Preferences = 4, /// The host keys this keychain has approved. /// /// Appended rather than slotted in beside the keychain screen it came out of. These values are written /// into NavRail.axaml as x:Static literals and read by tests; renumbering them would be a /// silent change to what every one of those means. /// KnownHosts = 5, /// Importing hosts from the machine's own ~/.ssh/config. /// /// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task /// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for /// something almost nobody is looking at. /// Import = 6, /// The saved commands in this keychain. /// Snippets = 7, /// What has been connected to, and what has been changed. /// Logs = 8, } /// /// What the area beside the nav rail is showing: one of the rail's screens, or a terminal. /// /// /// /// Two properties rather than a sixth , and the reason is that a terminal is not a /// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can /// be opened from any screen — and when it is dismissed the user expects to be back where they were, which /// means "which page" has to survive "a terminal is showing". Folding the terminal into /// would need a private field remembering the page underneath, which is this pair /// with one half hidden. /// /// internal enum ShellSurface { /// The screen named by . Page = 0, /// The pane of the tab named by . Terminal = 1, } /// /// The shell: get to an unlocked vault, then hand over to . /// /// /// /// The order of these states is the product's onboarding story. A fresh machine needs a server URL and one /// browser sign-in; everything about the identity provider comes from /// /.well-known/dodossh-configuration, so the user never configures an authority or a client id. /// After that the network is optional — the cached salt and wrapped bundle mean the passphrase alone /// unlocks, which is the state the application spends nearly all of its life in. /// /// /// Key derivation runs on a worker thread. At the shipped profile it is a third of a second of solid CPU, /// and doing that on the UI thread would freeze the window at exactly the moment the user is watching it. /// /// internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisposable { private readonly ClientPaths paths; private readonly ClientCacheFactory caches; private readonly TerminalWorkspace workspace; /// /// The concrete store rather than IKnownHostStore, because this is where its lifecycle belongs: /// the interface is what the handshake asks, and opening a vault behind it, refreshing it and forgetting /// it are this state machine's business. The same instance was handed to the connection factory when the /// application was composed. /// private readonly VaultKnownHostStore knownHosts; /// /// Whatever this machine can keep a device key in, chosen once at composition. An interface because the /// answer is a platform decision — see ADR 0007 — and because a machine with no TPM gets a store that /// reports itself unavailable rather than a null this state machine would have to check for. /// private readonly IDeviceKeyStore deviceKeys; private readonly SignInHandler signIn; /// /// Optional, and null is not merely "not configured": a shell with no resume handler is one that can /// only be online because somebody signed in during this run, which is what every test that asserts /// offline behaviour relies on. /// private readonly ResumeHandler? resume; private readonly TimeProvider clock; private readonly Argon2Profile? passphraseProfile; /// /// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the /// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself /// rather than one that fails silently — see . /// private readonly Func? copyToClipboard; /// /// Created once and kept for the life of the process, like and for the same /// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy /// a transfer in flight any more than it closes a running shell. See . The vault /// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list. /// private readonly TransfersViewModel transfers; /// /// Where connections are recorded, for as long as a vault is open to record them into. /// /// /// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it, /// and for the same reason: the thing that calls it — the workspace — outlives every lock. /// private readonly ConnectionRecorder connectionLog; private readonly TeamsViewModel teams; /// /// The tab standing in for each connection that has been asked for and has not answered yet. /// /// /// Keyed on the attempt rather than on the host, because connecting no longer holds the vault and two /// attempts against the same machine are a thing a user can now do by clicking twice. An entry lives /// exactly as long as the attempt: it goes when the session opens, when the connection is refused, and /// when the user closes the tab out from under it. /// private readonly Dictionary attempts = []; private IVaultServer? connection; /// The refresh token last written to the cache, so a rotation is noticed without reading it back. private string? rememberedToken; /// Guards against two resume attempts overlapping. /// /// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and /// the Sync button — and what has to be prevented is a second attempt starting while the first is /// waiting on a token endpoint, not a data race. /// private bool resuming; private bool disposed; /// /// Establishes a connection to a server. /// /// /// A delegate rather than a direct call to , so this whole /// state machine can be driven by a test against an in-memory server. Sign-in is the one step that /// genuinely needs a browser and a network, and letting it be the reason nothing else is testable /// would be the wrong trade. /// internal delegate Task SignInHandler(Uri serverUrl, CancellationToken cancellationToken); /// /// Re-establishes a connection from a remembered sign-in, without a browser. /// /// /// A delegate for the same reason is one: a real resume needs discovery /// and a token endpoint, and making that the only way to reach this state machine would put the whole /// "comes back online by itself" behaviour out of reach of a test. /// internal delegate Task ResumeHandler( Uri serverUrl, string refreshToken, CancellationToken cancellationToken); /// /// How file-transfer sessions are opened. The same object as the connection factory in the composed /// application — one type implements both — and a separate parameter because it is a separate capability /// and the tests that drive this state machine have no use for it. /// internal MainWindowViewModel( ClientPaths paths, ClientCacheFactory caches, TerminalWorkspace workspace, VaultKnownHostStore knownHosts, IDeviceKeyStore deviceKeys, SignInHandler signIn, TimeProvider clock, ISftpSessionFactory sftpSessions, Argon2Profile? passphraseProfile = null, ResumeHandler? resume = null, Func? copyToClipboard = null) { this.paths = paths; this.caches = caches; this.workspace = workspace; this.knownHosts = knownHosts; this.deviceKeys = deviceKeys; this.signIn = signIn; this.resume = resume; this.clock = clock; this.passphraseProfile = passphraseProfile; this.copyToClipboard = copyToClipboard; transfers = new TransfersViewModel(sftpSessions, clock); // Built once, like the workspace it writes for, and given a vault only while one is open. It has to // outlive every lock for the same reason the workspace does: a shell opened before a lock is still // running after it, and the entry it eventually produces belongs to the vault it was made in. connectionLog = new ConnectionRecorder(clock, Environment.MachineName); this.workspace.ConnectionLog = connectionLog; // Both dependencies as functions rather than values: the connection arrives after sign-in and the // session after unlock, and both go away again on lock. Capturing either would give this screen a // reference that outlives what it points at — which for a session means holding vault keys past the // moment locking is supposed to have zeroed them. teams = new TeamsViewModel(() => connection, () => Vault?.Session); // Subscribed for the life of the process, because the workspace lives that long and so does the tab // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; } [ObservableProperty] private ShellState state = ShellState.Starting; [ObservableProperty] private string statusMessage = "Opening the local cache…"; [ObservableProperty] private bool isBusy; /// Whether the unlock screen should offer a gesture instead of the passphrase. [ObservableProperty] private bool canUnlockWithDevice; /// Whether an unlocked vault should offer to register this machine. [ObservableProperty] private bool canRegisterDevice; /// Whether this machine has a device key to withdraw. [ObservableProperty] private bool canForgetDevice; /// /// Whether this machine can neither register a device key nor withdraw one. /// /// /// Not the negation of either flag on its own, which is exactly why it is worth a name. The two are /// independent: a machine with no TPM cannot register, and a machine already registered has nothing to /// register either — and only the second has something to take back. Both false at once is the one case /// that means "this machine has nowhere to keep a key", which is worth saying out loud on a preferences /// screen where the alternative is a section with no controls in it and no explanation. /// internal bool HasNoDeviceKeyOption => !CanRegisterDevice && !CanForgetDevice; /// /// The address dotnet run --project src/DodoSSH.Api actually serves, so the first launch after /// a clone works without the user having to know a port. This was https://localhost:7217, which /// is the API's second launch profile: the first is HTTP on 5233 and is the one both the /// README and a plain dotnet run select, so nothing was listening on 7217. Pointing an HTTPS /// client at a plaintext port fails as "The SSL connection could not be established", which sends /// people looking for a certificate problem — see . A real /// deployment is HTTPS behind a proxy and its address is typed over this one; the placeholder in the /// setup card shows that shape. /// [ObservableProperty] private string serverUrl = "http://localhost:5233"; [ObservableProperty] private string passphrase = string.Empty; [ObservableProperty] private string confirmPassphrase = string.Empty; /// Shown once, immediately after enrolling, and never stored anywhere. [ObservableProperty] private string? recoveryCode; [ObservableProperty] private bool recoveryCodeWrittenDown; /// Who this machine is enrolled as, readable without the passphrase. [ObservableProperty] private string? accountName; [ObservableProperty] private VaultViewModel? vault; /// The approved-host-keys screen, which exists exactly as long as the vault behind it does. /// /// Assigned from and nowhere else, so the three paths that open or close a /// vault — unlocking, locking and signing out — cannot get out of step with it. /// [ObservableProperty] private KnownHostsViewModel? knownHostsScreen; /// [ObservableProperty] private ImportViewModel? importScreen; /// [ObservableProperty] private SnippetsViewModel? snippetsScreen; /// [ObservableProperty] private LogsViewModel? logsScreen; /// /// The teams screen, which the window binds to whether or not a vault is open. /// /// /// Not nullable and never replaced, for the reason is not: the screen reads a /// server rather than a vault, and both of its dependencies are fetched through a function at the /// moment they are needed. That means a lock does not have to tear it down and an unlock does not have /// to rebuild it, and the list it is showing survives both. /// internal TeamsViewModel Teams => teams; /// The transfers screen, which the window binds to whether or not a vault is open. /// /// Not nullable and never replaced, unlike . The screen is unreachable while locked — /// the whole shell is — but the object behind it is what holds a transfer that is still running, so a /// property that went null on lock would be a transfer nothing could report on afterwards. /// internal TransfersViewModel Transfers => transfers; /// /// Shells that were left running when the vault was locked. /// /// /// Refreshed by , which is where the policy this reports is explained. /// [ObservableProperty] private int liveSessionCount; internal bool HasLiveSessions => LiveSessionCount > 0; /// The count as a sentence, because a bare number on a lock screen explains nothing. internal string LiveSessionSummary => LiveSessionCount == 1 ? "1 shell is still connected and still running." : $"{LiveSessionCount} shells are still connected and still running."; /// Where the embedded browser should navigate. internal Uri TerminalPageUrl => workspace.PageUrl; /// /// Types into a terminal on behalf of something that is not the keyboard. /// /// /// /// Exposed on the shell rather than reached through the workspace directly, because the workspace is a /// composition-root object and a view has no business holding one — the same reason tabs go through /// here rather than through TerminalWorkspace.CloseSessionAsync. /// /// /// The Android head's accessory key row is what needs it: a software keyboard has no Ctrl, Esc, Tab or /// arrows, so those keys are drawn and their bytes sent from here. Ordinary typing never comes this /// way — it goes from the renderer straight down the socket. /// /// internal ValueTask SendTerminalInputAsync(uint sessionId, ReadOnlyMemory data) => workspace.SendInputAsync(sessionId, data, CancellationToken.None); /// /// Raised when a terminal session opens, so the view can hand the terminal the keyboard. /// /// /// Forwarded from rather than exposed there directly, /// because is replaced on every unlock and the view would have to re-subscribe /// each time. This shell is the window's data context for the life of the process, so one /// subscription is enough. /// internal event EventHandler? TerminalSessionOpened; internal bool IsStarting => State == ShellState.Starting; internal bool IsNeedingServer => State == ShellState.NeedsServer; internal bool IsNeedingEnrollment => State == ShellState.NeedsEnrollment; internal bool IsShowingRecoveryCode => State == ShellState.ShowingRecoveryCode; internal bool IsLocked => State == ShellState.Locked; /// Whether the unlock card itself is showing, rather than the confirmation over it. /// /// Its own property because the markup cannot express IsLocked && !IsConfirmingSignOut, /// and the two cards genuinely swap rather than stack: the unlock card is already near the height the /// window guarantees at its minimum size, so putting a second question underneath it would push /// buttons off a screen with nothing to scroll. /// internal bool IsAskingForThePassphrase => IsLocked && !IsConfirmingSignOut; internal bool IsUnlocked => State == ShellState.Unlocked; /// Whether a connection to the server is currently held. internal bool IsOnline => connection is not null; /// /// Whether everything this machine has changed has reached the server. /// /// /// /// The design's titlebar says "SYNCED" beside a green dot, unconditionally. This is the honest version /// of that claim, and it is deliberately conservative: true only while a connection is held, the last /// pass actually reached the server, and the outbox is empty. /// /// /// The middle condition is the one that is easy to leave out, and was. Holding an IVaultServer /// proves a sign-in once succeeded and nothing more — it is obtained once and never dropped — so a /// laptop whose lid has been shut all afternoon still has one, with an empty outbox, which is precisely /// the shape of a green light that is lying. See VaultViewModel.LastSyncFailed. /// /// /// It still does not mean this machine has a colleague's change from a second ago. Nothing short of a /// completed pull could say that, and the pull runs on a one-minute timer. What it means is that this /// machine can reach the server and has nothing stuck. /// /// internal bool IsFullySynced => IsOnline && Vault is { PendingChanges: 0, LastSyncFailed: false }; /// The same fact as a word, for the titlebar. internal string SyncLabel => (IsOnline, Vault?.LastSyncFailed ?? true, Vault?.PendingChanges ?? 0) switch { (false, _, _) => "OFFLINE", (true, true, _) => "UNREACHABLE", (true, false, 0) => "SYNCED", (true, false, 1) => "1 PENDING", (true, false, var pending) => $"{pending} PENDING", }; // ---- Which screen is showing ---- /// /// Which of the nav rail's screens the page area holds. /// /// /// This always names a page, even while a terminal is showing over it — see . /// It is what dismissing a terminal returns to. /// [ObservableProperty] private ShellScreen screen; /// /// Whether the page area is showing rather than a terminal. /// /// /// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express /// IsHostsScreen && IsShowingPages in a binding, so the alternative is five compound /// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse /// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons /// cannot be clicked. See . /// internal bool IsShowingPages => Surface is ShellSurface.Page; internal bool IsHostsScreen => Screen is ShellScreen.Hosts; /// internal bool IsTransfersScreen => Screen is ShellScreen.Transfers; /// internal bool IsVaultScreen => Screen is ShellScreen.Vault; /// internal bool IsTeamScreen => Screen is ShellScreen.Team; /// internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences; /// internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts; /// internal bool IsImportScreen => Screen is ShellScreen.Import; /// internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets; /// internal bool IsLogsScreen => Screen is ShellScreen.Logs; /// /// Whether the nav rail should light its Hosts entry. /// /// /// Not the same question as , and the rail has to ask this one. A terminal /// opened from the hosts screen leaves on Hosts — deliberately, so closing the tab /// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a /// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks /// at once is one too many. /// internal bool IsHostsShowing => IsShowingPages && IsHostsScreen; /// internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen; /// internal bool IsVaultShowing => IsShowingPages && IsVaultScreen; /// internal bool IsTeamShowing => IsShowingPages && IsTeamScreen; /// internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen; /// internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen; /// internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen; /// internal bool IsLogsShowing => IsShowingPages && IsLogsScreen; /// /// Whether the terminal's WebView may be on screen at this instant. /// /// /// /// This is an occlusion rule, not a styling one. The WebView is a native child window on Windows, /// and a child window composites above everything its parent paints — so whatever Avalonia draws in the /// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the /// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a /// locked vault (the unlock card), the page area (every screen uses the full width), and the /// quick-connect palette. /// /// /// The terminal and the pages are exclusive, and that is the whole of the rule. They share one /// rectangle, so exactly one of and this may be true. That is why /// exists as a single enum rather than as two independent flags a caller could set /// to the same value. /// /// /// Not gated on there being a tab. Closing the last tab returns to /// instead, so the empty case never arises — and gating here as well /// would be a second answer to one question. The empty-state sentence lives in the tab strip, which /// Avalonia draws and nothing occludes. /// /// /// Revealing and focusing now happen in the same turn, routinely. Opening a terminal from the /// files screen, or clicking a tab while a page is showing, both flip this from false to true and then /// want the keyboard. NativeControlHost re-pushes its bounds on the next layout pass, so focusing /// microseconds ahead of that pass races the thing the focus depends on. The view answers that by /// posting the focus at DispatcherPriority.Loaded — see MainWindow.axaml.cs. It is not /// answered here, and it cannot be: this property has no way to know when layout ran. /// /// /// Collapsing is cheap and safe. NativeControlHost creates the native control on attach rather /// than on show, so WebView2 still starts, still loads the page and still lets the renderer connect /// while this is false; only the bounds are withheld. Removing the control from the tree would not be /// safe — that detaches it and destroys the whole WebView2 process tree. /// /// internal bool IsTerminalShowing => IsTerminalSurface && SelectedTab is { HasSession: true }; /// /// Whether the terminal half of the window is the half being shown, pane or no pane. /// /// /// Every condition in except the one about there being a session, and it /// is worth its own name because a tab exists before its session does — see /// . This is what "the user is looking at the terminal" means; the /// other two say which of the two things that can be in that rectangle is drawn. /// internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching; /// /// Whether the card that stands in for a pane is showing. /// /// /// /// The other half of , and exclusive with it by construction: a selected /// tab either has a session or it does not. It covers both of the states in which it does not — still /// connecting, and failed — because both are a tab with something to say and nothing to draw it in. /// /// /// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the /// terminal off rather than merely draw over it. See . /// /// internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false }; /// [ObservableProperty] private ShellSurface surface; /// Points the nav rail at a screen. /// /// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me /// something else" and a rail click that changed a screen nobody could see would do nothing visible. /// The tab itself is untouched: its shell goes on running and the strip goes on naming it. /// [RelayCommand] private void ShowScreen(ShellScreen target) { Screen = target; Surface = ShellSurface.Page; } /// Switches to the terminal surface. /// /// /// The other half of , and it exists because the phone's bottom bar /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a /// session or clicking a tab — because its tab strip is always on screen and is itself the way back. /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a /// button, and a button needs a command. /// /// /// Not gated on there being a tab, for the same reason is not: closing /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal /// screen carries an empty state anyway, which is worth being able to reach deliberately. /// /// [RelayCommand] private void ShowTerminal() => Surface = ShellSurface.Terminal; // ---- Open terminals ---- /// /// Every terminal that has been opened this run, in the order they were opened. /// /// /// On the shell rather than on the vault, and that follows from the lock policy rather than from /// convenience. Locking disposes the vault and leaves shells running, so tabs rebuilt per unlock would /// lose sessions that are still connected — the very sessions exists to /// admit to. This object is the window's data context for the life of the process, and so is this list. /// internal ObservableCollection Tabs { get; } = []; [ObservableProperty] private TerminalTabViewModel? selectedTab; internal bool HasTabs => Tabs.Count > 0; private void RaiseTabState() => OnPropertyChanged(nameof(HasTabs)); /// /// Closes one terminal, ending its shell. /// /// /// This is the one thing in the application that deliberately ends a session, which is why it is a tab's /// close button and not a menu item: closing the window somebody's job is running in should take exactly /// as much intent as it looks like it does. Locking does not do this, and neither does anything else. /// [RelayCommand] private async Task CloseTabAsync(TerminalTabViewModel tab) { if (tab is null) { return; } // Removed first, so the workspace's SessionEnded — which fires as the pump unwinds — finds no tab to // mark dead and does nothing. The alternative ordering leaves a window in which a tab that is on its // way out is repainted as disconnected. var index = Tabs.IndexOf(tab); Tabs.Remove(tab); if (ReferenceEquals(SelectedTab, tab)) { // The neighbour, preferring the one on the left, which is where the eye already is. SelectedTab = Tabs.Count == 0 ? null : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; } // The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the // terminal showing — the neighbour above is what it shows — but closing the last one would otherwise // leave a visible WebView with no pane in it, which reads as the application having broken. if (Tabs.Count == 0) { Surface = ShellSurface.Page; } RaiseTabState(); // Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one // changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose // terminal just went would keep a lit dot until something else happened to move the selection. RefreshConnectedHosts(); // What the rectangle holds is decided by the selected tab's state, and the line above may well have // moved the selection from a card to a pane or the other way round. RaiseTerminalState(); if (!tab.HasSession) { // Nothing to end: this tab is a connection that has not happened, or one that never will. The // attempt is forgotten so a handshake still in flight does not come back and reopen a tab the // user has just dismissed — it becomes a session with no tab, which OnVaultSessionOpened adopts // rather than drops, because a running shell nothing names is worse than a tab that reappears. foreach (var attemptId in attempts .Where(entry => ReferenceEquals(entry.Value, tab)) .Select(entry => entry.Key) .ToArray()) { attempts.Remove(attemptId); } return; } await workspace.CloseSessionAsync(tab.SessionId).ConfigureAwait(true); } // ---- Quick connect ---- /// Whether the quick-connect palette is open over the window. /// /// It has to collapse the terminal while it is open — see — which is why /// this is shell state rather than something a view could hold on its own. /// [ObservableProperty] private bool isSearching; [ObservableProperty] private string searchText = string.Empty; /// /// The hosts the palette is offering, best match first. /// /// /// The design's box says "search hosts · run command". Only the first half is here: a command palette /// needs commands to run, and this application has no snippet or saved-command item type — see /// docs/design-import-gaps.md. Offering an empty command list under a box that promised one is /// worse than a box that promises only what it does. /// internal ObservableCollection SearchResults { get; } = []; [ObservableProperty] private HostRowViewModel? selectedSearchResult; /// Whether the palette has anything to offer. /// /// A property rather than {Binding !SearchResults.Count} in the markup. Avalonia's ! is a /// boolean operator: against an int it produces a binding error, IsVisible falls back to /// its default of true, and "No host matches that" is shown permanently — under a list of matches. /// internal bool HasSearchResults => SearchResults.Count > 0; /// Opens the palette, or closes it if it is already open. [RelayCommand] private void ToggleSearch() { if (IsSearching) { CloseSearch(); return; } if (!IsUnlocked) { return; } SearchText = string.Empty; RefreshSearchResults(); IsSearching = true; } /// Dismisses the palette without connecting. [RelayCommand] private void CloseSearch() { IsSearching = false; SearchText = string.Empty; SearchResults.Clear(); SelectedSearchResult = null; OnPropertyChanged(nameof(HasSearchResults)); } /// /// Selects the highlighted host and connects to it. /// /// /// Goes through the vault's own ConnectCommand rather than opening a session directly, so the /// palette inherits every refusal that path already makes — a dangling key binding, a host with no /// username, a host key that has changed. A second connect path would be a second place for those to be /// forgotten. /// [RelayCommand] private async Task ConnectToSearchResultAsync() { if (Vault is not { } vault || SelectedSearchResult is not { } row) { return; } CloseSearch(); // The hosts page, because that is where this connection's questions get asked. An unknown or changed // host key is answered by a prompt drawn on that page and the palette opens from any screen, so // connecting from the files screen without this would leave the question behind the screen that asked // it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not // need to: a refusal that needs an answer puts the page back, which is where this leaves the screen. Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId); // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken // generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its // own source — so passing this one would read as cancellation plumbing that is not there. await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true); } /// /// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one /// that merely contains it, and both come before a match found only in the address. Typing three /// characters of a name people use daily should not put that host third. /// private void RefreshSearchResults() { SearchResults.Clear(); if (Vault is not { } vault) { SelectedSearchResult = null; return; } var query = SearchText.Trim(); var matches = query.Length == 0 ? vault.Hosts.AsEnumerable() : vault.Hosts .Select(host => (host, rank: Rank(host, query))) .Where(candidate => candidate.rank < int.MaxValue) .OrderBy(candidate => candidate.rank) .ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture) .Select(candidate => candidate.host); foreach (var host in matches.Take(8)) { SearchResults.Add(host); } SelectedSearchResult = SearchResults.FirstOrDefault(); OnPropertyChanged(nameof(HasSearchResults)); } private static int Rank(HostRowViewModel host, string query) { if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) { return 0; } if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase)) { return 1; } return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase) ? 2 : int.MaxValue; } /// /// Brings the schema up to date and works out which screen to show. /// /// /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables. /// That is the point of migrating rather than recreating: a user who upgrades while offline must still /// be able to open their vault. /// internal async Task StartAsync(CancellationToken cancellationToken) { try { paths.EnsureCreated(); await caches.MigrateAsync(cancellationToken).ConfigureAwait(true); var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true); if (profile is null) { State = ShellState.NeedsServer; StatusMessage = "Sign in to a DodoSSH server to set this machine up."; return; } AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject; ServerUrl = profile.ServerUrl; State = ShellState.Locked; StatusMessage = $"Enrolled against {profile.ServerUrl}."; // Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key // back. Offering the button without the second would prompt for a key that is not there; without // the first it would prompt for a wrap that is not there. Neither failure is one a user could // make sense of, so the button simply does not appear. CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OperationCanceledException) { State = ShellState.NeedsServer; StatusMessage = $"The local cache could not be opened: {exception.Message}"; } } /// Discovers the server and runs the browser sign-in. [RelayCommand] private async Task SignInAsync(CancellationToken cancellationToken) { if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url)) { StatusMessage = "That is not a valid server URL."; return; } // Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute // URI whose scheme is "localhost" — and then fails much later with something unrelated to the // actual mistake. if (url.Scheme is not ("http" or "https")) { StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:."; return; } await RunAsync( "Opening your browser to sign in…", explain: exception => ExplainSignInFailure(exception, url), work: async () => { connection?.Dispose(); connection = null; connection = await signIn(url, cancellationToken).ConfigureAwait(true); OnPropertyChanged(nameof(IsOnline)); RaiseSyncState(); // The browser is finished with, and what follows is a round trip to the DodoSSH server // that can take a while or fail on its own. Saying so is the difference between a wait // and a hang: a screen still reading "Opening your browser to sign in…" while the server // is the thing struggling sends the user back to a browser that did nothing wrong. StatusMessage = $"Signed in. Asking {url.Host} about your account…"; var outcome = await Provisioner()! .RefreshAsync(ServerUrl, cancellationToken) .ConfigureAwait(true); AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject; StatusMessage = outcome.Message; if (outcome.Status == ProvisionStatus.EnrollmentRequired) { State = ShellState.NeedsEnrollment; return; } // An unlocked vault stays unlocked. This command is reachable from the preferences screen // of a running application — it is how somebody whose sign-in expired gets back online — // and moving the state machine to Locked there would throw an unlock screen over an open // vault whose keys are still in memory, which is neither locked nor honest. if (IsUnlocked) { await RememberSignInAsync(cancellationToken).ConfigureAwait(true); return; } State = ShellState.Locked; }).ConfigureAwait(true); } /// Creates the identity key and the personal vault. [RelayCommand] private async Task EnrollAsync(CancellationToken cancellationToken) { if (Provisioner() is not { } provisioner) { StatusMessage = "Sign in first."; return; } if (!ValidateNewPassphrase()) { return; } await RunAsync( "Creating your keychain. This deliberately takes a moment…", async () => { var chosen = Passphrase; var outcome = await Task .Run( () => provisioner.EnrollAsync( ServerUrl, chosen, Environment.MachineName, "Personal", cancellationToken), cancellationToken) .ConfigureAwait(true); ConfirmPassphrase = string.Empty; RecoveryCode = outcome.RecoveryCode; RecoveryCodeWrittenDown = false; StatusMessage = outcome.Message; // A brand-new account always yields a code. An account someone else already enrolled does // not, and there is nothing to show. State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode; }).ConfigureAwait(true); } /// Leaves the recovery-code screen, once the user says they have it. [RelayCommand] private void ConfirmRecoveryCode() { if (!RecoveryCodeWrittenDown) { StatusMessage = "Confirm you have written the recovery code down first."; return; } // Cleared from memory as well as from the screen. It was never persisted, and keeping it in a view // model for the rest of the session would undo that. RecoveryCode = null; State = ShellState.Locked; StatusMessage = "Unlock with the passphrase you just chose."; } /// Opens the vault. [RelayCommand] private async Task UnlockAsync(CancellationToken cancellationToken) { if (Passphrase.Length == 0) { StatusMessage = "Enter your keychain passphrase."; return; } await RunAsync( "Unlocking…", async () => { var entered = Passphrase; // Off the UI thread: Argon2id at the shipped profile is a third of a second of solid CPU // and would otherwise freeze the window mid-unlock. var outcome = await Task .Run(() => Opener().UnlockAsync(entered, cancellationToken), cancellationToken) .ConfigureAwait(true); StatusMessage = outcome.Message; if (!outcome.IsUnlocked) { return; } Passphrase = string.Empty; await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true); }).ConfigureAwait(true); } /// Opens the vault with this machine's device key instead of the passphrase. /// /// No Task.Run, unlike the passphrase path: there is no Argon2 to pay for here, and the work that /// does block is a Windows consent dialog which belongs on the UI thread anyway. /// [RelayCommand] private async Task UnlockWithDeviceAsync(CancellationToken cancellationToken) { await RunAsync( "Waiting for Windows…", async () => { var outcome = await Opener() .UnlockWithDeviceAsync(deviceKeys, cancellationToken) .ConfigureAwait(true); StatusMessage = outcome.Message; if (!outcome.IsUnlocked) { // A declined gesture leaves the passphrase box exactly where it was, which is the whole // fallback: the user types instead. Nothing about the screen changes but the message. return; } await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true); }).ConfigureAwait(true); } /// /// Registers this machine so a later launch can unlock with a gesture. /// /// /// Needs a network, because the wrap has to reach the server — a wrap that exists only here would be /// lost with the cache file and could never be revoked. Needs an unlocked vault too, because only an /// open session can seal the bundle. /// [RelayCommand] private async Task RegisterDeviceAsync(CancellationToken cancellationToken) { if (Vault is not { } vault || connection is null) { StatusMessage = "Sign in first: registering this machine has to reach the server."; return; } await RunAsync( "Waiting for Windows…", async () => { var name = Environment.MachineName; var registered = await vault.Session .RegisterDeviceAsync(connection.Account, deviceKeys, name, cancellationToken) .ConfigureAwait(true); if (!registered) { StatusMessage = "This machine has nowhere to keep a device key."; return; } CanRegisterDevice = false; CanForgetDevice = true; StatusMessage = $"'{name}' can now unlock without your passphrase."; }).ConfigureAwait(true); } /// /// Withdraws this machine's device key, here and on the account. /// /// /// /// Offered without a confirmation prompt, which is deliberate. The cost of pressing it by accident is one /// passphrase and one re-registration; the cost of a confirmation dialog is a moment's hesitation at the /// point somebody has realised a machine is in the wrong hands. Reversible and urgent beats guarded. /// /// /// Works offline, and says so. What decides whether this machine may unlock itself is entirely local, so /// the useful half always happens — the account being told is the half that can be out of reach. /// /// [RelayCommand] private async Task ForgetDeviceAsync(CancellationToken cancellationToken) { if (Vault is not { } vault) { return; } await RunAsync( "Waiting for Windows…", async () => { var revocation = await vault.Session .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken) .ConfigureAwait(true); CanForgetDevice = false; // Not re-offered here even though it is now true, because registering probes the TPM and // this is not the moment to do it: somebody who has just withdrawn a device is not about to // add one back, and the offer reappears on the next unlock. StatusMessage = revocation switch { DeviceRevocation.Complete => "This machine no longer unlocks without your passphrase, and the account no longer " + "lists it.", DeviceRevocation.LocalOnly => "This machine no longer unlocks without your passphrase. You are offline, so the " + "account still lists it — sign in and withdraw it again to finish.", _ => "There was no device key on this machine.", }; }).ConfigureAwait(true); } /// /// Takes ownership of a freshly opened session, whichever door opened it. /// /// /// Shared by both unlock paths rather than duplicated, because the ordering in here is load-bearing and /// a second copy would be a second chance to get it wrong. /// private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken) { await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true); Vault = new VaultViewModel( session, workspace, knownHosts, () => connection, ReconnectAsync, copyToClipboard, connectionLog); State = ShellState.Unlocked; // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that // has not already registered one. Asked once here rather than recomputed, because the answer // involves a TPM probe. CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); // The other side of the same fact, and it needs its own flag rather than the negation of that one: // "not offered because this machine has no TPM" and "not offered because it is already registered" // are both !CanRegisterDevice, and only the second has anything to withdraw. CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null; await Vault.LoadAsync(cancellationToken).ConfigureAwait(true); // After the load, because what the transfers screen takes from the vault is the host list and an // empty one would leave its picker blank until the next unlock. transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory()); // After the list exists, and it matters after a lock rather than after the first unlock: shells kept // running while the vault was closed, so some of these hosts are connected before their rows are a // second old. RefreshConnectedHosts(); // After the first load, so the list is on screen before anything talks to a server. The loop is // started from the UI thread deliberately: every pass resumes here, which is what keeps the // observable collections single-threaded. // // Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a // server, and that is where a remembered sign-in is resumed. Nothing here has to know whether // this unlock followed a sign-in or a cold launch on a train. // // Deliberately not awaited here, and not done before this point either. Resuming is a discovery // call and a token exchange — a network round trip, and on an unreachable network a slow one — // and unlocking must never wait on one. Everything the unlock screen promises about working // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and // the titlebar says OFFLINE until the round trip this starts has an answer. Vault.StartAutoSync(); } /// /// Points the two process-lifetime stores at the session that has just opened. /// /// /// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is /// called by the workspace — so both are attached here rather than constructed per session, and both are /// released together on every path that closes a vault. /// private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken) { // Before the vault view model, so the first connection after an unlock already knows which host keys // this user has approved. Reading them is one listing; doing it here rather than lazily is what // keeps it off the SSH handshake thread. try { await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true); } catch { // Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed // session is vault keys left in memory for the life of the process, which is precisely what // unlocking must be able to undo. await session.DisposeAsync().ConfigureAwait(true); throw; } // The actor is the account that unlocked, which is what makes this an audit record rather than a // list of events with nobody attached to them. connectionLog.Open(session, session.Profile.UserId); } /// /// Gets this machine online if it is not, and keeps the remembered sign-in current if it is. /// /// /// /// Handed to the vault, which asks once per synchronisation pass. That cadence is the whole design: /// there is no connectivity monitor and no reconnect backoff, because a pass a minute already is one, /// and a machine that comes back from a closed lid is online again within a minute of having a /// network — with nothing pressed and no browser opened. /// /// /// Resuming needs an unlocked vault, and that is deliberate rather than incidental. The /// remembered refresh token is sealed under the vault's own cache key, so this can only succeed after /// somebody has opened the vault — a stolen laptop yields a cache file that cannot reach the account /// any more than it can read the hosts. /// /// /// Every failure returns null and stays quiet, with one exception: a provider that refuses the /// token is not a transient condition and will refuse it again once a minute forever, so that one is /// said out loud and the token is dropped. /// /// private async Task ReconnectAsync(CancellationToken cancellationToken) { if (connection is { } held) { await RememberSignInAsync(cancellationToken).ConfigureAwait(true); return held; } if (resume is not { } handler || resuming || Vault is not { } vault) { return null; } resuming = true; try { return await ResumeAsync(vault, handler, cancellationToken).ConfigureAwait(true); } finally { resuming = false; } } /// /// Split from only so the guard, the flag and the attempt are three short /// things rather than one long one. Everything about why this behaves as it does is up there. /// private async Task ResumeAsync( VaultViewModel vault, ResumeHandler handler, CancellationToken cancellationToken) { try { var token = await vault.Session .ReadRememberedSignInAsync(cancellationToken) .ConfigureAwait(true); if (token is null || !Uri.TryCreate(vault.Session.Profile.ServerUrl, UriKind.Absolute, out var server)) { return null; } var resumed = await handler(server, token, cancellationToken).ConfigureAwait(true); connection = resumed; rememberedToken = token; OnPropertyChanged(nameof(IsOnline)); RaiseSyncState(); // The refresh that just happened may have rotated the token, and the rotated one is the only // one the next launch can use. await RememberSignInAsync(cancellationToken).ConfigureAwait(true); return resumed; } catch (OidcException exception) { // The provider answered and said no: the session was revoked, or the token was rotated and // this machine kept the old one. Retrying costs a round trip a minute and can only ever get // the same answer, so the token goes and the user is told the one thing that fixes it. await ForgetSignInAsync(cancellationToken).ConfigureAwait(true); Announce($"Your sign-in has expired, so this machine is offline: {exception.Message} " + "Sign in again from Preferences to start syncing."); return null; } catch (Exception exception) when (exception is not OutOfMemoryException) { // Everything else is a machine with no network, a server that is down, or a vault that was // locked mid-attempt — all of which are ordinary and all of which resolve themselves. The // titlebar already says OFFLINE; a socket error once a minute would say nothing more. return null; } } /// /// Writes the connection's current refresh token into the vault, if it has changed. /// /// /// Called on every pass rather than driven by an event, because providers rotate the token inside a /// refresh that happens on whatever thread an API call was made from — and a value read once a minute /// is current enough for something only a relaunch reads. A failure here costs one browser sign-in on /// the next launch and nothing else, which is not worth interrupting anybody over. /// private async Task RememberSignInAsync(CancellationToken cancellationToken) { if (connection?.RefreshToken is not { } token || Vault is not { } vault || string.Equals(token, rememberedToken, StringComparison.Ordinal)) { return; } try { await vault.Session.RememberSignInAsync(token, cancellationToken).ConfigureAwait(true); rememberedToken = token; } catch (Exception exception) when (exception is not OutOfMemoryException) { // Left unremembered. The application is signed in for this run either way. } } /// Drops the remembered sign-in, so nothing tries to resume it again. private async Task ForgetSignInAsync(CancellationToken cancellationToken) { rememberedToken = null; if (Vault is not { } vault) { return; } try { await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException) { // A cache that will not take the deletion is one the next launch will fail to resume from and // then delete itself. Nothing here is worth a message. } } /// /// Says something wherever the user is looking. /// /// /// The shell's own message is on the setup and unlock cards, and the status bar shows the vault's — so /// a message about the connection, which is the shell's business but only interesting while somebody /// is using an open vault, has to go to both or it is invisible half the time. /// private void Announce(string message) { StatusMessage = message; if (Vault is { } vault) { vault.Status = message; } } /// /// Closes the vault and forgets every key it held. Open shells keep running. /// /// /// /// Lock is a vault operation, and deliberately not a disconnect. The reason a person locks is /// that they are walking away from the machine, which is exactly the moment a long upgrade, build or /// transfer is most likely to be in flight — so killing every shell would make Lock a button that /// destroys work, and the predictable response is to stop using it and leave the vault open instead. /// The same argument decides it for the idle auto-lock this will grow: an unattended timeout that /// terminated a running job would be worse than the exposure it removes. /// /// /// What "locked" therefore describes. Disposing the vault zeroes the identity keys, the vault /// keys and the cache key, so nothing on disk can be read without the passphrase again. It says /// nothing about this machine's access to remote hosts: an SSH channel authenticated at connect time /// needs no vault key to keep running, and the credential it used was already spent. Locking cannot /// retroactively un-authorise a session any more than revocation can — the same honest limit the /// README records for a removed team member. So a locked DodoSSH still holds open, authenticated /// channels, and is shown on the unlock screen rather than left to be /// inferred from a terminal that the lock screen hides. /// /// /// The count is a snapshot taken here. While locked it can only fall — opening a session needs the /// vault — so a stale value over-reports and never under-reports, which is the safe direction for a /// warning of this kind. /// /// [RelayCommand] private async Task LockAsync() { // First, and before the session it read from goes: a synchronisation pass may be in flight, and it // ends by refreshing this store. Detaching now makes that refresh a no-op instead of a set of pins // reappearing behind a lock screen. knownHosts.Close(); // Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is // about to be disposed. Tickets already open keep the repository they were opened against, so a // shell still running closes out into the vault it was actually made in. connectionLog.Close(); // Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is // holding references to them. What it does not give up is its connection or its queue — a transfer // in flight is exactly the work this method exists not to destroy. transfers.Detach(); if (Vault is { } open) { Vault = null; await open.DisposeAsync().ConfigureAwait(true); } LiveSessionCount = workspace.LiveSessionCount; // A confirmation armed on the preferences screen must not survive onto the unlock screen, where // the same card is offered with a warning it can no longer count. IsConfirmingSignOut = false; State = ShellState.Locked; StatusMessage = "Locked."; } // ---- Signing out ---- /// Whether the sign-out confirmation is showing. /// /// A state rather than a dialog, for the same reason the recovery code is a screen: this is the one /// action in the application that destroys something a user cannot get back from here — an unpushed /// change — and it has to be able to say what is about to go before it goes. /// [ObservableProperty] private bool isConfirmingSignOut; /// /// What signing out costs, on this machine, right now. /// /// /// /// The count is the part worth having. Everything else in the vault is on the server and comes back /// with the next sign-in; an operation still in the outbox exists nowhere else in the world, and /// "your changes will be lost" without a number leaves somebody guessing whether it means theirs. /// /// /// A locked vault cannot be counted — the outbox is sealed under the key the vault holds — so it gets /// the honest form of the same warning rather than a zero it has not earned. /// /// internal string SignOutWarning => (IsUnlocked, Vault?.PendingChanges ?? 0) switch { (false, _) => "Anything this machine changed and has not sent to the server yet will be lost. It cannot be " + "counted from here, because the keychain is locked.", (true, 0) => "Everything this machine has changed has reached the server, so nothing will be lost.", (true, 1) => "1 change has not reached the server yet and will be lost. Sync first to keep it.", (true, var pending) => $"{pending} changes have not reached the server yet and will be lost. Sync first to keep them.", }; /// Asks whether the user means it. [RelayCommand] private void SignOut() { // Taken now so the card can disclose it, on the same reasoning as the lock screen's: signing out // does not close a shell any more than locking does, and a screen that sends somebody back to // "connect to your server" while their upgrade is still running should say so. LiveSessionCount = workspace.LiveSessionCount; OnPropertyChanged(nameof(SignOutWarning)); IsConfirmingSignOut = true; } /// Thinks better of it. [RelayCommand] private void CancelSignOut() => IsConfirmingSignOut = false; /// /// Signs out: closes the vault, withdraws this machine, and deletes its copy of everything. /// /// /// /// What this does and does not destroy. It empties the local cache — the profile, the wrapped /// bundle, the item mirror, the outbox and the conflict log — and forgets this machine's device key /// here and on the account. The vault itself is on the server and is untouched, which is what makes /// this safe to offer beside a passphrase box: somebody who has forgotten their passphrase can reset /// this machine and sign in again, and the only thing they lose is what this machine had not yet sent. /// /// /// Ordered so that a failure cannot leave a half-signed-out machine. The device is withdrawn /// while there is still a session and a connection to withdraw it through; the vault is closed before /// the cache under it is emptied; and the cache is emptied last, because it is the step that makes /// this machine unenrolled and everything before it is a courtesy that a wiped profile makes moot. /// /// /// It does not end the session at the identity provider — there is no back channel to it from here, /// and pretending otherwise would be the sort of claim this project writes down instead of implying. /// The refresh token this machine held is dropped and never used again; the provider's own session /// outlives it, which is what the preferences screen says out loud. /// /// [RelayCommand] private async Task ConfirmSignOutAsync(CancellationToken cancellationToken) { await RunAsync( "Signing out…", async () => { IsConfirmingSignOut = false; await WithdrawThisMachineAsync(cancellationToken).ConfigureAwait(true); // As Lock does, and before the session it reads from goes. knownHosts.Close(); connectionLog.Close(); // The same detach locking does, and the same reasoning carried one step further: the host // rows go because the vault behind them is about to be disposed, and the session and its // queue stay because a transfer in flight is somebody's work. Signing out is the strongest // thing this application does to itself and it still does not destroy that, for exactly the // reason it does not close a shell — quitting DodoSSH is what ends both. transfers.Detach(); if (Vault is { } open) { Vault = null; await open.DisposeAsync().ConfigureAwait(true); } connection?.Dispose(); connection = null; rememberedToken = null; await caches.ResetAsync(cancellationToken).ConfigureAwait(true); LiveSessionCount = workspace.LiveSessionCount; AccountName = null; Passphrase = string.Empty; ConfirmPassphrase = string.Empty; RecoveryCode = null; RecoveryCodeWrittenDown = false; CanUnlockWithDevice = false; CanRegisterDevice = false; CanForgetDevice = false; State = ShellState.NeedsServer; OnPropertyChanged(nameof(IsOnline)); RaiseSyncState(); StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the " + "keychain itself is untouched. Sign in to set this machine up again."; }).ConfigureAwait(true); } /// /// Best effort, and swallowed on purpose. Withdrawing the device is the tidy half of signing out — the /// half that stops the account listing a machine whose key is about to be deleted — and a server that /// cannot be reached, or a keystore that declines, must not be able to strand somebody on a screen /// they asked to leave. The half that decides whether this machine can let itself in happens anyway, /// because the profile holding the wrap is emptied a moment later. /// private async Task WithdrawThisMachineAsync(CancellationToken cancellationToken) { try { if (Vault is { } vault) { await vault.Session .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken) .ConfigureAwait(true); await vault.Session.ForgetSignInAsync(cancellationToken).ConfigureAwait(true); return; } await deviceKeys.ForgetAsync(cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException) { // Nothing to report: the wipe below is what signing out actually is. } } /// public async ValueTask DisposeAsync() { if (disposed) { return; } disposed = true; workspace.SessionEnded -= OnWorkspaceSessionEnded; knownHosts.Close(); // Detached before it is disposed, so a session torn down after this point finds nothing to post to // rather than a completed channel. Disposed rather than merely closed, because it owns a background // task — and it waits only as long as that task takes to stop, never for the queue to drain. workspace.ConnectionLog = null; await connectionLog.DisposeAsync().ConfigureAwait(false); // Before the vault, and it waits: a transfer still writing has an open remote file and an open local // one, and a process that exits while those are in flight leaves a part file longer than the bytes // that reached it. await transfers.DisposeAsync().ConfigureAwait(false); if (Vault is { } open) { await open.DisposeAsync().ConfigureAwait(false); } connection?.Dispose(); } /// /// The passphrase is the entire defence for the vault — docs/crypto.md §2 says so plainly, and no /// server-side reset exists. A length floor is a crude check and still the one that matters most. /// private bool ValidateNewPassphrase() { if (Passphrase.Length < 12) { StatusMessage = "Use a passphrase of at least 12 characters."; return false; } if (!string.Equals(Passphrase, ConfirmPassphrase, StringComparison.Ordinal)) { StatusMessage = "The two passphrases do not match."; return false; } return true; } /// /// Sync limits come from the server when there is one, so a batch is never larger than this /// particular deployment accepts. Offline, the defaults apply and nothing is pushed anyway. /// private SessionOpener Opener() => new(caches, clock, connection?.SyncOptions); private AccountProvisioner? Provisioner() => connection is null ? null : new AccountProvisioner( connection.Account, connection.KeyBinding, caches, clock, passphraseProfile); /// /// Every command funnels through here so the busy flag and the failure message are handled once. A /// command that forgot either would leave the window permanently disabled or silently doing nothing. /// /// Shown while the work runs. /// The work. /// /// Turns a failure into something a user can act on. Optional, because most failures here already /// carry their own explanation; the ones that do not are the ones crossing into another process's /// vocabulary, where the exception describes a symptom and not the mistake. /// private async Task RunAsync( string busyMessage, Func work, Func? explain = null) { if (IsBusy) { return; } IsBusy = true; StatusMessage = busyMessage; try { await work().ConfigureAwait(true); } catch (OperationCanceledException) { StatusMessage = "Cancelled."; } catch (Exception exception) { StatusMessage = explain?.Invoke(exception) ?? exception.Message; } finally { IsBusy = false; } } /// /// /// Two cases earn a translation rather than the exception's own words, and both are cases where the /// exception names a symptom belonging to somebody else's process. /// /// /// Pointing an HTTPS client at a plaintext port reports "The SSL connection could not be /// established", which sends people looking for a certificate problem. The scheme is the mistake, and /// the development stack serves HTTP, so this is the first thing a new user will hit. /// /// /// A 5xx from the DodoSSH server is the other. By the time it arrives the browser flow has already /// succeeded — the identity provider authenticated the user and the tokens are in hand — so "The /// server returned 500" read underneath a sign-in button is naturally taken as the sign-in having /// failed, and the search starts in the wrong place. Naming which server, and saying that its own /// logs hold the reason, is the whole content of the fix; the client cannot know more than that. /// /// private static string ExplainSignInFailure(Exception exception, Uri server) { if (exception is DodoSshApiException api && (int)api.StatusCode >= 500) { return $"Signing in worked. The DodoSSH server at {server.Host} then failed while answering " + $"for your account ({(int)api.StatusCode}), which is a fault on the server rather than " + "anything to fix here — its own logs carry the reason."; } var secureChannelFailed = exception is HttpRequestException && exception.GetBaseException() is AuthenticationException; if (secureChannelFailed && server.Scheme is "https") { var plain = new UriBuilder(server) { Scheme = "http" }.Uri; return $"{exception.Message} {server.Host} answered, but not with TLS. If this is a " + $"development server it probably serves plain HTTP — try {plain.GetLeftPart(UriPartial.Authority)}."; } return exception.Message; } /// /// One place for the subscription, so unlocking, locking and disposing all route through it rather /// than each remembering to detach. /// partial void OnVaultChanged(VaultViewModel? oldValue, VaultViewModel? newValue) { if (oldValue is not null) { oldValue.PropertyChanged -= OnVaultPropertyChanged; oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged; // The three connection events are kept while an attempt is still in flight, and that is not an // oversight. Locking does not end a handshake any more than it ends a shell — the workspace is // what holds both, and it outlives every vault — so a connection started just before a lock still // has an answer coming, and the tab standing in for it is still in the strip afterwards, because // tabs are this object's rather than the vault's. Detaching here would strand that tab on // "connecting…" for ever and leave the session it eventually opened with nothing in the window // naming it, and so no way to close it. The subscription dies with the vault once the attempt // resolves: the vault holds the handler, not the other way round. if (attempts.Count == 0) { oldValue.ConnectionStarting -= OnVaultConnectionStarting; oldValue.ConnectionFailed -= OnVaultConnectionFailed; oldValue.SessionOpened -= OnVaultSessionOpened; } } if (newValue is not null) { newValue.ConnectionStarting += OnVaultConnectionStarting; newValue.ConnectionFailed += OnVaultConnectionFailed; newValue.SessionOpened += OnVaultSessionOpened; newValue.PropertyChanged += OnVaultPropertyChanged; // The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts // disconnected — so without this the status dots go out once a minute underneath terminals that // are still open. The rows belong to the vault and the connection state belongs to the shell, // which is exactly why the shell has to repaint them rather than the vault carrying the flag. newValue.Hosts.CollectionChanged += OnVaultHostsChanged; } // Built from the vault and thrown away with it, here rather than at each of the three places a // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind // would keep a disposed vault alive and repaint a screen nobody can reach. KnownHostsScreen?.Detach(); KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue); ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator()); SnippetsScreen?.Detach(); SnippetsScreen = newValue is null ? null : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync); LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections); RaiseSyncState(); } /// /// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox /// depth, which lives on the vault, and re-raising the shell's two derived properties on every /// notification a busy vault produces would repaint the titlebar on every keystroke in an editor. /// private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal) || string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal)) { RaiseSyncState(); } } private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshConnectedHosts(); private void RaiseSyncState() { OnPropertyChanged(nameof(IsFullySynced)); OnPropertyChanged(nameof(SyncLabel)); // The same fact from a third direction: what signing out would cost is the outbox depth, and a // confirmation card left showing a count from before the last pass would be quoting a number that // has since been sent. OnPropertyChanged(nameof(SignOutWarning)); } /// /// Puts a tab in the strip for a connection that has only just been asked for. /// /// /// /// This is what stops connecting looking like the application having stopped. The tab appears in the same /// turn as the click, carrying its own status, and the window switches to it — so a handshake against a /// machine that is asleep is a card that says which machine, rather than a status line under a window /// that does nothing for thirty seconds. /// /// /// Kept by attempt id rather than by label: several connections can be in flight now that one does not /// hold the vault, and two of them can perfectly well be to the same host. /// /// private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e) { var tab = new TerminalTabViewModel(e.Label, e.Address); attempts[e.AttemptId] = tab; AdoptTab(tab); } /// /// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between /// them and nothing more. /// private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) { if (!attempts.Remove(e.AttemptId, out var tab)) { // No tab was opened for this attempt, which means the user closed the connecting tab while the // handshake was still running. The session is real and has to be adopted rather than dropped: // dropping it would leave a shell running with nothing in the window naming it. AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address)); RefreshConnectedHosts(); return; } tab.Opened(e.SessionId); // The pane exists from this moment, so what the rectangle should hold has changed — the card goes and // the WebView comes back. Only for the tab being looked at, which is what these flags already ask. RaiseTerminalState(); // Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was // no pane to name until this line. Activate(tab); RefreshConnectedHosts(); TerminalSessionOpened?.Invoke(this, EventArgs.Empty); } /// /// Answers a connection that did not become a session. /// /// /// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab /// carrying its reason — connecting no longer holds the window, so the user may be three screens away by /// now, and the status line they are not looking at is not where a failure should end. A host key /// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put /// back where the question is being asked. /// private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e) { if (!attempts.Remove(e.AttemptId, out var tab)) { return; } if (!e.IsAwaitingAnAnswer) { tab.Failed(e.Reason); RaiseTerminalState(); return; } var index = Tabs.IndexOf(tab); Tabs.Remove(tab); RaiseTabState(); if (ReferenceEquals(SelectedTab, tab)) { // The neighbour, preferring the one on the left, exactly as closing a tab by hand does. SelectedTab = Tabs.Count == 0 ? null : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; } // The screen the question is drawn on, and the page rather than a terminal. A connection can be // started from the palette on any screen, so without this the prompt would be behind whatever the // user was looking at, with the connection waiting on an answer they cannot reach. Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; } /// /// Takes a tab into the strip and shows it. /// /// /// One method rather than one per way of opening a session, so the order of these steps is decided once. /// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs /// against a strip that already shows what it is about. /// private void AdoptTab(TerminalTabViewModel tab) { Tabs.Add(tab); RaiseTabState(); // Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab // that has one. A tab that is still connecting has none, and selecting it shows the card instead. SelectedTab = tab; // The surface, but deliberately not the screen. A session opened from the files screen shows its // terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or // clicking away comes back to the transfer that is presumably still running. Surface = ShellSurface.Terminal; if (tab.HasSession) { TerminalSessionOpened?.Invoke(this, EventArgs.Empty); } } /// /// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the /// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the /// renderer. See for why the last of those is not awaited. /// partial void OnSelectedTabChanged(TerminalTabViewModel? value) { foreach (var tab in Tabs) { tab.IsSelected = ReferenceEquals(tab, value); } // Which of the two things can be in the terminal's rectangle depends on the selected tab having a // session, so moving the selection is one of the ways that answer changes. It also repaints the // strip's active mark, which follows the selection and the surface together. RaiseTerminalState(); RefreshConnectedHosts(); // The snippets screen names the terminal its buttons will type into, and it has no way to learn that // a different tab is selected — the tab list is the shell's, and a subscription the other way would // be a screen keeping the shell alive. SnippetsScreen?.TargetChanged(); if (value is not null) { Activate(value); } } /// Tells the renderer which pane to show. /// /// /// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a /// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs /// one wrong pane until the next click; blocking the setter would cost the tab strip. /// /// /// The workspace's own token is not available here, so this passes none. The send is a single frame on /// an already-open socket and returns immediately when there is no renderer. /// /// /// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer /// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until /// the handshake finishes. /// /// private void Activate(TerminalTabViewModel tab) { if (!tab.HasSession) { return; } _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask(); } /// Which terminal a snippet would go into right now. /// /// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in, /// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked, /// say, the most recently opened would send a command somewhere the user is not looking. /// /// The connections that are open and therefore have no log entry yet. /// /// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and /// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent — /// an SFTP session has no tab at all, and a tab whose remote hung up still has one. /// private IReadOnlyList LiveConnections() => [ .. connectionLog.Open().Select(open => new LiveConnection( open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)), ]; private InsertTarget CurrentInsertTarget() => SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None; /// Brings one terminal's pane to the front, and shows it. /// /// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come /// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see /// would answer only one of those. /// [RelayCommand] private void SelectTab(TerminalTabViewModel tab) { SelectedTab = tab; Surface = ShellSurface.Terminal; } /// /// Marks a tab dead when its shell ends on its own. /// /// /// Marshalled onto the UI thread, because the workspace raises this from whichever thread the session's /// pump finished on and the tab list is only ever touched from one. The tab stays: its pane still holds /// the scrollback, and the renderer has already written the reason into it. /// private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Dispatcher.UIThread.Post(() => { if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) { tab.IsLive = false; } RefreshConnectedHosts(); }); /// /// Repaints the host list's status dots from the tab list. /// /// /// Matched on the label, which is what a tab was named after, because that is the only handle the two /// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would /// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller /// wrong than a dot that goes dark when the vault is reopened. /// private void RefreshConnectedHosts() { if (Vault is not { } vault) { return; } foreach (var host in vault.Hosts) { host.IsConnected = Tabs.Any( tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal)); } } partial void OnLiveSessionCountChanged(int value) { OnPropertyChanged(nameof(HasLiveSessions)); OnPropertyChanged(nameof(LiveSessionSummary)); } partial void OnStateChanged(ShellState value) { OnPropertyChanged(nameof(IsStarting)); OnPropertyChanged(nameof(IsNeedingServer)); OnPropertyChanged(nameof(IsNeedingEnrollment)); OnPropertyChanged(nameof(IsShowingRecoveryCode)); OnPropertyChanged(nameof(IsLocked)); OnPropertyChanged(nameof(IsAskingForThePassphrase)); OnPropertyChanged(nameof(IsUnlocked)); RaiseTerminalState(); OnPropertyChanged(nameof(SignOutWarning)); RaiseSyncState(); // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list. // The hosts screen is what this application is for. The surface as well as the screen: shells outlive // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to // the application would not be what "unlocked" looks like. if (value is ShellState.Unlocked) { Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; } } /// /// Every screen flag, on every change, for the same reason the vault column raises all four of its /// section flags: a rail lighting the current screen and a body showing it are one fact read from two /// directions, and raising only the one that became true leaves the old button lit. /// partial void OnScreenChanged(ShellScreen value) { RaiseSurfaceState(); // Read when the screen is opened rather than kept in step with every sync pass. Two full logs is // thousands of decryptions, and nobody is waiting for their own connection from an hour ago to // appear on a screen they are not looking at. Not awaited: navigating must not block on a read. if (value is ShellScreen.Logs && LogsScreen is { } logs) { _ = logs.RefreshCommand.ExecuteAsync(null); } // Teams are read from the server rather than from the vault, so there is nothing to show until // somebody asks for it — and asking for it on every unlock would be a request per launch for a // screen most people never open. Fire-and-forget because a property change cannot await, and // because the view model turns every failure into its own status line rather than throwing. if (value is ShellScreen.Team) { _ = teams.LoadAsync(CancellationToken.None); } } /// partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState(); /// /// Both changes raise the same set, and they have to: and its four siblings /// read and together, so which of the two moved does not /// narrow what became stale. /// private void RaiseSurfaceState() { OnPropertyChanged(nameof(IsHostsScreen)); OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsVaultScreen)); OnPropertyChanged(nameof(IsTeamScreen)); OnPropertyChanged(nameof(IsPreferencesScreen)); OnPropertyChanged(nameof(IsKnownHostsScreen)); OnPropertyChanged(nameof(IsImportScreen)); OnPropertyChanged(nameof(IsSnippetsScreen)); OnPropertyChanged(nameof(IsLogsScreen)); OnPropertyChanged(nameof(IsShowingPages)); OnPropertyChanged(nameof(IsHostsShowing)); OnPropertyChanged(nameof(IsTransfersShowing)); OnPropertyChanged(nameof(IsVaultShowing)); OnPropertyChanged(nameof(IsTeamShowing)); OnPropertyChanged(nameof(IsPreferencesShowing)); OnPropertyChanged(nameof(IsKnownHostsShowing)); OnPropertyChanged(nameof(IsSnippetsShowing)); OnPropertyChanged(nameof(IsLogsShowing)); RaiseTerminalState(); } /// /// Re-reads what the terminal's rectangle should hold, and which tab is lit. /// /// /// One method for all four, because they are one fact read from four directions: the surface, the /// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn — /// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up /// pointing at something nobody can see. /// private void RaiseTerminalState() { OnPropertyChanged(nameof(IsTerminalSurface)); OnPropertyChanged(nameof(IsTerminalShowing)); OnPropertyChanged(nameof(IsConnectingShowing)); // The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user // navigated to preferences would be a second "you are here" mark pointing at a terminal that is not // on screen; see TerminalTabViewModel.IsShowing. foreach (var tab in Tabs) { tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab); } } partial void OnIsSearchingChanged(bool value) => RaiseTerminalState(); /// /// The unlock card and the confirmation swap, so arming one has to hide the other — see /// . /// partial void OnIsConfirmingSignOutChanged(bool value) => OnPropertyChanged(nameof(IsAskingForThePassphrase)); partial void OnCanRegisterDeviceChanged(bool value) => OnPropertyChanged(nameof(HasNoDeviceKeyOption)); partial void OnCanForgetDeviceChanged(bool value) => OnPropertyChanged(nameof(HasNoDeviceKeyOption)); partial void OnSearchTextChanged(string value) => RefreshSearchResults(); }