Files
DodoSSH/src/DodoSSH.Client.Shell/ViewModels/VaultViewModel.cs
T
jaap-jan 808a9a7fc1 Open a new host in the vault of the group it is being made in
+ NEW HOST decided two defaults separately and let them contradict each other.
The group came from the screen — the selected card, or failing that the group
whose contents are showing — and the vault came from the keychain screen's
standing "new items go to" preference. Inside a group belonging to any other
vault the two disagreed, and the group is what lost: GroupInEditingVault drops a
group the editor's vault has not got, on the sound reasoning that a host filed
under an id its readers cannot resolve looks unfiled to everybody but the person
who wrote it. So pressing the button while standing inside a team's PLATFORM
opened a form filed under nothing, bound for the personal vault, with no sentence
anywhere saying either thing had happened.

The vault now follows the group. A group lives in exactly one vault, so a host
that is to land in that group has to be sealed in that vault too — which is the
rule + NEW GROUP has followed for a parent since the cards became a tree, and the
comment there claiming this as a deliberate difference from the host's editor is
the one the code has now caught up with.

The filter stays, because there is one case left for it: the group's vault may be
one this session can read and not write, a team vault this account is a viewer of.
TargetVaults is the readable-and-writable set and is what decides here, so a
viewer keeps the standing preference and loses the group with it, rather than
opening an editor aimed at a save that cannot happen.

Both directions are tested, since one alone would not say which default wins:
standing in a shared vault's group, the editor opens on that vault with the group
selected and the host saves there; and with the preference pointed at the shared
vault while a personal-vault group is open, the group beats the picker somebody
set once.
2026-08-06 12:08:15 +02:00

