using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Globalization; using System.Security.Authentication; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Auth; using DodoSSH.Client.Import; using DodoSSH.Client.ObjectStore; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; namespace DodoSSH.Client.Shell.ViewModels; /// One vault, as a switch in the rail's user popover. /// /// Was a switch in the tab strip's own vault menu; v5b moved the menu itself onto the rail's user chip — /// see NavRail.axaml — and this record moved with it, unchanged. A record rebuilt per change rather /// than an observable row, which is the idiom the rest of these lists use: the menu is short, it is rebuilt /// whenever anything about the vault list moves, and a row with a settable property would be a second copy /// of a fact the cache already holds. /// /// The vault. /// Its display name, which is plaintext as all vault names are. /// Whether this is the caller's own vault rather than a team's. /// Whether its items are currently drawn. internal sealed record VaultToggleViewModel(Guid VaultId, string Name, bool IsPersonal, bool IsShown) { /// What the switch says. /// /// A shared vault is marked as one, exactly as it is in the "file this into" picker, and for a weaker /// version of the same reason: two vaults may hold a host with the same label, and which vault a switch /// is about is the only thing that tells the two switches apart. /// internal string Display => IsPersonal ? Name : $"{Name} · SHARED"; /// The one letter the rail's popover draws in this vault's own initial square. /// /// The mock colours these squares per vault; nothing here tracks a per-vault colour, so drawing one /// would be inventing a fact rather than reading one — see the remark in NavRail.axaml. The /// letter is the honest half of the same badge. /// internal string Initial => Name.Length > 0 ? Name[..1].ToUpperInvariant() : "?"; /// Whether this vault can be switched off. /// /// The personal vault cannot. It is the active vault — the one snippets, logs and buckets are read from, /// the one the group and tag editors write to, and the fallback the save-target picker lands on — so /// switching it off would empty half the application rather than filter it. It is still drawn, ticked, /// because a vault missing from a list of vaults reads as something having gone wrong. /// internal bool CanHide => !IsPersonal; } /// 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 open vault that is not a host: keys, passwords, buckets, tags. /// /// Named for what the rail calls it rather than for the vault it reads, which is what it was called /// when arrived beside it. Two members a letter apart, one meaning "one vault's /// contents" and the other "the vaults themselves", is a pair somebody eventually gets the wrong way /// round. /// Keychain = 2, /// The vaults themselves and the people in them. Both heads draw it. /// /// Was Team, and the value is unchanged with it: the screen is the same destination, and these /// numbers are written into NavRail.axaml as x:Static literals. What changed is what the /// screen is about — see . /// Vaults = 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, /// /// The phone's hub for everything the bottom bar has no room for. /// /// /// /// Drawn by the Android head alone. The desktop has a nav rail with room for every destination, so it has /// nothing to put behind a hub and never sets this; the v2 phone design has four slots and nine places to /// go, so five of them live one tap deeper. It is a member of the shared enum rather than a phone-local /// bool because it is a value of like any other — the back arrow on each of those /// five screens comes here, and a second notion of "where am I" is how the two would disagree. /// /// /// More = 9, /// /// Objects in an S3-compatible bucket. /// /// /// The same screen as over the same , and a /// separate destination anyway. What differs is only which picker is offered, so the two do not each /// need a screen — but they do each need a name, because the v2 design puts SFTP and S3 side by side in /// the hub, and a single "files" entry that silently remembered which kind you last opened would be a /// destination whose meaning depended on history. Entering either sets /// ; see . /// Buckets = 10, } /// /// 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, } /// /// Which page the settings mode is showing, while is /// not null. /// /// /// /// v5c: the design's Settings area is a full-window mode that replaces the titlebar, the rail and the page /// area with its own — see SettingsView.axaml and the settings-mode remark on /// . This is a second, orthogonal notion of "where am I" /// from , not a replacement for it: and /// still set to the matching member, so /// every existing binding and test that asks "is the screen Preferences" keeps its answer. , /// and have no counterpart — nothing /// outside settings mode ever asked "which one of these three am I on" before this wave existed. /// /// /// v5c-2: Groups and Tags joined. The design's rail lists them beside Security and Preferences; v5c-1 /// omitted both from SettingsView.axaml rather than building a placeholder for either, and this wave /// is the page each was waiting on — see design-notes/v5c-fidelity-notes.md. Neither has a /// counterpart: managing groups and tags has never been its own screen before this, /// only a panel inside the hosts board and the keychain screen respectively, so there is no existing binding /// for either to keep in step with. /// /// internal enum SettingsPage { /// Updates, and the refused items from the design's General page, as an essay. General = 0, /// The vaults themselves and the people in them — the existing screen. Vaults = 1, /// The signed-in profile, the sign-in fact, and signing out of this machine. Account = 2, /// The end-to-end explainer, Windows Hello, and approved host keys. Security = 3, /// This machine's terminal and keychain settings — the existing screen. Preferences = 4, /// Every group, and the hosts filed under each — the existing group commands, given their own page. Groups = 5, /// Every tag, and how many hosts wear each — the existing tag commands, given their own page. Tags = 6, } /// /// 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; /// What this machine is called — on the account, and on every log entry it writes. /// /// From the head rather than from , because that property answers /// localhost on Android and would make every phone in an account indistinguishable from every /// other one — in the device list a user revokes from, and in the log they read to find out which /// machine opened a shell. The desktop passes nothing and keeps the machine name; a phone knows its own /// model and nothing in this assembly can ask for it, because Android.OS.Build is not reachable /// from a net10.0 library. See docs/android-port.md §7. /// private readonly string deviceName; /// /// Built here from the paths rather than taken as a dependency, because it holds preferences and not /// state: there is nothing for a head to substitute, and a constructor parameter every head would pass /// the same value to is a parameter that only ever makes the heads longer. /// private readonly ClientSettingsStore settings; /// /// 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 VaultsViewModel vaults; /// /// Where newer builds come from, and how far one has got. /// /// /// A process-lifetime object like , and for a reason that is its own rather than /// borrowed: this one outlives a lock because the release channel is not the vault. /// private readonly UpdateViewModel updateScreen; /// /// 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 = []; /// How often the hosts screen's ago-text is restrung while it is visible. /// /// A minute, to match the words it is restringing into: "1 min ago" is the shortest gap the wording /// ever names, so a tick any faster would restring a fact that has not changed in any word it prints. /// private static readonly TimeSpan LastConnectedTickInterval = TimeSpan.FromMinutes(1); /// /// Whether the hosts screen was showing the last time ran. /// /// /// The transition is what matters, not the level — see that method. Without this, every unrelated /// screen or surface change would re-read the connection log for no reason, which is exactly the /// background re-read LogsViewModel.cs argues a connection log must never be put on. /// private bool wasHostsScreenShowing; /// The loop started, or null while the hosts screen is not showing. private CancellationTokenSource? lastConnectedTick; /// /// The loop that restrings once a minute, for as long as this shell runs. /// /// /// Unlike , this one is not started and stopped as a screen comes and goes /// — it runs for the shell's whole life, the same way does. Gating it on /// / would save one restring a minute while /// on some other screen, at the cost of the same start/stop bookkeeping /// needs the hosts screen for — and is already re-read on every state /// change worth reacting to immediately; see . This loop only catches the /// case nothing else does: sitting still on a connected screen while a minute passes. /// private readonly CancellationTokenSource sessionElapsedTick = new(); 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; /// /// Where goes back to — captured once, on the turn settings mode is /// entered, and not touched again until it is left. /// /// /// Not re-captured on every call, which is what makes switching pages inside /// settings mode (Preferences, then Security, then Account) still come back to the one screen the user /// was actually on beforehand rather than to whichever settings page they last visited. /// private ShellScreen settingsReturnScreen; /// private ShellSurface settingsReturnSurface; /// /// 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. /// /// /// What to call this machine. Optional, and the default is right for every head that runs on a desktop /// operating system — see the field it is kept in for the one that it is not right for. /// /// /// Where newer builds of this client come from. Optional, and the default is a channel that reports /// itself unavailable — which is a deliberate difference from , which every /// head passes explicitly. With an optional parameter, "the phone has no updater" is enforced by the /// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles /// the Android head's distribution separately, so it must never acquire one by accident. /// 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, string? deviceName = null, IUpdateChannel? updates = 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; // Whitespace is treated as absent rather than honoured: an empty device name reaches the server as a // blank one, which RegisterDevice rejects, and a phone whose model string came back empty would fail // to register for a reason no message could explain. this.deviceName = string.IsNullOrWhiteSpace(deviceName) ? Environment.MachineName : deviceName; // The fourth argument is the route out of the S3 screen's empty state. A bucket is made on the // keychain screen, which is a different tab and two clicks away from somebody who has gone to S3 // to add one — so the screen that needs a bucket is given a way to reach the screen that makes one. transfers = new TransfersViewModel(sftpSessions, clock, addBucket: ShowNewBucket); // Built once, like the workspace it writes for, and given a vault only while one is open. It has to // outlive every lock for the same reason the workspace does: a shell opened before a lock is still // running after it, and the entry it eventually produces belongs to the vault it was made in. connectionLog = new ConnectionRecorder(clock, this.deviceName); this.workspace.ConnectionLog = connectionLog; // Both dependencies as functions rather than values: the connection arrives after sign-in and the // session after unlock, and both go away again on lock. Capturing either would give this screen a // reference that outlives what it points at — which for a session means holding vault keys past the // moment locking is supposed to have zeroed them. // The third argument is how a vault made over there reaches the lists and the menu over here: both // are built from the session's vault list, and neither would otherwise learn that it had grown until // something else happened to rebuild them. vaults = new VaultsViewModel(() => connection, () => Vault?.Session, OnVaultsChangedAsync); // Subscribed for the life of the process, because the workspace lives that long and so does the tab // list. Detached in DisposeAsync, which is the only point either of them ends. this.workspace.SessionEnded += OnWorkspaceSessionEnded; this.workspace.FontSizeStepRequested += OnFontSizeStepRequested; this.workspace.RendererReattached += OnRendererReattached; settings = new ClientSettingsStore(paths); updateScreen = CreateUpdateScreen(updates); // Read straight away rather than at first use, so the value is right before anything can read it — // a phone draws its terminal buttons from this, and a size that arrived a moment later would show // as the interface correcting itself. TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize); _ = TellRendererTheFontSizeAsync(); StartSessionShellTracking(); } /// /// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline. /// /// /// The subscription is narrow on purpose: and /// are the only two facts the header, the status bar and the SFTP tab row's active mark borrow from /// , and neither used to be read from outside that screen at all — see /// . The tick restrings once a /// minute for the shell's whole life; see the remark on for why it is not /// started and stopped with a screen the way is. /// private void StartSessionShellTracking() { transfers.PropertyChanged += OnTransfersPropertyChanged; _ = RunSessionElapsedTickAsync(sessionElapsedTick.Token); } /// /// Builds the updater, kept for the life of the process like the workspace and the transfer queue. /// /// /// A method rather than four more lines in the constructor, because the restart delegate needs a /// paragraph of its own and the constructor is already at the length the analyzers allow. /// private UpdateViewModel CreateUpdateScreen(IUpdateChannel? updates) { // The channel is captured rather than reached through the view model, which keeps the restart // delegate free of a reference to the object it is being handed to. var channel = updates ?? new UnavailableUpdateChannel(); return new UpdateViewModel( channel, settings, clock, () => workspace.LiveSessionCount, // Everything this application does on the way out, and only then the swap. Applying an update // ends the process, and disposing this view model is what zeroes the identity keys, the vault // keys and the cache key — so the other order would leave them sitting in a memory image the // installer is about to write over, and would abandon a transfer still writing to a part file. // // ◆ Unless applying does not end the process, which is the phone. There the swap is a request // to the platform's own installer and the answer may be no, so tearing everything down first // would answer "not now" with a locked keychain and every shell closed — a punishment for // declining an update. Nothing is zeroed in that case, and nothing needs to be while the // process is still the one holding it; when the install is agreed to, Android ends the process // outright. See IUpdateChannel.ApplyingEndsTheProcess. restart: async update => { if (channel.ApplyingEndsTheProcess) { await DisposeAsync().ConfigureAwait(true); } channel.ApplyAndRestart(update); }); } /// /// /// The page starts at its own default and has no way to know what was stored, so somebody has to tell /// it — and it cannot be told before its socket exists. Waiting on the renderer is the only ordering /// available; nothing else knows when the page is there. /// /// /// Failure is silence on purpose. A launch where no terminal is ever opened still runs this, and a /// renderer that never attached is not a fault in that case — it is the ordinary shape of a session /// spent in the keychain. The size is sent again by every change, so nothing is permanently lost. /// /// private async Task TellRendererTheFontSizeAsync() { try { await workspace.WaitForRendererAsync(CancellationToken.None).ConfigureAwait(false); await workspace .SetFontSizeAsync(TerminalFontSize, CancellationToken.None) .ConfigureAwait(false); } catch (Exception exception) when (exception is TimeoutException or OperationCanceledException) { // No renderer this run. Nothing to tell. } } /// /// Marshalled, because this arrives on the data plane's receive loop — see /// — and everything it touches is a view model /// property somebody's interface is bound to. /// private void OnFontSizeStepRequested(object? sender, TerminalFontSizeStepEventArgs e) => Dispatcher.UIThread.Post(() => StepTerminalFontSize(e.Step)); /// /// /// Marshalled for the same reason as : this arrives on the data /// plane's socket-accept thread, and both properties it reads here — and /// — are bound to by the interface. /// /// /// fires once the workspace has replayed what it /// owns — the live sessions. Font size and the choice of active tab are not the workspace's to know; /// they live here, so this is the other half of putting a reattached page back the way it was. The size /// is sent exactly as sends it at startup, because nothing /// has changed — the page has merely forgotten, and this is only a reminder. /// /// private void OnRendererReattached(object? sender, EventArgs e) => Dispatcher.UIThread.Post(() => { _ = workspace.SetFontSizeAsync(TerminalFontSize, CancellationToken.None).AsTask(); if (SelectedTab is { } tab) { _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask(); } }); [ObservableProperty] private ShellState state = ShellState.Starting; [ObservableProperty] private string statusMessage = "Opening the local cache…"; [ObservableProperty] private bool isBusy; /// 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 offered on first launch, before anything is enrolled. /// /// /// /// One default for every build. The hosted deployment is what all but a handful of launches are /// aiming at, and typing its address is the only thing standing between an installed application and /// a working one. Running against a clone means replacing this with /// http://localhost:5233 by hand — note the scheme, because the API's first launch profile — /// the one a plain dotnet run and the README both select — is plaintext on 5233, and pointing /// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which /// sends people looking for a certificate problem. See . /// /// /// This was once split on DEBUG so a development launch could not enroll a device against /// production by accident. That protection is gone: a debug build now offers the hosted address like /// any other, and the first sign-in accepted unread lands there. /// /// internal const string DefaultServerUrl = "https://ssh.dodotech.cloud"; [ObservableProperty] private string serverUrl = DefaultServerUrl; [ObservableProperty] private string passphrase = string.Empty; [ObservableProperty] private string confirmPassphrase = string.Empty; /// 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; /// /// The signed-in account's own email, when the server sent one — for the rail's user popover. /// /// /// A second field rather than a way to pull it back out of , which folds /// DisplayName ?? Email ?? Subject into one string and forgets which of the three it kept. /// Wherever is set from a profile or a MeResponse, this is set from the /// same object's own Email alongside it — so it is null exactly when the server has not sent one, /// never invented from the subject or the display name the way a naive fallback would. /// [ObservableProperty] private string? email; /// /// The OIDC issuer this account signs in through, when this machine has one cached — for the Account /// settings page's SIGN-IN row. /// /// /// v5c: MeResponse.Issuer was already being cached into StoredUnlockMaterial.Issuer by /// , for no reader — nothing before this wave surfaced it. Set from the /// same two places and are, in , /// so the three can never drift out of step with which account is actually signed in. /// [ObservableProperty] private string? issuer; /// Two letters for the rail's avatar circle, read off the signed-in display name. /// /// The first letter of the first two words in — which is already /// DisplayName ?? Email ?? Subject, so an account with no display name still yields two letters /// out of its email's local part or its subject rather than a blank circle. Never padded past what the /// name itself holds: a one-word name gets one letter rather than a second one invented to fill the /// mock's own two-letter shape. /// internal string AvatarInitials { get { if (string.IsNullOrWhiteSpace(AccountName)) { return string.Empty; } var words = AccountName.Split( [' ', '.', '_', '-', '@'], StringSplitOptions.RemoveEmptyEntries); return words switch { [] => string.Empty, [var only] => only[..1].ToUpperInvariant(), [var first, var second, ..] => (first[..1] + second[..1]).ToUpperInvariant(), }; } } partial void OnAccountNameChanged(string? value) => OnPropertyChanged(nameof(AvatarInitials)); /// /// Sets and from one profile, in one place. /// /// /// Both the cached-profile read in and the browser sign-in in /// land here rather than repeating the same two assignments, which is what /// kept them from drifting apart the day one of the two calls gained and the other /// did not. /// private void AdoptIdentity(string? displayName, string? emailAddress, string subject, string? issuer = null) { AccountName = displayName ?? emailAddress ?? subject; Email = emailAddress; Issuer = issuer; } [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 vaults screen, which the window binds to whether or not a vault is open. /// /// /// Not nullable and never replaced, for the reason is not: both of its /// dependencies are fetched through a function at the moment they are needed. That means a lock does /// not have to tear it down and an unlock does not have to rebuild it, and the list it is showing /// survives both. /// /// Distinct from , which is one vault's contents — the hosts, keys and /// passwords the rail's other screens draw. This one is the vaults themselves and the people in them. /// /// internal VaultsViewModel Vaults => vaults; /// 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; /// Where newer builds come from, which the window binds whether or not a vault is open. /// /// Bound from the titlebar's banner and from the preferences screen, and it answers on a locked shell /// too — the banner is drawn outside the unlocked half of the window on purpose, because a machine left /// locked overnight is exactly the one that will have found an update by morning. /// internal UpdateViewModel Updates => updateScreen; /// /// 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); /// /// How large the terminal draws, in CSS pixels. /// /// /// /// A font size rather than a zoom, and the difference is the whole design. Zoom scales what is already /// drawn, so the remote goes on wrapping to a width that is no longer on screen; changing the font size /// refits the grid and tells the far end how many columns it now has. That is why this is one number /// owned here and pushed to the renderer, rather than a gesture the page handles alone. /// /// /// Owned by the shell rather than by the page for two reasons that pull the same way: it has to survive /// a relaunch, and it has to be reachable from a button on a phone that has no keyboard to press /// Ctrl+plus with. The page's chords arrive here as steps — see /// — so both routes end in this property. /// /// [ObservableProperty] private int terminalFontSize = ClientSettings.DefaultTerminalFontSize; /// Whether the terminal could be drawn larger than it is. internal bool CanEnlargeTerminalFont => TerminalFontSize < ClientSettings.MaximumTerminalFontSize; /// Whether the terminal could be drawn smaller than it is. internal bool CanShrinkTerminalFont => TerminalFontSize > ClientSettings.MinimumTerminalFontSize; /// Draws the terminal one point larger. [RelayCommand] private void EnlargeTerminalFont() => StepTerminalFontSize(1); /// Draws the terminal one point smaller. [RelayCommand] private void ShrinkTerminalFont() => StepTerminalFontSize(-1); /// Puts the terminal back to the size it ships at. /// /// Worth a command of its own rather than leaving people to count clicks back. A terminal that has been /// made unreadable is hard to make readable again by eye, which is the state this exists for. /// [RelayCommand] private void ResetTerminalFont() => ApplyTerminalFontSize(ClientSettings.DefaultTerminalFontSize); /// Points to move by, or zero to return to the default. private void StepTerminalFontSize(int step) => ApplyTerminalFontSize( step == 0 ? ClientSettings.DefaultTerminalFontSize : TerminalFontSize + step); /// /// One path for every route in — the phone's buttons, the page's chords, and the stored value read at /// startup — so clamping, persisting and telling the renderer happen once each rather than three times /// with one of them eventually forgotten. /// private void ApplyTerminalFontSize(int pixels) { var clamped = ClientSettings.ClampTerminalFontSize(pixels); // Told anyway when nothing moved. A step at the cap is a no-op here, but the page may have been // reloaded since the last frame — and a renderer at the wrong size is worse than a redundant frame. TerminalFontSize = clamped; // Fire and forget: a socket that is not there yet is the ordinary case at startup, and a font size // is not worth blocking a button handler on. _ = workspace.SetFontSizeAsync(clamped, CancellationToken.None).AsTask(); // Read-modify-write against the file rather than against a field, so a setting this build does not // know about — written by a newer one, or by hand — survives this one storing its own. settings.Write(settings.Read() with { TerminalFontSize = clamped }); } partial void OnTerminalFontSizeChanged(int value) { OnPropertyChanged(nameof(CanEnlargeTerminalFont)); OnPropertyChanged(nameof(CanShrinkTerminalFont)); } /// /// 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 IsKeychainScreen => Screen is ShellScreen.Keychain; /// internal bool IsVaultsScreen => Screen is ShellScreen.Vaults; /// internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences; /// internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts; /// internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets; /// internal bool IsLogsScreen => Screen is ShellScreen.Logs; /// internal bool IsMoreScreen => Screen is ShellScreen.More; /// internal bool IsBucketsScreen => Screen is ShellScreen.Buckets; /// /// 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 IsKeychainShowing => IsShowingPages && IsKeychainScreen; /// internal bool IsVaultsShowing => IsShowingPages && IsVaultsScreen; /// internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen; /// internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen; /// internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen; /// internal bool IsLogsShowing => IsShowingPages && IsLogsScreen; /// internal bool IsMoreShowing => IsShowingPages && IsMoreScreen; /// internal bool IsBucketsShowing => IsShowingPages && IsBucketsScreen; /// /// Whether the phone's SETTINGS tab should light. /// /// /// The hub and everything behind it, because a bottom bar that went dark the moment you opened one of /// its destinations would be a bar that only ever lights two of its three entries. This is the one /// place where "which tab" and "which screen" are deliberately not the same question — the other two /// tabs are each exactly one thing, and this one is seven. /// /// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from /// the rail and never asks this. is in it for the same reason and no /// other: the desktop has a rail entry for it and the phone reaches it through the hub, so a /// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own /// header back over a screen that already has one. /// /// The keychain joined it too, and that is why the bar went from four entries to three. Unlike /// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a /// session moves between, and the keychain is not one of those — hosts and connections are what /// somebody opens the application to do, and keys, credentials and tags are what they go and manage /// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing /// the two heads deliberately arrange differently, after the hub itself. /// internal bool IsMoreSurface => IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences or ShellScreen.Vaults or ShellScreen.Keychain; /// /// 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), the quick-connect /// palette, the phone's connect sheet, and the host-key decision. /// /// /// The host-key decision is the newest of them, and the one that made it a rule rather than a list. /// It is drawn over whatever is on screen at the moment a handshake asks the question — including an open /// terminal, because a second connection can be being made while a first one is being typed into. Without /// this condition the two buttons that answer the most safety-critical question in the product would be /// sliced at the WebView's left edge and take no clicks. See . /// /// /// The sheet is here rather than in , and the palette is not. The /// palette replaces the whole surface, so collapsing everything the terminal half draws is right. The /// sheet is raised from the terminal's own top bar and that bar has to stay on screen behind it — /// dropping the surface would take the bar, the tabs and the phone's whole chrome with it and leave the /// sheet floating over the page underneath. So only the renderer's rectangle is given up. /// /// /// 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 && !IsConnectSheetOpen && !IsHostKeyDecisionShowing && SelectedTab is { HasSession: true }; /// /// Whether a host key is waiting to be judged, over whatever the user is looking at. /// /// /// /// It is asked of the shell rather than of a screen because the answer decides an occlusion, and /// because the question is no longer tied to a screen. The prompt used to be a banner at the top of /// the hosts screen, and the shell navigated there before letting the vault raise it — which is how a /// machine typed into the phone's own connect box came to be judged on a list it is deliberately not on. /// Both heads now draw the decision over whichever surface was showing when the handshake stopped, so /// there is nowhere it can be asked from that it cannot be answered on, and nothing has to move. /// /// /// Two states, one flag, and the views keep them apart: an unknown key is a decision with two buttons and /// a changed one is a refusal with no way forward. What they share is that both cover the rectangle the /// terminal would be in. See HostKeyCard.axaml on the desktop and HostKeySheet.axaml on the /// phone. /// /// /// The transfers screen's own copy of this question is not here, and that is not an omission: file /// transfer's prompt is drawn inside that screen, which is a page, so it occludes nothing. /// /// internal bool IsHostKeyDecisionShowing => IsUnlocked && Vault is { HasPendingHostKey: true } or { HasHostKeyMismatch: 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) { // v5c: Preferences and Vaults are settings pages now, and everywhere that used to navigate to either // of them — the rail's own popover, the phone's hub, a test calling this command by hand — is meant // to land in settings mode rather than on the bare screen the design retired. Redirecting here, // rather than at every caller, is what makes that true without hunting down every existing call. if (target is ShellScreen.Preferences) { EnterSettings(SettingsPage.Preferences); return; } if (target is ShellScreen.Vaults) { EnterSettings(SettingsPage.Vaults); return; } // v5c: Import sits inside the settings chrome too, per Import.dc.html — SettingsNav stays lit on // Preferences, and what changes underneath it is the content column and the titlebar's own back // label, both driven by IsImportOpen rather than by a SettingsPage of its own. See OpenImport. if (target is ShellScreen.Import) { OpenImport(); return; } // Any other screen leaves settings mode outright rather than restoring whatever was remembered on // the way in — the caller named a destination, and that destination wins over "go back". ActiveSettingsPage = null; Screen = target; Surface = ShellSurface.Page; } /// /// The full-window settings mode: its own titlebar, its own 340px rail, and a centred content column — /// see SettingsView.axaml. Not null exactly while that chrome, rather than the ordinary titlebar /// and nav rail, is what MainWindow.axaml draws. /// /// /// A second notion of "where am I" from rather than a replacement for it — see the /// remark on . Two of its five members, /// and , keep in step with the matching /// member so every binding and test written against that screen before this /// mode existed keeps working; the other three have nothing to keep in step with. /// [ObservableProperty] private SettingsPage? activeSettingsPage; /// Whether the settings chrome, rather than the ordinary one, is what the window is drawing. internal bool IsSettingsMode => ActiveSettingsPage is not null; /// internal bool IsSettingsGeneralPage => ActiveSettingsPage is SettingsPage.General; /// internal bool IsSettingsVaultsPage => ActiveSettingsPage is SettingsPage.Vaults; /// internal bool IsSettingsAccountPage => ActiveSettingsPage is SettingsPage.Account; /// internal bool IsSettingsSecurityPage => ActiveSettingsPage is SettingsPage.Security; /// internal bool IsSettingsPreferencesPage => ActiveSettingsPage is SettingsPage.Preferences; /// internal bool IsSettingsGroupsPage => ActiveSettingsPage is SettingsPage.Groups; /// internal bool IsSettingsTagsPage => ActiveSettingsPage is SettingsPage.Tags; /// /// Whether the importer is showing over the Preferences page, inside settings mode. /// /// /// A flag layered on top of rather than a /// member of its own — Import.dc.html draws SettingsNav lit on Preferences the whole time the /// importer is up, which this makes true for free: never leaves /// , so and the nav row it /// drives stay exactly as they were. What moves is only the content column, via /// , and the titlebar's own back label — see /// SettingsTitleBar.axaml. /// [ObservableProperty] private bool isImportOpen; /// /// Whether the Preferences page itself, rather than the importer drawn over it, is what settings mode's /// content column shows. /// internal bool IsSettingsPreferencesContentShowing => IsSettingsPreferencesPage && !IsImportOpen; partial void OnActiveSettingsPageChanged(SettingsPage? value) { OnPropertyChanged(nameof(IsSettingsMode)); OnPropertyChanged(nameof(IsSettingsGeneralPage)); OnPropertyChanged(nameof(IsSettingsVaultsPage)); OnPropertyChanged(nameof(IsSettingsAccountPage)); OnPropertyChanged(nameof(IsSettingsSecurityPage)); OnPropertyChanged(nameof(IsSettingsPreferencesPage)); OnPropertyChanged(nameof(IsSettingsGroupsPage)); OnPropertyChanged(nameof(IsSettingsTagsPage)); OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing)); } partial void OnIsImportOpenChanged(bool value) => OnPropertyChanged(nameof(IsSettingsPreferencesContentShowing)); /// Enters settings mode on a page, remembering where "Back to application" returns to. /// /// The return screen is captured only on the way in from outside settings mode — see /// — so switching between settings pages, which calls this /// repeatedly, cannot overwrite it with another settings page. /// /// v5c: also closes the importer, on the same reasoning. Naming a page — including Preferences again — is /// a request for that page, not for whatever was drawn over it the last time settings mode was up. /// /// [RelayCommand] private void EnterSettings(SettingsPage page) { if (ActiveSettingsPage is null) { settingsReturnScreen = Screen; settingsReturnSurface = Surface; } ActiveSettingsPage = page; IsImportOpen = false; Screen = page switch { SettingsPage.Preferences => ShellScreen.Preferences, SettingsPage.Vaults => ShellScreen.Vaults, _ => Screen, }; Surface = ShellSurface.Page; } /// /// Opens the importer over the Preferences page — the Preferences row's own "OPEN IMPORTER" button, and /// 's translation of for every other caller. /// private void OpenImport() { EnterSettings(SettingsPage.Preferences); IsImportOpen = true; } /// "Back to preferences": closes the importer without leaving settings mode. [RelayCommand] private void CloseImport() => IsImportOpen = false; /// "Back to application": leaves settings mode for wherever it was entered from. [RelayCommand] private void LeaveSettings() { if (ActiveSettingsPage is null) { return; } ActiveSettingsPage = null; IsImportOpen = false; Screen = settingsReturnScreen; Surface = settingsReturnSurface; } /// 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; // Only with nothing open, because that is the only state in which they are drawn — the surface shows // the sessions otherwise. Not awaited, for the reason the logs screen's own load is not: navigating // must not block on a read, and the list appears under the box the moment it arrives. if (!HasTabs) { _ = RefreshRecentConnectionsAsync(); } } /// /// The machines most recently connected to, for the Connections screen to offer when nothing is open. /// /// /// /// Deduplicated by address, because this is a list of places rather than of events: connecting to one /// box nine times in a morning is nine entries in the log and one thing worth offering here. The log /// screen shows every one of them; that is what a log is for and this is not one. /// /// /// Capped, and the cap is not about memory. What makes this list useful is that the machine somebody /// wants is visible without scrolling, above a keyboard, under the box they would otherwise be typing /// into. Twenty rows would push the box off the screen and be a worse version of the log. /// /// internal ObservableCollection RecentConnections { get; } = []; internal bool HasRecentConnections => RecentConnections.Count > 0; /// How many machines the Connections screen offers. private const int RecentConnectionLimit = 6; /// Re-reads the connection log and takes the most recent distinct machines from it. /// /// Failures are swallowed, and that is the same call the logs screen makes for the same reason: this is /// a convenience under a box that works without it. A screen whose whole purpose is to let somebody /// connect should not lead with a decryption error about a list of things they connected to yesterday. /// private async Task RefreshRecentConnectionsAsync() { if (LogsScreen is not { } logs) { return; } try { await logs.ReloadConnectionsAsync(CancellationToken.None).ConfigureAwait(true); } catch (Exception exception) when (exception is not OutOfMemoryException) { return; } RecentConnections.Clear(); var seen = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var row in logs.Connections) { // The live ones are skipped rather than filtered later: a connection that is open right now has // a tab, and a tab means this list is not on screen at all. Leaving them in would only matter in // the one state where it cannot be seen, which is a rule that would be wrong the moment that // stopped being true. if (row.IsLive || !seen.Add(row.Address)) { continue; } RecentConnections.Add(row); if (RecentConnections.Count == RecentConnectionLimit) { break; } } OnPropertyChanged(nameof(HasRecentConnections)); } /// Goes back to a machine that has been connected to before. /// /// /// Two destinations, because a recent row is one of two different things. One that names a /// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one /// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever /// authentication the keychain resolves for it. Connecting from here instead would be a third connect /// path that had to answer all of that again. /// /// /// ◆ It raises that rather than merely selecting the row, and on the phone it has to. Nothing on /// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the /// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back /// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See /// VaultViewModel.AskAboutHost, which is where the two heads' answers are raised together. /// /// /// One that names no item was typed into the manual box, and the log stored exactly what was dialled — /// user@host:port, which is the grammar that box takes. So it goes back into the box, and what /// is deliberately not restored is the password: it was never stored, which is the whole point of the /// manual path, and a field that filled itself in would be claiming otherwise. /// /// /// A host deleted since it was connected to falls through to the address, which is the honest answer: /// the machine is still there and the keychain no longer knows about it. So does a host in a vault the /// user has switched off, and for the same reason rather than by accident: selecting it would point the /// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back /// out — arriving at the hosts screen with nothing selected and no explanation. /// /// [RelayCommand] private void ConnectToRecent(ConnectionLogRowViewModel row) { if (row is null || Vault is not { } vault) { return; } if (row.HostId is { } hostId && vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known && vault.IsVaultShown(known.VaultId)) { vault.AskAboutHostCommand.Execute(known); ShowScreen(ShellScreen.Hosts); return; } vault.ManualTarget = row.Address; vault.ManualStatus = string.Empty; } // ---- The rail's own page grouping ---- /// /// Whether the page area is showing one of the rail's own destinations, rather than SFTP or S3. /// /// /// /// Named for what it used to gate rather than for what it does now. Through v5b's own strip, this and /// its two siblings — and — lit one of /// three tabs, and the rail was drawn only under this one; see the file history for that version of /// this remark. The tabs are gone — SSH, SFTP and S3 are a segmented switcher on the rail's own head /// now, and the rail is permanent furniture beside every one of the three — but the partition this /// answers is still real and still asked in three places: /// reads it under a new name for the switcher, the rail's mode-dependent first row reads /// which is built from the same three flags, and this one is still what /// the rail's own six rows below the switcher use to know a rail screen is the one on the page. /// /// /// Not , which is one of the six screens this covers. The two are /// true together whenever somebody is looking at the vaults screen and are otherwise unrelated: this one /// is "a rail screen is showing, rather than SFTP, S3 or a terminal". /// /// internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen); /// The pages the rail's own rows reach, as opposed to SFTP or S3. private static bool IsVaultsPage(ShellScreen screen) => screen is not (ShellScreen.Transfers or ShellScreen.Buckets); // ---- The rail's segmented switcher and its mode-dependent first entry ---- // // v5b moves the three-way choice that used to be the strip's own fixed tabs into the nav rail, as a // segmented control the design draws at the rail's head — see NavRail.axaml. What used to be // IsVaultsTab, IsTransfersShowing and IsBucketsShowing lighting three tab pills now lights three // segments and one rail row instead, and the partition is the same one: exactly one of "a page under // the rail's own list", "the files screen" and "the buckets screen" is ever true. /// Whether the switcher's SSH segment is lit, and the rail's default "mode". /// /// Not "a terminal is showing" — the design's own mode defaults to ssh on every page that is not /// explicitly SFTP or S3, Hosts and Preferences included, and this answers that broader question. It is /// the complement of the other two rather than a read of on its own, so a /// page under the rail's list and an open terminal both light this segment, exactly as IsVaultsTab /// used to treat both as "not SFTP, not S3". /// internal bool IsSshShowing => !IsTransfersShowing && !IsBucketsShowing; /// The rail's first entry, which the design calls "mode-dependent" rather than fixed. /// /// Terminal by default, Files while the SFTP screen is the one showing, Buckets while S3 is — read /// straight off the same three flags the switcher above lights, so the row and the segment can never /// name two different modes. See and /// for the matching glyph and the command the row runs. /// internal string FirstRailItemLabel => IsBucketsShowing ? "Buckets" : IsTransfersShowing ? "Files" : "Terminal"; /// /// The glyph beside , by Material Icons codepoint — see /// Palette.axaml's remark on IconFont for why this codebase spells glyphs that way. /// internal string FirstRailItemIcon => IsBucketsShowing ? "" : IsTransfersShowing ? "" : ""; /// Runs whichever of the three the row is currently naming. /// /// One command for a row whose meaning changes, rather than three rows shown and hidden by mode — the /// row itself already reads the same three flags and /// answer to, so asking again here would be a second place those three facts could disagree. /// [RelayCommand] private void ShowFirstRailItem() { if (IsBucketsShowing) { ShowFiles(RemoteKind.Bucket); } else if (IsTransfersShowing) { ShowFiles(RemoteKind.Host); } else { ShowTerminal(); } } // ---- Which vaults this window is showing ---- /// /// This machine's preferences about which vaults are drawn, or null while nothing is open. /// /// /// Held here rather than inside because the menu that changes it — the /// rail's own user popover since v5b, the tab strip's vault menu before it — is this view model's, and /// the screens that read it are that one's. Rebuilt per unlock: it is read out of the cache the session /// opened, so it cannot outlive the session any more than the keyring can. /// private VaultVisibility? visibility; /// /// One switch per readable vault, for the rail's user popover. /// /// /// Somebody in four teams does not want four teams' machines in front of them all day. The switches are /// per window and per machine, and what they change is what is drawn — see /// for the things they deliberately do not change. /// internal ObservableCollection VaultToggles { get; } = []; /// Whether the menu has anything to offer. /// /// One vault is the ordinary case — somebody who has never joined a team — and a menu holding a single /// switch that cannot be moved is a menu that answers nothing. The New vault entry is still worth /// having, so this hides the list rather than the flyout. /// internal bool HasVaultSwitches => VaultToggles.Count > 1; /// Refills the switches from the vaults this session can read. /// /// The readable ones, not every known one: a vault whose grant awaits re-wrap has nothing that would /// decrypt, so a switch for it would do nothing and say so to nobody. Personal first, then by name, /// which is the order every other vault list in the application uses. /// private void RebuildVaultToggles() { VaultToggles.Clear(); if (Vault is { } open && visibility is { } preferences) { foreach (var readable in open.Session.ReadableVaults .OrderByDescending(row => row.IsPersonal) .ThenBy(row => row.Name, StringComparer.CurrentCulture)) { VaultToggles.Add(new VaultToggleViewModel( readable.VaultId, readable.Name, readable.IsPersonal, preferences.IsShown(readable.VaultId))); } } OnPropertyChanged(nameof(HasVaultSwitches)); } /// Shows or stops showing one vault's items. /// /// /// The personal vault is drawn in the menu, ticked, and cannot be switched off — see /// . Leaving it out of the list would read as a bug, and /// letting it be switched off would empty the snippet, log and bucket screens at once, since all three /// are read from the active vault alone. /// /// /// Refuses to switch off the last one that is showing. In practice the rule above already makes that /// unreachable; it is here for the session whose personal grant is unreadable, where the alternative is /// an application that looks broken and gives no clue which menu broke it. /// /// [RelayCommand] private async Task ToggleVaultAsync(VaultToggleViewModel? row) { if (row is null || Vault is not { } open || visibility is not { } preferences) { return; } if (!row.CanHide) { StatusMessage = "Your personal vault is always shown. Everything filed nowhere else lives in it."; return; } var hiding = row.IsShown; if (hiding && VaultToggles.Count(toggle => toggle.IsShown) <= 1) { StatusMessage = "At least one vault has to be showing."; return; } await preferences.SetHiddenAsync(row.VaultId, hiding, CancellationToken.None) .ConfigureAwait(true); // The lists first, then the switches: rebuilding the switches is what redraws the menu, and doing it // second means the menu and the screen behind it never disagree, even for a frame. await open.RefreshVaultsAsync(CancellationToken.None).ConfigureAwait(true); RebuildVaultToggles(); StatusMessage = hiding ? $"'{row.Name}' is no longer shown. It still syncs, and hosts that authenticate with its keys " + "still connect." : $"'{row.Name}' is showing again."; } /// Redraws everything built from the session's vault list. /// /// Handed to the teams screen, which is where a vault gets made. The switches come from that list and /// so does every host, key and pin on the vault screens, so both are a vault out of date the moment one /// is created — and neither is on screen at that point, which is exactly why nothing would have noticed. /// private async Task OnVaultsChangedAsync(CancellationToken cancellationToken) { if (Vault is { } open) { await open.RefreshVaultsAsync(cancellationToken).ConfigureAwait(true); } RebuildVaultToggles(); } /// /// Goes to the vaults screen with the new-vault form open. /// /// /// The screen a vault is made on is the one that shows vaults — where the people, the roles and the key /// holders already are, which is the next thing anybody making a shared vault wants. The form asks for a /// name and nothing else; see VaultsViewModel.CreateVaultAsync for the membership list that is /// made behind it. /// [RelayCommand] private void ShowNewVault() { ShowScreen(ShellScreen.Vaults); vaults.NewVaultCommand.Execute(null); } /// /// Goes to the keychain with the bucket editor open. /// /// /// /// The same shape as and for the same reason: the thing being made lives on /// one screen, and the moment somebody wants it happens on another. Here the two are a whole tab apart — /// S3 is where a bucket is used and the keychain is where its keys are kept — which is what made the S3 /// screen's empty picker read as an application that could not add one at all. /// /// /// Silent when the vault is locked, which is a state the button behind this is not reachable in: the /// screen it sits on is inside the unlocked half of the window. /// /// private void ShowNewBucket() { ShowScreen(ShellScreen.Keychain); Vault?.NewObjectStoreCommand.Execute(null); } // ---- The phone's connect menu ---- /// /// Whether the phone's connect menu is open over the terminal. /// /// /// /// Drawn by the Android head alone, and shell state rather than something that view could hold on its /// own for the reason is: it has to collapse the renderer while it is up. See /// . /// /// /// It exists because the phone gives a terminal the whole screen. The bottom bar and the vault header /// are gone while a shell is showing, so the three things that bar was the way to — a host, a host's /// files, a bucket — need a way back that is not "leave the terminal first and remember what you were /// doing". The menu is that, and every entry on it is one of the two navigation commands above. /// /// [ObservableProperty] private bool isConnectSheetOpen; /// Raises the connect menu over the terminal. /// /// Gated on the terminal surface rather than merely trusting its only button to be off screen otherwise. /// The flag collapses the renderer, so one set while a page was showing would be a sheet nobody can see /// holding a terminal hidden that nothing would put back. /// [RelayCommand] private void OpenConnectSheet() { if (!IsTerminalSurface) { return; } IsConnectSheetOpen = true; } /// Lowers the connect menu, leaving the terminal where it was. /// /// The scrim, the CANCEL row and the system back gesture all come here. Choosing an entry does not, and /// does not need to: every entry navigates, and leaving the terminal surface lowers the sheet on its own /// — see , which is what makes "the sheet is only ever up over a terminal" /// true of routes nobody wrote it for. /// [RelayCommand] private void CloseConnectSheet() => IsConnectSheetOpen = false; // ---- 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(); // ◆ IT USED TO GO TO THE HOSTS PAGE FIRST, and the reason it did no longer exists. An unknown or // changed host key was answered by a prompt drawn on that page, and the palette opens from any screen, // so connecting from the files screen without the jump would have left the question behind the screen // that asked it. Both heads draw the decision over the surface now — see IsHostKeyDecisionShowing — // and the other thing this connection can say before it dials, a refusal on Vault.Status, is in the // status bar, which is a row of the window rather than part of a screen. // // So the palette leaves the user where they were, which is the whole point of a palette: Ctrl+K over a // transfer that is still running should not cost the transfer's screen. vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId); // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken // generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its // own source — so passing this one would read as cancellation plumbing that is not there. await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true); } /// /// 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) { // Before anything that can return early, and outside the try: looking for a newer build does not // depend on there being a profile, a server or a vault, and a machine that never gets past the setup // screen is still one that should not be running a build with a hole in it. Start() is a no-op on a // copy that cannot replace itself. updateScreen.Start(); try { paths.EnsureCreated(); await caches.MigrateAsync(cancellationToken).ConfigureAwait(true); var profile = await Opener().ReadProfileAsync(cancellationToken).ConfigureAwait(true); if (profile is null) { State = ShellState.NeedsServer; StatusMessage = "Sign in to a DodoSSH server to set this machine up."; return; } AdoptIdentity(profile.DisplayName, profile.Email, profile.Subject, profile.Issuer); ServerUrl = profile.ServerUrl; State = ShellState.Locked; StatusMessage = $"Enrolled against {profile.ServerUrl}."; // Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key // back. Offering the button without the second would prompt for a key that is not there; without // the first it would prompt for a wrap that is not there. Neither failure is one a user could // make sense of, so the button simply does not appear. CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OperationCanceledException) { State = ShellState.NeedsServer; StatusMessage = $"The local cache could not be opened: {exception.Message}"; } } /// 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); AdoptIdentity(outcome.Me.DisplayName, outcome.Me.Email, outcome.Me.Subject, outcome.Me.Issuer); StatusMessage = outcome.Message; if (outcome.Status == ProvisionStatus.EnrollmentRequired) { State = ShellState.NeedsEnrollment; return; } // An unlocked vault stays unlocked. This command is reachable from the preferences screen // of a running application — it is how somebody whose sign-in expired gets back online — // and moving the state machine to Locked there would throw an unlock screen over an open // vault whose keys are still in memory, which is neither locked nor honest. if (IsUnlocked) { await RememberSignInAsync(cancellationToken).ConfigureAwait(true); return; } State = ShellState.Locked; }).ConfigureAwait(true); } /// 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, deviceName, "Personal", cancellationToken), cancellationToken) .ConfigureAwait(true); ConfirmPassphrase = string.Empty; RecoveryCode = outcome.RecoveryCode; RecoveryCodeWrittenDown = false; StatusMessage = outcome.Message; // A brand-new account always yields a code. An account someone else already enrolled does // not, and there is nothing to show. State = RecoveryCode is null ? ShellState.Locked : ShellState.ShowingRecoveryCode; }).ConfigureAwait(true); } /// /// Puts the recovery code on the clipboard. /// /// /// /// ◆ The one secret in this application that is deliberately offered to the clipboard, and the /// contrast with VaultViewModel.CopyPublicKeyAsync is the whole argument. There, copying the /// private key is refused outright, because installing a key means pasting the public half and /// the private one has no business leaving the vault. Here there is no better route: the code exists for /// one screen, is stored nowhere, and has to reach a password manager — so the clipboard is the intended /// destination rather than a way around the design. /// /// /// Both screens already made the code selectable, and both said why: a person who cannot get it out of /// the box photographs the screen, and a screenshot is a far worse home for it than a clipboard. This is /// that argument finished. Selecting a monospaced, letter-spaced string with a thumb is the version of /// "possible" that people give up on. /// /// /// It says what it did, including the case where there is nothing to say it to — a machine with no /// clipboard has to be told so rather than left with a button that appears to do nothing, which is the /// same rule the keychain's copy already follows. And the sentence names what has to happen next, /// because a clipboard is not somewhere a recovery code may stay: this screen is the only moment it /// exists, and the next thing copied replaces it. /// /// [RelayCommand] private async Task CopyRecoveryCodeAsync() { if (RecoveryCode is not { Length: > 0 } code) { return; } if (copyToClipboard is null) { StatusMessage = "This machine has no clipboard. Select the code and copy it by hand."; return; } await copyToClipboard(code).ConfigureAwait(true); StatusMessage = "Copied. Paste it into your password manager now — this screen is the only place " + "it exists, and the next thing you copy replaces it."; } /// 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); } /// What the status line says while the platform's own consent dialogue is up. /// /// A runtime check rather than a constructor parameter, unlike the device name beside it, and the /// difference between the two is why: a device name is a fact about one handset that only the head can /// read, whereas which dialogue appears is a fact about the platform this assembly is running on, and a /// value every Android head would pass identically is a parameter that only makes the heads longer. /// Naming the wrong operating system here is not cosmetic — it is the sentence a user reads while /// deciding whether the prompt in front of them is the one this application asked for. /// private static string GestureWait => OperatingSystem.IsAndroid() ? "Waiting for your fingerprint…" : "Waiting for Windows…"; /// 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( GestureWait, async () => { var outcome = await Opener() .UnlockWithDeviceAsync(deviceKeys, cancellationToken) .ConfigureAwait(true); StatusMessage = outcome.Message; if (!outcome.IsUnlocked) { // A declined gesture leaves the passphrase box exactly where it was, which is the whole // fallback: the user types instead. Nothing about the screen changes but the message. return; } await AdoptAsync(outcome.Session!, cancellationToken).ConfigureAwait(true); }).ConfigureAwait(true); } /// /// 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( GestureWait, async () => { var registered = await vault.Session .RegisterDeviceAsync(connection.Account, deviceKeys, deviceName, cancellationToken) .ConfigureAwait(true); if (!registered) { StatusMessage = "This machine has nowhere to keep a device key."; return; } CanRegisterDevice = false; CanForgetDevice = true; // Named rather than "this machine", because the account lists several and this is the // sentence that says which one just gained the ability to open the vault. StatusMessage = $"'{deviceName}' can now unlock without your passphrase."; }).ConfigureAwait(true); } /// /// 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( GestureWait, async () => { var revocation = await vault.Session .ForgetDeviceAsync(connection?.Account, deviceKeys, cancellationToken) .ConfigureAwait(true); CanForgetDevice = false; // Not re-offered here even though it is now true, because registering probes the TPM and // this is not the moment to do it: somebody who has just withdrawn a device is not about to // add one back, and the offer reappears on the next unlock. StatusMessage = revocation switch { DeviceRevocation.Complete => "This machine no longer unlocks without your passphrase, and the account no longer " + "lists it.", DeviceRevocation.LocalOnly => "This machine no longer unlocks without your passphrase. You are offline, so the " + "account still lists it — sign in and withdraw it again to finish.", _ => "There was no device key on this machine.", }; }).ConfigureAwait(true); } /// /// 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); // Before the vault view model, because that is what reads it — and read at all rather than defaulted // to "everything shown", because a vault somebody set aside last week should still be set aside. visibility = await VaultVisibility.LoadAsync(session, cancellationToken).ConfigureAwait(true); Vault = new VaultViewModel( session, workspace, knownHosts, () => connection, ReconnectAsync, copyToClipboard, connectionLog, visibility); State = ShellState.Unlocked; // Offered only where it can actually be honoured: a machine that can keep a key, and a profile that // has not already registered one. Asked once here rather than recomputed, because the answer // involves a TPM probe. CanRegisterDevice = session.Profile.DeviceWrappedPrivateKey is null && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); // The other side of the same fact, and it needs its own flag rather than the negation of that one: // "not offered because this machine has no TPM" and "not offered because it is already registered" // are both !CanRegisterDevice, and only the second has anything to withdraw. CanForgetDevice = session.Profile.DeviceWrappedPrivateKey is not null; await Vault.LoadAsync(cancellationToken).ConfigureAwait(true); // After the load, because the switches are built from the vaults the session admitted and the // keyring is filled during it — before, and a machine with a team vault would come up with one // switch until something else rebuilt them. RebuildVaultToggles(); // After the load, because what the transfers screen takes from the vault is the host list and an // empty one would leave its picker blank until the next unlock. transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory()); // After the list exists, and it matters after a lock rather than after the first unlock: shells kept // running while the vault was closed, so some of these hosts are connected before their rows are a // second old. RefreshConnectedHosts(); // After the first load, so the list is on screen before anything talks to a server. The loop is // started from the UI thread deliberately: every pass resumes here, which is what keeps the // observable collections single-threaded. // // Its first pass is also what brings this machine online: the pass asks ReconnectAsync for a // server, and that is where a remembered sign-in is resumed. Nothing here has to know whether // this unlock followed a sign-in or a cold launch on a train. // // Deliberately not awaited here, and not done before this point either. Resuming is a discovery // call and a token exchange — a network round trip, and on an unreachable network a slow one — // and unlocking must never wait on one. Everything the unlock screen promises about working // offline stops being true the moment the passphrase leads to a socket. So the vault opens, and // the titlebar says OFFLINE until the round trip this starts has an answer. Vault.StartAutoSync(); } /// /// 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); } // With the session, because it was read out of that session's cache. Keeping it would be a set of // switches describing vaults nothing can open, offered on a lock screen. visibility = null; RebuildVaultToggles(); LiveSessionCount = workspace.LiveSessionCount; // A confirmation armed on the preferences screen must not survive onto the unlock screen, where // the same card is offered with a warning it can no longer count. IsConfirmingSignOut = false; State = ShellState.Locked; StatusMessage = "Locked."; } // ---- Signing out ---- /// 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; /// /// Starts a sign-out from the rail's user popover, or from settings mode's own Logout row, from wherever /// the window is showing. /// /// /// only arms ; the confirmation itself is drawn /// inline on the Account settings page while the vault is unlocked — see SettingsAccountPage.axaml /// — and nowhere else, because MainWindow.axaml's own copy of SignOutCard is inside the /// setup half of the window, which is hidden the whole time this one is reachable. Calling /// straight from the popover on, say, the hosts screen would arm the flag with /// nothing on screen to show it — a card raised nobody can see. Entering settings on Account first is /// what the popover's own "New vault" and "New bucket" rows already do for the same reason; see /// . /// /// v5c: went to ShellScreen.Preferences before this wave, because that bare screen was the only /// place the confirmation card could be seen. It moved to the Account settings page with the card — see /// design-notes/v5c-fidelity-notes.md — and this is the one command both the rail's popover Logout row /// and settings mode's own bottom Logout row are wired to, so the confirmation has exactly one home. /// /// [RelayCommand] private void SignOutFromPopover() { EnterSettings(SettingsPage.Account); SignOut(); } /// /// 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); } // With the session, as on lock — and here the cache it came from is about to be deleted // outright, so the switches would be describing vaults this machine no longer has a row for. visibility = null; RebuildVaultToggles(); connection?.Dispose(); connection = null; rememberedToken = null; await caches.ResetAsync(cancellationToken).ConfigureAwait(true); LiveSessionCount = workspace.LiveSessionCount; AccountName = null; Email = null; Issuer = null; Passphrase = string.Empty; ConfirmPassphrase = string.Empty; RecoveryCode = null; RecoveryCodeWrittenDown = false; CanUnlockWithDevice = false; CanRegisterDevice = false; CanForgetDevice = false; State = ShellState.NeedsServer; OnPropertyChanged(nameof(IsOnline)); RaiseSyncState(); StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the " + "keychain itself is untouched. Sign in to set this machine up again."; }).ConfigureAwait(true); } /// /// 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; workspace.FontSizeStepRequested -= OnFontSizeStepRequested; workspace.RendererReattached -= OnRendererReattached; transfers.PropertyChanged -= OnTransfersPropertyChanged; // Stopped here rather than left to the process exiting with it: the loop holds no vault key and // nothing it touches needs an ordered teardown, but a `PeriodicTimer` left running is a task this // object would otherwise leak. StopLastConnectedTick(); // The session-elapsed loop is the same kind of leak and gets the same treatment, cancelled rather // than merely forgotten so its own PeriodicTimer wait unblocks and the task actually ends. await sessionElapsedTick.CancelAsync().ConfigureAwait(false); sessionElapsedTick.Dispose(); // Early, and it only cancels a timer and waits for a pass in flight. It has to come before the // vault because the restart path disposes this whole object and then applies the update — so a // check still running would be writing into a view model the process is about to replace. await updateScreen.DisposeAsync().ConfigureAwait(false); knownHosts.Close(); // Detached before it is disposed, so a session torn down after this point finds nothing to post to // rather than a completed channel. Disposed rather than merely closed, because it owns a background // task — and it waits only as long as that task takes to stop, never for the queue to drain. workspace.ConnectionLog = null; await connectionLog.DisposeAsync().ConfigureAwait(false); // Before the vault, and it waits: a transfer still writing has an open remote file and an open local // one, and a process that exits while those are in flight leaves a part file longer than the bytes // that reached it. await transfers.DisposeAsync().ConfigureAwait(false); if (Vault is { } open) { await open.DisposeAsync().ConfigureAwait(false); } connection?.Dispose(); } /// /// 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. oldValue.VaultsChanged -= OnVaultsAdmitted; oldValue.FilesRequested -= OnVaultFilesRequested; 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; // A vault somebody shared arrives on a synchronisation pass rather than through anything the // user pressed here — so the menu that lists them is rebuilt from the event rather than at the // end of a command, which is the one place a newly admitted vault has no command to be at the // end of. newValue.VaultsChanged += OnVaultsAdmitted; // ◆ The phone's action bar asking for a host's files rather than a shell on it. An event because // the screen it leads to is this object's and the transfers view model behind it is a sibling of // the vault rather than a part of it; which machine is the vault's business, because a host is a // decrypted item. See OnVaultFilesRequested. newValue.FilesRequested += OnVaultFilesRequested; // The host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts // disconnected — so without this the status dots go out once a minute underneath terminals that // are still open. The rows belong to the vault and the connection state belongs to the shell, // which is exactly why the shell has to repaint them rather than the vault carrying the flag. newValue.Hosts.CollectionChanged += OnVaultHostsChanged; } // Built from the vault and thrown away with it, here rather than at each of the three places a // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind // would keep a disposed vault alive and repaint a screen nobody can reach. KnownHostsScreen?.Detach(); // v5c-3: the back arrow's own destination, on the same reasoning as ImportViewModel's onCancel below // — KnownHostsViewModel has no business knowing ShellScreen exists. KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue, () => ShowScreen(ShellScreen.Keychain)); // v5c-3: CloseImport, so the importer's own Cancel button can back out to the Preferences page // beneath it without ImportViewModel knowing anything about settings mode — the same reasoning // VaultViewModel's copyToClipboard delegate is built on. ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator(), CloseImport); SnippetsScreen?.Detach(); SnippetsScreen = newValue is null ? null : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync); LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections); // Emptied with the vault it was read out of. These rows are decrypted log entries — a host's name // and the account and endpoint dialled — and a lock that left them on the shell would be a list of // where somebody works, still on screen and still readable, after the thing that decrypted it was // disposed and every key it held was zeroed. RecentConnections.Clear(); OnPropertyChanged(nameof(HasRecentConnections)); RaiseSyncState(); // The host-key decision is a property of the vault, so swapping the vault out is one of the ways it // stops being on screen — locking with a question still up is the case, and it is reachable: a // handshake that stopped on an unknown key does not hold the window, so the lock button is live // behind the card asking about it. RaiseTerminalState(); } /// /// /// Named properties are watched rather than all of them: the titlebar's sync state is the vault's outbox /// depth, which lives on the vault, and re-raising the shell's derived properties on every notification a /// busy vault produces would repaint the titlebar on every keystroke in an editor. /// /// /// The two host-key flags are here because the decision the vault raises collapses this shell's WebView — /// see . It is a property rather than an event because it is a /// state the vault is in and not a moment: it is entered by a refused handshake, left by either answer, /// and cleared outright by forgetting a pin from a host's editor, which is a third caller that has /// nothing to do with connecting. /// /// private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal) || string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal)) { RaiseSyncState(); } if (string.Equals( e.PropertyName, nameof(VaultViewModel.HasPendingHostKey), StringComparison.Ordinal) || string.Equals( e.PropertyName, nameof(VaultViewModel.HasHostKeyMismatch), StringComparison.Ordinal)) { RaiseTerminalState(); } } /// /// Restrings the ago-text too, for the same reason it repaints the status dots: a rebuilt row starts /// with neither, and this is the one place both know a rebuild just happened. No log read here — see /// — only the timestamps already on hand, written /// onto whichever row objects exist now. /// private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) { RefreshConnectedHosts(); Vault?.RestringLastConnected(); } private void RaiseSyncState() { OnPropertyChanged(nameof(IsFullySynced)); OnPropertyChanged(nameof(SyncLabel)); // The same fact from a third direction: what signing out would cost is the outbox depth, and a // confirmation card left showing a count from before the last pass would be quoting a number that // has since been sent. OnPropertyChanged(nameof(SignOutWarning)); } /// /// 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); } /// /// Redraws the vault menu after a synchronisation pass found a vault this account had not seen. /// /// /// The vault itself is already on every screen by the time this runs — the pass that admitted it /// reloaded the lists — so this is only the one thing the vault view model has no way to reach: the /// switches in the tab strip, which are built here from the session's vault list. /// private void OnVaultsAdmitted(object? sender, EventArgs e) => RebuildVaultToggles(); /// /// 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. var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) { StartedAt = clock.GetUtcNow(), Cipher = NullIfEmpty(e.Cipher), HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm), IdentityLabel = e.IdentityLabel, }; AdoptTab(adopted); RefreshConnectedHosts(); return; } tab.Opened(e.SessionId); // From this moment, not from when the tab first appeared — connecting is not open, and the session // shell's elapsed timer is about a shell that is actually running. tab.StartedAt = clock.GetUtcNow(); tab.Cipher = NullIfEmpty(e.Cipher); tab.HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm); tab.IdentityLabel = e.IdentityLabel; // The pane exists from this moment, so what the rectangle should hold has changed — the card goes and // the WebView comes back. Only for the tab being looked at, which is what these flags already ask. RaiseTerminalState(); // Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was // no pane to name until this line. Activate(tab); RefreshConnectedHosts(); TerminalSessionOpened?.Invoke(this, EventArgs.Empty); } /// /// 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: nothing has gone wrong and there is nothing to report, so the tab goes and the /// decision takes its place until it is answered. /// /// /// It used to move the window, and that was the wrong half of the problem to solve. The prompt was /// a banner on the hosts screen, so this navigated there — Screen = Hosts, Surface = Page — /// on the reasoning that a connection can be started from the palette on any screen and a question behind /// the screen somebody is looking at is a question nobody can answer. True, and answered the wrong way /// round: what it did to the one connection that has no host at all was to judge a machine typed into the /// phone's connect box on a list it is deliberately not on, after taking the box away. /// /// /// Both heads now draw the decision over whatever is showing, so nothing has to move and the palette's /// case is covered without a special one. What is left here is the tab, and the surface it was on stays as /// it is — for the phone that surface is the connect box, and it is what the user comes back to /// whichever way they answer. /// /// private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e) { if (!attempts.Remove(e.AttemptId, out var tab)) { return; } if (!e.IsAwaitingAnAnswer) { tab.Failed(e.Reason); RaiseTerminalState(); return; } var index = Tabs.IndexOf(tab); Tabs.Remove(tab); RaiseTabState(); if (ReferenceEquals(SelectedTab, tab)) { // The neighbour, preferring the one on the left, exactly as closing a tab by hand does. SelectedTab = Tabs.Count == 0 ? null : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; } // Nothing else. The decision is drawn over the surface the user is on rather than on a screen they // have to be taken to — see the remark — and the neighbour selected above is what that surface shows // once the question is answered. Its WebView is collapsed while the card is up, which is the one // thing a card in that rectangle cannot do for itself; see IsHostKeyDecisionShowing. RaiseTerminalState(); } /// /// 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, deviceName)), ]; 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. /// /// /// Also where a session ending re-reads the connection log, per decision 4: the host just closed /// is the one whose ago-text is about to stop being suppressed by , /// and it deserves "just now" rather than whatever it last said. /// runs before this event does, but its own write is queued onto a background task rather than made /// inline — see its remarks — so a read landing before that write drains shows the previous entry /// instead. It self-heals on the next activation or the next session end, which is judged an acceptable /// gap rather than worth blocking this handler on the recorder's queue. /// /// private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Dispatcher.UIThread.Post(() => { if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) { tab.IsLive = false; } RefreshConnectedHosts(); _ = Vault?.RefreshLastConnectedAsync(CancellationToken.None); }); /// /// Repaints the host list's status dots from the tab list, and with them the session sidebar's contents. /// /// /// /// 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. /// /// /// is rebuilt here, on the same match, rather than from a subscription /// of its own — every place a dot can go stale is a place the sidebar can too, so folding the two into one /// pass is what keeps them from drifting apart rather than a saving of code. See this method's own call /// sites for the list of moments that counts as. /// /// private void RefreshConnectedHosts() { if (Vault is not { } vault) { ActiveTabPinnedPaths.Clear(); OnPropertyChanged(nameof(HasActiveTabPinnedPaths)); RaiseSessionState(); return; } HostRowViewModel? connectedActiveHost = null; foreach (var host in vault.Hosts) { host.IsConnected = Tabs.Any( tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal)); // The active tab's host, and only when it is actually connected: the sidebar is for a session // that is open, not for whichever machine's tab happens to be selected while it is still dialling. if (host.IsConnected && SelectedTab is { } selected && string.Equals(host.Label, selected.Label, StringComparison.Ordinal)) { connectedActiveHost = host; } } ActiveTabPinnedPaths.Clear(); if (connectedActiveHost is not null) { foreach (var path in connectedActiveHost.Host.PinnedPaths) { ActiveTabPinnedPaths.Add(path); } } OnPropertyChanged(nameof(HasActiveTabPinnedPaths)); RaiseSessionState(); } /// /// The paths pinned on the connected host behind the selected tab, for the sidebar's QUICK ACCESS section. /// /// /// Empty whenever there is no selected tab, no vault, or the selected tab's host cannot be found or is /// not connected — see , which is the one place this is filled. Kept /// under this name and this shape across v5b, which moved its one reader from a chip strip above the /// terminal to the session sidebar beside it — the fact and the command that acts on it did not change, /// only where they are drawn. /// internal ObservableCollection ActiveTabPinnedPaths { get; } = []; /// Whether the active tab's host has anything for QUICK ACCESS to draw. internal bool HasActiveTabPinnedPaths => ActiveTabPinnedPaths.Count > 0; /// /// Whether the v5b session sidebar draws at all. /// /// /// /// Wider than the old pin strip's own gate, which was IsTerminalSurface && HasActiveTabPinnedPaths /// — hidden for a host with nothing pinned. The sidebar draws more than pins now: QUICK ACCESS's own /// "+ Pin folder" row and, on the terminal surface, SNIPS, both worth showing on a host that has not pinned /// anything yet. So the gate moved from "is there something to list" to "is a session actually in focus": /// a selected terminal tab on the terminal surface, or a connected host on the SFTP surface — the design's /// own "hides when no session is active". /// /// /// The SFTP half reads rather than , /// unlike QUICK ACCESS's own rows, which stay keyed to the selected tab even here — see the remark on /// . That is a real seam: browsing a host on SFTP without ever having /// opened a terminal on it draws a sidebar whose QUICK ACCESS section is empty, because the pins it shows /// come from the tab list rather than from whichever host SFTP is connected to. Reusing /// OpenPinnedPathCommand unchanged, as asked, is what this trades for a second pins source. /// /// internal bool ShowsQuickAccessSidebar => (IsTerminalSurface && SelectedTab is not null) || (IsTransfersShowing && Transfers.IsConnected); /// /// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins. /// /// /// The sidebar's QUICK ACCESS click handler — the pin strip's own, unchanged, since v5b moved where this /// is drawn and not what it does. It shares 's plumbing — the same /// refusal, the same re-found row, the same password-sheet branch for a host /// that cannot be dialled unattended — through , and the host is found by /// the same label match used to decide the row is there to click. /// [RelayCommand] private async Task OpenPinnedPathAsync(string path) { if (Vault is not { } vault || SelectedTab is not { } tab || vault.Hosts.FirstOrDefault( host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host) { return; } await GoToHostFilesAsync(host, path).ConfigureAwait(true); } // ---- v5b session shell: the sidebar's + rows, and the SFTP tab row's click ---- /// /// Opens the vault's snippet editor from the sidebar's own "+ Add Snip" row. /// /// /// The same shape as : land on the screen the new item belongs to, then run /// that screen's own "start one" command, rather than opening the editor from here and hoping the screen /// underneath it agrees what it is editing. /// [RelayCommand] private void AddSnippetFromSidebar() { ShowScreen(ShellScreen.Snippets); SnippetsScreen?.NewCommand.Execute(null); } /// /// Types a sidebar SNIPS row into the terminal the terminal surface is showing. /// /// /// /// Selects the row on and runs its own InsertCommand rather than /// writing to the renderer directly — that command already carries the whole safety story the snippets /// screen argues for: pasted text rather than a typed one, no Enter unless the snippet was marked as one /// that runs. A second insert path here would be a second place that story could go stale. /// /// /// reads , which is /// — the sidebar only appears on the terminal surface with a tab selected, so /// this is ordinarily available. It can still be a tab that is still connecting, which has no session to /// type into; landing on the snippets screen instead of doing nothing silently is this command's answer to /// that one gap, the same as clicking a row with nothing selected would otherwise be. /// /// [RelayCommand] private async Task InsertSnippetAsync(SnippetRowViewModel snip) { if (SnippetsScreen is not { } screen || snip is null) { return; } screen.Selected = snip; if (screen.CanInsert) { await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true); return; } ShowScreen(ShellScreen.Snippets); } /// /// Opens the active tab's host for editing, at QUICK ACCESS, from the sidebar's own "+ Pin folder" row. /// /// /// The closest honest affordance rather than a new one: this application has no way to open the host /// editor already scrolled to one card inside it, so what this does is what a person reaching for the same /// goal from the hosts screen already does — select the host and press EDIT. /// opens the same three-card editor QUICK ACCESS's own "Pin folder" row inside the pane already reaches; /// see App.axaml's Border.section remark for that column's own QUICK ACCESS heading. /// [RelayCommand] private void PinFolderFromSidebar() { if (Vault is not { } vault || SelectedTab is not { } tab || vault.Hosts.FirstOrDefault( host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host) { return; } ShowScreen(ShellScreen.Hosts); vault.SelectedHost = host; vault.EditSelectedHostCommand.Execute(null); } /// /// The SFTP tab row's click: makes one of the terminal's tabs the SFTP surface's browsed host. /// /// /// /// This is the resolution of the v5b notes' open question about a per-tab SFTP session: this application /// has no such architecture, and building one is out of this wave's scope. What it has instead is /// — the same "Browse files" plumbing a pin click and the hosts screen's /// own action already use — so a click on the SFTP tab row honestly does the one thing this application /// can honestly do with a tab's host on that screen: open (or reuse) a second, SFTP-specific connection to /// it and land the remote pane there. /// /// /// is set here too, ahead of the navigation, which is what lets the SFTP tab /// row mark its active tab with the same flag the terminal /// row's own active mark already reads — see SessionTabRow.axaml. It is also what keeps the /// sidebar's QUICK ACCESS in step: that list is keyed to , on both surfaces, so /// browsing a host's files from its tab also makes that host's pins the ones QUICK ACCESS shows. /// /// [RelayCommand] private async Task SelectFilesHostAsync(TerminalTabViewModel tab) { if (Vault is not { } vault || tab is null || vault.Hosts.FirstOrDefault( host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host) { return; } SelectedTab = tab; await GoToHostFilesAsync(host, null).ConfigureAwait(true); } /// /// The SFTP session header's "Open terminal" button: connects a new terminal to the host SFTP has open. /// /// /// The mirror of and named in the notes as the other of the two /// directions the header's cross-surface button needs — "ConnectCommand-side for the terminal direction". /// Goes through exactly as the quick-connect palette's own /// does, rather than reusing an existing tab: SFTP's connection is /// its own, opened separately from any terminal, so there is no terminal tab to already point at — a new /// one is what "Open terminal" honestly means here, the same as it does from the nav rail's switcher. /// [RelayCommand] private async Task OpenTerminalForFilesHostAsync() { if (Vault is not { } vault || Transfers.SelectedHost is not { } row) { return; } vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId); await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true); } /// /// The account and endpoint the session shell's header and status bar are about right now, or null when /// neither surface has one. /// /// /// One property reading whichever surface is showing, rather than one binding per surface reading its own /// source directly — SessionHeader.axaml and SessionStatusBar.axaml are the same markup on /// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view /// to. The terminal's is 's own address; SFTP's is , /// which is already the account and endpoint actually dialled — nothing here re-derives it. /// internal string? SessionAddress => Surface switch { ShellSurface.Terminal => SelectedTab?.Address, _ when IsTransfersShowing => Transfers.IsConnected ? Transfers.ConnectedTo : null, _ => null, }; /// /// The negotiated server-to-client cipher the status bar shows beside CONNECTED, or null when neither /// surface has a session — or when the one it has has nothing to report. /// /// /// One property reading whichever surface is showing, for the same reason /// is: SessionStatusBar.axaml is the same markup on both surfaces. The terminal's is /// ; SFTP's is . /// internal string? SessionCipher => Surface switch { ShellSurface.Terminal => NullIfEmpty(SelectedTab?.Cipher), _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedCipher), _ => null, }; /// The accepted host key's algorithm, e.g. ssh-ed25519 — printed as negotiated, not shortened. /// See ; resolved the same way, off or . internal string? SessionHostKeyAlgorithm => Surface switch { ShellSurface.Terminal => NullIfEmpty(SelectedTab?.HostKeyAlgorithm), _ when IsTransfersShowing => NullIfEmpty(Transfers.ConnectedHostKeyAlgorithm), _ => null, }; /// /// The display name of the key or credential that authenticated, or null when a typed password did or /// nothing is connected. /// /// See ; resolved the same way, off or . internal string? SessionIdentityLabel => Surface switch { ShellSurface.Terminal => SelectedTab?.IdentityLabel, _ when IsTransfersShowing => Transfers.ConnectedIdentityLabel, _ => null, }; /// /// The status bar's one run of text for the host key and the identity that authenticated — the design's /// ed25519 · acme-deploy-key — or null while is. /// /// /// Composed here rather than in the view, so SessionStatusBar.axaml binds one TextBlock to /// one string instead of assembling a separator between two bindings that can each be absent on their own. /// The algorithm prints exactly as negotiated — ssh-ed25519, not the design's shortened /// ed25519 — because trimming it would be a cosmetic claim about a string this shell has no /// business editing. A typed-password session has no item to name, so it shows the algorithm alone with /// no · — there being nothing after the dot would be a punctuation mark standing in for the fact /// that was never real. /// internal string? SessionIdentityText => SessionHostKeyAlgorithm is { } algorithm ? SessionIdentityLabel is { } label ? $"{algorithm} · {label}" : algorithm : null; /// Null for an empty string, unchanged otherwise. /// /// and are non-nullable /// strings that this shell nonetheless treats as absent when empty — a session whose facts genuinely /// could not be read back (see TerminalWorkspace.GetSessionFacts) hands over /// rather than null, and the status bar's collapse bindings only know how to ask about null. /// private static string? NullIfEmpty(string? value) => string.IsNullOrEmpty(value) ? null : value; /// Whether the session the header and status bar are describing is actually open. /// /// Not the same question as being non-null on the terminal surface: a tab /// that is still connecting has an address — it is what the connecting card names — but no live shell /// behind it yet, and "CONNECTED" would be a claim has not made. /// internal bool IsSessionConnected => Surface switch { ShellSurface.Terminal => SelectedTab?.IsLive is true, _ when IsTransfersShowing => Transfers.IsConnected, _ => false, }; /// /// "session HH:MM:SS" for the status bar, or null while there is nothing connected or nothing timed. /// /// /// Restrung on every state change worth reacting to at once — see — and /// once a minute besides, by , for the case where nothing else /// changes and a minute simply passes. Not restrung any faster than that: the v5b notes ask for "restrung /// per minute max", which this reads as a ceiling on how often the bound value is asked to repaint rather /// than a floor on the precision of what it says — the seconds in the string can be up to a minute stale /// between two ticks, exactly as "1 min ago" already is elsewhere in this shell. /// internal string? SessionElapsedText { get { var startedAt = Surface switch { ShellSurface.Terminal => SelectedTab?.StartedAt, _ when IsTransfersShowing => Transfers.ConnectedStartedAt, _ => null, }; if (startedAt is not { } started) { return null; } var elapsed = clock.GetUtcNow() - started; if (elapsed < TimeSpan.Zero) { // The clock this ran on and the clock the session opened on can disagree by a hair when both // are TimeProvider.System, which is close enough to "now" that a negative span is rounding // rather than a session that has not started yet. elapsed = TimeSpan.Zero; } return string.Create( CultureInfo.InvariantCulture, $"session {(int)elapsed.TotalHours:00}:{elapsed.Minutes:00}:{elapsed.Seconds:00}"); } } /// Re-reads the three facts the session shell's header, status bar and tab rows depend on. /// /// Its own method rather than three more lines folded into and /// , because the SFTP tab row and header need it too and neither of those /// two methods otherwise has anything to do with . /// private void RaiseSessionState() { OnPropertyChanged(nameof(SessionAddress)); OnPropertyChanged(nameof(IsSessionConnected)); OnPropertyChanged(nameof(SessionElapsedText)); OnPropertyChanged(nameof(SessionCipher)); OnPropertyChanged(nameof(SessionHostKeyAlgorithm)); OnPropertyChanged(nameof(SessionIdentityLabel)); OnPropertyChanged(nameof(SessionIdentityText)); OnPropertyChanged(nameof(ShowsQuickAccessSidebar)); } /// /// The narrow subscription 's own remark on the shell's constructor /// promises: three properties this screen exposes, each already raised through ObservableObject, /// picked out by name rather than repainting on every change makes — /// the transfer queue's own rows tick several times a second while a download runs, and none of that is a /// fact the header, the status bar or the SFTP tab row's active mark reads. /// private void OnTransfersPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName is nameof(TransfersViewModel.SelectedHost) or nameof(TransfersViewModel.IsConnected) or nameof(TransfersViewModel.Remote) or nameof(TransfersViewModel.ConnectedTo) or nameof(TransfersViewModel.ConnectedCipher) or nameof(TransfersViewModel.ConnectedHostKeyAlgorithm) or nameof(TransfersViewModel.ConnectedIdentityLabel)) { RaiseSessionState(); } } /// Restrings once a minute, for the shell's whole life. /// See the remark on for why this loop is not gated on a screen. private async Task RunSessionElapsedTickAsync(CancellationToken cancellationToken) { try { using var timer = new PeriodicTimer(LastConnectedTickInterval, clock); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true)) { OnPropertyChanged(nameof(SessionElapsedText)); } } catch (OperationCanceledException) { // The shell is closing; see DisposeAsync. } } partial void OnLiveSessionCountChanged(int value) { OnPropertyChanged(nameof(HasLiveSessions)); OnPropertyChanged(nameof(LiveSessionSummary)); } partial void OnStateChanged(ShellState value) { OnPropertyChanged(nameof(IsStarting)); OnPropertyChanged(nameof(IsNeedingServer)); OnPropertyChanged(nameof(IsNeedingEnrollment)); OnPropertyChanged(nameof(IsShowingRecoveryCode)); OnPropertyChanged(nameof(IsLocked)); OnPropertyChanged(nameof(IsAskingForThePassphrase)); OnPropertyChanged(nameof(IsUnlocked)); RaiseTerminalState(); OnPropertyChanged(nameof(SignOutWarning)); RaiseSyncState(); // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list. // The hosts screen is what this application is for. The surface as well as the screen: shells outlive // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to // the application would not be what "unlocked" looks like. if (value is ShellState.Unlocked) { Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; } } /// /// 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); } // Who is in each vault is read from the server rather than from the vault itself, so there is // nothing to show until somebody asks for it — and asking for it on every unlock would be a request // per launch for a screen most people never open. Fire-and-forget because a property change cannot // await, and because the view model turns every failure into its own status line rather than // throwing. if (value is ShellScreen.Vaults) { _ = vaults.LoadAsync(CancellationToken.None); } UpdateLastConnectedVisibility(); } /// /// Goes to the file screen with one of the two kinds of remote offered. /// /// /// /// The phone splits SFTP and S3 into two destinations over this one view model; the desktop has a /// single FILES screen with the toggle on it. So the kind is set here, by the thing that navigates, /// rather than in — which would have made every arrival at /// force the picker back to hosts, including the desktop's own nav /// rail arriving at a screen with a bucket already open. /// /// /// It will not change the kind while something is open. There is one session behind both /// destinations, so switching the picker under a live one would leave a screen titled S3 listing an /// SFTP host's files. Refusing and saying so is the honest half of sharing a view model between two /// destinations; the screen keeps showing what is actually open. /// /// [RelayCommand] private void ShowFiles(RemoteKind kind) { // Refusing means staying put, not arriving somewhere and saying no. Moving Screen anyway would put // the S3 entry in the sidebar over a screen still listing an SFTP host — two pieces of chrome // disagreeing about where you are, which is worse than the navigation simply not happening. if (Transfers.IsConnected && Transfers.Remote != kind) { Transfers.Status = kind is RemoteKind.Bucket ? "An SFTP session is open. Close it before opening a bucket." : "A bucket is open. Close it before connecting to a host."; // Still show the screen the open session belongs to, so the message is somewhere it can be // read — the button that was pressed is in the sidebar, which is on screen either way. Screen = Transfers.Remote is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers; Surface = ShellSurface.Page; return; } Transfers.Remote = kind; Screen = kind is RemoteKind.Bucket ? ShellScreen.Buckets : ShellScreen.Transfers; Surface = ShellSurface.Page; } /// /// Takes the hosts screen to the files screen, on the host it asked about. /// /// /// /// ◆ "Connect via SFTP", from the phone's action bar. It was reachable before only by going to the /// files screen and choosing the machine again out of a picker — which meant naming a host twice, the /// second time on a screen that had no idea one had already been chosen. /// /// /// It goes through rather than setting the screen itself, so the one refusal /// there — a bucket already open — is made once and made here too. The host is chosen after that call, /// because arriving is what clears the picker. /// /// /// It navigates and then connects, and the two are separate on purpose. A host wanting a typed /// password cannot be dialled from a list, so that case opens the picker with the machine already chosen /// and the box beside it — the same branch the tap on the hosts screen makes, and for the same reason. /// Everything else connects, and its failures land on the files screen's own status line, which is where /// somebody who has just arrived there is looking. /// /// /// The row is re-found in the transfers screen's own list rather than used directly. That list is a copy /// rebuilt from the vault's — see TransfersViewModel.Hosts — and its picker binds to rows in it, /// so handing it the vault's object would select nothing. /// /// private void OnVaultFilesRequested(object? sender, HostFilesEventArgs e) => _ = GoToHostFilesAsync(e.Host, null); /// /// Takes the hosts screen to the files screen, on one host — and, if asked, straight to one of its pins. /// /// /// /// The shared body behind and : which /// machine and where to land once it is open are the only two things that differ between "Browse files" /// and a click on the pin strip, and both are parameters here. /// /// /// A host asking for a typed password still only opens the picker. is not /// retried once somebody types the password in and presses CONNECT by hand — the same gap Browse files /// already had before there was a path to carry, and closing it would mean holding a pending navigation /// across an arbitrarily long wait for someone else to type something, which is a worse trade than a pin /// that has to be clicked again once the session is open. /// /// /// It reconnects rather than checking whether this host is already open. The existing "Browse /// files" flow — — never asked, and reusing it here /// keeps the two entry points behaving alike rather than teaching the pin strip a shortcut Browse files /// does not have. See hosts-v5-design-spec.md: SFTP is a second authenticated connection, every /// time it is opened, by design. /// /// private async Task GoToHostFilesAsync(HostRowViewModel host, string? path) { ShowFiles(RemoteKind.Host); if (Screen is not ShellScreen.Transfers) { // The refusal above stood: a bucket is open, and its message is on screen. Choosing a host under // it would leave the picker pointing at a machine nothing is going to dial. return; } Transfers.SelectedHost = Transfers.Hosts.FirstOrDefault(row => row.EntityId == host.EntityId); if (Transfers.SelectedHost is null) { return; } if (Transfers.SelectedHostAsksForAPassword) { Transfers.BeginChoosingRemoteCommand.Execute(null); Transfers.Status = $"{host.Label} asks for a password. Type it here, then CONNECT."; return; } await Transfers.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true); if (path is not null && Transfers.IsConnected) { await Transfers.GoRemoteCommand.ExecuteAsync(path).ConfigureAwait(true); } } /// /// /// The one place the connect sheet is lowered by something other than a tap. Every way out of a /// terminal ends here — a rail or bottom-bar destination, the files screen, the palette connecting to a /// host, closing the last tab, a lock — and each of them would otherwise leave the flag set on a shell /// showing a page. That is not merely untidy: the flag collapses the renderer, so the next return to the /// terminal would draw the sheet again over a rectangle held blank by it. /// partial void OnSurfaceChanged(ShellSurface value) { if (value is not ShellSurface.Terminal) { IsConnectSheetOpen = false; } RaiseSurfaceState(); UpdateLastConnectedVisibility(); } /// /// Starts or stops the last-connected feature as the hosts screen comes on screen or leaves it. /// /// /// /// Called from both and , because either one /// alone can be what makes flip: moves both /// together, but selecting a tab and then clicking back to the Hosts rail item moves only the surface, /// and switching groups on the hosts screen itself moves neither. is /// what turns two call sites into one decision rather than two chances to double the work. /// /// /// The read this starts is the one already argues /// for doing on demand rather than on a timer; this is the "on demand" it means. Not awaited, for the /// reason every other navigation-triggered read on this shell is not: arriving at a screen must not wait /// on a decrypt pass over its log. /// /// private void UpdateLastConnectedVisibility() { var showing = IsHostsShowing; if (showing == wasHostsScreenShowing) { return; } wasHostsScreenShowing = showing; if (showing) { _ = Vault?.RefreshLastConnectedAsync(CancellationToken.None); StartLastConnectedTick(); } else { StopLastConnectedTick(); } } /// /// Starts restringing the hosts screen's ago-text once a minute, for as long as it stays visible. /// /// /// No log read here or in . The tick only turns the /// timestamps already read into new words, through /// — reading the connection log itself on a timer is /// exactly what LogsViewModel.cs argues that log must never be put on. /// private void StartLastConnectedTick() { if (lastConnectedTick is not null) { return; } var cts = new CancellationTokenSource(); lastConnectedTick = cts; _ = RunLastConnectedTickAsync(cts.Token); } /// Stops the loop began, if one is running. /// /// Cancelled rather than merely forgotten, so the loop's own PeriodicTimer wait unblocks and the /// task actually ends instead of ticking, unobserved, against a hosts screen nobody is looking at. /// private void StopLastConnectedTick() { if (lastConnectedTick is not { } cts) { return; } lastConnectedTick = null; cts.Cancel(); cts.Dispose(); } private async Task RunLastConnectedTickAsync(CancellationToken cancellationToken) { try { using var timer = new PeriodicTimer(LastConnectedTickInterval, clock); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true)) { Vault?.RestringLastConnected(); } } catch (OperationCanceledException) { // The hosts screen navigated away, or the shell is closing. } } /// /// 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() { // ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface and // IsTransfersShowing, and Surface moves through here rather than through RaiseTerminalState — see // OnSurfaceChanged. Without this the sidebar's binding would go stale the moment somebody navigated // off a terminal tab to a page screen, even though the property itself would answer correctly the // next time anything else asked it. RaiseSessionState(); OnPropertyChanged(nameof(IsHostsScreen)); OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsKeychainScreen)); OnPropertyChanged(nameof(IsVaultsScreen)); OnPropertyChanged(nameof(IsPreferencesScreen)); OnPropertyChanged(nameof(IsKnownHostsScreen)); OnPropertyChanged(nameof(IsSnippetsScreen)); OnPropertyChanged(nameof(IsLogsScreen)); OnPropertyChanged(nameof(IsMoreScreen)); OnPropertyChanged(nameof(IsBucketsScreen)); OnPropertyChanged(nameof(IsShowingPages)); OnPropertyChanged(nameof(IsVaultsTab)); OnPropertyChanged(nameof(IsHostsShowing)); OnPropertyChanged(nameof(IsTransfersShowing)); OnPropertyChanged(nameof(IsKeychainShowing)); OnPropertyChanged(nameof(IsVaultsShowing)); OnPropertyChanged(nameof(IsPreferencesShowing)); OnPropertyChanged(nameof(IsKnownHostsShowing)); OnPropertyChanged(nameof(IsSnippetsShowing)); OnPropertyChanged(nameof(IsLogsShowing)); OnPropertyChanged(nameof(IsMoreShowing)); OnPropertyChanged(nameof(IsBucketsShowing)); OnPropertyChanged(nameof(IsMoreSurface)); // The rail's switcher and its mode-dependent first row read the same three flags above, so // whatever moved them has to repaint these too — see the remarks on each. OnPropertyChanged(nameof(IsSshShowing)); OnPropertyChanged(nameof(FirstRailItemLabel)); OnPropertyChanged(nameof(FirstRailItemIcon)); RaiseTerminalState(); } /// /// 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)); OnPropertyChanged(nameof(IsHostKeyDecisionShowing)); // ShowsQuickAccessSidebar and the session shell's own facts read IsTerminalSurface too, so anything // that moves the terminal state has to repaint them — otherwise the sidebar could stay drawn over a // page reached by clicking away from a terminal tab. RaiseSessionState(); // The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user // navigated to preferences would be a second "you are here" mark pointing at a terminal that is not // on screen; see TerminalTabViewModel.IsShowing. foreach (var tab in Tabs) { tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab); } } partial void OnIsSearchingChanged(bool value) => RaiseTerminalState(); /// partial void OnIsConnectSheetOpenChanged(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(); }