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 host, and the group it is being filed under. /// The host to move. /// The group it should end up in, or null for none. /// /// A pair rather than two command parameters, because a command takes one — and a pair rather than the two /// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already /// carries the vault the edit has to return to. /// internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId); /// 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 tag, as a row in the keychain table. /// /// The narrowest row on that screen — a tag is a name — and it is there rather than only inside the host /// editor for the reason the type exists at all: a tag is worth being an item because renaming it is one /// write instead of twenty, and a rename needs somewhere to happen. Deleting needs somewhere too, or the /// picker fills with names nobody uses and never empties. /// internal sealed class TagRowViewModel(VaultItem tag, int hostCount) { internal Guid EntityId => tag.EntityId; internal TagSecret Tag => tag.Secret; internal string Label => tag.Secret.Label; internal int HostCount => hostCount; internal bool IsReadOnly => tag.IsReadOnly; internal bool HasUnsyncedChanges => tag.HasUnsyncedChanges; internal string Badge => ItemBadge.For(tag.IsBlocked, tag.IsReadOnly, tag.HasUnsyncedChanges); /// What the row says under the name. /// /// The count, because it is the only fact a tag has beyond its name and it is the one that decides /// whether deleting it matters. A tag on nothing is a tidy-up; a tag on twenty machines is a filter /// somebody relies on. /// internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts"; } /// /// One tag in the host editor's picker, and whether this host wears it. /// /// The tag item. /// What to show on the chip. /// Whether the host being edited currently carries it. /// /// /// A chip that toggles rather than a multi-select list, because that is what a tag looks like everywhere /// else on both heads — the row already draws worn tags as chips, and a picker drawn as anything else /// would make the user match a list entry to a chip they can see two inches away. /// /// /// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents. /// A mutable IsWorn would need change notification on every chip in the vault to make one toggle /// redraw. /// /// internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn); /// 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, ResolvedHost resolved, IReadOnlyList tagLabels, 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; /// /// The name of the group this host is filed under, or empty for a host that is in none. /// /// /// /// What the desktop's grid draws as a chip on the card. It is the one thing the fold-away group headings /// between the cards used to say, and the reason removing them cost nothing: a card that names its own /// group answers the question per host, where a heading answered it per run of cards and needed the grid /// to be sorted into runs to do it. The phone still draws headings — its list has no room for the row of /// group cards the desktop puts above the grid — so stays. /// /// /// Resolved once when the list is built, like and for the same two reasons: the /// row stores an id and a chip shows a name, and the answer cannot change without the list being rebuilt. /// A group id that does not resolve leaves this empty rather than printing the id — the reference /// is allowed to dangle, deleting a group deliberately does not rewrite the hosts in it, and "the group /// this names is not here" and "this names no group" are the same thing to look at. See /// . /// /// internal string GroupLabel { get; init; } = string.Empty; /// Whether this host is filed under a group the vault can name. internal bool HasGroup => GroupLabel.Length > 0; internal HostSecret Host => host.Secret; /// /// The same host with its group chain applied: what it dials, not what was typed into it. /// /// /// Resolved once, when the list is built, rather than per property. Every label on this row wants the /// same three answers, and re-walking the chain for each of them would be a walk per property per redraw. /// It also means a row cannot disagree with itself — the address the list shows and the port the connect /// command dials come from one value. /// internal ResolvedHost Resolved => resolved; /// /// The names of the tags this host wears, in the order the chips are drawn. /// /// /// /// Names rather than ids, resolved once when the list is built, because a chip shows a name and the /// host stores an id — and resolving per chip per redraw would be a dictionary lookup per tag per row /// per frame for a string that cannot change without the list being rebuilt. /// /// /// A tag that does not resolve is left out rather than drawn as its id. It means the tag was /// deleted on another machine, or belongs to a vault this session cannot read; either way the honest /// answer is a host with one chip fewer, not one wearing a GUID. That the host still carries the id is /// the point — nothing rewrites twenty payloads to clear one deleted tag, so the chip comes back if the /// tag does. See . /// /// internal IReadOnlyList TagLabels => tagLabels; /// Whether this host has any chips to draw. internal bool HasTags => tagLabels.Count > 0; internal string Label => host.Secret.Label; /// /// What this row dials, as one string. /// /// /// The resolved address, which is not decoration: MainWindowViewModel.Rank searches this, /// so a host inheriting 2222 that displayed 22 would be unfindable by the port it actually answers on — /// and the user would be searching for the number the machine really uses. /// internal string Address => string.Create( CultureInfo.InvariantCulture, $"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}"); /// /// An em dash for "nobody", which covers both a host that states no username and a host whose group /// states none either. The two are the same thing to look at and the same thing at connect time: refused, /// with a message asking for one. /// private string DisplayUsername => string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value; 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. /// /// /// Read from the resolved binding rather than the host's own two ids, because the question this answers /// is "will the password box be used" — and a host that inherits its group's key would otherwise say /// "password" while quietly not needing one. Where the binding came from a group the word is the same; /// the group is named in , which has room for /// a sentence rather than a word. /// /// internal string Authentication => resolved.Binding.Kind switch { ResolvedBindingKind.Credential => "credential", ResolvedBindingKind.SshKey => "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. /// /// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it /// creates a credential and moves the host to , so a stored password is always /// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See /// . /// Typed, /// An SSH key in this vault. SshKey, /// A username and password in this vault. Credential, /// /// Whatever the group above this host says, or a typed password if it says nothing. /// /// /// The entry that arrived with inheritance, and the reason /// exists. Naming neither a key nor a credential used to be the way to say "type one each time"; it now /// means this, so the old meaning needs a value of its own — otherwise a host under a group that binds a /// key could not opt out of it. /// Inherited, } /// 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); /// The "whatever the group says" entry, offered only to a host that is in one. /// /// Named for where the answer comes from rather than for what it will be, because what it will be /// changes when somebody edits the group — which is the point of it. It is deliberately not offered to /// an ungrouped host: with nothing above it this and do the same thing, and two /// entries that behave identically are two entries a user has to guess between. /// internal static AuthenticationChoice Inherited { get; } = new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty); /// The "this group lends no binding" entry, first in the group editor's picker. /// /// A sentinel rather than a null selection, for the reason is one: a picker showing /// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is /// not under another name — a group cannot assert "everything under here types its /// password", because that is already what a host gets when the chain lends nothing. /// internal static AuthenticationChoice NoDefault { get; } = new(AuthenticationKind.Typed, null, "No default binding", 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( Guid attemptId, uint sessionId, string label, string address) : EventArgs { /// Which attempt this session came out of. /// internal Guid AttemptId { get; } = attemptId; internal uint SessionId { get; } = sessionId; internal string Label { get; } = label; internal string Address { get; } = address; } /// A connection that has been asked for, and has not answered yet. /// Identifies this attempt for the whole of its life. /// The host's name, as the vault has it. /// Who this will be logged in as, and where. /// /// The vault says a connection has started before it says whether it worked, so that the shell can put a /// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake /// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell /// has no vault to read it from. /// internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs { /// Identifies this attempt for the whole of its life. /// /// Carried by all three events, because several connections can be in flight at once now that one no /// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one". /// internal Guid AttemptId { get; } = attemptId; internal string Label { get; } = label; internal string Address { get; } = address; } /// A connection that was asked for and did not happen. /// The attempt that has just ended. /// What to say about it, in the tab. /// /// Whether the connection stopped on a question rather than on a failure. /// /// /// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and /// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something /// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown /// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection /// resumes the moment it is answered — so the tab goes and the window shows the question instead. /// internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer) : EventArgs { /// internal Guid AttemptId { get; } = attemptId; internal string Reason { get; } = reason; /// internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer; } /// 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, /// The tags hosts wear. /// /// The odd one out, and here anyway. A tag holds no secret at all — it is a name, and the reason it is /// an item rather than a string on a host is that renaming it should be one write instead of twenty. /// That rename needs somewhere to happen, and deleting does too, or the host editor's picker fills with /// names nobody uses and never empties. This is where every other item kind already lives. /// Tags, /// 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, /// A tag a host can wear. Tag, } /// /// 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 tag, from the vault screen. Tag, } /// /// 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 same groups by id, which is the shape the inheritance walk takes. /// /// /// Cached beside rather than built per call, because resolving is on the path /// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host /// would be one allocation per row per redraw. /// private Dictionary groupsById = []; /// /// The tags as they came out of the vault, before the host counts are attached. /// /// private IReadOnlyList> tagItems = []; /// /// The same tags by id, which is what a host's resolves through. /// /// /// Every readable vault's, like and for a weaker version of the same reason. /// A tag id that does not resolve draws no chip, so a host in a team's vault would silently look /// untagged — which costs a label rather than a connection, but is just as confusing to look at and /// costs nothing to avoid. /// private Dictionary tagsById = []; /// 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; } = []; /// Whether the grid has anything to draw. /// /// A property rather than {Binding !VisibleHosts.Count} in the markup. Avalonia's ! is a /// boolean operator: against an int it produces a binding error, IsVisible falls back to /// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts. /// internal bool HasVisibleHosts => VisibleHosts.Count > 0; /// /// What the hosts grid says when it has nothing in it. /// /// /// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing /// matches what you typed" are three different situations and only the first is an invitation to add /// something. Telling somebody with thirty machines to add their first one is answering a question they /// did not ask. /// internal string NoVisibleHostsMessage => (Hosts.Count, GroupFilter, HostFilter.Trim().Length) switch { (0, _, _) => "No hosts yet. Press + NEW HOST to add one, or import the machines already in this computer's " + "~/.ssh/config from Preferences.", (_, not null, 0) => "Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's " + "card — or choose the group in a host's own editor.", _ => "No host matches that. The name, the address and the notes are all searched.", }; /// /// 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(); /// 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 tags to show, with the number of hosts wearing each. /// /// Counted like a group's rows are, and read after the hosts for the same reason: the count is a /// property of the host list rather than of the tag. /// internal ObservableCollection Tags { 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; /// /// The group the hosts grid is narrowed to, or null for every host. /// /// /// /// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it. /// This is that choice. is the way back to all of them, and it is /// an explicit control rather than a second press on the chosen card: the cards are a /// ListBox so that the selected one is marked by the same style every other list in this /// application uses, and a ListBox does not unselect on a second click. /// /// /// Separate from , and it sets it. The two answer different questions — /// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a /// card means both, which is why the change handler assigns one from the other. They are not one /// property because the phone sets on its own account: /// selects a group in order to open its editor, and a single property /// would have made opening that editor silently filter the phone's host list to the group being renamed. /// /// [ObservableProperty] private HostGroupRowViewModel? groupFilter; /// Whether the grid is showing one group rather than every host. internal bool IsFilteredByGroup => GroupFilter is not null; /// Shows every host again. [RelayCommand] private void ClearGroupFilter() { GroupFilter = null; SelectedGroup = null; } /// What the group name box holds, for both creating and renaming. [ObservableProperty] private string groupEditorLabel = string.Empty; /// /// The port hosts in this group take when they state none, empty for no default. /// /// /// Empty is a real answer here rather than a missing one: a group that states no port lends none, and /// the hosts beneath it walk further up. Distinguishing that from 22 is the difference between "these /// machines are on 22" and "these machines have not been told". /// [ObservableProperty] private int? groupEditorDefaultPort; /// The user hosts in this group log in as when they state none. /// [ObservableProperty] private string groupEditorDefaultUsername = string.Empty; /// /// What the group's parent picker offers: "no parent", then every group that may legally be one. /// /// internal ObservableCollection GroupEditorParentChoices { get; } = []; [ObservableProperty] private GroupChoice? groupEditorSelectedParent; /// What the group's authentication picker offers, for the hosts beneath it. /// /// The host picker's list without its first entry. A group cannot default to "ask for a password each /// time": that is what a host beneath it gets when nothing in the chain lends a binding, so the entry /// would be indistinguishable from leaving this alone — and two controls that do the same thing is one /// control and a guess. "No default" is the sentinel instead. /// internal ObservableCollection GroupEditorAuthenticationChoices { get; } = []; [ObservableProperty] private AuthenticationChoice? groupEditorSelectedAuthentication; /// 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 TagRowViewModel? selectedTag; // ---- The tag editor ---- // A fifth set, and the smallest by a long way: a tag is a name. It is still its own pair rather than // sharing the group editor's box, for the reason the other four are separate — a half-typed tag // appearing inside a group's name field is the kind of thing that only shows up in a bug report. [ObservableProperty] private bool isEditingTag; [ObservableProperty] private string tagEditorLabel = string.Empty; /// The tag being edited, or null when creating. private Guid? editingTagId; /// Which vault the tag editor will write to. /// private Guid editingTagVaultId; [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; /// internal bool ShowsTags => Section is VaultSection.Tags; /// /// 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", VaultSection.Tags => "TAGS", _ => "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. /// /// What the ALL chip counts, which is everything ALL actually shows. /// /// Buckets were already missing from this before tags were, so the number under a chip labelled ALL has /// been smaller than the list it opens for as long as there have been four kinds. Fixed here rather /// than left consistent: a count that disagrees with the rows beneath it is worse than no count, and /// adding a fifth kind to the same expression would have widened the gap rather than caused it. /// internal int TotalItemCount => Keys.Count + Credentials.Count + ObjectStores.Count + Tags.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 or VaultItemKind.Tag; /// 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.", VaultSection.Tags => "No tags yet. Add one here, or from a host's editor, and it becomes a chip you can put on " + "twenty machines and rename once.", _ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.", }; // ---- The editor ---- [ObservableProperty] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] private bool isEditing; /// /// Whether the group editor is open as a surface of its own. /// /// /// /// It was the phone's alone. The phone has no room for a permanent bar, so its group editor is a card /// that replaces the list — and "is the card showing" is a different question from "which group is being /// edited", because adding one has no id. /// /// /// The desktop sets it too now. Its group editor used to be a bar under the group list that was /// always on screen, which is why was enough there. The hosts screen has no /// such bar since it became a grid of cards: the group editor is a panel in the drawer, raised by /// + NEW GROUP or by EDIT, and "is it raised" is exactly this. So /// now answers for both heads rather than being false on one of /// them. /// /// /// Held here rather than on the phone's own control so that the two heads cannot disagree about /// whether an editor is open. The back gesture and the floating button both read it. /// /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))] [NotifyPropertyChangedFor(nameof(IsDrawerOpen))] [NotifyPropertyChangedFor(nameof(IsShowingHostDetail))] private bool isEditingGroup; /// /// Whether the hosts screen's right-hand drawer is open. /// /// /// /// The drawer is where everything that is about one thing lives: what a host is, the host /// editor, and the group editor. The grid beside it is about all of them. Splitting the screen that way /// is what let the 268-pixel host list go — the list was carrying both jobs, and neither at full size. /// /// /// It stays open while a host deletion is in question, because the question is asked in the drawer and a /// deletion does not clear the selection. There is no separate term for that here: a pending deletion /// always has a selected host behind it. /// /// /// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule /// rather than a preference — see MainWindow.axaml. Nothing on this screen may be laid over the /// terminal's rectangle, and a drawer that slid over the grid would be doing exactly that on the day /// somebody moved the grid. /// /// internal bool IsDrawerOpen => IsEditing || IsEditingGroup || SelectedHost is not null; /// Whether the drawer is showing what a host is, rather than one of the two editors. internal bool IsShowingHostDetail => !IsEditing && !IsEditingGroup && SelectedHost is not null; /// /// Whether the add sheet is showing over the host list. /// /// /// The phone's answer to a + that has two things to offer. It is a separate flag from the two /// editors because it sits before either of them: the sheet asks which kind, and choosing /// closes the sheet and opens that kind's editor. See . /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(AnEditorIsOpen))] [NotifyPropertyChangedFor(nameof(ShowsConnectBar))] private bool isAddSheetOpen; /// /// Whether anything the host screen can put over its list is showing. /// /// /// One property rather than three tests at each call site, and it exists because two controls need /// exactly this question and would otherwise each answer it their own way: the floating + hides /// while any of them is up — a button that opens an editor on top of an open editor is a button that /// does nothing — and the back gesture closes them before it considers leaving the screen. /// internal bool AnEditorIsOpen => IsAddSheetOpen || IsEditing || IsEditingGroup; /// /// Whether the phone's connect bar has anything to be about. /// /// /// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one /// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing /// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host /// editor, for the very record being typed into. /// internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen; [ObservableProperty] private string editorLabel = string.Empty; [ObservableProperty] private string editorHostname = string.Empty; /// /// The port box, empty when the host is to take its group's. /// /// /// Nullable rather than defaulting to 22, which is the difference between a form that states a port and /// one that leaves it to the group. An empty box shows , so the form /// says what leaving it blank will get you rather than making the user guess — and a new host under a /// group that says 2222 is created wanting 2222, without anybody typing it. /// [ObservableProperty] [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))] private int? editorPort; [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] [NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))] [NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))] private GroupChoice? editorSelectedGroup; /// /// Keeps the authentication picker in step with whether there is a group to inherit from. /// /// /// /// Filing a host into a group must not pin it to a typed password, and without this it did. An /// ungrouped host is offered no "Inherit from group" entry — with nothing above it the two entries would /// behave identically — so its picker sits on "Password (ask each time)". Choose a group and save, and /// that selection would be written as : the host would be pinned /// to a prompt it never asked for, by a user who was only filing it, and the group's key would never /// reach it. /// /// /// The selection moves to "Inherit from group" rather than staying, because for an ungrouped host the /// two are the same thing and the stored value was "nothing stated". Reading a deliberate refusal into /// a choice the user could not have made differently would be inventing an intent; inheriting is what /// the record already said. /// /// partial void OnEditorSelectedGroupChanged(GroupChoice? value) { var grouped = value?.EntityId is not null; if (grouped == EditorAuthenticationChoices.Contains(AuthenticationChoice.Inherited)) { return; } var selected = EditorSelectedAuthentication; BuildAuthenticationChoices( Bound(AuthenticationKind.SshKey), Bound(AuthenticationKind.Credential), asksForPassword: !grouped && selected?.Kind == AuthenticationKind.Typed, grouped); } /// What an empty port box will dial. /// /// /// Recomputed when the group picker moves, which is the whole point of it being a placeholder rather /// than a pre-filled value: filing a host into a group is supposed to visibly change what leaving the /// box empty means. A pre-filled 2222 would have been indistinguishable from a port the user typed, and /// saving it would have pinned it. /// /// /// Reads the group chain from the picker's current selection rather than from the host being edited, /// because the two differ for exactly as long as the editor is open and unsaved — which is when this is /// read. /// /// internal string EditorPortPlaceholder => InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture)) ?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture); /// What an empty username box will log in as, or a prompt when nothing supplies one. /// internal string EditorUsernamePlaceholder => InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username"; /// /// Walks through rather than looking only at the selected group, so /// a placeholder shows the value that will actually be used — which may come from three levels up — and /// so that a cycle assembled elsewhere cannot hang the editor either. /// private string? InheritedFromEditorGroup(Func read) => HostInheritance .Chain(EditorSelectedGroup?.EntityId, groupsById) .Select(entry => read(entry.Group)) .FirstOrDefault(value => value is not null); /// /// The tags the host being edited wears, as the picker leaves them. /// /// /// The authority while an editor is open, not : the chips are a /// projection of this and are rebuilt from it, so a tag the vault no longer offers — deleted on another /// machine between the editor opening and Save — is still carried through rather than dropped by an /// edit that was about something else. /// private TagSet editorTagIds = TagSet.Empty; /// /// Every tag in the vault, as a chip, with whether the host being edited wears it. /// /// internal ObservableCollection EditorTagChoices { get; } = []; /// Whether there is any tag to offer, which is what draws the picker at all. /// /// A vault with no tags shows the new-tag box and nothing else. An empty row of chips with a heading /// over it would be a control that looks broken rather than one that has nothing to say. /// internal bool HasTagChoices => EditorTagChoices.Count > 0; /// The name in the editor's "new tag" box. [ObservableProperty] private string editorNewTag = string.Empty; /// /// Puts a tag on the host being edited, or takes it off. /// /// /// Applies to the editor's own set rather than saving anything. The host is written by /// like every other field, so cancelling an edit drops the tagging with the /// rest of it — which is what a user who pressed CANCEL asked for. /// [RelayCommand] private void ToggleEditorTag(TagChoice? choice) { if (choice is null) { return; } editorTagIds = choice.IsWorn ? TagSet.Create(editorTagIds.Where(id => id != choice.EntityId)) : TagSet.Create([.. editorTagIds, choice.EntityId]); BuildTagChoices(); } /// /// Creates a tag from the editor's box and puts it on the host being edited. /// /// /// /// The moment a tag is wanted is the moment somebody is tagging a host and finds it does not exist yet, /// so this is where creating one belongs. The keychain screen has the list for renaming and deleting; /// making a user go there first, come back, and find their half-typed host gone would be the wrong way /// round. /// /// /// It writes to the vault immediately, unlike everything else in this editor. A tag is a shared /// item with an id, and a host can only name an id that exists — so there is nothing to defer. The /// consequence is honest rather than hidden: cancelling the host edit leaves the tag behind, because /// the tag was never part of the host. /// /// /// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable /// — see for why refusing the second would be worse — but creating /// one by hand, from a box, beside a chip of the same name is a slip rather than an intention. /// /// [RelayCommand] private async Task AddEditorTagAsync(CancellationToken cancellationToken) { var label = EditorNewTag.Trim(); if (label.Length == 0) { return; } // Ordinal-ignore-case rather than the current culture's. A tag name is a filter token people type // and re-type — "PCI" and "pci" are one tag by anybody's reading — and culture-aware casing would // make whether they are the same depend on the phone's locale, which is not a property of the // keychain the two machines share. if (Tags.FirstOrDefault( row => string.Equals(row.Label, label, StringComparison.OrdinalIgnoreCase)) is { } existing) { editorTagIds = TagSet.Create([.. editorTagIds, existing.EntityId]); EditorNewTag = string.Empty; BuildTagChoices(); Status = $"'{existing.Label}' is already in this keychain, so it was used rather than repeated."; return; } var tag = new TagSecret { Label = label }; if (!tag.TryValidate(out var reason)) { Status = reason; return; } await RunAsync( "Saving…", async () => { // The active vault, not the host's. The picker lists the active vault's tags — exactly as // the group picker lists the active vault's groups — so creating one anywhere else would // put a chip on the host that the editor beside it could not show. A host in a team's vault // can therefore end up naming a personal tag, which is the same cross-vault reference a // group already allows and is recorded with it in docs/design-import-gaps.md. var entityId = await session.Tags .CreateAsync(session.ActiveVaultId, tag, cancellationToken) .ConfigureAwait(true); editorTagIds = TagSet.Create([.. editorTagIds, entityId]); EditorNewTag = string.Empty; await ReloadAsync(cancellationToken).ConfigureAwait(true); BuildTagChoices(); Status = $"Added the tag '{tag.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Refills the editor's chips from the vault's tags and the set being edited. /// /// Rebuilt wholesale rather than mutated, because is a record and its /// IsWorn is part of its value. One toggle therefore replaces the collection, which for a list of /// chips is cheaper than raising change notification on each of them. /// private void BuildTagChoices() { EditorTagChoices.Clear(); foreach (var tag in Tags) { EditorTagChoices.Add( new TagChoice(tag.EntityId, tag.Label, editorTagIds.Contains(tag.EntityId))); } OnPropertyChanged(nameof(HasTagChoices)); } /// 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. /// /// "SAVE" rather than the "RENAME" it said while a group was only a name. A button that offers to /// rename, pressed after somebody has changed the default port beside it, describes one of the four /// things it is about to do. /// internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE"; /// Whether the vault screen's Edit and Delete are showing. /// internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion; // ---- Connecting ---- /// /// Typed per connection, not persisted unless says otherwise, and /// 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; /// What was typed into the manual connect box, as user@host or user@host:port. /// /// One box rather than four, because this is the form of an address people already have: it is what a /// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port /// would make the ordinary case three taps between three keyboards on a phone. /// [ObservableProperty] private string manualTarget = string.Empty; /// /// /// Its own box rather than , for the reason TryBuildConnectionRequest /// takes the typed password as a parameter: these are different screens, and a password typed on one is /// not a password offered on the other. /// [ObservableProperty] private string manualPassword = string.Empty; /// Why the manual box refused, if it did. /// /// Beside that box rather than only on . The refusals here are about what was typed /// — a missing account, a port that is not a number — and a sentence about a text box belongs next to /// the text box, not on a status line that also carries what the sync engine is doing. /// [ObservableProperty] private string manualStatus = string.Empty; /// /// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it. /// /// /// Set on every attempt rather than only on the ones that stop, because whether a question is coming is /// not knowable until the handshake has run. It is never cleared: a stale pair costs nothing, since the /// only thing that reads it is a trust decision, and one of those can only exist for the attempt that /// raised it. /// private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry; /// /// Whether a password typed here should be kept, so this host stops asking for it. /// /// /// /// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a /// password can live. The two-step chore it replaces — add a password under Keychain, then open the host /// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand /// means typing the secret into a second screen while the first one already has it. /// /// /// Off by default, and it stays a decision. The reason a typed password exists at all is that not /// every password belongs in a synchronised vault; remembering silently would move each of them there and /// tell nobody. It also only takes effect once the handshake has succeeded — see /// — because a password that has just been refused is precisely /// the one not worth keeping. /// /// [ObservableProperty] private bool remembersConnectPassword; /// /// 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. /// /// /// The resolved binding, not the host's own two ids. A host that names neither used to mean "type one", /// and now means "take the group's" — so reading the ids directly would put a password box in front of /// every host under a group that binds a key, and the box would do nothing. /// /// internal bool SelectedHostAsksForAPassword => SelectedHost is null || SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword; /// /// 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. /// /// /// An inherited binding says so. This is the one place with room for the sentence, and it is worth /// spending: a user looking at a host that says nothing about keys, being told it authenticates with one, /// would go looking in the wrong editor. Naming the group points at the record that can actually be /// changed. /// /// internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch { { Kind: ResolvedBindingKind.Credential } binding => $"This host uses a password stored in your keychain{From(binding)}.", { Kind: ResolvedBindingKind.SshKey } binding => $"This host authenticates with an SSH key{From(binding)}.", _ => string.Empty, }; /// /// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to /// search. A group that has since been deleted leaves the binding dangling, which the connect path /// reports on its own — this only has to avoid claiming a name it cannot read. /// private string From(ResolvedBinding binding) => binding.FromGroupId is { } groupId && groupsById.TryGetValue(groupId, out var group) ? $", from the group {group.Label}" : 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; /// /// Raised the moment a connection is asked for, before anything has been dialled. /// /// /// The other half of , and the reason connecting no longer makes the window /// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the /// handshake is still happening, and every other screen stays usable. Exactly one of /// and follows it, carrying the same /// AttemptId. /// internal event EventHandler? ConnectionStarting; /// Raised when a connection this vault announced does not become a session. /// internal event EventHandler? ConnectionFailed; 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. Tags are read // in the same place and for the same reason: a host row draws a chip per tag, and it can only draw // the name if the tag was read first. var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadTagsAsync(cancellationToken).ConfigureAwait(true); unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true); // After the hosts, because a tag row says how many wear it. RebuildTags(); 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; // Resolved here, which is why ReloadGroupsAsync and ReloadTagsAsync both run before this: a host // resolved against a stale group list would show one port and dial another, and one resolved // against a stale tag list would draw a chip that has been renamed. rows.AddRange(listing.Items.Select( item => new HostRowViewModel( item, Resolve(item.Secret), LabelsFor(item.Secret.TagIds), 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, // Every readable vault's groups, not the active one's, because a host in a team's // vault is filed under that team's group — and looked up here rather than on the row // for the reason the tag names are: the map is the list's, and a row that reached for // it would be a lookup per chip per redraw. GroupLabel = GroupLabelFor(item.Secret.GroupId), })); } 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. /// /// /// Two reads, and they cover different vaults on purpose. The editable list — the rows the /// sidebar draws headings from and the group editor renames — is the active vault's alone. The /// resolution map is every readable vault's. /// /// /// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which /// vault it lives in, because 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. /// /// /// The map could not stay narrow, and that changed with inheritance. While a group was only a /// name, a host in a team's vault whose group this did not read appeared under UNGROUPED and lost /// nothing else — the same thing the sidebar shows for a group that has been deleted. Since a group /// began lending a port, a username and a binding, the same omission silently drops all three: that host /// would dial 22 as nobody, while the machine it names is on 2222 as deploy, and nothing on /// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong /// place. /// /// /// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across /// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever /// asked "what does this id say", which is exactly the question a host's GroupId poses. /// /// private async Task ReloadGroupsAsync(CancellationToken cancellationToken) { var unreadable = 0; var resolvable = new Dictionary(); // Emptied before the loop rather than assigned inside it, because the active vault may not be in // the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only // ever writes on a match would leave the last readable vault's groups on screen as though they were // still this one's. groupItems = []; foreach (var vault in session.ReadableVaults) { var listing = await session.HostGroups .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; foreach (var group in listing.Items) { resolvable[group.EntityId] = group.Secret; } if (vault.VaultId == session.ActiveVaultId) { groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)]; } } groupsById = resolvable; return unreadable; } /// How many tags would not decrypt. /// /// The same two reads as , and the same split: the editable list is the /// active vault's, the resolution map is every readable vault's. A tag id that does not resolve simply /// draws no chip, so the cost of a narrow map here is a host that looks untagged rather than one that /// dials the wrong port — cheaper than the group case, and no cheaper to get right. /// private async Task ReloadTagsAsync(CancellationToken cancellationToken) { var unreadable = 0; var resolvable = new Dictionary(); tagItems = []; foreach (var vault in session.ReadableVaults) { var listing = await session.Tags .ListAsync(vault.VaultId, cancellationToken) .ConfigureAwait(true); unreadable += listing.Unreadable; foreach (var tag in listing.Items) { resolvable[tag.EntityId] = tag.Secret; } if (vault.VaultId == session.ActiveVaultId) { tagItems = [.. listing.Items.OrderBy(tag => tag.Secret.Label, StringComparer.CurrentCulture)]; } } tagsById = resolvable; return unreadable; } /// Refills , counting the hosts wearing each. /// /// Counted over , which spans every readable vault, while the rows themselves are /// the active vault's. That asymmetry is deliberate and is the same one the delete warning needs: a tag /// worn by a teammate's host is still worn, and a count that ignored those would tell somebody a tag /// was unused just before they deleted it out from under twenty machines. /// private void RebuildTags() { var selectedId = SelectedTag?.EntityId; Tags.Clear(); foreach (var tag in tagItems) { Tags.Add(new TagRowViewModel( tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId)))); } SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId); } /// /// A host with its group chain applied: the port to dial, the user to log in as, and how to /// authenticate. /// /// /// /// The one place this view model turns a stored host into a dialled one. A host may leave any of the /// three unset and take its group's, so reading host.Port or host.SshKeyId directly /// answers "what did the user type into this host" and not "what happens when this is connected" — and /// almost everything on screen wants the second question. See . /// /// /// Reads , which is refilled by before the hosts /// are read. That ordering is not incidental: a host resolved against a stale group list would show one /// port and dial another. /// /// internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById); /// /// The names behind a host's tag ids, sorted for display, skipping any that do not resolve. /// /// /// Sorted by name rather than kept in the set's own order. sorts by id, which is a /// UUIDv7 and therefore by when the tag was created — an order that means nothing on screen and changes /// what a row looks like depending on which machine made which tag first. /// private IReadOnlyList LabelsFor(TagSet tagIds) => [ .. tagIds .Where(tagsById.ContainsKey) .Select(id => tagsById[id].Label) .OrderBy(label => label, StringComparer.CurrentCulture), ]; /// /// The name behind a host's group id, or empty where there is none to show. /// /// /// Empty covers both "this host is in no group" and "the group it names is not in this vault any more", /// which is the same answer the list gives a dangling reference everywhere else. See /// . /// private string GroupLabelFor(Guid? groupId) => groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty; /// Refills , counting the hosts filed under each. private void RebuildGroups() { var selectedId = SelectedGroup?.EntityId; var filteredId = GroupFilter?.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); // Re-resolved by id for the reason the selection above is: every row object here is replaced on // every reload, so a filter holding the old one would go on narrowing the grid to a group that is no // longer in the list — and the card the user could press to clear it would be a different object // that never matched. A group deleted by a sync clears the filter, which is the honest answer: the // grid comes back to every host rather than to none. // // This assignment is a new row object whenever there is a filter at all, so it always fires // OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is // wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see // the same thing — and it is left rather than dodged by writing the backing field, because writing // the field would skip SelectedGroup and IsFilteredByGroup with it. GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId); 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; // The grid's empty state. Both of these are computed rather than stored, and neither has a change // notification of its own — VisibleHosts raises collection changes, which is not the same event. OnPropertyChanged(nameof(HasVisibleHosts)); OnPropertyChanged(nameof(NoVisibleHostsMessage)); } /// /// 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; } /// /// Files one host under one group, or under none. /// /// /// /// What dragging a host card onto a group card does, and the only thing in this application that changes /// a host without opening the editor. That is the justification for it existing at all: filing thirty /// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one /// field of a host that is about arrangement rather than about the machine. /// /// /// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A /// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same /// host would be a save the user never asked for, and one they would then be unable to cancel. /// /// /// A group id that is not in this vault is not refused — it is treated as no group at all, which is what /// the list already does with a dangling reference. See . /// /// /// No cancellation token, for the reason has none: a command generated over a /// method that takes one cancels the previous execution's token on every invocation, and two drops in /// quick succession are two writes rather than one superseding the other. This is one row's one field /// and it is over in a moment. /// /// /// The host to move, and where to. [RelayCommand] private async Task MoveHostToGroupAsync(HostGroupMove? request) { if (request is not { Host: { } row }) { return; } if (row.IsReadOnly) { // The same refusal editing makes, and for the same reason: re-encoding an item a newer client // wrote would drop the fields this build has no concept of. Status = "This host was written by a newer version of DodoSSH. Update before filing it."; return; } if (IsEditing) { Status = "Finish or cancel the host you are editing first."; return; } Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted) ? wanted : null; if (row.Host.GroupId == target) { return; } var moved = row.Host with { GroupId = target }; var name = target is null ? "no group" : Groups.First(group => group.EntityId == target).Label; await RunAsync( $"Filing {row.Label} under {name}…", async () => { await session.Hosts .UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None) .ConfigureAwait(true); await ReloadAsync(CancellationToken.None).ConfigureAwait(true); // Re-found rather than kept: the reload replaces every row, so the object that was dragged is // no longer the one in the list, and leaving the selection pointing at it would light nothing. SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId); Status = target is null ? $"'{row.Label}' is no longer in a group." : $"Filed '{row.Label}' under '{name}'."; }).ConfigureAwait(true); // Pushed straight away, as a save from the editor is: this is a save from the editor, minus the // editor. await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true); } /// /// 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) { // The group cards, and they narrow before the box does — a host outside the chosen group is out // whatever was typed. The two are deliberately not one control: the box is what you type when you // know the name, and the cards are what you press when you do not. if (GroupFilter is { } group && row.Host.GroupId != group.EntityId) { return false; } 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, Resolve(host.Host).Port.Value)) .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; } /// /// Raises the sheet that asks whether the thing being added is a host or a group. /// /// /// /// Two things behind one +, which is the design's arrangement and is also the honest one: a /// phone has room for one floating button, and "add" on this screen has genuinely been two operations /// since groups existed. The desktop asks the same question by having two buttons in two panels, which /// is what a 1280-pixel window can afford. /// /// /// Refuses while an editor is open rather than stacking on top of it. The button that raises this is /// hidden in that state — see — so this is the guard for the path the /// button does not control, which is a command invoked from anywhere else. /// /// [RelayCommand] private void OpenAddSheet() { if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay()) { return; } IsAddSheetOpen = true; } /// Lowers the add sheet without choosing anything. [RelayCommand] private void CloseAddSheet() => IsAddSheetOpen = false; /// Starts a new host. [RelayCommand] private void NewHost() { IsAddSheetOpen = false; if (AHostEditorIsInTheWay()) { return; } editingEntityId = null; editingHostVaultId = TargetVaultId; EditorLabel = string.Empty; EditorHostname = string.Empty; // Empty rather than 22, so a new host under a group that says 2222 is created wanting 2222 without // anybody typing it — and one under no group still dials 22, because that is where the chain ends. EditorPort = null; EditorUsername = string.Empty; EditorNotes = string.Empty; EditorRelayEnabled = false; editorTagIds = TagSet.Empty; EditorNewTag = string.Empty; BuildTagChoices(); // 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. Before the picker, because // whether there is a group to inherit from decides whether the picker offers to. BuildGroupChoices(SelectedGroup?.EntityId); BuildAuthenticationChoices( boundKeyId: null, boundCredentialId: null, asksForPassword: false, grouped: EditorSelectedGroup?.EntityId is not null); 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; // The stored port, not the resolved one, and the difference is the whole feature: an inheriting host // opens with an empty box showing its group's value as a placeholder. Loading the resolved value // instead would fill the box, and saving would then pin what the host had deliberately left open. EditorPort = row.Host.Port; EditorUsername = row.Host.Username ?? string.Empty; EditorNotes = row.Host.Notes ?? string.Empty; EditorRelayEnabled = row.Host.RelayEnabled; editorTagIds = row.Host.TagIds; EditorNewTag = string.Empty; BuildTagChoices(); BuildGroupChoices(row.Host.GroupId); BuildAuthenticationChoices( row.Host.SshKeyId, row.Host.CredentialId, row.Host.AsksForPassword is true, grouped: row.Host.GroupId is not null); 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; case VaultItemKind.Tag: EditTagCommand.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; case VaultItemKind.Tag: DeleteTagCommand.Execute(null); break; default: break; } } // AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded // the sidebar's whole host list away under its one heading — an affordance that existed because that // list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading // nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup. /// 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(), ParentId = GroupEditorSelectedParent?.EntityId, DefaultPort = GroupEditorDefaultPort, DefaultUsername = string.IsNullOrWhiteSpace(GroupEditorDefaultUsername) ? null : GroupEditorDefaultUsername.Trim(), DefaultSshKeyId = GroupBound(AuthenticationKind.SshKey), DefaultCredentialId = GroupBound(AuthenticationKind.Credential), }; 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); } ClearGroupEditor(); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{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; GroupEditorDefaultPort = row.Group.DefaultPort; GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty; BuildGroupParentChoices(row.EntityId, row.Group.ParentId); BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId); IsEditingGroup = true; Status = $"Editing {row.Label}."; } /// /// Opens a group's editor from its heading in the host list. /// /// /// /// The phone's only route to , and it exists because there is no other. The /// desktop reaches the group editor through the groups panel, which selects a /// HostGroupRowViewModel; the phone draws no such panel, and its host list draws /// SidebarGroupHeader rows whose selection deliberately bounces back to the host — a heading is /// not a thing to be selected. So the heading needs a button, and the button needs a command that takes /// the header rather than the selection. /// /// /// A + that adds groups with no way to correct one is the same strange thing to ship as a /// + that adds hosts with no way to correct one — and worse here, because a group's defaults are /// inherited: getting one wrong is wrong for every host beneath it at once. /// /// /// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a /// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one /// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than /// opening an editor on nothing. /// /// [RelayCommand] private void EditGroupFromHeading(SidebarGroupHeader? header) { if (header?.GroupId is not { } groupId || Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row) { return; } SelectedGroup = row; EditGroupCommand.Execute(null); } /// Starts a new group. /// /// The desktop never needed this command: its group editor is a bar that is always on screen, so /// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its /// editor is a card that has to be raised — and raising it from a stale state would offer the last /// group's default key to the new one without anybody choosing it, which is what /// prevents. /// [RelayCommand] private void NewGroup() { IsAddSheetOpen = false; if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay()) { return; } ClearGroupEditor(); IsEditingGroup = true; Status = "Adding a group."; } /// /// Whether the group editor has to be dealt with before another editor opens. /// /// /// /// It answered only for the phone while the desktop's group editor was a bar that was always present. /// Both heads raise a card now — the desktop's is the panel in the hosts drawer — so this refuses on /// both, which is what it was always meant to do. /// private bool AGroupEditorIsInTheWay() { if (IsEditingGroup) { Status = "Finish or cancel the group you are editing first."; } return IsEditingGroup; } /// The group picker's selection, if it names something of this kind. private Guid? GroupBound(AuthenticationKind kind) => GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null; /// /// Fills the parent picker, leaving out the group itself and everything beneath it. /// /// /// /// Refusing a descendant here is a convenience, not the guarantee. It stops a cycle being made on /// this machine, which is worth doing because the alternative is a user watching their own sidebar go /// flat. What it cannot stop is a cycle assembled from two offline re-parents on two machines, neither /// of which was ever offered this list — so the walk itself carries a visited set. See /// . /// /// /// Descendants are found by walking each candidate upwards rather than this group downwards, /// because upwards is the direction the pointer goes and already /// terminates on a cycle. Walking down would need a child index this view model does not keep, and /// building one that has to survive a cycle is the same problem twice. /// /// private void BuildGroupParentChoices(Guid groupId, Guid? parentId) { GroupEditorParentChoices.Clear(); GroupEditorParentChoices.Add(GroupChoice.None); foreach (var candidate in groupItems.Where(item => item.EntityId != groupId)) { var descends = HostInheritance .Chain(candidate.EntityId, groupsById) .Any(entry => entry.Id == groupId); if (!descends) { GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label)); } } // A parent that is no longer selectable keeps a placeholder, so that editing a group's default port // cannot unparent it as a side effect — the same reason the host's group picker keeps one. if (parentId is { } bound && !GroupEditorParentChoices.Any(choice => choice.EntityId == bound)) { GroupEditorParentChoices.Add(new GroupChoice(bound, "(a group that is no longer here)")); } GroupEditorSelectedParent = GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == parentId) ?? GroupChoice.None; } /// Fills the group's binding picker, keeping whatever it currently defaults to selectable. /// /// without the typed-password entry and without the inherited /// one. A group defaults to a key, to a credential, or to nothing — and "nothing" is the sentinel first /// entry, because a picker with no selection and a group that deliberately lends no binding look /// identical and are not the same thing. /// private void BuildGroupAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId) { GroupEditorAuthenticationChoices.Clear(); GroupEditorAuthenticationChoices.Add(AuthenticationChoice.NoDefault); foreach (var key in Keys) { GroupEditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label)); } foreach (var credential in Credentials) { GroupEditorAuthenticationChoices.Add( AuthenticationChoice.ForCredential(credential.EntityId, credential.Label)); } AddMissingGroupBinding(AuthenticationKind.SshKey, boundKeyId); AddMissingGroupBinding(AuthenticationKind.Credential, boundCredentialId); GroupEditorSelectedAuthentication = (boundKeyId, boundCredentialId) switch { ({ } key, _) => FindGroupBinding(AuthenticationKind.SshKey, key), (_, { } credential) => FindGroupBinding(AuthenticationKind.Credential, credential), _ => AuthenticationChoice.NoDefault, }; } private void AddMissingGroupBinding(AuthenticationKind kind, Guid? boundId) { if (boundId is { } bound && !GroupEditorAuthenticationChoices.Any( choice => choice.Kind == kind && choice.EntityId == bound)) { GroupEditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound)); } } private AuthenticationChoice FindGroupBinding(AuthenticationKind kind, Guid entityId) => GroupEditorAuthenticationChoices .FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId) ?? AuthenticationChoice.NoDefault; /// Empties every box in the group editor, so the next open starts from nothing. /// /// One method rather than a line per field at each of the three places that clear it. A group editor /// left holding the last group's default key would lend it to the next group somebody created without /// anybody choosing it, which is the quiet kind of wrong. /// private void ClearGroupEditor() { EditingGroupId = null; IsEditingGroup = false; GroupEditorLabel = string.Empty; GroupEditorDefaultPort = null; GroupEditorDefaultUsername = string.Empty; BuildGroupParentChoices(Guid.Empty, parentId: null); BuildGroupAuthenticationChoices(boundKeyId: null, boundCredentialId: null); } /// Abandons a rename, leaving the box ready to create one instead. [RelayCommand] private void CancelGroupEdit() { ClearGroupEditor(); 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(ResolvedBindingKind.SshKey, 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); } /// Starts a new tag from the keychain screen. /// /// The second way to make one. The first — the host editor's box — is where it usually happens, because /// wanting a tag and tagging a host are the same moment. This is for the times they are not: setting up /// a scheme before there is anything to put it on. /// [RelayCommand] private void NewTag() { if (AVaultEditorIsInTheWay()) { return; } editingTagId = null; // The active vault, not the "new items go to" picker, and this is the one place the difference // bites. Keys and credentials are read across every readable vault, so filing one into a team's is // safe — it comes back in the list. Tags are read like groups and buckets are: the editable list is // the active vault's alone. A tag filed anywhere else would be created, queued for push, reported // as added, and then invisible — no row, no count, no entry in any host editor's picker, and // nothing on this screen able to rename or delete it, because there is no active-vault switcher to // go and find it with. NewObjectStore ignores the picker for exactly this reason. editingTagVaultId = session.ActiveVaultId; TagEditorLabel = string.Empty; IsEditingTag = true; Status = "Adding a tag."; } /// Loads the selected tag's name into the box, so saving renames it. [RelayCommand] private void EditTag() { if (SelectedTag is not { } row || AVaultEditorIsInTheWay()) { return; } if (row.IsReadOnly) { Status = "This tag was written by a newer version of DodoSSH. Update before editing it."; return; } editingTagId = row.EntityId; editingTagVaultId = session.ActiveVaultId; TagEditorLabel = row.Label; IsEditingTag = true; Status = $"Renaming {row.Label}."; } /// Abandons the tag editor. [RelayCommand] private void CancelTagEdit() { IsEditingTag = false; editingTagId = null; TagEditorLabel = string.Empty; } /// /// Stores the tag in the box, encrypted, and queues it for the server. /// /// /// A rename is one write, and that is the whole reason this type exists. Every host wearing the /// tag names its id, so none of them is touched — which is what makes renaming safe to do casually and /// what a string repeated inside twenty payloads could never have offered. See . /// [RelayCommand] private async Task SaveTagAsync(CancellationToken cancellationToken) { var tag = new TagSecret { Label = TagEditorLabel.Trim() }; if (!tag.TryValidate(out var reason)) { Status = reason; return; } var renaming = editingTagId; await RunAsync( "Saving…", async () => { if (renaming is { } entityId) { await session.Tags .UpdateAsync(editingTagVaultId, entityId, tag, cancellationToken) .ConfigureAwait(true); } else { await session.Tags .CreateAsync(editingTagVaultId, tag, cancellationToken) .ConfigureAwait(true); } CancelTagEdit(); await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = renaming is null ? $"Added the tag '{tag.Label}'." : $"Renamed to '{tag.Label}'."; }).ConfigureAwait(true); await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// Asks whether the selected tag should go. /// /// The consequence is worth counting rather than describing. Deleting a tag does not touch the hosts /// wearing it — rewriting N payloads inside one delete is what keeping membership on the host exists to /// avoid — so what actually happens is that N rows lose a chip, and the number is the difference /// between a tidy-up and losing a filter somebody relies on. /// [RelayCommand] private void DeleteTag() { if (SelectedTag is not { } row) { return; } PendingDeletion = new DeletionRequest( DeletionTarget.Tag, row.EntityId, $"Delete the tag '{row.Label}'?", HowFarADeletionGoes("The tag"), row.HostCount == 0 ? string.Empty : row.HostCount == 1 ? "1 host wears it and will simply stop showing the chip. Nothing else about it changes." : $"{row.HostCount} hosts wear it and will simply stop showing the chip. Nothing else " + "about them changes."); } /// Queues a tombstone for the tag that was agreed to. private async Task DeleteTagNowAsync(Guid entityId, CancellationToken cancellationToken) { if (Tags.FirstOrDefault(row => row.EntityId == entityId) is not { } row) { Status = "That tag is no longer here, so nothing was deleted."; return; } await RunAsync( "Deleting…", async () => { await session.Tags .DeleteAsync(session.ActiveVaultId, entityId, cancellationToken) .ConfigureAwait(true); if (editingTagId == entityId) { CancelTagEdit(); } await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Deleted the tag '{row.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(ResolvedBindingKind.Credential, 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; case DeletionTarget.Tag: await DeleteTagNowAsync(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 . /// /// /// Counted over the resolved binding, so a group's default is counted too. Reading each host's own /// ids would miss a key that only a group names — which is the worst case rather than an edge one: a key /// bound once on a group and inherited by twenty hosts would warn about nobody, be deleted, and then /// refuse all twenty at connect time. /// /// private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId) { var bound = Hosts .Where(row => row.Resolved.Binding is { } binding && binding.Kind == kind && binding.EntityId == 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. /// /// /// /// Deliberately not inside , unlike every other command here. That gate is /// what makes the vault do one thing at a time, and connecting is the one operation that must not hold /// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate /// for it means a window in which nothing else can be saved, edited or even connected to. The strip /// carries the feedback instead — puts a tab there before anything is /// dialled — so the wait is visible without being in the way. Everything RunAsync would have done /// for the failures is done by , which reports every one of them. /// /// /// Several connections can therefore be in flight at once, which is why an attempt has an id and why /// this command allows concurrent executions. That is a feature rather than a tolerated race: opening /// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting /// for each in turn. Without the flag the generated command refuses a second call outright while the /// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of /// the explanation. /// /// /// It takes no cancellation token, and that is what makes the flag above mean anything. A /// [RelayCommand] over a method that takes one generates a command which cancels the previous /// execution's token every time it is invoked — so a second connection would quietly abandon the first, /// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the /// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not /// having one is a way to abort a handshake from here; closing the tab is that, and the session it /// abandons is adopted rather than lost. See MainWindowViewModel.CloseTabAsync. /// /// [RelayCommand(AllowConcurrentExecutions = true)] private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None); /// /// /// Whatever the caller's own lifetime is. The command passes none; the host-key retry passes its own, /// which is a different command's and so is not cancelled by anyone else connecting. /// private async Task ConnectToSelectedHostAsync(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, row.Resolved, ConnectPassword, out var authentication, out var refusal)) { Status = refusal; return; } await ConnectToAsync( new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row), authentication, cancellationToken).ConfigureAwait(true); } /// /// Opens a terminal on somewhere that is not in the keychain. /// /// /// /// The one connection this application makes to a machine it has never been told about. Everything /// else starts from a keychain item, and that is still the way a host anybody uses twice should be /// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is /// not typed again. This is for the other case, which is real and had no answer: a box somebody has just /// been given the address of. /// /// /// A typed password and nothing else. Offering the keychain's keys here would be a second binding /// resolution beside , and the argument against a second one is /// written there at length. A key is a reason to save the host. /// /// /// Nothing is written to the keychain, deliberately. What is written, if the user answers the /// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and /// a machine reached this way is exactly the one whose key nobody has seen before. /// /// /// It takes no cancellation token and allows concurrent executions, for the two reasons /// carries. /// /// [RelayCommand(AllowConcurrentExecutions = true)] private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None); /// private async Task ConnectManuallyAsync(CancellationToken cancellationToken) { if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal)) { ManualStatus = refusal; return; } if (ManualPassword.Length == 0) { ManualStatus = "A password is needed. Save this machine as a host to reach it with a key."; return; } ManualStatus = string.Empty; await ConnectToAsync( // Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two // different connections, and a strip showing the same name twice would be the tab equivalent of // the log entry this also names. new ConnectionTarget( $"{endpoint.Username}@{endpoint.Hostname}", endpoint.Hostname, endpoint.Port), new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)), cancellationToken).ConfigureAwait(true); } /// /// Reads user@host, with an optional :port, or says why it cannot. /// /// /// /// The username is required rather than defaulted to this device's account name, which is what /// ssh itself would do. A phone has no account name worth borrowing — the value there is the /// Android user, which is never a login on anything — so a default would be a guess that fails at the /// remote with "authentication failed" rather than here with a sentence. /// /// /// The port defaults to 22 and refuses anything outside 1–65535, which is the range /// HostSecret.TryValidate already enforces for a saved host. A target that cannot be stored is /// not one this path should be able to dial either. /// /// /// IPv6 in brackets is not accepted, and the refusal says so rather than silently reading /// ::1's last colon as a port separator. Nothing else in this application accepts a bracketed /// address — HostSecret.Hostname is a bare string dialled as it stands — so accepting one here /// would make this the only field in the product with its own address grammar. /// /// private static bool TryParseManualTarget( string typed, [NotNullWhen(true)] out ManualEndpoint? endpoint, [NotNullWhen(false)] out string? reason) { endpoint = null; var trimmed = typed.Trim(); if (trimmed.Length == 0) { reason = "Type a machine to connect to, as user@host."; return false; } if (trimmed.Contains('[', StringComparison.Ordinal)) { reason = "A bracketed IPv6 address is not accepted here. Save it as a host instead."; return false; } var at = trimmed.LastIndexOf('@'); if (at <= 0 || at == trimmed.Length - 1) { reason = "Say who to log in as: user@host, or user@host:port."; return false; } var username = trimmed[..at]; var host = trimmed[(at + 1)..]; var port = 22; if (host.LastIndexOf(':') is var colon && colon >= 0) { if (!int.TryParse( host[(colon + 1)..], NumberStyles.None, CultureInfo.InvariantCulture, out port) || port is < 1 or > 65535) { reason = "The port has to be a number between 1 and 65535."; return false; } host = host[..colon]; } if (host.Length == 0) { reason = "Say which machine: user@host, or user@host:port."; return false; } endpoint = new ManualEndpoint(username, host, port); reason = null; return true; } /// What a manual target reads as, once it has been taken apart. /// /// Separate from because a keychain host has no username of its own at /// this level — its account comes out of , possibly from a bound /// credential rather than from the host — so a username on the shared record would be a field that is /// null for every connection but this one. /// private sealed record ManualEndpoint(string Username, string Hostname, int Port); /// Everything both connect paths share, from the tab appearing to the session opening. /// /// One method rather than two, and it is the same argument makes /// about there being one authentication resolution: the ladder of refusals below this, the host-key /// question, the log entry and the tab's own lifecycle are the parts nobody should be able to get /// subtly different for one kind of connection. /// private async Task ConnectToAsync( ConnectionTarget target, HostAuthentication authentication, CancellationToken cancellationToken) { PendingHostKey = null; HostKeyMismatch = null; // Remembered so that answering the host-key question retries *this* attempt. It used to re-run the // selected host unconditionally, which was right while that was the only way to connect and would // now dial the wrong machine — or refuse, with nothing selected — for a manual one. pendingRetry = (target, authentication); // Before the first await, so the tab is in the strip in the same turn the user asked for it. The // address is the one that will actually be dialled — a bound credential can supply the username — // rather than the host's own fields, so the tab does not rename itself on connecting. var attempt = new ConnectionAttemptEventArgs( Guid.CreateVersion7(), target.Label, Dialled(target, authentication)); ConnectionStarting?.Invoke(this, attempt); Status = $"Connecting to {target.Label}…"; await OpenSessionAsync(attempt, target, 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; // The attempt that raised the question, replayed as it stood. Re-running the selected host was // right while that was the only way to connect; with a manual target it would dial whichever host // happens to be selected, or refuse with "choose a host first" over a machine the user has just // agreed to trust. if (pendingRetry is { } retry) { await ConnectToAsync(retry.Target, retry.Authentication, 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; // The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its // group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch // exactly where it was. var port = Resolve(row.Host).Port.Value; 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); } /// Connects, and turns every way of not connecting into something a tab can carry. /// /// /// 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…". /// /// /// The timeout is translated rather than reported. says only "The /// operation has timed out", and the one thing worth saying is where to look: a runtime this application /// does not install. /// /// /// An unknown host key is not a failure and is deliberately not logged. Nothing was refused and /// nothing broke — 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. A changed /// key is logged, and it is the entry the connection log most exists for: it is refused outright /// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses. /// /// /// Everything else is caught by shape rather than by type. This project's SSH layer defines only /// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not /// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded /// before it is reported — the log is an observer here and must never become the thing that swallows an /// error. Cancellation is excluded from that, because a user who gave up did not fail to connect. /// /// private async Task OpenSessionAsync( ConnectionAttemptEventArgs attempt, ConnectionTarget target, HostAuthentication authentication, CancellationToken cancellationToken) { try { await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken) .ConfigureAwait(true); } catch (TimeoutException) { Abandon( attempt, "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) { PendingHostKey = exception.Presentation; Answer(attempt, "This host has not been seen before."); } catch (SshHostKeyMismatchException exception) { RecordFailure(target, authentication, ConnectionOutcome.Refused); HostKeyMismatch = exception.Message; Answer(attempt, "The host key has changed. The connection was refused."); } catch (OperationCanceledException) { Answer(attempt, "Cancelled."); } catch (Exception exception) { RecordFailure(target, authentication, ConnectionOutcome.Failed); Abandon(attempt, exception.Message); } } /// Says, in one place, that an attempt ended without a session and why. /// /// The reason goes to two places on purpose. The status line is where somebody watching this screen is /// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary /// case, because connecting does not hold the window still any more. /// private void Abandon(ConnectionAttemptEventArgs attempt, string reason) { Status = reason; ConnectionFailed?.Invoke( this, new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false)); } /// /// The same, for an attempt that stopped on something the user has to answer rather than on a failure. /// /// /// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away /// so the window can show the question instead. See . Cancelling /// counts as a question in the sense that matters here — the tab is going either way, and nothing about /// it is worth keeping on screen. /// private void Answer(ConnectionAttemptEventArgs attempt, string status) { Status = status; ConnectionFailed?.Invoke( this, new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true)); } /// 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( ConnectionAttemptEventArgs attempt, ConnectionTarget target, HostAuthentication authentication, CancellationToken cancellationToken) { await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true); var request = new SshConnectionRequest( target.Hostname, target.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. A target with no item still gets its name, and a null id: the entry is the // only record that machine was reached at all. connectionLog?.Identify(sessionId, target.Label, target.HostId); Status = $"Connected to {target.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( attempt.AttemptId, sessionId, target.Label, Dialled(target, authentication))); // Last, and after the tab exists: keeping the password is a favour, and the session the user asked // for must not wait on a vault write to appear. // // Only for a target that came from a keychain host. A machine typed into the manual box has nothing // to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the // screen it is typed on says so. if (target.Row is { } row) { await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true); } } /// /// Turns the password that just worked into a keychain credential bound to this host. /// /// /// /// Only after a handshake the remote accepted. Storing a password the moment it is typed would /// bind whatever was in the box — including the typo that is about to be refused — and the host would /// then stop asking, leaving a machine that cannot be connected to until somebody works out that the /// keychain is where the wrong password now lives. /// /// /// A credential rather than a field on the host, which is why nothing else here had to change. /// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type /// exists — bound to the other nineteen machines that share the account. See /// on why the binding is an id and not a copy. /// /// /// The credential carries no username of its own, so it keeps taking the host's — which is what the /// connection that just succeeded did. Copying the resolved username into it would pin whatever the /// group happened to say at this moment, and quietly stop following the group afterwards. /// /// /// Every failure is reported and swallowed. The caller's catch blocks describe a connection that /// did not happen, and this one did: a vault write that fails here must not tell the user their terminal /// was abandoned, and a cancellation must not report it as cancelled. /// /// private async Task RememberTypedPasswordAsync( HostRowViewModel row, HostAuthentication authentication, CancellationToken cancellationToken) { // The password as dialled, not as the box currently reads: the two can differ by now, because a // handshake takes time and the box stays typeable throughout it. if (!RemembersConnectPassword || row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword || authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed) { return; } if (row.IsReadOnly) { Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a " + "newer version of DodoSSH, and binding a credential would re-encode it."; return; } var credential = new CredentialSecret { Label = row.Label, Password = typed.Password }; try { var credentialId = await session.Credentials .CreateAsync(row.VaultId, credential, cancellationToken) .ConfigureAwait(true); // Into the same vault as the host, deliberately: a credential in the personal vault bound to a // team's host is a binding every other member can see and none of them can resolve. await session.Hosts .UpdateAsync( row.VaultId, row.EntityId, row.Host with { CredentialId = credentialId, AsksForPassword = null }, cancellationToken) .ConfigureAwait(true); } catch (Exception exception) { Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}"; return; } // Cleared together. The box is about to disappear — the host answers "credential" now — and a tick // left behind would apply to the next host somebody selects. RemembersConnectPassword = false; ConnectPassword = string.Empty; await ReloadAsync(cancellationToken).ConfigureAwait(true); Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it " + "will not be asked for again."; await AutoSyncAsync(cancellationToken).ConfigureAwait(true); } /// The address as actually dialled. /// /// Built from what was dialled rather than from the host's own fields, because neither half of it need /// come from the host. A bound credential can supply the username, and a group can supply the port and /// the username both — so a host saved with neither still has both here, and they are the ones the /// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log /// naming a port nothing dialled is worse than no log. /// private static string Dialled(ConnectionTarget target, HostAuthentication authentication) => string.Create( CultureInfo.InvariantCulture, $"{authentication.Username}@{target.Hostname}:{target.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( ConnectionTarget target, HostAuthentication authentication, ConnectionOutcome outcome) { var at = TimeProvider.System.GetUtcNow(); connectionLog?.Record( Dialled(target, authentication), target.Label, target.HostId, 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); /// /// The machine a connection is being made to, however it was named. /// /// What to call it — a keychain host's alias, or what was typed. /// The address to dial. /// The port to dial, already resolved through any group. /// /// The keychain host this came from, or null for a machine that is not in the keychain. /// /// /// /// This exists so the connect path stops being shaped like . Everything /// below the resolution needs three facts and a row carries dozens; taking the three is what let a /// connection to an address that has no keychain item share the ladder rather than grow a second one. /// and Identify both take a nullable id already, so the /// log has always been able to hold a connection with no item behind it. /// /// /// The row is still here, and only two things read it. Both are things that can only be done to /// a keychain item rather than to an address: identifying the log entry, and binding the password that /// just worked. Null is not missing data — it is the whole of what makes the manual path different, and /// having it here rather than as a separate id keeps "was this a keychain host" one question with one /// answer. /// /// private sealed record ConnectionTarget( string Label, string Hostname, int Port, HostRowViewModel? Row = null) { /// The keychain item, or null for a machine that is not in it. internal Guid? HostId => Row?.EntityId; } /// /// 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) { ArgumentNullException.ThrowIfNull(host); var resolved = Resolve(host); if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason)) { request = null; return false; } request = new SshConnectionRequest( host.Hostname, resolved.Port.Value, authentication.Username, authentication.Credential); return true; } /// /// Turns a host into a key, a password or a prompt, or says why it cannot. /// /// /// /// Takes the resolved host as well as the stored one, and this is where group context used to be /// lost. Both callers of this and of used to hand over a /// bare , which answers "what did the user type into this host" rather than /// "what happens when it is connected". It is the only authentication resolution in the product — both /// heads and both transports come through here — so a host inheriting its binding would otherwise have /// been offered a password prompt on every screen at once. /// /// /// The refusal messages name the group when the binding came from one. A user told that "this host /// authenticates with a key that is not in this keychain any more" would go looking at a host that says /// nothing about keys. /// /// private bool TryBuildAuthentication( HostSecret host, ResolvedHost resolved, string typedPassword, [NotNullWhen(true)] out HostAuthentication? authentication, [NotNullWhen(false)] out string? reason) { var binding = resolved.Binding; var where = binding.IsInherited ? $"'{host.Label}' inherits" : $"'{host.Label}' authenticates"; var repair = binding.IsInherited ? "Edit the group it is filed under, or bind the host itself." : "Edit the host to choose another one, or set it back to a typed password."; if (binding is { Kind: ResolvedBindingKind.Credential, EntityId: { } credentialId }) { if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential) { return Refuse( $"{where} a credential that is not in this keychain any more. {repair}", 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 resolved username covers the // ordinary case of a shared password used under each machine's own account — and it is the // resolved one rather than the host's, so the fallback has three levels rather than two. return Complete( credential.Credential.Username ?? resolved.Username.Value, new SshPasswordCredential(credential.Credential.Password), out authentication, out reason); } if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId }) { if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key) { return Refuse( $"{where} an SSH key that is not in this keychain any more. {repair}", out authentication, out reason); } return Complete( resolved.Username.Value, new SshPrivateKeyCredential( Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase), out authentication, out reason); } return Complete( resolved.Username.Value, 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. /// /// /// It is also why every caller passes a username the group chain has already been consulted for. Refusing /// before the chain is walked would refuse exactly the hosts inheritance exists to serve: the twenty /// machines filed under one group that says deploy once. /// /// 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(), // Empty means "take the group's" in both directions, which is what makes the placeholder honest: // what the box showed while empty is what the host will use. A relay host is the exception — // HostSecret.TryValidate refuses one with no port of its own, so the box is pre-filled and // required there; see EditorRelayEnabled. Port = EditorRelayEnabled ? EditorPort ?? HostSecret.DefaultPort : EditorPort, Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(), Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes, RelayEnabled = EditorRelayEnabled, // Only ever true or null, never false: the two would mean the same thing, and writing false would // change the bytes of every host that has never touched this. See HostSecret.AsksForPassword. // // And only for a host in a group, because only then was there another entry to choose instead — // an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the // absence of a decision rather than one, and storing it as a decision would pin every ungrouped // host in the vault the first time it was edited. AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed && EditorSelectedGroup?.EntityId is not null ? true : null, // The picker's set, and it is the picker's rather than the chips' — see editorTagIds. An id the // vault cannot currently offer, because the tag was deleted on another machine between this // editor opening and Save, is carried through rather than dropped by an edit that was about // something else. TagIds = editorTagIds, // 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. /// Whether the host is pinned to a typed password. /// Whether the host is filed under a group, and so has anything to inherit. /// /// /// 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. /// /// /// Four entries where there were three, and only for a host in a group. The three states two /// nullable ids could carry became four when naming neither came to mean "inherit"; see /// . For an ungrouped host the fourth would behave exactly like /// the first, so it is left out rather than offered and then explained. /// /// private void BuildAuthenticationChoices( Guid? boundKeyId, Guid? boundCredentialId, bool asksForPassword, bool grouped) { EditorAuthenticationChoices.Clear(); EditorAuthenticationChoices.Add(AuthenticationChoice.Typed); if (grouped) { EditorAuthenticationChoices.Add(AuthenticationChoice.Inherited); } 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, asksForPassword, grouped); } 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. /// /// /// The last two arms are where the third state has to be told from the fourth. A host that names neither /// binding and does not ask for a password is inheriting; one that asks is not. An ungrouped host has no /// "Inherit" entry to select, so it falls to the typed one — which is what it resolves to anyway. /// /// private AuthenticationChoice Selected( Guid? boundKeyId, Guid? boundCredentialId, bool asksForPassword, bool grouped) => (boundKeyId, boundCredentialId) switch { ({ } key, _) => Find(AuthenticationKind.SshKey, key), (_, { } credential) => Find(AuthenticationKind.Credential, credential), _ when asksForPassword || !grouped => AuthenticationChoice.Typed, _ => AuthenticationChoice.Inherited, }; 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, IsEditingTag) 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.", (_, _, _, _, true) => "Finish or cancel the tag you are editing first.", _ => Status, }; return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore || IsEditingTag; } 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)); OnPropertyChanged(nameof(ShowsConnectBar)); // The drawer opens on a selection and closes when there is none, so both of these move with it. OnPropertyChanged(nameof(IsDrawerOpen)); OnPropertyChanged(nameof(IsShowingHostDetail)); // 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); } /// /// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the /// property. Assigning the same value again is a no-op, so this and /// cannot chase each other. /// partial void OnGroupFilterChanged(HostGroupRowViewModel? value) { SelectedGroup = value; OnPropertyChanged(nameof(IsFilteredByGroup)); RebuildVisibleHosts(); } 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(); /// Adds the tag rows to the table, when the table is showing them. /// /// Its own method purely for length: is one if per kind and a /// fifth took it past what this project lets a method be. Split at the newest arm rather than the /// prettiest place, so the diff that added it is the diff that moved it. /// private void AddTagRows() { if (Section is not (VaultSection.All or VaultSection.Tags)) { return; } foreach (var tag in Tags) { VaultItems.Add(new VaultItemRowViewModel( VaultItemKind.Tag, tag.EntityId, tag.Label, "TAG", tag.Description, tag.Badge, tag.HasUnsyncedChanges)); } } /// /// 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)); } } AddTagRows(); 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)); // Every kind this table can delete, because one selection covers all of their lists. DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId); DisarmIfAimedElsewhere(DeletionTarget.Tag, 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; case VaultItemKind.Tag: SelectedTag = Tags.FirstOrDefault(row => row.EntityId == value.EntityId); break; default: break; } } /// /// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG /// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so /// the DELETE the user could see belonged to an item they were no longer looking at. /// partial void OnIsEditingTagChanged(bool value) => DisarmOnceAnEditorIsOpen(value); 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(ShowsTags)); 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)); }