Public Access
+ 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.
11540 lines
516 KiB
C#
11540 lines
516 KiB
C#
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 && !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 1–65535, 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));
|
||
}
|