using System.Collections.ObjectModel; using System.Collections.Specialized; using System.ComponentModel; using System.Security.Authentication; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Auth; using DodoSSH.Client.Import; using DodoSSH.Client.ObjectStore; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Storage; using DodoSSH.Client.Terminal; using DodoSSH.Crypto; namespace DodoSSH.Client.Shell.ViewModels; /// One vault, as a switch in the tab strip's vault menu. /// /// 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"; /// 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, } /// /// 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 = []; private IVaultServer? connection; /// The refresh token last written to the cache, so a rotation is noticed without reading it back. private string? rememberedToken; /// Guards against two resume attempts overlapping. /// /// A plain flag rather than a semaphore because every caller is on the UI thread — the sync loop and /// the Sync button — and what has to be prevented is a second attempt starting while the first is /// waiting on a token endpoint, not a data race. /// private bool resuming; private bool disposed; /// /// Establishes a connection to a server. /// /// /// A delegate rather than a direct call to , so this whole /// state machine can be driven by a test against an in-memory server. Sign-in is the one step that /// genuinely needs a browser and a network, and letting it be the reason nothing else is testable /// would be the wrong trade. /// internal delegate Task SignInHandler(Uri serverUrl, CancellationToken cancellationToken); /// /// Re-establishes a connection from a remembered sign-in, without a browser. /// /// /// A delegate for the same reason is one: a real resume needs discovery /// and a token endpoint, and making that the only way to reach this state machine would put the whole /// "comes back online by itself" behaviour out of reach of a test. /// internal delegate Task ResumeHandler( Uri serverUrl, string refreshToken, CancellationToken cancellationToken); /// /// How file-transfer sessions are opened. The same object as the connection factory in the composed /// application — one type implements both — and a separate parameter because it is a separate capability /// and the tests that drive this state machine have no use for it. /// /// /// 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; 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(); } /// /// 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)); [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; [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 IsImportScreen => Screen is ShellScreen.Import; /// 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, and the phone's connect sheet. /// /// /// 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 && SelectedTab is { HasSession: true }; /// /// Whether the terminal half of the window is the half being shown, pane or no pane. /// /// /// Every condition in except the one about there being a session, and it /// is worth its own name because a tab exists before its session does — see /// . This is what "the user is looking at the terminal" means; the /// other two say which of the two things that can be in that rectangle is drawn. /// internal bool IsTerminalSurface => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching; /// /// Whether the card that stands in for a pane is showing. /// /// /// /// The other half of , and exclusive with it by construction: a selected /// tab either has a session or it does not. It covers both of the states in which it does not — still /// connecting, and failed — because both are a tab with something to say and nothing to draw it in. /// /// /// It obeys the same occlusion rule as everything else in that rectangle, which is why it has to turn the /// terminal off rather than merely draw over it. See . /// /// internal bool IsConnectingShowing => IsTerminalSurface && SelectedTab is { HasSession: false }; /// [ObservableProperty] private ShellSurface surface; /// Points the nav rail at a screen. /// /// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me /// something else" and a rail click that changed a screen nobody could see would do nothing visible. /// The tab itself is untouched: its shell goes on running and the strip goes on naming it. /// [RelayCommand] private void ShowScreen(ShellScreen target) { Screen = target; Surface = ShellSurface.Page; } /// Switches to the terminal surface. /// /// /// The other half of , and it exists because the phone's bottom bar /// names the terminal beside the pages. The desktop reaches this surface only implicitly — opening a /// session or clicking a tab — because its tab strip is always on screen and is itself the way back. /// A phone has no room for a permanent strip beside a full-height screen, so the destination needs a /// button, and a button needs a command. /// /// /// Not gated on there being a tab, for the same reason is not: closing /// the last tab returns the surface to a page, so the empty case does not arise here — and the terminal /// screen carries an empty state anyway, which is worth being able to reach deliberately. /// /// [RelayCommand] private void ShowTerminal() { Surface = ShellSurface.Terminal; // 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 the panel about it opened — the desktop's /// drawer, the phone's connect bar — carrying whatever authentication the keychain resolves for it and a /// password box only if it needs one. Connecting from here instead would be a third connect path that /// had to answer all of that again. /// /// /// ◆ It opens that panel rather than merely selecting the row, and on the phone it has to. /// Choosing a host there no longer raises the bar — a tap on the list connects instead, see /// VaultViewModel.ShowsConnectBar — 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 that flag exists to distinguish from browsing. /// /// /// 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.OpenHostPaneCommand.Execute(known); ShowScreen(ShellScreen.Hosts); return; } vault.ManualTarget = row.Address; vault.ManualStatus = string.Empty; } // ---- The desktop's fixed tabs ---- /// /// Whether the tab strip's Vaults tab is the one showing. /// /// /// /// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open /// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail /// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which /// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of /// which is the screen you are looking at. /// /// /// Expressed as "a page, and not one of the two the strip took" rather than as a fourth /// . SFTP and S3 were already members before they /// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs — /// so a surface for each would have been a second way to say a thing already says, /// and the two would have had to be kept in step. and /// are the other two tabs, unchanged and already used by both heads. /// /// /// Not , which is one of the nine screens underneath this tab. The /// two are true together whenever somebody is looking at the vaults screen and are otherwise unrelated: /// this one is "the strip is on its first tab rather than on SFTP, S3 or a terminal". /// /// internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen); /// The pages that live under the Vaults tab, as opposed to under SFTP or S3. private static bool IsVaultsPage(ShellScreen screen) => screen is not (ShellScreen.Transfers or ShellScreen.Buckets); /// /// Which page the Vaults tab returns to. /// /// /// /// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to /// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain, /// not on the hosts screen. Without this it would land on whatever happened to hold, /// which after a visit to SFTP is — a Vaults tab showing the file /// screen. /// /// /// This is not the hidden field argues against, and the difference is /// worth stating because the two look alike. That one would have been a second copy of "which page", /// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab /// remembering its page, the way any tab does — and nothing else reads it. /// /// private ShellScreen vaultsScreen = ShellScreen.Hosts; /// Selects the Vaults tab, on the page it was last left on. [RelayCommand] private void ShowVaults() => ShowScreen(vaultsScreen); // ---- 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 is in the /// tab strip, which 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 menu on the Vaults tab. /// /// /// 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(); // The hosts page, because that is where this connection's questions get asked. An unknown or changed // host key is answered by a prompt drawn on that page and the palette opens from any screen, so // connecting from the files screen without this would leave the question behind the screen that asked // it. The surface does not stay here — the tab that appears for the attempt takes it — and it does not // need to: a refusal that needs an answer puts the page back, which is where this leaves the screen. Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId); // Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken // generates ExecuteAsync(object? parameter) that ignores the argument and supplies a token from its // own source — so passing this one would read as cancellation plumbing that is not there. await vault.ConnectCommand.ExecuteAsync(null).ConfigureAwait(true); } /// /// Ranked rather than merely filtered: a host whose name starts with what was typed comes before one /// that merely contains it, and both come before a match found only in the address. Typing three /// characters of a name people use daily should not put that host third. /// private void RefreshSearchResults() { SearchResults.Clear(); if (Vault is not { } vault) { SelectedSearchResult = null; return; } var query = SearchText.Trim(); var matches = query.Length == 0 ? vault.Hosts.AsEnumerable() : vault.Hosts .Select(host => (host, rank: Rank(host, query))) .Where(candidate => candidate.rank < int.MaxValue) .OrderBy(candidate => candidate.rank) .ThenBy(candidate => candidate.host.Label, StringComparer.CurrentCulture) .Select(candidate => candidate.host); foreach (var host in matches.Take(8)) { SearchResults.Add(host); } SelectedSearchResult = SearchResults.FirstOrDefault(); OnPropertyChanged(nameof(HasSearchResults)); } private static int Rank(HostRowViewModel host, string query) { if (host.Label.StartsWith(query, StringComparison.CurrentCultureIgnoreCase)) { return 0; } if (host.Label.Contains(query, StringComparison.CurrentCultureIgnoreCase)) { return 1; } return host.Address.Contains(query, StringComparison.CurrentCultureIgnoreCase) ? 2 : int.MaxValue; } /// /// Brings the schema up to date and works out which screen to show. /// /// /// Migrating happens before unlock and touches no encrypted content — only the shape of the tables. /// That is the point of migrating rather than recreating: a user who upgrades while offline must still /// be able to open their vault. /// internal async Task StartAsync(CancellationToken cancellationToken) { // 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; } AccountName = profile.DisplayName ?? profile.Email ?? profile.Subject; ServerUrl = profile.ServerUrl; State = ShellState.Locked; StatusMessage = $"Enrolled against {profile.ServerUrl}."; // Both halves have to hold: a wrap in the cache, and a machine still willing to hand the key // back. Offering the button without the second would prompt for a key that is not there; without // the first it would prompt for a wrap that is not there. Neither failure is one a user could // make sense of, so the button simply does not appear. CanUnlockWithDevice = profile.DeviceWrappedPrivateKey is not null && await deviceKeys.IsAvailableAsync(cancellationToken).ConfigureAwait(true); } catch (Exception exception) when (exception is not OperationCanceledException) { State = ShellState.NeedsServer; StatusMessage = $"The local cache could not be opened: {exception.Message}"; } } /// Discovers the server and runs the browser sign-in. [RelayCommand] private async Task SignInAsync(CancellationToken cancellationToken) { if (!Uri.TryCreate(ServerUrl, UriKind.Absolute, out var url)) { StatusMessage = "That is not a valid server URL."; return; } // Checked separately from parsing, because "localhost:5233" parses perfectly well as an absolute // URI whose scheme is "localhost" — and then fails much later with something unrelated to the // actual mistake. if (url.Scheme is not ("http" or "https")) { StatusMessage = $"A server URL has to start with http:// or https://, not {url.Scheme}:."; return; } await RunAsync( "Opening your browser to sign in…", explain: exception => ExplainSignInFailure(exception, url), work: async () => { connection?.Dispose(); connection = null; connection = await signIn(url, cancellationToken).ConfigureAwait(true); OnPropertyChanged(nameof(IsOnline)); RaiseSyncState(); // The browser is finished with, and what follows is a round trip to the DodoSSH server // that can take a while or fail on its own. Saying so is the difference between a wait // and a hang: a screen still reading "Opening your browser to sign in…" while the server // is the thing struggling sends the user back to a browser that did nothing wrong. StatusMessage = $"Signed in. Asking {url.Host} about your account…"; var outcome = await Provisioner()! .RefreshAsync(ServerUrl, cancellationToken) .ConfigureAwait(true); AccountName = outcome.Me.DisplayName ?? outcome.Me.Email ?? outcome.Me.Subject; StatusMessage = outcome.Message; if (outcome.Status == ProvisionStatus.EnrollmentRequired) { State = ShellState.NeedsEnrollment; return; } // An unlocked vault stays unlocked. This command is reachable from the preferences screen // of a running application — it is how somebody whose sign-in expired gets back online — // and moving the state machine to Locked there would throw an unlock screen over an open // vault whose keys are still in memory, which is neither locked nor honest. if (IsUnlocked) { await RememberSignInAsync(cancellationToken).ConfigureAwait(true); return; } State = ShellState.Locked; }).ConfigureAwait(true); } /// Creates the identity key and the personal vault. [RelayCommand] private async Task EnrollAsync(CancellationToken cancellationToken) { if (Provisioner() is not { } provisioner) { StatusMessage = "Sign in first."; return; } if (!ValidateNewPassphrase()) { return; } await RunAsync( "Creating your keychain. This deliberately takes a moment…", async () => { var chosen = Passphrase; var outcome = await Task .Run( () => provisioner.EnrollAsync( ServerUrl, chosen, 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; /// /// 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; 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; // 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; 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 host list is rebuilt from scratch on every synchronisation pass, and a rebuilt row starts // disconnected — so without this the status dots go out once a minute underneath terminals that // are still open. The rows belong to the vault and the connection state belongs to the shell, // which is exactly why the shell has to repaint them rather than the vault carrying the flag. newValue.Hosts.CollectionChanged += OnVaultHostsChanged; } // Built from the vault and thrown away with it, here rather than at each of the three places a // vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind // would keep a disposed vault alive and repaint a screen nobody can reach. KnownHostsScreen?.Detach(); KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue); ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator()); SnippetsScreen?.Detach(); SnippetsScreen = newValue is null ? null : new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync); LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections); // 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(); } /// /// One property is watched rather than all of them: the titlebar's sync state is the vault's outbox /// depth, which lives on the vault, and re-raising the shell's two derived properties on every /// notification a busy vault produces would repaint the titlebar on every keystroke in an editor. /// private void OnVaultPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (string.Equals(e.PropertyName, nameof(VaultViewModel.PendingChanges), StringComparison.Ordinal) || string.Equals(e.PropertyName, nameof(VaultViewModel.LastSyncFailed), StringComparison.Ordinal)) { RaiseSyncState(); } } private void OnVaultHostsChanged(object? sender, NotifyCollectionChangedEventArgs e) => RefreshConnectedHosts(); private void RaiseSyncState() { OnPropertyChanged(nameof(IsFullySynced)); OnPropertyChanged(nameof(SyncLabel)); // The same fact from a third direction: what signing out would cost is the outbox depth, and a // confirmation card left showing a count from before the last pass would be quoting a number that // has since been sent. OnPropertyChanged(nameof(SignOutWarning)); } /// /// Puts a tab in the strip for a connection that has only just been asked for. /// /// /// /// This is what stops connecting looking like the application having stopped. The tab appears in the same /// turn as the click, carrying its own status, and the window switches to it — so a handshake against a /// machine that is asleep is a card that says which machine, rather than a status line under a window /// that does nothing for thirty seconds. /// /// /// Kept by attempt id rather than by label: several connections can be in flight now that one does not /// hold the vault, and two of them can perfectly well be to the same host. /// /// private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e) { var tab = new TerminalTabViewModel(e.Label, e.Address); attempts[e.AttemptId] = tab; AdoptTab(tab); } /// /// 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. AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address)); RefreshConnectedHosts(); return; } tab.Opened(e.SessionId); // The pane exists from this moment, so what the rectangle should hold has changed — the card goes and // the WebView comes back. Only for the tab being looked at, which is what these flags already ask. RaiseTerminalState(); // Now, and not when the tab appeared. Activating tells the renderer which pane to show, and there was // no pane to name until this line. Activate(tab); RefreshConnectedHosts(); TerminalSessionOpened?.Invoke(this, EventArgs.Empty); } /// /// Answers a connection that did not become a session. /// /// /// Two outcomes, because there are two kinds of not-connecting. A refusal stays in the strip as a tab /// carrying its reason — connecting no longer holds the window, so the user may be three screens away by /// now, and the status line they are not looking at is not where a failure should end. A host key /// question is not a refusal: it is a prompt on the hosts screen, so the tab goes and the window is put /// back where the question is being asked. /// private void OnVaultConnectionFailed(object? sender, ConnectionFailedEventArgs e) { if (!attempts.Remove(e.AttemptId, out var tab)) { return; } if (!e.IsAwaitingAnAnswer) { tab.Failed(e.Reason); RaiseTerminalState(); return; } var index = Tabs.IndexOf(tab); Tabs.Remove(tab); RaiseTabState(); if (ReferenceEquals(SelectedTab, tab)) { // The neighbour, preferring the one on the left, exactly as closing a tab by hand does. SelectedTab = Tabs.Count == 0 ? null : Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)]; } // The screen the question is drawn on, and the page rather than a terminal. A connection can be // started from the palette on any screen, so without this the prompt would be behind whatever the // user was looking at, with the connection waiting on an answer they cannot reach. Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; } /// /// Takes a tab into the strip and shows it. /// /// /// One method rather than one per way of opening a session, so the order of these steps is decided once. /// It is not arbitrary: the tab is in the strip before anything is told about it, so a handler runs /// against a strip that already shows what it is about. /// private void AdoptTab(TerminalTabViewModel tab) { Tabs.Add(tab); RaiseTabState(); // Selecting it is what tells the renderer to show its pane, through OnSelectedTabChanged — for a tab // that has one. A tab that is still connecting has none, and selecting it shows the card instead. SelectedTab = tab; // The surface, but deliberately not the screen. A session opened from the files screen shows its // terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or // clicking away comes back to the transfer that is presumably still running. Surface = ShellSurface.Terminal; if (tab.HasSession) { TerminalSessionOpened?.Invoke(this, EventArgs.Empty); } } /// /// Everything the selection decides, in the order it has to be decided in: which tab is marked, what the /// terminal's rectangle holds, which hosts show as connected, and finally the frame that tells the /// renderer. See for why the last of those is not awaited. /// partial void OnSelectedTabChanged(TerminalTabViewModel? value) { foreach (var tab in Tabs) { tab.IsSelected = ReferenceEquals(tab, value); } // Which of the two things can be in the terminal's rectangle depends on the selected tab having a // session, so moving the selection is one of the ways that answer changes. It also repaints the // strip's active mark, which follows the selection and the surface together. RaiseTerminalState(); RefreshConnectedHosts(); // The snippets screen names the terminal its buttons will type into, and it has no way to learn that // a different tab is selected — the tab list is the shell's, and a subscription the other way would // be a screen keeping the shell alive. SnippetsScreen?.TargetChanged(); if (value is not null) { Activate(value); } } /// Tells the renderer which pane to show. /// /// /// Fire-and-forget, and it has to be: one caller is a property setter, and a selection that awaited a /// socket write would make clicking a tab an operation that can fail. A dropped activation frame costs /// one wrong pane until the next click; blocking the setter would cost the tab strip. /// /// /// The workspace's own token is not available here, so this passes none. The send is a single frame on /// an already-open socket and returns immediately when there is no renderer. /// /// /// A tab with no session is skipped rather than sent as session zero, which is not a pane the renderer /// has: selecting a tab that is still connecting shows the card, and there is nothing to activate until /// the handshake finishes. /// /// private void Activate(TerminalTabViewModel tab) { if (!tab.HasSession) { return; } _ = workspace.ActivateSessionAsync(tab.SessionId, CancellationToken.None).AsTask(); } /// Which terminal a snippet would go into right now. /// /// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in, /// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked, /// say, the most recently opened would send a command somewhere the user is not looking. /// /// The connections that are open and therefore have no log entry yet. /// /// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and /// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent — /// an SFTP session has no tab at all, and a tab whose remote hung up still has one. /// private IReadOnlyList LiveConnections() => [ .. connectionLog.Open().Select(open => new LiveConnection( open.HostLabel, open.Address, open.StartedAt, 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. /// private void OnWorkspaceSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Dispatcher.UIThread.Post(() => { if (Tabs.FirstOrDefault(tab => tab.SessionId == e.SessionId) is { } tab) { tab.IsLive = false; } RefreshConnectedHosts(); }); /// /// Repaints the host list's status dots from the tab list. /// /// /// Matched on the label, which is what a tab was named after, because that is the only handle the two /// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would /// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller /// wrong than a dot that goes dark when the vault is reopened. /// private void RefreshConnectedHosts() { if (Vault is not { } vault) { return; } foreach (var host in vault.Hosts) { host.IsConnected = Tabs.Any( tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal)); } } partial void OnLiveSessionCountChanged(int value) { OnPropertyChanged(nameof(HasLiveSessions)); OnPropertyChanged(nameof(LiveSessionSummary)); } partial void OnStateChanged(ShellState value) { OnPropertyChanged(nameof(IsStarting)); OnPropertyChanged(nameof(IsNeedingServer)); OnPropertyChanged(nameof(IsNeedingEnrollment)); OnPropertyChanged(nameof(IsShowingRecoveryCode)); OnPropertyChanged(nameof(IsLocked)); OnPropertyChanged(nameof(IsAskingForThePassphrase)); OnPropertyChanged(nameof(IsUnlocked)); RaiseTerminalState(); OnPropertyChanged(nameof(SignOutWarning)); RaiseSyncState(); // Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list. // The hosts screen is what this application is for. The surface as well as the screen: shells outlive // a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to // the application would not be what "unlocked" looks like. if (value is ShellState.Unlocked) { Screen = ShellScreen.Hosts; Surface = ShellSurface.Page; } } /// /// Every screen flag, on every change, for the same reason the vault column raises all four of its /// section flags: a rail lighting the current screen and a body showing it are one fact read from two /// directions, and raising only the one that became true leaves the old button lit. /// partial void OnScreenChanged(ShellScreen value) { RaiseSurfaceState(); // What the Vaults tab comes back to; see the field. if (IsVaultsPage(value)) { vaultsScreen = value; } // 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); } } /// /// 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; } /// /// /// 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(); } /// /// Both changes raise the same set, and they have to: and its four siblings /// read and together, so which of the two moved does not /// narrow what became stale. /// private void RaiseSurfaceState() { OnPropertyChanged(nameof(IsHostsScreen)); OnPropertyChanged(nameof(IsTransfersScreen)); OnPropertyChanged(nameof(IsKeychainScreen)); OnPropertyChanged(nameof(IsVaultsScreen)); OnPropertyChanged(nameof(IsPreferencesScreen)); OnPropertyChanged(nameof(IsKnownHostsScreen)); OnPropertyChanged(nameof(IsImportScreen)); 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)); RaiseTerminalState(); } /// /// Re-reads what the terminal's rectangle should hold, and which tab is lit. /// /// /// One method for all four, because they are one fact read from four directions: the surface, the /// selection and the selected tab's own state decide together whether a pane, a card or a page is drawn — /// and the strip's active mark has to agree with the answer. Raising a subset is how one of them ends up /// pointing at something nobody can see. /// private void RaiseTerminalState() { OnPropertyChanged(nameof(IsTerminalSurface)); OnPropertyChanged(nameof(IsTerminalShowing)); OnPropertyChanged(nameof(IsConnectingShowing)); // The tabs themselves, and not only the window's own flags. A tab that stayed lit after the user // navigated to preferences would be a second "you are here" mark pointing at a terminal that is not // on screen; see TerminalTabViewModel.IsShowing. foreach (var tab in Tabs) { tab.IsShowing = IsTerminalSurface && ReferenceEquals(tab, SelectedTab); } } partial void OnIsSearchingChanged(bool value) => RaiseTerminalState(); /// 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(); }