using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Text; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using DodoSSH.Client.Api; using DodoSSH.Client.Domain; using DodoSSH.Client.Session; using DodoSSH.Client.Ssh; using DodoSSH.Client.Sync; using DodoSSH.Client.Terminal; namespace DodoSSH.Client.Shell.ViewModels; /// /// Anything the host sidebar's one list can hold. /// /// /// /// A marker, because the list is one ListBox and has to stay one — it owns the selection, and it is /// where keyboard focus lands when the terminal gives it back. Neither survives being split into a list per /// group, which is why headings are rows rather than containers. /// /// /// The cost is that a heading is selectable as far as the ListBox is concerned, and it must not be as /// far as anything else is: Connect, Edit and Delete all read the host selection. See /// VaultViewModel.SelectedSidebarRow, which is where a heading click is turned back into whatever was /// selected before it. /// /// internal interface ISidebarRow; /// One group heading, as a row in the host list. /// The group, or null for the heading ungrouped hosts fall under. /// What the heading says. /// How many hosts are under it, after the filter. /// Whether its hosts are showing. internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded) : ISidebarRow { /// The chevron, as text, because the heading is drawn in the list's own item template. internal string Chevron => IsExpanded ? "▾" : "▸"; } /// One group, as a row in the group list. /// /// Thinner than the other row types because a group is thinner: a name, and how many hosts name it. The /// count is computed from the host list rather than stored on the group — see /// for why membership lives on the host — so it is passed in rather than read off the item. /// internal sealed class HostGroupRowViewModel(VaultItem group, int hostCount) { internal Guid EntityId => group.EntityId; internal HostGroupSecret Group => group.Secret; internal string Label => group.Secret.Label; internal int HostCount => hostCount; internal bool IsReadOnly => group.IsReadOnly; internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges); /// What the row says under the name. internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts"; } /// An entry in the host editor's group picker. /// The group, or null for "no group". /// What to show. /// /// A sentinel entry rather than a nullable selection, for the reason gives: /// a ComboBox with nothing selected and a ComboBox meaning "no group" look identical and are not the same /// thing. As there, a group the vault no longer has keeps a placeholder entry, so that editing a host's port /// cannot quietly unfile it. /// internal sealed record GroupChoice(Guid? EntityId, string Label) { /// The "not in a group" entry, always first. internal static GroupChoice None { get; } = new(null, "No group"); } /// One snippet, as a row in the list. /// /// Carries the decrypted so opening the editor needs no second decryption, in /// the same way a host row does — and so that inserting one is a read from memory rather than a decryption /// per click. /// internal sealed class SnippetRowViewModel(VaultItem snippet) { internal Guid EntityId => snippet.EntityId; internal SnippetSecret Snippet => snippet.Secret; internal string Label => snippet.Secret.Label; internal bool RunsOnInsert => snippet.Secret.RunsOnInsert; internal bool IsReadOnly => snippet.IsReadOnly; internal bool HasUnsyncedChanges => snippet.HasUnsyncedChanges; internal string Badge => ItemBadge.For(snippet.IsBlocked, snippet.IsReadOnly, snippet.HasUnsyncedChanges); /// /// The command, on one line, for the list. /// /// /// Newlines become rather than being dropped or wrapped. A three-line snippet shown as one run /// of text would read as a single command, which is exactly the thing the user is deciding about when /// they look at this row. /// internal string Preview => snippet.Secret.Command .ReplaceLineEndings("\n") .Replace("\n", " ⏎ ", StringComparison.Ordinal) .Trim(); } /// One host, as a row in the list. /// /// Carries the decrypted so opening the editor needs no second decryption, and /// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and /// an item a newer client wrote that must not be re-encoded here. /// internal sealed partial class HostRowViewModel( VaultItem host, Guid vaultId, string vaultName) : ObservableObject, ISidebarRow { internal Guid EntityId => host.EntityId; /// /// Which vault this host lives in. /// /// /// Carried on the row rather than read from the session, because a session now holds several and an /// edit has to return to the vault the item came from. Writing it to the active vault instead would /// create a second copy in the personal vault and leave the team's original untouched — a silent fork /// that only shows up when somebody else wonders why their change never arrived. /// internal Guid VaultId => vaultId; /// The vault's display name, for the heading the sidebar groups under. internal string VaultName => vaultName; /// /// The vault name to print on this row, or empty when there is only one vault to be in. /// /// /// Decided by the list rather than by the row, because "is there more than one vault" is not /// something a row can see — and the alternative, a binding that reaches out to the parent view /// model from inside an item template, is the kind of thing that silently resolves to nothing. /// internal string VaultBadge { get; init; } = string.Empty; /// Whether this row has a vault to name. internal bool HasVaultBadge => VaultBadge.Length > 0; internal HostSecret Host => host.Secret; internal string Label => host.Secret.Label; internal string Address => string.Create( CultureInfo.InvariantCulture, $"{host.Secret.Username ?? "—"}@{host.Secret.Hostname}:{host.Secret.Port}"); internal bool HasUnsyncedChanges => host.HasUnsyncedChanges; internal bool IsBlocked => host.IsBlocked; internal bool IsReadOnly => host.IsReadOnly; /// How this host authenticates, in one word. /// /// Worth a word in the list because the three behave differently at the moment of connecting: only one of /// them needs the password box filled in, and a user staring at an empty password box on a /// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which /// is why the stored kind is "credential" rather than a second sort of password. /// internal string Authentication => host.Secret switch { { CredentialId: not null } => "credential", { SshKeyId: not null } => "key", _ => "password", }; /// A short marker for the row, so the list says what it knows without a tooltip. internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges); /// /// Whether a terminal is open on this host right now. /// /// /// /// The one thing on a host row that is not a property of the host. It is written by the shell, which owns /// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one /// holding the answer. The design's status dot is this, and it is the reason the row is observable at /// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright. /// /// /// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability /// would be a claim this application never checks. /// /// [ObservableProperty] private bool isConnected; } /// What a host can authenticate with. internal enum AuthenticationKind { /// Typed at the moment of connecting, and never stored. Typed, /// An SSH key in this vault. SshKey, /// A username and password in this vault. Credential, } /// An entry in the host editor's authentication picker. /// Which of the three ways this entry means. /// The bound item's id, or null for a typed password. /// What to show. /// /// What kind of thing the label names, shown beside it. Empty for the typed-password entry, which is not a /// thing in the vault. /// /// /// /// One picker for all three, not two pickers. A host authenticates with a key or a stored /// credential or a typed password, never two of them — HostSecret.TryValidate refuses a host /// naming both. Two controls would express the illegal state and then reject it at save time; one control /// cannot express it at all. That is the same reason this is a sentinel entry rather than a nullable /// selection: a ComboBox with nothing selected and a ComboBox meaning "type a password" look identical and /// are not the same thing — the first is a host whose binding has not been decided, the second is a decision. /// /// /// The qualifier is load-bearing rather than decoration. Keys and credentials are named by the user and often /// named the same thing — a key called deploy and the deploy account's password are the ordinary case — /// so a list of bare labels would offer two indistinguishable entries that authenticate completely /// differently. /// /// internal sealed record AuthenticationChoice( AuthenticationKind Kind, Guid? EntityId, string Label, string Qualifier) { /// The "type it each time" entry, always first. /// /// Named for what it costs rather than for what it lacks. "Password (no key)" described the old two-way /// choice from the key's side; with credentials in the same list the distinction a user needs is between a /// password this vault knows and one they will be asked for. /// internal static AuthenticationChoice Typed { get; } = new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty); /// An SSH key that is in the vault. internal static AuthenticationChoice ForKey(Guid entityId, string label) => new(AuthenticationKind.SshKey, entityId, label, "SSH key"); /// A credential that is in the vault. internal static AuthenticationChoice ForCredential(Guid entityId, string label) => new(AuthenticationKind.Credential, entityId, label, "credential"); /// /// A stand-in for something the host names and the vault no longer has. /// /// /// Kept in the list, and kept selected, so that opening a host to change its port does not silently /// convert it to a typed password on save. The id and the kind are both preserved — the kind because /// dropping it would rebind a dangling credential as a dangling key — and only the label admits the /// problem. /// internal static AuthenticationChoice Missing(AuthenticationKind kind, Guid entityId) => new( kind, entityId, kind is AuthenticationKind.Credential ? "(a credential that is no longer here)" : "(a key that is no longer here)", kind is AuthenticationKind.Credential ? "credential" : "SSH key"); } /// One SSH key, as a row in the list. /// /// /// Carries the decrypted , as the host row carries its host, so opening the /// editor or connecting with the key needs no second decryption. /// /// /// Nothing here exposes the private key to the view. is what the editor and the /// connect path read, and the members the XAML binds are the label, a description and a badge. That is not /// a security boundary — the same object holds the material either way — but it does mean no template, /// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious /// property. /// /// internal sealed class SshKeyRowViewModel(VaultItem key, Guid vaultId, string vaultName) { internal Guid EntityId => key.EntityId; /// Which vault this key lives in. See . internal Guid VaultId => vaultId; /// The vault's display name. internal string VaultName => vaultName; internal SshKeySecret Key => key.Secret; internal string Label => key.Secret.Label; /// What the list shows under the name: what is known about the key, never the key. internal string Description => key.Secret switch { { Passphrase: not null, PublicKey: not null } => "passphrase · public half stored", { Passphrase: not null } => "passphrase · no public half", { PublicKey: not null } => "no passphrase · public half stored", _ => "no passphrase · no public half", }; internal bool HasUnsyncedChanges => key.HasUnsyncedChanges; internal bool IsBlocked => key.IsBlocked; internal bool IsReadOnly => key.IsReadOnly; internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges); } /// One stored credential, as a row in the list. /// /// /// The same arrangement as , for the same two reasons: the decrypted secret /// travels with the row so opening the editor or connecting needs no second decryption, and /// nothing here exposes the password to the view. is what the editor and the /// connect path read; what the XAML binds is a label, a description and a badge. Not a security boundary — /// the same object holds the password either way — but it means no template, tooltip or accessibility surface /// can render a password by being pointed at the obvious property. /// /// /// One bucket, as a row in the list. internal sealed class ObjectStoreRowViewModel(VaultItem store) { internal Guid EntityId => store.EntityId; internal ObjectStoreSecret Store => store.Secret; internal string Label => store.Secret.Label; /// What the list shows under the name: where it is, never the keys. /// /// The bucket and the service, because those are what tell two entries apart — the same bucket name in /// two accounts is the ordinary case. The access key id is an identifier rather than a secret and is /// still not here: it is long, it is noise in a list, and it belongs in the detail pane. /// internal string Description => store.Secret.Endpoint is { } endpoint ? $"{store.Secret.Bucket} at {endpoint}" : $"{store.Secret.Bucket} · {store.Secret.Region}"; internal bool HasUnsyncedChanges => store.HasUnsyncedChanges; internal bool IsBlocked => store.IsBlocked; internal bool IsReadOnly => store.IsReadOnly; internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges); } internal sealed class CredentialRowViewModel( VaultItem credential, Guid vaultId, string vaultName) { internal Guid EntityId => credential.EntityId; /// Which vault this credential lives in. See . internal Guid VaultId => vaultId; /// The vault's display name. internal string VaultName => vaultName; internal CredentialSecret Credential => credential.Secret; internal string Label => credential.Secret.Label; /// What the list shows under the name: the account, never the password. /// /// The username is the whole reason a credential is a separate item rather than two fields on a host, so /// it is what the row has to show. Absent means "whatever the host says", which is a different statement /// from a blank one and is written out rather than left as an empty line. /// internal string Description => credential.Secret.Username is { } username ? username : "uses each host's own username"; internal bool HasUnsyncedChanges => credential.HasUnsyncedChanges; internal bool IsBlocked => credential.IsBlocked; internal bool IsReadOnly => credential.IsReadOnly; internal string Badge => ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges); } /// The one-word marker a row shows for its sync state. /// /// Shared by both row types rather than written twice, because the three states mean the same thing for /// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two /// different conditions. /// internal static class ItemBadge { internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) => (isBlocked, isReadOnly, hasUnsyncedChanges) switch { (true, _, _) => "rejected", (_, true, _) => "newer version", (_, _, true) => "not synced", _ => string.Empty, }; } /// /// A terminal session that has just opened. /// /// Identifies the session to the renderer and to the workspace. /// The host's name, as the vault has it. /// The account and endpoint actually dialled. internal sealed class TerminalSessionEventArgs(uint sessionId, string label, string address) : EventArgs { internal uint SessionId { get; } = sessionId; internal string Label { get; } = label; internal string Address { get; } = address; } /// A conflict, as a row. internal sealed class ConflictRowViewModel(ConflictNotice notice) { internal Guid Id => notice.Id; internal string Summary => notice.Summary; /// /// The overridden values, one line each. /// /// /// This is the whole justification for resolving a conflict automatically. If these were not shown, /// the merge would be last-writer-wins with a longer explanation. /// // The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual // keyword inside a property accessor, and this whole expression is one. internal string Detail => notice.Fields.Count == 0 ? string.Empty : string.Join( Environment.NewLine, notice.Fields.Select(entry => entry.DiscardedWasRemoval ? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept" : $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'")); internal bool HasDetail => notice.Fields.Count > 0; } /// /// Which kind of item the vault column is showing. /// /// /// One at a time, chosen by a selector at the top of the column. The alternative was every kind stacked in /// one scrolling column, which is what the column did with two of them and would not survive a third: two /// lists and two editors already only just fit at the window's minimum height, and credentials are coming. /// A section is also the unit the column's own layout is measured in — see /// DodoSSH.Client.App.Layout.Tests, which lays out one of these at a time because that is all a user /// can ever see at once. /// internal enum VaultSection { /// Every kind at once, which is where the screen opens. All, /// The SSH keys hosts authenticate with. Keys, /// The usernames and passwords they authenticate with instead. /// /// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see /// KnownHostsScreen — and the member is gone rather than left unused, because a value this /// screen's command would still accept and no longer draw anything for is a trap with no upside. /// Credentials, /// S3-compatible buckets, and the keys that reach them. /// /// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something /// somebody creates and edits and whose secret has to be kept, which is exactly what the other two /// categories are. A pin is not. /// Buckets, } /// What kind of thing a row in the vault table is. /// /// Two, since the pins left. Both are things somebody created on purpose and can edit, which is what the /// table's shared shape now assumes throughout. /// internal enum VaultItemKind { /// An SSH key. Key, /// A stored username and password. Credential, /// An S3-compatible bucket. ObjectStore, } /// /// One row of the vault table, whatever kind of item it is. /// /// /// /// The table has one shape and a TYPE column, which is what lets a single category show every kind /// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row /// rather than a replacement for one: selecting here sets the typed selection the editors and the delete /// commands already act on, so nothing downstream had to learn about a second way of naming an item. /// /// /// Nothing here is a secret. is what is known about an item — whether a /// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key /// or the password itself. The same rule the three list templates already followed, now in one place where /// it is harder to break by pointing a template at the obvious property. /// /// /// Which of the three lists this came from. /// The item, so a selection can be mapped back. /// The label the user gave it. /// The one-word kind, for the table's TYPE column. /// What is known about it. /// Its sync state, or empty. /// /// Whether this machine has a change to this item that the server has not accepted. Carried separately from /// the badge rather than read back out of it, because the badge is a sentence for a person and a count built /// by comparing it against the literal "not synced" would break the day that wording improves. /// /// One vault, as an option in the "file this into" picker. /// 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. internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal) { /// /// What the picker shows. /// /// /// A team vault is marked as one. The whole risk this picker introduces is putting a credential /// somewhere more people can read it, so the option that does that must not look like the option /// that does not. /// internal string Display => IsPersonal ? Name : $"{Name} · TEAM"; } internal sealed record VaultItemRowViewModel( VaultItemKind Kind, Guid EntityId, string Name, string Type, string Detail, string Badge, bool HasUnsyncedChanges) { /// Whether this row has anything to say about its sync state. internal bool HasBadge => Badge.Length > 0; } /// Which list a deletion that has been asked for is aimed at. internal enum DeletionTarget { /// A host, from the sidebar beside the terminal. Host, /// An SSH key, from the vault screen. Key, /// A stored password, from the vault screen. Credential, /// A group, from the panel beside the host sidebar. Group, /// A bucket, from the vault screen. ObjectStore, } /// /// A deletion that has been asked for and not yet agreed to. /// /// /// /// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see /// MainWindowViewModel.IsConfirmingSignOut. What makes it worth having at all is that the sentences /// below are computed: how many hosts authenticate with the key about to go, whether a terminal is /// open on the host about to go, and whether this machine can push the tombstone yet. A confirmation that /// only said "are you sure?" would be a click to train people out of. /// /// /// It carries the item's id rather than pointing at the selection, so that whatever moves the selection /// between the question and the answer — a background sync, a filter, a click in the list — cannot turn an /// agreement about one item into the deletion of another. /// /// /// Which list to delete from. /// The item the question is about. /// The question itself, naming the item. /// Where it goes, and how far. /// /// What is riding on this particular item — hosts that authenticate with it, a terminal open on it — or /// empty when nothing is. The line that changes the answer, as opposed to the one every deletion shares. /// internal sealed record DeletionRequest( DeletionTarget Target, Guid EntityId, string Question, string Consequence, string Usage) { /// Whether anything depends on the item, which is the line worth reading twice. internal bool HasUsage => Usage.Length > 0; } /// /// Gets this machine online, if it can be. /// /// /// /// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked /// once per synchronisation pass rather than once per vault, and that is what makes coming back from a /// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute /// of reaching a network, with nothing pressed. /// /// /// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token /// the provider has stopped accepting — because the vault's answer to all of them is the same: work /// locally and queue. /// /// internal delegate Task ServerReconnectHandler(CancellationToken cancellationToken); /// /// An open vault: the host list, the editor, syncing, and connecting a terminal. /// /// /// /// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a /// delete disappears immediately whether or not the network is there. Nothing here waits on a server to /// show a change. /// /// /// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a /// timer while it stays open. The Sync button remains, because a person who has just been handed a /// credential wants to know now rather than within the minute — but nothing depends on it being pressed. /// A background pass is deliberately quieter than the button: see . /// /// /// Being offline is a state a pass tries to leave, not one it gives up on. Every pass asks the /// shell for a connection rather than reading one it was handed at unlock — see /// — so a machine that unlocked with no network comes online by /// itself once it has one, and a sign-in survives a restart without a browser opening. /// /// /// Everything a connection needs is in the vault. Keys, passwords and host key trust are all synced /// items, so each is stored once and available on every machine — approving a fingerprint here approves it on /// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and /// that is now a choice rather than the only option this interface offered. /// /// /// A key or a password belongs to a host. Each host names the one thing it authenticates with, or /// nothing, and that choice is a field in its encrypted payload — so it follows the host to every machine /// rather than being made again per connection. The two are mutually exclusive and the editor offers them /// through a single picker, which is what makes the illegal combination unrepresentable rather than merely /// invalid. The cost is a payload schema version, paid only by hosts that actually bind something: see /// HostSecretCodec.CurrentSchemaVersion. /// /// /// /// Puts one line of text on the system clipboard, or null where there is none. /// /// A delegate rather than Avalonia's IClipboard, for the reason SignInHandler is one: the /// clipboard is reached through TopLevel.GetTopLevel(control), so taking it directly would make this /// view model need a visual — and every test that drives it need a window. Null is a machine that has no /// clipboard rather than one that failed to copy, and the difference is worth saying out loud. /// /// internal sealed partial class VaultViewModel( VaultSession session, TerminalWorkspace workspace, VaultKnownHostStore knownHosts, Func connection, ServerReconnectHandler? reconnect = null, Func? copyToClipboard = null, ConnectionRecorder? connectionLog = null) : ObservableObject, IAsyncDisposable { /// /// A minute. The pull is a delta keyed on a cursor, so an idle pass is one small request and costs the /// server almost nothing; the number that matters is how stale a teammate's change may look, and a /// minute is short enough not to be noticed. Anything much shorter would be polling for its own sake, /// and a change made on this machine does not wait for the timer anyway — saving pushes immediately. /// private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1); /// How often the logs are pruned, at most. /// /// Hours rather than minutes, because pruning writes tombstones that sync. Retention is measured in days /// and thousands of entries, so a pass that ran six hours late has nothing to catch up on — and one that /// ran every minute would be a machine talking to a server all day about housekeeping. /// private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6); /// When the logs were last pruned, or null when this session has not pruned yet. private DateTimeOffset? lastPruned; /// Serialises every synchronisation pass, whether a button pressed it or a timer did. private readonly SemaphoreSlim syncGate = new(1, 1); /// /// The groups as they came out of the vault, before the host counts are attached. /// /// /// Held between the group reload and the host reload, which are two passes because a group row says how /// many hosts name it and the hosts are read second. See . /// private IReadOnlyList> groupItems = []; /// The groups whose hosts are folded away, by id, with for ungrouped. private readonly HashSet collapsedGroups = []; private CancellationTokenSource? autoSync; private Task? autoSyncLoop; private bool disposed; /// /// The open vault this view model is showing. /// /// /// Exposed for the operations only an open session can perform — sealing the bundle to a new device key, /// chiefly — which the shell drives rather than this view model. Ownership does not move: this type /// disposes it, and a caller must not. /// internal VaultSession Session => session; /// The hosts to show, unpushed local state included. /// /// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned /// host key list checks itself against, so a filter applied here would change what the application can /// do rather than what it shows. is the filtered view. /// internal ObservableCollection Hosts { get; } = []; /// The hosts the sidebar is showing: the filter applied, nothing else. /// /// A second collection rather than a filtered view over the first, because the sidebar's list has to be /// one ListBox — it owns and it is where the keyboard lands when the /// terminal gives it back, and neither of those survives being split across several lists. /// internal ObservableCollection VisibleHosts { get; } = []; /// /// What the sidebar's list actually holds: the visible hosts, with group headings between them. /// /// /// /// A vault with no groups produces no headings at all, so this is in /// the same order, and the sidebar looks exactly as it did before groups existed. The feature is /// invisible until it is used, which is the point: somebody with eleven machines and no wish to file them /// should not be shown a heading saying so. /// /// /// Kept beside rather than replacing it. The count on the section heading, the /// filter's own arithmetic and every test that asks what the sidebar is showing all mean hosts — a /// collection whose Count silently included headings would be wrong in each of them. /// /// internal ObservableCollection SidebarRows { get; } = []; /// The groups in this vault, with the number of hosts filed under each. internal ObservableCollection Groups { get; } = []; /// The saved commands in this vault, unpushed local state included. /// /// Held here rather than on the screen that shows them, for the reason every other list is: this is where /// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it /// to be forgotten. SnippetsViewModel is the filter and the editor over the top. /// internal ObservableCollection Snippets { get; } = []; /// /// What the sidebar's one group heading says. /// /// /// The vault's name, because the vault is the only grouping a host has — there are no tags and no /// folders on HostSecret, and deriving a group from a naming convention would be a guess /// presented as structure. /// /// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold /// several, and then the heading stops naming one of them and each row names its own — a heading that /// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this /// interface is otherwise careful about. /// /// internal string HostsHeading => session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant(); /// Whether the host list under the heading is folded away. [ObservableProperty] private bool areHostsExpanded = true; /// The SSH keys to show, unpushed local state included. internal ObservableCollection Keys { get; } = []; /// The stored credentials to show, unpushed local state included. internal ObservableCollection Credentials { get; } = []; /// The buckets to show, unpushed local state included. internal ObservableCollection ObjectStores { get; } = []; /// The host keys this user has approved. internal ObservableCollection KnownHostPins { get; } = []; /// Whatever the merge had to override and the user has not acknowledged. internal ObservableCollection Conflicts { get; } = []; internal string VaultName => session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain"; /// /// The vaults a new item may be filed into: readable, and writable by this account. /// /// /// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one /// it can read but not write is a team vault this member is a viewer of — offering either would end /// in a Save that fails, one of them locally and one at the server. /// internal ObservableCollection TargetVaults { get; } = []; /// /// Where the next new item goes. /// /// /// Falls back to the session's active vault, which is the personal one wherever there is one. Filing /// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is /// visible to people who should not have it, and moving it afterwards means deleting and retyping. /// internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId; /// Whether there is more than one vault to choose between. /// /// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is /// a question with no answer, and for most people this stays at one for ever. /// internal bool HasVaultChoice => TargetVaults.Count > 1; [ObservableProperty] private VaultChoiceViewModel? selectedTargetVault; [ObservableProperty] private HostRowViewModel? selectedHost; /// /// What the sidebar's ListBox has selected, which may be a heading. /// /// /// /// The list holds two kinds of row and only one of them is a host, so the control's selection and the /// application's selection are no longer the same thing. This is the control's; /// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on /// reading that one. /// /// /// A heading click is bounced back to whatever was selected before it rather than being left highlighted /// or clearing the selection. Clearing would mean the buttons at the foot of the sidebar quietly stopped /// working because somebody folded a group away; leaving it highlighted would mean a selected row that /// none of those buttons act on. /// /// [ObservableProperty] private ISidebarRow? selectedSidebarRow; [ObservableProperty] private HostGroupRowViewModel? selectedGroup; /// What the group name box holds, for both creating and renaming. [ObservableProperty] private string groupEditorLabel = string.Empty; /// The group being renamed, or null when the box would create one. [ObservableProperty] private Guid? editingGroupId; [ObservableProperty] private SshKeyRowViewModel? selectedKey; [ObservableProperty] private CredentialRowViewModel? selectedCredential; [ObservableProperty] private ObjectStoreRowViewModel? selectedObjectStore; [ObservableProperty] private KnownHostRowViewModel? selectedKnownHost; /// /// What the sidebar's filter box holds. /// /// /// Matched against the label, the address and the notes, case-insensitively, because those are the three /// things a person remembers a machine by. It narrows only: the selection, the /// connect path and everything else read , so filtering can never make a host /// unusable — only unlisted. /// [ObservableProperty] private string hostFilter = string.Empty; [ObservableProperty] private string status = string.Empty; [ObservableProperty] private int pendingChanges; /// /// Whether the last synchronisation attempt failed to reach the server. /// /// /// /// Holding a connection object is not the same as being able to reach anything, and this is the /// difference. IVaultServer is obtained once at sign-in and never dropped, so a laptop whose lid /// has been shut all afternoon still has one — and the background pass swallows its socket errors on /// purpose, which means nothing else would ever notice. /// /// /// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has /// not reached the server since lunch is exactly the sort of thing this project writes down instead of /// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable /// machine is the ordinary state of a laptop nobody has changed anything on. /// /// /// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the /// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic /// would flash UNREACHABLE at every launch by somebody who is not. /// /// [ObservableProperty] private bool lastSyncFailed; [ObservableProperty] private int unreadableItems; [ObservableProperty] private bool isBusy; // ---- Which kind of item is showing ---- /// /// Settable, and the markup deliberately does not bind a selector's selection to it. A selection binding /// would move before could refuse, leaving a selector highlighting a section /// the column is not showing; two plain buttons and a command carry no state of their own and cannot /// disagree with this. Tests set it directly, which is the same thing the command does once it has /// decided. /// [ObservableProperty] private VaultSection section; /// Whether every kind is showing at once. internal bool ShowsAll => Section is VaultSection.All; /// internal bool ShowsKeys => Section is VaultSection.Keys; /// internal bool ShowsCredentials => Section is VaultSection.Credentials; /// internal bool ShowsBuckets => Section is VaultSection.Buckets; /// /// The rows the vault table is showing, for whichever category is selected. /// /// /// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from /// storage — so it costs one pass over what is already decrypted in memory and can never disagree with /// the lists the editors act on. /// internal ObservableCollection VaultItems { get; } = []; /// /// The selected row of the vault table. /// /// /// Setting it sets the matching typed selection, which is what every editor and every delete command /// reads. The typed selections stay the state; this is the way the table names one of them. /// [ObservableProperty] private VaultItemRowViewModel? selectedVaultItem; /// What the vault screen's header calls the category showing. internal string SectionTitle => Section switch { VaultSection.Keys => "SSH KEYS", VaultSection.Credentials => "PASSWORDS", VaultSection.Buckets => "BUCKETS", _ => "ALL ITEMS", }; /// /// What the header says about the category, under its name. /// /// /// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this /// user's — so the second half is what this build can actually count instead: how many of the ones on /// screen this machine has not managed to push yet. /// /// Counted over the rows showing rather than over the whole outbox, which is what this said first and /// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads /// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen. /// /// internal string SectionSummary { get { var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items"; var waiting = VaultItems.Count(row => row.HasUnsyncedChanges); return waiting == 0 ? items : $"{items} · {waiting} not synced"; } } /// Everything on this screen, which is the keychain less the hosts and the pins. /// /// Both of those have screens of their own now. Counting a pin here would put a number on the ALL /// category that the ALL category does not list. /// internal int TotalItemCount => Keys.Count + Credentials.Count; internal bool HasVaultItems => VaultItems.Count > 0; internal bool HasSelectedVaultItem => SelectedVaultItem is not null; /// Whether the selected row is one with an editor behind it. internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore; /// Whether the selected row is an SSH key, which is the only kind with a public half to copy. internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key; /// What the detail pane calls the block under the chips. internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch { VaultItemKind.Credential => "ACCOUNT", _ => "WHAT IS STORED", }; internal bool HasUnreadableItems => UnreadableItems > 0; /// /// A sentence rather than a number, because the number alone reads as a count of something you have /// rather than of something you cannot open — and what to do about it is not guessable. /// internal string UnreadableSummary => UnreadableItems == 1 ? "1 item will not decrypt" : $"{UnreadableItems} items will not decrypt"; /// What an empty category says instead of showing an empty grid. internal string EmptySectionMessage => Section switch { VaultSection.Keys => "No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.", VaultSection.Credentials => "No stored passwords yet. Add one to stop typing the same password into every connection.", VaultSection.Buckets => "No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.", _ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.", }; // ---- The editor ---- [ObservableProperty] private bool isEditing; [ObservableProperty] private string editorLabel = string.Empty; [ObservableProperty] private string editorHostname = string.Empty; [ObservableProperty] private int editorPort = HostSecret.DefaultPort; [ObservableProperty] private string editorUsername = string.Empty; [ObservableProperty] private string editorNotes = string.Empty; [ObservableProperty] private bool editorRelayEnabled; /// /// What the authentication picker offers: a typed password, then every key, then every credential. /// /// /// Rebuilt when the editor opens rather than kept in step with the two lists. A background sync could pull /// a new key while a host is being edited, and having the picker's contents change under the user mid-edit /// is worse than the list being a minute stale — only one editor may be open at a time, so the only way to /// add a key or a credential is to close this one anyway. /// internal ObservableCollection EditorAuthenticationChoices { get; } = []; [ObservableProperty] private AuthenticationChoice? editorSelectedAuthentication; /// What the group picker offers: "no group", then every group. /// internal ObservableCollection EditorGroupChoices { get; } = []; [ObservableProperty] private GroupChoice? editorSelectedGroup; /// The item being edited, or null when creating. private Guid? editingEntityId; /// /// Which vault the editor will write to. /// /// /// Captured when the editor opens rather than read at save time, and there are two different reasons /// for that depending on which way the editor was opened. Editing an existing item, it is the vault /// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the /// target picker said at that moment, so changing the picker afterwards cannot silently move /// a half-typed host into a team's vault. /// private Guid editingHostVaultId; /// Which vault the key editor will write to. See . private Guid editingKeyVaultId; /// Which vault the credential editor will write to. See . private Guid editingCredentialVaultId; /// /// Whether the editor is showing a host that could have a pinned key to forget. /// /// /// Read by the editor to hide the button while a host is being created, where there is nothing to /// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the /// known-host store for a button's visibility, and the command already says plainly when there was /// nothing to forget. /// internal bool CanForgetHostKey => IsEditing && editingEntityId is not null; // ---- The key editor ---- // A second set of editor state rather than a shared one. The two editors hold unrelated fields, and // sharing them would mean a half-typed host reappearing inside a key editor. [ObservableProperty] private bool isEditingKey; [ObservableProperty] private string keyEditorLabel = string.Empty; /// /// Bound to a text box the user pastes a private key into, so this holds key material for as long as /// the editor is open, and clears it. Neither that nor anything else here /// can wipe it — see SshKeySecret, which explains why a .NET string is the honest choice for /// this and what it does not buy. /// [ObservableProperty] private string keyEditorPrivateKey = string.Empty; [ObservableProperty] private string keyEditorPassphrase = string.Empty; [ObservableProperty] private string keyEditorPublicKey = string.Empty; [ObservableProperty] private string keyEditorNotes = string.Empty; /// The key being edited, or null when creating. private Guid? editingKeyId; // ---- Generating a key ---- /// /// Whether the small form in front of generating a key is showing. /// /// /// A step of its own rather than two more boxes in the key editor, because the two are opposite /// directions: the editor is where a key that already exists is pasted in, and this makes one that does /// not exist yet. What it produces lands in that editor unsaved, so there is still exactly one thing in /// this application that writes a key, and it is still SAVE. /// [ObservableProperty] private bool isGeneratingKey; /// /// What the generated key is called, and the comment written into it. /// /// /// One field for both. The comment is the only thing in a host's authorized_keys that will ever /// say where a key came from, and a key whose name here and comment there disagree is one nobody can /// match up months later when they are deciding which line to delete. /// [ObservableProperty] private string generateComment = string.Empty; [ObservableProperty] private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519; /// internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519; /// internal bool GeneratesRsa => GenerateAlgorithm is SshKeyAlgorithm.Rsa4096; // ---- The credential editor ---- // A third set, on the same reasoning as the second: three editors holding unrelated fields, and sharing // them would mean a half-typed key reappearing inside a credential. [ObservableProperty] private bool isEditingCredential; [ObservableProperty] private string credentialEditorLabel = string.Empty; /// /// Optional, and what makes a credential worth being its own item: one account on twenty machines is /// rotated in one place. Blank means "use each host's own username" — see CredentialSecret.Username, /// which normalises the two spellings of that to one. /// [ObservableProperty] private string credentialEditorUsername = string.Empty; /// /// Holds a password for as long as the editor is open, and clears it — /// the same bargain, and the same limits, as the private key box. See CredentialSecret. /// [ObservableProperty] private string credentialEditorPassword = string.Empty; [ObservableProperty] private string credentialEditorNotes = string.Empty; /// The credential being edited, or null when creating. private Guid? editingCredentialId; // ---- The bucket editor ---- // A fourth set, on the same reasoning as the third. [ObservableProperty] private bool isEditingObjectStore; [ObservableProperty] private string bucketEditorLabel = string.Empty; [ObservableProperty] private string bucketEditorBucket = string.Empty; [ObservableProperty] private string bucketEditorAccessKeyId = string.Empty; /// /// Holds a secret access key for as long as the editor is open, and cancelling clears it — the same /// bargain, and the same limits, as the password box. A secret access key is a password. /// [ObservableProperty] private string bucketEditorSecretAccessKey = string.Empty; [ObservableProperty] private string bucketEditorRegion = string.Empty; /// /// Blank means Amazon and the region resolves the host. Anything else is a full URL, which is what makes /// this work against a MinIO on somebody's own network. /// [ObservableProperty] private string bucketEditorEndpoint = string.Empty; /// /// Defaulted on for a new bucket, which is the opposite of the AWS default and the right guess here: /// somebody adding a bucket with a custom endpoint is nearly always pointing at a self-hosted service, /// and those have no wildcard DNS. Somebody adding an AWS bucket leaves the endpoint blank, and /// turns it off for them. /// [ObservableProperty] private bool bucketEditorUsePathStyle; [ObservableProperty] private string bucketEditorNotes = string.Empty; /// The bucket being edited, or null when creating. private Guid? editingObjectStoreId; // ---- Deleting ---- /// The deletion that has been asked for, or null when nothing has been. /// /// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user /// looking at two cards, and this application only ever has one selected item per screen to aim a /// question at. /// [ObservableProperty] private DeletionRequest? pendingDeletion; internal bool IsConfirmingDeletion => PendingDeletion is not null; /// Whether the sidebar's row of host buttons is showing. /// /// Its own property because the markup cannot express !IsEditing && !IsConfirmingDeletion, /// and because both halves are the same rule: the question about deleting a host takes the place of the /// buttons that asked it, so that DELETE cannot be pressed a second time while its own confirmation is /// on screen. /// internal bool ShowsHostActions => !IsEditing && !IsConfirmingHostDeletion; /// /// Whether the question on screen is the one about deleting a host. /// /// /// The sidebar and the group panel are on the same screen, and there is one pending deletion between /// them, so each has to ask whether the question is its own — otherwise deleting a group draws /// the group's question inside the host sidebar as well, in a place its buttons never were. /// internal bool IsConfirmingHostDeletion => PendingDeletion?.Target is DeletionTarget.Host; /// internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group; /// Whether the group panel's buttons are showing. internal bool ShowsGroupActions => !IsConfirmingGroupDeletion; /// Whether this vault has any groups, which is what makes the sidebar draw headings. internal bool HasGroups => Groups.Count > 0; /// What the group panel's save button says. internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "RENAME"; /// Whether the vault screen's Edit and Delete are showing. /// internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion; // ---- Connecting ---- /// /// Typed per connection, never persisted, and now only reached by a host bound to nothing. It stays because /// not every password is worth storing — a one-off on a machine somebody will never open again, or one /// they would rather this vault did not hold — and because a credential has to be created before it can be /// bound, which means the first connection to a new host happens through this box. /// [ObservableProperty] private string connectPassword = string.Empty; /// /// Whether the selected host will want something typed into the password box. /// /// /// True with nothing selected, which is deliberate: the box is the resting state of that corner of the /// window, and an empty terminal column with no password box in it reads as a column that is still /// loading. /// internal bool SelectedHostAsksForAPassword => SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } }; /// /// What the terminal column says in place of the password box, or nothing when the box is showing. /// /// /// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs /// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two /// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this /// said nothing, and a key cannot. /// internal string SelectedHostAuthenticationNote => SelectedHost?.Host switch { { CredentialId: not null } => "This host uses a password stored in your keychain.", { SshKeyId: not null } => "This host authenticates with its SSH key.", _ => string.Empty, }; [ObservableProperty] private HostKeyPresentation? pendingHostKey; [ObservableProperty] private string? hostKeyMismatch; /// /// Raised once a terminal session is open and its renderer has it. /// /// /// /// An event rather than a property because handing the terminal the keyboard is something that /// happens, not something that is true: connecting a second host while one is already open has to /// move focus again, and no state change describes that. Raised on the UI thread — every await on /// the path from the command to here uses ConfigureAwait(true) — so a handler may touch /// controls directly. /// /// /// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The /// vault is the only thing that knows what this session is of — a host's name is a decrypted /// item — so the naming happens here and the tab list happens there. /// /// internal event EventHandler? SessionOpened; internal bool HasPendingHostKey => PendingHostKey is not null; internal bool HasHostKeyMismatch => HostKeyMismatch is not null; internal bool HasConflicts => Conflicts.Count > 0; /// Reads the vault into the list and says what is in it. internal async Task LoadAsync(CancellationToken cancellationToken) { await ReloadAsync(cancellationToken).ConfigureAwait(true); // Built from whatever is there rather than enumerated per combination. Two item types were four cases; // three would be eight, and the fourth kind the selector will eventually grow — pinned host keys — // would be sixteen. var counted = new List(3); if (Hosts.Count > 0) { counted.Add($"{Hosts.Count} host(s)"); } if (Keys.Count > 0) { counted.Add($"{Keys.Count} key(s)"); } if (Credentials.Count > 0) { counted.Add($"{Credentials.Count} credential(s)"); } var contents = string.Join(", ", counted); // "No hosts yet" survives as its own case, because it is the one nudge this line gives: a vault holding // keys and credentials and no hosts is set up but unused, and "2 key(s) in Personal." would read as // though everything were in order. Status = (Hosts.Count, counted.Count) switch { (0, 0) => "No hosts yet. Add one.", (0, _) => $"No hosts yet, and {contents} in {VaultName}.", _ => $"{contents} in {VaultName}.", }; } /// /// Reads the vault into the list, silently. /// /// /// Separate from because every caller except the first load has something /// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing /// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made /// "the background pass is quiet" false on the one path that mattered. /// private async Task ReloadAsync(CancellationToken cancellationToken) { // First, because the four lists below are read across the same set and a vault admitted by the // last refresh should appear in the picker on the same pass its items do. RebuildTargetVaults(); // Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what // gets counted under them — so the host reload is the pass that can put both together. var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true); // Last, because it reads the host list to work out which pins nothing dials any more. unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true); UnreadableItems = unreadable; PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true); // After all four lists, because the table is a projection of three of them. RebuildVaultItems(); await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } /// Refills the "file this into" picker from the vaults this session can read and write. /// /// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the /// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active /// vault rather than to nothing, so the next Save still has somewhere to go. /// private void RebuildTargetVaults() { var selectedId = TargetVaultId; TargetVaults.Clear(); foreach (var vault in session.ReadableVaults .Where(vault => vault.CanWrite) .OrderByDescending(vault => vault.IsPersonal) .ThenBy(vault => vault.Name, StringComparer.CurrentCulture)) { TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal)); } SelectedTargetVault = TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId) ?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId) ?? TargetVaults.FirstOrDefault(); OnPropertyChanged(nameof(HasVaultChoice)); } /// How many hosts would not decrypt. private async Task ReloadHostsAsync(CancellationToken cancellationToken) { var selectedId = SelectedHost?.EntityId; var unreadable = 0; var rows = new List(); // Every vault this session holds a key for, not only the one new items are filed into. A team // vault whose hosts never reached this list would make sharing look as though it had not worked. var readable = session.ReadableVaults.ToList(); var several = readable.Count > 1; foreach (var vault in readable) { var listing = await session.Hosts .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; rows.AddRange(listing.Items.Select( item => new HostRowViewModel(item, vault.VaultId, vault.Name) { // Only when there is something to tell apart. A badge on every row of a // single-vault list is noise that says the same thing on all of them. VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty, })); } Hosts.Clear(); // Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can // hold a host with the same label and both are shown: which vault it is in is what tells them // apart, which is why the row carries the name rather than the list deduplicating. foreach (var host in rows .OrderByDescending(row => row.VaultId == session.ActiveVaultId) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { Hosts.Add(host); } // Selection survives a reload. Losing it on every sync would move the terminal's target out from // under the user. SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault(); // Both, in this order: the group rows carry a host count, and the sidebar's headings are built from // the group rows. RebuildGroups(); RebuildVisibleHosts(); return unreadable; } /// How many buckets would not decrypt. /// /// The selection survives a reload and a reload never invents one, as the key and credential lists do and /// for the same reason: it is what the delete button aims at. /// private async Task ReloadObjectStoresAsync(CancellationToken cancellationToken) { var listing = await session.ObjectStores .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); var selectedId = SelectedObjectStore?.EntityId; ObjectStores.Clear(); foreach (var store in listing.Items .OrderBy(store => store.Secret.Label, StringComparer.CurrentCulture)) { ObjectStores.Add(new ObjectStoreRowViewModel(store)); } SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == selectedId); return listing.Unreadable; } /// How many snippets would not decrypt. /// /// No selection to preserve: what a snippet screen selects is its own, and it restores it around this /// list changing the way every other screen does. /// private async Task ReloadSnippetsAsync(CancellationToken cancellationToken) { var listing = await session.Snippets .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); Snippets.Clear(); foreach (var snippet in listing.Items .OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture)) { Snippets.Add(new SnippetRowViewModel(snippet)); } return listing.Unreadable; } /// Stores one snippet, encrypted, and queues it for the server. /// The snippet to replace, or null to create one. /// What to store. /// Cancellation. /// Whether it was stored; means the reason is in . /// /// Here rather than on the screen, so the write goes through the same repository, the same outbox and the /// same immediate push as every other save. The screen decides what a snippet is and nothing /// else. /// internal async Task SaveSnippetAsync( Guid? entityId, SnippetSecret snippet, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(snippet); if (!snippet.TryValidate(out var reason)) { Status = reason; return false; } await RunAsync( "Saving…", async () => { if (entityId is { } existing) { await session.Snippets .UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken) .ConfigureAwait(true); } else { await session.Snippets .CreateAsync(session.ActiveVaultId, snippet, cancellationToken) .ConfigureAwait(true); } await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = connection() is null ? $"Saved '{snippet.Label}'. It will sync when you are online." : $"Saved '{snippet.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); return true; } /// Queues a tombstone for one snippet. internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken) { if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That snippet is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.Snippets .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// How many groups would not decrypt. /// /// /// The listing is kept rather than projected straight into , because a group row /// carries how many hosts name it and the hosts have not been read yet when this runs. See /// , which is where the two meet. /// /// /// The active vault only, unlike every other list on this screen. Hosts, keys, credentials and /// pins are read across every vault this session holds a key for; groups are not, so a host in a team's /// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows /// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them /// across vaults means a group row has to carry the vault it lives in — rename and delete both need it — /// and two vaults may hold groups with the same name, which the one-heading-per-group layout cannot tell /// apart. Both are worth doing and neither is a merge's business. Recorded in /// docs/design-import-gaps.md. /// /// private async Task ReloadGroupsAsync(CancellationToken cancellationToken) { var listing = await session.HostGroups .ListAsync(session.ActiveVaultId, cancellationToken) .ConfigureAwait(true); groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)]; return listing.Unreadable; } /// Refills , counting the hosts filed under each. private void RebuildGroups() { var selectedId = SelectedGroup?.EntityId; Groups.Clear(); foreach (var group in groupItems) { var count = Hosts.Count(row => row.Host.GroupId == group.EntityId); Groups.Add(new HostGroupRowViewModel(group, count)); } // Never defaulted to the first row, as the key and credential lists are not: this selection is what // RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody // chose. SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId); OnPropertyChanged(nameof(HasGroups)); } /// Refills the sidebar's list from and the filter. /// /// The selection is captured and restored around the rebuild, and that is not tidiness — it is what /// keeps this method from undoing its own caller. ListBox.SelectedItem is two-way bound to /// , so VisibleHosts.Clear() is a Reset the list reacts to by /// nulling its selection, and the binding writes that null straight back — silently, and before this /// method's own refill has a chance to matter. restores the selection and /// calls this immediately after, which used to mean every reload undid what it had just restored, and /// every keystroke in the filter box did the same. /// private void RebuildVisibleHosts() { var selected = SelectedHost; VisibleHosts.Clear(); foreach (var host in Hosts.Where(Matches)) { VisibleHosts.Add(host); } RebuildSidebarRows(); // Restored when it still matches, and explicitly cleared when it does not — rather than left alone // and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides // the selected host has to mean nothing is selected: Connect, Edit and Delete all read this // property directly, and a host that is not on screen is not one any of them should act on. SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null; // After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear() // above is a null that has to be overwritten rather than read. SelectedSidebarRow = SelectedHost; } /// /// Lays the visible hosts out under their group headings. /// /// /// /// No groups means no headings. The sidebar of a vault nobody has filed anything in is the list it /// always was, which is what makes this feature cost nothing to ignore. /// /// /// A host whose group has been deleted falls under the ungrouped heading rather than disappearing /// or keeping an empty heading of its own. The reference is allowed to dangle — see /// for why deleting a group deliberately does not rewrite the hosts in /// it — so "the group this names is not here" and "this names no group" have to look the same, because to /// the user they are the same thing. /// /// /// An empty group still gets its heading, and a group emptied by the filter does not. The first /// is a thing the user made and can file hosts into; the second is an absence of search results, and a /// heading with nothing under it would read as a group that had lost its contents. /// /// private void RebuildSidebarRows() { SidebarRows.Clear(); if (Groups.Count == 0) { foreach (var host in VisibleHosts) { SidebarRows.Add(host); } return; } var known = Groups.Select(group => group.EntityId).ToHashSet(); foreach (var group in Groups) { AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId); } AddSection( null, "UNGROUPED", host => host.Host.GroupId is not { } id || !known.Contains(id), onlyWhenOccupied: true); void AddSection( Guid? groupId, string label, Func belongs, bool onlyWhenOccupied = false) { var members = VisibleHosts.Where(belongs).ToArray(); if (onlyWhenOccupied && members.Length == 0) { return; } var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty); SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded)); if (!expanded) { return; } foreach (var member in members) { SidebarRows.Add(member); } } } /// Folds one group's hosts away, or brings them back. /// /// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows /// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be /// forgotten a minute after it was set. The ungrouped heading uses , which is not /// a legal group id: HostSecret.TryValidate refuses one. /// [RelayCommand] private void ToggleGroup(SidebarGroupHeader? header) { if (header is null) { return; } var key = header.GroupId ?? Guid.Empty; if (!collapsedGroups.Remove(key)) { collapsedGroups.Add(key); } RebuildSidebarRows(); SelectedSidebarRow = SelectedHost; } /// /// An empty filter matches everything rather than nothing, which is the only reading that makes an empty /// box mean "not filtering". The notes are searched as well as the name and the address: what somebody /// wrote down about a machine is often the only place its purpose is recorded. /// private bool Matches(HostRowViewModel row) { var filter = HostFilter.Trim(); if (filter.Length == 0) { return true; } return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes); bool Contains(string? value) => value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase); } /// How many keys would not decrypt. /// /// Unlike the host list, the selection is not defaulted to the first row: it is what /// aims at, and a list that picked a row on every background sync would point /// that button at a key nobody chose. /// private async Task ReloadKeysAsync(CancellationToken cancellationToken) { var selectedId = SelectedKey?.EntityId; var unreadable = 0; var rows = new List(); foreach (var vault in session.ReadableVaults) { var listing = await session.SshKeys .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; rows.AddRange(listing.Items.Select( item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name))); } Keys.Clear(); foreach (var key in rows .OrderByDescending(row => row.VaultId == session.ActiveVaultId) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { Keys.Add(key); } SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId); return unreadable; } /// How many credentials would not decrypt. /// /// An existing selection survives a reload and a reload never invents one, which is the same pair of rules /// as the key list and matters more here. reads the selection, so a list /// that fell back to its first row would point the deletion — and the question in front of it — at a /// password nobody chose. /// private async Task ReloadCredentialsAsync(CancellationToken cancellationToken) { var selectedId = SelectedCredential?.EntityId; var unreadable = 0; var rows = new List(); foreach (var vault in session.ReadableVaults) { var listing = await session.Credentials .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; rows.AddRange(listing.Items.Select( item => new CredentialRowViewModel(item, vault.VaultId, vault.Name))); } Credentials.Clear(); foreach (var credential in rows .OrderByDescending(row => row.VaultId == session.ActiveVaultId) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { Credentials.Add(credential); } SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId); return unreadable; } /// How many pins would not decrypt. /// /// Read through the repository rather than through VaultKnownHostStore, which holds a snapshot /// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list /// has to show duplicates, because a duplicate is one of the things worth seeing. /// private async Task ReloadKnownHostsAsync(CancellationToken cancellationToken) { var selectedId = SelectedKnownHost?.EntityId; var unreadable = 0; var rows = new List(); // Built once rather than searched per pin. A vault with a hundred of each would otherwise be a // hundred scans of the host list on every background sync. var dialled = Hosts .Select(host => Endpoint(host.Host.Hostname, host.Host.Port)) .ToHashSet(StringComparer.OrdinalIgnoreCase); // Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in // the active vault alone. The difference is deliberate and is stated in the README: a pin in a // team vault is something a teammate can write, and letting it answer for a host in somebody's // personal vault would let one member suppress another's first-contact prompt. Showing them is // safe and is the only way somebody can see what their team has trusted. foreach (var vault in session.ReadableVaults) { var listing = await session.KnownHosts .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel( item, dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)), vault.VaultId, vault.Name))); } KnownHostPins.Clear(); foreach (var pin in rows .OrderByDescending(row => row.VaultId == session.ActiveVaultId) .ThenBy(row => row.VaultName, StringComparer.CurrentCulture) .ThenBy(row => row.Label, StringComparer.CurrentCulture)) { KnownHostPins.Add(pin); } SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId); return unreadable; } /// /// Case-insensitively, because a host name is, and KnownHostIdentity keys the store the same way. /// A pin written as DB.internal and a host saved as db.internal are the same machine, and a /// list that called one of them unused would be inviting somebody to delete trust they rely on. /// private static string Endpoint(string host, int port) => string.Create(CultureInfo.InvariantCulture, $"{host}:{port}"); /// Runs a synchronisation pass, if this machine can reach a server. /// /// The offline branch is inside rather than in front of it, because getting /// online is now part of what this button does: resuming a remembered sign-in is a network round trip /// and belongs under the same busy flag as the pass it leads to. /// [RelayCommand] private async Task SyncAsync(CancellationToken cancellationToken) { await RunAsync( "Synchronising…", async () => { if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server) { LastSyncFailed = true; Status = "Offline. Changes are queued and will be sent as soon as this machine " + "can reach the server again."; return; } var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); // Null means a background pass held the gate. Saying so beats reporting a sync that this // press did not perform. Status = report is null ? "A synchronisation is already running." : Describe(report); }).ConfigureAwait(true); } /// /// Finds a server to sync against, getting this machine online if it is not. /// /// /// The handler is asked even when a connection is already held, which looks redundant and is not: the /// shell is the thing that persists the refresh token so a later launch can resume, and identity /// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered /// sign-in current without an event and without this view model knowing what a token is. /// private Task ResolveServerAsync(CancellationToken cancellationToken) => reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken); /// /// Starts syncing in the background until the vault is disposed. /// /// /// Explicit rather than started from the constructor, so that a test can drive /// a pass at a time instead of racing a timer. /// internal void StartAutoSync() { if (autoSync is not null) { return; } autoSync = new CancellationTokenSource(); autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token); } /// /// One background synchronisation pass, which stays out of the way. /// /// /// /// Deliberately not routed through . That would raise the busy flag every /// interval — disabling Connect and Save for the duration — and repaint the status line with /// "Synchronising…" while the user was reading something else. A background pass that makes the /// application feel intermittently broken is worse than a Sync button. /// /// /// So it is silent unless it has something to say: the status line changes only when the pass actually /// moved an item or produced something needing attention. It also yields to the user — a pass is /// skipped outright while a command is running, rather than queueing behind it. /// /// internal async Task AutoSyncAsync(CancellationToken cancellationToken) { if (IsBusy) { return; } await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true); } /// /// One background synchronisation pass, run whether or not a command is in flight. /// /// /// /// The same quiet pass as without the one thing that made it useless at /// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a /// timed pass yields to is raised by the very command that opened the vault — and the pass on open /// therefore never ran, silently, putting the first synchronisation a full minute after unlock. /// /// /// Yielding is right for every later pass, because by then a busy flag means a person is doing /// something. It is wrong for this one, because the thing it would be yielding to is the unlock. /// /// internal async Task SyncOnOpenAsync(CancellationToken cancellationToken) { try { if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server) { return; } var report = await SyncOnceAsync(server.Sync, cancellationToken).ConfigureAwait(true); if (report is null) { return; } // A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it // gets the treatment the catch below gives a total failure, the fact kept and the message // swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was // reading, once a minute, with the name of a vault it could not reach. Pressing Sync still // names the vault and the reason, because somebody who pressed it is waiting for an answer. if (IsWorthReporting(report)) { Status = Describe(report); } await PruneLogsIfDueAsync(cancellationToken).ConfigureAwait(true); } catch (OperationCanceledException) { // Locking, or closing. } catch (Exception exception) when (exception is not OutOfMemoryException) { // The message is swallowed on purpose, and this is the one place in the view model where that // is right: a laptop that has been closed all afternoon would otherwise replace whatever the // user was reading with a socket error once a minute. Pressing Sync still reports the reason. // // The *fact* is not swallowed, and that is the half that used to be missing. Recording it is // what lets the titlebar stop claiming to be up to date with a server it cannot reach. LastSyncFailed = true; } } /// /// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a /// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by /// waiting for it, and queueing them would turn a slow server into a backlog of identical work. /// private async Task?> SyncOnceAsync( ISyncApi api, CancellationToken cancellationToken) { if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true)) { return null; } try { // Every vault this session can read, not only the one new items are filed into. A team's // vault that never synced would show its hosts exactly once — at the unlock that first // pulled it — and then quietly stop, which reads as the feature not working. var report = await session.SyncAllAsync(api, cancellationToken).ConfigureAwait(true); // Not unconditionally false, which it was while a pass was one vault and a failure was an // exception. A failure is now a report — one unreachable team vault must not stop the others // syncing — so clearing the flag here regardless would light the titlebar green over a vault // that had just failed to sync, which is exactly the lie that flag exists to prevent. LastSyncFailed = report.Any(vault => !vault.Succeeded); await ReloadAsync(cancellationToken).ConfigureAwait(true); // Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a // snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here, // or a host a colleague approved stays a first-contact prompt until the next unlock. await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true); return report; } finally { syncGate.Release(); } } /// /// ConfigureAwait(true) throughout, and that is load-bearing rather than habit: the loop is /// started from the UI thread, so every continuation returns to it and the observable collections /// rebuilds are still only ever touched from one thread. A /// ConfigureAwait(false) here would mutate them from a timer thread, which Avalonia will /// eventually notice in a way that looks like a rendering bug. /// private async Task RunAutoSyncLoopAsync(CancellationToken cancellationToken) { using var timer = new PeriodicTimer(AutoSyncInterval); try { // A pass on open, before the first tick. A vault edited on another machine should be current by // the time the user has finished reading the list, not a minute afterwards. // // Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from // inside the unlock command, so the busy flag that pass yields to is raised by the very command // that opened the vault — and the pass on open therefore never ran at all. It was a silent // no-op that put the first synchronisation a full minute after unlock, on the launch where // being current matters most. The later passes keep the check: by then, a busy flag means a // user is doing something. await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true); while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true)) { await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } } catch (OperationCanceledException) { // Locking, or closing. } } /// Shows one kind of item, if nothing is being edited. /// /// Takes the section rather than there being one command per kind, so a third kind is an enum member and /// a button and nothing else. /// [RelayCommand] private void ShowSection(VaultSection target) { // Before the editor check, not after. Asking for the section already showing is not a request to leave // an editor, and refusing it would scold somebody for clicking where they already are. if (target == Section) { return; } if (AVaultEditorIsInTheWay()) { return; } Section = target; // Cleared rather than set to the section's name. The status line is shared with the account bar and // carries the result of the last thing that happened; "Hosts." would push a sync report or a save // confirmation off it to say something the selector is already showing. Status = string.Empty; } /// Starts a new host. [RelayCommand] private void NewHost() { if (AHostEditorIsInTheWay()) { return; } editingEntityId = null; editingHostVaultId = TargetVaultId; EditorLabel = string.Empty; EditorHostname = string.Empty; EditorPort = HostSecret.DefaultPort; EditorUsername = string.Empty; EditorNotes = string.Empty; EditorRelayEnabled = false; BuildAuthenticationChoices(boundKeyId: null, boundCredentialId: null); // A new host opens in whichever group is selected beside the list, if one is, because adding three // machines to the group somebody has just made is the ordinary case. BuildGroupChoices(SelectedGroup?.EntityId); IsEditing = true; Status = "Adding a host."; } /// Opens the selected host for editing. [RelayCommand] private void EditSelectedHost() { if (SelectedHost is not { } row || AHostEditorIsInTheWay()) { return; } if (row.IsReadOnly) { // Re-encoding would drop fields this build has no concept of, so the honest answer is to // refuse rather than to silently lose a colleague's data. Status = "This host was written by a newer version of DodoSSH. Update before editing it."; return; } editingEntityId = row.EntityId; editingHostVaultId = row.VaultId; EditorLabel = row.Host.Label; EditorHostname = row.Host.Hostname; EditorPort = row.Host.Port; EditorUsername = row.Host.Username ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty; EditorRelayEnabled = row.Host.RelayEnabled; BuildAuthenticationChoices(row.Host.SshKeyId, row.Host.CredentialId); BuildGroupChoices(row.Host.GroupId); IsEditing = true; Status = $"Editing {row.Label}."; } /// /// Opens whichever editor the selected row belongs to. /// /// /// One button over three kinds, because the table is one table. It delegates rather than duplicating: /// each kind's own command already knows how to refuse a read-only item and how to load an editor /// without a second decryption, and a merged copy of that would be a second place to get it wrong. /// [RelayCommand] private void EditSelectedItem() { switch (SelectedVaultItem?.Kind) { case VaultItemKind.Key: EditSelectedKeyCommand.Execute(null); break; case VaultItemKind.Credential: EditSelectedCredentialCommand.Execute(null); break; case VaultItemKind.ObjectStore: EditObjectStoreCommand.Execute(null); break; default: // A pin has no editor. Its button is Forget, and it is elsewhere on the pane. break; } } /// Asks about deleting whatever the selected row is. /// /// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint /// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons /// that remove exactly one item would misdescribe it. It has its own button, named for what it does. /// [RelayCommand] private void DeleteSelectedItem() { switch (SelectedVaultItem?.Kind) { case VaultItemKind.Key: DeleteKeyCommand.Execute(null); break; case VaultItemKind.Credential: DeleteCredentialCommand.Execute(null); break; case VaultItemKind.ObjectStore: DeleteObjectStoreCommand.Execute(null); break; default: break; } } /// Folds the host list away, or brings it back. [RelayCommand] private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded; /// Stores whatever the group name box holds, as a new group or as a rename. /// /// /// One box and one button for both, because a group is one field: a separate "rename" form would be the /// same text box with a different title. is what decides which of the two /// this is, and it is set by and cleared by everything else. /// /// /// Duplicate names are allowed. Two groups called "staging" are confusing and they are not /// wrong — hosts point at ids, so the two are genuinely separate folders — and refusing the /// second one would mean a name somebody chose on another machine could block one they choose here, at /// the next sync, with the rename already saved. /// /// [RelayCommand] private async Task SaveGroupAsync(CancellationToken cancellationToken) { var group = new HostGroupSecret { Label = GroupEditorLabel.Trim() }; if (!group.TryValidate(out var reason)) { Status = reason; return; } var renaming = EditingGroupId; await RunAsync( "Saving…", async () => { if (renaming is { } entityId) { await session.HostGroups .UpdateAsync(session.ActiveVaultId, entityId, group, cancellationToken) .ConfigureAwait(true); } else { await session.HostGroups .CreateAsync(session.ActiveVaultId, group, cancellationToken) .ConfigureAwait(true); } GroupEditorLabel = string.Empty; EditingGroupId = null; await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = renaming is null ? $"Added the group '{group.Label}'." : $"Renamed to '{group.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Loads the selected group's name into the box, so saving renames it. [RelayCommand] private void EditGroup() { if (SelectedGroup is not { } row) { return; } if (row.IsReadOnly) { Status = "This group was written by a newer version of DodoSSH. Update before editing it."; return; } EditingGroupId = row.EntityId; GroupEditorLabel = row.Label; Status = $"Renaming {row.Label}."; } /// Abandons a rename, leaving the box ready to create one instead. [RelayCommand] private void CancelGroupEdit() { EditingGroupId = null; GroupEditorLabel = string.Empty; Status = string.Empty; } /// Asks whether the selected group should go. /// /// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts /// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn /// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about /// to move, and that none of them are going anywhere else. /// [RelayCommand] private void DeleteGroup() { if (SelectedGroup is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.Group, row.EntityId, $"Delete the group '{row.Label}'?", HowFarADeletionGoes("The group"), row.HostCount switch { 0 => string.Empty, 1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.", _ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.", }); } /// Queues a tombstone for the group that was agreed to. private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken) { if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That group is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.HostGroups .DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); if (EditingGroupId == entityId) { EditingGroupId = null; GroupEditorLabel = string.Empty; } await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted the group '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Abandons the editor. [RelayCommand] private void CancelEdit() { IsEditing = false; editingEntityId = null; Status = string.Empty; } /// Stores the editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveHostAsync(CancellationToken cancellationToken) { var host = BuildHost(); if (!host.TryValidate(out var error)) { Status = error; return; } await RunAsync( "Saving…", async () => { if (editingEntityId is { } entityId) { await session.Hosts .UpdateAsync(editingHostVaultId, entityId, host, cancellationToken) .ConfigureAwait(true); } else { editingEntityId = await session.Hosts .CreateAsync(editingHostVaultId, host, cancellationToken) .ConfigureAwait(true); } IsEditing = false; await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId); editingEntityId = null; Status = connection() is null ? $"Saved '{host.Label}'. It will sync when you are online." : $"Saved '{host.Label}'."; }).ConfigureAwait(true); // Pushed now rather than at the next tick. A change the user just made is the one they are most // likely to be about to look for on another machine. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// /// Stores several hosts at once, the way one save does. /// /// /// /// For the ssh_config import, which is the only thing that produces hosts in bulk. It goes /// through the same repository, the same outbox and the same automatic push as saving one — the import /// screen decides which hosts and nothing else, so there is no second way for a host to be /// written and no second place for the sync wiring to be forgotten. /// /// /// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be /// thirty rebuilds of the host list and thirty sync passes, which on a slow link is minutes of the /// window doing nothing visible. /// /// /// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good /// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import. /// /// internal async Task ImportHostsAsync( IReadOnlyList hosts, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(hosts); var imported = 0; var refused = 0; await RunAsync( hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…", async () => { foreach (var host in hosts) { if (!host.TryValidate(out _)) { refused++; continue; } await session.Hosts .CreateAsync(session.ActiveVaultId, host, cancellationToken) .ConfigureAwait(true); imported++; } await ReloadAsync(cancellationToken).ConfigureAwait(true); var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped."; Status = connection() is null ? $"Imported {imported} host(s). They will sync when you are online.{refusals}" : $"Imported {imported} host(s).{refusals}"; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); return imported; } /// Asks whether the selected host should go. /// /// A terminal already open on the host is disclosed rather than prevented, because deleting a host does /// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody /// deleting a machine they are still working on should know that is what they have done. /// [RelayCommand] private void DeleteHost() { if (SelectedHost is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.Host, row.EntityId, $"Delete the host '{row.Label}'?", HowFarADeletionGoes("The host and everything saved about it"), row.IsConnected ? "A terminal is open on this host. It stays open — deleting the host does not close it, and " + "nothing will reopen it afterwards." : string.Empty); } /// Queues a tombstone for the host that was agreed to. private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken) { if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { // Gone between the question and the answer — a sync that pulled somebody else's deletion is the // realistic way. Saying so beats a silent no-op under a card that has just been agreed to. Status = "That host is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.Hosts .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); // As with saving: a tombstone is worth pushing straight away, so the item does not reappear on // another machine that syncs before the next tick. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Starts a new SSH key. [RelayCommand] private void NewKey() { if (AVaultEditorIsInTheWay()) { return; } Section = VaultSection.Keys; editingKeyId = null; editingKeyVaultId = TargetVaultId; ClearKeyEditor(); IsEditingKey = true; Status = "Adding an SSH key."; } /// Opens the selected key for editing. /// /// The private key is loaded into the editor, which is the only way an edit can preserve it: the /// codec has no notion of a partial update, so saving re-encodes every field. /// [RelayCommand] private void EditSelectedKey() { if (SelectedKey is not { } row || AVaultEditorIsInTheWay()) { return; } if (row.IsReadOnly) { Status = "This key was written by a newer version of DodoSSH. Update before editing it."; return; } Section = VaultSection.Keys; editingKeyId = row.EntityId; editingKeyVaultId = row.VaultId; KeyEditorLabel = row.Key.Label; KeyEditorPrivateKey = row.Key.PrivateKeyPem; KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty; KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty; KeyEditorNotes = row.Key.Notes ?? string.Empty; IsEditingKey = true; Status = $"Editing {row.Label}."; } /// Opens the form in front of generating a key. [RelayCommand] private void NewGeneratedKey() { if (AVaultEditorIsInTheWay()) { return; } Section = VaultSection.Keys; GenerateComment = $"{Environment.UserName}@{Environment.MachineName}"; GenerateAlgorithm = SshKeyAlgorithm.Ed25519; IsGeneratingKey = true; Status = "Generating a new SSH key."; } /// Chooses which kind of key to make. /// /// A command and two buttons rather than a selector bound to , which is /// the idiom the category rail already uses and for the same reason: a selector moves its own highlight /// before anything here can decide, so it can end up showing a choice that was not made. /// [RelayCommand] private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm; /// Abandons the generate form without making anything. [RelayCommand] private void CancelGenerateKey() { IsGeneratingKey = false; GenerateComment = string.Empty; Status = string.Empty; } /// /// Makes a new key pair and drops it into the key editor, unsaved. /// /// /// /// It does not save. What comes back lands in the editor and waits for SAVE, so the whole /// storage path — validation, encoding, the outbox, the push — is the one that already exists and this /// command has no second version of it. It also means a generated key can be renamed or annotated /// before it is written, and abandoned by pressing CANCEL. /// /// /// Off the UI thread. RSA at 4096 bits is seconds of solid CPU, which on this thread is a frozen /// window at the moment somebody is watching it — the same reason key derivation runs on a worker. The /// generator does not know which algorithm is cheap, and neither should this. /// /// /// The private key exists in memory from here until the editor is cleared, as a pasted one does. See /// SshKeySecret for why a .NET string is the honest choice for that and what it does not buy. /// /// [RelayCommand] private async Task GenerateKeyAsync(CancellationToken cancellationToken) { var algorithm = GenerateAlgorithm; var comment = string.IsNullOrWhiteSpace(GenerateComment) ? $"{Environment.UserName}@{Environment.MachineName}" : GenerateComment.Trim(); var kind = algorithm is SshKeyAlgorithm.Rsa4096 ? "RSA 4096-bit" : "Ed25519"; await RunAsync( $"Generating a {kind} key…", async () => { var generated = await Task .Run(() => SshKeyGenerator.Generate(algorithm, comment), cancellationToken) .ConfigureAwait(true); IsGeneratingKey = false; editingKeyId = null; // Filed where a pasted key would be, and set here rather than left over from whatever was // edited last: this path opens the same editor without going through NewKey, so without // this a key generated after editing a team's key would be saved into that team's vault. editingKeyVaultId = TargetVaultId; ClearKeyEditor(); KeyEditorLabel = comment; KeyEditorPrivateKey = generated.PrivateKeyArmour; KeyEditorPublicKey = generated.PublicKeyLine; KeyEditorNotes = $"Generated by DodoSSH. {generated.Fingerprint}"; IsEditingKey = true; Status = $"Generated {generated.Fingerprint}. Nothing is stored until you press SAVE."; }).ConfigureAwait(true); } /// /// Puts the selected key's public half on the clipboard. /// /// /// The public half only, and there is deliberately no command for the other one. Installing a key means /// pasting this line into a host's authorized_keys; a private key on a clipboard is a private key /// in every application on the machine and in whatever syncs it between them. /// [RelayCommand] private async Task CopyPublicKeyAsync() { if (SelectedKey is not { } row) { Status = "Choose a key first."; return; } if (row.Key.PublicKey is not { Length: > 0 } line) { // Not derivable here: SshKeySecret stores whatever armour it was given and declines to parse // it, so a key imported without its .pub has no public half to offer. Saying so beats copying // an empty string. Status = $"'{row.Label}' has no public half stored. Paste it into the key's editor to keep it."; return; } if (copyToClipboard is null) { Status = "This machine has no clipboard."; return; } await copyToClipboard(line).ConfigureAwait(true); Status = $"Copied the public key for '{row.Label}'. Add it to the host's ~/.ssh/authorized_keys."; } /// Abandons the key editor, clearing the material out of it. [RelayCommand] private void CancelKeyEdit() { IsEditingKey = false; editingKeyId = null; ClearKeyEditor(); Status = string.Empty; } /// Stores the key editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveKeyAsync(CancellationToken cancellationToken) { var key = BuildKey(); if (!key.TryValidate(out var reason)) { Status = reason; return; } await RunAsync( "Saving…", async () => { if (editingKeyId is { } entityId) { await session.SshKeys .UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken) .ConfigureAwait(true); } else { editingKeyId = await session.SshKeys .CreateAsync(editingKeyVaultId, key, cancellationToken) .ConfigureAwait(true); } IsEditingKey = false; ClearKeyEditor(); await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId); editingKeyId = null; Status = connection() is null ? $"Saved '{key.Label}'. It will sync when you are online." : $"Saved '{key.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Asks whether the selected key should go. /// /// The private key is the thing this vault holds that is least likely to exist anywhere else, which is /// why the question says so. What it does not say is that the key is gone from the machines it was /// installed on: deleting it here removes this vault's copy, and the authorized_keys file on a /// server is not something this application has ever written to. /// [RelayCommand] private void DeleteKey() { if (SelectedKey is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.Key, row.EntityId, $"Delete the SSH key '{row.Label}'?", HowFarADeletionGoes("The private key, its passphrase and everything saved with them") + " If this key is not on disk anywhere else, this is the only copy.", HostsBoundTo(host => host.SshKeyId, row.EntityId)); } /// Queues a tombstone for the key that was agreed to. private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken) { if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That key is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.SshKeys .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Starts a new credential. [RelayCommand] private void NewCredential() { if (AVaultEditorIsInTheWay()) { return; } Section = VaultSection.Credentials; editingCredentialId = null; editingCredentialVaultId = TargetVaultId; ClearCredentialEditor(); IsEditingCredential = true; Status = "Adding a credential."; } /// Opens the selected credential for editing. /// /// The password is loaded into the editor, as the private key is and for the same reason: the codec has no /// notion of a partial update, so saving re-encodes every field. /// [RelayCommand] private void EditSelectedCredential() { if (SelectedCredential is not { } row || AVaultEditorIsInTheWay()) { return; } if (row.IsReadOnly) { Status = "This credential was written by a newer version of DodoSSH. Update before editing it."; return; } Section = VaultSection.Credentials; editingCredentialId = row.EntityId; editingCredentialVaultId = row.VaultId; CredentialEditorLabel = row.Credential.Label; CredentialEditorUsername = row.Credential.Username ?? string.Empty; CredentialEditorPassword = row.Credential.Password; CredentialEditorNotes = row.Credential.Notes ?? string.Empty; IsEditingCredential = true; Status = $"Editing {row.Label}."; } /// Starts a new bucket. /// /// Path-style addressing starts off, which is the AWS default — and loads /// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why. /// [RelayCommand] private void NewObjectStore() { if (AVaultEditorIsInTheWay()) { return; } Section = VaultSection.Buckets; editingObjectStoreId = null; ClearObjectStoreEditor(); IsEditingObjectStore = true; Status = "Adding a bucket."; } /// Opens the selected bucket for editing. [RelayCommand] private void EditObjectStore() { if (SelectedObjectStore is not { } row || AVaultEditorIsInTheWay()) { return; } if (row.IsReadOnly) { Status = "This bucket was written by a newer version of DodoSSH. Update before editing it."; return; } Section = VaultSection.Buckets; editingObjectStoreId = row.EntityId; BucketEditorLabel = row.Store.Label; BucketEditorBucket = row.Store.Bucket; BucketEditorAccessKeyId = row.Store.AccessKeyId; BucketEditorSecretAccessKey = row.Store.SecretAccessKey; BucketEditorRegion = row.Store.Region ?? string.Empty; BucketEditorEndpoint = row.Store.Endpoint ?? string.Empty; BucketEditorUsePathStyle = row.Store.UsePathStyle; BucketEditorNotes = row.Store.Notes ?? string.Empty; IsEditingObjectStore = true; Status = $"Editing {row.Label}."; } /// Abandons the bucket editor, clearing the secret access key out of it. [RelayCommand] private void CancelObjectStoreEdit() { IsEditingObjectStore = false; editingObjectStoreId = null; ClearObjectStoreEditor(); Status = string.Empty; } /// Stores the bucket editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveObjectStoreAsync(CancellationToken cancellationToken) { var store = BuildObjectStore(); if (!store.TryValidate(out var reason)) { Status = reason; return; } await RunAsync( "Saving…", async () => { if (editingObjectStoreId is { } entityId) { await session.ObjectStores .UpdateAsync(session.ActiveVaultId, entityId, store, cancellationToken) .ConfigureAwait(true); } else { editingObjectStoreId = await session.ObjectStores .CreateAsync(session.ActiveVaultId, store, cancellationToken) .ConfigureAwait(true); } IsEditingObjectStore = false; ClearObjectStoreEditor(); await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedObjectStore = ObjectStores .FirstOrDefault(row => row.EntityId == editingObjectStoreId); editingObjectStoreId = null; Status = connection() is null ? $"Saved '{store.Label}'. It will sync when you are online." : $"Saved '{store.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Asks whether the selected bucket should go. /// /// The bucket itself is untouched, and the question says so. Removing the entry here removes this /// keychain's way of reaching it — the objects in it are somebody else's to delete, and a confirmation /// that did not distinguish the two would be genuinely frightening. /// [RelayCommand] private void DeleteObjectStore() { if (SelectedObjectStore is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.ObjectStore, row.EntityId, $"Remove the bucket '{row.Label}'?", HowFarADeletionGoes("The bucket's address and its keys"), "Nothing in the bucket is touched. This removes the way this keychain reaches it, not the " + "objects in it."); } /// Queues a tombstone for the bucket that was agreed to. private async Task DeleteObjectStoreNowAsync(Guid entityId, CancellationToken cancellationToken) { if (ObjectStores.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That bucket is no longer here, so nothing was removed."; return; } await RunAsync( "Removing…", async () => { await session.ObjectStores .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Removed '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Empties the bucket editor, including the secret access key. private void ClearObjectStoreEditor() { BucketEditorLabel = string.Empty; BucketEditorBucket = string.Empty; BucketEditorAccessKeyId = string.Empty; BucketEditorSecretAccessKey = string.Empty; BucketEditorRegion = string.Empty; BucketEditorEndpoint = string.Empty; BucketEditorUsePathStyle = false; BucketEditorNotes = string.Empty; } private ObjectStoreSecret BuildObjectStore() => new() { Label = BucketEditorLabel.Trim(), Bucket = BucketEditorBucket.Trim(), // Trimmed, both of them. A pasted access key with a trailing newline signs every request wrongly // and the service answers "SignatureDoesNotMatch", which names neither the field nor the paste. AccessKeyId = BucketEditorAccessKeyId.Trim(), SecretAccessKey = BucketEditorSecretAccessKey.Trim(), // Blank is a real answer for both — no region, or no custom endpoint — and is stored as null so // that ObjectStoreSecret can tell "not set" from "set to nothing". Region = string.IsNullOrWhiteSpace(BucketEditorRegion) ? null : BucketEditorRegion.Trim(), Endpoint = string.IsNullOrWhiteSpace(BucketEditorEndpoint) ? null : BucketEditorEndpoint.Trim().TrimEnd('/'), UsePathStyle = BucketEditorUsePathStyle, Notes = string.IsNullOrWhiteSpace(BucketEditorNotes) ? null : BucketEditorNotes, }; /// Abandons the credential editor, clearing the password out of it. [RelayCommand] private void CancelCredentialEdit() { IsEditingCredential = false; editingCredentialId = null; ClearCredentialEditor(); Status = string.Empty; } /// Stores the credential editor's contents, encrypted, and queues it for the server. [RelayCommand] private async Task SaveCredentialAsync(CancellationToken cancellationToken) { var credential = BuildCredential(); if (!credential.TryValidate(out var reason)) { Status = reason; return; } await RunAsync( "Saving…", async () => { if (editingCredentialId is { } entityId) { await session.Credentials .UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken) .ConfigureAwait(true); } else { editingCredentialId = await session.Credentials .CreateAsync(editingCredentialVaultId, credential, cancellationToken) .ConfigureAwait(true); } IsEditingCredential = false; ClearCredentialEditor(); await ReloadAsync(cancellationToken).ConfigureAwait(true); SelectedCredential = Credentials .FirstOrDefault(row => row.EntityId == editingCredentialId); editingCredentialId = null; Status = connection() is null ? $"Saved '{credential.Label}'. It will sync when you are online." : $"Saved '{credential.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Asks whether the selected credential should go. [RelayCommand] private void DeleteCredential() { if (SelectedCredential is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.Credential, row.EntityId, $"Delete the password '{row.Label}'?", HowFarADeletionGoes("The password and the account saved with it"), HostsBoundTo(host => host.CredentialId, row.EntityId)); } /// Queues a tombstone for the credential that was agreed to. private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken) { if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That password is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.Credentials .DeleteAsync(row.VaultId, row.EntityId, cancellationToken) .ConfigureAwait(true); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted '{row.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Carries out the deletion that was asked about. /// /// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a /// second press during a slow round trip has nothing left to agree to. /// [RelayCommand] private async Task ConfirmDeleteAsync(CancellationToken cancellationToken) { if (PendingDeletion is not { } request) { return; } PendingDeletion = null; switch (request.Target) { case DeletionTarget.Host: await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; case DeletionTarget.Key: await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; case DeletionTarget.Credential: await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; case DeletionTarget.Group: await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; case DeletionTarget.ObjectStore: await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true); break; default: break; } } /// Thinks better of it. [RelayCommand] private void CancelDelete() => PendingDeletion = null; /// /// Where a deleted item goes, and how far. /// /// /// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that /// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a /// credential because it leaked should be told which of those two they have just done. /// private string HowFarADeletionGoes(string what) => connection() is null ? $"{what} goes from this machine now, and from your other machines once this one is online again. " + "There is no undo." : $"{what} goes from this machine now, and from your other machines at the next synchronisation. " + "There is no undo."; /// /// What the hosts that authenticate with an item would be left with, or nothing when none do. /// /// /// Counted rather than warned about in general terms. The number is the difference between a sentence /// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound /// to something the vault no longer has is refused at connect time rather than quietly falling back to a /// typed password — see . /// private string HostsBoundTo(Func binding, Guid entityId) { var bound = Hosts .Where(row => binding(row.Host) == entityId) .Select(row => row.Label) .ToArray(); if (bound.Length == 0) { return string.Empty; } // Three names and a count past that, because this is read in a 244-pixel column and a vault with // twenty hosts on one key would otherwise put a paragraph of names where a warning should be. var named = bound.Length <= 3 ? string.Join(", ", bound) : $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more"; return bound.Length == 1 ? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed " + "password." : $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than " + "fall back to a typed password."; } /// /// Withdraws trust from the selected pin's endpoint. /// /// /// /// Goes through the same ForgetAsync as the host editor's button, which withdraws every pin for /// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust /// is about an address, a second pin for the same address under another algorithm would go on being /// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided /// to stop trusting one of its keys. The status line says how many went. /// /// /// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the /// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the /// prompt at connect time. /// /// [RelayCommand] private async Task ForgetPinAsync(CancellationToken cancellationToken) { if (SelectedKnownHost is not { } row) { return; } await RunAsync( $"Forgetting the pinned host key for {row.Host}…", async () => { var forgotten = await knownHosts .ForgetAsync(row.Host, row.Port, cancellationToken) .ConfigureAwait(true); // A mismatch the user was staring at is about a pin that may have just gone. HostKeyMismatch = null; await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = forgotten == 1 ? $"Forgot the pinned key for {row.Host}:{row.Port}." : $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}."; }).ConfigureAwait(true); // Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to // a server that has been rebuilt. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Opens a terminal on the selected host. [RelayCommand] private async Task ConnectAsync(CancellationToken cancellationToken) { if (SelectedHost is not { } row) { Status = "Choose a host first."; return; } // Refused rather than quietly falling back to the password box. A host set up for key-only access // that silently starts offering a password is the failure worth ruling out — the user asked for one // thing and got another, and the host is the last place that would say so. if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal)) { Status = refusal; return; } PendingHostKey = null; HostKeyMismatch = null; await RunAsync( $"Connecting to {row.Label}…", () => OpenSessionAsync(row, authentication, cancellationToken)).ConfigureAwait(true); } /// /// Pins the offered host key and retries. /// /// /// The pin goes into the vault, so this writes to the local cache and queues a change for every other /// machine — which is why the write is guarded and the connection is only retried once it has landed. /// It used to be a dictionary insert that could not fail; reporting a failed write as a failed /// connection would send the user looking at the host. /// [RelayCommand] private async Task TrustHostKeyAsync(CancellationToken cancellationToken) { if (PendingHostKey is not { } presentation) { return; } try { await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true); } catch (OperationCanceledException) { Status = "Cancelled."; return; } catch (Exception exception) { Status = $"The host key could not be stored, so nothing was connected: {exception.Message}"; return; } PendingHostKey = null; await ConnectAsync(cancellationToken).ConfigureAwait(true); // After connecting, not before. A pin is worth pushing straight away — the same host on another // machine should not ask again — but not at the cost of delaying the connection the user asked for. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Dismisses the trust prompt without pinning anything. [RelayCommand] private void RejectHostKey() { PendingHostKey = null; Status = "The host key was not trusted, so nothing was connected."; } /// /// Withdraws trust from every key pinned for the host being edited. /// /// /// /// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is /// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for /// ever without this. It is deliberately here — in the host's editor, reached by choosing to edit /// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same /// button as "continue anyway" with two clicks instead of one. /// /// /// Applies to the host's saved address rather than whatever the editor's boxes currently hold. /// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a /// hostname has not moved it yet. /// /// [RelayCommand] private async Task ForgetHostKeyAsync(CancellationToken cancellationToken) { if (editingEntityId is not { } entityId || Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { return; } var address = row.Host.Hostname; var port = row.Host.Port; await RunAsync( $"Forgetting the pinned host key for {address}…", async () => { var forgotten = await knownHosts .ForgetAsync(address, port, cancellationToken) .ConfigureAwait(true); // The refusal that sent the user here is about a pin that no longer exists. HostKeyMismatch = null; PendingChanges = await session .PendingChangeCountAsync(cancellationToken) .ConfigureAwait(true); if (forgotten == 0) { Status = $"Nothing was pinned for {address}:{port}."; return; } Status = $"Forgot the pinned host key for {address}:{port}. The next connection will " + "ask you to check its fingerprint again."; }).ConfigureAwait(true); // Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would // leave the other ones refusing to connect to a server that has been rebuilt. await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// /// Marks every shown conflict as seen. /// /// /// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who /// dismisses this and realises a minute later that they wanted the other value should still be able to /// get it. /// [RelayCommand] private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken) { foreach (var conflict in Conflicts.ToArray()) { await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true); } await LoadConflictsAsync(cancellationToken).ConfigureAwait(true); } /// public async ValueTask DisposeAsync() { if (disposed) { return; } disposed = true; // The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight // holds the vault keys and the cache; letting it run on into a disposed session is how locking // turns into an ObjectDisposedException on a background thread that nobody sees. if (autoSync is not null) { await autoSync.CancelAsync().ConfigureAwait(false); } if (autoSyncLoop is not null) { await autoSyncLoop.ConfigureAwait(false); } autoSync?.Dispose(); syncGate.Dispose(); await session.DisposeAsync().ConfigureAwait(false); } /// /// The renderer has to be attached before a session opens: the transport drops frames when nothing is /// connected, so a session opened earlier would lose its SessionOpened frame and then stream /// output at a terminal that was never created. That wait is bounded and takes this command's token, so /// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…". /// private async Task OpenSessionAsync( HostRowViewModel row, HostAuthentication authentication, CancellationToken cancellationToken) { try { await ConnectAndAnnounceAsync(row, authentication, cancellationToken).ConfigureAwait(true); } catch (TimeoutException) { // The renderer never attached, so nothing was connected. Reported here rather than left to // RunAsync's generic handler because TimeoutException says only "The operation has timed out", // and the one thing worth saying is where to look: a runtime this application does not install. Status = "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 " + "runtime is probably missing or blocked; install it and try again."; } catch (SshHostKeyUnknownException exception) { // First contact. The user has to decide, and they need the fingerprint to do it. // // Deliberately not logged. Nothing was refused and nothing failed — the connection is paused on a // question, and it becomes a session the moment the user answers it. An entry here would record a // failure that did not happen, once per new host. PendingHostKey = exception.Presentation; Status = "This host has not been seen before."; } catch (SshHostKeyMismatchException exception) { // Logged, and this is the entry the connection log most exists for. A changed host key is // refused outright with no way past it, so the only trace it would otherwise leave is a status // line the user dismisses — and a run of these against one machine is what somebody reviewing a // log needs to see. RecordFailure(row, authentication, ConnectionOutcome.Refused); HostKeyMismatch = exception.Message; Status = "The host key has changed. The connection was refused."; } catch (Exception exception) when (exception is not OperationCanceledException) { // Everything else: an unreachable host, a rejected password, a key the remote will not take. // Caught by shape rather than by type because this project's SSH layer defines only the two // host-key exceptions above and everything else arrives from SSH.NET, which the client // deliberately does not reference. // // Recorded and rethrown, so RunAsync goes on reporting it exactly as it did. The log is an // observer here and must never become the thing that swallows an error. Cancellation is excluded // because a user who gave up did not fail to connect. RecordFailure(row, authentication, ConnectionOutcome.Failed); throw; } } /// Opens the session and tells the shell about it. Every failure is a throw. /// /// Split from the handlers around it for length, and the split falls where it should: this is the whole /// happy path, and everything above it is one catch per way of not having one. /// private async Task ConnectAndAnnounceAsync( HostRowViewModel row, HostAuthentication authentication, CancellationToken cancellationToken) { await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true); var request = new SshConnectionRequest( row.Host.Hostname, row.Host.Port, authentication.Username, authentication.Credential); var sessionId = await workspace .OpenSessionAsync(request, TerminalSize.Default, cancellationToken) .ConfigureAwait(true); // The workspace has already opened a ticket for this session, with the address and the moment it // connected. What it could not know is which keychain item this was — an SshConnectionRequest has no // notion of one — so the name is added here rather than the ticket being replaced, which would move // the start time to now. connectionLog?.Identify(sessionId, row.Label, row.EntityId); Status = $"Connected to {row.Label}."; // Only now, and only on success. The page's own term.focus() focuses the textarea inside the // document, which does nothing while the window's keyboard focus is still on the Connect button — so // without this the first keystrokes of the session go to the shell's UI instead of the remote shell. SessionOpened?.Invoke( this, new TerminalSessionEventArgs(sessionId, row.Label, Dialled(row, authentication))); } /// The address as actually dialled. /// /// Built from what was dialled rather than from the host's own fields, because a bound credential can /// supply the username — so a host saved with no username of its own still has one here, and it is the /// one the remote saw. /// private static string Dialled(HostRowViewModel row, HostAuthentication authentication) => string.Create( CultureInfo.InvariantCulture, $"{authentication.Username}@{row.Host.Hostname}:{row.Host.Port}"); /// /// Removes log entries this vault has agreed to stop keeping, at most once every few hours. /// /// /// /// Rate-limited, because pruning writes. Log entries are synced items, so removing one is a real /// tombstone that goes to the server — and a prune on every sync tick would be a machine that writes to /// a server once a minute for ever. It rides the existing loop rather than a timer of its own, so a /// laptop that is asleep prunes nothing and one that is awake prunes when it was going to talk to the /// server anyway. /// /// /// The first pass after a vault opens always runs, which is what makes a machine that has been off for a /// month tidy up as soon as it comes back. /// /// /// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault /// somebody needs to be told about mid-sync. /// /// private async Task PruneLogsIfDueAsync(CancellationToken cancellationToken) { var now = TimeProvider.System.GetUtcNow(); if (lastPruned is { } previous && now - previous < PruneInterval) { return; } lastPruned = now; try { await LogPruner.PruneAsync(session, LogRetention.Default, now, cancellationToken) .ConfigureAwait(true); } catch (OperationCanceledException) { // Locking, or closing. } catch (Exception exception) when (exception is not OutOfMemoryException) { // Housekeeping. Nothing the user did failed, and there is nothing for them to do about it. } } /// Records a connection that never became a session. /// /// Start and end are the same instant, which is what a connection that never opened actually looks like: /// the duration is zero and the outcome carries the meaning. /// private void RecordFailure( HostRowViewModel row, HostAuthentication authentication, ConnectionOutcome outcome) { var at = TimeProvider.System.GetUtcNow(); connectionLog?.Record( Dialled(row, authentication), row.Label, row.EntityId, ConnectionKind.Terminal, at, at, outcome); } /// /// Everything the SSH stack needs to authenticate as somebody on a host. /// /// The account to log in as, after any credential has had its say. /// What proves it. /// /// The two travel together because a credential can change both. Returning only the secret and reading the /// username off the host separately is what the connect path used to do, and it would have sent a stored /// credential's password under the host's username — which is the one combination that is wrong in a way /// the server reports as "authentication failed". /// private sealed record HostAuthentication(string Username, SshCredential Credential); /// /// Works out how a host authenticates, or says why it cannot. /// /// /// /// The key material is handed over as UTF-8 bytes, which is what PrivateKeyFile reads from a /// MemoryStream — so the key reaches SSH.NET without ever becoming a file on disk. The /// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point /// of a vault, and SshKeySecret says why. It is passed straight through with no empty-to-null /// check, because SshKeySecret.Passphrase cannot hold an empty string. /// /// /// False is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means the /// key or credential was deleted on another machine — plausible, and no reason to start sending a typed /// password to a host somebody deliberately set up not to accept one. /// /// /// The credential branch comes first because the two bindings are mutually exclusive and a host carrying /// both is already invalid; reading the credential first means a host that somehow acquired both is /// answered by the more specific of the two rather than by whichever the code happened to check. /// /// /// /// Works out how to reach a host, or says why it cannot. /// /// /// The same resolution the Connect button performs, exposed because file transfer opens its own /// connection — see ISftpSession — and a second copy of "which key, which password, whose /// username" would be a second place for a dangling binding to be silently turned back into a typed /// password. The typed password is a parameter rather than because the /// transfers screen has its own box: they are different screens, and a password typed on one is not a /// password offered on the other. /// internal bool TryBuildConnectionRequest( HostSecret host, string typedPassword, [NotNullWhen(true)] out SshConnectionRequest? request, [NotNullWhen(false)] out string? reason) { if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason)) { request = null; return false; } request = new SshConnectionRequest( host.Hostname, host.Port, authentication.Username, authentication.Credential); return true; } private bool TryBuildAuthentication( HostSecret host, string typedPassword, [NotNullWhen(true)] out HostAuthentication? authentication, [NotNullWhen(false)] out string? reason) { if (host.CredentialId is { } credentialId) { if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential) { return Refuse( $"'{host.Label}' authenticates with a credential that is not in this keychain any more. " + "Edit the host to choose another one, or set it back to a typed password.", out authentication, out reason); } // The credential's username wins where it has one, which is the whole reason it can carry one: one // account on twenty machines is described once. Falling back to the host's covers the ordinary // case of a shared password used under each machine's own account. return Complete( credential.Credential.Username ?? host.Username, new SshPasswordCredential(credential.Credential.Password), out authentication, out reason); } if (host.SshKeyId is { } keyId) { if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key) { return Refuse( $"'{host.Label}' authenticates with an SSH key that is not in this keychain any more. " + "Edit the host to choose another key, or set it back to a password.", out authentication, out reason); } return Complete( host.Username, new SshPrivateKeyCredential( Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase), out authentication, out reason); } return Complete( host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason); } /// /// The last thing every branch has to agree on: there is somebody to log in as. /// /// /// Checked here rather than at the top of because the answer depends /// on which branch was taken — a host with no username of its own is perfectly usable through a credential /// that carries one, and refusing it up front would have made the credential's most useful property /// unreachable. /// private static bool Complete( string? username, SshCredential credential, out HostAuthentication? authentication, [NotNullWhen(false)] out string? reason) { if (string.IsNullOrEmpty(username)) { return Refuse( "This host has no username. Edit it and add one, or bind it to a credential that carries one.", out authentication, out reason); } authentication = new HostAuthentication(username, credential); reason = null; return true; } private static bool Refuse( string reason, out HostAuthentication? authentication, out string? refusal) { authentication = null; refusal = reason; return false; } private HostSecret BuildHost() => new() { Label = EditorLabel.Trim(), Hostname = EditorHostname.Trim(), Port = EditorPort, Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(), Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes, RelayEnabled = EditorRelayEnabled, // Both read off the one picker, including the id of something that has gone missing. Reading them // from the picker rather than carrying the originals through is what lets a binding be removed at // all, and preserving a missing id is what stops an unrelated edit removing one by accident. One // control means the two can never both be set: mutual exclusion by construction, rather than // HostSecret.TryValidate catching it after the fact. SshKeyId = Bound(AuthenticationKind.SshKey), CredentialId = Bound(AuthenticationKind.Credential), // Read off the picker for the same reason, including the id of a group that has gone missing: // an unrelated edit must not unfile a host as a side effect. GroupId = EditorSelectedGroup?.EntityId, }; /// The picker's selection, if it names something of this kind. private Guid? Bound(AuthenticationKind kind) => EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null; /// /// Fills the authentication picker, keeping whatever the host is currently bound to selectable. /// /// The key the host names, if any. /// The credential the host names, if any. /// /// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped. /// Without one the picker would open on "Password (ask each time)", and someone editing the host's port /// would convert it to a typed password by saving — which is the quiet version of the failure the connect /// path refuses outright. /// private void BuildAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId) { EditorAuthenticationChoices.Clear(); EditorAuthenticationChoices.Add(AuthenticationChoice.Typed); foreach (var key in Keys) { EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label)); } foreach (var credential in Credentials) { EditorAuthenticationChoices.Add( AuthenticationChoice.ForCredential(credential.EntityId, credential.Label)); } // At most one of the two is set on a valid host, so at most one placeholder is ever added. AddMissing(AuthenticationKind.SshKey, boundKeyId); AddMissing(AuthenticationKind.Credential, boundCredentialId); EditorSelectedAuthentication = Selected(boundKeyId, boundCredentialId); } private void AddMissing(AuthenticationKind kind, Guid? boundId) { if (boundId is { } bound && !EditorAuthenticationChoices.Any(choice => choice.Kind == kind && choice.EntityId == bound)) { EditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound)); } } /// /// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the /// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a /// key when the host named a credential the day the two ever shared an id. /// private AuthenticationChoice Selected(Guid? boundKeyId, Guid? boundCredentialId) => (boundKeyId, boundCredentialId) switch { ({ } key, _) => Find(AuthenticationKind.SshKey, key), (_, { } credential) => Find(AuthenticationKind.Credential, credential), _ => AuthenticationChoice.Typed, }; private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) => EditorAuthenticationChoices .FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId) ?? AuthenticationChoice.Typed; /// Fills the group picker, keeping whatever the host is currently filed under selectable. /// The group the host names, if any. /// /// A group that is no longer in the vault gets a placeholder, for the reason /// gives: without one the picker would open on "No group", and /// somebody editing the host's port would unfile it by saving. It says the group is gone rather than /// naming it, because there is nothing left to read the name off. /// private void BuildGroupChoices(Guid? groupId) { EditorGroupChoices.Clear(); EditorGroupChoices.Add(GroupChoice.None); foreach (var group in Groups) { EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label)); } if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound)) { EditorGroupChoices.Add(new GroupChoice(bound, "(a group that is no longer here)")); } EditorSelectedGroup = EditorGroupChoices.FirstOrDefault(choice => choice.EntityId == groupId) ?? GroupChoice.None; } private CredentialSecret BuildCredential() => new() { Label = CredentialEditorLabel.Trim(), // Not trimmed and not emptied, exactly as a key's passphrase is not: leading or trailing spaces // are legitimate in a password, and CredentialSecret refuses an empty one on its own. Password = CredentialEditorPassword, // Trimmed, unlike the password. A username with a trailing space is a different account name to // sshd, and it is never the one somebody meant. Username = CredentialEditorUsername.Trim(), Notes = string.IsNullOrWhiteSpace(CredentialEditorNotes) ? null : CredentialEditorNotes, }; /// /// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up /// would eventually tidy a format it did not fully understand — the same reason /// SshKeySecret.PrivateKeyPem stores it verbatim. Everything else is trimmed, because a label /// with a trailing space sorts oddly and reads as a different name. /// private SshKeySecret BuildKey() => new() { Label = KeyEditorLabel.Trim(), PrivateKeyPem = KeyEditorPrivateKey, // Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and // the record turns an empty one into null on its own. Passphrase = KeyEditorPassphrase, PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(), Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes, }; /// /// Whether the host editor has to be dealt with before the sidebar starts another one. /// /// /// Scoped to the host editor alone, and that scoping is the point: the host editor lives in /// HostSidebar, on the Hosts screen, and nothing on the Vault screen shares its column or its /// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start /// editing a host — the two cannot even be looked at at the same time — so this no longer asks about /// them. See for the reasoning this once shared with them, and why /// splitting it was necessary rather than cosmetic: the earlier single check refused every host action /// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an /// editor the user could not see and no way to reach it without abandoning what they had just started /// on the Hosts screen. /// private bool AHostEditorIsInTheWay() { if (IsEditing) { Status = "Finish or cancel the host you are editing first."; } return IsEditing; } /// /// Whether a vault-screen editor has to be dealt with before the rail or another editor opens. /// /// /// /// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted /// private key in a bound string for as long as it is open, and only CancelKeyEdit lets go of /// it. Letting the rail move the category, or another editor open, with that editor still holding /// material would leave a private key in a form nobody can see, with nothing on screen to say it is /// there. /// /// /// Refused rather than resolved by closing the open editor, because closing it would silently discard /// what was typed there — and in the key editor that is a pasted private key the user may have nowhere /// else. One sentence and one click is the cheaper of the two. /// /// /// Does not ask about . The host editor is a different screen's business now — /// see — and asking about it here is what used to leave three /// quarters of this screen inert with a status line pointing at an editor the user was not looking at. /// /// private bool AVaultEditorIsInTheWay() { Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore) switch { (true, _, _, _) => "Finish or cancel the SSH key you are editing first.", (_, true, _, _) => "Finish or cancel the credential you are editing first.", (_, _, true, _) => "Finish or cancel the key you are generating first.", (_, _, _, true) => "Finish or cancel the bucket you are editing first.", _ => Status, }; return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore; } private void ClearKeyEditor() { KeyEditorLabel = string.Empty; KeyEditorPrivateKey = string.Empty; KeyEditorPassphrase = string.Empty; KeyEditorPublicKey = string.Empty; KeyEditorNotes = string.Empty; } private void ClearCredentialEditor() { CredentialEditorLabel = string.Empty; CredentialEditorUsername = string.Empty; CredentialEditorPassword = string.Empty; CredentialEditorNotes = string.Empty; } private async Task LoadConflictsAsync(CancellationToken cancellationToken) { var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true); Conflicts.Clear(); foreach (var notice in notices) { Conflicts.Add(new ConflictRowViewModel(notice)); } OnPropertyChanged(nameof(HasConflicts)); } /// /// Deliberately reports the things a user has to act on rather than a count of successes. A pass that /// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point /// of recording those is that somebody sees them. /// /// /// Movement and attention only — deliberately not failure. A background pass that announced every /// unreachable vault would be a socket error on screen once a minute, which is the thing /// 's catch block exists to avoid; the caller records /// instead, and the titlebar stops claiming to be up to date. Pressing /// Sync reports the failure in full, because somebody who pressed it is waiting for an answer. /// /// /// The item counts rather than the raw ones. Every user action queues a log entry a moment after the /// action's own status message, and this machine reads its own entries back on the next pull — so a /// rule written against the raw numbers would overwrite that message after every single save, which is /// exactly what it did until the report learned to tell the two apart. /// private static bool IsWorthReporting(IReadOnlyList reports) => reports.Any(vault => vault.Succeeded && (vault.Report!.PulledItems > 0 || vault.Report.PushedItems > 0 || vault.Report.NeedsAttention // A pass that had to start over says so even when it pulled nothing, which is the one // place this rule is broken deliberately. A machine that silently re-read a whole vault // has had something happen to it, and the alternative is that nobody ever finds out. || vault.Report.ResyncedFromStart)); /// /// Counts are summed across vaults, and a failure is named with its reason. Both halves /// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a /// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them /// costs nothing. /// private static string Describe(IReadOnlyList reports) { var failed = reports .Where(vault => !vault.Succeeded) .Select(vault => $"{vault.Name} ({vault.Failure?.Message})") .ToList(); var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList(); var line = succeeded.Count switch { 0 => string.Empty, 1 => Describe(succeeded[0]), _ => DescribeMany(succeeded), }; if (failed.Count == 0) { return line.Length == 0 ? "Nothing to synchronise." : line; } var names = string.Join("; ", failed); return line.Length == 0 ? $"Could not synchronise {names}." : $"{line} Could not synchronise {names}."; } private static string DescribeMany(List reports) { var pulled = reports.Sum(report => report.Pulled); var pushed = reports.Sum(report => report.Pushed); var attention = reports.Count(report => report.NeedsAttention); var line = pulled == 0 && pushed == 0 ? $"Already up to date across {reports.Count} vaults." : $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out."; return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list."; } private static string Describe(SyncReport report) { // Said first, and in both branches, because it is the explanation for the numbers after it. A pass // reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong; // this is what actually happened, and it needs nothing from the reader. var replayed = report.ResyncedFromStart ? "The server no longer recognised this machine's position, so the keychain was read again from " + "the beginning. " : string.Empty; if (!report.NeedsAttention) { return replayed + (report.Pulled == 0 && report.Pushed == 0 ? "Already up to date." : $"Synchronised: {report.Pulled} in, {report.Pushed} out."); } var notes = new List(); // "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind // would send someone looking through the wrong list for something that was not there. if (report.Resurrected > 0) { notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name"); } if (report.DeletesAbandoned > 0) { notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit"); } if (report.Parked > 0) { notes.Add($"{report.Parked} change(s) were refused and need attention"); } if (report.Unreadable > 0) { notes.Add($"{report.Unreadable} item(s) could not be decrypted"); } if (report.RekeyRequired) { notes.Add("this keychain was rekeyed and your access needs re-issuing"); } return replayed + "Synchronised, but: " + string.Join("; ", notes) + "."; } private async Task RunAsync(string busyMessage, Func work) { if (IsBusy) { return; } IsBusy = true; Status = busyMessage; try { await work().ConfigureAwait(true); } catch (OperationCanceledException) { Status = "Cancelled."; } catch (Exception exception) { Status = exception.Message; } finally { IsBusy = false; } } partial void OnSelectedHostChanged(HostRowViewModel? value) { OnPropertyChanged(nameof(SelectedHostAsksForAPassword)); OnPropertyChanged(nameof(SelectedHostAuthenticationNote)); // Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to // one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each // other. SelectedSidebarRow = value; DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId); } /// /// /// The one direction that needs a decision. A host selection is the application's selection and passes /// straight through; a heading is not, and is turned back into whatever was selected before it, so that /// clicking a group name neither breaks the buttons at the foot of the sidebar nor leaves a row /// highlighted that none of them act on. /// /// /// A null is left alone rather than cleared through. It arrives from the ListBox's own answer to /// the Reset that rebuilding the list raises — which happens on every filter keystroke and every /// background sync — and treating that as the user deselecting would take the selection away from under /// them once a minute. Deliberate clearing is done by , which sets /// itself. /// /// partial void OnSelectedSidebarRowChanged(ISidebarRow? value) { switch (value) { case HostRowViewModel host: SelectedHost = host; break; case SidebarGroupHeader: SelectedSidebarRow = SelectedHost; break; default: break; } } partial void OnSelectedGroupChanged(HostGroupRowViewModel? value) { DisarmIfAimedElsewhere(DeletionTarget.Group, value?.EntityId); } partial void OnPendingDeletionChanged(DeletionRequest? value) { OnPropertyChanged(nameof(IsConfirmingDeletion)); OnPropertyChanged(nameof(IsConfirmingHostDeletion)); OnPropertyChanged(nameof(IsConfirmingGroupDeletion)); OnPropertyChanged(nameof(ShowsHostActions)); OnPropertyChanged(nameof(ShowsGroupActions)); OnPropertyChanged(nameof(ShowsItemActions)); } partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel)); /// /// Takes the question away when the selection it was asked about has moved on. /// /// /// Compared by entity id rather than by row, and that is the whole point of the method. A reload /// replaces every row object in the list, so a background pass a minute after the question would /// otherwise take the card away from under somebody still reading it — while a click onto a different /// item, which is the case that actually needs handling, leaves an armed deletion pointing at something /// nobody is looking at any more. /// private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId) { if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId) { PendingDeletion = null; } } /// /// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the /// hosts already in memory, with no decryption and nothing on disk behind it. /// partial void OnHostFilterChanged(string value) => RebuildVisibleHosts(); /// /// Refills the vault table from the typed lists. /// /// /// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins. /// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved /// them would put a pin nobody created between two things somebody did. /// private void RebuildVaultItems() { var selectedId = SelectedVaultItem?.EntityId; VaultItems.Clear(); if (Section is VaultSection.All or VaultSection.Keys) { foreach (var key in Keys) { VaultItems.Add(new VaultItemRowViewModel( VaultItemKind.Key, key.EntityId, key.Label, "SSH KEY", key.Description, key.Badge, key.HasUnsyncedChanges)); } } if (Section is VaultSection.All or VaultSection.Credentials) { foreach (var credential in Credentials) { VaultItems.Add(new VaultItemRowViewModel( VaultItemKind.Credential, credential.EntityId, credential.Label, "PASSWORD", credential.Description, credential.Badge, credential.HasUnsyncedChanges)); } } if (Section is VaultSection.All or VaultSection.Buckets) { foreach (var store in ObjectStores) { VaultItems.Add(new VaultItemRowViewModel( VaultItemKind.ObjectStore, store.EntityId, store.Label, "BUCKET", store.Description, store.Badge, store.HasUnsyncedChanges)); } } // The selection survives a reload, as every other list's does, and for the same reason: a background // sync every minute would otherwise move the detail pane out from under whoever was reading it. SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId); OnPropertyChanged(nameof(SectionSummary)); OnPropertyChanged(nameof(HasVaultItems)); OnPropertyChanged(nameof(TotalItemCount)); OnPropertyChanged(nameof(EmptySectionMessage)); } /// /// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving /// the other two alone means switching category and back does not clear what an editor was pointing at. /// partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value) { OnPropertyChanged(nameof(HasSelectedVaultItem)); OnPropertyChanged(nameof(SelectedItemIsEditable)); OnPropertyChanged(nameof(SelectedItemIsKey)); OnPropertyChanged(nameof(SelectedDetailHeading)); OnPropertyChanged(nameof(ShowsItemActions)); // Both kinds this table can delete, because one selection covers both lists. DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId); switch (value?.Kind) { case VaultItemKind.Key: SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId); break; case VaultItemKind.Credential: SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId); break; case VaultItemKind.ObjectStore: SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId); break; default: break; } } partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary)); partial void OnUnreadableItemsChanged(int value) { OnPropertyChanged(nameof(HasUnreadableItems)); OnPropertyChanged(nameof(UnreadableSummary)); } /// /// Both, on every change. A selector that highlights the showing section and a column that shows the /// selected one are the same fact read from two directions, and raising only the one that became true /// would leave the other button lit. /// partial void OnSectionChanged(VaultSection value) { OnPropertyChanged(nameof(ShowsAll)); OnPropertyChanged(nameof(ShowsKeys)); OnPropertyChanged(nameof(ShowsCredentials)); OnPropertyChanged(nameof(ShowsBuckets)); OnPropertyChanged(nameof(SectionTitle)); RebuildVaultItems(); } /// /// The editing id is set before this flips in every path that opens the editor, and cleared after it /// flips back in every path that closes one, so this notification always observes the pair in a /// consistent state. /// partial void OnIsEditingChanged(bool value) { OnPropertyChanged(nameof(CanForgetHostKey)); OnPropertyChanged(nameof(ShowsHostActions)); DisarmOnceAnEditorIsOpen(value); } partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value); partial void OnIsGeneratingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value); partial void OnGenerateAlgorithmChanged(SshKeyAlgorithm value) { OnPropertyChanged(nameof(GeneratesEd25519)); OnPropertyChanged(nameof(GeneratesRsa)); } partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value); partial void OnIsEditingObjectStoreChanged(bool value) => DisarmOnceAnEditorIsOpen(value); /// /// Takes the question away when an editor opens over the pane it was asked in. /// /// /// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot /// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One /// rule for both, rather than a guard on the three commands that would have to be remembered by the /// fourth. /// private void DisarmOnceAnEditorIsOpen(bool opened) { if (opened) { PendingDeletion = null; } } partial void OnPendingHostKeyChanged(HostKeyPresentation? value) => OnPropertyChanged(nameof(HasPendingHostKey)); partial void OnHostKeyMismatchChanged(string? value) => OnPropertyChanged(nameof(HasHostKeyMismatch)); }