11540 lines
516 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.InteropServices;
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;
/// <summary>
/// Anything the host sidebar's one list can hold.
/// </summary>
/// <remarks>
/// <para>
/// A marker, because the list is one <c>ListBox</c> 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.
/// </para>
/// <para>
/// The cost is that a heading is selectable as far as the <c>ListBox</c> is concerned, and it must not be as
/// far as anything else is: <c>Connect</c>, <c>Edit</c> and <c>Delete</c> all read the host selection. See
/// <c>VaultViewModel.SelectedSidebarRow</c>, which is where a heading click is turned back into whatever was
/// selected before it.
/// </para>
/// </remarks>
internal interface ISidebarRow;
/// <summary>One group heading, as a row in the host list.</summary>
/// <param name="GroupId">The group, or null for the heading ungrouped hosts fall under.</param>
/// <param name="Label">What the heading says.</param>
/// <param name="Count">How many hosts are under it, after the filter.</param>
/// <param name="IsExpanded">Whether its hosts are showing.</param>
internal sealed record SidebarGroupHeader(Guid? GroupId, string Label, int Count, bool IsExpanded)
: ISidebarRow
{
/// <summary>
/// The vault this heading's group lives in, or empty where there is only one vault to be in.
/// </summary>
/// <remarks>
/// 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 <see cref="HostRowViewModel.VaultBadge"/> is.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this heading has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
internal string Chevron => IsExpanded ? "▾" : "▸";
}
/// <summary>One group as it came out of a vault, with the vault it came out of.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="Item">The group, decrypted.</param>
/// <param name="VaultId">The vault it lives in.</param>
/// <param name="VaultName">That vault's display name.</param>
internal sealed record VaultGroupItem(VaultItem<HostGroupSecret> Item, Guid VaultId, string VaultName);
/// <summary>One group, as a row in the group list.</summary>
/// <remarks>
/// 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 <see cref="HostGroupSecret"/>
/// for why membership lives on the host — so it is passed in rather than read off the item.
/// </remarks>
internal sealed class HostGroupRowViewModel(VaultGroupItem group, int hostCount)
{
internal Guid EntityId => group.Item.EntityId;
/// <summary>Which vault this group lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => group.VaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => group.VaultName;
/// <summary>
/// The vault name to print on this card, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
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);
/// <summary>What the row says under the name.</summary>
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}
/// <summary>One step of the path into the groups, as a button in a breadcrumb trail.</summary>
/// <param name="Name">What the step is called.</param>
/// <param name="Group">The group it opens, or null for the step that shows every host again.</param>
/// <remarks>
/// The same shape the transfers screen's trail uses — see <c>CrumbViewModel</c> — 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
/// <see cref="VaultViewModel.GroupFilter"/> holds a row, and an id would only be looked up again.
/// </remarks>
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
/// <summary>An entry in the host editor's group picker.</summary>
/// <param name="EntityId">The group, or null for "no group".</param>
/// <param name="Label">What to show.</param>
/// <remarks>
/// A sentinel entry rather than a nullable selection, for the reason <see cref="AuthenticationChoice"/> 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.
/// </remarks>
internal sealed record GroupChoice(Guid? EntityId, string Label)
{
/// <summary>The "not in a group" entry, always first.</summary>
internal static GroupChoice None { get; } = new(null, "No group");
}
/// <summary>One host, and the group it is being filed under.</summary>
/// <param name="Host">The host to move.</param>
/// <param name="GroupId">The group it should end up in, or null for none.</param>
/// <remarks>
/// 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.
/// </remarks>
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
/// <summary>One snippet, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="SnippetSecret"/> 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.
/// </remarks>
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet, Guid vaultId, string vaultName)
{
internal Guid EntityId => snippet.EntityId;
/// <summary>Which vault this snippet lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
/// <remarks>
/// What makes a snippet shareable rather than private. The list spans every readable vault now, so an
/// edit and a deletion both have to return to the vault the snippet came out of — saving a team's
/// snippet into the active vault instead would leave the original untouched and put a second copy
/// somewhere only the person editing it can see.
/// </remarks>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
/// <summary>
/// The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <inheritdoc cref="HostRowViewModel.VaultBadge" path="/remarks" />
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
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);
/// <summary>
/// The command, on one line, for the list.
/// </summary>
/// <remarks>
/// Newlines become <c>⏎</c> 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.
/// </remarks>
internal string Preview => snippet.Secret.Command
.ReplaceLineEndings("\n")
.Replace("\n", " ⏎ ", StringComparison.Ordinal)
.Trim();
}
/// <summary>One tag, as a row in the keychain table.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class TagRowViewModel(VaultItem<TagSecret> 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);
/// <summary>What the row says under the name.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
}
/// <summary>
/// One tag in the host editor's picker, and whether this host wears it.
/// </summary>
/// <param name="EntityId">The tag item.</param>
/// <param name="Label">What to show on the chip.</param>
/// <param name="IsWorn">Whether the host being edited currently carries it.</param>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents.
/// A mutable <c>IsWorn</c> would need change notification on every chip in the vault to make one toggle
/// redraw.
/// </para>
/// </remarks>
internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn);
/// <summary>One host, as a row in the list.</summary>
/// <remarks>
/// Carries the decrypted <see cref="HostSecret"/> 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.
/// </remarks>
internal sealed partial class HostRowViewModel(
VaultItem<HostSecret> host,
ResolvedHost resolved,
IReadOnlyList<string> tagLabels,
Guid vaultId,
string vaultName) : ObservableObject, ISidebarRow
{
internal Guid EntityId => host.EntityId;
/// <summary>
/// Which vault this host lives in.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
internal string VaultName => vaultName;
/// <summary>
/// The vault name to print on this row, or empty when there is only one vault to be in.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal string VaultBadge { get; init; } = string.Empty;
/// <summary>Whether this row has a vault to name.</summary>
internal bool HasVaultBadge => VaultBadge.Length > 0;
/// <summary>
/// The name of the group this host is filed under, or empty for a host that is in none.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="SidebarGroupHeader"/> stays.
/// </para>
/// <para>
/// Resolved once when the list is built, like <see cref="TagLabels"/> 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.
/// <b>A group id that does not resolve leaves this empty</b> 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
/// <see cref="HostSecret.GroupId"/>.
/// </para>
/// </remarks>
internal string GroupLabel { get; init; } = string.Empty;
/// <summary>Whether this host is filed under a group the vault can name.</summary>
internal bool HasGroup => GroupLabel.Length > 0;
internal HostSecret Host => host.Secret;
/// <summary>
/// The same host with its group chain applied: what it dials, not what was typed into it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal ResolvedHost Resolved => resolved;
/// <summary>
/// The names of the tags this host wears, in the order the chips are drawn.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>A tag that does not resolve is left out rather than drawn as its id.</b> 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 <see cref="HostSecret.TagIds"/>.
/// </para>
/// </remarks>
internal IReadOnlyList<string> TagLabels => tagLabels;
/// <summary>Whether this host has any chips to draw.</summary>
internal bool HasTags => tagLabels.Count > 0;
internal string Label => host.Secret.Label;
/// <summary>
/// What this row dials, as one string.
/// </summary>
/// <remarks>
/// The <em>resolved</em> address, which is not decoration: <c>MainWindowViewModel.Rank</c> 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.
/// </remarks>
internal string Address => string.Create(
CultureInfo.InvariantCulture,
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
/// <remarks>
/// 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.
/// </remarks>
private string DisplayUsername =>
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
/// <summary>
/// The one line under the name on a card: the transport, the account, and every tag, comma-separated.
/// </summary>
/// <remarks>
/// <para>
/// <b>The address is deliberately not in it, and it used to be the whole line.</b> A card carrying
/// <c>root@10.0.4.12:22</c> 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 <c>HostsScreen.axaml</c>.
/// </para>
/// <para>
/// <b>"ssh" is a constant today and is printed anyway</b>, 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.
/// </para>
/// <para>
/// The account is the <em>resolved</em> 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.
/// </para>
/// </remarks>
internal string Summary => string.Join(", ", SummaryParts());
private IEnumerable<string> 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;
/// <summary>How this host authenticates, in one word.</summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <see cref="VaultViewModel.SelectedHostAuthenticationNote"/>, which has room for
/// a sentence rather than a word.
/// </para>
/// </remarks>
internal string Authentication => resolved.Binding.Kind switch
{
ResolvedBindingKind.Credential => "credential",
ResolvedBindingKind.SshKey => "key",
_ => "password",
};
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
/// <summary>
/// Whether a terminal is open on this host right now.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
/// would be a claim this application never checks.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isConnected;
/// <summary>
/// Whether this host is one of the ones the phone's action bar is about.
/// </summary>
/// <remarks>
/// <para>
/// The second thing on this row that is not a property of the host, and it is here for the same reason
/// <see cref="IsConnected"/> is: the list is rebuilt from scratch on every filter keystroke and every
/// background sync, so the row cannot be where the answer <em>lives</em> — it is written back onto the
/// new rows from the set of ids the vault holds. See <c>VaultViewModel.ChosenHostIds</c>.
/// </para>
/// <para>
/// Not the same as being selected, and the phone no longer draws the latter at all. A selection is what
/// a control lights; this is what a long press put a tick against, and every entry in the action bar's
/// menu acts on it rather than on whatever the list happens to have marked.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isChosen;
}
/// <summary>What a host can authenticate with.</summary>
internal enum AuthenticationKind
{
/// <summary>Typed at the moment of connecting.</summary>
/// <remarks>
/// 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 <see cref="Credential"/>, so a stored password is always
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
/// </remarks>
Typed,
/// <summary>An SSH key in this vault.</summary>
SshKey,
/// <summary>A username and password in this vault.</summary>
Credential,
/// <summary>
/// Whatever the group above this host says, or a typed password if it says nothing.
/// </summary>
/// <remarks>
/// The entry that arrived with inheritance, and the reason <see cref="HostSecret.AsksForPassword"/>
/// 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.
/// </remarks>
Inherited,
}
/// <summary>An entry in the host editor's authentication picker.</summary>
/// <param name="Kind">Which of the three ways this entry means.</param>
/// <param name="EntityId">The bound item's id, or null for a typed password.</param>
/// <param name="Label">What to show.</param>
/// <param name="Qualifier">
/// What kind of thing the label names, shown beside it. Empty for the typed-password entry, which is not a
/// thing in the vault.
/// </param>
/// <remarks>
/// <para>
/// <b>One picker for all three, not two pickers.</b> A host authenticates with a key <i>or</i> a stored
/// credential <i>or</i> a typed password, never two of them — <c>HostSecret.TryValidate</c> 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.
/// </para>
/// <para>
/// 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 <c>deploy</c> 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.
/// </para>
/// </remarks>
internal sealed record AuthenticationChoice(
AuthenticationKind Kind,
Guid? EntityId,
string Label,
string Qualifier)
{
/// <summary>The "type it each time" entry, always first.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal static AuthenticationChoice Typed { get; } =
new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty);
/// <summary>The "whatever the group says" entry, offered only to a host that is in one.</summary>
/// <remarks>
/// 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 <see cref="Typed"/> do the same thing, and two
/// entries that behave identically are two entries a user has to guess between.
/// </remarks>
internal static AuthenticationChoice Inherited { get; } =
new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty);
/// <summary>The "this group lends no binding" entry, first in the group editor's picker.</summary>
/// <remarks>
/// A sentinel rather than a null selection, for the reason <see cref="Typed"/> is one: a picker showing
/// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is
/// not <see cref="Typed"/> 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.
/// </remarks>
internal static AuthenticationChoice NoDefault { get; } =
new(AuthenticationKind.Typed, null, "No default binding", string.Empty);
/// <summary>An SSH key that is in the vault.</summary>
internal static AuthenticationChoice ForKey(Guid entityId, string label) =>
new(AuthenticationKind.SshKey, entityId, label, "SSH key");
/// <summary>A credential that is in the vault.</summary>
internal static AuthenticationChoice ForCredential(Guid entityId, string label) =>
new(AuthenticationKind.Credential, entityId, label, "credential");
/// <summary>
/// A stand-in for something the host names and the vault no longer has.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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");
}
/// <summary>One SSH key, as a row in the list.</summary>
/// <remarks>
/// <para>
/// Carries the decrypted <see cref="SshKeySecret"/>, as the host row carries its host, so opening the
/// editor or connecting with the key needs no second decryption.
/// </para>
/// <para>
/// <b>Nothing here exposes the private key to the view.</b> <see cref="Key"/> 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.
/// </para>
/// </remarks>
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
{
internal Guid EntityId => key.EntityId;
/// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal SshKeySecret Key => key.Secret;
internal string Label => key.Secret.Label;
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
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);
}
/// <summary>One stored credential, as a row in the list.</summary>
/// <remarks>
/// <para>
/// The same arrangement as <see cref="SshKeyRowViewModel"/>, for the same two reasons: the decrypted secret
/// travels with the row so opening the editor or connecting needs no second decryption, and
/// <b>nothing here exposes the password to the view</b>. <see cref="Credential"/> 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.
/// </para>
/// </remarks>
/// <summary>One bucket, as a row in the list.</summary>
internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store)
{
internal Guid EntityId => store.EntityId;
internal ObjectStoreSecret Store => store.Secret;
internal string Label => store.Secret.Label;
/// <summary>What the list shows under the name: where it is, never the keys.</summary>
/// <remarks>
/// 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.
/// </remarks>
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<CredentialSecret> credential,
Guid vaultId,
string vaultName)
{
internal Guid EntityId => credential.EntityId;
/// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
internal Guid VaultId => vaultId;
/// <summary>The vault's display name.</summary>
internal string VaultName => vaultName;
internal CredentialSecret Credential => credential.Secret;
internal string Label => credential.Secret.Label;
/// <summary>What the list shows under the name: the account, never the password.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The one-word marker a row shows for its sync state.</summary>
/// <remarks>
/// 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.
/// </remarks>
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,
};
}
/// <summary>
/// A terminal session that has just opened.
/// </summary>
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">The account and endpoint actually dialled.</param>
internal sealed class TerminalSessionEventArgs(
Guid attemptId,
uint sessionId,
string label,
string address) : EventArgs
{
/// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
internal Guid AttemptId { get; } = attemptId;
internal uint SessionId { get; } = sessionId;
internal string Label { get; } = label;
internal string Address { get; } = address;
}
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
/// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
{
/// <summary>Identifies this attempt for the whole of its life.</summary>
/// <remarks>
/// 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".
/// </remarks>
internal Guid AttemptId { get; } = attemptId;
internal string Label { get; } = label;
internal string Address { get; } = address;
}
/// <summary>A connection that was asked for and did not happen.</summary>
/// <param name="attemptId">The attempt that has just ended.</param>
/// <param name="reason">What to say about it, in the tab.</param>
/// <param name="isAwaitingAnAnswer">
/// Whether the connection stopped on a question rather than on a failure.
/// </param>
/// <remarks>
/// 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.
/// </remarks>
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
: EventArgs
{
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
internal Guid AttemptId { get; } = attemptId;
internal string Reason { get; } = reason;
/// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
}
/// <summary>A host somebody has asked to browse rather than to open a shell on.</summary>
/// <param name="host">The machine, as the row the list is holding.</param>
/// <remarks>
/// <para>
/// An event rather than a command, because the file screen is not the vault's. Which host to browse is a
/// decrypted item and so is this object's business; <em>going</em> to a screen is the shell's, and the
/// transfers view model it has to be handed to is a sibling of this one rather than a part of it. The same
/// division <see cref="TerminalSessionEventArgs"/> already draws for a shell.
/// </para>
/// <para>
/// It carries the row rather than an id, because the far side has to find the same host in its own copy of
/// the list — see <c>TransfersViewModel.Hosts</c> — and the entity id is what identifies it there.
/// </para>
/// </remarks>
internal sealed class HostFilesEventArgs(HostRowViewModel host) : EventArgs
{
internal HostRowViewModel Host { get; } = host;
}
/// <summary>A conflict, as a row.</summary>
internal sealed class ConflictRowViewModel(ConflictNotice notice)
{
internal Guid Id => notice.Id;
internal string Summary => notice.Summary;
/// <summary>
/// The overridden values, one line each.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
// 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;
}
/// <summary>
/// Which kind of item the vault column is showing.
/// </summary>
/// <remarks>
/// 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
/// <c>DodoSSH.Client.App.Layout.Tests</c>, which lays out one of these at a time because that is all a user
/// can ever see at once.
/// </remarks>
internal enum VaultSection
{
/// <summary>Every kind at once, which is where the screen opens.</summary>
All,
/// <summary>The SSH keys hosts authenticate with.</summary>
Keys,
/// <summary>The usernames and passwords they authenticate with instead.</summary>
/// <remarks>
/// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see
/// <c>KnownHostsScreen</c> — 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.
/// </remarks>
Credentials,
/// <summary>The tags hosts wear.</summary>
/// <remarks>
/// 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.
/// </remarks>
Tags,
/// <summary>S3-compatible buckets, and the keys that reach them.</summary>
/// <remarks>
/// 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.
/// </remarks>
Buckets,
}
/// <summary>What kind of thing a row in the vault table is.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal enum VaultItemKind
{
/// <summary>An SSH key.</summary>
Key,
/// <summary>A stored username and password.</summary>
Credential,
/// <summary>An S3-compatible bucket.</summary>
ObjectStore,
/// <summary>A tag a host can wear.</summary>
Tag,
}
/// <summary>
/// One row of the vault table, whatever kind of item it is.
/// </summary>
/// <remarks>
/// <para>
/// The table has one shape and a <c>TYPE</c> 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.
/// </para>
/// <para>
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> 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.
/// </para>
/// </remarks>
/// <param name="Kind">Which of the three lists this came from.</param>
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
/// <param name="Name">The label the user gave it.</param>
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
/// <param name="Detail">What is known about it.</param>
/// <param name="Badge">Its sync state, or empty.</param>
/// <param name="HasUnsyncedChanges">
/// 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.
/// </param>
/// <summary>One vault, as an option in the "file this into" picker.</summary>
/// <param name="VaultId">The vault.</param>
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
{
/// <summary>
/// What the picker shows.
/// </summary>
/// <remarks>
/// 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 <c>VaultsViewModel</c>.
/// </remarks>
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)
{
/// <summary>Whether this row has anything to say about its sync state.</summary>
internal bool HasBadge => Badge.Length > 0;
}
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
internal enum DeletionTarget
{
/// <summary>A host, from the sidebar beside the terminal.</summary>
Host,
/// <summary>
/// Every host the phone's action bar has a tick against.
/// </summary>
/// <remarks>
/// A member of its own rather than <see cref="Host"/> asked several times, because the question is asked
/// once and has to name a count — "delete 6 hosts?" is a different sentence from six copies of "delete
/// prod-db?", and the second of those is not a confirmation anybody reads. It is also the one deletion
/// here whose scope is not one item, which is why <c>DeletionRequest.EntityId</c> is
/// <see cref="Guid.Empty"/> for it and the set itself is what gets walked.
/// </remarks>
ChosenHosts,
/// <summary>An SSH key, from the vault screen.</summary>
Key,
/// <summary>A stored password, from the vault screen.</summary>
Credential,
/// <summary>A group, from the panel beside the host sidebar.</summary>
Group,
/// <summary>A bucket, from the vault screen.</summary>
ObjectStore,
/// <summary>A tag, from the vault screen.</summary>
Tag,
}
/// <summary>
/// A deletion that has been asked for and not yet agreed to.
/// </summary>
/// <remarks>
/// <para>
/// A state rather than a dialog, on the same reasoning as the sign-out confirmation — see
/// <c>MainWindowViewModel.IsConfirmingSignOut</c>. What makes it worth having at all is that the sentences
/// below are <em>computed</em>: 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <param name="Target">Which list to delete from.</param>
/// <param name="EntityId">The item the question is about.</param>
/// <param name="Question">The question itself, naming the item.</param>
/// <param name="Consequence">Where it goes, and how far.</param>
/// <param name="Usage">
/// 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.
/// </param>
internal sealed record DeletionRequest(
DeletionTarget Target,
Guid EntityId,
string Question,
string Consequence,
string Usage)
{
/// <summary>Whether anything depends on the item, which is the line worth reading twice.</summary>
internal bool HasUsage => Usage.Length > 0;
/// <summary>
/// The second question this deletion has to ask, or empty where it has none.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="VaultViewModel.DeletionTakesTheHostsToo"/>, which is the answer.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal string Choice { get; init; } = string.Empty;
/// <summary>Whether this deletion has a second question to put.</summary>
internal bool HasChoice => Choice.Length > 0;
}
/// <summary>
/// Gets this machine online, if it can be.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);
/// <summary>
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <see cref="AutoSyncAsync" />.
/// </para>
/// <para>
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
/// shell for a connection rather than reading one it was handed at unlock — see
/// <see cref="ServerReconnectHandler" /> — 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.
/// </para>
/// <para>
/// <b>Everything a connection needs is in the vault.</b> 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.
/// </para>
/// <para>
/// <b>A key or a password belongs to a host.</b> 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
/// <c>HostSecretCodec.CurrentSchemaVersion</c>.
/// </para>
/// </remarks>
/// <param name="copyToClipboard">
/// Puts one line of text on the system clipboard, or null where there is none.
/// <para>
/// A delegate rather than Avalonia's <c>IClipboard</c>, for the reason <c>SignInHandler</c> is one: the
/// clipboard is reached through <c>TopLevel.GetTopLevel(control)</c>, 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.
/// </para>
/// </param>
/// <param name="visibility">
/// Which vaults this machine has been asked to leave off the screens, or null where nothing is hidden.
/// <para>
/// Read by <see cref="IsVaultShown"/> 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.
/// </para>
/// </param>
internal sealed partial class VaultViewModel(
VaultSession session,
TerminalWorkspace workspace,
VaultKnownHostStore knownHosts,
Func<IVaultServer?> connection,
ServerReconnectHandler? reconnect = null,
Func<string, Task>? copyToClipboard = null,
ConnectionRecorder? connectionLog = null,
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
{
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Unchanged by the push channel, and deliberately so. The socket makes a pass <em>early</em>; 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 <see cref="WaitForWorkAsync"/> and ADR 0012.
/// </para>
/// </remarks>
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
/// <summary>How long a pushed notice waits, in case more are on their way.</summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly TimeSpan NoticeDebounce = TimeSpan.FromMilliseconds(250);
/// <summary>How often the logs are pruned, at most.</summary>
/// <remarks>
/// 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.
/// </remarks>
private static readonly TimeSpan PruneInterval = TimeSpan.FromHours(6);
/// <summary>When the logs were last pruned, or null when this session has not pruned yet.</summary>
private DateTimeOffset? lastPruned;
/// <summary>Serialises every synchronisation pass, whether a button pressed it or a timer did.</summary>
private readonly SemaphoreSlim syncGate = new(1, 1);
/// <summary>
/// The groups as they came out of the vaults, before the host counts are attached.
/// </summary>
/// <remarks>
/// 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 <see cref="RebuildGroups"/>. 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.
/// </remarks>
private IReadOnlyList<VaultGroupItem> groupItems = [];
/// <summary>
/// The same groups by id, which is the shape the inheritance walk takes.
/// </summary>
/// <remarks>
/// Cached beside <see cref="groupItems"/> 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.
/// </remarks>
private Dictionary<Guid, HostGroupSecret> groupsById = [];
/// <summary>
/// Every readable vault's groups, kept apart by the vault they live in.
/// </summary>
/// <remarks>
/// What the host editor's group picker is built from, and it has to be per vault rather than the one
/// list <see cref="Groups"/> 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 <see cref="BuildGroupChoices"/>.
/// </remarks>
private Dictionary<Guid, List<GroupChoice>> groupsByVault = [];
/// <summary>
/// The tags as they came out of the vault, before the host counts are attached.
/// </summary>
/// <inheritdoc cref="groupItems" path="/remarks" />
private IReadOnlyList<VaultItem<TagSecret>> tagItems = [];
/// <summary>
/// The same tags by id, which is what a host's <see cref="HostSecret.TagIds"/> resolves through.
/// </summary>
/// <remarks>
/// Every readable vault's, like <see cref="groupsById"/> 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.
/// </remarks>
private Dictionary<Guid, TagSecret> tagsById = [];
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
private readonly HashSet<Guid> collapsedGroups = [];
private CancellationTokenSource? autoSync;
private Task? autoSyncLoop;
private bool disposed;
/// <summary>
/// The open vault this view model is showing.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal VaultSession Session => session;
/// <summary>Whether a vault's items are drawn on the screens that list them.</summary>
/// <remarks>
/// <para>
/// <b>The one place the visibility preference is read, and it is read only by the projections a person
/// looks at</b> — <see cref="Matches"/>, <see cref="RebuildVaultItems"/>, the group and tag card counts,
/// and the pin list. Every <c>Reload*Async</c> above stays complete, and that is not tidiness:
/// </para>
/// <list type="bullet">
/// <item>
/// <see cref="Keys"/> and <see cref="Credentials"/> are what <see cref="TryBuildAuthentication"/>
/// 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.
/// </item>
/// <item>
/// <see cref="groupsById"/> decides what port a host dials. Hiding a vault must never change that.
/// </item>
/// <item>
/// The dialled-endpoint set in <see cref="ReloadKnownHostsAsync"/> decides which pins are described as
/// unused, which is a hint that invites deleting trust.
/// </item>
/// </list>
/// <para>
/// Nothing outside those projections asks. Sync walks <c>session.ReadableVaults</c>, the keyring is
/// filled from the same list, and the trust the SSH handshake consults is read straight out of
/// <c>VaultKnownHostStore</c> — none of which has ever come through this type.
/// </para>
/// </remarks>
internal bool IsVaultShown(Guid vaultId) => visibility?.IsShown(vaultId) ?? true;
/// <summary>Whether anything at all is being kept off the screens.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool HasHiddenVaults =>
visibility is not null && session.ReadableVaults.Any(vault => visibility.IsHidden(vault.VaultId));
/// <summary>The hosts to show, unpushed local state included.</summary>
/// <remarks>
/// 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. <see cref="VisibleHosts"/> is the filtered view.
/// </remarks>
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
/// <remarks>
/// <para>
/// A second collection rather than a filtered view over the first, because the grid has to be one
/// <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
/// terminal gives it back, and neither of those survives being split across several lists.
/// </para>
/// <para>
/// What "one level" means, and why the box escapes it, is in <see cref="Matches"/>. It is the desktop's
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
/// </para>
/// </remarks>
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
/// <summary>Whether the grid has anything to draw.</summary>
/// <remarks>
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
/// </remarks>
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
/// <summary>
/// What the hosts grid says when it has nothing in it.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <see cref="Matches"/>.
/// </para>
/// </remarks>
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.",
};
/// <summary>
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
/// </summary>
/// <remarks>
/// <para>
/// <b>A vault with no groups produces no headings at all</b>, so this is <see cref="VisibleHosts"/> 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.
/// </para>
/// <para>
/// Kept beside <see cref="VisibleHosts"/> 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 <c>Count</c> silently included headings would be wrong in each of them.
/// </para>
/// </remarks>
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
/// <remarks>
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
/// from; <see cref="VisibleGroups"/> is the desktop's one level of it.
/// </remarks>
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
/// <summary>
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
/// groups when none is.
/// </summary>
/// <remarks>
/// <para>
/// <see cref="Groups"/> is to this what <see cref="Hosts"/> is to <see cref="VisibleHosts"/>: 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.
/// </para>
/// <para>
/// 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 <see cref="HostGroupSecret.ParentId"/>.
/// </para>
/// </remarks>
internal ObservableCollection<HostGroupRowViewModel> VisibleGroups { get; } = [];
/// <summary>
/// The path down to the group that is open: every host, then each group above it, then it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<GroupCrumbViewModel> GroupTrail { get; } = [];
/// <summary>Whether there are any group cards to draw at this level.</summary>
/// <remarks>
/// Separate from <see cref="HasGroups"/>, 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.
/// </remarks>
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
/// <remarks>
/// 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. <c>SnippetsViewModel</c> is the filter and the editor over the top.
/// </remarks>
internal ObservableCollection<SnippetRowViewModel> Snippets { get; } = [];
/// <summary>
/// What the sidebar's one group heading says.
/// </summary>
/// <remarks>
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
/// presented as structure.
/// <para>
/// 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.
/// </para>
/// </remarks>
internal string HostsHeading =>
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
/// <summary>The SSH keys to show, unpushed local state included.</summary>
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
/// <summary>The stored credentials to show, unpushed local state included.</summary>
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
/// <summary>The buckets to show, unpushed local state included.</summary>
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<TagRowViewModel> Tags { get; } = [];
/// <summary>The host keys this user has approved.</summary>
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
internal string VaultName =>
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
/// <summary>
/// The vaults a new item may be filed into: readable, and writable by this account.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
/// <summary>
/// Where the next new item goes.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
/// <summary>Whether there is more than one vault to choose between.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool HasVaultChoice => TargetVaults.Count > 1;
[ObservableProperty]
private VaultChoiceViewModel? selectedTargetVault;
/// <summary>
/// The host card that is selected, or null when none is.
/// </summary>
/// <remarks>
/// The application's selection, which everything that acts on a host reads — connecting, editing,
/// deleting. <b>It shares one selection with <see cref="SelectedGroup"/>:</b> selecting a host takes the
/// mark off a group card and the other way about. See <see cref="OnSelectedHostChanged"/>.
/// </remarks>
[ObservableProperty]
private HostRowViewModel? selectedHost;
/// <summary>
/// What the sidebar's <c>ListBox</c> has selected, which may be a heading.
/// </summary>
/// <remarks>
/// <para>
/// 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; <see cref="SelectedHost"/>
/// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on
/// reading that one.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[ObservableProperty]
private ISidebarRow? selectedSidebarRow;
/// <summary>
/// The group card that is selected, or null when none is.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="OpenGroup"/>.
/// </para>
/// <para>
/// <b>It shares one selection with <see cref="SelectedHost"/>.</b> Setting either clears the other, so
/// exactly one card on the screen is ever lit. See <see cref="OnSelectedHostChanged"/>.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? selectedGroup;
/// <summary>
/// The group that is open: the one whose contents the screen is showing, or null for every host.
/// </summary>
/// <remarks>
/// <para>
/// Set by <see cref="OpenGroup"/> 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.
/// </para>
/// <para>
/// <b>Separate from <see cref="SelectedGroup"/>, and no longer sets it.</b> 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; <see cref="GroupTarget"/> is where the two meet.
/// </para>
/// </remarks>
[ObservableProperty]
private HostGroupRowViewModel? groupFilter;
/// <summary>
/// Opens a group, or every host when handed null.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenGroup(HostGroupRowViewModel? group)
{
SelectedGroup = null;
GroupFilter = group;
}
/// <summary>What the group name box holds, for both creating and renaming.</summary>
[ObservableProperty]
private string groupEditorLabel = string.Empty;
/// <summary>
/// The port hosts in this group take when they state none, empty for no default.
/// </summary>
/// <remarks>
/// 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".
/// </remarks>
[ObservableProperty]
private int? groupEditorDefaultPort;
/// <summary>The user hosts in this group log in as when they state none.</summary>
/// <inheritdoc cref="GroupEditorDefaultPort" path="/remarks" />
[ObservableProperty]
private string groupEditorDefaultUsername = string.Empty;
/// <summary>
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
/// </summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
/// <remarks>
/// One vault's, and the one this group is going into — see <see cref="BuildGroupParentChoices"/>. 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.
/// </remarks>
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
[ObservableProperty]
private GroupChoice? groupEditorSelectedParent;
/// <summary>
/// Which vault a group being created will be filed into.
/// </summary>
/// <remarks>
/// <para>
/// 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 <em>by saving</em>, 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.
/// </para>
/// <para>
/// Changed afterwards it can be, by <see cref="MoveGroup"/>, 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.
/// </para>
/// <para>
/// Filled from <see cref="TargetVaults"/>, so it offers what every other "file this into" control does:
/// vaults this session can both read and write.
/// </para>
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> GroupEditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? groupEditorSelectedVault;
/// <summary>Whether the editor should be asking which vault this group goes into.</summary>
/// <inheritdoc cref="ShowsEditorVaultChoice" path="/remarks" />
internal bool ShowsGroupEditorVaultChoice =>
EditingGroupId is null && GroupEditorVaultChoices.Count > 1;
/// <summary>What the group's authentication picker offers, for the hosts beneath it.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> GroupEditorAuthenticationChoices { get; } = [];
[ObservableProperty]
private AuthenticationChoice? groupEditorSelectedAuthentication;
/// <summary>The group being renamed, or null when the box would create one.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsGroupEditorVaultChoice))]
private Guid? editingGroupId;
/// <summary>
/// Which vault the group editor will write to, or null until an editor has been opened.
/// </summary>
/// <remarks>
/// Nullable where <see cref="editingHostVaultId"/> 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. <see cref="GroupEditorVaultId"/> 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.
/// </remarks>
private Guid? editingGroupVaultId;
/// <summary>The vault the group editor writes to, whether or not one was ever chosen for it.</summary>
private Guid GroupEditorVaultId => editingGroupVaultId ?? TargetVaultId;
/// <summary>
/// The name of the vault a group being renamed is in, for the drawer's header, or empty.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>The tag being edited, or null when creating.</summary>
private Guid? editingTagId;
/// <summary>Which vault the tag editor will write to.</summary>
/// <inheritdoc cref="editingHostVaultId" path="/remarks" />
private Guid editingTagVaultId;
[ObservableProperty]
private KnownHostRowViewModel? selectedKnownHost;
/// <summary>
/// What the sidebar's filter box holds.
/// </summary>
/// <remarks>
/// 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 <see cref="HostGroups"/> only: the selection, the
/// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
/// unusable — only unlisted.
/// </remarks>
[ObservableProperty]
private string hostFilter = string.Empty;
[ObservableProperty]
private string status = string.Empty;
[ObservableProperty]
private int pendingChanges;
/// <summary>
/// Whether the last synchronisation attempt failed to reach the server.
/// </summary>
/// <remarks>
/// <para>
/// Holding a connection object is not the same as being able to reach anything, and this is the
/// difference. <c>IVaultServer</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[ObservableProperty]
private bool lastSyncFailed;
[ObservableProperty]
private int unreadableItems;
[ObservableProperty]
private bool isBusy;
// ---- Which kind of item is showing ----
/// <remarks>
/// Settable, and the markup deliberately does not bind a selector's selection to it. A selection binding
/// would move before <see cref="ShowSection" /> 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.
/// </remarks>
[ObservableProperty]
private VaultSection section;
/// <summary>Whether every kind is showing at once.</summary>
internal bool ShowsAll => Section is VaultSection.All;
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsKeys => Section is VaultSection.Keys;
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsCredentials => Section is VaultSection.Credentials;
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsBuckets => Section is VaultSection.Buckets;
/// <inheritdoc cref="ShowsAll" />
internal bool ShowsTags => Section is VaultSection.Tags;
/// <summary>
/// The rows the vault table is showing, for whichever category is selected.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
/// <summary>
/// The selected row of the vault table.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private VaultItemRowViewModel? selectedVaultItem;
/// <summary>What the vault screen's header calls the category showing.</summary>
internal string SectionTitle => Section switch
{
VaultSection.Keys => "SSH KEYS",
VaultSection.Credentials => "PASSWORDS",
VaultSection.Buckets => "BUCKETS",
VaultSection.Tags => "TAGS",
_ => "ALL ITEMS",
};
/// <summary>
/// What the header says about the category, under its name.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
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";
}
}
/// <summary>Everything on this screen, which is the keychain less the hosts and the pins.</summary>
/// <remarks>
/// 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.
/// </remarks>
/// <summary>What the ALL chip counts, which is everything ALL actually shows.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal int TotalItemCount =>
Keys.Count + Credentials.Count + ObjectStores.Count + Tags.Count;
internal bool HasVaultItems => VaultItems.Count > 0;
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
/// <summary>Whether the selected row is one with an editor behind it.</summary>
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
/// <summary>Whether the selected row is an SSH key, which is the only kind with a public half to copy.</summary>
internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;
/// <summary>What the detail pane calls the block under the chips.</summary>
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
{
VaultItemKind.Credential => "ACCOUNT",
_ => "WHAT IS STORED",
};
internal bool HasUnreadableItems => UnreadableItems > 0;
/// <remarks>
/// 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.
/// </remarks>
internal string UnreadableSummary => UnreadableItems == 1
? "1 item will not decrypt"
: $"{UnreadableItems} items will not decrypt";
/// <summary>What an empty category says instead of showing an empty grid.</summary>
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(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
private bool isEditing;
/// <summary>
/// Whether the group editor is open as a surface of its own.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>The desktop sets it too now.</b> Its group editor used to be a bar under the group list that was
/// always on screen, which is why <see cref="EditingGroupId"/> 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
/// <c>+ NEW GROUP</c> or by <c>EDIT</c>, and "is it raised" is exactly this. So
/// <see cref="AGroupEditorIsInTheWay"/> now answers for both heads rather than being false on one of
/// them.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
[NotifyPropertyChangedFor(nameof(ShowsHostList))]
[NotifyPropertyChangedFor(nameof(DrawerTitle))]
[NotifyPropertyChangedFor(nameof(DrawerSubtitle))]
private bool isEditingGroup;
/// <summary>
/// Whether the hosts screen's right-hand drawer is open.
/// </summary>
/// <remarks>
/// <para>
/// The drawer is where everything that is about <em>one</em> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// It occupies a column of the hosts screen rather than floating over it, which is the occlusion rule
/// rather than a preference — see <c>MainWindow.axaml</c>. 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.
/// </para>
/// </remarks>
internal bool IsDrawerOpen =>
IsEditing || IsEditingGroup || (IsHostPaneOpen && SelectedHost is not null);
/// <summary>Whether the drawer is showing what a host is, rather than one of the two editors.</summary>
internal bool IsShowingHostDetail =>
!IsEditing && !IsEditingGroup && IsHostPaneOpen && SelectedHost is not null;
/// <summary>
/// Whether the pane about one host has been asked for.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>A selection no longer opens the drawer, and this flag is the difference.</b> It used to:
/// <see cref="IsDrawerOpen"/> read <c>SelectedHost is not null</c>, 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.
/// </para>
/// <para>
/// It <em>follows</em> 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.
/// </para>
/// <para>
/// Cleared when the selection goes, in <see cref="OnSelectedHostChanged"/>. 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.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isHostPaneOpen;
/// <summary>
/// Whether the detail pane's own actions are showing: CONNECT, and the menu holding EDIT and DELETE.
/// </summary>
/// <remarks>
/// 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
/// <see cref="ShowsHostActions"/> 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.
/// <para>
/// 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.
/// </para>
/// </remarks>
internal bool ShowsHostPaneActions =>
IsShowingHostDetail && !IsConfirmingHostDeletion && !IsMovingHost;
/// <summary>
/// Whether the panel asking which vault to move the selected host to is up.
/// </summary>
/// <remarks>
/// 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 <see cref="MoveHost"/>.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
private bool isMovingHost;
/// <summary>Which host the open move panel is about. Null when it is closed.</summary>
/// <remarks>
/// Held rather than read off the selection, so the panel survives a reload replacing every row object —
/// see <see cref="OnSelectedHostChanged"/>, which is the only thing that reads it.
/// </remarks>
private Guid? movingHostId;
/// <summary>Where the selected host could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveVault;
/// <summary>
/// Whether the selected host has anywhere to move to.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal bool CanMoveSelectedHost =>
SelectedHost is { IsReadOnly: false } row
&& session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != row.VaultId);
/// <summary>
/// Whether the open move panel has a key or password it could bring with the host.
/// </summary>
/// <remarks>
/// <para>
/// Read against the vault in the picker rather than against the host, so choosing a different
/// destination re-asks the question: a key already sitting in the vault the host is going to has nothing
/// to move, and offering to move it there would be offering to do nothing.
/// </para>
/// <para>
/// The binding is the <em>resolved</em> one, so a key the host only inherits from its group counts. That
/// is the case this question matters most in — the group stays behind, so a host that inherited its key
/// arrives naming nothing at all unless the move writes the binding onto it.
/// </para>
/// </remarks>
internal bool HasABindingToBring => BindingOfTheMovingHost() is not null;
/// <summary>What the tick box beside the move picker says.</summary>
internal string BindingToBringQuestion => BindingOfTheMovingHost() is { } binding
? $"Bring the {binding.Noun} '{binding.Label}' too"
: string.Empty;
/// <summary>
/// What bringing it would do to everything else that uses it, and what leaving it would do to the host.
/// </summary>
/// <remarks>
/// Both halves, because both are decisions. The hosts that also authenticate with it are re-aimed at the
/// key's new vault and go on working for whoever can read both — but for the members of the vault it
/// left, it is gone; and a host that arrives without its key is a host its new colleagues cannot connect
/// with. Neither is the wrong answer, which is why this is a question rather than a rule.
/// </remarks>
internal string BindingToBringNote => BindingOfTheMovingHost() is { } binding
? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: movingHostId)
: string.Empty;
/// <summary>
/// Whether the panel asking which vault to move the group to is up.
/// </summary>
/// <remarks>
/// <para>
/// The host's panel again — see <see cref="IsMovingHost"/> — 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.
/// </para>
/// <para>
/// It has no <c>ShowsGroupActions</c> 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 <see cref="MoveGroup"/> and folds this away in return,
/// so the section shows at most one of the two.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isMovingGroup;
/// <summary>Which group the open move panel is about. Null when it is closed.</summary>
/// <inheritdoc cref="movingHostId" path="/remarks" />
private Guid? movingGroupId;
/// <summary>Where the group could be moved: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveGroupVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveGroupVault;
/// <summary>The group the open move panel is about, by name.</summary>
/// <remarks>
/// For the phone, which draws this panel over its host list rather than beside the card it was opened
/// from — so unlike the desktop, where the group's own tile is on screen underneath the picker, there is
/// nothing left saying which shelf is about to move. The same reason its connect bar names the host.
/// Held rather than read back through <see cref="GroupTarget"/>, which is a desktop selection and is
/// null on the head that needs this.
/// </remarks>
[ObservableProperty]
private string movingGroupLabel = string.Empty;
// 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 a menu
// on both heads — the desktop's right-click, the phone's sheet — and a menu is not drawn until it is
// opened and its 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.
/// <summary>
/// Whether the host's move panel is offering to bring the key or password it authenticates with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Off unless it is ticked</b>, and that is not a default chosen for tidiness. Moving a key into a
/// team's vault hands it to everybody who holds that vault's key — it is a disclosure, and the same rule
/// <see cref="TargetVaultId"/> follows applies: filing something where other people can read it is
/// chosen, never defaulted into. Leaving it off is also the state that was there before this question
/// existed, so somebody pressing MOVE without reading gets what they used to get.
/// </para>
/// <para>
/// The alternative — moving the host and quietly copying the key — was rejected for the reason the
/// keychain has one item per key: two items holding the same private half cannot be told apart
/// afterwards, and rotating the key means finding both.
/// </para>
/// </remarks>
[ObservableProperty]
private bool bringsTheBindingAlong;
/// <summary>
/// Whether the panel asking which vault a keychain item should move to is up.
/// </summary>
/// <remarks>
/// The host's panel — see <see cref="IsMovingHost"/> — over on the keychain, where until now a key was
/// stuck in the vault it was typed into for ever. It takes the place of that pane's EDIT and DELETE
/// while it is open, as the deletion question does, so the pane asks one thing at a time.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowsItemActions))]
private bool isMovingItem;
/// <summary>Which keychain item the open move panel is about. Null when it is closed.</summary>
/// <inheritdoc cref="movingHostId" path="/remarks" />
private Guid? movingItemId;
/// <summary>Which kind of item that id belongs to, so the confirmation knows which repository to ask.</summary>
private VaultItemKind movingItemKind;
/// <summary>Where that item lives now. Held for the same reason its id is.</summary>
private Guid movingItemVaultId;
/// <summary>Where the selected keychain item could go: every vault this session can write to but its own.</summary>
internal ObservableCollection<VaultChoiceViewModel> MoveItemVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedMoveItemVault;
/// <summary>The item the open move panel is about, by name.</summary>
/// <inheritdoc cref="MovingGroupLabel" path="/remarks" />
[ObservableProperty]
private string movingItemLabel = string.Empty;
/// <summary>
/// What else points at the item about to move, said before the move rather than after it.
/// </summary>
/// <remarks>
/// The count is the whole of what makes this decidable. A key is the one item in this vault that other
/// items name, so moving one is never only about the key: every host bound to it and every group lending
/// it is re-aimed at the new id, and somebody about to move a key twenty machines authenticate with
/// should see the twenty before they press it, not read about them in the sentence afterwards.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasMovingItemUsage))]
private string movingItemUsage = string.Empty;
/// <summary>Whether anything at all points at the item the move panel is about.</summary>
internal bool HasMovingItemUsage => MovingItemUsage.Length > 0;
/// <summary>
/// Whether the selected keychain item can be moved to another vault.
/// </summary>
/// <remarks>
/// Keys and passwords only. A tag, a bucket and a pin are read from the active vault alone, so "another
/// vault" is not a question any of them has — and a key is the item this exists for: it is the one thing
/// on this screen that other vaults' hosts genuinely authenticate with.
/// </remarks>
internal bool CanMoveSelectedItem =>
MovableRow() is { IsReadOnly: false } item && CanLeaveItsVault(item.VaultId);
/// <summary>
/// What the drawer's header says it is about.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal string DrawerTitle => (IsEditing, IsEditingGroup) switch
{
(true, _) => editingEntityId is null ? "New host" : "Host details",
(_, true) => EditingGroupId is null ? "New group" : "Group details",
_ => "Host details",
};
/// <summary>
/// The line under it: which keychain this is filed in, or what a group is for.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="MoveHost"/> and <see cref="MoveGroup"/> — 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 <see cref="TargetVaults"/>
/// </para>
/// <para>
/// 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".
/// </para>
/// </remarks>
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,
};
/// <summary>
/// Whether the add sheet is showing over the host list.
/// </summary>
/// <remarks>
/// The phone's answer to a <c>+</c> that has two things to offer. It is a separate flag from the two
/// editors because it sits <em>before</em> either of them: the sheet asks which kind, and choosing
/// closes the sheet and opens that kind's editor. See <see cref="OpenAddSheet"/>.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAddSheetOpen;
/// <summary>
/// The group heading the phone's action sheet is open on, or null when it is closed.
/// </summary>
/// <remarks>
/// <para>
/// The phone's answer to the desktop's right-click menu, and it holds a heading rather than a row
/// because a heading is what its list draws — see <see cref="EditGroupFromHeading"/> for why a group
/// there is not a thing that can be selected, which is what leaves the three commands with nothing to
/// aim at unless the gesture carries it.
/// </para>
/// <para>
/// One nullable property rather than a flag beside a field, so that "open" and "open on what" cannot
/// disagree — the sheet names the group in its own title, and a flag left true beside a cleared header
/// would be a menu about nothing.
/// </para>
/// <para>
/// The heading is not resolved to a group until one of the entries is pressed. A sheet is a menu and
/// deciding not to use it is a perfectly good outcome, so nothing is looked up on the way in; a heading
/// whose group has gone by the time an entry is pressed is dropped there. See <see cref="GroupOf"/>.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(GroupSheetLabel))]
private SidebarGroupHeader? groupSheet;
/// <summary>Which group the sheet says it is about.</summary>
internal string GroupSheetLabel => GroupSheet?.Label ?? string.Empty;
/// <summary>
/// Whether anything the host screen can put over its list is showing.
/// </summary>
/// <remarks>
/// One property rather than five 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 <c>+</c> 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.
/// </remarks>
internal bool AnEditorIsOpen =>
IsAddSheetOpen || GroupSheet is not null || IsEditing || IsEditingGroup
|| IsHostActionSheetOpen || IsAskingForConnectPassword;
/// <summary>
/// Whether the phone's host list is drawn.
/// </summary>
/// <remarks>
/// ◆ <b>Only the group editor takes the list's place now.</b> This used to be
/// <see cref="AnEditorIsOpen"/>, which meant the list went blank behind every sheet — a scrim over an
/// empty canvas, on a screen whose sheets are all about rows the user had just been looking at. The
/// sheets float; the group editor is a card in the list's own row and genuinely replaces it; and the
/// host editor is a page over the whole screen, which needs nothing hidden underneath it.
/// </remarks>
internal bool ShowsHostList => !IsEditingGroup;
/// <summary>
/// Whether the floating <c>+</c> is drawn.
/// </summary>
/// <remarks>
/// Hidden rather than disabled, in three cases and for one reason each. Over an editor or a sheet it
/// would open a form on top of a form. While hosts are ticked it would offer to add a twelfth machine
/// beside eleven chosen for deletion — a control that does something, where the something is not what
/// the screen is about. And under one of the action bar's panels it would be a large accented circle
/// over a question waiting to be answered.
/// </remarks>
internal bool ShowsAddButton => !AnEditorIsOpen && !IsChoosingHosts && !AChosenHostPanelIsOpen;
// ---- ◆ The phone's chosen hosts ----
//
// THE CONNECT BAR WAS HERE, AND WHAT REPLACED IT IS A SELECTION RATHER THAN A PANEL.
//
// ShowsConnectBar, ShowsConnectControls and CanEditSelectedHost went with it. The bar was a card over the
// bottom of the list carrying a password box, CONNECT, EDIT, MOVE and DELETE — raised by a long press
// since a tap started connecting, which made it a menu drawn as a form, in the place a menu is hardest to
// reach. A long press now *chooses* the host it landed on and the actions move into a bar across the top,
// which is where Android has put them since contextual action bars existed and is the one strip of the
// screen a list can never grow into.
//
// The set is what the actions act on, and the count in that bar is why it is a set rather than one row:
// filing eleven imported machines under a group, or clearing out a vault, is the case the old bar could
// not express at all. Everything below is about keeping "which hosts" honest across a list that is
// rebuilt on every filter keystroke and every background sync.
/// <summary>
/// The hosts the action bar is about, by entity id.
/// </summary>
/// <remarks>
/// <para>
/// Ids rather than rows, for the reason the deletion question carries one: every row object in
/// <see cref="Hosts"/> is replaced on every reload, so a set of rows would empty itself once a minute
/// under somebody who was still choosing what to do with them. The rows carry
/// <see cref="HostRowViewModel.IsChosen"/> for the tick, and it is written back onto the new rows from
/// this — see <see cref="ApplyTheChosenHosts"/>.
/// </para>
/// <para>
/// An id that no longer resolves is dropped rather than kept, which is what makes a colleague's deletion
/// arriving mid-selection leave a count that matches what is on screen.
/// </para>
/// </remarks>
private readonly HashSet<Guid> chosenHostIds = [];
/// <summary>
/// Whether the phone is in selection mode: a long press has chosen at least one host.
/// </summary>
/// <remarks>
/// Derived from the set being non-empty rather than being a flag beside it, so the mode and its contents
/// cannot disagree. Unticking the last host leaves selection mode, which is what every Android list does
/// and is the second way out of it — the other being the cross at the left of the bar.
/// </remarks>
internal bool IsChoosingHosts => chosenHostIds.Count > 0;
/// <summary>How many hosts are ticked.</summary>
internal int ChosenHostCount => chosenHostIds.Count;
/// <summary>What the action bar prints between the cross and the pencil.</summary>
/// <remarks>
/// The count alone, because the bar it sits in is already the thing saying what the number is about, and
/// "6 hosts selected" beside a pencil and a menu at 360dp spends the width the two icons need.
/// </remarks>
internal string ChosenHostsLabel =>
ChosenHostCount.ToString(CultureInfo.CurrentCulture);
/// <summary>The chosen hosts, as the rows currently in the list.</summary>
/// <remarks>
/// Rebuilt per read rather than kept, because the rows it names are replaced on every reload and this is
/// only ever asked at the moment an action runs. Ordered as the list is, so a status line naming the
/// first of them names the one nearest the top of the screen.
/// </remarks>
internal IReadOnlyList<HostRowViewModel> ChosenHosts =>
[.. Hosts.Where(row => chosenHostIds.Contains(row.EntityId))];
/// <summary>
/// The one chosen host, where exactly one is chosen; otherwise null.
/// </summary>
/// <remarks>
/// Three of the bar's entries are about one machine and cannot be made to mean anything about six —
/// editing it, opening a shell on it, and browsing its files, the last two because a terminal and an SFTP
/// session are each a connection somebody is about to look at. They are left out of the bar rather than
/// refused from it; see <see cref="HasOneChosenHost"/>.
/// </remarks>
internal HostRowViewModel? TheChosenHost => chosenHostIds.Count == 1
? Hosts.FirstOrDefault(row => chosenHostIds.Contains(row.EntityId))
: null;
/// <summary>Whether the bar should be offering the entries that are about a single machine.</summary>
internal bool HasOneChosenHost => TheChosenHost is not null;
/// <summary>
/// Whether the menu behind the action bar's ⋯ is showing.
/// </summary>
/// <remarks>
/// The add sheet's shape doing the same job the group sheet already does — a scrim and a bottom panel —
/// rather than a flyout under the button. A flyout hangs from the top-right corner of a 360dp screen and
/// puts seven entries under a thumb that is holding the phone at the bottom; a sheet puts them where the
/// hand is. It is also the one arrangement that has room for the sentence under each entry, and three of
/// these seven need one.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isHostActionSheetOpen;
/// <summary>
/// Whether the panel asking which vault the chosen hosts should go to is up, and which of the two
/// questions it is asking.
/// </summary>
/// <remarks>
/// <para>
/// One panel and a mode rather than two panels, because a move and a copy differ in one verb and in the
/// sentence under the picker — everything else, from which vaults are offered to what is left behind, is
/// the same question. Two copies of it would be two places to fix the day a third vault rule arrives.
/// </para>
/// <para>
/// Separate from <see cref="IsMovingHost"/>, which is the desktop drawer's panel about the one selected
/// host. Sharing that one would have made the phone's bar and the desktop's pane disagree about what
/// "the host" means the moment a selection and a set were both non-empty.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AChosenHostPanelIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelTitle))]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelNote))]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelVerb))]
private bool isSendingChosenHostsToAVault;
/// <inheritdoc cref="IsSendingChosenHostsToAVault" />
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelTitle))]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelNote))]
[NotifyPropertyChangedFor(nameof(ChosenHostVaultPanelVerb))]
private bool chosenHostsAreBeingCopied;
/// <summary>What that panel's heading says.</summary>
internal string ChosenHostVaultPanelTitle =>
ChosenHostsAreBeingCopied ? "COPY TO VAULT" : "MOVE TO VAULT";
/// <summary>What its button says.</summary>
internal string ChosenHostVaultPanelVerb => ChosenHostsAreBeingCopied ? "COPY" : "MOVE";
/// <summary>
/// The sentence under the picker, which is different for the two and not decoration in either case.
/// </summary>
/// <remarks>
/// A move leaves the group and the tags behind because both are items of the vault being left — the same
/// sentence <see cref="ConfirmMoveHostAsync"/> has always had to say. A copy leaves the original where it
/// is, and that is worth saying rather than assuming: a second readable copy of a machine's details is
/// exactly what somebody sharing one host with a team wants and exactly what somebody who meant to move
/// it does not.
/// </remarks>
internal string ChosenHostVaultPanelNote => ChosenHostsAreBeingCopied
? "A second copy is written, encrypted with the other vault's key, and the original stays where it "
+ "is. Groups and tags do not come across — both belong to the vault being copied from — so the "
+ "copies arrive filed under nothing."
: "Each host is re-encrypted with the other vault's key, so everybody who holds that key can read it "
+ "and nobody else can. Groups and tags stay behind — both belong to the vault being left.";
/// <summary>
/// Whether the action bar's move panel is offering to bring the key or password along.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>Asked only where it can be answered: one host, and a move rather than a copy.</b> Which key to
/// carry is a fact about one machine, so a selection of six has six answers and no tick can carry them.
/// And a copy must never move it: taking the key out of the vault the original is still sitting in would
/// leave that original unable to connect, which is the one thing "copy" promises not to do.
/// </para>
/// <para>
/// Off unless it is ticked, on the same terms as the drawer's — see
/// <see cref="BringsTheBindingAlong"/>, whose reasoning is the whole of this one's.
/// </para>
/// </remarks>
[ObservableProperty]
private bool bringsTheChosenBindingAlong;
/// <inheritdoc cref="HasABindingToBring" />
internal bool HasAChosenBindingToBring => ChosenBindingToBring() is not null;
/// <inheritdoc cref="BindingToBringQuestion" />
internal string ChosenBindingToBringQuestion => ChosenBindingToBring() is { } binding
? $"Bring the {binding.Noun} '{binding.Label}' too"
: string.Empty;
/// <inheritdoc cref="BindingToBringNote" />
internal string ChosenBindingToBringNote => ChosenBindingToBring() is { } binding
? WhatElseUses(binding.Kind, binding.EntityId, binding.Label, besidesHost: TheChosenHost?.EntityId)
: string.Empty;
/// <summary>Where the chosen hosts could go: every vault this session can write to.</summary>
/// <remarks>
/// Every one of them rather than "all but their own", which is what the single-host picker offers. A set
/// may span vaults, so there is no single vault to leave out — a host already in the destination is
/// skipped when the panel is answered, and said so, rather than shrinking the list it was chosen from.
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> ChosenHostVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? selectedChosenHostVault;
/// <summary>
/// Whether the panel asking which group the chosen hosts should be filed under is up.
/// </summary>
/// <remarks>
/// The one action in the bar that has always existed and had no phone control: filing is
/// <see cref="MoveHostToGroupAsync"/>, which is what dragging a card onto a group does on the desktop, and
/// there is no dragging here. It is the reason the set is worth having at all — thirty imported machines
/// under one heading is one gesture rather than thirty rounds of open, pick, save.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AChosenHostPanelIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isRegroupingChosenHosts;
/// <summary>What the chosen hosts could be filed under: one vault's groups, and "no group".</summary>
/// <inheritdoc cref="RegroupChosenHosts" path="/remarks" />
internal ObservableCollection<GroupChoice> ChosenHostGroupChoices { get; } = [];
[ObservableProperty]
private GroupChoice? selectedChosenHostGroup;
[ObservableProperty]
private string editorLabel = string.Empty;
[ObservableProperty]
private string editorHostname = string.Empty;
/// <summary>
/// The port box, empty when the host is to take its group's.
/// </summary>
/// <remarks>
/// 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 <see cref="EditorPortPlaceholder"/>, 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.
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
private int? editorPort;
[ObservableProperty]
private string editorUsername = string.Empty;
[ObservableProperty]
private string editorNotes = string.Empty;
[ObservableProperty]
private bool editorRelayEnabled;
/// <summary>
/// What the authentication picker offers: a typed password, then every key, then every credential.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];
[ObservableProperty]
private AuthenticationChoice? editorSelectedAuthentication;
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
/// <summary>
/// Which vault a host being created will be filed into.
/// </summary>
/// <remarks>
/// <para>
/// The picker in the host editor itself, and it is a second one rather than the keychain screen's
/// <see cref="TargetVaults"/> 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
/// <see cref="editingHostVaultId"/> was introduced to prevent.
/// </para>
/// <para>
/// Filled from the same source, so what it offers is what the keychain screen offers: vaults this
/// session can both read and write.
/// </para>
/// </remarks>
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
[ObservableProperty]
private VaultChoiceViewModel? editorSelectedVault;
/// <summary>
/// Whether the editor should be asking which vault this host goes into.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="MoveHost"/>, 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.
/// </para>
/// <para>
/// Hidden at one vault rather than shown with a single option, which is the rule
/// <see cref="HasVaultChoice"/> already applies for the same reason: a control offering one answer is
/// a question nobody was asked.
/// </para>
/// </remarks>
internal bool ShowsEditorVaultChoice => editingEntityId is null && EditorVaultChoices.Count > 1;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
private GroupChoice? editorSelectedGroup;
/// <summary>
/// Keeps the authentication picker in step with whether there is a group to inherit from.
/// </summary>
/// <remarks>
/// <para>
/// <b>Filing a host into a group must not pin it to a typed password, and without this it did.</b> 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 <see cref="HostSecret.AsksForPassword"/>: 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
/// <summary>What an empty port box will dial.</summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal string EditorPortPlaceholder =>
InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture))
?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture);
/// <summary>What an empty username box will log in as, or a prompt when nothing supplies one.</summary>
/// <inheritdoc cref="EditorPortPlaceholder" path="/remarks" />
internal string EditorUsernamePlaceholder =>
InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username";
/// <remarks>
/// Walks through <see cref="HostInheritance.Chain"/> 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.
/// </remarks>
private string? InheritedFromEditorGroup(Func<HostGroupSecret, string?> read) =>
HostInheritance
.Chain(EditorSelectedGroup?.EntityId, groupsById)
.Select(entry => read(entry.Group))
.FirstOrDefault(value => value is not null);
/// <summary>
/// The tags the host being edited wears, as the picker leaves them.
/// </summary>
/// <remarks>
/// The authority while an editor is open, not <see cref="EditorTagChoices"/>: 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.
/// </remarks>
private TagSet editorTagIds = TagSet.Empty;
/// <summary>
/// Every tag in the vault, as a chip, with whether the host being edited wears it.
/// </summary>
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
internal ObservableCollection<TagChoice> EditorTagChoices { get; } = [];
/// <summary>Whether there is any tag to offer, which is what draws the picker at all.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool HasTagChoices => EditorTagChoices.Count > 0;
/// <summary>The name in the editor's "new tag" box.</summary>
[ObservableProperty]
private string editorNewTag = string.Empty;
/// <summary>
/// Puts a tag on the host being edited, or takes it off.
/// </summary>
/// <remarks>
/// Applies to the editor's own set rather than saving anything. The host is written by
/// <see cref="BuildHost"/> 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.
/// </remarks>
[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();
}
/// <summary>
/// Creates a tag from the editor's box and puts it on the host being edited.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>It writes to the vault immediately, unlike everything else in this editor.</b> 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.
/// </para>
/// <para>
/// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable
/// — see <see cref="TagSecret.TryValidate"/> 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.
/// </para>
/// </remarks>
[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);
}
/// <summary>Refills the editor's chips from the vault's tags and the set being edited.</summary>
/// <remarks>
/// Rebuilt wholesale rather than mutated, because <see cref="TagChoice"/> is a record and its
/// <c>IsWorn</c> 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.
/// </remarks>
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));
}
/// <summary>The item being edited, or null when creating.</summary>
private Guid? editingEntityId;
/// <summary>
/// Which vault the editor will write to.
/// </summary>
/// <remarks>
/// 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 <em>at that moment</em>, so changing the picker afterwards cannot silently move
/// a half-typed host into a team's vault.
/// </remarks>
private Guid editingHostVaultId;
/// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
private Guid editingKeyVaultId;
/// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
private Guid editingCredentialVaultId;
/// <summary>
/// Whether the editor is showing a host that could have a pinned key to forget.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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;
/// <remarks>
/// 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 <see cref="CancelKeyEdit" /> clears it. Neither that nor anything else here
/// can wipe it — see <c>SshKeySecret</c>, which explains why a .NET string is the honest choice for
/// this and what it does not buy.
/// </remarks>
[ObservableProperty]
private string keyEditorPrivateKey = string.Empty;
[ObservableProperty]
private string keyEditorPassphrase = string.Empty;
[ObservableProperty]
private string keyEditorPublicKey = string.Empty;
[ObservableProperty]
private string keyEditorNotes = string.Empty;
/// <summary>The key being edited, or null when creating.</summary>
private Guid? editingKeyId;
// ---- Generating a key ----
/// <summary>
/// Whether the small form in front of generating a key is showing.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private bool isGeneratingKey;
/// <summary>
/// What the generated key is called, and the comment written into it.
/// </summary>
/// <remarks>
/// One field for both. The comment is the only thing in a host's <c>authorized_keys</c> 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.
/// </remarks>
[ObservableProperty]
private string generateComment = string.Empty;
[ObservableProperty]
private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519;
/// <inheritdoc cref="ShowsAll" />
internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519;
/// <inheritdoc cref="ShowsAll" />
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;
/// <remarks>
/// 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 <c>CredentialSecret.Username</c>,
/// which normalises the two spellings of that to one.
/// </remarks>
[ObservableProperty]
private string credentialEditorUsername = string.Empty;
/// <remarks>
/// Holds a password for as long as the editor is open, and <see cref="CancelCredentialEdit" /> clears it —
/// the same bargain, and the same limits, as the private key box. See <c>CredentialSecret</c>.
/// </remarks>
[ObservableProperty]
private string credentialEditorPassword = string.Empty;
[ObservableProperty]
private string credentialEditorNotes = string.Empty;
/// <summary>The credential being edited, or null when creating.</summary>
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;
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private string bucketEditorSecretAccessKey = string.Empty;
[ObservableProperty]
private string bucketEditorRegion = string.Empty;
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private string bucketEditorEndpoint = string.Empty;
/// <remarks>
/// 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
/// <see cref="NewObjectStore"/> turns it off for them.
/// </remarks>
[ObservableProperty]
private bool bucketEditorUsePathStyle;
[ObservableProperty]
private string bucketEditorNotes = string.Empty;
/// <summary>The bucket being edited, or null when creating.</summary>
private Guid? editingObjectStoreId;
// ---- Deleting ----
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private DeletionRequest? pendingDeletion;
/// <summary>
/// The answer to <see cref="DeletionRequest.Choice"/>: whether a group's hosts go with it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Off is the answer that keeps the machines</b>, and it is off by default and reset to off on every
/// question — see <see cref="OnPendingDeletionChanged"/>. 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[ObservableProperty]
private bool deletionTakesTheHostsToo;
internal bool IsConfirmingDeletion => PendingDeletion is not null;
/// <summary>Whether the sidebar's row of host buttons is showing.</summary>
/// <remarks>
/// Its own property because the markup cannot express <c>!IsEditing &amp;&amp; !IsConfirmingDeletion</c>,
/// 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.
/// </remarks>
internal bool ShowsHostActions => !IsEditing && !IsConfirmingHostDeletion;
/// <summary>
/// Whether the question on screen is the one about deleting a host.
/// </summary>
/// <remarks>
/// 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 <em>its own</em> — otherwise deleting a group draws
/// the group's question inside the host sidebar as well, in a place its buttons never were.
/// </remarks>
internal bool IsConfirmingHostDeletion => PendingDeletion?.Target is DeletionTarget.Host;
/// <inheritdoc cref="IsConfirmingHostDeletion" />
internal bool IsConfirmingGroupDeletion => PendingDeletion?.Target is DeletionTarget.Group;
/// <inheritdoc cref="IsConfirmingHostDeletion" />
internal bool IsConfirmingChosenHostDeletion =>
PendingDeletion?.Target is DeletionTarget.ChosenHosts;
/// <summary>
/// Whether the phone's list has one of the action bar's panels drawn above it.
/// </summary>
/// <remarks>
/// The bar's menu raises three things that are not immediate — which vault, which group, and the deletion
/// question — and at most one of them is ever up. One property because two controls need exactly this
/// question: the floating <c>+</c> stands down while a panel about six other hosts is on screen, and the
/// back gesture closes the panel before it considers anything else.
/// </remarks>
internal bool AChosenHostPanelIsOpen =>
IsSendingChosenHostsToAVault || IsRegroupingChosenHosts || IsConfirmingChosenHostDeletion;
/// <summary>
/// What a group command with no argument acts on: the card that is selected, or the group that is open.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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 <c>HostsScreen.OnGroupContextRequested</c>.
/// 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.
/// </para>
/// </remarks>
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
/// <summary>Whether this vault has any groups, which is what makes the sidebar draw headings.</summary>
internal bool HasGroups => Groups.Count > 0;
/// <summary>What the group panel's save button says.</summary>
/// <remarks>
/// "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.
/// </remarks>
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "SAVE";
/// <summary>Whether the vault screen's Edit and Delete are showing.</summary>
/// <inheritdoc cref="ShowsHostActions" />
internal bool ShowsItemActions => SelectedItemIsEditable && !IsConfirmingDeletion && !IsMovingItem;
// ---- Connecting ----
/// <remarks>
/// Typed per connection, not persisted unless <see cref="RemembersConnectPassword"/> 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.
/// </remarks>
[ObservableProperty]
private string connectPassword = string.Empty;
/// <summary>
/// Whether the phone is asking for a password before it can finish a tap.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>What is left of the connect bar, and the only part of it worth keeping.</b> The bar carried a
/// password box, CONNECT, EDIT, MOVE and DELETE over the bottom of the list; four of those are in the
/// action bar now — see <see cref="ChosenHosts"/> — and this is the fifth. A host that authenticates with
/// a typed password has nowhere on a phone to be given one, so the tap that cannot finish raises a sheet
/// with the box in it rather than doing nothing or, worse, dialling with no password.
/// </para>
/// <para>
/// A sheet rather than the bar it replaces, and that is not cosmetic: the bar was raised by a long press
/// and stayed up, so it was a set of controls sitting over the list whether or not anything was being
/// asked. This is up only while a question is on screen, and answering or dismissing it takes it away.
/// </para>
/// <para>
/// Not set on this head alone but only ever read on it: the desktop's drawer has a password box that is
/// always on screen for a host that wants one, so it has no moment where the question has to be raised.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
private bool isAskingForConnectPassword;
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
/// <remarks>
/// 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.
/// </remarks>
[ObservableProperty]
private string manualTarget = string.Empty;
/// <inheritdoc cref="ConnectPassword" />
/// <remarks>
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
/// 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.
/// </remarks>
[ObservableProperty]
private string manualPassword = string.Empty;
/// <summary>Why the manual box refused, if it did.</summary>
/// <remarks>
/// Beside that box rather than only on <see cref="Status"/>. 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.
/// </remarks>
[ObservableProperty]
private string manualStatus = string.Empty;
/// <summary>
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
/// <summary>
/// Whether a password typed here should be kept, so this host stops asking for it.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Off by default, and it stays a decision.</b> 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
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
/// the one not worth keeping.
/// </para>
/// </remarks>
[ObservableProperty]
private bool remembersConnectPassword;
/// <summary>
/// Whether the selected host will want something typed into the password box.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal bool SelectedHostAsksForAPassword =>
SelectedHost is null
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
/// <summary>
/// What the terminal column says in place of the password box, or nothing when the box is showing.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>An inherited binding says so.</b> 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.
/// </para>
/// </remarks>
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,
};
/// <summary>
/// The port the drawer prints, which is the one this host would dial.
/// </summary>
/// <remarks>
/// 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 <em>placeholder</em> behind an
/// empty box, which is the same fact said the other way round — see <see cref="EditorPortPlaceholder"/>.
/// </remarks>
internal string SelectedHostPortLabel =>
SelectedHost?.Resolved.Port.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
/// <summary>Whether that port came from a group rather than from the host.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal bool SelectedHostPortIsInherited => SelectedHost?.Resolved.Port.IsInherited ?? false;
/// <inheritdoc cref="SelectedHostPortLabel" />
/// <remarks>
/// 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.
/// </remarks>
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
? user
: "no account set";
/// <inheritdoc cref="SelectedHostPortIsInherited" />
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
/// <summary>
/// What the pane names in the credentials row: the key or password this host authenticates with.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// A binding whose target the vault no longer holds says so instead of printing an id — the same rule
/// <see cref="AuthenticationChoice.Missing"/> 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.
/// </para>
/// </remarks>
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,
};
/// <remarks>
/// 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.
/// </remarks>
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;
/// <summary>
/// Raised once a terminal session is open and its renderer has it.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>ConfigureAwait(true)</c> — so a handler may touch
/// controls directly.
/// </para>
/// <para>
/// 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 <em>of</em> — a host's name is a decrypted
/// item — so the naming happens here and the tab list happens there.
/// </para>
/// </remarks>
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
/// <summary>
/// Raised the moment a connection is asked for, before anything has been dialled.
/// </summary>
/// <remarks>
/// The other half of <see cref="SessionOpened"/>, 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
/// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
/// <c>AttemptId</c>.
/// </remarks>
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
/// <summary>Raised when somebody asks to browse a host's files rather than open a shell on it.</summary>
/// <inheritdoc cref="HostFilesEventArgs" path="/remarks" />
internal event EventHandler<HostFilesEventArgs>? FilesRequested;
internal bool HasPendingHostKey => PendingHostKey is not null;
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
internal bool HasConflicts => Conflicts.Count > 0;
/// <summary>Reads the vault into the list and says what is in it.</summary>
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<string>(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}.",
};
}
/// <summary>
/// Reads the vault into the list, silently.
/// </summary>
/// <remarks>
/// Separate from <see cref="LoadAsync" /> 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.
/// </remarks>
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);
}
/// <summary>Redraws every list from the vault, without saying anything about it.</summary>
/// <remarks>
/// 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.
/// </remarks>
internal async Task RefreshVaultsAsync(CancellationToken cancellationToken)
{
await ReloadAsync(cancellationToken).ConfigureAwait(true);
OnPropertyChanged(nameof(HasHiddenVaults));
OnPropertyChanged(nameof(NoVisibleHostsMessage));
}
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Deliberately not filtered by <see cref="IsVaultShown"/>.</b> 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
/// <see cref="Hosts"/> for the same reason.
/// </para>
/// </remarks>
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));
}
/// <returns>How many hosts would not decrypt.</returns>
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedHost?.EntityId;
var unreadable = 0;
var rows = new List<HostRowViewModel>();
// 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);
ApplyTheChosenHosts();
// 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;
}
/// <summary>Which host a freshly filled <see cref="Hosts"/> leaves selected.</summary>
/// <param name="selectedId">Whatever was selected before the list was refilled.</param>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// That fallback is skipped while a group card holds the selection, and it has to be: the two grids
/// share one mark — see <see cref="OnSelectedHostChanged"/> — so a sync that invented a host would
/// quietly unselect a group nobody had touched, once a minute. Read before <see cref="RebuildGroups"/>
/// runs, which is where <see cref="SelectedGroup"/> is re-resolved against the rows this pass makes.
/// </para>
/// </remarks>
private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
Hosts.FirstOrDefault(row => row.EntityId == selectedId)
?? (SelectedGroup is null ? Hosts.FirstOrDefault() : null);
/// <returns>How many buckets would not decrypt.</returns>
/// <remarks>
/// 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.
/// </remarks>
private async Task<int> 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;
}
/// <returns>How many snippets would not decrypt.</returns>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Every readable vault, not the active one, which is what makes a snippet shareable.</b> The read is
/// the half that has to come first: a snippet moved into a team's vault by the machine that owns it would
/// otherwise vanish from the list that moved it, and one a colleague wrote there would never appear at
/// all — sharing would look like losing. The same shape as <see cref="ReloadHostsAsync"/> and
/// <see cref="ReloadKeysAsync"/>, down to the ordering: the vault new items go into first, then by vault
/// name, then by label, because two vaults may hold a snippet called the same thing and which vault it
/// is in is the only thing that tells them apart.
/// </para>
/// </remarks>
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var rows = new List<SnippetRowViewModel>();
var readable = session.ReadableVaults.ToList();
var several = readable.Count > 1;
foreach (var vault in readable)
{
var listing = await session.Snippets
.ListAsync(vault.VaultId, cancellationToken)
.ConfigureAwait(true);
unreadable += listing.Unreadable;
rows.AddRange(listing.Items.Select(
item => new SnippetRowViewModel(item, vault.VaultId, vault.Name)
{
// Only when there is something to tell apart, as the host grid's badge is.
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
}));
}
Snippets.Clear();
foreach (var snippet in rows
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
{
Snippets.Add(snippet);
}
return unreadable;
}
/// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
/// <param name="vaultId">The vault to write it into.</param>
/// <param name="entityId">The snippet to replace, or null to create one.</param>
/// <param name="snippet">What to store.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>Whether it was stored; <see langword="false"/> means the reason is in <see cref="Status"/>.</returns>
/// <remarks>
/// <para>
/// 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 <em>what</em> a snippet is and nothing
/// else.
/// </para>
/// <para>
/// <b>The vault is a parameter rather than the active one</b>, and that is not tidiness: the screen
/// latches it when the editor opens — the chosen vault for a new snippet, the row's own for an existing
/// one — because an update sent to the active vault would write a second copy there and leave the team's
/// original untouched, which is a fork nobody would see until a colleague asked why the change never
/// arrived. The same rule <c>editingHostVaultId</c> carries for hosts.
/// </para>
/// </remarks>
internal async Task<bool> SaveSnippetAsync(
Guid vaultId,
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(vaultId, existing, snippet, cancellationToken)
.ConfigureAwait(true);
}
else
{
await session.Snippets
.CreateAsync(vaultId, 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;
}
/// <summary>Queues a tombstone for one snippet.</summary>
/// <remarks>
/// The tombstone goes to the vault the row came out of, which the row carries. Deleting out of the
/// active vault instead would tombstone nothing and leave the snippet on screen.
/// </remarks>
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(row.VaultId, entityId, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Deleted '{row.Label}'.";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Re-seals one snippet under another vault's key and tombstones the original.
/// </summary>
/// <param name="row">The snippet to move.</param>
/// <param name="target">The vault it should end up in.</param>
/// <param name="cancellationToken">Cancellation.</param>
/// <returns>Its id in the destination, or null when nothing was moved.</returns>
/// <remarks>
/// <para>
/// The write half of sharing a snippet; the panel that asks which vault belongs to the screen, as the
/// snippet editor does. See <c>SnippetsViewModel.Move</c>.
/// </para>
/// <para>
/// <b>A snippet crosses whole</b>, which is the one way this is simpler than <see cref="MoveHost"/>.
/// A host leaves its group and its tags behind because both are items of the vault it came from; a
/// snippet is a label, a command and a note, and none of them points at anything — so there is nothing
/// to strip and nothing to warn about. What the caller still has to say is that the command is now
/// readable by everybody holding the destination's key.
/// </para>
/// <para>
/// Refused for a snippet a newer client wrote, exactly as editing one is: the move re-encodes the
/// payload here, so a field this build cannot represent would be dropped on the way across.
/// </para>
/// </remarks>
internal async Task<Guid?> MoveSnippetAsync(
SnippetRowViewModel row,
VaultChoiceViewModel target,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(row);
ArgumentNullException.ThrowIfNull(target);
if (row.IsReadOnly)
{
Status = "This snippet 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 null;
}
Guid? moved = null;
await RunAsync(
"Moving…",
async () =>
{
moved = await session.Snippets
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, row.Snippet, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = $"Moved '{row.Label}' to {target.Name}.";
}).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 snippet twice or not at all until the next pass.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
return moved;
}
/// <summary>Every vault this session can write to except one, for a screen that owns its own picker.</summary>
/// <remarks>
/// The snippets screen's move panel lives on <c>SnippetsViewModel</c> — its editor does too — so it
/// needs the same list <see cref="BuildMoveVaultChoices"/> fills the host's panel from, in the same
/// order. Shared rather than written twice, for the reason <see cref="WritableVaultsBesides"/> gives.
/// </remarks>
internal IReadOnlyList<VaultChoiceViewModel> MoveTargetsBesides(Guid vaultId) =>
[.. WritableVaultsBesides(vaultId)];
/// <returns>How many groups would not decrypt.</returns>
/// <remarks>
/// <para>
/// The listing is kept rather than projected straight into <see cref="Groups"/>, because a group row
/// carries how many hosts name it and the hosts have not been read yet when this runs. See
/// <see cref="RebuildGroups"/>, which is where the two meet.
/// </para>
/// <para>
/// <b>Three shapes of the same read, and every one of them spans every readable vault.</b> 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 <c>GroupId</c> resolves through.
/// </para>
/// <para>
/// <b>The list stopped being the active vault's, which is what makes a group shareable.</b> 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.
/// </para>
/// <para>
/// <b>The map was widened first, and it had to be, which is why it is separate.</b> 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
/// <c>deploy</c>. So the map answers "what does this id say" for every vault, including the ones
/// <see cref="IsVaultShown"/> is keeping off the screen — hiding a vault must never change what a host
/// dials, and the list is where hiding is applied. See <see cref="RebuildGroups"/>.
/// </para>
/// </remarks>
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var resolvable = new Dictionary<Guid, HostGroupSecret>();
var items = new List<VaultGroupItem>();
var perVault = new Dictionary<Guid, List<GroupChoice>>();
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;
}
/// <returns>How many tags would not decrypt.</returns>
/// <remarks>
/// The same two reads as <see cref="ReloadGroupsAsync"/>, 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.
/// </remarks>
private async Task<int> ReloadTagsAsync(CancellationToken cancellationToken)
{
var unreadable = 0;
var resolvable = new Dictionary<Guid, TagSecret>();
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;
}
/// <summary>Refills <see cref="Tags"/>, counting the hosts wearing each.</summary>
/// <remarks>
/// Counted over <see cref="Hosts"/>, 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.
/// </remarks>
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);
}
/// <summary>
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
/// authenticate.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>host.Port</c> or <c>host.SshKeyId</c> 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 <see cref="HostInheritance"/>.
/// </para>
/// <para>
/// Reads <see cref="groupsById"/>, which is refilled by <see cref="ReloadGroupsAsync"/> 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.
/// </para>
/// </remarks>
internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
/// <summary>
/// The names behind a host's tag ids, sorted for display, skipping any that do not resolve.
/// </summary>
/// <remarks>
/// Sorted by name rather than kept in the set's own order. <see cref="TagSet"/> 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.
/// </remarks>
private IReadOnlyList<string> LabelsFor(TagSet tagIds) =>
[
.. tagIds
.Where(tagsById.ContainsKey)
.Select(id => tagsById[id].Label)
.OrderBy(label => label, StringComparer.CurrentCulture),
];
/// <summary>
/// The name behind a host's group id, or empty where there is none to show.
/// </summary>
/// <remarks>
/// 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
/// <see cref="HostRowViewModel.GroupLabel"/>.
/// </remarks>
private string GroupLabelFor(Guid? groupId) =>
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
/// <remarks>
/// <b>Where a hidden vault's groups are dropped, and the only place they are.</b> 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 <see cref="IsVaultShown"/>.
/// </remarks>
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));
}
/// <summary>
/// Refills the group cards and the trail from whichever group is open.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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));
}
/// <summary>
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
/// outermost level.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="HostGroupSecret.ParentId"/>.
/// </para>
/// <para>
/// <b>Both have to end up somewhere the user can reach.</b> 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.
/// </para>
/// </remarks>
private Dictionary<Guid, Guid?> EffectiveParents()
{
var stated = Groups.ToDictionary(row => row.EntityId, row => row.Group.ParentId);
var parents = new Dictionary<Guid, Guid?>(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>();
Guid? current = id;
while (current is { } step && visited.Add(step))
{
current = parents[step];
}
return current is null;
}
}
/// <summary>The groups from the outermost down to the one that is open, or nothing when none is.</summary>
/// <remarks>
/// Walked against <see cref="EffectiveParents"/> 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.
/// </remarks>
private List<HostGroupRowViewModel> Ancestry(Guid? open, Dictionary<Guid, Guid?> parents)
{
var trail = new List<HostGroupRowViewModel>();
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;
}
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
/// <remarks>
/// 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. <c>ListBox.SelectedItem</c> is two-way bound to
/// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> 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. <see cref="ReloadHostsAsync"/> 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.
/// </remarks>
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));
}
/// <summary>
/// Lays the visible hosts out under their group headings.
/// </summary>
/// <remarks>
/// <para>
/// <b>No groups means no headings.</b> 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.
/// </para>
/// <para>
/// <b>A host whose group has been deleted falls under the ungrouped heading</b> rather than disappearing
/// or keeping an empty heading of its own. The reference is allowed to dangle — see
/// <see cref="HostSecret.GroupId"/> 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.
/// </para>
/// <para>
/// An empty group still gets its heading, and a group emptied by the <em>filter</em> 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.
/// </para>
/// </remarks>
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);
}
/// <summary>Adds one heading to the sidebar, and the hosts under it when it is not folded away.</summary>
/// <param name="shown">The hosts that survived the filters, which every section draws its members from.</param>
/// <param name="group">
/// 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.
/// </param>
/// <param name="belongs">Which of the shown hosts fall under it.</param>
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
private void AddSidebarSection(
IReadOnlyList<HostRowViewModel> shown,
HostGroupRowViewModel? group,
Func<HostRowViewModel, bool> 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);
}
}
/// <summary>Folds one group's hosts away, or brings them back.</summary>
/// <remarks>
/// 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 <see cref="Guid.Empty"/>, which is not
/// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
/// </remarks>
[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;
}
/// <summary>
/// Files one host under one group, or under none.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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 <see cref="RebuildSidebarRows"/>.
/// </para>
/// <para>
/// <b>A group in a different vault to the host is refused, and said so.</b> 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.
/// </para>
/// <para>
/// No cancellation token, for the reason <see cref="ConnectAsync"/> 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.
/// </para>
/// </remarks>
/// <param name="request">The host to move, and where to.</param>
[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);
}
/// <summary>Whether a drop has to be turned down, saying why on the status line when it does.</summary>
/// <param name="row">The host that was dragged.</param>
/// <param name="card">The group card it was dropped on, or null for the drop that unfiles a host.</param>
/// <remarks>
/// 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.
/// </remarks>
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;
}
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
/// <remarks>
/// <para>
/// <b>The grid holds one level of the tree, the way a directory pane holds one directory.</b> 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.
/// </para>
/// <para>
/// <b>The find box is the exception, and deliberately.</b> 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.
/// </para>
/// </remarks>
private bool Matches(HostRowViewModel row, Dictionary<Guid, Guid?> 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);
}
/// <summary>Whether one host survives the vault switches and the find box — where it sits aside.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>The group a host is actually drawn under, or none.</summary>
/// <remarks>
/// 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
/// <see cref="RebuildSidebarRows"/>. 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.
/// </remarks>
private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary<Guid, Guid?> parents) =>
row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null;
/// <summary>Whether a group is the open one or lies somewhere beneath it.</summary>
/// <remarks>
/// 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
/// <see cref="EffectiveParents"/>, where anything caught in a cycle has already been promoted to a root.
/// </remarks>
private static bool IsUnder(Guid? open, Guid? group, Dictionary<Guid, Guid?> 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;
}
/// <returns>How many keys would not decrypt.</returns>
/// <remarks>
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
/// that button at a key nobody chose.
/// </remarks>
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedKey?.EntityId;
var unreadable = 0;
var rows = new List<SshKeyRowViewModel>();
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;
}
/// <returns>How many credentials would not decrypt.</returns>
/// <remarks>
/// 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. <see cref="DeleteCredential" /> 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.
/// </remarks>
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedCredential?.EntityId;
var unreadable = 0;
var rows = new List<CredentialRowViewModel>();
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;
}
/// <returns>How many pins would not decrypt.</returns>
/// <remarks>
/// Read through the repository rather than through <c>VaultKnownHostStore</c>, 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.
/// </remarks>
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
{
var selectedId = SelectedKnownHost?.EntityId;
var unreadable = 0;
var rows = new List<KnownHostRowViewModel>();
// 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;
}
/// <remarks>
/// Case-insensitively, because a host name is, and <c>KnownHostIdentity</c> keys the store the same way.
/// A pin written as <c>DB.internal</c> and a host saved as <c>db.internal</c> are the same machine, and a
/// list that called one of them unused would be inviting somebody to delete trust they rely on.
/// </remarks>
private static string Endpoint(string host, int port) =>
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
/// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
/// <remarks>
/// The offline branch is inside <see cref="RunAsync" /> 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.
/// </remarks>
[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);
}
/// <summary>
/// Finds a server to sync against, getting this machine online if it is not.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
/// <summary>
/// Starts syncing in the background until the vault is disposed.
/// </summary>
/// <remarks>
/// Explicit rather than started from the constructor, so that a test can drive
/// <see cref="AutoSyncAsync" /> a pass at a time instead of racing a timer.
/// </remarks>
internal void StartAutoSync()
{
if (autoSync is not null)
{
return;
}
autoSync = new CancellationTokenSource();
autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
}
/// <summary>
/// One background synchronisation pass, which stays out of the way.
/// </summary>
/// <remarks>
/// <para>
/// Deliberately not routed through <see cref="RunAsync" />. 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
{
if (IsBusy)
{
return;
}
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// One background synchronisation pass, run whether or not a command is in flight.
/// </summary>
/// <remarks>
/// <para>
/// The same quiet pass as <see cref="AutoSyncAsync" /> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
}
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Takes the whole server rather than its sync half, because the first thing a pass does is ask
/// <em>which vaults there are</em> — see <see cref="AdmitNewVaultsAsync"/>. A pass that only synced the
/// vaults it already knew could never discover one somebody had just shared.
/// </para>
/// </remarks>
private async Task<IReadOnlyList<VaultSyncReport>?> 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();
}
}
/// <summary>
/// Re-reads which vaults this account can reach, and opens any that have become readable.
/// </summary>
/// <remarks>
/// <para>
/// <b>This is the whole of how a shared vault arrives.</b> 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.
/// </para>
/// <para>
/// The server now says when this is worth doing — a <c>vaults.changed</c> notice wakes the pass, so the
/// vault turns up as it is shared rather than within the minute — but that only decides <em>when</em>.
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
/// <summary>
/// Raised when a synchronisation pass found that the vaults this account can reach have changed.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
internal event EventHandler? VaultsChanged;
/// <remarks>
/// <c>ConfigureAwait(true)</c> 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
/// <see cref="LoadAsync" /> rebuilds are still only ever touched from one thread. A
/// <c>ConfigureAwait(false)</c> here would mutate them from a timer thread, which Avalonia will
/// eventually notice in a way that looks like a rendering bug.
/// </remarks>
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.
}
}
/// <summary>
/// Waits for the timer to come round, or for the server to say there is something to fetch.
/// </summary>
/// <returns>Whether to run a pass. False means the loop is over.</returns>
/// <remarks>
/// <para>
/// The timer is unchanged and is still what guarantees a pass. The socket only makes one
/// <em>early</em>, 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.
/// </para>
/// <para>
/// <b>Both waits are held across iterations, and that is load-bearing rather than an
/// optimisation.</b> <see cref="PeriodicTimer"/> permits only one outstanding
/// <c>WaitForNextTickAsync</c> 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.
/// </para>
/// </remarks>
private async Task<bool> 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;
}
/// <summary>The two waits the background loop keeps alive between passes.</summary>
/// <remarks>
/// A class rather than three locals because <see cref="WaitForWorkAsync"/> has to hand them back
/// changed, and a method that took three <c>ref</c> parameters could not be <c>async</c>. See that
/// method for why abandoning either of them is a defect rather than a tidiness question.
/// </remarks>
private sealed class AutoSyncWaits
{
/// <summary>The pending timer tick, or null when the last one has been consumed.</summary>
internal Task<bool>? Tick { get; set; }
/// <summary>The pending read from the server's push channel.</summary>
internal Task<VaultEvent>? Notice { get; set; }
/// <summary>The stream <see cref="Notice"/> was taken from, to notice a reconnection.</summary>
internal IVaultEventStream? Watching { get; set; }
}
/// <summary>Shows one kind of item, if nothing is being edited.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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;
}
/// <summary>
/// Raises the sheet that asks whether the thing being added is a host or a group.
/// </summary>
/// <remarks>
/// <para>
/// Two things behind one <c>+</c>, 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.
/// </para>
/// <para>
/// Refuses while an editor is open rather than stacking on top of it. The button that raises this is
/// hidden in that state — see <see cref="AnEditorIsOpen"/> — so this is the guard for the path the
/// button does not control, which is a command invoked from anywhere else.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenAddSheet()
{
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
{
return;
}
IsAddSheetOpen = true;
}
/// <summary>Lowers the add sheet without choosing anything.</summary>
[RelayCommand]
private void CloseAddSheet() => IsAddSheetOpen = false;
// ---- ◆ Choosing hosts, and the seven things the bar can do to them ----
/// <summary>
/// Puts a tick against the host a long press landed on, entering selection mode with it.
/// </summary>
/// <param name="row">The host that was held.</param>
/// <remarks>
/// <para>
/// Adds rather than toggles, and the difference is what makes the gesture safe to repeat: a long press on
/// a host that is already ticked leaves it ticked, where a toggle would take the selection off a machine
/// somebody was holding down on to make sure of. Untick is what a tap is for once the mode is up — see
/// <see cref="ToggleHostChoice"/>.
/// </para>
/// <para>
/// Refused while anything is over the list. A sheet or an editor is what the gesture would be aimed
/// through, and the row underneath is not what the finger was on.
/// </para>
/// </remarks>
[RelayCommand]
private void ChooseHost(HostRowViewModel? row)
{
if (row is null || AnEditorIsOpen)
{
return;
}
chosenHostIds.Add(row.EntityId);
ApplyTheChosenHosts();
}
/// <summary>
/// Adds or removes one host, which is what a tap means once the bar is up.
/// </summary>
/// <remarks>
/// Taking the last tick off leaves selection mode, which is what every Android list does and is the
/// second way out of it — the cross at the left of the bar being the first. It goes through
/// <see cref="ClearHostChoice"/> rather than merely emptying the set, so the menu and any panel it raised
/// go with it: a picker asking which vault to move nothing to is not a state worth having.
/// </remarks>
[RelayCommand]
private void ToggleHostChoice(HostRowViewModel? row)
{
if (row is null)
{
return;
}
if (!chosenHostIds.Remove(row.EntityId))
{
chosenHostIds.Add(row.EntityId);
}
if (chosenHostIds.Count == 0)
{
ClearHostChoice();
return;
}
ApplyTheChosenHosts();
}
/// <summary>Leaves selection mode, which is the cross at the left of the bar.</summary>
/// <remarks>
/// It takes the menu and whichever panel was raised from it, because all three are about the set: a
/// deletion question left armed over an empty selection would be a question with no answer, and the
/// vault picker would be offering to move nothing.
/// </remarks>
[RelayCommand]
private void ClearHostChoice()
{
chosenHostIds.Clear();
IsHostActionSheetOpen = false;
CloseTheChosenHostPanels();
ApplyTheChosenHosts();
}
/// <summary>Raises the menu behind the bar's ⋯.</summary>
[RelayCommand]
private void OpenHostActionSheet()
{
if (!IsChoosingHosts)
{
return;
}
IsHostActionSheetOpen = true;
}
/// <summary>Lowers it without choosing anything.</summary>
[RelayCommand]
private void CloseHostActionSheet() => IsHostActionSheetOpen = false;
/// <summary>
/// Opens a terminal on the one chosen host.
/// </summary>
/// <remarks>
/// The bar's entry for what a tap already does, and it is in the menu because the bar is what a long
/// press leaves you in: without it, connecting to the machine you had just chosen would mean leaving
/// selection mode first. It goes through <see cref="ConnectToRowAsync"/> so that a host wanting a typed
/// password raises the same sheet a tap does rather than failing quietly.
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectToChosenHostAsync()
{
IsHostActionSheetOpen = false;
if (TheChosenHost is not { } row)
{
return Task.CompletedTask;
}
ClearHostChoice();
return ConnectToRowAsync(row);
}
/// <summary>
/// Opens the files screen on the one chosen host.
/// </summary>
/// <remarks>
/// <para>
/// It raises <see cref="FilesRequested"/> rather than doing anything itself, because the screen and the
/// view model behind it are the shell's — see the event. What this side owns is which machine, and that
/// is a decrypted item.
/// </para>
/// <para>
/// Single-host only, and not because a loop would be hard: there is one file-transfer session behind that
/// screen, so six hosts would be five connections nobody could look at.
/// </para>
/// </remarks>
[RelayCommand]
private void BrowseChosenHost()
{
IsHostActionSheetOpen = false;
if (TheChosenHost is not { } row)
{
return;
}
ClearHostChoice();
FilesRequested?.Invoke(this, new HostFilesEventArgs(row));
}
/// <summary>Opens the editor on the one chosen host.</summary>
/// <remarks>
/// The pencil at the right of the bar, and the one entry that is not in the menu: it is the action people
/// reach for most and it is worth a control that does not need a menu opened first. It leaves selection
/// mode, because the editor is a page over the list and a bar counting hosts above a form about one of
/// them would be two answers to "what is this screen about".
/// </remarks>
[RelayCommand]
private void EditChosenHost()
{
IsHostActionSheetOpen = false;
if (TheChosenHost is not { } row)
{
return;
}
SelectedHost = row;
ClearHostChoice();
EditSelectedHostCommand.Execute(null);
}
/// <summary>Asks which vault the chosen hosts should move to.</summary>
[RelayCommand]
private void MoveChosenHostsToVault() => SendChosenHostsToAVault(copying: false);
/// <summary>Asks which vault the chosen hosts should be copied into.</summary>
[RelayCommand]
private void CopyChosenHostsToVault() => SendChosenHostsToAVault(copying: true);
/// <summary>
/// Raises the one picker both of those use.
/// </summary>
/// <remarks>
/// Refused with the host editor open, as every other write from this list is: the editor holds a
/// half-typed version of a record this would rewrite underneath it.
/// </remarks>
private void SendChosenHostsToAVault(bool copying)
{
IsHostActionSheetOpen = false;
if (!IsChoosingHosts || AHostEditorIsInTheWay())
{
return;
}
BuildChosenHostVaultChoices();
if (ChosenHostVaultChoices.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 = copying
? "There is nowhere to copy these to: this is the only keychain you can write to."
: "There is nowhere to move these to: this is the only keychain you can write to.";
return;
}
// At most one panel over the list, which is the rule the group panels already keep between
// themselves: two questions about the same six machines, one of which destroys them, is not
// something anybody should have to read carefully.
PendingDeletion = null;
IsRegroupingChosenHosts = false;
ChosenHostsAreBeingCopied = copying;
IsSendingChosenHostsToAVault = true;
Status = string.Empty;
}
/// <summary>Abandons the vault picker.</summary>
[RelayCommand]
private void CancelSendChosenHostsToAVault()
{
IsSendingChosenHostsToAVault = false;
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
BringsTheChosenBindingAlong = false;
Status = string.Empty;
}
/// <summary>
/// Moves or copies every chosen host into the vault that was picked.
/// </summary>
/// <remarks>
/// <para>
/// <b>The group and the tags are left behind either way</b>, which is the rule
/// <see cref="ConfirmMoveHostAsync"/> carries at length and which a copy does not escape: both are items
/// of the vault being left, so a host arriving with either would point at something the destination does
/// not contain — resolvable here and dangling for everybody else in it.
/// </para>
/// <para>
/// <b>Three kinds of host are skipped rather than refused</b>, and each is counted into the sentence
/// afterwards. One written by a newer client cannot be re-encoded here without losing fields; one already
/// in the destination has nowhere to go; and a copy of a host into its own vault is
/// <see cref="DuplicateChosenHostsAsync"/> rather than this. Skipping beats refusing the whole run,
/// because a selection of eleven with one read-only row would otherwise do nothing at all and say so
/// about the wrong ten.
/// </para>
/// <para>
/// <b>The key or password comes too where the tick says so</b>, which is only ever the one-host move —
/// see <see cref="BringsTheChosenBindingAlong"/>. It is carried before the host is written, so the host
/// lands naming the id the key arrived with; that is <see cref="ConfirmMoveHostAsync"/>'s own ordering
/// and the reason for it is written there.
/// </para>
/// <para>
/// One reload and one sync at the end rather than per host. Both are the expensive half, and a run over
/// eleven machines that reloaded eleven times would replace every row in the list under a user watching
/// it.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmSendChosenHostsToAVaultAsync(CancellationToken cancellationToken)
{
if (SelectedChosenHostVault is not { } target || !IsChoosingHosts)
{
return;
}
var copying = ChosenHostsAreBeingCopied;
var rows = ChosenHosts;
// Read before the panel is folded away, because both are answered against it.
var bringing = BringsTheChosenBindingAlong ? ChosenBindingToBring() : null;
var only = TheChosenHost;
IsSendingChosenHostsToAVault = false;
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
BringsTheChosenBindingAlong = false;
await RunAsync(
copying ? "Copying…" : "Moving…",
async () =>
{
var (rewritten, carried) = await CarryTheChosenBindingAsync(
bringing, only, target.VaultId, cancellationToken)
.ConfigureAwait(true);
var (done, skipped) = await SendEachChosenHostAsync(
rows, target.VaultId, (only?.EntityId, rewritten), copying, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
ClearHostChoice();
var verb = copying ? "Copied" : "Moved";
Status = WhatTheRunDid(
done,
skipped,
$"{verb} {done} host(s) to {target.Name}.{carried}",
copying ? "nothing was copied" : "nothing was moved");
}).ConfigureAwait(true);
// As a save and a deletion are. 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);
}
/// <summary>
/// Moves the one chosen host's key or password ahead of it, where the tick asked for that.
/// </summary>
/// <returns>
/// The host as it must now be written — naming the id the key landed with — and what to add to the
/// sentence afterwards. Null and empty where nothing was carried.
/// </returns>
/// <remarks>
/// <para>
/// Before the hosts rather than after them, which is <see cref="ConfirmMoveHostAsync"/>'s own ordering:
/// an interruption between the two leaves the key in the destination and the host still where it started,
/// pointing at a tombstone — visible, and repaired by moving it again. The other way round leaves a host
/// in a vault whose members cannot read the key it names, which looks like nothing at all until somebody
/// tries to connect.
/// </para>
/// <para>
/// <b>The rewritten host is the return value and not a side effect</b>, because the key takes a new id in
/// the destination: a caller that went on writing the row's own secret would send the machine across
/// still naming the id the key had before it moved, which is a tombstone. That is the one thing this
/// whole path exists to avoid.
/// </para>
/// </remarks>
private async Task<(HostSecret? Payload, string Note)> CarryTheChosenBindingAsync(
MovableBinding? bringing,
HostRowViewModel? only,
Guid target,
CancellationToken cancellationToken)
{
if (bringing is not { } bring || only is null)
{
return (null, string.Empty);
}
var (payload, carried) = await CarriedAlongAsync(
bring,
Detached(only.Host, only.Resolved.Binding),
only.EntityId,
target,
cancellationToken)
.ConfigureAwait(true);
return (payload, carried);
}
/// <summary>Writes every chosen host into the destination, counting what it passed over.</summary>
/// <param name="rows">The ticked hosts, as the list is holding them.</param>
/// <param name="target">The vault they are going to.</param>
/// <param name="carried">
/// The one host a key was carried for and the secret that carry left, or nulls where no key moved.
/// </param>
/// <param name="copying">Whether the originals stay behind.</param>
/// <param name="cancellationToken">The run's own.</param>
/// <returns>How many hosts were written, and how many were left alone.</returns>
/// <remarks>
/// <para>
/// <b>Everything that decides what crosses is here.</b> The group and the tags are dropped in both
/// directions — a host arriving with either would point at an item the destination does not contain —
/// and that goes through <see cref="Detached"/> rather than being done inline, so that a host which only
/// <em>inherited</em> its key from the group arrives naming that key rather than naming nothing. The
/// group stays behind, so without it the machine would connect before the move and refuse after it, with
/// nothing on screen saying why.
/// </para>
/// <para>
/// The one exception is the host a key was carried for: it is written as the carry left it, naming the
/// id the key landed with. Detaching it again here would send it across still naming a tombstone, which
/// is the one thing that whole path exists to avoid.
/// </para>
/// <para>
/// Read-only rows and hosts already in the destination are skipped rather than refusing the whole run,
/// for the reason <see cref="ConfirmSendChosenHostsToAVaultAsync"/> gives.
/// </para>
/// </remarks>
private async Task<(int Done, int Skipped)> SendEachChosenHostAsync(
IReadOnlyList<HostRowViewModel> rows,
Guid target,
(Guid? HostId, HostSecret? Payload) carried,
bool copying,
CancellationToken cancellationToken)
{
var done = 0;
var skipped = 0;
foreach (var row in rows)
{
if (row.IsReadOnly || row.VaultId == target)
{
skipped++;
continue;
}
var payload = carried is { HostId: { } id, Payload: { } written } && id == row.EntityId
? written
: Detached(row.Host, row.Resolved.Binding);
_ = await SendOneHostToAVaultAsync(row, target, payload, copying, cancellationToken)
.ConfigureAwait(true);
done++;
}
return (done, skipped);
}
/// <summary>Writes one host into another vault, leaving the original behind or not.</summary>
/// <remarks>
/// One write and no policy: what crosses is settled by <see cref="SendEachChosenHostAsync"/>. This is a
/// method of its own because the two verbs differ in one call and in nothing else.
/// </remarks>
/// <param name="row">The host, for the vault and the id it is leaving.</param>
/// <param name="target">The vault it is going to.</param>
/// <param name="payload">What to write over there, already detached from its group and tags.</param>
/// <param name="copying">Whether the original stays behind.</param>
/// <param name="cancellationToken">The run's own.</param>
/// <returns>The entity id the host has in the destination, which nothing here needs.</returns>
private Task<Guid> SendOneHostToAVaultAsync(
HostRowViewModel row,
Guid target,
HostSecret payload,
bool copying,
CancellationToken cancellationToken)
{
return copying
? session.Hosts.CreateAsync(target, payload, cancellationToken)
: session.Hosts.MoveAsync(row.VaultId, target, row.EntityId, payload, cancellationToken);
}
/// <summary>
/// Asks which group the chosen hosts should be filed under.
/// </summary>
/// <remarks>
/// <para>
/// <b>One keychain's groups, so a selection spanning two is refused rather than half-filed.</b> A group is
/// an item of one vault: filing a host from another under it would leave everybody but the person who did
/// it seeing a machine filed under nothing. That is the same refusal <see cref="RefusesTheDrop"/> makes
/// for a card dragged across the boundary on the desktop, made once here instead of per host.
/// </para>
/// <para>
/// "No group" is an entry rather than an omission, because unfiling a run of machines is exactly as
/// common as filing them — it is what somebody does after deleting a heading and finding its hosts under
/// UNGROUPED by another name.
/// </para>
/// </remarks>
[RelayCommand]
private void RegroupChosenHosts()
{
IsHostActionSheetOpen = false;
if (!IsChoosingHosts || AHostEditorIsInTheWay())
{
return;
}
var vaults = ChosenHosts.Select(row => row.VaultId).Distinct().ToList();
if (vaults.Count != 1)
{
Status = "These hosts are in more than one keychain, and a group belongs to one. Choose hosts "
+ "from a single keychain to file them together.";
return;
}
BuildChosenHostGroupChoices(vaults[0]);
PendingDeletion = null;
IsSendingChosenHostsToAVault = false;
IsRegroupingChosenHosts = true;
Status = string.Empty;
}
/// <summary>Abandons the group picker.</summary>
[RelayCommand]
private void CancelRegroupChosenHosts()
{
IsRegroupingChosenHosts = false;
ChosenHostGroupChoices.Clear();
SelectedChosenHostGroup = null;
Status = string.Empty;
}
/// <summary>
/// Files every chosen host under the group that was picked.
/// </summary>
/// <remarks>
/// The same write <see cref="MoveHostToGroupAsync"/> makes for one dragged card, and deliberately the same
/// one: the group is the single field of a host that is about arrangement rather than about the machine,
/// which is why it is the only one anything changes without opening the editor.
/// </remarks>
[RelayCommand]
private async Task ConfirmRegroupChosenHostsAsync(CancellationToken cancellationToken)
{
if (SelectedChosenHostGroup is not { } choice || !IsChoosingHosts)
{
return;
}
var rows = ChosenHosts;
var name = choice.EntityId is null ? "no group" : choice.Label;
IsRegroupingChosenHosts = false;
ChosenHostGroupChoices.Clear();
SelectedChosenHostGroup = null;
var done = 0;
var skipped = 0;
await RunAsync(
$"Filing under {name}…",
async () =>
{
foreach (var row in rows)
{
if (row.IsReadOnly)
{
skipped++;
continue;
}
if (row.Host.GroupId == choice.EntityId)
{
// Already there. Counted as done rather than skipped: the user asked for these hosts
// to be under this heading, and they are.
done++;
continue;
}
await session.Hosts
.UpdateAsync(
row.VaultId,
row.EntityId,
row.Host with { GroupId = choice.EntityId },
cancellationToken)
.ConfigureAwait(true);
done++;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
ClearHostChoice();
Status = WhatTheRunDid(
done, skipped, $"Filed {done} host(s) under {name}.", "nothing was filed");
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Writes a second copy of every chosen host beside it, in its own vault.
/// </summary>
/// <remarks>
/// <para>
/// The group and the tags <em>do</em> come with it, which is the whole difference between this and a copy
/// into another vault: the duplicate stays in the same keychain, so everything it points at is still
/// there. It is what somebody does before changing one field on a machine they do not want to lose the
/// old shape of.
/// </para>
/// <para>
/// The name gets " copy" and nothing else — no numbering, and duplicates of duplicates are allowed to
/// stack. Two hosts called the same thing are not wrong here for the reason two groups are not: hosts are
/// pointed at by id, the row says which keychain it is in, and a name this application refused would be a
/// name the next sync could hand it anyway.
/// </para>
/// </remarks>
[RelayCommand]
private async Task DuplicateChosenHostsAsync(CancellationToken cancellationToken)
{
IsHostActionSheetOpen = false;
if (!IsChoosingHosts || AHostEditorIsInTheWay())
{
return;
}
var rows = ChosenHosts;
var done = 0;
var skipped = 0;
await RunAsync(
"Duplicating…",
async () =>
{
foreach (var row in rows)
{
if (row.IsReadOnly)
{
skipped++;
continue;
}
await session.Hosts
.CreateAsync(
row.VaultId,
row.Host with { Label = row.Host.Label + " copy" },
cancellationToken)
.ConfigureAwait(true);
done++;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
ClearHostChoice();
Status = WhatTheRunDid(
done, skipped, $"Duplicated {done} host(s).", "nothing was duplicated");
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Asks whether the chosen hosts should go.
/// </summary>
/// <remarks>
/// One question naming a count rather than one question per host, which is the reason
/// <see cref="DeletionTarget.ChosenHosts"/> exists. Terminals still open on any of them are disclosed for
/// the reason <see cref="DeleteHost"/> discloses one: a deletion does not close a session, and somebody
/// removing machines they are still working on should know that is what they have done.
/// </remarks>
[RelayCommand]
private void DeleteChosenHosts()
{
IsHostActionSheetOpen = false;
if (!IsChoosingHosts)
{
return;
}
var rows = ChosenHosts;
var live = rows.Count(row => row.IsConnected);
IsSendingChosenHostsToAVault = false;
IsRegroupingChosenHosts = false;
PendingDeletion = new DeletionRequest(
DeletionTarget.ChosenHosts,
Guid.Empty,
rows.Count == 1
? $"Delete the host '{rows[0].Label}'?"
: $"Delete these {rows.Count} hosts?",
HowFarADeletionGoes(
rows.Count == 1
? "The host and everything saved about it"
: "The hosts and everything saved about them"),
live switch
{
0 => string.Empty,
1 => "A terminal is open on one of them. It stays open — deleting a host does not close it, "
+ "and nothing will reopen it afterwards.",
_ => $"Terminals are open on {live} of them. They stay open — deleting a host does not close "
+ "one, and nothing will reopen them afterwards.",
});
}
/// <summary>Queues a tombstone for every host that was agreed to.</summary>
/// <remarks>
/// Read-only rows are skipped, as they are in every other run over the set, and for the same reason a
/// deletion is otherwise allowed on one: a tombstone re-encodes nothing, but the row was written by a
/// client this build does not fully understand and removing it here would be acting on a record it cannot
/// read back.
/// </remarks>
private async Task DeleteChosenHostsNowAsync(CancellationToken cancellationToken)
{
var rows = ChosenHosts;
var done = 0;
var skipped = 0;
await RunAsync(
"Deleting…",
async () =>
{
foreach (var row in rows)
{
if (row.IsReadOnly)
{
skipped++;
continue;
}
await session.Hosts
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
.ConfigureAwait(true);
done++;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
ClearHostChoice();
Status = WhatTheRunDid(
done, skipped, $"Deleted {done} host(s).", "nothing was deleted");
}).ConfigureAwait(true);
// As with saving: a tombstone is worth pushing straight away, so the items do not reappear on
// another machine that syncs before the next tick.
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// What a run over the set says afterwards, counting what it left alone.
/// </summary>
/// <param name="done">How many hosts the run actually wrote.</param>
/// <param name="skipped">How many it passed over.</param>
/// <param name="did">The sentence for what it did, naming the count.</param>
/// <param name="didNothing">What to say instead when the run wrote nothing at all.</param>
/// <remarks>
/// The skipped count is stated rather than swallowed, because it is the difference between "eleven hosts
/// moved" and "ten moved and one you will find still here tomorrow". Why each one was passed over is not
/// spelled out per host: the rows carry a badge saying they were written by a newer client, which is the
/// only reason a run stops short of a machine that was in the destination already.
/// </remarks>
private static string WhatTheRunDid(int done, int skipped, string did, string didNothing)
{
var left = skipped == 0
? string.Empty
: $" {skipped} were left alone — a host already in the keychain being written to, or one written "
+ "by a newer version of DodoSSH.";
return done == 0 ? $"{char.ToUpperInvariant(didNothing[0])}{didNothing[1..]}.{left}" : did + left;
}
/// <summary>Fills the picker with every vault the chosen hosts could be sent to.</summary>
/// <remarks>
/// Every writable vault where the set spans more than one of them, and all but their own where it does
/// not. A set from two keychains has no single vault to leave out, and shrinking the list to the
/// intersection would offer nothing at all for a selection that straddles the only two vaults there are.
/// </remarks>
private void BuildChosenHostVaultChoices()
{
ChosenHostVaultChoices.Clear();
var vaults = ChosenHosts.Select(row => row.VaultId).Distinct().ToList();
var choices = vaults.Count == 1
? WritableVaultsBesides(vaults[0])
: session.ReadableVaults
.Where(vault => vault.CanWrite)
.OrderByDescending(vault => vault.IsPersonal)
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
.Select(vault => new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
foreach (var choice in choices)
{
ChosenHostVaultChoices.Add(choice);
}
SelectedChosenHostVault = ChosenHostVaultChoices.FirstOrDefault();
}
/// <summary>Fills the group picker with one vault's groups, and the entry that files under none.</summary>
private void BuildChosenHostGroupChoices(Guid vaultId)
{
ChosenHostGroupChoices.Clear();
ChosenHostGroupChoices.Add(GroupChoice.None);
if (groupsByVault.TryGetValue(vaultId, out var groups))
{
foreach (var group in groups)
{
ChosenHostGroupChoices.Add(group);
}
}
SelectedChosenHostGroup = ChosenHostGroupChoices[0];
}
/// <summary>Folds away whatever the bar's menu raised over the list.</summary>
private void CloseTheChosenHostPanels()
{
IsSendingChosenHostsToAVault = false;
ChosenHostVaultChoices.Clear();
SelectedChosenHostVault = null;
BringsTheChosenBindingAlong = false;
IsRegroupingChosenHosts = false;
ChosenHostGroupChoices.Clear();
SelectedChosenHostGroup = null;
if (IsConfirmingChosenHostDeletion)
{
PendingDeletion = null;
}
}
/// <summary>
/// Writes the ticks back onto the rows and tells the bar what it is about.
/// </summary>
/// <remarks>
/// Called after every change to the set and after every reload, because the two are the same problem seen
/// from either end: the set outlives the rows, and the rows are what the ticks are drawn on. Ids that no
/// longer resolve are dropped here rather than left, so a colleague's deletion arriving mid-selection
/// leaves a count that matches what is on screen.
/// </remarks>
private void ApplyTheChosenHosts()
{
if (chosenHostIds.Count > 0)
{
chosenHostIds.RemoveWhere(id => !Hosts.Any(row => row.EntityId == id));
}
foreach (var row in Hosts)
{
row.IsChosen = chosenHostIds.Contains(row.EntityId);
}
// The panels go with the last host, wherever the set emptied from. A sync that removed the only
// chosen machine would otherwise leave a vault picker up with nothing behind it.
if (chosenHostIds.Count == 0)
{
IsHostActionSheetOpen = false;
CloseTheChosenHostPanels();
}
OnPropertyChanged(nameof(IsChoosingHosts));
OnPropertyChanged(nameof(ShowsAddButton));
OnPropertyChanged(nameof(ChosenHostCount));
OnPropertyChanged(nameof(ChosenHostsLabel));
OnPropertyChanged(nameof(ChosenHosts));
OnPropertyChanged(nameof(TheChosenHost));
OnPropertyChanged(nameof(HasOneChosenHost));
}
/// <summary>
/// Opens the pane about one host, on the card the pencil was pressed on.
/// </summary>
/// <param name="row">
/// 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.
/// </param>
/// <remarks>
/// <para>
/// The pencil takes the row as a parameter rather than relying on the click having selected the card
/// first. A button inside a <c>ListBoxItem</c> 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
/// <see cref="SelectedHost"/> would be opening the pane on whichever card happened to be lit, which on
/// the first click of a session is none of them.
/// </para>
/// <para>
/// 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
/// <see cref="IsHostPaneOpen"/> exists to prevent in the other direction.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenHostPane(HostRowViewModel? row)
{
if (row is not null)
{
SelectedHost = row;
}
if (SelectedHost is null)
{
return;
}
IsHostPaneOpen = true;
}
/// <summary>
/// Raises whatever the head in front of the user uses to ask about one host.
/// </summary>
/// <param name="row">The machine, which the caller is holding.</param>
/// <remarks>
/// <para>
/// ◆ <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
/// that is a different piece of state, so a caller that only opened the pane would leave this head
/// arriving at a list with nothing on it to press.
/// </para>
/// <para>
/// Both are set rather than branching on which head is running, because neither view model knows and
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
/// phone has no drawer.
/// </para>
/// <para>
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
/// act both flags exist to tell apart from browsing.
/// </para>
/// </remarks>
[RelayCommand]
private void AskAboutHost(HostRowViewModel? row)
{
if (row is null)
{
return;
}
OpenHostPane(row);
ChooseHost(row);
}
/// <summary>
/// Puts the drawer away, whichever of the three panels is in it.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[RelayCommand]
private void CloseDrawer()
{
if (IsEditing)
{
CancelEditCommand.Execute(null);
}
if (IsEditingGroup)
{
CancelGroupEditCommand.Execute(null);
}
IsHostPaneOpen = false;
}
/// <summary>Starts a new host.</summary>
[RelayCommand]
private void NewHost()
{
IsAddSheetOpen = false;
if (AHostEditorIsInTheWay())
{
return;
}
editingEntityId = null;
// Read before the vault is decided, because it is what decides it.
var inherited = GroupTarget;
editingHostVaultId = VaultForANewHostUnder(inherited);
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. Still filtered by the vault being written to, which is that group's
// own wherever this session can write there: what the filter is left holding is the case where it
// cannot. Before the authentication picker, because whether there is a group to inherit from decides
// whether that one offers to.
BuildGroupChoices(GroupInEditingVault(inherited?.EntityId));
BuildAuthenticationChoices(
boundKeyId: null,
boundCredentialId: null,
asksForPassword: false,
grouped: EditorSelectedGroup?.EntityId is not null);
IsEditing = true;
Status = "Adding a host.";
}
/// <summary>Which vault a host started from the grid is sealed in.</summary>
/// <param name="inherited">The group the editor is about to open on, if there is one.</param>
/// <remarks>
/// <para>
/// 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 the host lands. See
/// <see cref="EditorVaultChoices"/>.
/// </para>
/// <para>
/// A group to inherit beats it, because a group lives in exactly one vault and a host that is to land in
/// that group has to be sealed in that vault too. Deciding the two separately made them contradict each
/// other: the group was dropped by <see cref="GroupInEditingVault"/> and + NEW HOST inside PLATFORM
/// opened a form filed under nothing, bound for somewhere else. It is the rule <see cref="NewGroup"/>
/// already follows for a parent.
/// </para>
/// <para>
/// Only where this session can write to that vault, which is what <see cref="TargetVaults"/> is asked
/// here: a viewer of a shared vault keeps the standing preference and loses the group with it, rather
/// than opening an editor aimed at a save that cannot happen.
/// </para>
/// </remarks>
private Guid VaultForANewHostUnder(HostGroupRowViewModel? inherited) =>
inherited is { } group && TargetVaults.Any(choice => choice.VaultId == group.VaultId)
? group.VaultId
: TargetVaultId;
/// <summary>Opens the selected host for editing.</summary>
[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}.";
}
/// <summary>
/// Opens whichever editor the selected row belongs to.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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;
}
}
/// <summary>Asks about deleting whatever the selected row is.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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.
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
/// <remarks>
/// <para>
/// 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. <see cref="EditingGroupId"/> is what decides which of the two
/// this is, and it is set by <see cref="EditGroup"/> and cleared by everything else.
/// </para>
/// <para>
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
/// <em>wrong</em> — 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.
/// </para>
/// <para>
/// Writes to <see cref="editingGroupVaultId"/>, 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.
/// </para>
/// </remarks>
[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);
}
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
/// <param name="group">
/// 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 <see cref="GroupTarget"/>. 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.
/// </param>
/// <remarks>
/// 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 <see cref="OnSelectedHostChanged"/> — so a command
/// reachable only through <see cref="SelectedGroup"/> would deselect the machine somebody was about to
/// connect to, on a screen that draws no group cards at all.
/// </remarks>
[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}.";
}
/// <summary>
/// Raises the phone's menu of the three things that can be done to a group.
/// </summary>
/// <remarks>
/// <para>
/// The desktop's right-click menu, as a bottom sheet, and the same three entries in the same order for
/// the same reasons: opening is a gesture on the desktop and so is offered here as well, moving sits
/// above the separator because it is not a deletion, and deleting sits below it.
/// </para>
/// <para>
/// Open is the entry this one does <em>not</em> carry, and the difference is real rather than an
/// abbreviation: the desktop's grid holds one level of the tree at a time and the phone's list holds all
/// of it flattened, so there is nothing on this head to open a group <em>into</em>. See the note on
/// <c>SidebarRows</c> in the desktop's HostsScreen.
/// </para>
/// <para>
/// It is a menu rather than three buttons on the heading row, and that is a width decision before it is
/// a taste one: the row already carries a chevron, a name, a vault badge and a count at 360dp, and three
/// icons after them would leave the name a dozen characters. It is also what makes the two destructive
/// entries reachable without either of them being a control a thumb can brush.
/// </para>
/// </remarks>
[RelayCommand]
private void OpenGroupSheet(SidebarGroupHeader? header)
{
// The ungrouped heading has no group behind it, so there is nothing for the three entries to act on.
// The button is left off that row as well; this is the guard for the path a stale row would take.
if (header?.GroupId is not null)
{
GroupSheet = header;
}
}
/// <summary>Closes the group's menu without doing anything.</summary>
/// <remarks>
/// Reached from CANCEL and from a tap on the scrim, and the sheet is dismissible that way for the reason
/// the add sheet is and the host key sheet deliberately is not: "which of these three" has no wrong
/// answer, and none of them is one of them.
/// </remarks>
[RelayCommand]
private void CloseGroupSheet() => GroupSheet = null;
/// <summary>
/// The group a heading in the host list names, or null where it names none.
/// </summary>
/// <remarks>
/// A heading carries an id, a label and a count; every one of the three commands below needs the record.
/// A heading whose group has gone — the ungrouped heading, or one a sync deleted between the list being
/// drawn and the entry being pressed — resolves to nothing and is dropped, rather than opening an editor
/// or arming a question on nothing.
/// </remarks>
private HostGroupRowViewModel? GroupOf(SidebarGroupHeader? header) =>
header?.GroupId is { } groupId
? Groups.FirstOrDefault(row => row.EntityId == groupId)
: null;
/// <summary>
/// Opens a group's editor from its heading in the host list.
/// </summary>
/// <remarks>
/// <para>
/// The phone's only route to <see cref="EditGroup"/>, and it exists because there is no other. The
/// desktop reaches the group editor through the groups panel, which selects a
/// <c>HostGroupRowViewModel</c>; the phone draws no such panel, and its host list draws
/// <c>SidebarGroupHeader</c> rows whose selection deliberately bounces back to the host — a heading is
/// not a thing to be selected. So the heading needs a menu, and the menu's entries need commands that
/// take the header rather than the selection.
/// </para>
/// <para>
/// A <c>+</c> that adds groups with no way to correct one is the same strange thing to ship as a
/// <c>+</c> 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.
/// </para>
/// <para>
/// The row is handed to <see cref="EditGroup"/> 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.
/// </para>
/// <para>
/// The sheet is closed first and unconditionally, including where the heading resolves to nothing. A
/// menu left standing over a command that declined to run is a menu somebody presses again.
/// </para>
/// </remarks>
[RelayCommand]
private void EditGroupFromHeading(SidebarGroupHeader? header)
{
var row = GroupOf(header);
GroupSheet = null;
if (row is not null)
{
EditGroupCommand.Execute(row);
}
}
/// <summary>Opens the group's move panel from its heading in the host list.</summary>
/// <remarks>
/// Aimed by the header rather than by <see cref="GroupTarget"/>, which is the whole reason this exists:
/// that property reads the selected card or the open group, and the phone has neither — so
/// <see cref="MoveGroup"/> called bare on this head would silently do nothing at all. The sheet is
/// closed first, on <see cref="EditGroupFromHeading"/>'s terms.
/// </remarks>
[RelayCommand]
private void MoveGroupFromHeading(SidebarGroupHeader? header)
{
var row = GroupOf(header);
GroupSheet = null;
if (row is not null)
{
MoveGroupCommand.Execute(row);
}
}
/// <summary>Asks the group's deletion question from its heading in the host list.</summary>
/// <remarks>
/// Aimed by the header for the reason <see cref="MoveGroupFromHeading"/> is, and it matters more here:
/// a <see cref="DeleteGroup"/> that quietly aimed at nothing would be a DELETE that appeared to have
/// been pressed and had not.
/// </remarks>
[RelayCommand]
private void DeleteGroupFromHeading(SidebarGroupHeader? header)
{
var row = GroupOf(header);
GroupSheet = null;
if (row is not null)
{
DeleteGroupCommand.Execute(row);
}
}
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
/// <remarks>
/// <para>
/// 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
/// <see cref="ClearGroupEditor"/> prevents.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>The group that is open, and deliberately not the card that is selected</b> — which is where this
/// differs from <see cref="NewHost"/>. 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.
/// </para>
/// <para>
/// <b>And in the open group's vault</b>, which <see cref="NewHost"/> now does too and for the same
/// reason: a parent — or a group — in a second vault is a level half the readers cannot resolve, so the
/// thing being made goes where the thing it is going inside already is. Defaulting to the standing
/// "new items go to" 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.
/// </para>
/// </remarks>
[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.";
}
/// <summary>
/// Whether the group editor has to be dealt with before another editor opens.
/// </summary>
/// <remarks>
/// <inheritdoc cref="AHostEditorIsInTheWay" path="/remarks" />
/// 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.
/// </remarks>
private bool AGroupEditorIsInTheWay()
{
if (IsEditingGroup)
{
Status = "Finish or cancel the group you are editing first.";
}
return IsEditingGroup;
}
/// <summary>The group picker's selection, if it names something of this kind.</summary>
private Guid? GroupBound(AuthenticationKind kind) =>
GroupEditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
/// <summary>
/// Fills the parent picker, leaving out the group itself and everything beneath it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Refusing a descendant here is a convenience, not the guarantee.</b> 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
/// <see cref="HostInheritance"/>.
/// </para>
/// <para>
/// Descendants are found by walking each candidate <em>upwards</em> rather than this group downwards,
/// because upwards is the direction the pointer goes and <see cref="HostInheritance.Chain"/> 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.
/// </para>
/// <para>
/// <b>One vault's candidates, and it is the vault this group is going into rather than the one it is
/// on screen beside.</b> 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.
/// </para>
/// </remarks>
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;
}
/// <summary>Refills the group editor's vault picker, landing on the vault the editor will write to.</summary>
/// <inheritdoc cref="BuildEditorVaultChoices" path="/remarks" />
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));
}
/// <summary>
/// Moves a half-typed group into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, for the reason <see cref="OnEditorSelectedVaultChanged"/> 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
/// <see cref="MoveGroup"/>, which does the re-seal and the tombstone this could not.
/// </remarks>
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);
}
/// <summary>Fills the group's binding picker, keeping whatever it currently defaults to selectable.</summary>
/// <remarks>
/// <see cref="BuildAuthenticationChoices"/> 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.
/// </remarks>
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;
/// <summary>Empties every box in the group editor, so the next open starts from nothing.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>Abandons a rename, leaving the box ready to create one instead.</summary>
[RelayCommand]
private void CancelGroupEdit()
{
ClearGroupEditor();
Status = string.Empty;
}
/// <summary>Asks whether the group being acted on should go, and what should become of its hosts.</summary>
/// <remarks>
/// <para>
/// <b>The hosts are a second question rather than a stated consequence, and that is a reversal.</b> 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 <see cref="DeletionTakesTheHostsToo"/>.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Refused with a host editor open</b>, 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
/// <see cref="MoveHostToGroupAsync"/> refuses for the same reason.
/// </para>
/// <para>
/// Aims where Edit does: at the group it is handed, and failing that at the selected card, which on the
/// desktop is the one the menu opened on. See <see cref="GroupTarget"/>.
/// </para>
/// <para>
/// <b>The argument is the phone's and the fallback is the desktop's.</b> The desktop's menu passes
/// nothing, because the code-behind has already selected whatever was right-clicked; the phone has no
/// group selection to make — a heading is not a thing its list can select — so its sheet passes the row
/// it was opened on. See <see cref="DeleteGroupFromHeading"/>.
/// </para>
/// </remarks>
/// <param name="group">The group to ask about, or null to use <see cref="GroupTarget"/>.</param>
[RelayCommand]
private void DeleteGroup(HostGroupRowViewModel? group)
{
if ((group ?? 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.",
},
};
}
/// <summary>
/// Queues the tombstone for the group that was agreed to, and settles what pointed at it.
/// </summary>
/// <remarks>
/// <para>
/// <b>Nothing is left pointing at the group.</b> 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 <em>another</em> machine still arrives that way.
/// </para>
/// <para>
/// <b>Everything that points at it is written before the group's own tombstone.</b> 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.
/// </para>
/// <para>
/// <b>A read-only host is skipped and counted rather than rewritten.</b> 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 <em>not</em> skipped: a tombstone re-encodes nothing.
/// </para>
/// </remarks>
/// <param name="entityId">The group.</param>
/// <param name="takesTheHosts">Whether the hosts filed under it were agreed to go too.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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);
}
/// <summary>
/// Rewrites everything naming the group about to go, and says how many could not be.
/// </summary>
/// <remarks>
/// Before the tombstone and in this order, which <see cref="DeleteGroupNowAsync"/> gives the reasons for.
/// </remarks>
/// <returns>How many hosts were left holding the reference because they could not be rewritten.</returns>
private async Task<int> ReleaseWhatPointedAtAsync(
HostGroupRowViewModel row,
IEnumerable<HostRowViewModel> filed,
IEnumerable<HostGroupRowViewModel> 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;
}
/// <summary>What the group's deletion did to the machines under it, said only when there were any.</summary>
/// <remarks>
/// 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.
/// </remarks>
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.",
};
/// <summary>Abandons the editor.</summary>
[RelayCommand]
private void CancelEdit()
{
IsEditing = false;
editingEntityId = null;
Status = string.Empty;
}
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
[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);
}
/// <summary>
/// Stores several hosts at once, the way one save does, with the keys they named where those are coming
/// too.
/// </summary>
/// <remarks>
/// <para>
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
/// through the same repositories, the same outbox and the same automatic push as saving one — the import
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host or a key to
/// be written and no second place for the sync wiring to be forgotten.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// ◆ <b>One vault key per file, however many hosts named it.</b> An <c>ssh_config</c> pointing twelve
/// entries at <c>~/.ssh/id_ed25519</c> is the ordinary shape, and twelve copies of one private key would
/// be twelve things to rotate and eleven to forget — which is the argument <c>HostSecret.SshKeyId</c>
/// already makes for referencing a key rather than embedding it. The path is what identifies a file here,
/// because it is the only thing the config gave that two entries can be compared on.
/// </para>
/// <para>
/// <b>A key whose material is already in the keychain is bound to rather than stored again</b>, which is
/// what makes running the import twice harmless. Compared on the armour verbatim, as the codec stores it:
/// a re-import of the same file is byte-identical, and anything that is not is a different key whatever
/// it is called.
/// </para>
/// </remarks>
internal async Task<ImportOutcome> ImportHostsAsync(
IReadOnlyList<ImportedHostRequest> requests,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(requests);
var hosts = 0;
var keys = 0;
var refused = 0;
await RunAsync(
requests.Count == 1 ? "Importing 1 host…" : $"Importing {requests.Count} hosts…",
async () =>
{
// Keyed on the path rather than on the material, and filled as the batch runs rather than up
// front: the second host naming a file has to land on the id the first one produced, and
// nothing is reloaded until the batch finishes.
var bound = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
foreach (var request in requests)
{
if (!request.Host.TryValidate(out _))
{
refused++;
continue;
}
var (keyId, created) = await BindImportedKeyAsync(request, bound, cancellationToken)
.ConfigureAwait(true);
if (created)
{
keys++;
}
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
await session.Hosts
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
.ConfigureAwait(true);
hosts++;
}
await ReloadAsync(cancellationToken).ConfigureAwait(true);
Status = DescribeImport(hosts, keys, refused);
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
return new ImportOutcome(hosts, keys);
}
/// <summary>Finds or stores the key an imported host should be bound to.</summary>
/// <returns>
/// The key to bind to, and whether storing it was what put it there. The second half is what the count
/// reported afterwards is about: binding to a key the keychain already held reads nothing off a disk and
/// must not be reported as having.
/// </returns>
/// <remarks>
/// The keychain is searched as well as this batch's own map, and both are needed. The map answers within
/// one run, where nothing has been reloaded yet; the keychain answers for a run an hour ago, which is
/// what makes importing the same config twice leave one key rather than two.
/// </remarks>
private async Task<(Guid? Id, bool Created)> BindImportedKeyAsync(
ImportedHostRequest request,
Dictionary<string, Guid> bound,
CancellationToken cancellationToken)
{
if (request.Key is not { } key || request.KeyPath is not { } path)
{
return (null, false);
}
if (bound.TryGetValue(path, out var already))
{
return (already, false);
}
if (Keys.FirstOrDefault(row => string.Equals(
row.Key.PrivateKeyPem, key.PrivateKeyPem, StringComparison.Ordinal)) is { } existing)
{
bound[path] = existing.EntityId;
return (existing.EntityId, false);
}
if (!key.TryValidate(out _))
{
return (null, false);
}
var id = await session.SshKeys
.CreateAsync(session.ActiveVaultId, key, cancellationToken)
.ConfigureAwait(true);
bound[path] = id;
return (id, true);
}
/// <summary>What the import did, in one sentence.</summary>
/// <remarks>
/// The keys are named separately from the hosts and only when there are any, because importing key
/// material is the half of this somebody agreed to rather than the half they asked for — folded into
/// "imported 12 hosts" it would be the one number worth saying out loud, said quietly.
/// </remarks>
private string DescribeImport(int hosts, int keys, int refused)
{
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
var material = keys switch
{
0 => string.Empty,
1 => " 1 private key came with them.",
_ => $" {keys} private keys came with them.",
};
return connection() is null
? $"Imported {hosts} host(s). They will sync when you are online.{material}{refusals}"
: $"Imported {hosts} host(s).{material}{refusals}";
}
/// <summary>
/// Opens the panel that asks which vault the selected host should move to.
/// </summary>
/// <remarks>
/// <para>
/// 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
/// <c>VaultItemRepository.MoveAsync</c>. 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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;
}
/// <summary>Abandons the move panel.</summary>
[RelayCommand]
private void CancelMoveHost()
{
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the selected host into the chosen vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>The group and the tags are left behind, and that is the whole of what makes this honest.</b> 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.
/// </para>
/// <para>
/// <b>The key or password binding is kept</b>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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);
// Read before the panel is folded away, because all three of these are answered against it.
var bringing = BringsTheBindingAlong ? BindingOfTheMovingHost() : null;
var stranded = bringing is null ? BindingOutside(row, target.VaultId) : string.Empty;
var moved = Detached(row.Host, row.Resolved.Binding);
IsMovingHost = false;
movingHostId = null;
MoveVaultChoices.Clear();
SelectedMoveVault = null;
BringsTheBindingAlong = false;
await RunAsync(
"Moving…",
async () =>
{
var carried = string.Empty;
// The binding first, so the host can be written naming the id it landed with. An
// interruption between the two leaves the key in the destination and the host still in the
// vault it started in, pointing at a tombstone — visible, and repaired by moving it again.
if (bringing is { } bring)
{
(moved, carried) = await CarriedAlongAsync(
bring, moved, row.EntityId, target.VaultId, cancellationToken)
.ConfigureAwait(true);
}
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}{carried}{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);
}
/// <summary>What the move left behind, said only when it left something.</summary>
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,
};
/// <summary>
/// The host as it will be written on the other side: no group, no tags, and its binding spelled out.
/// </summary>
/// <remarks>
/// <para>
/// The group and the tags go for the reason <see cref="ConfirmMoveHostAsync"/> gives. <b>The binding is
/// written onto the host when it came from a group</b>, and that is the half this used to lose: the
/// group stays behind, so a host that inherited its key arrived in the destination naming nothing at all
/// and authenticating with nothing — a machine that connected before the move and refused after it, with
/// no sentence anywhere saying why.
/// </para>
/// <para>
/// Only the inherited case writes anything. A host that names its own key already carries it, and one
/// that types its password says so with <c>AsksForPassword</c>, which is an answer rather than a gap.
/// </para>
/// </remarks>
private static HostSecret Detached(HostSecret host, ResolvedBinding binding)
{
var moved = host with { GroupId = null, TagIds = TagSet.Empty };
if (!binding.IsInherited || binding.EntityId is not { } entityId)
{
return moved;
}
return binding.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = entityId }
: moved with { CredentialId = entityId };
}
/// <summary>
/// Takes the host's key or password across with it, and re-aims everything else that named it.
/// </summary>
/// <returns>The host as it should now be written, and what to say about what came with it.</returns>
/// <remarks>
/// The moving host is left out of the re-aim and given the new id directly, because it is about to be
/// written into another vault anyway: re-aiming it would be a save in the vault it is leaving, followed
/// immediately by a tombstone for the row that save had just amended.
/// </remarks>
private async Task<(HostSecret Host, string Note)> CarriedAlongAsync(
MovableBinding bring,
HostSecret moved,
Guid movingHostId,
Guid vaultId,
CancellationToken cancellationToken)
{
var (hosts, groups) = PointingAt(bring.Kind, bring.EntityId);
if (await MoveTheBindingAsync(bring, vaultId, cancellationToken).ConfigureAwait(true)
is not { } landed)
{
return (moved, string.Empty);
}
var reaimed = await ReAimAtAsync(
bring.Kind,
landed,
hosts.Where(host => host.EntityId != movingHostId),
groups,
cancellationToken)
.ConfigureAwait(true);
return (
bring.Kind is ResolvedBindingKind.SshKey
? moved with { SshKeyId = landed }
: moved with { CredentialId = landed },
$" The {bring.Noun} '{bring.Label}' came with it.{WhatFollowedIt(reaimed)}");
}
/// <summary>Re-seals one key or password into another vault, or null when its row has gone.</summary>
/// <remarks>
/// Null rather than a throw, because the row is read from a list a background sync can replace: the
/// honest outcome is a host that moves and keeps naming the key where it was, which is exactly what
/// leaving the tick box alone would have done.
/// </remarks>
private async Task<Guid?> MoveTheBindingAsync(
MovableBinding binding,
Guid vaultId,
CancellationToken cancellationToken)
{
if (binding.Kind is ResolvedBindingKind.SshKey)
{
return Keys.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } key
? null
: await session.SshKeys
.MoveAsync(binding.VaultId, vaultId, binding.EntityId, key.Key, cancellationToken)
.ConfigureAwait(true);
}
return Credentials.FirstOrDefault(row => row.EntityId == binding.EntityId) is not { } credential
? null
: await session.Credentials
.MoveAsync(
binding.VaultId, vaultId, binding.EntityId, credential.Credential, cancellationToken)
.ConfigureAwait(true);
}
/// <summary>
/// The warning about a key or password that is not in the vault the host has moved to.
/// </summary>
/// <remarks>
/// <para>
/// Named rather than counted, because which one it is decides what to do about it — and the answer is to
/// bring that key across, which is the tick box beside the picker and needs to know which key.
/// </para>
/// <para>
/// Read from the resolved binding, so a key the host only inherits is warned about too. It is written
/// onto the host by <see cref="Detached"/> on the way over, so it is genuinely what the moved host
/// authenticates with — and it is the case where somebody is least likely to know a key is involved.
/// </para>
/// </remarks>
private string BindingOutside(HostRowViewModel row, Guid vaultId)
{
if (row.Resolved.Binding is not { EntityId: { } entityId } binding)
{
return string.Empty;
}
if (binding.Kind is ResolvedBindingKind.SshKey
&& Keys.FirstOrDefault(key => key.EntityId == entityId) is { } stored
&& stored.VaultId != vaultId)
{
return $" It still authenticates with the key '{stored.Label}', which is in another vault — "
+ "everybody else in this one will find that binding unresolvable.";
}
if (binding.Kind is ResolvedBindingKind.Credential
&& Credentials.FirstOrDefault(stored => stored.EntityId == entityId) 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;
}
/// <summary>Fills the move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveVaultChoices(Guid vaultId)
{
MoveVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveVaultChoices.Add(choice);
}
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
}
/// <summary>Every vault this session can write to except one, in the order a picker should offer them.</summary>
/// <remarks>
/// 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.
/// </remarks>
private IEnumerable<VaultChoiceViewModel> 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));
/// <summary>
/// Opens the panel that asks which vault the group — and everything under it — should move to.
/// </summary>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// Refused for a group written by a newer client, as editing one is, and refused with a host editor open,
/// as <see cref="DeleteGroup"/> is: this rewrites hosts.
/// </para>
/// <para>
/// <b>The argument is the phone's and the fallback is the desktop's</b>, exactly as it is on
/// <see cref="DeleteGroup"/>. See <see cref="MoveGroupFromHeading"/>.
/// </para>
/// </remarks>
/// <param name="group">The group to move, or null to use <see cref="GroupTarget"/>.</param>
[RelayCommand]
private void MoveGroup(HostGroupRowViewModel? group)
{
if ((group ?? 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;
MovingGroupLabel = row.Label;
IsMovingGroup = true;
Status = string.Empty;
}
/// <summary>Abandons the group's move panel.</summary>
[RelayCommand]
private void CancelMoveGroup()
{
if (!IsMovingGroup)
{
return;
}
IsMovingGroup = false;
movingGroupId = null;
MovingGroupLabel = string.Empty;
MoveGroupVaultChoices.Clear();
SelectedMoveGroupVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the group, everything nested inside it and every host filed under any of them.
/// </summary>
/// <remarks>
/// <para>
/// <b>The whole subtree goes, and taking less than that was never coherent.</b> 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.
/// </para>
/// <para>
/// <b>The groups go first, top down, and the hosts last.</b> Each item is re-sealed under the
/// destination's key and takes a new id — see <c>VaultItemRepository.MoveAsync</c> — 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.
/// </para>
/// <para>
/// <b>The parent is left behind and the tags are dropped</b>, 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.
/// </para>
/// <para>
/// <b>Keys and passwords are kept</b>, 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.
/// </para>
/// <para>
/// <b>The group is resolved from the panel's own id rather than from <see cref="GroupTarget"/>.</b> That
/// property is the desktop's selection and is null on the phone, whose sheet aims the move by handing
/// the row in — so reading it here would leave the phone's MOVE button doing nothing at all. It also
/// says the honest thing on both heads: what this moves is the shelf the panel was opened on, and a
/// selection that has since gone elsewhere has already folded the panel away. See
/// <see cref="CloseTheGroupMovePanelIfAimedElsewhere"/>.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveGroupAsync(CancellationToken cancellationToken)
{
if (movingGroupId is not { } moved
|| Groups.FirstOrDefault(group => group.EntityId == moved) is not { } row
|| 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);
}
/// <summary>
/// Re-seals a group, its nested groups and their hosts under another vault's key.
/// </summary>
/// <remarks>
/// The order and what it costs an interruption are <see cref="ConfirmMoveGroupAsync"/>'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.
/// </remarks>
/// <param name="subtree">The group and its nested groups, each after its parent.</param>
/// <param name="filed">The hosts under any of them.</param>
/// <param name="vaultId">The vault they are all going to.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The id the group at the root of it has in its new vault.</returns>
private async Task<Guid> ReSealTheSubtreeAsync(
List<HostGroupRowViewModel> subtree,
IEnumerable<HostRowViewModel> filed,
Guid vaultId,
CancellationToken cancellationToken)
{
var root = subtree[0].EntityId;
var remapped = new Dictionary<Guid, Guid>();
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];
}
/// <summary>
/// The group and every group nested under it, each one after the group it hangs from.
/// </summary>
/// <remarks>
/// <para>
/// A breadth-first walk from the root, and the order is the whole reason it is one: every group but the
/// first is discovered <em>by</em> its parent, so a caller writing the list in order always has the
/// parent's new id in hand before it needs it.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Cycle-safe, and it has to be.</b> 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
/// <see cref="HostGroupSecret.ParentId"/> 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.
/// </para>
/// </remarks>
private List<HostGroupRowViewModel> SubtreeOf(HostGroupRowViewModel root)
{
var ordered = new List<HostGroupRowViewModel> { root };
var found = new HashSet<Guid> { 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;
}
/// <summary>What the move brought with it, said as the counts the user can check against the cards.</summary>
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}.",
};
}
/// <summary>The two things a group cannot take across, said only where it had one.</summary>
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,
};
/// <summary>
/// The warning about keys or passwords that are not in the vault the group has moved to.
/// </summary>
/// <remarks>
/// Counted and named to one, where <see cref="BindingOutside"/> 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.
/// </remarks>
private string BindingsOutside(
IEnumerable<HostGroupRowViewModel> groups,
IEnumerable<HostRowViewModel> 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<Guid>()
.Distinct()
.Select(LabelOfBindingOutside)
.OfType<string>()
.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);
}
/// <summary>Fills the group move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveGroupVaultChoices(Guid vaultId)
{
MoveGroupVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveGroupVaultChoices.Add(choice);
}
SelectedMoveGroupVault = MoveGroupVaultChoices.FirstOrDefault();
}
/// <summary>A keychain item that could be moved, with what the panel needs to say about it.</summary>
/// <param name="Kind">Which list it came from, so the confirmation knows which repository to ask.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now.</param>
/// <param name="IsReadOnly">Whether this build can re-encode it. A move re-encodes.</param>
private sealed record MovableItem(
VaultItemKind Kind,
Guid EntityId,
string Label,
Guid VaultId,
bool IsReadOnly);
/// <summary>A key or password a host's move could carry, resolved to the row that holds it.</summary>
/// <param name="Kind">Key or password.</param>
/// <param name="EntityId">The item.</param>
/// <param name="Label">What it is called.</param>
/// <param name="VaultId">The vault it is in now, which is not the one the host is going to.</param>
private sealed record MovableBinding(
ResolvedBindingKind Kind,
Guid EntityId,
string Label,
Guid VaultId)
{
/// <summary>What to call it in a sentence a person reads.</summary>
internal string Noun => Kind is ResolvedBindingKind.SshKey ? "key" : "password";
}
/// <summary>How many things a move re-aimed, and how many it could not.</summary>
/// <param name="Hosts">Hosts whose own binding now names the item's new id.</param>
/// <param name="Groups">Groups whose default now names it.</param>
/// <param name="Refused">
/// Things left naming the old id, because this build cannot re-encode them or this account cannot
/// write to the vault they are in. Counted rather than swallowed: each one is a host that will refuse
/// to connect, and the sentence afterwards says how many.
/// </param>
[StructLayout(LayoutKind.Auto)]
private readonly record struct ReAimed(int Hosts, int Groups, int Refused);
/// <summary>The selected keychain row, when it is one of the kinds a vault can hand to another.</summary>
/// <inheritdoc cref="CanMoveSelectedItem" path="/remarks" />
private MovableItem? MovableRow() => SelectedVaultItem?.Kind switch
{
VaultItemKind.Key when SelectedKey is { } key =>
new MovableItem(VaultItemKind.Key, key.EntityId, key.Label, key.VaultId, key.IsReadOnly),
VaultItemKind.Credential when SelectedCredential is { } credential => new MovableItem(
VaultItemKind.Credential,
credential.EntityId,
credential.Label,
credential.VaultId,
credential.IsReadOnly),
_ => null,
};
/// <summary>Whether there is a vault to move something out of this one into.</summary>
private bool CanLeaveItsVault(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId != vaultId);
/// <summary>Whether this account may write to one vault at all.</summary>
/// <remarks>
/// Asked before every re-aim. A viewer of a team vault can read the hosts in it and cannot save one, so
/// a key move that tried would queue an operation the server refuses — and the honest answer is to leave
/// that host naming the old id and say so, rather than to fail the move that had already happened.
/// </remarks>
private bool CanWriteTo(Guid vaultId) =>
session.ReadableVaults.Any(vault => vault.CanWrite && vault.VaultId == vaultId);
/// <summary>The binding kind that goes with a keychain row's kind.</summary>
private static ResolvedBindingKind BindingKindOf(VaultItemKind kind) =>
kind is VaultItemKind.Key ? ResolvedBindingKind.SshKey : ResolvedBindingKind.Credential;
/// <summary>
/// Everything that names one key or password by id: the hosts that bind it and the groups that lend it.
/// </summary>
/// <remarks>
/// The hosts' <em>own</em> ids rather than their resolved bindings, which is the opposite of what
/// <see cref="HostsBoundTo"/> reads and is right for the opposite reason. That one warns a person, so it
/// counts everybody who would stop connecting, inherited or not. This one drives writes: a host that
/// inherits its key names nothing, so rewriting it would put a binding on a host that never had one —
/// the group it inherits from is in this list and is the one thing that has to change.
/// </remarks>
private (List<HostRowViewModel> Hosts, List<HostGroupRowViewModel> Groups) PointingAt(
ResolvedBindingKind kind,
Guid entityId)
{
var hosts = Hosts
.Where(row => OwnBinding(row.Host, kind) == entityId)
.ToList();
var groups = Groups
.Where(row => DefaultBinding(row.Group, kind) == entityId)
.ToList();
return (hosts, groups);
}
private static Guid? OwnBinding(HostSecret host, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? host.SshKeyId : host.CredentialId;
private static Guid? DefaultBinding(HostGroupSecret group, ResolvedBindingKind kind) =>
kind is ResolvedBindingKind.SshKey ? group.DefaultSshKeyId : group.DefaultCredentialId;
/// <summary>
/// Points everything that named a moved key or password at the id it landed with.
/// </summary>
/// <remarks>
/// <para>
/// <b>Without this a move is a deletion with extra steps.</b> An item re-sealed into another vault takes
/// a new id — see <c>VaultItemRepository.MoveAsync</c> — so every host bound to the old one would be
/// left naming a tombstone and would refuse to connect rather than fall back to a typed password. The
/// bindings themselves cross vaults perfectly well; it is only the id that changes.
/// </para>
/// <para>
/// A host this build cannot re-encode, or one in a vault this account cannot write to, is skipped and
/// counted. Failing the whole move instead would be worse: the item has already landed, and the
/// alternative to a partial re-aim is none at all.
/// </para>
/// </remarks>
private async Task<ReAimed> ReAimAtAsync(
ResolvedBindingKind kind,
Guid landedId,
IEnumerable<HostRowViewModel> hosts,
IEnumerable<HostGroupRowViewModel> groups,
CancellationToken cancellationToken)
{
var rebound = 0;
var relent = 0;
var refused = 0;
foreach (var host in hosts)
{
if (host.IsReadOnly || !CanWriteTo(host.VaultId))
{
refused++;
continue;
}
await session.Hosts
.UpdateAsync(
host.VaultId,
host.EntityId,
kind is ResolvedBindingKind.SshKey
? host.Host with { SshKeyId = landedId }
: host.Host with { CredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
rebound++;
}
foreach (var group in groups)
{
if (group.IsReadOnly || !CanWriteTo(group.VaultId))
{
refused++;
continue;
}
await session.HostGroups
.UpdateAsync(
group.VaultId,
group.EntityId,
kind is ResolvedBindingKind.SshKey
? group.Group with { DefaultSshKeyId = landedId }
: group.Group with { DefaultCredentialId = landedId },
cancellationToken)
.ConfigureAwait(true);
relent++;
}
return new ReAimed(rebound, relent, refused);
}
/// <summary>
/// The key or password the open host move panel could carry, or null when there is nothing to carry.
/// </summary>
/// <remarks>
/// Null in four cases, and each is a case where the tick box would be a lie: the host authenticates with
/// a typed password, the binding dangles already, the item is in the vault the host is going to, or it is
/// one this build cannot re-encode.
/// </remarks>
private MovableBinding? BindingOfTheMovingHost()
{
if (!IsMovingHost
|| movingHostId is not { } hostId
|| Hosts.FirstOrDefault(row => row.EntityId == hostId) is not { } host
|| SelectedMoveVault is not { } target)
{
return null;
}
return MovableBindingOf(host, target.VaultId);
}
/// <summary>
/// The key or password one host authenticates with, when a move to one vault could carry it.
/// </summary>
/// <param name="host">The machine being moved.</param>
/// <param name="targetVaultId">Where it is going.</param>
/// <remarks>
/// ◆ Split out of <see cref="BindingOfTheMovingHost"/> so the phone's action bar can ask the same
/// question its own way. That one reads the desktop drawer's panel — a flag, an id and a picker — and
/// the phone's move is over a set with a picker of its own; sharing the panel's state between the two
/// is how the heads would come to disagree about which host "the host" is. What they must not disagree
/// about is the answer, which is this.
/// </remarks>
private MovableBinding? MovableBindingOf(HostRowViewModel host, Guid targetVaultId)
{
if (host.Resolved.Binding is not { EntityId: { } entityId } binding)
{
return null;
}
return binding.Kind switch
{
ResolvedBindingKind.SshKey =>
Keys.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } key
&& key.VaultId != targetVaultId
&& CanWriteTo(key.VaultId)
? new MovableBinding(binding.Kind, entityId, key.Label, key.VaultId)
: null,
ResolvedBindingKind.Credential =>
Credentials.FirstOrDefault(row => row.EntityId == entityId) is { IsReadOnly: false } stored
&& stored.VaultId != targetVaultId
&& CanWriteTo(stored.VaultId)
? new MovableBinding(binding.Kind, entityId, stored.Label, stored.VaultId)
: null,
_ => null,
};
}
/// <summary>The action bar's answer to the same question, for the one host it can be asked about.</summary>
/// <inheritdoc cref="BringsTheChosenBindingAlong" path="/remarks" />
private MovableBinding? ChosenBindingToBring()
{
if (!IsSendingChosenHostsToAVault
|| ChosenHostsAreBeingCopied
|| TheChosenHost is not { } host
|| SelectedChosenHostVault is not { } target)
{
return null;
}
return MovableBindingOf(host, target.VaultId);
}
/// <summary>Re-asks the binding question, which is answered against the vault in the picker.</summary>
private void TheBindingQuestionChanged()
{
OnPropertyChanged(nameof(HasABindingToBring));
OnPropertyChanged(nameof(BindingToBringQuestion));
OnPropertyChanged(nameof(BindingToBringNote));
OnPropertyChanged(nameof(HasAChosenBindingToBring));
OnPropertyChanged(nameof(ChosenBindingToBringQuestion));
OnPropertyChanged(nameof(ChosenBindingToBringNote));
}
partial void OnSelectedMoveVaultChanged(VaultChoiceViewModel? value) => TheBindingQuestionChanged();
partial void OnIsMovingHostChanged(bool value) => TheBindingQuestionChanged();
partial void OnSelectedChosenHostVaultChanged(VaultChoiceViewModel? value) =>
TheBindingQuestionChanged();
partial void OnIsSendingChosenHostsToAVaultChanged(bool value) => TheBindingQuestionChanged();
/// <summary>What else authenticates with one item, for the tick box beside the host's picker.</summary>
private string WhatElseUses(ResolvedBindingKind kind, Guid entityId, string label, Guid? besidesHost)
{
var (hosts, groups) = PointingAt(kind, entityId);
var others = hosts.Count(row => row.EntityId != besidesHost);
return Users(others, groups.Count, "other host") is not { Length: > 0 } phrase
? $"Nothing else authenticates with '{label}', so nothing is left behind by bringing it."
: $"Also used by {phrase}, which will be re-aimed at it in its new vault — and for anybody else "
+ "in the vault it leaves, it is gone.";
}
/// <summary>What uses one item, for the keychain's own move panel.</summary>
private string WhatUses(ResolvedBindingKind kind, Guid entityId)
{
var (hosts, groups) = PointingAt(kind, entityId);
return Users(hosts.Count, groups.Count, "host") is not { Length: > 0 } phrase
? string.Empty
: $"Used by {phrase}, which will be re-aimed at it in the vault it moves to.";
}
/// <summary>The hosts and groups that name something, counted into a phrase.</summary>
/// <remarks>
/// Empty when nothing does, so each caller can say its own sentence about nothing rather than being
/// handed "0 hosts" to put in the middle of one.
/// </remarks>
private static string Users(int hosts, int groups, string hostNoun)
{
var machines = hosts switch
{
0 => string.Empty,
1 => $"one {hostNoun}",
_ => $"{hosts} {hostNoun}s",
};
var shelves = groups switch
{
0 => string.Empty,
1 => "one group",
_ => $"{groups} groups",
};
return (machines, shelves) switch
{
("", "") => string.Empty,
("", _) => shelves,
(_, "") => machines,
_ => $"{machines} and {shelves}",
};
}
/// <summary>
/// Opens the panel that asks which vault the selected key or password should move to.
/// </summary>
/// <remarks>
/// <para>
/// The host's panel again — see <see cref="MoveHost"/> — and the gap it closes is the one the host's
/// move kept running into: moving a machine into a team's vault left the key it authenticates with in
/// the vault it came from, where the team cannot read it. Until now the only remedy was to paste the
/// private half into a second item, which is a private key on a clipboard and two items nobody can tell
/// apart afterwards.
/// </para>
/// <para>
/// Refused for an item 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.
/// </para>
/// </remarks>
[RelayCommand]
private void MoveSelectedItem()
{
if (MovableRow() is not { } item || AVaultEditorIsInTheWay())
{
return;
}
if (item.IsReadOnly)
{
Status = "This 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;
}
BuildMoveItemVaultChoices(item.VaultId);
if (MoveItemVaultChoices.Count == 0)
{
Status = $"There is nowhere to move '{item.Label}' to: this is the only vault you can write to.";
return;
}
// As the host's panel disarms a deletion aimed at the same host: two questions about one item, one
// of which destroys it, is not a pane anybody should have to read carefully.
PendingDeletion = null;
movingItemId = item.EntityId;
movingItemKind = item.Kind;
movingItemVaultId = item.VaultId;
MovingItemLabel = item.Label;
MovingItemUsage = WhatUses(BindingKindOf(item.Kind), item.EntityId);
IsMovingItem = true;
Status = string.Empty;
}
/// <summary>Abandons the keychain's move panel.</summary>
[RelayCommand]
private void CancelMoveItem()
{
if (!IsMovingItem)
{
return;
}
IsMovingItem = false;
movingItemId = null;
MovingItemLabel = string.Empty;
MovingItemUsage = string.Empty;
MoveItemVaultChoices.Clear();
SelectedMoveItemVault = null;
Status = string.Empty;
}
/// <summary>
/// Moves the key or password into the chosen vault, and re-aims everything that named it.
/// </summary>
/// <remarks>
/// <para>
/// <b>The item first, the re-aims after</b>, because each of those has to name the id it landed with.
/// What an interruption between them leaves is a key in its new vault and some hosts still naming the
/// old one, which is visible — those hosts say they cannot resolve their binding — and repaired by
/// binding them again. The other order cannot be written at all.
/// </para>
/// <para>
/// <b>The hosts are re-aimed across every vault they are in, not only the one the key came from.</b> A
/// binding resolves over everything this session can read, which is the arrangement one key on twenty
/// hosts in three vaults exists for — so a re-aim scoped to one vault would quietly break the other two.
/// </para>
/// </remarks>
[RelayCommand]
private async Task ConfirmMoveItemAsync(CancellationToken cancellationToken)
{
if (movingItemId is not { } entityId
|| SelectedMoveItemVault is not { } target
|| MovableRow() is not { IsReadOnly: false })
{
return;
}
var kind = movingItemKind;
var from = movingItemVaultId;
var label = MovingItemLabel;
var bindingKind = BindingKindOf(kind);
var (hosts, groups) = PointingAt(bindingKind, entityId);
var name = target.Name;
var key = Keys.FirstOrDefault(row => row.EntityId == entityId);
var credential = Credentials.FirstOrDefault(row => row.EntityId == entityId);
CancelMoveItemCommand.Execute(null);
await RunAsync(
"Moving…",
async () =>
{
var landed = kind is VaultItemKind.Key
? await session.SshKeys
.MoveAsync(from, target.VaultId, entityId, key!.Key, cancellationToken)
.ConfigureAwait(true)
: await session.Credentials
.MoveAsync(from, target.VaultId, entityId, credential!.Credential, cancellationToken)
.ConfigureAwait(true);
var reaimed = await ReAimAtAsync(
bindingKind, landed, hosts, groups, cancellationToken)
.ConfigureAwait(true);
await ReloadAsync(cancellationToken).ConfigureAwait(true);
// By its new id, as a moved host's pane is: leaving the pane on the row it came from would
// read as the item having been deleted rather than moved.
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == landed);
Status = $"Moved '{label}' to {name}.{WhatFollowedIt(reaimed)}";
}).ConfigureAwait(true);
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
}
/// <summary>What the re-aim achieved, said as the counts somebody can check against the cards.</summary>
private static string WhatFollowedIt(ReAimed reaimed)
{
var followed = Users(reaimed.Hosts, reaimed.Groups, "host") is { Length: > 0 } phrase
? $" {phrase} now point at it there."
: string.Empty;
var left = reaimed.Refused switch
{
0 => string.Empty,
1 => " One thing that used it could not be rewritten here and still names the old item; it will "
+ "refuse to connect until it is bound again.",
_ => $" {reaimed.Refused} things that used it could not be rewritten here and still name the old "
+ "item; they will refuse to connect until they are bound again.",
};
return followed + left;
}
/// <summary>Fills the keychain move panel's picker with every vault this session can write to but that one.</summary>
private void BuildMoveItemVaultChoices(Guid vaultId)
{
MoveItemVaultChoices.Clear();
foreach (var choice in WritableVaultsBesides(vaultId))
{
MoveItemVaultChoices.Add(choice);
}
SelectedMoveItemVault = MoveItemVaultChoices.FirstOrDefault();
}
/// <summary>Asks whether the selected host should go.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
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);
}
/// <summary>Starts a new SSH key.</summary>
[RelayCommand]
private void NewKey()
{
if (AVaultEditorIsInTheWay())
{
return;
}
Section = VaultSection.Keys;
editingKeyId = null;
editingKeyVaultId = TargetVaultId;
ClearKeyEditor();
IsEditingKey = true;
Status = "Adding an SSH key.";
}
/// <summary>Opens the selected key for editing.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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}.";
}
/// <summary>Opens the form in front of generating a key.</summary>
[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.";
}
/// <summary>Chooses which kind of key to make.</summary>
/// <remarks>
/// A command and two buttons rather than a selector bound to <see cref="GenerateAlgorithm"/>, 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.
/// </remarks>
[RelayCommand]
private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm;
/// <summary>Abandons the generate form without making anything.</summary>
[RelayCommand]
private void CancelGenerateKey()
{
IsGeneratingKey = false;
GenerateComment = string.Empty;
Status = string.Empty;
}
/// <summary>
/// Makes a new key pair and drops it into the key editor, unsaved.
/// </summary>
/// <remarks>
/// <para>
/// <b>It does not save.</b> 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.
/// </para>
/// <para>
/// <b>Off the UI thread.</b> 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.
/// </para>
/// <para>
/// The private key exists in memory from here until the editor is cleared, as a pasted one does. See
/// <c>SshKeySecret</c> for why a .NET string is the honest choice for that and what it does not buy.
/// </para>
/// </remarks>
[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);
}
/// <summary>
/// Puts the selected key's public half on the clipboard.
/// </summary>
/// <remarks>
/// 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 <c>authorized_keys</c>; a private key on a clipboard is a private key
/// in every application on the machine and in whatever syncs it between them.
/// </remarks>
[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.";
}
/// <summary>Abandons the key editor, clearing the material out of it.</summary>
[RelayCommand]
private void CancelKeyEdit()
{
IsEditingKey = false;
editingKeyId = null;
ClearKeyEditor();
Status = string.Empty;
}
/// <summary>Stores the key editor's contents, encrypted, and queues it for the server.</summary>
[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);
}
/// <summary>Asks whether the selected key should go.</summary>
/// <remarks>
/// 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 <c>authorized_keys</c> file on a
/// server is not something this application has ever written to.
/// </remarks>
[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));
}
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
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);
}
/// <summary>Starts a new credential.</summary>
[RelayCommand]
private void NewCredential()
{
if (AVaultEditorIsInTheWay())
{
return;
}
Section = VaultSection.Credentials;
editingCredentialId = null;
editingCredentialVaultId = TargetVaultId;
ClearCredentialEditor();
IsEditingCredential = true;
Status = "Adding a credential.";
}
/// <summary>Opens the selected credential for editing.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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}.";
}
/// <summary>Starts a new bucket.</summary>
/// <remarks>
/// Path-style addressing starts off, which is the AWS default — and <see cref="EditObjectStore"/> loads
/// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why.
/// </remarks>
[RelayCommand]
private void NewObjectStore()
{
if (AVaultEditorIsInTheWay())
{
return;
}
Section = VaultSection.Buckets;
editingObjectStoreId = null;
ClearObjectStoreEditor();
IsEditingObjectStore = true;
Status = "Adding a bucket.";
}
/// <summary>Opens the selected bucket for editing.</summary>
[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}.";
}
/// <summary>Abandons the bucket editor, clearing the secret access key out of it.</summary>
[RelayCommand]
private void CancelObjectStoreEdit()
{
IsEditingObjectStore = false;
editingObjectStoreId = null;
ClearObjectStoreEditor();
Status = string.Empty;
}
/// <summary>Stores the bucket editor's contents, encrypted, and queues it for the server.</summary>
[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);
}
/// <summary>Starts a new tag from the keychain screen.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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.";
}
/// <summary>Loads the selected tag's name into the box, so saving renames it.</summary>
[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}.";
}
/// <summary>Abandons the tag editor.</summary>
[RelayCommand]
private void CancelTagEdit()
{
IsEditingTag = false;
editingTagId = null;
TagEditorLabel = string.Empty;
}
/// <summary>
/// Stores the tag in the box, encrypted, and queues it for the server.
/// </summary>
/// <remarks>
/// <b>A rename is one write, and that is the whole reason this type exists.</b> 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 <see cref="TagSecret"/>.
/// </remarks>
[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);
}
/// <summary>Asks whether the selected tag should go.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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.");
}
/// <summary>Queues a tombstone for the tag that was agreed to.</summary>
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);
}
/// <summary>Asks whether the selected bucket should go.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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.");
}
/// <summary>Queues a tombstone for the bucket that was agreed to.</summary>
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);
}
/// <summary>Empties the bucket editor, including the secret access key.</summary>
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,
};
/// <summary>Abandons the credential editor, clearing the password out of it.</summary>
[RelayCommand]
private void CancelCredentialEdit()
{
IsEditingCredential = false;
editingCredentialId = null;
ClearCredentialEditor();
Status = string.Empty;
}
/// <summary>Stores the credential editor's contents, encrypted, and queues it for the server.</summary>
[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);
}
/// <summary>Asks whether the selected credential should go.</summary>
[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));
}
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
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);
}
/// <summary>Carries out the deletion that was asked about.</summary>
/// <remarks>
/// 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.
/// </remarks>
[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.ChosenHosts:
await DeleteChosenHostsNowAsync(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;
}
}
/// <summary>Thinks better of it.</summary>
[RelayCommand]
private void CancelDelete() => PendingDeletion = null;
/// <summary>
/// Where a deleted item goes, and how far.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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.";
/// <summary>
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
/// </summary>
/// <remarks>
/// <para>
/// 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 <see cref="TryBuildAuthentication" />.
/// </para>
/// <para>
/// <b>Counted over the resolved binding, so a group's default is counted too.</b> 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.
/// </para>
/// </remarks>
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.";
}
/// <summary>
/// Withdraws trust from the selected pin's endpoint.
/// </summary>
/// <remarks>
/// <para>
/// Goes through the same <c>ForgetAsync</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
[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);
}
/// <summary>
/// Opens a terminal on the selected host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> 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 — <see cref="ConnectionStarting"/> puts a tab there before anything is
/// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
/// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
/// <c>[RelayCommand]</c> 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 <c>MainWindowViewModel.CloseTabAsync</c>.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
/// <summary>
/// Connects to the host a tap landed on, or raises the phone's bar when it needs a password first.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's tap, and it lives here rather than in the head because the branch is a product rule
/// rather than a gesture.</b> Which gesture means "open this" is the head's business — that is why the
/// files screen maps its own tap in code-behind — but <em>whether this machine can be reached without
/// asking for anything</em> is the same question the password sheet's own box answers, and a copy of it
/// in a view would be a second reading of a binding chain that already has one.
/// </para>
/// <para>
/// The two outcomes are both "connect": one of them arrives, and the other needs a secret first and so
/// raises the password sheet and says so. What it must never do is quietly connect with no password, or
/// raise the sheet for a host that did not need one. See <see cref="IsAskingForConnectPassword"/>, which
/// is what is left of the bar this used to open for every machine.
/// </para>
/// <para>
/// A successful tap lowers the sheet. Tapping a second machine while the first one's sheet is up would
/// otherwise leave it behind on the new selection, which is a question nobody asked, raised by the
/// gesture that exists to avoid raising one.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries at length.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectToRowAsync(HostRowViewModel? row)
{
if (row is null)
{
return Task.CompletedTask;
}
SelectedHost = row;
// Read after the assignment, because both are about the row that was just chosen. ConnectPassword
// is checked as well as the binding: a sheet already up with a password typed into it is exactly the
// second tap this should honour rather than answer with the same instruction again.
if (SelectedHostAsksForAPassword && ConnectPassword.Length == 0)
{
IsAskingForConnectPassword = true;
Status = $"{row.Label} asks for a password.";
return Task.CompletedTask;
}
IsAskingForConnectPassword = false;
return ConnectToSelectedHostAsync(CancellationToken.None);
}
/// <summary>
/// Lowers the password sheet without connecting, taking what was typed with it.
/// </summary>
/// <remarks>
/// The box is cleared, on the same terms the files screen clears its own: it is a secret nobody asked to
/// keep, and leaving it behind would mean the next tap on a different machine starts with somebody else's
/// password already in the box — and, worse, would satisfy the length check above and dial with it.
/// </remarks>
[RelayCommand]
private void CancelConnectPassword()
{
IsAskingForConnectPassword = false;
ConnectPassword = string.Empty;
RemembersConnectPassword = false;
Status = string.Empty;
}
/// <inheritdoc cref="ConnectAsync" />
/// <param name="cancellationToken">
/// 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.
/// </param>
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;
}
// After the refusal and before the dial, so the phone's password sheet stays up for a box that was
// filled in wrongly and goes for one that is about to be used. It is set here rather than in the
// sheet's own button so that every way of connecting lowers it — a tap on another row, the host-key
// retry replaying an attempt, the action bar's CONNECT.
IsAskingForConnectPassword = false;
await ConnectToAsync(
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
authentication,
cancellationToken).ConfigureAwait(true);
}
/// <summary>
/// Opens a terminal on somewhere that is not in the keychain.
/// </summary>
/// <remarks>
/// <para>
/// <b>The one connection this application makes to a machine it has never been told about.</b> 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.
/// </para>
/// <para>
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
/// written there at length. A key is a reason to save the host.
/// </para>
/// <para>
/// Nothing is written to the keychain, deliberately. What <em>is</em> 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.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
/// <inheritdoc cref="ConnectManuallyAsync()" />
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);
}
/// <summary>
/// Reads <c>user@host</c>, with an optional <c>:port</c>, or says why it cannot.
/// </summary>
/// <remarks>
/// <para>
/// The username is required rather than defaulted to this device's account name, which is what
/// <c>ssh</c> 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.
/// </para>
/// <para>
/// The port defaults to 22 and refuses anything outside 165535, which is the range
/// <c>HostSecret.TryValidate</c> already enforces for a saved host. A target that cannot be stored is
/// not one this path should be able to dial either.
/// </para>
/// <para>
/// IPv6 in brackets is not accepted, and the refusal says so rather than silently reading
/// <c>::1</c>'s last colon as a port separator. Nothing else in this application accepts a bracketed
/// address — <c>HostSecret.Hostname</c> 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.
/// </para>
/// </remarks>
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;
}
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
/// <remarks>
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
/// this level — its account comes out of <see cref="TryBuildAuthentication"/>, 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.
/// </remarks>
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
/// <remarks>
/// One method rather than two, and it is the same argument <see cref="TryBuildConnectionRequest"/> 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.
/// </remarks>
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);
}
/// <summary>
/// Pins the offered host key and retries.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
[RelayCommand]
private void RejectHostKey()
{
PendingHostKey = null;
Status = "The host key was not trusted, so nothing was connected.";
}
/// <summary>
/// Withdraws trust from every key pinned for the host being edited.
/// </summary>
/// <remarks>
/// <para>
/// 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 <em>here</em> — 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.
/// </para>
/// <para>
/// Applies to the host's <em>saved</em> 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.
/// </para>
/// </remarks>
[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);
}
/// <summary>
/// Marks every shown conflict as seen.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[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);
}
/// <inheritdoc />
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);
}
/// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
/// <remarks>
/// <para>
/// 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 <c>SessionOpened</c> 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…".
/// </para>
/// <para>
/// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
/// operation has timed out", and the one thing worth saying is where to look: a runtime this application
/// does not install.
/// </para>
/// <para>
/// <b>An unknown host key is not a failure and is deliberately not logged.</b> 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 <em>is</em> 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.
/// </para>
/// <para>
/// <b>Everything else is caught by shape rather than by type.</b> 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.
/// </para>
/// </remarks>
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);
}
}
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
/// <remarks>
/// 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.
/// </remarks>
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
{
Status = reason;
ConnectionFailed?.Invoke(
this,
new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
}
/// <summary>
/// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
/// </summary>
/// <remarks>
/// 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 <see cref="ConnectionFailedEventArgs"/>. 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.
/// </remarks>
private void Answer(ConnectionAttemptEventArgs attempt, string status)
{
Status = status;
ConnectionFailed?.Invoke(
this,
new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
}
/// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
/// <remarks>
/// 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 <c>catch</c> per way of not having one.
/// </remarks>
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);
}
}
/// <summary>
/// Turns the password that just worked into a keychain credential bound to this host.
/// </summary>
/// <remarks>
/// <para>
/// <b>Only after a handshake the remote accepted.</b> 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.
/// </para>
/// <para>
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
/// 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 <see cref="HostSecret"/>
/// on why the binding is an id and not a copy.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Every failure is reported and swallowed. The caller's <c>catch</c> 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.
/// </para>
/// </remarks>
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);
}
/// <summary>The address as actually dialled.</summary>
/// <remarks>
/// 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.
/// </remarks>
private static string Dialled(ConnectionTarget target, HostAuthentication authentication) =>
string.Create(
CultureInfo.InvariantCulture,
$"{authentication.Username}@{target.Hostname}:{target.Port}");
/// <summary>
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
/// </summary>
/// <remarks>
/// <para>
/// <b>Rate-limited, because pruning writes.</b> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault
/// somebody needs to be told about mid-sync.
/// </para>
/// </remarks>
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.
}
}
/// <summary>Records a connection that never became a session.</summary>
/// <remarks>
/// 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.
/// </remarks>
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);
}
/// <summary>
/// Everything the SSH stack needs to authenticate as somebody on a host.
/// </summary>
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
/// <param name="Credential">What proves it.</param>
/// <remarks>
/// 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".
/// </remarks>
private sealed record HostAuthentication(string Username, SshCredential Credential);
/// <summary>
/// The machine a connection is being made to, however it was named.
/// </summary>
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
/// <param name="Hostname">The address to dial.</param>
/// <param name="Port">The port to dial, already resolved through any group.</param>
/// <param name="Row">
/// The keychain host this came from, or null for a machine that is not in the keychain.
/// </param>
/// <remarks>
/// <para>
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. 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.
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
/// log has always been able to hold a connection with no item behind it.
/// </para>
/// <para>
/// <b>The row is still here, and only two things read it.</b> 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.
/// </para>
/// </remarks>
private sealed record ConnectionTarget(
string Label,
string Hostname,
int Port,
HostRowViewModel? Row = null)
{
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
internal Guid? HostId => Row?.EntityId;
}
/// <summary>
/// Works out how a host authenticates, or says why it cannot.
/// </summary>
/// <remarks>
/// <para>
/// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
/// <c>MemoryStream</c> — 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 <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
/// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
/// <summary>
/// Works out how to reach a host, or says why it cannot.
/// </summary>
/// <remarks>
/// The same resolution the Connect button performs, exposed because file transfer opens its own
/// connection — see <c>ISftpSession</c> — 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 <see cref="ConnectPassword"/> 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.
/// </remarks>
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;
}
/// <summary>
/// Turns a host into a key, a password or a prompt, or says why it cannot.
/// </summary>
/// <remarks>
/// <para>
/// <b>Takes the resolved host as well as the stored one, and this is where group context used to be
/// lost.</b> Both callers of this and of <see cref="TryBuildConnectionRequest"/> used to hand over a
/// bare <see cref="HostSecret"/>, 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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);
}
/// <summary>
/// The last thing every branch has to agree on: there is somebody to log in as.
/// </summary>
/// <remarks>
/// <para>
/// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> 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.
/// </para>
/// <para>
/// 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 <c>deploy</c> once.
/// </para>
/// </remarks>
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,
};
/// <summary>The picker's selection, if it names something of this kind.</summary>
private Guid? Bound(AuthenticationKind kind) =>
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
/// <summary>
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
/// </summary>
/// <param name="boundKeyId">The key the host names, if any.</param>
/// <param name="boundCredentialId">The credential the host names, if any.</param>
/// <param name="asksForPassword">Whether the host is pinned to a typed password.</param>
/// <param name="grouped">Whether the host is filed under a group, and so has anything to inherit.</param>
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// <b>Four entries where there were three, and only for a host in a group.</b> The three states two
/// nullable ids could carry became four when naming neither came to mean "inherit"; see
/// <see cref="AuthenticationKind.Inherited"/>. For an ungrouped host the fourth would behave exactly like
/// the first, so it is left out rather than offered and then explained.
/// </para>
/// </remarks>
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));
}
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </remarks>
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;
/// <summary>Fills the group picker, keeping whatever the host is currently filed under selectable.</summary>
/// <param name="groupId">The group the host names, if any.</param>
/// <remarks>
/// A group that is no longer in the vault gets a placeholder, for the reason
/// <see cref="BuildAuthenticationChoices"/> 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.
/// </remarks>
/// <summary>Refills the host editor's vault picker, landing on the vault the editor will write to.</summary>
/// <remarks>
/// Filled from <see cref="TargetVaults"/>, which is already the readable-and-writable set and is kept
/// in step with the session by <see cref="RebuildTargetVaults"/>. 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.
/// </remarks>
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));
}
/// <summary>
/// Moves a half-typed host into the vault just chosen for it.
/// </summary>
/// <remarks>
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
/// the control. An existing host <em>can</em> change vaults — see <see cref="MoveHost"/> — 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.
/// </remarks>
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);
}
/// <summary>The group, if the vault being written to actually has it; otherwise none.</summary>
private Guid? GroupInEditingVault(Guid? groupId) =>
groupId is { } id
&& groupsByVault.TryGetValue(editingHostVaultId, out var groups)
&& groups.Any(choice => choice.EntityId == id)
? id
: null;
/// <summary>Refills the host editor's group picker for one vault.</summary>
/// <param name="groupId">The group to land on, or null for none.</param>
/// <remarks>
/// <para>
/// The vault's own groups and no others — see <see cref="groupsByVault"/>. 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.
/// </para>
/// <para>
/// A group the vault no longer has keeps a placeholder entry, so that editing a host's port cannot
/// quietly unfile it.
/// </para>
/// </remarks>
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,
};
/// <remarks>
/// 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
/// <c>SshKeySecret.PrivateKeyPem</c> stores it verbatim. Everything else is trimmed, because a label
/// with a trailing space sorts oddly and reads as a different name.
/// </remarks>
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,
};
/// <summary>
/// Whether the host editor has to be dealt with before the sidebar starts another one.
/// </summary>
/// <remarks>
/// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
/// <c>HostSidebar</c>, 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 <see cref="AVaultEditorIsInTheWay"/> 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.
/// </remarks>
private bool AHostEditorIsInTheWay()
{
if (IsEditing)
{
Status = "Finish or cancel the host you are editing first.";
}
return IsEditing;
}
/// <summary>
/// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
/// </summary>
/// <remarks>
/// <para>
/// 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 <c>CancelKeyEdit</c> 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
/// see <see cref="AHostEditorIsInTheWay"/> — 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.
/// </para>
/// </remarks>
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));
}
/// <remarks>
/// 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.
/// </remarks>
/// <remarks>
/// 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
/// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
/// <see cref="LastSyncFailed"/> 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.
/// </remarks>
/// <remarks>
/// 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.
/// </remarks>
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> 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));
/// <remarks>
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. 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.
/// </remarks>
private static string Describe(IReadOnlyList<VaultSyncReport> 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<SyncReport> 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<string>();
// "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<Task> 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));
// 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);
}
}
/// <remarks>
/// <para>
/// 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.
/// </para>
/// <para>
/// A null is left alone rather than cleared through. It arrives from the <c>ListBox</c>'s own answer to
/// the <c>Reset</c> 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 <see cref="RebuildVisibleHosts"/>, which sets
/// <see cref="SelectedHost"/> itself.
/// </para>
/// </remarks>
partial void OnSelectedSidebarRowChanged(ISidebarRow? value)
{
switch (value)
{
case HostRowViewModel host:
SelectedHost = host;
break;
case SidebarGroupHeader:
SelectedSidebarRow = SelectedHost;
break;
default:
break;
}
}
/// <remarks>
/// The other half of the shared selection; see <see cref="OnSelectedHostChanged"/>. 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.
/// </remarks>
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
{
if (value is not null)
{
SelectedHost = null;
}
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
}
/// <remarks>
/// <para>
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
/// from <see cref="OpenGroup"/>, and that command has already dropped the selection before assigning
/// here — see the property.
/// </para>
/// <para>
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
/// against <see cref="GroupTarget"/> 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 <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid.
/// </para>
/// </remarks>
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
{
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
CloseTheGroupMovePanelIfAimedElsewhere();
OnPropertyChanged(nameof(GroupTarget));
RebuildGroupLevel();
RebuildVisibleHosts();
}
/// <summary>Folds the group's move panel away once the buttons under it point at something else.</summary>
/// <remarks>
/// By entity id and not by row, for the reason <see cref="DisarmIfAimedElsewhere"/> 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.
/// </remarks>
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(IsConfirmingChosenHostDeletion));
OnPropertyChanged(nameof(AChosenHostPanelIsOpen));
OnPropertyChanged(nameof(ShowsAddButton));
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));
}
/// <summary>
/// Takes the question away when the selection it was asked about has moved on.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
{
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
{
PendingDeletion = null;
}
}
/// <remarks>
/// 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.
/// </remarks>
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
/// <remarks>
/// Its own method purely for length: <see cref="RebuildVaultItems"/> is one <c>if</c> 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.
/// </remarks>
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));
}
}
/// <summary>Adds the bucket rows to the table, when the table is showing them.</summary>
/// <remarks>
/// Out of <see cref="RebuildVaultItems"/> for the reason <see cref="AddTagRows"/> is — length — and this
/// is the arm that left rather than the newest one, because a rebuild that also has to say whether the
/// selected row can be moved has one line more than it can hold.
/// </remarks>
private void AddBucketRows()
{
if (Section is not (VaultSection.All or VaultSection.Buckets))
{
return;
}
foreach (var store in ObjectStores)
{
VaultItems.Add(new VaultItemRowViewModel(
VaultItemKind.ObjectStore,
store.EntityId,
store.Label,
"BUCKET",
store.Description,
store.Badge,
store.HasUnsyncedChanges));
}
}
/// <summary>
/// Refills the vault table from the typed lists.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// This is where a hidden vault's keys and passwords come off the keychain — the table rather than
/// <see cref="Keys"/> and <see cref="Credentials"/> themselves, which stay whole for the reason
/// <see cref="IsVaultShown"/> gives. Tags and buckets are read from the active vault alone, which
/// cannot be hidden, so neither needs a test of its own.
/// </para>
/// </remarks>
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();
AddBucketRows();
// 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));
// A reload replaces every row object, and the selection is restored by id — so the setter above may
// not have fired even though the row this answers about is a different instance. Asked again here,
// because the answer decides whether the pane draws MOVE at all.
OnPropertyChanged(nameof(CanMoveSelectedItem));
}
/// <remarks>
/// 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.
/// </remarks>
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;
}
// After the switch, not with the three above it: this one is answered from the typed selection the
// switch has just made, so asking before it would answer about the row that was selected before.
OnPropertyChanged(nameof(CanMoveSelectedItem));
// The move panel names one item and its picker is built from that item's vault, so a selection that
// has gone elsewhere has left it aimed at something nobody is looking at. The deletion question
// above is disarmed the same way and for the same reason.
if (IsMovingItem && movingItemId != value?.EntityId)
{
CancelMoveItemCommand.Execute(null);
}
}
/// <remarks>
/// 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.
/// </remarks>
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));
}
/// <remarks>
/// 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.
/// </remarks>
partial void OnSectionChanged(VaultSection value)
{
OnPropertyChanged(nameof(ShowsAll));
OnPropertyChanged(nameof(ShowsKeys));
OnPropertyChanged(nameof(ShowsCredentials));
OnPropertyChanged(nameof(ShowsBuckets));
OnPropertyChanged(nameof(ShowsTags));
OnPropertyChanged(nameof(SectionTitle));
RebuildVaultItems();
}
/// <remarks>
/// 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.
/// </remarks>
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);
/// <summary>
/// Takes the question away when an editor opens over the pane it was asked in.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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));
}