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;
using DodoSSH.Contracts;
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 vault this heading's group lives in, or empty where there is only one vault to be in.
///
///
/// The one thing a heading could not say while the headings were one vault's. Two vaults may each hold
/// a group called "production" — they are separate folders under separate keys — and a list with one
/// heading per group has nothing else to tell them apart with. Decided by the list rather than the row,
/// for the reason is.
///
internal string VaultBadge { get; init; } = string.Empty;
/// Whether this heading has a vault to name.
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// The chevron, as text, because the heading is drawn in the list's own item template.
internal string Chevron => IsExpanded ? "▾" : "▸";
}
/// One group as it came out of a vault, with the vault it came out of.
///
/// The pair the group reload hands the group rebuild, and the vault half of it is what makes a group a
/// shared thing rather than a private one: a rename and a delete both have to go back to the vault the group
/// is in, and the row that offers them is drawn from a list that now spans every readable vault.
///
/// The group, decrypted.
/// The vault it lives in.
/// That vault's display name.
internal sealed record VaultGroupItem(VaultItem Item, Guid VaultId, string VaultName);
/// 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(VaultGroupItem group, int hostCount)
{
internal Guid EntityId => group.Item.EntityId;
/// Which vault this group lives in. See .
internal Guid VaultId => group.VaultId;
/// The vault's display name.
internal string VaultName => group.VaultName;
///
/// The vault name to print on this card, or empty when there is only one vault to be in.
///
///
internal string VaultBadge { get; init; } = string.Empty;
/// Whether this row has a vault to name.
internal bool HasVaultBadge => VaultBadge.Length > 0;
internal HostGroupSecret Group => group.Item.Secret;
internal string Label => group.Item.Secret.Label;
internal int HostCount => hostCount;
internal bool IsReadOnly => group.Item.IsReadOnly;
internal string Badge =>
ItemBadge.For(group.Item.IsBlocked, group.Item.IsReadOnly, group.Item.HasUnsyncedChanges);
/// What the row says under the name.
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}
/// One step of the path into the groups, as a button in a breadcrumb trail.
/// What the step is called.
/// The group it opens, or null for the step that shows every host again.
///
/// The same shape the transfers screen's trail uses — see CrumbViewModel — and drawn the same way,
/// because it answers the same question: a directory pane and a grid of groups both have to say where the
/// thing on screen came from and offer a way back out. The row rather than its id, because
/// holds a row, and an id would only be looked up again.
///
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
/// 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;
///
/// The one line under the name on a card: the transport, the account, and every tag, comma-separated.
///
///
///
/// The address is deliberately not in it, and it used to be the whole line. A card carrying
/// root@10.0.4.12:22 and a second row of tag chips is three facts and a wrap in a 232-pixel tile,
/// and the two that a person scanning forty machines actually reads are the name and what kind of
/// machine it is. The address is on the card's tooltip and in the drawer, which is where somebody
/// checking an address is looking anyway. See HostsScreen.axaml.
///
///
/// "ssh" is a constant today and is printed anyway, which is the one thing on this row worth
/// arguing about — this codebase omits constants dressed up as readings, and by that rule the word
/// should not be here. It is here because it is the first item of a list whose other items vary, and a
/// list that begins with the account on one card and with a tag on the next has no shape to scan. It
/// becomes a real fact the day a second transport exists; until then it is a label, not a reading.
///
///
/// The account is the resolved one, so a host taking its group's user says that user rather
/// than nothing, and a host nobody has given one to is one item shorter rather than saying "—". Tags
/// come last because there can be any number of them and the two before them are at most one each.
///
///
internal string Summary => string.Join(", ", SummaryParts());
private IEnumerable SummaryParts()
{
yield return "ssh";
if (!string.IsNullOrEmpty(resolved.Username.Value))
{
yield return resolved.Username.Value;
}
foreach (var tag in tagLabels)
{
yield return tag;
}
}
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 shared 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. It says SHARED rather than TEAM because a team is no longer something the person
/// choosing has been shown — see VaultsViewModel.
///
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
}
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;
///
/// The second question this deletion has to ask, or empty where it has none.
///
///
///
/// Only a group has one, and it is the one deletion in this application whose scope is not decided by
/// what is being deleted: a group is a heading, and the machines under it may be the reason the heading
/// existed or may be forty perfectly good hosts that want a different shelf. Nothing here can tell which,
/// so it is asked — see , which is the answer.
///
///
/// A sentence rather than a flag, because the card that draws it is shared by six kinds of deletion and
/// must not grow a branch per kind. Empty is "there is no second question", which is also what a group
/// with nothing filed under it gets.
///
///
internal string Choice { get; init; } = string.Empty;
/// Whether this deletion has a second question to put.
internal bool HasChoice => Choice.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.
///
///
///
/// Which vaults this machine has been asked to leave off the screens, or null where nothing is hidden.
///
/// Read by and by nothing else in here, which is the whole of how this stays a
/// display filter — see that method. Null rather than a required argument because "no preference" is the
/// state every caller that does not care about this is in, including a locked launch and every test.
///
///
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
Func connection,
ServerReconnectHandler? reconnect = null,
Func? copyToClipboard = null,
ConnectionRecorder? connectionLog = null,
VaultVisibility? visibility = 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.
///
///
/// Unchanged by the push channel, and deliberately so. The socket makes a pass early; this is
/// what makes one happen at all, for a client whose network eats WebSockets, whose server has the
/// feature off, or whose notice was dropped. See and ADR 0012.
///
///
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// How long a pushed notice waits, in case more are on their way.
///
/// A quarter of a second, which is below what anybody perceives and above the gap between the
/// notices one person's save produces — a host and its activity log entry are two items in one
/// push, and a colleague clearing a folder is a burst. Without it each notice would run its own
/// full pass, and the pass a burst deserves is one.
///
private static readonly TimeSpan NoticeDebounce = TimeSpan.FromMilliseconds(250);
/// 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 vaults, 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 . Every readable
/// vault's, each entry carrying which one it came from — a group is shared by being in a shared vault,
/// so the vault has to travel with it as far as the row that renames and deletes it.
///
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 = [];
///
/// Every readable vault's groups, kept apart by the vault they live in.
///
///
/// What the host editor's group picker is built from, and it has to be per vault rather than the one
/// list holds. A group is an item like any other, so it lives in exactly one
/// vault; offering the personal vault's groups while a host is being filed into a shared one would
/// produce a host whose group id nobody else in that vault can resolve — a colleague would see it
/// filed under nothing, which is the quietest kind of wrong. See .
///
private Dictionary> groupsByVault = [];
///
/// 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;
/// Whether a vault's items are drawn on the screens that list them.
///
///
/// The one place the visibility preference is read, and it is read only by the projections a person
/// looks at — , , the group and tag card counts,
/// and the pin list. Every Reload*Async above stays complete, and that is not tidiness:
///
///
/// -
/// and are what
/// resolves a host's binding out of, and a host in one vault may legitimately name a key filed in
/// another. Filtering the lists rather than the table would make hiding a vault break connections to
/// hosts that are still on screen.
///
/// -
/// decides what port a host dials. Hiding a vault must never change that.
///
/// -
/// The dialled-endpoint set in decides which pins are described as
/// unused, which is a hint that invites deleting trust.
///
///
///
/// Nothing outside those projections asks. Sync walks session.ReadableVaults, the keyring is
/// filled from the same list, and the trust the SSH handshake consults is read straight out of
/// VaultKnownHostStore — none of which has ever come through this type.
///
///
internal bool IsVaultShown(Guid vaultId) => visibility?.IsShown(vaultId) ?? true;
/// Whether anything at all is being kept off the screens.
///
/// What lets an empty grid say why it is empty rather than implying the vault is. Computed from the
/// vaults this session can read rather than from the hidden set, because a hidden vault whose grant has
/// since been withdrawn is not a reason to tell somebody to go and unhide something.
///
internal bool HasHiddenVaults =>
visibility is not null && session.ReadableVaults.Any(vault => visibility.IsHidden(vault.VaultId));
/// 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 grid is showing: one level of the group tree, narrowed by the box.
///
///
/// A second collection rather than a filtered view over the first, because the grid 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.
///
///
/// What "one level" means, and why the box escapes it, is in . It is the desktop's
/// alone: the phone draws , which is the same hosts flattened under headings.
///
///
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.
///
///
/// One answer per reason the grid can be empty, because "there are no hosts", "you have set a vault
/// aside", "this group is empty", "they are all filed away" and "nothing matches what you typed" are
/// 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.
///
/// The hidden-vault answer comes before the group and the search box, because it is the one an empty
/// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group
/// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen
/// to read.
///
///
/// The fourth is what the grid holding one level of the tree cost: a keychain whose every host is filed
/// under a group shows no host cards at the outermost level, and without a sentence saying so that is
/// indistinguishable from a keychain that has lost them. See .
///
///
internal string NoVisibleHostsMessage =>
(Hosts.Count, HasHiddenVaults, 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.",
(_, true, null, 0) =>
"Every host here is in a vault you have switched off. Press the ⌄ beside Vaults in the tab "
+ "strip to switch one back on.",
(_, _, not null, 0) =>
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto "
+ "this group's card — or choose the group in a host's own editor.",
(_, _, null, 0) =>
"Every host here is filed under a group. Double-press one of the cards above to open it, or "
+ "type in the box at the top to search all of them at once.",
(_, _, not null, _) =>
"No host in this group, or in anything under it, matches that. Press ALL HOSTS above to "
+ "search every machine.",
_ => "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.
///
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
/// from; is the desktop's one level of it.
///
internal ObservableCollection Groups { get; } = [];
///
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
/// groups when none is.
///
///
///
/// is to this what is to : the whole
/// collection beside the part of it on screen. The grid used to draw every group at once, which was the
/// only honest thing to do while pressing a card meant nothing but "narrow the list" — a card was a
/// filter, and every filter has to be reachable. Opening one is navigation, so the cards became the
/// contents of wherever the trail says you are.
///
///
/// A group whose parent this vault has not got is drawn at the outermost level rather than nowhere, and
/// so is one caught in a parent cycle. Both are states two offline edits can produce and neither can be
/// repaired from a screen that will not draw the group — see .
///
///
internal ObservableCollection VisibleGroups { get; } = [];
///
/// The path down to the group that is open: every host, then each group above it, then it.
///
///
/// Always at least one crumb, and the first one is the way back to every host — which is what it is for.
/// A grid whose cards are one level of a tree needs somewhere to say which level, and the same control
/// is the way out of it; without that the only way back would be a button that says so, which is what
/// SHOW ALL was and what this replaces.
///
internal ObservableCollection GroupTrail { get; } = [];
/// Whether there are any group cards to draw at this level.
///
/// Separate from , which is about the vault. A group with nothing inside it is an
/// ordinary thing to open, and the trail and the group's own buttons have to stay on screen when it is —
/// so it is the card grid alone that folds away, not the panel around it.
///
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
/// 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;
///
/// The host card that is selected, or null when none is.
///
///
/// The application's selection, which everything that acts on a host reads — connecting, editing,
/// deleting. It shares one selection with : selecting a host takes the
/// mark off a group card and the other way about. See .
///
[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;
///
/// The group card that is selected, or null when none is.
///
///
///
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
/// it deliberately no longer does is narrow the grid — see .
///
///
/// It shares one selection with . Setting either clears the other, so
/// exactly one card on the screen is ever lit. See .
///
///
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
///
/// The group that is open: the one whose contents the screen is showing, or null for every host.
///
///
///
/// Set by and by nothing else. It decides three things at once — which hosts the
/// grid holds, which groups the cards hold, and what the trail says — which is what makes it "where you
/// are" rather than a filter that happens to be on.
///
///
/// Separate from , and no longer sets it. The two answer different
/// questions — "what is on screen" and "which card is chosen" — and while one click meant
/// both there was no way to name a group without also narrowing the grid to it. Two gestures, two
/// properties; is where the two meet.
///
///
[ObservableProperty]
private HostGroupRowViewModel? groupFilter;
///
/// Opens a group, or every host when handed null.
///
///
///
/// A double-click on a card, or a press on a crumb of the trail. Deliberately not a single click, which
/// is what it was: a card is the only place a group can be selected, and a gesture that both selected a
/// group and threw the rest of the grid away left no way to rename one without first losing sight of
/// everything else. Double-clicking to go inside something is what the transfers screen's directories do
/// and what the host cards beneath these do to open a shell, so the grid now has one vocabulary rather
/// than one per list.
///
///
/// The selection is dropped first, and it has to be: the cards are about to be redrawn one level along,
/// and a selection pointing at a card that is no longer on screen would aim EDIT and DELETE at something
/// nobody can see. Null is a real argument here rather than a missing one — it is the trail's first
/// crumb, and it is the way back out.
///
///
[RelayCommand]
private void OpenGroup(HostGroupRowViewModel? group)
{
SelectedGroup = null;
GroupFilter = group;
}
/// 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.
///
///
///
/// One vault's, and the one this group is going into — see . A
/// parent in another vault would be a group half the people holding this one's key cannot resolve, and
/// the tree they see would be missing a level nobody can point at.
///
internal ObservableCollection GroupEditorParentChoices { get; } = [];
[ObservableProperty]
private GroupChoice? groupEditorSelectedParent;
///
/// Which vault a group being created will be filed into.
///
///
///
/// The same picker the host editor has, on the form beside it, and for the same reason: this is the
/// decision that makes the thing shared, it cannot be changed by saving, and the only other
/// control that could have answered it is a standing preference on a different screen. A group is where
/// hosts are filed and what lends them a port, a username and a key — so putting one in a shared vault is
/// how a team gets an arrangement rather than twenty machines in a heap, which is most of what sharing is
/// for.
///
///
/// Changed afterwards it can be, by , which is a separate act for the reason
/// moving a host is: it re-seals the group, everything nested inside it and every host filed under them
/// into a second vault's key, and nothing that happens as a side effect of pressing SAVE on a form should
/// be that.
///
///
/// Filled from , so it offers what every other "file this into" control does:
/// vaults this session can both read and write.
///
///
internal ObservableCollection GroupEditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? groupEditorSelectedVault;
/// Whether the editor should be asking which vault this group goes into.
///
internal bool ShowsGroupEditorVaultChoice =>
EditingGroupId is null && GroupEditorVaultChoices.Count > 1;
/// 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]
[NotifyPropertyChangedFor(nameof(ShowsGroupEditorVaultChoice))]
private Guid? editingGroupId;
///
/// Which vault the group editor will write to, or null until an editor has been opened.
///
///
/// Nullable where is not, because the group name box is bound whether
/// or not anything raised an editor over it — that is what the desktop's group bar was, and typing a
/// name into it and pressing ADD is still a way to make a group. is
/// what answers for that case, and it answers with the standing preference: a group made without
/// choosing a vault is a new item like any other.
///
private Guid? editingGroupVaultId;
/// The vault the group editor writes to, whether or not one was ever chosen for it.
private Guid GroupEditorVaultId => editingGroupVaultId ?? TargetVaultId;
///
/// The name of the vault a group being renamed is in, for the drawer's header, or empty.
///
///
/// Kept rather than looked up per redraw, and empty wherever there is only one vault to be in — the
/// same rule the badges follow, because a header naming the only vault there is says nothing.
///
private string editingGroupVaultName = string.Empty;
[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))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
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))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
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 || (IsHostPaneOpen && 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 && IsHostPaneOpen && SelectedHost is not null;
///
/// Whether the pane about one host has been asked for.
///
///
///
/// ◆ A selection no longer opens the drawer, and this flag is the difference. It used to:
/// read SelectedHost is not null, so touching any card took 304 pixels
/// off the grid — which is the cost of choosing, paid every time somebody arrows through a list to find
/// the machine they want. Selecting is now free, and the pane is opened by the pencil on the card, by
/// the context menu, or by either editor being raised.
///
///
/// It follows the selection once it is open rather than pinning the host it was opened on. A
/// pane that kept showing the previous machine while a different card was lit would be two answers to
/// "which host is this about" on one screen; the rule is that opening is deliberate and tracking is not.
///
///
/// Cleared when the selection goes, in . Without that a filter that
/// matched nothing would leave this true, and the pane would spring open again on the next card
/// somebody merely selected — which is the behaviour this exists to remove.
///
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isHostPaneOpen;
///
/// Whether the detail pane's own actions are showing: CONNECT, and the menu holding EDIT and DELETE.
///
///
/// The detail pane and nothing else. With an editor open the menu would offer to open the editor, and
/// while the deletion question is up it would offer to ask it again — which is the rule
/// has always carried for the row of buttons these two replaced. The
/// question takes CONNECT's place in the footer for the same reason it took DELETE's.
///
/// The move panel is in that list too and for the same reason. It takes the footer as well, so leaving
/// CONNECT under it would put two things in one row — and the menu it came from would still be offering
/// to open it.
///
///
internal bool ShowsHostPaneActions =>
IsShowingHostDetail && !IsConfirmingHostDeletion && !IsMovingHost;
///
/// Whether the panel asking which vault to move the selected host to is up.
///
///
/// The armed-state idiom this window uses everywhere instead of a modal, and here it carries a choice
/// rather than a yes: the question is not "are you sure" but "which vault", and the sentence beside it
/// says what will be left behind. See .
///
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isMovingHost;
/// Which host the open move panel is about. Null when it is closed.
///
/// Held rather than read off the selection, so the panel survives a reload replacing every row object —
/// see , which is the only thing that reads it.
///
private Guid? movingHostId;
/// Where the selected host could be moved: every vault this session can write to but its own.
internal ObservableCollection MoveVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveVault;
///
/// Whether the selected host has anywhere to move to.
///
///
///
/// Asked so the phone can leave the button out rather than offer one that answers with a refusal — it
/// has room for two buttons under a host and no room to explain a third that does nothing. The desktop
/// keeps its menu entry either way: a menu that grew and shrank would be a menu whose items move.
///
///
/// It counts vaults rather than merely asking whether there are two, because the answer is per host: a
/// host already in the only other writable vault has nowhere to go, and a read-only vault is not
/// somewhere anything can be moved to.
///
///
internal bool CanMoveSelectedHost =>
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
///
/// Whether the panel asking which vault to move the group to is up.
///
///
///
/// The host's panel again — see — under the GROUPS heading rather than in the
/// drawer, because a group has no drawer of its own: the pane beside this screen is about one machine.
///
///
/// It has no ShowsGroupActions to turn off, and does not need one. A group's actions are the
/// card's right-click menu now, which is not on screen while this panel is: opening it is what draws it.
/// The deletion question below it is disarmed by and folds this away in return,
/// so the section shows at most one of the two.
///
///
[ObservableProperty]
private bool isMovingGroup;
/// Which group the open move panel is about. Null when it is closed.
///
private Guid? movingGroupId;
/// Where the group could be moved: every vault this session can write to but its own.
internal ObservableCollection MoveGroupVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveGroupVault;
// There is deliberately no CanMoveGroup to match CanMoveSelectedHost. That one exists so the phone can
// leave a button out rather than draw one that answers with a refusal; a group is reached through the
// card's right-click menu, which is not drawn until it is opened and whose entries do not move. The one
// place the question decides anything is MoveGroup, which asks it by building the picker and saying so
// when it comes back empty.
///
/// What the drawer's header says it is about.
///
///
/// On the view model rather than as three exclusive headings in the markup, because the header is one
/// row that outlives the panel under it: it carries the close button and the overflow menu, and three
/// copies of that row would be three places to fix the day one of them moves.
///
internal string DrawerTitle => (IsEditing, IsEditingGroup) switch
{
(true, _) => editingEntityId is null ? "New host" : "Host details",
(_, true) => EditingGroupId is null ? "New group" : "Group details",
_ => "Host details",
};
///
/// The line under it: which keychain this is filed in, or what a group is for.
///
///
///
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host and a
/// group can both be moved between vaults — see and — but
/// not from here and not by saving: the two vaults are encrypted under different keys, so a move is a
/// re-seal into one and a tombstone in the other, and every item involved takes a new id. A chevron on a
/// subtitle implies an edit and would be describing something else. Where a *new* item goes is chosen in
/// the editor's own picker; see
///
///
/// A group being renamed says its vault here for the same reason a host being edited does, and it is
/// the only line that says it: the picker is hidden for an existing group, and renaming a colleague's
/// shelf without being told whose it is is exactly the edit worth naming. A group being *made* says
/// what a group is for instead, because the picker under it is already answering "which vault".
///
///
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
{
(_, true) when EditingGroupId is not null && editingGroupVaultName.Length > 0 =>
editingGroupVaultName,
(_, true) => "A heading, and what its hosts inherit",
(true, _) when editingEntityId is null => SelectedTargetVault?.Name ?? string.Empty,
_ => SelectedHost?.VaultName ?? string.Empty,
};
///
/// 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 of the chosen vault.
///
internal ObservableCollection EditorGroupChoices { get; } = [];
///
/// Which vault a host being created will be filed into.
///
///
///
/// The picker in the host editor itself, and it is a second one rather than the keychain screen's
/// reused: that one is a standing preference about where new items go and
/// this is a field of the host in front of you. Binding both to one selection would mean the box under
/// SSH KEYS moved every time somebody put a host somewhere, and — the other way round — that a host
/// half-typed on this screen could be moved by a click on that one, which is the bug
/// was introduced to prevent.
///
///
/// Filled from the same source, so what it offers is what the keychain screen offers: vaults this
/// session can both read and write.
///
///
internal ObservableCollection EditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? editorSelectedVault;
///
/// Whether the editor should be asking which vault this host goes into.
///
///
///
/// Only while creating, and only where there is more than one vault to choose between. An existing
/// host's vault is not a field of this form and the picker is not shown disabled beside it: the two
/// are encrypted under different keys, so moving one is a re-seal and a tombstone rather than a save.
/// That is offered — by , from the pane's own menu — and it is a separate act
/// precisely because it must not happen as a side effect of saving something else.
///
///
/// Hidden at one vault rather than shown with a single option, which is the rule
/// already applies for the same reason: a control offering one answer is
/// a question nobody was asked.
///
///
internal bool ShowsEditorVaultChoice => editingEntityId is null && EditorVaultChoices.Count > 1;
[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;
///
/// The answer to : whether a group's hosts go with it.
///
///
///
/// Off is the answer that keeps the machines, and it is off by default and reset to off on every
/// question — see . A tick left standing from the last group
/// deleted would delete forty hosts on behalf of somebody who was only tidying a heading away, and there
/// is no undo on either side of it.
///
///
/// A tick rather than a pair of options, and deliberately not two equally weighted answers: they are not
/// equally weighted. Keeping the hosts is recoverable — they turn up under UNGROUPED and can be filed
/// again — and deleting them is not, so the safe answer is the one that needs no decision and the
/// destructive one is the one that has to be reached for.
///
///
[ObservableProperty]
private bool deletionTakesTheHostsToo;
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;
///
/// What a group command with no argument acts on: the card that is selected, or the group that is open.
///
///
/// Two answers, because a card is no longer where the user is. Selecting one aims at it, which is what a
/// click has always done; with nothing selected the answer is the group whose contents are on screen —
/// the one the trail ends with. That fallback is what makes + NEW HOST open on the group somebody is
/// standing in rather than on none, and it is what a file manager does: act on the selection, and on the
/// current folder when there is none.
///
/// The desktop's Edit and Delete reach this through the card menu, which selects whatever was
/// right-clicked first, so the fallback is not what they read — see HostsScreen.OnGroupContextRequested.
/// They used to be a pair of buttons beside the GROUPS heading, which had no card under a pointer to
/// mean and so leaned on it.
///
///
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
/// 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,
};
///
/// The port the drawer prints, which is the one this host would dial.
///
///
/// Resolved rather than stored, like everything else the pane draws: a host that states no port of its
/// own and sits under a group on 2222 shows 2222 here, because the question the pane answers is what
/// happens when CONNECT is pressed. The editor shows the same number as a placeholder behind an
/// empty box, which is the same fact said the other way round — see .
///
internal string SelectedHostPortLabel =>
SelectedHost?.Resolved.Port.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
/// Whether that port came from a group rather than from the host.
///
/// Drawn as a word beside the value rather than folded into it. "2222" and "2222, inherited" are the
/// same connection and different edits: clearing the group's default moves the first host and the
/// second, and only somebody who knows which is which can predict that.
///
internal bool SelectedHostPortIsInherited => SelectedHost?.Resolved.Port.IsInherited ?? false;
///
///
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
/// value that had not loaded.
///
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
? user
: "no account set";
///
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
///
/// What the pane names in the credentials row: the key or password this host authenticates with.
///
///
///
/// The item's own label, resolved here rather than carried on the row, because the answer changes when
/// somebody renames a key on the keychain screen and the host row is not rebuilt for that.
///
///
/// A binding whose target the vault no longer holds says so instead of printing an id — the same rule
/// follows in the editor's picker, and for the same reason:
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
///
///
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
{
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
?? "(a password that is no longer here)",
_ => 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);
}
/// Redraws every list from the vault, without saying anything about it.
///
/// For the two things that change which vaults exist or which are drawn without going through this type
/// at all: a vault created on the Teams screen, and a switch in the tab strip's vault menu. Both leave
/// the lists on screen describing the world as it was a moment ago, and neither has a sentence worth
/// printing — which is exactly what the quiet reload is for. Also refreshes the empty-state sentence,
/// which is computed and has no change notification of its own.
///
internal async Task RefreshVaultsAsync(CancellationToken cancellationToken)
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(HasHiddenVaults));
OnPropertyChanged(nameof(NoVisibleHostsMessage));
}
/// 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.
///
///
/// Deliberately not filtered by . Hiding is a preference about reading,
/// and a destination you cannot choose is a vault you cannot put anything in — so switching a team's
/// vault off to get its forty hosts out of the way would quietly stop you filing anything into it, which
/// nobody asked for. The same goes for the transfers screen's host picker, which reads
/// for the same reason.
///
///
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);
}
SelectedHost = SelectionAfterReload(selectedId);
// 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;
}
/// Which host a freshly filled leaves selected.
/// Whatever was selected before the list was refilled.
///
///
/// The selection survives a reload, because losing it on every sync would move the terminal's target out
/// from under the user. The first host is the fallback rather than nothing, so that a fresh unlock has
/// something under CONNECT.
///
///
/// That fallback is skipped while a group card holds the selection, and it has to be: the two grids
/// share one mark — see — so a sync that invented a host would
/// quietly unselect a group nobody had touched, once a minute. Read before
/// runs, which is where is re-resolved against the rows this pass makes.
///
///
private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
Hosts.FirstOrDefault(row => row.EntityId == selectedId)
?? (SelectedGroup is null ? Hosts.FirstOrDefault() : null);
/// 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.
///
///
/// Three shapes of the same read, and every one of them spans every readable vault. The editable
/// list is what the cards and the headings are drawn from and what the editor renames; the per-vault
/// lists are what a picker offers, because a picker is always asking about one vault; the map is what a
/// host's GroupId resolves through.
///
///
/// The list stopped being the active vault's, which is what makes a group shareable. It was
/// narrow because a row shown across vaults has to carry the vault it lives in — a rename and a delete
/// both need it — and because two vaults may hold groups with the same name, which a list with one
/// heading per group cannot tell apart. Both are now paid for rather than avoided: the row carries the
/// vault, and the badge beside the name says which. Until it did, a group a colleague made in a shared
/// vault had no card, no heading and no way to be corrected from the machine looking straight at the
/// hosts filed under it.
///
///
/// The map was widened first, and it had to be, which is why it is separate. 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. 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. So the map answers "what does this id say" for every vault, including the ones
/// is keeping off the screen — hiding a vault must never change what a host
/// dials, and the list is where hiding is applied. See .
///
///
private async Task ReloadGroupsAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var resolvable = new Dictionary();
var items = new List();
var perVault = new Dictionary>();
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;
items.Add(new VaultGroupItem(group, vault.VaultId, vault.Name));
}
perVault[vault.VaultId] =
[
.. listing.Items
.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
.Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
];
}
// Ordered here rather than in the rebuild, and by the same three keys the host and key lists use:
// the vault new items go into first, then by vault name, then by label inside each. Two vaults may
// hold a group with the same name and both are drawn; which vault it is in is what tells them apart.
groupItems =
[
.. items
.OrderByDescending(entry => entry.VaultId == session.ActiveVaultId)
.ThenBy(entry => entry.VaultName, StringComparer.CurrentCulture)
.ThenBy(entry => entry.Item.Secret.Label, StringComparer.CurrentCulture),
];
groupsById = resolvable;
groupsByVault = perVault;
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)
{
// Over the shown vaults, for the reason the group counts are — see RebuildGroups.
Tags.Add(new TagRowViewModel(
tag,
Hosts.Count(row =>
row.Host.TagIds.Contains(tag.EntityId) && IsVaultShown(row.VaultId))));
}
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.
///
/// Where a hidden vault's groups are dropped, and the only place they are. The reload above keeps
/// every readable vault's, because the map built beside them decides what a host dials; this is the list
/// a person looks at, and a card for a vault whose forty hosts have been switched off is a folder that
/// cannot be opened onto anything. Dropping them here rather than at the read is what keeps the two
/// answers apart. See .
///
private void RebuildGroups()
{
var selectedId = SelectedGroup?.EntityId;
var filteredId = GroupFilter?.EntityId;
// The same test the host rows are badged by, and it counts the vaults this session can read rather
// than the ones with a group in them: a badge that appeared the moment a colleague made their first
// group would be a column arriving on its own.
var several = session.ReadableVaults.Take(2).Count() > 1;
Groups.Clear();
foreach (var group in groupItems.Where(entry => IsVaultShown(entry.VaultId)))
{
// Counted over the shown vaults rather than over every host, so a card cannot claim members the
// grid beside it is not drawing. Not counted over VisibleHosts, which would be both too early —
// that list is rebuilt after this — and wrong: a card must not lose members to the search box.
var count = Hosts.Count(
row => row.Host.GroupId == group.Item.EntityId && IsVaultShown(row.VaultId));
Groups.Add(new HostGroupRowViewModel(group, count)
{
// Only when there is something to tell apart, as on a host card — and it matters more here,
// because two vaults may each hold a "production" and the cards would otherwise be two
// identical folders side by side.
VaultBadge = several ? group.VaultName.ToUpperInvariant() : string.Empty,
});
}
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
// group holding the old one would go on showing a group that is no longer in the list — and the
// crumb the user could press to leave it would be a different object that never matched. A group
// deleted by a sync closes itself, which is the honest answer: the screen comes back to every host
// rather than to none.
//
// This assignment is a new row object whenever a group is open 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 the cards, the trail and GroupTarget with it.
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
// Unconditionally, because the assignment above is a no-op — and fires nothing — whenever no group
// was open, and the cards still have to be rebuilt out of the row objects this pass just made.
RebuildGroupLevel();
// Out of the cards on screen rather than out of every group, and after the level has been rebuilt:
// this is the card ListBox's own selection, and a row it is not showing is one the control would
// null straight back out again.
//
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
// EDIT and DELETE aim at, and a background sync that picked a group would point them at one nobody
// chose.
SelectedGroup = VisibleGroups.FirstOrDefault(row => row.EntityId == selectedId);
OnPropertyChanged(nameof(HasGroups));
}
///
/// Refills the group cards and the trail from whichever group is open.
///
///
/// Both together because they are two halves of one answer: the cards are what is inside the open group
/// and the trail is how it was reached, and a pass that rebuilt one without the other would draw a level
/// under a path that does not lead to it.
///
private void RebuildGroupLevel()
{
var parents = EffectiveParents();
var open = GroupFilter?.EntityId;
VisibleGroups.Clear();
foreach (var row in Groups.Where(row => parents.GetValueOrDefault(row.EntityId) == open))
{
VisibleGroups.Add(row);
}
GroupTrail.Clear();
// Always first, always there, and it is the way out — see GroupTrail. The name is what the grid
// below shows when nothing is open, rather than the vault's, because that is the choice being
// offered: this crumb widens the screen back to every machine in it.
GroupTrail.Add(new GroupCrumbViewModel("ALL HOSTS", null));
foreach (var row in Ancestry(open, parents))
{
GroupTrail.Add(new GroupCrumbViewModel(row.Label, row));
}
OnPropertyChanged(nameof(HasVisibleGroups));
}
///
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
/// outermost level.
///
///
///
/// Two things are promoted, and both are states this application has decided to survive rather than
/// prevent. A parent id this vault has not got is a group deleted on another machine — the reference is
/// allowed to dangle, because preventing it would mean one delete rewriting every item naming the
/// deleted thing. A cycle is two clients each re-parenting A under B and B under A while offline, which
/// no merge can see because the pointer is inside the payload. See
/// .
///
///
/// Both have to end up somewhere the user can reach. The repair for either is the group's own
/// editor, and the editor is opened from the card — so a group left inside a card nobody can open, or
/// inside a cycle no walk terminates in, would be a broken state with the fix locked inside it. Promoting
/// to a root is the same degradation the resolver's visited set produces for inheritance: a cycle reads
/// as a flat run of top-level groups.
///
///
private Dictionary EffectiveParents()
{
var stated = Groups.ToDictionary(row => row.EntityId, row => row.Group.ParentId);
var parents = new Dictionary(stated.Count);
foreach (var (id, parent) in stated)
{
parents[id] = parent is { } wanted && stated.ContainsKey(wanted) ? wanted : null;
}
foreach (var id in stated.Keys)
{
if (!ReachesTheTop(id))
{
parents[id] = null;
}
}
return parents;
bool ReachesTheTop(Guid id)
{
var visited = new HashSet();
Guid? current = id;
while (current is { } step && visited.Add(step))
{
current = parents[step];
}
return current is null;
}
}
/// The groups from the outermost down to the one that is open, or nothing when none is.
///
/// Walked against rather than against the stated parents, so the trail
/// cannot lead through a group the cards will not draw — and so that it terminates, which is what the
/// promotion above buys: a cycle has no parent left to follow.
///
private List Ancestry(Guid? open, Dictionary parents)
{
var trail = new List();
var current = open;
while (current is { } id
&& Groups.FirstOrDefault(row => row.EntityId == id) is { } row)
{
trail.Add(row);
current = parents.GetValueOrDefault(id);
}
trail.Reverse();
return trail;
}
/// 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;
// Built once and handed down rather than rebuilt inside the predicate: deciding where a host sits is
// a walk up the group tree, and this is the map that walk is made against.
var parents = EffectiveParents();
VisibleHosts.Clear();
foreach (var host in Hosts.Where(host => Matches(host, parents)))
{
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();
// Its own pass over the hosts rather than a read of VisibleHosts, which has been one level of the
// tree since the desktop's grid became a folder pane — see Matches. This list is the flat answer to
// the same question: every group it has as a heading, every host filed under one of them, and no way
// to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so
// a list narrowed to the outermost level would be a list showing only the hosts nobody had filed.
var shown = Hosts.Where(MatchesFilters).ToArray();
if (Groups.Count == 0)
{
foreach (var host in shown)
{
SidebarRows.Add(host);
}
return;
}
var known = Groups.Select(group => group.EntityId).ToHashSet();
foreach (var group in Groups)
{
AddSidebarSection(shown, group, host => host.Host.GroupId == group.EntityId);
}
AddSidebarSection(
shown,
null,
host => host.Host.GroupId is not { } id || !known.Contains(id),
onlyWhenOccupied: true);
}
/// Adds one heading to the sidebar, and the hosts under it when it is not folded away.
/// The hosts that survived the filters, which every section draws its members from.
///
/// The group this heading is for, or null for the ungrouped one. The row rather than its id and label,
/// because a heading now says which vault the group is in as well — and null has no vault to name, since
/// it is every vault's unfiled hosts at once.
///
/// Which of the shown hosts fall under it.
/// Whether an empty section is left out altogether.
private void AddSidebarSection(
IReadOnlyList shown,
HostGroupRowViewModel? group,
Func belongs,
bool onlyWhenOccupied = false)
{
var members = shown.Where(belongs).ToArray();
if (onlyWhenOccupied && members.Length == 0)
{
return;
}
var expanded = !collapsedGroups.Contains(group?.EntityId ?? Guid.Empty);
SidebarRows.Add(new SidebarGroupHeader(
group?.EntityId,
group?.Label ?? "UNGROUPED",
members.Length,
expanded)
{
VaultBadge = group?.VaultBadge ?? string.Empty,
});
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 in no readable vault is not refused — it is treated as no group at all, which is
/// what the list already does with a dangling reference. See .
///
///
/// A group in a different vault to the host is refused, and said so. The cards are every readable
/// vault's since a group became a thing that can be shared, so this gesture can now be aimed across a
/// boundary that a save cannot cross: the host would keep an id only the other vault's holders can
/// resolve, and everybody in this one would see it filed under nothing. Refusing beats the two
/// alternatives — filing it anyway is the quiet wrong, and treating it as "no group" would unfile a host
/// somebody was plainly trying to file.
///
///
/// 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;
}
var card = request.GroupId is { } wanted
? Groups.FirstOrDefault(group => group.EntityId == wanted)
: null;
if (RefusesTheDrop(row, card))
{
return;
}
Guid? target = card?.EntityId;
if (row.Host.GroupId == target)
{
return;
}
var moved = row.Host with { GroupId = target };
var name = card?.Label ?? "no group";
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);
}
/// Whether a drop has to be turned down, saying why on the status line when it does.
/// The host that was dragged.
/// The group card it was dropped on, or null for the drop that unfiles a host.
///
/// Three refusals rather than one, and separated from the write so that the reason reaches the status
/// line before anything is encrypted. Every one of them is a thing the layer below would either refuse
/// or, worse, accept: a newer client's item re-encoded loses fields, a write under an open editor is a
/// save nobody asked for, and a group in another vault is an id half the readers cannot resolve.
///
private bool RefusesTheDrop(HostRowViewModel row, HostGroupRowViewModel? card)
{
if (row.IsReadOnly)
{
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
return true;
}
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
return true;
}
if (card is not null && card.VaultId != row.VaultId)
{
Status =
$"'{card.Label}' is in {card.VaultName} and '{row.Label}' is in {row.VaultName}. "
+ "A host can only be filed under a group in its own vault.";
return true;
}
return false;
}
/// Whether one host belongs on the grid at the level it is currently showing.
///
///
/// The grid holds one level of the tree, the way a directory pane holds one directory. A host
/// filed under a group is inside that group and nowhere else — it is not also on the screen the group's
/// own card sits on. While it was both, a card was a heading over a grid that already held everything
/// underneath it, so opening one could only ever take hosts away; a card is now the only place its hosts
/// are, which is what makes it a folder rather than a filter that happens to be switched off.
///
///
/// The find box is the exception, and deliberately. Typed into, it searches the open group and
/// everything under it — which, with nothing open, is every host in the keychain. A search that looked
/// only in the level it was typed on would answer "no host matches that" about a machine this keychain
/// has got, which is the one answer a search box must never give; and finding a machine without first
/// remembering where it was filed is most of what the box is for.
///
///
private bool Matches(HostRowViewModel row, Dictionary parents)
{
if (!MatchesFilters(row))
{
return false;
}
var open = GroupFilter?.EntityId;
var group = EffectiveGroupOf(row, parents);
return HostFilter.Trim().Length == 0 ? group == open : IsUnder(open, group, parents);
}
/// Whether one host survives the vault switches and the find box — where it sits aside.
///
/// 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 MatchesFilters(HostRowViewModel row)
{
// First, and ahead of the box, because it is not a search: a hidden vault's host is out however the
// grid is narrowed, and a count taken after this reflects what is on screen.
if (!IsVaultShown(row.VaultId))
{
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);
}
/// The group a host is actually drawn under, or none.
///
/// An id this vault has not got reads as no group at all, which is what the chip on the card, the
/// ungrouped heading and the group picker each already do with one — see
/// . It matters more here than in any of them: a host naming a group
/// deleted on another machine would otherwise sit at a level nothing on screen can open, and now that
/// the grid is one level at a time there would be nothing left that ever drew it.
///
private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary parents) =>
row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null;
/// Whether a group is the open one or lies somewhere beneath it.
///
/// Nothing open means everything is under it, which is what makes the search box reach the whole keychain
/// from the outermost level. The walk terminates because it is made against
/// , where anything caught in a cycle has already been promoted to a root.
///
private static bool IsUnder(Guid? open, Guid? group, Dictionary parents)
{
if (open is null)
{
return true;
}
var current = group;
while (current is { } id)
{
if (id == open)
{
return true;
}
current = parents.GetValueOrDefault(id);
}
return false;
}
/// 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, 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, 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.
///
///
/// Takes the whole server rather than its sync half, because the first thing a pass does is ask
/// which vaults there are — see . A pass that only synced the
/// vaults it already knew could never discover one somebody had just shared.
///
///
private async Task?> SyncOnceAsync(
IVaultServer server,
CancellationToken cancellationToken)
{
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
{
return null;
}
try
{
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
// other order would show a newly shared vault as an empty one until the minute after.
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
// 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(server.Sync, 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();
}
}
///
/// Re-reads which vaults this account can reach, and opens any that have become readable.
///
///
///
/// This is the whole of how a shared vault arrives. Sharing is two acts on two machines: the
/// person sharing wraps the vault key to the recipient, and the recipient's own client has to notice.
/// Without this the vault list stayed exactly as it was cached at sign-in, and a vault shared with
/// somebody appeared on their machine only if they happened to sign in through the browser again.
/// Everything else was already right, which is why it looked like sharing was broken rather than like a
/// list that was never re-read.
///
///
/// The server now says when this is worth doing — a vaults.changed notice wakes the pass, so the
/// vault turns up as it is shared rather than within the minute — but that only decides when.
/// This call is still what discovers the vault, on the notice and on every timed pass alike, because a
/// client with no socket has to arrive at the same place. See ADR 0012.
///
///
/// A failure is left to the caller, which treats it as the pass failing: the call is to the same server
/// the sync is about to use, so a refresh that cannot answer is not a state in which the sync would
/// have.
///
///
/// The shell is told only when the set actually changed. It rebuilds the tab strip's vault menu from
/// this list, and doing that on every quiet pass would rebuild a menu once a minute for nothing.
///
///
private async Task AdmitNewVaultsAsync(IAccountApi api, CancellationToken cancellationToken)
{
var before = session.Vaults.Count;
var admitted = await session.RefreshVaultsAsync(api, cancellationToken).ConfigureAwait(true);
if (admitted == 0 && session.Vaults.Count == before)
{
return;
}
VaultsChanged?.Invoke(this, EventArgs.Empty);
}
///
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
///
///
/// An event rather than a callback because the listener is the shell and the shell owns this object,
/// which is the same shape the connection events above use. What it is for is the tab strip's vault
/// menu: it is built from the session's vault list, so a vault admitted mid-session would otherwise be
/// on every screen and missing from the one control that can hide it.
///
internal event EventHandler? VaultsChanged;
///
/// 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);
var waits = new AutoSyncWaits();
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 WaitForWorkAsync(timer, waits, cancellationToken).ConfigureAwait(true))
{
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
}
catch (OperationCanceledException)
{
// Locking, or closing.
}
}
///
/// Waits for the timer to come round, or for the server to say there is something to fetch.
///
/// Whether to run a pass. False means the loop is over.
///
///
/// The timer is unchanged and is still what guarantees a pass. The socket only makes one
/// early, which is why nothing here treats its absence as a problem: no connection, a
/// server without the feature, a network that eats WebSockets, or a notice dropped under
/// backpressure all leave a loop that behaves exactly as it did before this existed. See ADR 0012.
///
///
/// Both waits are held across iterations, and that is load-bearing rather than an
/// optimisation. permits only one outstanding
/// WaitForNextTickAsync and throws on a second, and an abandoned channel read stays
/// registered and consumes the next notice written — which would silently lose exactly the wake-up
/// this is for. Whichever wait did not win is kept and awaited again.
///
///
private async Task WaitForWorkAsync(
PeriodicTimer timer,
AutoSyncWaits waits,
CancellationToken cancellationToken)
{
// Re-read every time, because signing out and back in replaces the connection — and with it
// the stream. A read still pending against the old one is left to be cancelled with it.
var stream = connection()?.Events;
if (!ReferenceEquals(stream, waits.Watching))
{
waits.Watching = stream;
waits.Notice = null;
}
waits.Tick ??= timer.WaitForNextTickAsync(cancellationToken).AsTask();
waits.Notice ??= stream?.ReadAsync(cancellationToken).AsTask();
if (waits.Notice is null)
{
var only = waits.Tick;
waits.Tick = null;
return await only.ConfigureAwait(true);
}
var first = await Task.WhenAny(waits.Tick, waits.Notice).ConfigureAwait(true);
if (ReferenceEquals(first, waits.Tick))
{
var ticked = waits.Tick;
waits.Tick = null;
return await ticked.ConfigureAwait(true);
}
// Observed so a faulted read does not go unhandled, and so a stream that has been disposed
// ends this wait rather than being asked again.
await waits.Notice.ConfigureAwait(true);
waits.Notice = null;
// A burst — one person's save is two items, and a colleague tidying a folder is a dozen —
// deserves one pass rather than one each.
await Task.Delay(NoticeDebounce, cancellationToken).ConfigureAwait(true);
while (stream!.TryRead(out _))
{
// Swallowed on purpose. Every notice means the same thing, which is what the pass about to
// run already does; what they say about *which* vault is not read, because a pass syncs
// every vault this session can reach anyway.
}
return true;
}
/// The two waits the background loop keeps alive between passes.
///
/// A class rather than three locals because has to hand them back
/// changed, and a method that took three ref parameters could not be async. See that
/// method for why abandoning either of them is a defect rather than a tidiness question.
///
private sealed class AutoSyncWaits
{
/// The pending timer tick, or null when the last one has been consumed.
internal Task? Tick { get; set; }
/// The pending read from the server's push channel.
internal Task? Notice { get; set; }
/// The stream was taken from, to notice a reconnection.
internal IVaultEventStream? Watching { get; set; }
}
/// 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;
///
/// Opens the pane about one host, on the card the pencil was pressed on.
///
///
/// The card, or null to open on whatever is already selected — which is what the context menu passes,
/// since the code-behind has already selected the card the pointer was over.
///
///
///
/// The pencil takes the row as a parameter rather than relying on the click having selected the card
/// first. A button inside a ListBoxItem handles the press itself, and whether the item is also
/// selected by it is the theme's business rather than this application's — so a command reading
/// would be opening the pane on whichever card happened to be lit, which on
/// the first click of a session is none of them.
///
///
/// It selects as well as opening, because the two have to agree: the pane is about one host and the grid
/// marks one host, and a pane opened on a card the grid has not lit is the disagreement
/// exists to prevent in the other direction.
///
///
[RelayCommand]
private void OpenHostPane(HostRowViewModel? row)
{
if (row is not null)
{
SelectedHost = row;
}
if (SelectedHost is null)
{
return;
}
IsHostPaneOpen = true;
}
///
/// Puts the drawer away, whichever of the three panels is in it.
///
///
/// One button for all three, because what it means is "give the grid its 304 pixels back" rather than
/// "cancel". An open editor is abandoned by it — the same thing its own CANCEL does, and the same thing
/// the arrow has to mean, since a header button that refused while a form was open would be a control
/// that is sometimes furniture and sometimes a decision. The selection survives: the card stays lit and
/// the pencil on it opens the pane again.
///
[RelayCommand]
private void CloseDrawer()
{
if (IsEditing)
{
CancelEditCommand.Execute(null);
}
if (IsEditingGroup)
{
CancelGroupEditCommand.Execute(null);
}
IsHostPaneOpen = false;
}
/// Starts a new host.
[RelayCommand]
private void NewHost()
{
IsAddSheetOpen = false;
if (AHostEditorIsInTheWay())
{
return;
}
editingEntityId = null;
// The keychain screen's picker is the default rather than the answer: the editor has a picker of its
// own from here on, and moving that one is what decides where this host lands. See
// EditorVaultChoices.
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();
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
BuildEditorVaultChoices(editingHostVaultId);
// A new host opens in the group the screen is already about — the card that is selected, or failing
// that the group whose contents are showing. Adding three machines to the group somebody has just
// made is the ordinary case, and since the grid holds one level at a time the alternative is worse
// than a default nobody chose: a host created inside a group and filed under none would vanish from
// the screen it was created on. Only when that group is in the vault this host is going into,
// though — the grid draws the active vault's groups, and inheriting one into a shared vault would
// file the host under something nobody else in it can resolve. Before the authentication picker,
// because whether there is a group to inherit from decides whether that one offers to.
BuildGroupChoices(GroupInEditingVault(GroupTarget?.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;
// The host's own vault, and it does not move: the two are encrypted under different keys, so
// saving anywhere else would fork it rather than move it. The picker is hidden for an existing
// host — see ShowsEditorVaultChoice — and is filled anyway so that it is not showing the last
// host's vault behind the panel.
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();
BuildEditorVaultChoices(editingHostVaultId);
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;
// So that saving lands on this host's own pane rather than closing the drawer. The editor is reached
// from that pane most of the time and the flag is already true; it is not when EDIT was chosen from
// the grid's context menu, and coming back to a collapsed column after a save reads as the edit
// having been thrown away. See IsHostPaneOpen.
IsHostPaneOpen = 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. Two vaults holding one each is not even confusing: the
/// card and the heading both say which vault, and they are as separate as two vaults can make them.
///
///
/// Writes to , which is the group's own vault on a rename and whatever
/// the picker said when the form opened on a create. Never the active vault, which is what it was while
/// the list held one vault's groups: a rename typed into a colleague's group would have created a second
/// group of that name in the personal vault and left theirs untouched.
///
///
[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(GroupEditorVaultId, entityId, group, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.HostGroups
.CreateAsync(GroupEditorVaultId, 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 group being acted on into the box, so saving renames it.
///
/// The group to edit, or null for whatever the screen is aimed at — the selected card, or the open group
/// when no card is selected. See . The desktop's card menu passes nothing and
/// means the card that was right-clicked, which opening the menu has already selected; the phone has no
/// card to select and passes the group its heading names.
///
///
/// Taking it as an argument is what keeps the phone from having to select a group in order to edit one.
/// A selection is shared with the host grid now — see — so a command
/// reachable only through would deselect the machine somebody was about to
/// connect to, on a screen that draws no group cards at all.
///
[RelayCommand]
private void EditGroup(HostGroupRowViewModel? group)
{
if ((group ?? GroupTarget) 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;
// The group's own vault, and no save moves it — the same rule an existing host's follows, and the
// same reason: the two are encrypted under different keys, so saving anywhere else would leave a
// copy behind rather than move anything. The picker is not drawn for an existing group at all, and
// the move that does work is MoveGroup, from the card's own menu.
editingGroupVaultId = row.VaultId;
editingGroupVaultName = row.HasVaultBadge ? row.VaultName : string.Empty;
GroupEditorLabel = row.Label;
GroupEditorDefaultPort = row.Group.DefaultPort;
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
// Before the parent picker, because that picker is one vault's and this is which one.
BuildGroupEditorVaultChoices(row.VaultId);
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.
///
///
/// The row is handed to rather than selected first, which it used to be. A group
/// selection now clears the host selection — the two grids share one mark — and the phone draws no group
/// cards, so selecting one here would have taken the highlight off the machine in the list with nothing
/// on screen to say where it had gone.
///
///
[RelayCommand]
private void EditGroupFromHeading(SidebarGroupHeader? header)
{
if (header?.GroupId is not { } groupId
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
{
return;
}
EditGroupCommand.Execute(row);
}
/// Starts a new group, inside whichever one the screen is showing.
///
///
/// 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.
///
///
/// The parent is defaulted after that clearing rather than inside it, and only here. This is the one
/// path that means "make one", and a group made inside the group that is open is what + NEW GROUP has to
/// mean now that the cards are one level of a tree — filed at the outermost level it would disappear
/// from the screen it was made on. The other two callers are a cancel and a save, and neither is asking
/// for a group anywhere.
///
///
/// The group that is open, and deliberately not the card that is selected — which is where this
/// differs from . A selected card is what EDIT and DELETE are aimed at; reading it
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
/// while the open group is the screen everybody can see they are on.
///
///
/// And in the open group's vault, which is where this differs from a second
/// time. A host opens on the standing "new items go to" preference and takes the open group only if
/// that group happens to be in the same vault; a group made inside another group is in that group's
/// vault by construction, because a parent in a second vault is a level half the readers cannot resolve.
/// Defaulting to the preference instead would answer "+ NEW GROUP inside PLATFORM" with a group
/// somewhere else and no parent — a form that silently dropped the one thing the button said.
///
///
[RelayCommand]
private void NewGroup()
{
IsAddSheetOpen = false;
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
{
return;
}
ClearGroupEditor();
// The open group's vault, where there is one and this session can write to it. A viewer of a shared
// vault gets the standing preference instead — and, with it, no parent, because the group they were
// looking inside belongs to a vault they cannot add to.
if (GroupFilter is { } open
&& GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == open.VaultId) is { } vault)
{
// Assigned rather than written to the field, so the parent picker is refilled for it: the
// handler below is what keeps the two in step, here and when the user moves the picker by hand.
GroupEditorSelectedVault = vault;
}
// Falls back to no parent, which is both what the picker's first entry says and what the phone always
// gets: it has no group cards and no way to go inside one, so nothing there is ever open.
GroupEditorSelectedParent =
GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == GroupFilter?.EntityId)
?? GroupChoice.None;
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.
///
///
/// One vault's candidates, and it is the vault this group is going into rather than the one it is
/// on screen beside. The list this picker used to be built from held one vault's groups, so the
/// restriction came free; it spans every readable vault now, and offering all of them would let somebody
/// file a shared group under a personal one — a parent nobody else can resolve, whose port and username
/// would then be lent to their hosts and to nobody else's. Which is the same failure the host editor's
/// group picker was fixed for, one level up.
///
///
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
{
GroupEditorParentChoices.Clear();
GroupEditorParentChoices.Add(GroupChoice.None);
foreach (var candidate in groupItems.Where(
entry => entry.VaultId == GroupEditorVaultId && entry.Item.EntityId != groupId))
{
var descends = HostInheritance
.Chain(candidate.Item.EntityId, groupsById)
.Any(entry => entry.Id == groupId);
if (!descends)
{
GroupEditorParentChoices.Add(
new GroupChoice(candidate.Item.EntityId, candidate.Item.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;
}
/// Refills the group editor's vault picker, landing on the vault the editor will write to.
///
private void BuildGroupEditorVaultChoices(Guid vaultId)
{
GroupEditorVaultChoices.Clear();
foreach (var choice in TargetVaults)
{
GroupEditorVaultChoices.Add(choice);
}
// Null where the group's vault is one this session cannot write — a shared vault this account is a
// viewer of. The picker is not drawn for an existing group anyway, and an empty box is a better
// answer than an option that would move the group if it were touched.
GroupEditorSelectedVault =
GroupEditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
OnPropertyChanged(nameof(ShowsGroupEditorVaultChoice));
}
///
/// Moves a half-typed group into the vault just chosen for it.
///
///
/// Only while creating, for the reason gives: a save cannot
/// move a group between vaults, so a path that reassigned this on a rename would write a second group
/// into the other vault and leave the original standing with the old name. Moving one is
/// , which does the re-seal and the tombstone this could not.
///
partial void OnGroupEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
if (value is null || EditingGroupId is not null || editingGroupVaultId == value.VaultId)
{
return;
}
editingGroupVaultId = value.VaultId;
// The parent picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
// to the vault just left, so it is dropped rather than carried: a group is one item in one vault,
// and there is nothing in the new one it could mean instead. The defaults below it are not touched,
// because a key or a credential may legitimately come from another vault, exactly as a host's may.
//
// Guid.Empty for the group being edited, because the guard above means there is not one: this only
// ever runs while creating, and a group that does not exist yet cannot be its own parent.
BuildGroupParentChoices(Guid.Empty, parentId: null);
}
/// 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;
// Back to the standing preference rather than to whatever the last group edited was in, which is
// the same clearing every other field here gets and matters more than any of them: a vault carried
// over from a colleague's group is where the next one would silently go.
editingGroupVaultId = TargetVaultId;
editingGroupVaultName = string.Empty;
// Before the parent choices, which are that vault's.
BuildGroupEditorVaultChoices(GroupEditorVaultId);
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 group being acted on should go, and what should become of its hosts.
///
///
/// The hosts are a second question rather than a stated consequence, and that is a reversal. This
/// used to say what would happen to them — they stay, and turn up under UNGROUPED — because a group's
/// deletion did not touch them at all. That answer was right for one of the two things people delete a
/// group for and wrong for the other: a heading being tidied away should leave its machines alone, and a
/// project that has been decommissioned is a shelf and everything on it. Nothing here can tell which of
/// the two it is looking at, so it is asked. See .
///
///
/// The count is still the reason this asks rather than acting, and it now counts twice over: it is the
/// number of machines about to be unfiled, or — with the box ticked — the number about to be destroyed.
/// A group with nothing under it gets no second question and no tick, because there is nothing for
/// either to be about.
///
///
/// Refused with a host editor open, which the deletion of a single host is not. The difference is
/// that this one writes to hosts: unfiling rewrites every machine under the heading, and doing that
/// beneath a half-typed edit of one of them is the save nobody asked for that
/// refuses for the same reason.
///
///
/// Aims where Edit does: at the selected card, which on the desktop is the one the menu opened on. See
/// .
///
///
[RelayCommand]
private void DeleteGroup()
{
if (GroupTarget is not { } row)
{
return;
}
// Only where it would write to a host. A heading with nothing under it is deleted with an editor
// open, because nothing on that form is about to be rewritten underneath it.
if (row.HostCount > 0 && AHostEditorIsInTheWay())
{
return;
}
// Disarms a move aimed at the same group, on the reasoning MoveHost uses in the other direction: two
// panels about one shelf, one of which destroys it, is not something to make anybody read carefully.
CancelMoveGroupCommand.Execute(null);
PendingDeletion = new DeletionRequest(
DeletionTarget.Group,
row.EntityId,
// Named with its vault where there is more than one, because two of them may hold a group of
// this name and the question is about exactly one of the two. The badge is already the answer
// the card gives; this is the same answer at the moment it decides something.
row.HasVaultBadge
? $"Delete the group '{row.Label}' in {row.VaultName}?"
: $"Delete the group '{row.Label}'?",
HowFarADeletionGoes("The group"),
row.HostCount switch
{
0 => string.Empty,
1 => "1 host is filed under it. Left as it is, the host stays and moves to UNGROUPED.",
_ => $"{row.HostCount} hosts are filed under it. Left as it is, they stay and move to "
+ "UNGROUPED.",
})
{
Choice = row.HostCount switch
{
0 => string.Empty,
1 => "Delete the host filed under it as well.",
_ => $"Delete the {row.HostCount} hosts filed under it as well.",
},
};
}
///
/// Queues the tombstone for the group that was agreed to, and settles what pointed at it.
///
///
///
/// Nothing is left pointing at the group. That is the change: the hosts filed under it are
/// rewritten — unfiled, or deleted where that was asked for — and the groups nested inside it take its
/// place in the tree rather than being orphaned into roots. It costs one write per item and it is the
/// honest cost of the question above it. The dangling reference the list used to absorb is still handled
/// everywhere it is read, because a group deleted on another machine still arrives that way.
///
///
/// Everything that points at it is written before the group's own tombstone. A crash in the middle
/// then leaves a heading standing over fewer things, which is visible and can simply be deleted again;
/// the other order leaves a group gone with its members still naming it, which is exactly the state this
/// exists to stop producing. It also matters against a merge: the group's tombstone can lose one, and if
/// it does, the unfiling has already been recorded on its own items rather than riding on it.
///
///
/// A read-only host is skipped and counted rather than rewritten. Unfiling it would re-encode a
/// payload this build cannot fully represent, which is the same refusal editing and moving one already
/// make — and the cost of skipping is a dangling id, which is the behaviour every reader here already
/// survives. Deleting one is not skipped: a tombstone re-encodes nothing.
///
///
/// The group.
/// Whether the hosts filed under it were agreed to go too.
/// Cancellation token.
private async Task DeleteGroupNowAsync(
Guid entityId,
bool takesTheHosts,
CancellationToken cancellationToken)
{
if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
{
Status = "That group is no longer here, so nothing was deleted.";
return;
}
// Its own vault's, both of them. A host in another vault naming this group is a reference the editor
// cannot make and the drop gesture refuses, so the only way to hold one is a hand-edited payload —
// and rewriting somebody else's vault because a group in this one went is worse than the dangle.
var filed = Hosts
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId == entityId)
.ToList();
var nested = Groups
.Where(group => group.VaultId == row.VaultId && group.Group.ParentId == entityId)
.ToList();
await RunAsync(
"Deleting…",
async () =>
{
var skipped = await ReleaseWhatPointedAtAsync(row, filed, nested, takesTheHosts, cancellationToken)
.ConfigureAwait(true);
// The row's own vault, which is why the row is re-found above rather than the id being
// enough: a tombstone written to the active vault would delete nothing and leave a
// colleague's group standing while this machine reported it gone.
await session.HostGroups
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
if (EditingGroupId == entityId)
{
EditingGroupId = null;
GroupEditorLabel = string.Empty;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted the group '{row.Label}'."
+ WhatBecameOfTheHosts(filed.Count, takesTheHosts, skipped);
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
///
/// Rewrites everything naming the group about to go, and says how many could not be.
///
///
/// Before the tombstone and in this order, which gives the reasons for.
///
/// How many hosts were left holding the reference because they could not be rewritten.
private async Task ReleaseWhatPointedAtAsync(
HostGroupRowViewModel row,
IEnumerable filed,
IEnumerable nested,
bool takesTheHosts,
CancellationToken cancellationToken)
{
var skipped = 0;
foreach (var host in filed)
{
if (takesTheHosts)
{
await session.Hosts
.DeleteAsync(host.VaultId, host.EntityId, cancellationToken)
.ConfigureAwait(true);
continue;
}
if (host.IsReadOnly)
{
skipped++;
continue;
}
await session.Hosts
.UpdateAsync(
host.VaultId,
host.EntityId,
host.Host with { GroupId = null },
cancellationToken)
.ConfigureAwait(true);
}
// Promoted to where the group they were under sat, rather than to the top level. A nested group whose
// parent goes has not been moved by anybody, and dropping it to a root would rearrange a tree on a
// delete that was about one heading.
foreach (var child in nested.Where(child => !child.IsReadOnly))
{
await session.HostGroups
.UpdateAsync(
child.VaultId,
child.EntityId,
child.Group with { ParentId = row.Group.ParentId },
cancellationToken)
.ConfigureAwait(true);
}
return skipped;
}
/// What the group's deletion did to the machines under it, said only when there were any.
///
/// The skipped count is named rather than folded into the total, because those hosts are the ones still
/// holding the deleted group's id: they show up under UNGROUPED like the rest, so nothing looks wrong,
/// and the sentence is the only place anybody is told that updating this client is what finishes the job.
///
private static string WhatBecameOfTheHosts(int filed, bool takesTheHosts, int skipped) =>
(filed, takesTheHosts, skipped) switch
{
(0, _, _) => string.Empty,
(1, true, _) => " Its host went with it.",
(_, true, _) => $" Its {filed} hosts went with it.",
(1, false, 0) => " Its host moved to UNGROUPED.",
(_, false, 0) => $" Its {filed} hosts moved to UNGROUPED.",
_ => $" Its {filed} hosts moved to UNGROUPED, except {skipped} written by a newer version of "
+ "DodoSSH — those still name the group that has gone. Update to unfile them.",
};
/// 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;
}
///
/// Opens the panel that asks which vault the selected host should move to.
///
///
///
/// A panel rather than a picker in the host editor, and the reason is what a move is underneath: the
/// item is re-sealed under another vault's key and the one it came from gets a tombstone — see
/// VaultItemRepository.MoveAsync. That is not a field of the host and must not be saved with
/// one, or somebody correcting a port would move a machine into a colleague's vault by leaving a
/// picker where they found it.
///
///
/// Refused for a host written by a newer client, exactly as editing one is: the move re-encodes the
/// payload, so a field this build cannot represent would be dropped on the way across.
///
///
[RelayCommand]
private void MoveHost()
{
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This host was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
BuildMoveVaultChoices(row.VaultId);
if (MoveVaultChoices.Count == 0)
{
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
// somebody in a team whose only other vault is read-only sees.
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
// Disarms a deletion aimed at the same host. Two questions about one machine, one of which
// destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingHostId = row.EntityId;
IsMovingHost = true;
Status = string.Empty;
}
/// Abandons the move panel.
[RelayCommand]
private void CancelMoveHost()
{
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
Status = string.Empty;
}
///
/// Moves the selected host into the chosen vault.
///
///
///
/// The group and the tags are left behind, and that is the whole of what makes this honest. Both
/// are items of the vault the host is leaving: the group picker in the editor offers one vault's groups
/// and the tag chips are drawn from one vault's tags, so a host carrying either across would point at
/// something the destination does not contain. On this machine it would still resolve — groups and tags
/// are resolved across every readable vault — and for everybody else in the destination it would dangle,
/// which means the mover and their colleagues would see two different hosts. Cleared and reported beats
/// carried and invisible.
///
///
/// The key or password binding is kept, and the difference is not inconsistency. Those genuinely
/// resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for —
/// so clearing them would take a working host and make it one that cannot connect. What it can do is say
/// when the binding is now in a different vault from the host, because that is exactly what the other
/// members of the destination will not be able to resolve.
///
///
/// The row is re-selected by its new id afterwards. A move that left the pane on a host that no longer
/// exists would read as the machine having been deleted.
///
///
[RelayCommand]
private async Task ConfirmMoveHostAsync(CancellationToken cancellationToken)
{
if (SelectedHost is not { } row || SelectedMoveVault is not { } target)
{
return;
}
var name = target.Name;
var dropped = WhatWasLeftBehind(row.Host);
var stranded = BindingOutside(row.Host, target.VaultId);
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
await RunAsync(
"Moving…",
async () =>
{
var entityId = await session.Hosts
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
}).ConfigureAwait(true);
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
// them and not the other shows the host twice or not at all until the next pass.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// What the move left behind, said only when it left something.
private static string WhatWasLeftBehind(HostSecret host) =>
(host.GroupId is not null, host.TagIds.Count > 0) switch
{
(true, true) => " Its group and tags were left behind — both belong to the vault it came from.",
(true, false) => " Its group was left behind — a group belongs to the vault it is in.",
(false, true) => " Its tags were left behind — a tag belongs to the vault it is in.",
_ => string.Empty,
};
///
/// The warning about a key or password that is not in the vault the host has moved to.
///
///
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
/// usually to put a copy of that key in the destination vault, which needs to know which key.
///
private string BindingOutside(HostSecret host, Guid vaultId)
{
if (host.SshKeyId is { } keyId
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
&& key.VaultId != vaultId)
{
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
+ "everybody else in this one will find that binding unresolvable.";
}
if (host.CredentialId is { } credentialId
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
&& credential.VaultId != vaultId)
{
return $" It still authenticates with the password '{credential.Label}', which is in another "
+ "vault — everybody else in this one will find that binding unresolvable.";
}
return string.Empty;
}
/// Fills the move panel's picker with every vault this session can write to but that one.
private void BuildMoveVaultChoices(Guid vaultId)
{
MoveVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveVaultChoices.Add(choice);
}
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
}
/// Every vault this session can write to except one, in the order a picker should offer them.
///
/// Shared by the host's picker and the group's rather than written twice. The order is the one every
/// vault picker in this application uses: your own first, then the shared ones by name — a list that
/// re-sorted itself per control would make "the second entry" mean something different on each.
///
private IEnumerable WritableVaultsBesides(Guid vaultId) =>
session.ReadableVaults
.Where(vault => vault.CanWrite && vault.VaultId != vaultId)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
///
/// Opens the panel that asks which vault the group — and everything under it — should move to.
///
///
///
/// The host's panel, one level up, and the reason it exists at all is that the host's did not go far
/// enough. Moving twenty machines into a shared vault one at a time meant twenty round trips through a
/// menu, and each one arrived stripped of the group it had been filed under, so the shelf had to be
/// rebuilt by hand on the other side. Moving the shelf is the operation people were actually attempting.
///
///
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
/// as is: this rewrites hosts.
///
///
[RelayCommand]
private void MoveGroup()
{
if (GroupTarget is not { } row || AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
{
return;
}
if (row.IsReadOnly)
{
Status = "This group was written by a newer version of DodoSSH. Moving it would re-encode it "
+ "here and lose what this build cannot read. Update first.";
return;
}
BuildMoveGroupVaultChoices(row.VaultId);
if (MoveGroupVaultChoices.Count == 0)
{
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
return;
}
// As MoveHost disarms a deletion aimed at the same host.
PendingDeletion = null;
movingGroupId = row.EntityId;
IsMovingGroup = true;
Status = string.Empty;
}
/// Abandons the group's move panel.
[RelayCommand]
private void CancelMoveGroup()
{
if (!IsMovingGroup)
{
return;
}
IsMovingGroup = false;
movingGroupId = null;
MoveGroupVaultChoices.Clear();
SelectedMoveGroupVault = null;
Status = string.Empty;
}
///
/// Moves the group, everything nested inside it and every host filed under any of them.
///
///
///
/// The whole subtree goes, and taking less than that was never coherent. A group's children are
/// items of the vault it is leaving: move the parent alone and they are left naming a tombstone, so they
/// surface as roots in the vault the user has just emptied — half a shelf here and half there, from one
/// gesture that said "move this". The hosts are the same argument and are the half the user asked about.
///
///
/// The groups go first, top down, and the hosts last. Each item is re-sealed under the
/// destination's key and takes a new id — see VaultItemRepository.MoveAsync — so nothing that
/// points at a group can be written until that group has landed and its new id is known. Top down for
/// the same reason one level up: a child's parent must already be over there. What an interruption
/// leaves is therefore hosts still in the vault they started in, under UNGROUPED, which is visible and
/// re-movable; the reverse order would leave hosts in the destination filed under nothing.
///
///
/// The parent is left behind and the tags are dropped, and both are the same rule the host's move
/// follows: a parent group and a tag are items of the vault being left, so a reference carried across
/// would resolve on this machine — groups and tags resolve over every readable vault — and dangle for
/// everybody else in the destination. The moved group becomes a root, which is what the trail will show,
/// and it is said in the sentence afterwards rather than discovered.
///
///
/// Keys and passwords are kept, on the host and on the group's own defaults, because those do
/// genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they
/// exist for. What is reported is a binding now outside the destination, since that is precisely what
/// the other holders of it will not be able to resolve.
///
///
[RelayCommand]
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
{
if (GroupTarget is not { } row
|| movingGroupId != row.EntityId
|| SelectedMoveGroupVault is not { } target)
{
return;
}
var subtree = SubtreeOf(row);
var moving = subtree.Select(group => group.EntityId).ToHashSet();
var filed = Hosts
.Where(host => host.VaultId == row.VaultId && host.Host.GroupId is { } id && moving.Contains(id))
.ToList();
// Asked once, over everything that is about to be re-encoded, and refused as a whole rather than
// half-done: a move that skipped the items it could not represent would file some of the shelf in one
// vault and leave the rest in the other, which is the state this operation exists to prevent.
if (subtree.Any(group => group.IsReadOnly) || filed.Any(host => host.IsReadOnly))
{
Status = "Something under this group was written by a newer version of DodoSSH. Moving it would "
+ "re-encode it here and lose what this build cannot read. Update first.";
return;
}
var name = target.Name;
var stranded = BindingsOutside(subtree, filed, target.VaultId);
var uprooted = row.Group.ParentId is not null;
var tagged = filed.Count(host => host.Host.TagIds.Count > 0);
CancelMoveGroupCommand.Execute(null);
await RunAsync(
"Moving…",
async () =>
{
var landed = await ReSealTheSubtreeAsync(subtree, filed, target.VaultId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the buttons aimed at a card that no
// longer exists would read as the shelf having been deleted rather than moved.
SelectedGroup = VisibleGroups.FirstOrDefault(card => card.EntityId == landed);
Status = $"Moved '{row.Label}' to {name}.{WhatCameAlong(subtree.Count, filed.Count)}"
+ $"{WhatStayedBehind(uprooted, tagged)}{stranded}";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
///
/// Re-seals a group, its nested groups and their hosts under another vault's key.
///
///
/// The order and what it costs an interruption are 's to explain. What
/// lives here is the id map every write after the first depends on: each item lands with an id of the
/// destination's making, so a parent's is looked up rather than reused, and a host's group is the entry
/// its old group left behind.
///
/// The group and its nested groups, each after its parent.
/// The hosts under any of them.
/// The vault they are all going to.
/// Cancellation token.
/// The id the group at the root of it has in its new vault.
private async Task ReSealTheSubtreeAsync(
List subtree,
IEnumerable filed,
Guid vaultId,
CancellationToken cancellationToken)
{
var root = subtree[0].EntityId;
var remapped = new Dictionary();
foreach (var group in subtree)
{
// Null for the root, whose parent is staying behind in the vault it came from; every other group
// in the list was discovered by its own parent, so that parent has already landed.
var parent = group.EntityId == root
? (Guid?)null
: remapped[group.Group.ParentId!.Value];
remapped[group.EntityId] = await session.HostGroups
.MoveAsync(
group.VaultId,
vaultId,
group.EntityId,
group.Group with { ParentId = parent },
cancellationToken)
.ConfigureAwait(true);
}
foreach (var host in filed)
{
await session.Hosts
.MoveAsync(
host.VaultId,
vaultId,
host.EntityId,
host.Host with
{
GroupId = remapped[host.Host.GroupId!.Value],
TagIds = TagSet.Empty,
},
cancellationToken)
.ConfigureAwait(true);
}
return remapped[root];
}
///
/// The group and every group nested under it, each one after the group it hangs from.
///
///
///
/// A breadth-first walk from the root, and the order is the whole reason it is one: every group but the
/// first is discovered by its parent, so a caller writing the list in order always has the
/// parent's new id in hand before it needs it.
///
///
/// Its own vault's only, because a child in another vault is a level half the readers cannot resolve and
/// the editor refuses to make one.
///
///
/// Cycle-safe, and it has to be. A group already found is not walked again and not added twice —
/// the same visited set every walk over this tree carries, for the reason
/// gives: two offline clients can each re-parent one group under
/// the other, and the pair that results was never shown to an editor. Without the set, a group that is
/// its own parent would be appended to this list for as long as there was memory to append to.
///
///
private List SubtreeOf(HostGroupRowViewModel root)
{
var ordered = new List { root };
var found = new HashSet { root.EntityId };
for (var index = 0; index < ordered.Count; index++)
{
var parent = ordered[index].EntityId;
foreach (var child in Groups.Where(
group => group.VaultId == root.VaultId && group.Group.ParentId == parent))
{
if (found.Add(child.EntityId))
{
ordered.Add(child);
}
}
}
return ordered;
}
/// What the move brought with it, said as the counts the user can check against the cards.
private static string WhatCameAlong(int groups, int hosts)
{
var nested = groups switch
{
1 => string.Empty,
2 => " with the group inside it",
_ => $" with the {groups - 1} groups inside it",
};
return hosts switch
{
0 when groups == 1 => string.Empty,
0 => $" It went{nested}, and no hosts were filed under any of them.",
1 => $" Its host came too{nested}.",
_ => $" Its {hosts} hosts came too{nested}.",
};
}
/// The two things a group cannot take across, said only where it had one.
private static string WhatStayedBehind(bool uprooted, int tagged) =>
(uprooted, tagged > 0) switch
{
(true, true) => " The group it was nested under stayed behind and the hosts' tags were dropped —"
+ " both belong to the vault it came from, so it now sits at the top level.",
(true, false) => " The group it was nested under stayed behind — a parent belongs to the vault it"
+ " is in — so it now sits at the top level.",
(false, true) => " The hosts' tags were left behind — a tag belongs to the vault it is in.",
_ => string.Empty,
};
///
/// The warning about keys or passwords that are not in the vault the group has moved to.
///
///
/// Counted and named to one, where names the only one there can be. A shelf
/// of twenty machines may strand five different keys, and twenty sentences is not a status line — but a
/// bare number is not actionable either, so the first is named and the rest are counted. Both the hosts'
/// own bindings and the groups' defaults are asked, because a group that lends a key is the one case
/// where a machine can be unable to connect without naming anything itself.
///
private string BindingsOutside(
IEnumerable groups,
IEnumerable hosts,
Guid vaultId)
{
var stranded = groups
.SelectMany(group => new[] { group.Group.DefaultSshKeyId, group.Group.DefaultCredentialId })
.Concat(hosts.SelectMany(host => new[] { host.Host.SshKeyId, host.Host.CredentialId }))
.OfType()
.Distinct()
.Select(LabelOfBindingOutside)
.OfType()
.ToList();
return stranded.Count switch
{
0 => string.Empty,
1 => $" It still authenticates with '{stranded[0]}', which is in another vault — everybody else "
+ "in this one will find that binding unresolvable.",
_ => $" It still authenticates with '{stranded[0]}' and {stranded.Count - 1} other key(s) or "
+ "password(s) in other vaults — everybody else in this one will find those bindings "
+ "unresolvable.",
};
string? LabelOfBindingOutside(Guid entityId) =>
(Keys.FirstOrDefault(key => key.EntityId == entityId) is { } key && key.VaultId != vaultId
? key.Label
: null)
?? (Credentials.FirstOrDefault(row => row.EntityId == entityId) is { } credential
&& credential.VaultId != vaultId
? credential.Label
: null);
}
/// Fills the group move panel's picker with every vault this session can write to but that one.
private void BuildMoveGroupVaultChoices(Guid vaultId)
{
MoveGroupVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveGroupVaultChoices.Add(choice);
}
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
}
/// 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;
}
// Read before the disarming below, which resets it. The answer belongs to the question that was on
// screen, and taking it afterwards would read whatever the next question starts from.
var takesTheHosts = DeletionTakesTheHostsToo;
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, takesTheHosts, 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.
///
/// Refills the host editor's vault picker, landing on the vault the editor will write to.
///
/// Filled from , which is already the readable-and-writable set and is kept
/// in step with the session by . The options are shared objects rather
/// than copies, so the two pickers show the same names without either one being able to move the other:
/// what they do not share is the selection.
///
private void BuildEditorVaultChoices(Guid vaultId)
{
EditorVaultChoices.Clear();
foreach (var choice in TargetVaults)
{
EditorVaultChoices.Add(choice);
}
// Null where the host's vault is one this session cannot write — a team vault this account is a
// viewer of. The picker is hidden for an existing host anyway, and leaving the box empty is a
// better answer than adding an option that would move the host if it were touched.
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
}
///
/// Moves a half-typed host into the vault just chosen for it.
///
///
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
/// the control. An existing host can change vaults — see — but not this
/// way and not as part of a save: reassigning it here on an edit would write the host into a second
/// vault and leave the original behind, which is a fork rather than a move.
///
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
{
if (value is null || editingEntityId is not null || editingHostVaultId == value.VaultId)
{
return;
}
editingHostVaultId = value.VaultId;
var authentication = EditorSelectedAuthentication;
// The group picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
// to the vault just left, so it is kept only if the new one has it too. Which in practice means it
// is dropped, because a group is one item in one vault.
BuildGroupChoices(GroupInEditingVault(EditorSelectedGroup?.EntityId));
// Rebuilt after it, because "inherit from group" is offered only to a host that is in one — and
// whether this one still is has just been decided above. The key and credential entries are not
// filtered by vault, unlike the groups: the key list spans every readable vault by design, and a
// host authenticating with a key from another vault is a thing this application already supports.
BuildAuthenticationChoices(
authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
grouped: EditorSelectedGroup?.EntityId is not null);
}
/// The group, if the vault being written to actually has it; otherwise none.
private Guid? GroupInEditingVault(Guid? groupId) =>
groupId is { } id
&& groupsByVault.TryGetValue(editingHostVaultId, out var groups)
&& groups.Any(choice => choice.EntityId == id)
? id
: null;
/// Refills the host editor's group picker for one vault.
/// The group to land on, or null for none.
///
///
/// The vault's own groups and no others — see . A vault this session cannot
/// read has no entry there and gets an empty list rather than the active vault's, which is the right
/// answer for a picker: there is nothing in it that this host could be filed under.
///
///
/// A group the vault no longer has keeps a placeholder entry, so that editing a host's port cannot
/// quietly unfile it.
///
///
private void BuildGroupChoices(Guid? groupId)
{
EditorGroupChoices.Clear();
EditorGroupChoices.Add(GroupChoice.None);
if (groupsByVault.TryGetValue(editingHostVaultId, out var groups))
{
foreach (var group in groups)
{
EditorGroupChoices.Add(group);
}
}
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)
{
// What is readable and what is not, because the two differ and the difference is the whole
// of what somebody in this state needs to know: the keys they hold still open everything
// written before the rotation, and nothing written since.
notes.Add(
"this keychain was rekeyed — you can still read what was here, and need the new key "
+ "before you can see anything written since");
}
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)
{
// One selection, across both grids. The two lists are drawn one above the other and they are marked
// the same way, so two lit cards read as two things chosen — and the buttons underneath them are two
// pairs, only one of which would act. Losing a selection leaves the other alone: a null here is what
// a filter matching nothing writes, and taking the mark off a group card because a search box
// emptied the grid beneath it would be this rule firing at something that is not a choice.
if (value is not null)
{
SelectedGroup = null;
}
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
OnPropertyChanged(nameof(ShowsConnectBar));
// Every field the drawer's detail pane draws. They are properties of the vault rather than of the
// row because two of them need the group chain read and one needs the keychain searched, and none of
// that can be done from inside an item template.
OnPropertyChanged(nameof(SelectedHostPortLabel));
OnPropertyChanged(nameof(SelectedHostPortIsInherited));
OnPropertyChanged(nameof(SelectedHostUsernameLabel));
OnPropertyChanged(nameof(SelectedHostUsernameIsInherited));
OnPropertyChanged(nameof(SelectedHostBindingLabel));
OnPropertyChanged(nameof(DrawerSubtitle));
// A selection no longer opens the drawer, but losing one still closes it — and takes the flag with
// it, so that the pane does not spring back open on the next card somebody merely selects. See
// IsHostPaneOpen.
if (value is null)
{
IsHostPaneOpen = false;
}
OnPropertyChanged(nameof(IsDrawerOpen));
OnPropertyChanged(nameof(IsShowingHostDetail));
OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(CanMoveSelectedHost));
// 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 move panel goes with the selection, as the deletion question does — and by entity id for the
// same reason DisarmIfAimedElsewhere compares them: a background pass replaces every row object in
// the list, so a panel closed on row identity would fold up once a minute under somebody who was
// still choosing a vault in it. A click onto a different host is the case that needs handling, and
// it is cleared rather than re-aimed: which vault to move to is a choice about the host it was
// asked for.
if (IsMovingHost && movingHostId != value?.EntityId)
{
CancelMoveHostCommand.Execute(null);
}
}
///
///
/// 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;
}
}
///
/// The other half of the shared selection; see . Clearing the host
/// takes the drawer with it, and that is the point rather than a side effect: a pane about one machine
/// cannot go on standing beside a marked group, since nothing on it would be about what is selected.
///
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
{
if (value is not null)
{
SelectedHost = null;
}
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
}
///
///
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
/// from , and that command has already dropped the selection before assigning
/// here — see the property.
///
///
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
/// against rather than against the group itself: a reload hands this a new row
/// object for the group already open, and taking a question away from under somebody because the row
/// behind it was replaced is exactly what compares ids to avoid.
///
///
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
{
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
RebuildGroupLevel();
RebuildVisibleHosts();
}
/// Folds the group's move panel away once the buttons under it point at something else.
///
/// By entity id and not by row, for the reason compares ids: every
/// row object in the list is replaced on every reload, so a panel closed on row identity would fold up
/// once a minute under somebody who was still choosing a vault in it. It is cleared rather than re-aimed,
/// because which vault to move to is a choice about the shelf it was asked for.
///
private void CloseTheGroupMovePanelIfAimedElsewhere()
{
if (IsMovingGroup && movingGroupId != GroupTarget?.EntityId)
{
CancelMoveGroupCommand.Execute(null);
}
}
partial void OnPendingDeletionChanged(DeletionRequest? value)
{
// Back to "keep them" on every question, including the one that disarms it. A tick is the answer to
// the group that was named in the sentence above it and to nothing else; carried into the next
// question it would delete a second group's machines on the strength of a decision about the first.
DeletionTakesTheHostsToo = false;
OnPropertyChanged(nameof(IsConfirmingDeletion));
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
OnPropertyChanged(nameof(ShowsHostActions));
OnPropertyChanged(nameof(ShowsHostPaneActions));
OnPropertyChanged(nameof(ShowsItemActions));
}
partial void OnEditingGroupIdChanged(Guid? value)
{
OnPropertyChanged(nameof(GroupSaveLabel));
// Which of the two things the group editor is doing, which its header says as well as its button —
// and, under it, the vault a group being renamed is in, which only a rename has an answer for.
OnPropertyChanged(nameof(DrawerTitle));
OnPropertyChanged(nameof(DrawerSubtitle));
}
///
/// 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.
///
/// This is where a hidden vault's keys and passwords come off the keychain — the table rather than
/// and themselves, which stay whole for the reason
/// gives. Tags and buckets are read from the active vault alone, which
/// cannot be hidden, so neither needs a test of its own.
///
///
private void RebuildVaultItems()
{
var selectedId = SelectedVaultItem?.EntityId;
VaultItems.Clear();
if (Section is VaultSection.All or VaultSection.Keys)
{
foreach (var key in Keys.Where(row => IsVaultShown(row.VaultId)))
{
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.Where(row => IsVaultShown(row.VaultId)))
{
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));
}