Public Access
A host can now carry port forwards (-L, -R, -D), saved inside its encrypted payload as RelayTicketRequest.PortForwardId already assumed, so they sync and merge per forward like tags and pinned paths. The host schema moves to version 8 only for hosts that carry forwards. ISshConnection gains StartForwardAsync over SSH.NET's forwarded ports. Every listener binds loopback and there is no bind-address field: a forward is something any member of a shared keychain can edit. A remote forward's target is resolved here, IPv4 first, rather than by SSH.NET's constructor, which takes the first DNS answer and can send ::1. Forwards start with every terminal opened to the host and stop with its connection; a reconnect starts them again. The session sidebar lists them with state, failure reason and an on/off switch, the status bar counts the listening ones, and the host editor and detail pane gain a PORT FORWARDS section. A forward that cannot start fails alone. The phone does not start forwards yet: it has no surface showing what is listening, and its More screen now says so. No byte counts, since SSH.NET keeps none.
13618 lines
616 KiB
C#
13618 lines
616 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>
|
||
/// What this heading is folded and unfolded by, in <c>VaultViewModel.collapsedGroups</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// ◆ <b>The group's id for a group, and something else for "No group" — which is why this is not
|
||
/// <see cref="GroupId"/> with a fallback.</b> It was exactly that until the desktop's board grew a
|
||
/// heading per vault: there is now one "No group" heading <em>per vault</em>, and every one of them
|
||
/// answers <see cref="GroupId"/> with null. Keyed on <see cref="Guid.Empty"/> they were one heading as
|
||
/// far as the folded set was concerned, so folding one vault's unfiled hosts away folded every other
|
||
/// vault's with it. The desktop passes the vault's own id instead; the phone, whose list has a single
|
||
/// UNGROUPED heading spanning every vault, goes on passing <see cref="Guid.Empty"/>. Neither can collide
|
||
/// with a group id, and <see cref="Guid.Empty"/> is not a legal one — <c>HostSecret.TryValidate</c>
|
||
/// refuses it.
|
||
/// </remarks>
|
||
internal Guid CollapseKey { get; init; }
|
||
|
||
/// <summary>The chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||
|
||
/// <summary>What the desktop board's per-section toggle says beside the chevron.</summary>
|
||
internal string CollapseLabel => IsExpanded ? "Collapse" : "Expand";
|
||
|
||
/// <summary>
|
||
/// Whether this is the first heading the desktop's flat board drew.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The mock draws Collapse all / Expand all on the first heading row's right edge rather than as a
|
||
/// control of its own above the board, and a virtualised list has no clean way to ask a container "are
|
||
/// you the first realized one" from inside its own template — so the row carries the answer instead,
|
||
/// stamped on once by <see cref="VaultViewModel.RebuildHostSections"/>. Always false on the phone's
|
||
/// headings, which draw no such control, and on every heading but the first on the desktop's own board.
|
||
/// </remarks>
|
||
internal bool IsFirstBoardSection { get; init; }
|
||
}
|
||
|
||
/// <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>The single letter the settings page's card draws on this group's tile.</summary>
|
||
internal string Initial => Label.Length > 0 ? Label[..1].ToUpperInvariant() : "?";
|
||
}
|
||
|
||
/// <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 section of the desktop's flat host board: a heading, and the cards under it.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Regrouped out of the same flat rows the phone's <see cref="VaultViewModel.SidebarRows"/> draws one after
|
||
/// another — see <see cref="VaultViewModel.FlattenIntoSections"/> — because a section's own
|
||
/// <c>WrapPanel</c> of cards needs its members as a list rather than as headings mixed into one stream. The
|
||
/// alternative was one <c>ListBox</c> for the whole board with a heading item pretending to be as wide as
|
||
/// the row it sits on so the wrap panel breaks a line for it; that is a real technique and it is a fragile
|
||
/// one, and a second, per-section <c>ListBox</c> is not. See the note on <c>HostsScreen.axaml</c> for why
|
||
/// this shape was chosen over the phone's single flat list.
|
||
/// </para>
|
||
/// <para>
|
||
/// <see cref="Header"/> is null for exactly one case: a keychain with no groups at all draws one section
|
||
/// with no heading, which is what makes the feature invisible until it is used — see
|
||
/// <see cref="VaultViewModel.RebuildHostSections"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed class HostSectionViewModel(SidebarGroupHeader? header, IReadOnlyList<HostRowViewModel> hosts)
|
||
{
|
||
internal SidebarGroupHeader? Header => header;
|
||
|
||
internal bool HasHeader => header is not null;
|
||
|
||
internal IReadOnlyList<HostRowViewModel> Hosts => hosts;
|
||
|
||
/// <summary>
|
||
/// The vault this section opens, or empty where it is not the first section of one.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>The board's outer level, and groups are the inner one.</b> A group belongs to exactly one vault
|
||
/// — it is an item stored under that vault's key, like the hosts filed into it — so a board that drew
|
||
/// every group in one run was flattening the containment that already exists. Two vaults each holding a
|
||
/// "production" made the point: they were two headings with the same word on them and a small grey
|
||
/// vault name beside each, distinguishable only by reading the second line. Vaults are now the headings
|
||
/// and groups sort the hosts inside one, which is the shape the data has.
|
||
/// </para>
|
||
/// <para>
|
||
/// Carried on the first section of each vault rather than as a section of its own, because a vault
|
||
/// heading is not a thing cards go under: every card is under a group heading, and the group headings
|
||
/// are what a vault contains. Empty on every other section, and empty on all of them where the board
|
||
/// draws one vault — a heading naming the only vault there is says nothing the screen does not already
|
||
/// say, and is the same test <see cref="HostRowViewModel.VaultBadge"/> has always applied to itself.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string VaultHeading { get; init; } = string.Empty;
|
||
|
||
/// <summary>Whether this section opens a vault and should draw its name above the group heading.</summary>
|
||
internal bool StartsAVault => VaultHeading.Length > 0;
|
||
}
|
||
|
||
/// <summary>An entry in the toolbar's Group ▾ filter flyout.</summary>
|
||
/// <param name="GroupId">The group this entry narrows the board to.</param>
|
||
/// <param name="Label">What to show — vault-qualified where the session holds more than one.</param>
|
||
/// <param name="IsChecked">Whether this group currently narrows the board.</param>
|
||
/// <remarks>
|
||
/// Rebuilt whenever the checked set changes rather than mutated, on the same reasoning
|
||
/// <see cref="TagChoice"/> gives: a chip is a value, and equality is contents.
|
||
/// </remarks>
|
||
internal sealed record GroupFilterChoice(Guid GroupId, string Label, bool IsChecked);
|
||
|
||
/// <summary>An entry in the toolbar's Tag ▾ filter flyout.</summary>
|
||
/// <param name="TagId">The tag this entry narrows the board to.</param>
|
||
/// <param name="Label">What to show.</param>
|
||
/// <param name="IsChecked">Whether this tag currently narrows the board.</param>
|
||
/// <remarks>A host passes the filter by wearing <em>any</em> checked tag, not all of them.</remarks>
|
||
internal sealed record TagFilterChoice(Guid TagId, string Label, bool IsChecked);
|
||
|
||
/// <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}");
|
||
|
||
/// <summary>
|
||
/// What a card says when the pointer rests on it: the name in full, and the address under it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// ◆ <b>The name is here because the card trims it and the tooltip was the one place it was not said.</b>
|
||
/// A tile is a fixed 214 wide (see <c>Border.tile</c> in App.axaml) and the name is drawn with
|
||
/// <c>TextTrimming</c>, so anything past about twenty characters ends in an ellipsis — which is the
|
||
/// honest thing for the card to do and leaves the machine unidentifiable on a board of hosts whose names
|
||
/// share a prefix. The tooltip carried the address alone, which named the *host* but not the bookmark:
|
||
/// six servers behind one address differ only by the half that was trimmed away.
|
||
/// </remarks>
|
||
internal string Tooltip => $"{Label}\n{Address}";
|
||
|
||
/// <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>How many folders are pinned on this host.</summary>
|
||
/// <remarks>
|
||
/// Reads <see cref="Host"/> rather than being cached, so it needs no refreshing of its own: the row is
|
||
/// replaced wholesale whenever the host changes, exactly as <see cref="Summary"/> is, and both read the
|
||
/// same decrypted secret this constructor was handed.
|
||
/// </remarks>
|
||
internal int PinCount => host.Secret.PinnedPaths.Count;
|
||
|
||
/// <summary>Whether the card draws a pin-count badge at all.</summary>
|
||
internal bool HasPins => PinCount > 0;
|
||
|
||
/// <summary>Whether this host carries any port forwards, for the detail pane's FORWARDS card.</summary>
|
||
/// <remarks>Read off the secret for the reason <see cref="PinCount"/> is.</remarks>
|
||
internal bool HasForwards => host.Secret.PortForwards.Count > 0;
|
||
|
||
/// <summary>
|
||
/// The two letters a card's monogram avatar draws.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The first letters of the first two words in the label, or the first two characters where the label is
|
||
/// one word — the mock's own rule. Lowercased because that is how the design draws every monogram, not
|
||
/// because a name typed in capitals means anything different from one that was not.
|
||
/// </remarks>
|
||
internal string Monogram
|
||
{
|
||
get
|
||
{
|
||
var words = Label.Split(
|
||
MonogramWordSeparators,
|
||
StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||
|
||
var letters = words.Length >= 2
|
||
? string.Concat(words[0][0], words[1][0])
|
||
: Label.Length >= 2 ? Label[..2] : Label;
|
||
|
||
return letters.ToLowerInvariant();
|
||
}
|
||
}
|
||
|
||
private static readonly char[] MonogramWordSeparators = [' ', '-', '_', '.'];
|
||
|
||
/// <summary>
|
||
/// Which of the four monogram hues this card's avatar is painted in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A stable hash of the label, deliberately not <see cref="string.GetHashCode()"/> — .NET randomises
|
||
/// that per process as a defence against hash-flooding, so the same host would draw a different colour
|
||
/// every time the application started. FNV-1a costs nothing to hold constant across runs, which is the
|
||
/// one property a monogram's colour needs: the same machine has to look like the same machine tomorrow.
|
||
/// </remarks>
|
||
internal string MonogramHue => MonogramHues[StableHash(Label) % (uint)MonogramHues.Length];
|
||
|
||
/// <summary>violet, green, amber, gray — the four pairs the mock assigns a monogram, in a fixed order.</summary>
|
||
private static readonly string[] MonogramHues = ["violet", "green", "amber", "gray"];
|
||
|
||
private static uint StableHash(string value)
|
||
{
|
||
unchecked
|
||
{
|
||
var hash = 2166136261u;
|
||
|
||
foreach (var character in value)
|
||
{
|
||
hash = (hash ^ character) * 16777619u;
|
||
}
|
||
|
||
return hash;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// When this host was last connected to, as relative text — "2 min ago" — or empty for never.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>Not computed here.</b> <see cref="VaultViewModel.RefreshLastConnectedAsync"/> fills this from the
|
||
/// connection log on the hosts screen's activation and on <c>SessionEnded</c>, and
|
||
/// <see cref="VaultViewModel.RestringLastConnected"/> re-strings it on a tick while the screen is visible
|
||
/// and after every rebuild of <see cref="VaultViewModel.Hosts"/>. This row carries only the string, the
|
||
/// way <see cref="IsConnected"/> carries a fact the vault does not own either.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string lastConnectedText = string.Empty;
|
||
}
|
||
|
||
/// <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>
|
||
/// ── v5b ── 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;
|
||
|
||
/// <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>
|
||
/// ── v5b ── 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;
|
||
|
||
/// <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>
|
||
/// <param name="cipher">The negotiated server-to-client cipher; see <see cref="ISshConnection.Cipher"/>.</param>
|
||
/// <param name="hostKeyAlgorithm">The accepted host key's algorithm, e.g. <c>ssh-ed25519</c>.</param>
|
||
/// <param name="identityLabel">
|
||
/// The display name of the key or credential that authenticated, or null when a typed password did.
|
||
/// </param>
|
||
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
|
||
internal sealed class TerminalSessionEventArgs(
|
||
Guid attemptId,
|
||
uint sessionId,
|
||
string label,
|
||
string address,
|
||
string cipher,
|
||
string hostKeyAlgorithm,
|
||
string? identityLabel,
|
||
Guid? hostId,
|
||
PortForwardList? forwards = null) : EventArgs
|
||
{
|
||
/// <summary>
|
||
/// The forwards to start on this session: the host's saved ones, or none for a typed-in address.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Carried here because the shell has no vault to read them from, for the reason the label is.
|
||
/// </remarks>
|
||
internal PortForwardList Forwards { get; } = forwards ?? PortForwardList.Empty;
|
||
|
||
/// <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;
|
||
|
||
internal string Cipher { get; } = cipher;
|
||
|
||
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
|
||
|
||
internal string? IdentityLabel { get; } = identityLabel;
|
||
|
||
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
|
||
internal Guid? HostId { get; } = hostId;
|
||
}
|
||
|
||
/// <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>
|
||
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</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,
|
||
Guid? hostId) : 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>
|
||
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
|
||
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
|
||
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
|
||
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
|
||
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
|
||
/// see <c>TerminalTabViewModel.HostId</c>.
|
||
/// </remarks>
|
||
internal Guid? HostId { get; } = hostId;
|
||
}
|
||
|
||
/// <summary>A connection that has got as far as a named step.</summary>
|
||
/// <param name="attemptId">The attempt this is about.</param>
|
||
/// <param name="step">The step that has just begun.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The fourth of the attempt events, and the only one that can be raised more than once for an attempt. It
|
||
/// exists because the other three say a connection started and then, seconds later, whether it worked — and
|
||
/// the seconds in between are the whole of what a user staring at a connecting card is trying to find out.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Raised on whichever thread the handshake is on.</b> SSH.NET reports the interior of a connection from
|
||
/// its own thread, and this event is that report forwarded rather than a copy made on a timer, so a
|
||
/// subscriber that touches a view model must marshal for itself. <c>MainWindowViewModel</c> does; see the
|
||
/// handler.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed class ConnectionProgressEventArgs(Guid attemptId, ConnectionStep step) : EventArgs
|
||
{
|
||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
||
internal Guid AttemptId { get; } = attemptId;
|
||
|
||
/// <inheritdoc cref="ConnectionProgressEventArgs" path="/param[@name='step']" />
|
||
internal ConnectionStep Step { get; } = step;
|
||
}
|
||
|
||
/// <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>
|
||
/// ── v5b ── The vault name to print on this row, or empty when there is only one vault to be in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Additive: every other kind of row in this application already carries this fact (see
|
||
/// <see cref="HostRowViewModel.VaultBadge"/>), and the keychain table is the one list that merged four
|
||
/// of those kinds into one projection without carrying it along. Empty by the same convention the others
|
||
/// follow — a badge on every row of a single-vault list is noise that says the same thing on all of them.
|
||
/// </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>
|
||
/// ── v5b ── Which hosts authenticate with this item, short-form — "prod-api-01 +2" — or empty when
|
||
/// nothing does or the kind has no such fact.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Real for a key or a credential, which a host can be bound to — see <see cref="VaultViewModel.HostsBoundTo"/>,
|
||
/// the same resolved-binding scan this reads, kept in step so the table and the deletion warning never
|
||
/// disagree about who is counted. A tag's own version of this fact is its host count, already carried on
|
||
/// <see cref="TagRowViewModel.Description"/>. A bucket has no binding concept in this codebase at all —
|
||
/// nothing here resolves a host's authentication to an object store — so it stays empty rather than
|
||
/// showing a region or an endpoint under a column that promises "who uses this".
|
||
/// </remarks>
|
||
internal string UsedBySummary { get; init; } = string.Empty;
|
||
|
||
/// <summary>Whether this row has anything to say about who uses it.</summary>
|
||
internal bool HasUsedBySummary => UsedBySummary.Length > 0;
|
||
|
||
/// <summary>
|
||
/// ── v5b ── The Material Icons glyph Keychain.dc.html draws beside each row's name, by kind.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Computed from <see cref="Kind"/> rather than stored, because it is a fact about the kind and not
|
||
/// about the item — every key gets the same glyph regardless of algorithm, which is honest: this type
|
||
/// carries no algorithm field (see docs/design-import-gaps.md).
|
||
/// </remarks>
|
||
internal string IconGlyph => Kind switch
|
||
{
|
||
VaultItemKind.Key => "",
|
||
VaultItemKind.Credential => "",
|
||
VaultItemKind.Tag => "",
|
||
VaultItemKind.ObjectStore => "",
|
||
_ => string.Empty,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// ── v5b ── One host on the keychain detail pane's own USED BY list, for the selected key or credential.
|
||
/// </summary>
|
||
/// <param name="Label">The host's name.</param>
|
||
/// <param name="IsConnected">
|
||
/// Whether a terminal is open on it right now — the same two-state truth <see cref="HostRowViewModel.IsConnected"/>
|
||
/// draws a dot from everywhere else in this application. Never a third colour: nothing here pings anything.
|
||
/// </param>
|
||
internal sealed record UsedByHostRowViewModel(string Label, bool IsConnected);
|
||
|
||
/// <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,
|
||
ISshReachabilityProbe? reachability = null) : ObservableObject, IAsyncDisposable
|
||
{
|
||
/// <summary>What explains a failed connection when no caller supplied something else.</summary>
|
||
/// <remarks>
|
||
/// Static and shared, because <see cref="SshReachabilityProbe"/> holds nothing but a budget and every
|
||
/// call to it is a fresh socket. Last in the parameter list and optional for the reason
|
||
/// <c>visibility</c> is: every existing caller — which is both heads and every test — goes on meaning
|
||
/// exactly what it did, and the one that wants to watch what was probed passes a substitute.
|
||
/// </remarks>
|
||
private static readonly ISshReachabilityProbe DefaultReachability = new SshReachabilityProbe();
|
||
|
||
/// <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>
|
||
/// <remarks>
|
||
/// Shared by <see cref="SidebarRows"/> and <see cref="HostSections"/> deliberately: it is a fact about
|
||
/// which shelves are open, not about which head is asking, and the two heads never run in the same
|
||
/// process to disagree about it.
|
||
/// </remarks>
|
||
private readonly HashSet<Guid> collapsedGroups = [];
|
||
|
||
/// <summary>The groups the toolbar's Group ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
|
||
/// <remarks>Empty means every group, which is what "All groups" resets it to.</remarks>
|
||
private readonly HashSet<Guid> checkedGroupFilterIds = [];
|
||
|
||
/// <summary>The tags the toolbar's Tag ▾ flyout has ticked, narrowing <see cref="HostSections"/>.</summary>
|
||
/// <remarks>
|
||
/// A host passes by wearing <em>any</em> of these, not all of them — the same semantics
|
||
/// <see cref="TagChoice"/>'s picker uses for what a host wears, just read the other way round.
|
||
/// </remarks>
|
||
private readonly HashSet<Guid> checkedTagFilterIds = [];
|
||
|
||
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>
|
||
/// The most recent <c>StartedAt</c> the connection log carries for each host, by <c>HostId</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Read once per <see cref="RefreshLastConnectedAsync"/> and kept here rather than only on the rows,
|
||
/// because <see cref="Hosts"/> is rebuilt wholesale on every synchronisation pass — see the remark on
|
||
/// <see cref="HostRowViewModel.IsConnected"/> — and a rebuilt row has to get its text back from
|
||
/// somewhere that survived the rebuild. <see cref="RestringLastConnected"/> is what writes it back on;
|
||
/// <c>MainWindowViewModel.OnVaultHostsChanged</c> is what calls that after every rebuild.
|
||
/// </remarks>
|
||
private readonly Dictionary<Guid, DateTimeOffset> lastConnectedAt = [];
|
||
|
||
/// <summary>
|
||
/// Re-reads the connection log and refreshes every host row's <see cref="HostRowViewModel.LastConnectedText"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// On demand, not on the sync loop — the same rule <c>LogsViewModel</c> states for the log itself:
|
||
/// nobody is waiting for their own connection from an hour ago to update on a screen they are not
|
||
/// looking at, and a decrypt pass over the whole log is not free. The caller decides when "on demand"
|
||
/// is: the hosts screen's own activation and a session ending, both in <c>MainWindowViewModel</c>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Scoped to <see cref="VaultSession.ActiveVaultId"/> alone</b>, the same limitation
|
||
/// <c>LogsViewModel</c> and <c>MainWindowViewModel.RecentConnections</c> already carry, unlike
|
||
/// <see cref="Hosts"/> itself, which reads every readable vault. A host filed in a second shown vault
|
||
/// gets no ago-text until this reads that vault's log too — worth fixing the day something on this
|
||
/// screen already reads more than one vault's connection history; nothing does yet.
|
||
/// </para>
|
||
/// <para>
|
||
/// Failures are swallowed, like every other advisory read on this screen: a missing ago-text is not
|
||
/// worth a status line under a screen whose job is letting somebody connect.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal async Task RefreshLastConnectedAsync(CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
var connections = await session.ConnectionLog
|
||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
lastConnectedAt.Clear();
|
||
|
||
foreach (var entry in connections.Items)
|
||
{
|
||
if (entry.Secret.HostId is not { } hostId)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (!lastConnectedAt.TryGetValue(hostId, out var current)
|
||
|| entry.Secret.StartedAt > current)
|
||
{
|
||
lastConnectedAt[hostId] = entry.Secret.StartedAt;
|
||
}
|
||
}
|
||
}
|
||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||
{
|
||
return;
|
||
}
|
||
|
||
RestringLastConnected();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Turns the timestamps <see cref="RefreshLastConnectedAsync"/> already read into the words each card
|
||
/// shows, without reading the log again.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// What the hosts screen's one-minute tick calls, and what <c>MainWindowViewModel.OnVaultHostsChanged</c>
|
||
/// calls after every rebuild of <see cref="Hosts"/> — neither of those is a reason to ask the vault
|
||
/// anything, only to say the same fact in fresher words or on a new set of row objects.
|
||
/// <para>
|
||
/// <b>A connected host shows nothing here, per the design's own decision.</b> The green dot already
|
||
/// says "open right now"; printing "3 min ago" beside it would be answering a question about a
|
||
/// connection that is not the one still running, on the one card where that answer is most likely to
|
||
/// be misread as current.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal void RestringLastConnected()
|
||
{
|
||
var now = TimeProvider.System.GetUtcNow();
|
||
|
||
foreach (var row in Hosts)
|
||
{
|
||
row.LastConnectedText = !row.IsConnected && lastConnectedAt.TryGetValue(row.EntityId, out var at)
|
||
? DescribeElapsed(now - at)
|
||
: string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <summary>The word a card prints for how long ago a connection started, given how long ago that was.</summary>
|
||
/// <remarks>
|
||
/// A pure function of the gap rather than of a clock, which is what lets <see cref="RestringLastConnected"/>
|
||
/// call it once a tick and once after every log read alike, and what lets a test hand it a gap directly
|
||
/// instead of freezing time to manufacture one.
|
||
/// <para>
|
||
/// Each unit's own top end is the next unit's first tick: fifty-nine seconds still reads "just now" and
|
||
/// sixty is the first "1 min ago"; fifty-nine minutes stays minutes and sixty crosses into "1 hr ago";
|
||
/// twenty-three hours stays hours and twenty-four crosses into "1 day ago". A negative gap — a clock that
|
||
/// skipped backwards — reads as "just now" rather than as a claim nothing here can back up.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal static string DescribeElapsed(TimeSpan elapsed)
|
||
{
|
||
if (elapsed < TimeSpan.FromMinutes(1))
|
||
{
|
||
return "just now";
|
||
}
|
||
|
||
if (elapsed < TimeSpan.FromHours(1))
|
||
{
|
||
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalMinutes} min ago");
|
||
}
|
||
|
||
if (elapsed < TimeSpan.FromDays(1))
|
||
{
|
||
return string.Create(CultureInfo.InvariantCulture, $"{(int)elapsed.TotalHours} hr ago");
|
||
}
|
||
|
||
var days = (int)elapsed.TotalDays;
|
||
|
||
return days == 1
|
||
? "1 day ago"
|
||
: string.Create(CultureInfo.InvariantCulture, $"{days} days ago");
|
||
}
|
||
|
||
/// <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 desktop's own flattening of <see cref="Hosts"/>: one section per group in label order, "No
|
||
/// group" first, narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Built from the same <see cref="FlattenIntoSections"/> pass as <see cref="SidebarRows"/>, regrouped
|
||
/// into one entry per heading rather than left as a flat stream of rows — see
|
||
/// <see cref="HostSectionViewModel"/> for why, and <see cref="RebuildHostSections"/> for how.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A keychain with no groups draws one section with no heading</b>, which is what
|
||
/// <see cref="HostSectionViewModel.HasHeader"/> is for: the invariant the hosts screen documents, that a
|
||
/// groupless keychain is one flat grid and nothing above it.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<HostSectionViewModel> HostSections { get; } = [];
|
||
|
||
/// <summary>Every host on the board, in the order its cards are drawn — every section, one after another.</summary>
|
||
/// <remarks>
|
||
/// Computed rather than stored, since it is nothing a control binds directly: it is what a shift-click's
|
||
/// run is measured against and what Ctrl+A ticks, both of which want the flat order rather than the
|
||
/// sections that draw it. See <see cref="ChooseHostRun"/> and <c>HostsScreen.axaml.cs</c>.
|
||
/// </remarks>
|
||
internal IEnumerable<HostRowViewModel> HostBoardOrder =>
|
||
HostSections.SelectMany(section => section.Hosts);
|
||
|
||
/// <summary>Whether the board has any card on it at all.</summary>
|
||
internal bool HasHostBoardEntries => HostSections.Any(section => section.Hosts.Count > 0);
|
||
|
||
/// <summary>The count chip beside the "Hosts" title — every host in a shown vault, unfiltered.</summary>
|
||
/// <remarks>
|
||
/// Deliberately not narrowed by the find box or the two toolbar flyouts: it answers "how big is this
|
||
/// keychain", the way the vault screen's own item count does, not "how many cards are on screen right
|
||
/// now" — which is already on the row beneath it, in each section's own heading.
|
||
/// </remarks>
|
||
internal int HostBoardTotalCount => Hosts.Count(row => IsVaultShown(row.VaultId));
|
||
|
||
/// <summary>Whether the Group ▾ flyout is currently narrowing the board.</summary>
|
||
internal bool HasActiveGroupFilter => checkedGroupFilterIds.Count > 0;
|
||
|
||
/// <summary>Whether the Tag ▾ flyout is currently narrowing the board.</summary>
|
||
internal bool HasActiveTagFilter => checkedTagFilterIds.Count > 0;
|
||
|
||
/// <summary>
|
||
/// What the board says when it has nothing on it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One answer per reason it can be empty, on <see cref="NoVisibleHostsMessage"/>'s own reasoning: an
|
||
/// empty keychain is an invitation, a vault switched off is a setting to revisit, a filter that has
|
||
/// narrowed everything away is a filter to widen, and a search that matches nothing is not an invitation
|
||
/// to add a host that may well already be there.
|
||
/// </remarks>
|
||
internal string NoHostBoardMessage =>
|
||
(Hosts.Count, HasHiddenVaults, HasActiveGroupFilter || HasActiveTagFilter, 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, false, 0) =>
|
||
"Every host here is in a vault you have switched off. Press the vault menu in the tab strip "
|
||
+ "to switch one back on.",
|
||
(_, _, true, _) =>
|
||
"Nothing matches the Group or Tag filter. Press All groups or All tags to widen it.",
|
||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||
};
|
||
|
||
/// <summary>The toolbar's Group ▾ entries: one per group, checkable, narrowing the board when ticked.</summary>
|
||
internal ObservableCollection<GroupFilterChoice> GroupFilterChoices { get; } = [];
|
||
|
||
/// <summary>The toolbar's Tag ▾ entries: one per tag, checkable, narrowing the board when ticked.</summary>
|
||
internal ObservableCollection<TagFilterChoice> TagFilterChoices { get; } = [];
|
||
|
||
/// <summary>
|
||
/// What the board's Collapse all / Expand all control says, on the first heading it draws.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Collapses when anything is open and expands only once everything already is, which is the ordinary
|
||
/// meaning of the pair: a mix of open and folded shelves is "more to fold away" until none are left.
|
||
/// </remarks>
|
||
internal string CollapseAllLabel =>
|
||
HostSections.Select(section => section.Header)
|
||
.OfType<SidebarGroupHeader>()
|
||
.Any(header => header.IsExpanded)
|
||
? "Collapse all"
|
||
: "Expand all";
|
||
|
||
/// <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 both heads' headings are built
|
||
/// from — v5 retired the desktop's own grid of group cards in favour of the flat board the phone always
|
||
/// drew; see <see cref="HostSections"/>.
|
||
/// </remarks>
|
||
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
||
|
||
/// <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. Through v4 this was one of two things a click could light, sharing itself with a group
|
||
/// card's own selection; v5's flat board draws no group cards to share it with, so a host is the only
|
||
/// thing this screen ever marks. 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 <see cref="GroupTarget"/> falls back to when a command is not handed one directly.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>v3 named this "the group that is open" and set it from a double-click on a card, the way a
|
||
/// directory pane's own breadcrumb worked.</b> v5 retired that grid, its cards and its trail in favour of
|
||
/// the flat board both heads now draw — see <see cref="HostSections"/> — and nothing on either head
|
||
/// opens a group any more, so nothing sets this to a value other than null. It stays rather than being
|
||
/// removed with the card grid because <see cref="RebuildGroups"/> still re-resolves it against every
|
||
/// reload, and <see cref="GroupTarget"/>, <see cref="EditGroup"/>, <see cref="DeleteGroup"/> and
|
||
/// <see cref="MoveGroup"/> still read it as their fallback when handed no group directly — a caller none
|
||
/// of them currently has, since <see cref="EditGroupFromHeading"/> and its two siblings always resolve
|
||
/// and pass a row of their own. Removing the fallback itself would mean re-proving the same thing about
|
||
/// three more commands than removing the trail did about one property.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private HostGroupRowViewModel? groupFilter;
|
||
|
||
/// <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>
|
||
/// ── v5b ── Narrows <see cref="VaultItems"/> to rows whose name, type or detail contains this text.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Keychain.dc.html draws a filter box this table never had; wired here rather than left as a control
|
||
/// that does nothing, on the same "narrows the list and nothing else" reasoning
|
||
/// <see cref="HostFilter"/> already carries — it can hide a row but can never change what a category or
|
||
/// a selection means. Case-insensitive, and read against the same three fields the row already shows,
|
||
/// so what matches is never a surprise to whoever typed it.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string itemFilter = string.Empty;
|
||
|
||
/// <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>
|
||
/// ── v5b ── The selected key or credential's own USED BY list, for the keychain's detail pane.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Rebuilt in <see cref="OnSelectedVaultItemChanged"/> by <see cref="RebuildSelectedItemUsage"/>, off the
|
||
/// same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning. Empty for every
|
||
/// other kind — a tag's own version of this fact is its host count, already on the row, and a bucket has
|
||
/// no binding concept to scan at all.
|
||
/// </remarks>
|
||
internal ObservableCollection<UsedByHostRowViewModel> SelectedItemUsedByHosts { get; } = [];
|
||
|
||
/// <summary>Whether the selected item has anything on its USED BY list.</summary>
|
||
internal bool HasSelectedItemUsedByHosts => SelectedItemUsedByHosts.Count > 0;
|
||
|
||
/// <summary>The "in use · N hosts" chip text, or empty when nothing authenticates with the selected item.</summary>
|
||
internal string SelectedItemInUseSummary => SelectedItemUsedByHosts.Count switch
|
||
{
|
||
0 => string.Empty,
|
||
1 => "in use · 1 host",
|
||
var n => $"in use · {n} hosts",
|
||
};
|
||
|
||
/// <summary>Whether the "in use" chip has anything to say.</summary>
|
||
internal bool HasSelectedItemInUseSummary => SelectedItemInUseSummary.Length > 0;
|
||
|
||
/// <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 chosen hosts ----
|
||
//
|
||
// ◆ BOTH HEADS, AND IT WAS THE PHONE'S ALONE. The set below is unchanged by that: what a head brings is
|
||
// the gesture that fills it and the furniture that acts on it. The phone has a long press, a contextual
|
||
// action bar and a sheet of seven entries; the desktop has Ctrl, Shift, a band dragged over the grid and
|
||
// one context menu — see HostsScreen.axaml.cs there, and ChooseHosts below, which is the entry those
|
||
// gestures use. Everything about *which hosts* and *what happens to them* is here, once.
|
||
//
|
||
// 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>Which hosts the deletion question on screen is about.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>The question names a count and the run that answers it reads the set again, so the two have to be
|
||
/// the same set.</b> Nothing kept them together: the panel is drawn <em>above</em> the list on both heads
|
||
/// rather than over it — deliberately, so the ticked rows can be seen while the question is answered —
|
||
/// which leaves every one of them still able to be ticked and unticked. One more tick between "Delete
|
||
/// these 6 hosts?" and pressing DELETE deleted seven, and the desktop's Ctrl-click and band made that a
|
||
/// second's work rather than a deliberate act.
|
||
/// </para>
|
||
/// <para>
|
||
/// Compared rather than frozen, and the question is dropped rather than the set: what somebody has just
|
||
/// chosen is what they meant, and a question they have already read is not. It also covers the case
|
||
/// nobody performs — a colleague's deletion arriving mid-question and shrinking the set under it. Ids,
|
||
/// for the reason <see cref="chosenHostIds"/> holds ids.
|
||
/// </para>
|
||
/// </remarks>
|
||
private readonly HashSet<Guid> deletionAskedAbout = [];
|
||
|
||
/// <summary>
|
||
/// Whether at least one host is ticked: a long press on the phone, a Ctrl-click or a band on the desktop.
|
||
/// </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 list of either
|
||
/// kind does and is the second way out of it — the others being the cross at the left of the phone's bar,
|
||
/// and CLEAR, Esc or a plain click on the desktop.
|
||
/// </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 same count with the word on it, which is what the desktop prints.</summary>
|
||
/// <remarks>
|
||
/// The bare number above works on the phone because it sits in a bar that is the whole top of the
|
||
/// screen and can mean nothing else. The desktop has no such bar — the ticks arrive beside a HOSTS
|
||
/// heading that already has a count of its own at the far end of the same row — so a second bare number
|
||
/// there would be two numbers about two different things, side by side, neither of them labelled.
|
||
/// </remarks>
|
||
internal string ChosenHostsSummary =>
|
||
$"{ChosenHostCount.ToString(CultureInfo.CurrentCulture)} chosen";
|
||
|
||
/// <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 had no phone control and, since v5 retired the desktop's own
|
||
/// drag, no desktop gesture either: filing a set is now only ever this panel, on both heads, through
|
||
/// <see cref="ConfirmRegroupChosenHostsAsync"/>. 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>
|
||
/// <para>
|
||
/// Filled when the editor opens and kept in step with the two lists from then on, by
|
||
/// <see cref="RefreshOpenEditors"/>. It used to be the snapshot alone, on the grounds that only one editor
|
||
/// could be open at a time and so the only way to add a key was to close this one — which stopped being
|
||
/// true when <see cref="AHostEditorIsInTheWay"/> was split from <see cref="AVaultEditorIsInTheWay"/>. A
|
||
/// host editor now sits open on the Hosts screen while a key is added on the Keychain screen, and a
|
||
/// picker that did not notice left the user cancelling an editor to see the key they had just made.
|
||
/// </para>
|
||
/// <para>
|
||
/// What the old note was protecting against is real, and is why the refill restores the selection by id
|
||
/// rather than rebuilding from the stored host: entries appear and disappear under the user, but what
|
||
/// they have chosen does not move.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];
|
||
|
||
[ObservableProperty]
|
||
private AuthenticationChoice? editorSelectedAuthentication;
|
||
|
||
// ---- Making a credential from inside the host editor ----
|
||
// A fifth set of editor fields, and deliberately not the keychain screen's four. Sharing them would put
|
||
// IsEditingCredential — which AVaultEditorIsInTheWay asks about — true while the user is on the Hosts
|
||
// screen, and the whole Vault screen would refuse to open an editor with a sentence naming a form on
|
||
// another screen. That is the exact failure AHostEditorIsInTheWay was split out to end; see its remarks.
|
||
|
||
/// <summary>Whether the host editor is showing its own new-credential form.</summary>
|
||
[ObservableProperty]
|
||
private bool isAddingEditorCredential;
|
||
|
||
/// <summary>The name in the host editor's new-credential form.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewCredentialLabel = string.Empty;
|
||
|
||
/// <inheritdoc cref="CredentialEditorUsername" path="/remarks" />
|
||
[ObservableProperty]
|
||
private string editorNewCredentialUsername = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Holds a password for as long as the form is open, on the same terms the keychain's box does — see
|
||
/// <see cref="CredentialEditorPassword"/>. Cleared by every path that closes this form, including the
|
||
/// ones that close the host editor around it, so a password typed here cannot outlive the form and
|
||
/// reappear behind the next host somebody edits.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string editorNewCredentialPassword = string.Empty;
|
||
|
||
/// <summary>Free text, as the keychain's own editor takes.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewCredentialNotes = string.Empty;
|
||
|
||
/// <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>
|
||
/// Opens the host editor's own new-credential form.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A button beside the picker rather than an entry inside it. Every row of that list is a binding the
|
||
/// host can have — see <see cref="AuthenticationChoice"/> — and "make a new one" is an action, not a
|
||
/// binding: as an entry it would sit in the box afterwards describing a state no host can be in, and
|
||
/// cancelling the form would leave the picker showing it.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void BeginEditorCredential()
|
||
{
|
||
ClearEditorCredentialForm();
|
||
IsAddingEditorCredential = true;
|
||
Status = "Adding a credential for this host.";
|
||
}
|
||
|
||
/// <summary>Abandons the form, clearing the password out of it.</summary>
|
||
[RelayCommand]
|
||
private void CancelEditorCredential()
|
||
{
|
||
ClearEditorCredentialForm();
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Closes the form and drops what was typed into it, the password included.</summary>
|
||
private void ClearEditorCredentialForm()
|
||
{
|
||
IsAddingEditorCredential = false;
|
||
EditorNewCredentialLabel = string.Empty;
|
||
EditorNewCredentialUsername = string.Empty;
|
||
EditorNewCredentialPassword = string.Empty;
|
||
EditorNewCredentialNotes = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates a credential from the host editor's form and binds the host being edited to it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The same reasoning <see cref="AddEditorTagAsync"/> gives, and for the same moment: somebody is
|
||
/// choosing how a host authenticates and finds the password they want is not in the keychain yet.
|
||
/// Sending them to the other screen to make one would lose the half-typed host they were standing in.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>It writes to the keychain immediately, unlike every other field in this editor.</b> A credential
|
||
/// is a shared item with an id and a host can only name an id that exists, so there is nothing to defer.
|
||
/// Cancelling the host edit therefore leaves the credential behind — honest rather than hidden, and the
|
||
/// bargain a tag already makes here.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A name that already exists is duplicated rather than reused, which is where this deliberately
|
||
/// parts from the tag path.</b> Two tags called "staging" are the same intention spelled twice; two
|
||
/// credentials called "root" are two different passwords, and quietly binding the host to the one that
|
||
/// happened to be there already would authenticate it as an account the user never chose. A duplicate
|
||
/// label in the picker is a smaller problem than a silent wrong password.
|
||
/// </para>
|
||
/// <para>
|
||
/// Into <see cref="editingHostVaultId"/>, not the standing target: the credential belongs wherever the
|
||
/// host is being sealed, so everybody who can read the host can read what it authenticates with. That is
|
||
/// stricter than the tag path — which files into the active vault and is recorded as a gap — and it can
|
||
/// be, because the picker here lists credentials from every readable vault rather than one.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task AddEditorCredentialAsync(CancellationToken cancellationToken)
|
||
{
|
||
var credential = new CredentialSecret
|
||
{
|
||
Label = EditorNewCredentialLabel.Trim(),
|
||
|
||
// Not trimmed. A password of spaces is a password — CredentialSecret.TryValidate says so — and
|
||
// trimming one here would lock somebody out of a host over a tidiness opinion.
|
||
Password = EditorNewCredentialPassword,
|
||
Username = string.IsNullOrWhiteSpace(EditorNewCredentialUsername)
|
||
? null
|
||
: EditorNewCredentialUsername.Trim(),
|
||
|
||
// Untrimmed and unnormalised past blank-is-absent, as the keychain's editor writes it: free text
|
||
// is the user's to lay out, and its leading indent is theirs rather than this form's to correct.
|
||
Notes = string.IsNullOrWhiteSpace(EditorNewCredentialNotes) ? null : EditorNewCredentialNotes,
|
||
};
|
||
|
||
if (!credential.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
var entityId = await session.Credentials
|
||
.CreateAsync(editingHostVaultId, credential, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// Before the reload, not after it. RefreshOpenEditors rebuilds this picker and then restores
|
||
// it from whatever this property says, so writing the binding here is what survives the pass
|
||
// — and by the time it is read, ReloadCredentialsAsync has put the matching entry in the
|
||
// list for it to land on.
|
||
EditorSelectedAuthentication =
|
||
AuthenticationChoice.ForCredential(entityId, credential.Label);
|
||
|
||
ClearEditorCredentialForm();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = $"Added '{credential.Label}' and bound this host to it. "
|
||
+ "Save the host to keep the binding.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The authority while an editor is open, on the same footing <see cref="editorTagIds"/> holds for tags:
|
||
/// populated from <see cref="HostSecret.PinnedPaths"/> when the editor opens, mutated by
|
||
/// <see cref="AddEditorPin"/> and <see cref="RemoveEditorPin"/> while it stays open, and read back by
|
||
/// <see cref="BuildHost"/> on Save. Unlike the tag set there is nothing else to project it onto — a pin
|
||
/// has no id and no vault-wide list of every pin that exists, so this collection is both the staging area
|
||
/// and the thing QUICK ACCESS binds to directly, with no <c>BuildTagChoices</c>-style rebuild step.
|
||
/// </para>
|
||
/// <para>
|
||
/// An <see cref="ObservableCollection{T}"/> rather than a field replaced wholesale, because a row in the
|
||
/// editor is a button that removes one path — a rebuilt collection would have to re-diff itself against
|
||
/// the one the list was bound to, where an in-place <c>Add</c>/<c>Remove</c> is what the binding already
|
||
/// knows how to redraw incrementally.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<string> EditorPinnedPaths { get; } = [];
|
||
|
||
/// <summary>The box QUICK ACCESS's add row holds before ADD is pressed.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewPin = string.Empty;
|
||
|
||
/// <summary>
|
||
/// Pins a path on the host being edited, from the editor's own box.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Refuses everything <see cref="HostSecret.TryValidate"/> would, and for the same reason
|
||
/// <see cref="AddEditorTagAsync"/> validates a tag's label before writing it anywhere: a refusal that
|
||
/// waits for SAVE is a refusal that throws away five other fields typed since, where one at the box that
|
||
/// caused it costs nothing else on the form. <c>HostSecret.MaxPinnedPaths</c> and
|
||
/// <c>MaxPinnedPathLength</c> are read from the domain type rather than restated here, so a bound that
|
||
/// moves there cannot go stale at this, its only other reader.
|
||
/// </para>
|
||
/// <para>
|
||
/// A path already pinned is refused rather than duplicated. <see cref="PinnedPathList.Create"/> would
|
||
/// silently drop the second one at Save regardless, but refusing here is the honest version — a user who
|
||
/// typed the same path twice is told why nothing changed, rather than watching it vanish once the drawer
|
||
/// closes.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void AddEditorPin()
|
||
{
|
||
var path = EditorNewPin.Trim();
|
||
|
||
if (path.Length == 0)
|
||
{
|
||
Status = "A pinned path cannot be blank.";
|
||
return;
|
||
}
|
||
|
||
if (EditorPinnedPaths.Count >= HostSecret.MaxPinnedPaths)
|
||
{
|
||
Status = $"A host cannot pin more than {HostSecret.MaxPinnedPaths} paths.";
|
||
return;
|
||
}
|
||
|
||
if (path.Length > HostSecret.MaxPinnedPathLength)
|
||
{
|
||
Status = $"A pinned path cannot be longer than {HostSecret.MaxPinnedPathLength} characters.";
|
||
return;
|
||
}
|
||
|
||
if (path.Any(char.IsControl))
|
||
{
|
||
Status = "A pinned path cannot contain a control character.";
|
||
return;
|
||
}
|
||
|
||
if (EditorPinnedPaths.Contains(path, StringComparer.Ordinal))
|
||
{
|
||
Status = $"'{path}' is already pinned.";
|
||
return;
|
||
}
|
||
|
||
EditorPinnedPaths.Add(path);
|
||
EditorNewPin = string.Empty;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Unpins a path from the host being edited.</summary>
|
||
[RelayCommand]
|
||
private void RemoveEditorPin(string? path)
|
||
{
|
||
if (path is not null)
|
||
{
|
||
EditorPinnedPaths.Remove(path);
|
||
}
|
||
}
|
||
|
||
/// <summary>Stages an existing host's pins for editing. See <see cref="EditorPinnedPaths"/>.</summary>
|
||
private void LoadEditorPinnedPaths(PinnedPathList pinned)
|
||
{
|
||
EditorPinnedPaths.Clear();
|
||
|
||
foreach (var path in pinned)
|
||
{
|
||
EditorPinnedPaths.Add(path);
|
||
}
|
||
|
||
EditorNewPin = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The port forwards on the host being edited, staged the way <see cref="EditorPinnedPaths"/> is.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Populated when the editor opens, changed by <see cref="AddEditorForward"/> and
|
||
/// <see cref="RemoveEditorForward"/>, read back by <see cref="BuildHost"/> on Save. A rule keeps its id
|
||
/// through the editor, which is what lets the merge tell an edited forward from a new one.
|
||
/// </remarks>
|
||
internal ObservableCollection<PortForwardRule> EditorPortForwards { get; } = [];
|
||
|
||
/// <summary>The add row's kind, as the index of <see cref="PortForwardKind"/>: local, remote, dynamic.</summary>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(EditorNewForwardHasTarget))]
|
||
[NotifyPropertyChangedFor(nameof(EditorNewForwardBindHint))]
|
||
[NotifyPropertyChangedFor(nameof(EditorNewForwardKindHint))]
|
||
private int editorNewForwardKind;
|
||
|
||
/// <summary>The add row's listening port, as typed.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewForwardBindPort = string.Empty;
|
||
|
||
/// <summary>The add row's target, as <c>host:port</c>.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewForwardTarget = string.Empty;
|
||
|
||
/// <summary>Whether the add row's kind has a target at all; a dynamic forward does not.</summary>
|
||
internal bool EditorNewForwardHasTarget => (PortForwardKind)EditorNewForwardKind is not PortForwardKind.Dynamic;
|
||
|
||
/// <summary>The add row's listening-port placeholder: short, because the box is narrow.</summary>
|
||
internal string EditorNewForwardBindHint => (PortForwardKind)EditorNewForwardKind switch
|
||
{
|
||
PortForwardKind.Remote => "remote port",
|
||
PortForwardKind.Dynamic => "SOCKS port",
|
||
_ => "local port",
|
||
};
|
||
|
||
/// <summary>
|
||
/// What the add row's kind does, in a sentence naming which end each port is on — the thing people get
|
||
/// backwards about <c>-L</c> and <c>-R</c>.
|
||
/// </summary>
|
||
internal string EditorNewForwardKindHint => (PortForwardKind)EditorNewForwardKind switch
|
||
{
|
||
PortForwardKind.Remote =>
|
||
"A port on the remote, carried back to a host:port as this machine sees it.",
|
||
PortForwardKind.Dynamic =>
|
||
"A SOCKS proxy on this machine. Each connection through it leaves from the remote.",
|
||
_ => "A port on this machine, carried to a host:port as the remote sees it.",
|
||
};
|
||
|
||
/// <summary>
|
||
/// Adds a forward to the host being edited, from the editor's add row.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Validated here rather than at Save, for the reason <see cref="AddEditorPin"/> is: a refusal at the
|
||
/// box that caused it costs nothing else on the form. The rule's own
|
||
/// <see cref="PortForwardRule.TryValidate"/> is the check, so this cannot drift from what the codec
|
||
/// accepts.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A second listener on a port this host already listens on is refused here</b>, though the domain
|
||
/// allows it — it has to, because two machines each adding one is what a merge produces. Here it is one
|
||
/// person, and the second would only ever fail to start.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void AddEditorForward()
|
||
{
|
||
var kind = (PortForwardKind)EditorNewForwardKind;
|
||
|
||
if (!int.TryParse(EditorNewForwardBindPort.Trim(), NumberStyles.None, CultureInfo.InvariantCulture, out var bindPort))
|
||
{
|
||
Status = "A forward needs a listening port, as a number.";
|
||
return;
|
||
}
|
||
|
||
string? targetHost = null;
|
||
int? targetPort = null;
|
||
|
||
if (kind is not PortForwardKind.Dynamic)
|
||
{
|
||
if (!TryParseForwardTarget(EditorNewForwardTarget, out targetHost, out targetPort))
|
||
{
|
||
Status = "A forward's target is host:port, for example db.internal:5432.";
|
||
return;
|
||
}
|
||
}
|
||
|
||
var rule = new PortForwardRule
|
||
{
|
||
Id = Guid.CreateVersion7(),
|
||
Kind = kind,
|
||
BindPort = bindPort,
|
||
TargetHost = targetHost,
|
||
TargetPort = targetPort,
|
||
};
|
||
|
||
if (!rule.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
if (EditorPortForwards.Count >= HostSecret.MaxPortForwards)
|
||
{
|
||
Status = $"A host cannot carry more than {HostSecret.MaxPortForwards} port forwards.";
|
||
return;
|
||
}
|
||
|
||
// Local and dynamic forwards both listen on this machine, so they share its ports; a remote forward
|
||
// listens on the remote, which is a different set.
|
||
var listensHere = kind is not PortForwardKind.Remote;
|
||
|
||
if (EditorPortForwards.Any(existing =>
|
||
existing.BindPort == bindPort && existing.Kind is not PortForwardKind.Remote == listensHere))
|
||
{
|
||
Status = listensHere
|
||
? $"This host already has a forward listening on port {bindPort} here."
|
||
: $"This host already has a forward listening on port {bindPort} on the remote.";
|
||
return;
|
||
}
|
||
|
||
EditorPortForwards.Add(rule);
|
||
EditorNewForwardBindPort = string.Empty;
|
||
EditorNewForwardTarget = string.Empty;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Removes a forward from the host being edited.</summary>
|
||
[RelayCommand]
|
||
private void RemoveEditorForward(PortForwardRule? rule)
|
||
{
|
||
if (rule is not null)
|
||
{
|
||
EditorPortForwards.Remove(rule);
|
||
}
|
||
}
|
||
|
||
/// <summary>Stages an existing host's forwards for editing. See <see cref="EditorPortForwards"/>.</summary>
|
||
private void LoadEditorPortForwards(PortForwardList forwards)
|
||
{
|
||
EditorPortForwards.Clear();
|
||
|
||
foreach (var forward in forwards)
|
||
{
|
||
EditorPortForwards.Add(forward);
|
||
}
|
||
|
||
EditorNewForwardKind = (int)PortForwardKind.Local;
|
||
EditorNewForwardBindPort = string.Empty;
|
||
EditorNewForwardTarget = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads <c>host:port</c>, with an IPv6 literal in brackets as OpenSSH writes one: <c>[::1]:5432</c>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Split at the last colon, so an unbracketed IPv6 address fails to parse as a port rather than being
|
||
/// cut somewhere in its middle. The host is checked by the rule, not here.
|
||
/// </remarks>
|
||
internal static bool TryParseForwardTarget(string text, out string? host, out int? port)
|
||
{
|
||
host = null;
|
||
port = null;
|
||
|
||
var trimmed = text.Trim();
|
||
var colon = trimmed.LastIndexOf(':');
|
||
|
||
if (colon <= 0
|
||
|| !int.TryParse(trimmed.AsSpan(colon + 1), NumberStyles.None, CultureInfo.InvariantCulture, out var parsed))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var name = trimmed[..colon];
|
||
|
||
if (name.StartsWith('[') && name.EndsWith(']'))
|
||
{
|
||
name = name[1..^1];
|
||
}
|
||
else if (name.Contains(':', StringComparison.Ordinal))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
host = name;
|
||
port = parsed;
|
||
return true;
|
||
}
|
||
|
||
/// <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.</summary>
|
||
/// <remarks>
|
||
/// Equal to <see cref="GroupFilter"/> today, and <see cref="GroupFilter"/>'s own remarks say why nothing
|
||
/// currently makes that non-null: the card-and-trail navigation this was a fallback for is gone from both
|
||
/// heads. <see cref="EditGroupFromHeading"/>, <see cref="MoveGroupFromHeading"/> and
|
||
/// <see cref="DeleteGroupFromHeading"/> resolve a row from the heading they were pressed on and pass it
|
||
/// directly — every current caller hands a group in, so this is read but never leaned on.
|
||
/// </remarks>
|
||
internal HostGroupRowViewModel? GroupTarget => 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 as a connection this vault announced gets from one step to the next.</summary>
|
||
/// <remarks>
|
||
/// Between <see cref="ConnectionStarting"/> and whichever of the other two ends the attempt, any number
|
||
/// of times including none — a handshake fast enough to finish inside one turn reports nothing, which is
|
||
/// the honest account of it. See <see cref="ConnectionProgressEventArgs"/> for the threading, which is
|
||
/// the one way this event differs from its three neighbours.
|
||
/// </remarks>
|
||
internal event EventHandler<ConnectionProgressEventArgs>? ConnectionProgress;
|
||
|
||
/// <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();
|
||
|
||
// Last of the rebuilds, because every picker an open editor holds is drawn from one of the lists
|
||
// above and would otherwise be showing the vault as it was when that editor opened.
|
||
RefreshOpenEditors();
|
||
|
||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refills the pickers of whichever editor is open, so a key or a vault that has just arrived shows in it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The pickers are snapshots — see <see cref="EditorAuthenticationChoices"/> — and taking one when the
|
||
/// editor opens was correct while nothing could change a list without closing it first. Nothing about
|
||
/// that holds any more: the host editor lives on the Hosts screen and the keychain's editors live on the
|
||
/// Vault screen, so a key, a credential, a tag, a group or a whole vault can be added with a host editor
|
||
/// standing open behind it. Every one of them then failed to appear in the picker that exists to offer
|
||
/// it, and the only way to see it was to abandon the edit and start again.
|
||
/// </para>
|
||
/// <para>
|
||
/// Called from the one reload rather than from each of the twenty-odd places that write to the vault,
|
||
/// which is what makes a sync count as well as a save: a key pulled from another machine reaches the
|
||
/// open editor by the same path a key typed here does.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Selections are carried across by id, and every typed field is left alone.</b> The refill has to be
|
||
/// invisible to somebody halfway through a form — an editor that reset its own bindings because a
|
||
/// background sync landed would be a worse bug than the stale list it fixes. So each picker is rebuilt
|
||
/// and then put back onto what it was already showing, including the placeholder entries that stand for
|
||
/// a binding whose target has gone: it is the same restore-by-id the open path does, from the editor's
|
||
/// current selection rather than from the stored item, because for as long as the editor is open the two
|
||
/// deliberately differ.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void RefreshOpenEditors()
|
||
{
|
||
if (IsEditing)
|
||
{
|
||
// Read before anything is cleared. Rebuilding a bound collection makes the control null its own
|
||
// selection and write that back, so by the time the last picker is refilled these properties no
|
||
// longer say what the user chose.
|
||
var authentication = EditorSelectedAuthentication;
|
||
var groupId = EditorSelectedGroup?.EntityId;
|
||
|
||
BuildTagChoices();
|
||
|
||
// The same order the two commands that open this editor use: the vault picker first because a
|
||
// group belongs to one vault, then the groups, then the bindings — which offer "inherit from
|
||
// group" only where the group picker has landed on something.
|
||
BuildEditorVaultChoices(editingHostVaultId);
|
||
|
||
// The selection as it stands, not filtered through GroupInEditingVault: a group that has gone
|
||
// keeps its placeholder here for the reason it does on the open path, so that a sync arriving
|
||
// mid-edit cannot unfile the host when the form is saved.
|
||
BuildGroupChoices(groupId);
|
||
|
||
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);
|
||
}
|
||
|
||
if (IsEditingGroup)
|
||
{
|
||
var authentication = GroupEditorSelectedAuthentication;
|
||
var parentId = GroupEditorSelectedParent?.EntityId;
|
||
|
||
BuildGroupEditorVaultChoices(GroupEditorVaultId);
|
||
|
||
// Guid.Empty while creating, which is what EditingGroupId being null means and is the same
|
||
// stand-in ClearGroupEditor uses: a group that does not exist yet cannot be its own parent.
|
||
BuildGroupParentChoices(EditingGroupId ?? Guid.Empty, parentId);
|
||
|
||
BuildGroupAuthenticationChoices(
|
||
authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
|
||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null);
|
||
}
|
||
}
|
||
|
||
/// <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 (rows, unreadable) = await FetchHostRowsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
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();
|
||
|
||
// The group rows carry a host count, so the boards' headings on both heads are built from them.
|
||
RebuildGroups();
|
||
RebuildVisibleHosts();
|
||
|
||
// The desktop's own board and its two toolbar flyouts. After the group rows for the reason above,
|
||
// and after tagsById — filled by ReloadTagsAsync, which ReloadAsync always runs first — since the
|
||
// tag flyout reads it rather than the active vault's own Tags.
|
||
RebuildGroupFilterChoices();
|
||
RebuildTagFilterChoices();
|
||
RebuildHostSections();
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <summary>Reads every readable vault's hosts and resolves each into a row, without touching <see cref="Hosts"/>.</summary>
|
||
/// <remarks>
|
||
/// Split out of <see cref="ReloadHostsAsync"/> purely for length — this project's analyser caps a method
|
||
/// at 60 lines, and the two halves this makes are "read and resolve" and "sort, select and rebuild",
|
||
/// which is a seam that was already there.
|
||
/// </remarks>
|
||
private async Task<(List<HostRowViewModel> Rows, int Unreadable)> FetchHostRowsAsync(
|
||
CancellationToken cancellationToken)
|
||
{
|
||
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),
|
||
}));
|
||
}
|
||
|
||
return (rows, 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>
|
||
/// 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. Through v4 that fallback was skipped while a group card held the selection
|
||
/// instead of a host; v5's board draws no group cards to hold it, so there is no longer a second case to
|
||
/// skip the fallback for.
|
||
/// </remarks>
|
||
private HostRowViewModel? SelectionAfterReload(Guid? selectedId) =>
|
||
Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
|
||
|
||
/// <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);
|
||
|
||
OnPropertyChanged(nameof(HasTagItems));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether there is at least one tag to manage.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// v5c-2: the settings Tags page's empty state. Named apart from <c>HostRowViewModel.HasTags</c>, which
|
||
/// answers a different question — whether one host wears any — rather than reusing that name at this
|
||
/// level and relying on which <c>x:DataType</c> a binding happens to be inside to tell the two apart.
|
||
/// </remarks>
|
||
internal bool HasTagItems => Tags.Count > 0;
|
||
|
||
/// <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 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
|
||
// GroupFilter holding the old one would go on naming a group that is no longer in the list. A group
|
||
// deleted by a sync clears it, which is the honest answer.
|
||
//
|
||
// This assignment is a new row object whenever GroupFilter names one 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 GroupTarget's own change notification with it.
|
||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||
|
||
OnPropertyChanged(nameof(HasGroups));
|
||
OnPropertyChanged(nameof(UngroupedHostCount));
|
||
}
|
||
|
||
/// <summary>
|
||
/// How many hosts, across every shown vault, carry no group that still exists.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// v5c: the settings Groups page's "No group" footer row. The same dangling-reference reading
|
||
/// <see cref="FlattenIntoSections"/> gives the sidebar's own UNGROUPED heading — a host naming a group
|
||
/// this vault no longer has counts as ungrouped rather than vanishing — but counted over every shown
|
||
/// vault's hosts rather than over a find-box-filtered subset, because a settings page has no find box
|
||
/// and a count that shrank while somebody typed in one would be answering the wrong question.
|
||
/// </remarks>
|
||
internal int UngroupedHostCount
|
||
{
|
||
get
|
||
{
|
||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||
|
||
return Hosts.Count(row =>
|
||
IsVaultShown(row.VaultId) && (row.Host.GroupId is not { } id || !known.Contains(id)));
|
||
}
|
||
}
|
||
|
||
/// <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>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.
|
||
//
|
||
// Ungrouped last, which is the order this list has drawn since groups existed — see
|
||
// FlattenIntoSections for why the desktop's own board no longer has to agree.
|
||
var shown = Hosts.Where(MatchesFilters).ToArray();
|
||
|
||
foreach (var row in FlattenIntoSections(shown, ungroupedFirst: false, ungroupedLabel: "UNGROUPED"))
|
||
{
|
||
SidebarRows.Add(row);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refills <see cref="HostSections"/>: the desktop's own flattening of the same hosts, "No group" first
|
||
/// and narrowed by the toolbar's Group ▾ and Tag ▾ flyouts as well as the find box.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Reuses <see cref="FlattenIntoSections"/> and then regroups its flat rows into one
|
||
/// <see cref="HostSectionViewModel"/> per heading — see that type for why a per-section
|
||
/// <c>ListBox</c> wants its members as a list rather than as headings mixed into one stream.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The no-groups invariant is drawn here rather than left to the regrouping loop below.</b> With no
|
||
/// groups at all <see cref="FlattenIntoSections"/> hands back the hosts and nothing else, and one section
|
||
/// with a null header is what the board's own XAML reads as "draw the cards and nothing above them" —
|
||
/// the same rule <see cref="RebuildSidebarRows"/> has always followed for the phone's list.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void RebuildHostSections()
|
||
{
|
||
HostSections.Clear();
|
||
|
||
foreach (var built in BuildHostSections())
|
||
{
|
||
HostSections.Add(built);
|
||
}
|
||
|
||
// Computed rather than stored, and none of the four has a change notification of its own — the same
|
||
// arrangement RebuildVisibleHosts holds for HasVisibleHosts and NoVisibleHostsMessage, and for the
|
||
// same reason: a control bound to one of these has no other way to learn it should ask again.
|
||
OnPropertyChanged(nameof(HasHostBoardEntries));
|
||
OnPropertyChanged(nameof(HostBoardTotalCount));
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
OnPropertyChanged(nameof(CollapseAllLabel));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The board, vault by vault: every vault's own groups under its own heading.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>Two levels, and the outer one is the vault.</b> See
|
||
/// <see cref="HostSectionViewModel.VaultHeading"/> for the argument. What it means here is a pass per
|
||
/// vault rather than one over every group at once: each vault's unfiled hosts and each vault's groups
|
||
/// are flattened on their own, and the first section that comes back wears the vault's name.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>One vault on the board and this is exactly what it was.</b> Not a special case for tidiness — a
|
||
/// heading naming the only vault there is would be a level of hierarchy with nothing to distinguish,
|
||
/// and the single-vault board is what most of this application's users have. The count is of vaults
|
||
/// that are readable <em>and</em> shown, so hiding the second vault takes the headings away with it.
|
||
/// </para>
|
||
/// </remarks>
|
||
private List<HostSectionViewModel> BuildHostSections()
|
||
{
|
||
var shown = Hosts.Where(MatchesHostBoardFilters).ToArray();
|
||
var vaults = BoardVaults();
|
||
|
||
var sections = vaults.Count < 2
|
||
? SectionsFrom(
|
||
FlattenIntoSections(shown, ungroupedFirst: true, ungroupedLabel: "No group"),
|
||
vaultHeading: null)
|
||
: vaults
|
||
.SelectMany(vault => SectionsFrom(
|
||
FlattenIntoSections(
|
||
[.. shown.Where(row => row.VaultId == vault.VaultId)],
|
||
ungroupedFirst: true,
|
||
ungroupedLabel: "No group",
|
||
vaultId: vault.VaultId),
|
||
vault.Name.ToUpperInvariant()))
|
||
.ToList();
|
||
|
||
// Marked on the first heading of the whole board, which is where the mock draws Collapse all /
|
||
// Expand all — see IsFirstBoardSection and the XAML. The first *heading* rather than the first
|
||
// section: a vault with no groups at all draws its cards under no heading, and there is nothing on
|
||
// that row for the control to sit on.
|
||
for (var index = 0; index < sections.Count; index++)
|
||
{
|
||
if (sections[index].Header is not { } first)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
sections[index] = new HostSectionViewModel(
|
||
first with { IsFirstBoardSection = true },
|
||
sections[index].Hosts)
|
||
{
|
||
VaultHeading = sections[index].VaultHeading,
|
||
};
|
||
|
||
break;
|
||
}
|
||
|
||
return sections;
|
||
}
|
||
|
||
/// <summary>The vaults the board draws a heading for, in the order it draws them.</summary>
|
||
/// <remarks>
|
||
/// The active vault first and then by name, which is the order <see cref="Groups"/>, the host list and
|
||
/// the key list are all already sorted into — so the sections come out in the order the rows inside them
|
||
/// were. Hidden vaults are left out here rather than filtered later, because their absence is what makes
|
||
/// the headings disappear when one vault is left. See <see cref="IsVaultShown"/>.
|
||
/// </remarks>
|
||
private List<(Guid VaultId, string Name)> BoardVaults() =>
|
||
[
|
||
.. session.ReadableVaults
|
||
.Where(vault => IsVaultShown(vault.VaultId))
|
||
.OrderByDescending(vault => vault.VaultId == session.ActiveVaultId)
|
||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture)
|
||
.Select(vault => (vault.VaultId, vault.Name)),
|
||
];
|
||
|
||
/// <summary>
|
||
/// Regroups one flattened run — a whole board, or one vault's part of it — into sections.
|
||
/// </summary>
|
||
/// <param name="flat">Headings and rows in draw order, from <see cref="FlattenIntoSections"/>.</param>
|
||
/// <param name="vaultHeading">
|
||
/// The vault's name for the first section, or null where the board is not drawing vault headings. When
|
||
/// it is given, the group headings drop their own vault badge: the name is already on the row above
|
||
/// them, and printing it twice on every heading is how a two-level board reads as a cluttered one.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// Rows arriving before any heading are the no-groups case — <see cref="FlattenIntoSections"/> hands
|
||
/// back the hosts alone — and they become one section with a null header, which is what the board's XAML
|
||
/// reads as "draw the cards and nothing above them". Per vault now rather than for the whole board: a
|
||
/// vault nobody has made a group in draws its cards flat, under its own name, beside a vault that has.
|
||
/// </remarks>
|
||
private static List<HostSectionViewModel> SectionsFrom(List<ISidebarRow> flat, string? vaultHeading)
|
||
{
|
||
var sections = new List<HostSectionViewModel>();
|
||
|
||
if (flat.Count == 0)
|
||
{
|
||
return sections;
|
||
}
|
||
|
||
SidebarGroupHeader? header = null;
|
||
var members = new List<HostRowViewModel>();
|
||
var loose = new List<HostRowViewModel>();
|
||
|
||
foreach (var row in flat)
|
||
{
|
||
if (row is SidebarGroupHeader next)
|
||
{
|
||
if (header is not null)
|
||
{
|
||
sections.Add(new HostSectionViewModel(header, members));
|
||
}
|
||
|
||
header = vaultHeading is null ? next : next with { VaultBadge = string.Empty };
|
||
members = [];
|
||
}
|
||
else if (row is HostRowViewModel host)
|
||
{
|
||
(header is null ? loose : members).Add(host);
|
||
}
|
||
}
|
||
|
||
if (header is not null)
|
||
{
|
||
sections.Add(new HostSectionViewModel(header, members));
|
||
}
|
||
else
|
||
{
|
||
sections.Add(new HostSectionViewModel(null, loose));
|
||
}
|
||
|
||
if (vaultHeading is { Length: > 0 })
|
||
{
|
||
sections[0] = new HostSectionViewModel(sections[0].Header, sections[0].Hosts)
|
||
{
|
||
VaultHeading = vaultHeading,
|
||
};
|
||
}
|
||
|
||
return sections;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Every group as a heading and its members underneath, in label order — the phone's whole list, and the
|
||
/// desktop's board besides it.
|
||
/// </summary>
|
||
/// <param name="shown">The hosts that survived whatever filters the caller applies before this runs.</param>
|
||
/// <param name="ungroupedFirst">
|
||
/// Whether the heading for hosts filed under nothing comes before every group or after them. The phone
|
||
/// puts it last, which is the order this list has drawn since groups existed; the mock puts the
|
||
/// desktop's first, and there is no reason the two heads have to agree once there are two callers.
|
||
/// </param>
|
||
/// <param name="ungroupedLabel">What that heading says — "UNGROUPED" on the phone, "No group" here.</param>
|
||
/// <param name="vaultId">
|
||
/// The vault this run is about, or null for a run over every vault at once. Given, it narrows the
|
||
/// groups to that vault's own and becomes the fold key of its ungrouped heading — see
|
||
/// <see cref="SidebarGroupHeader.CollapseKey"/>, which exists because there is now one such heading per
|
||
/// vault. The phone's single list passes null and is unchanged by any of this.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// <b>No groups means no headings</b>, on both heads and for the same reason: a keychain nobody has
|
||
/// filed anything in should look exactly as it did before groups existed, on a screen that draws
|
||
/// headings and on one that draws sections for them. A host whose group has been deleted falls under the
|
||
/// ungrouped heading rather than disappearing, for the reason <see cref="HostSecret.GroupId"/> is
|
||
/// allowed to dangle. An empty group still gets its heading, since it is a shelf the user made; the
|
||
/// ungrouped heading is dropped instead when nothing is on it, since that is only ever the box a search
|
||
/// left empty.
|
||
/// </remarks>
|
||
private List<ISidebarRow> FlattenIntoSections(
|
||
IReadOnlyList<HostRowViewModel> shown,
|
||
bool ungroupedFirst,
|
||
string ungroupedLabel,
|
||
Guid? vaultId = null)
|
||
{
|
||
var rows = new List<ISidebarRow>();
|
||
|
||
// This vault's groups where a vault was named, every readable vault's where one was not. A group is
|
||
// an item of one vault, so a run about one vault has no business drawing another's headings — and a
|
||
// vault nobody has made a group in falls into the case below, drawing its cards flat.
|
||
var groups = vaultId is { } only
|
||
? Groups.Where(group => group.VaultId == only).ToList()
|
||
: [.. Groups];
|
||
|
||
if (groups.Count == 0)
|
||
{
|
||
rows.AddRange(shown);
|
||
return rows;
|
||
}
|
||
|
||
var known = groups.Select(group => group.EntityId).ToHashSet();
|
||
|
||
void AddUngrouped() => AddFlatSection(
|
||
rows,
|
||
shown,
|
||
null,
|
||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||
ungroupedLabel,
|
||
onlyWhenOccupied: true,
|
||
ungroupedKey: vaultId ?? Guid.Empty);
|
||
|
||
if (ungroupedFirst)
|
||
{
|
||
AddUngrouped();
|
||
}
|
||
|
||
foreach (var group in groups)
|
||
{
|
||
AddFlatSection(rows, shown, group, host => host.Host.GroupId == group.EntityId, ungroupedLabel);
|
||
}
|
||
|
||
if (!ungroupedFirst)
|
||
{
|
||
AddUngrouped();
|
||
}
|
||
|
||
return rows;
|
||
}
|
||
|
||
/// <summary>Appends one heading to a flat row list, and its members when it is not folded away.</summary>
|
||
/// <param name="rows">The list being built, shared by every section a caller adds in one pass.</param>
|
||
/// <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="ungroupedLabel">What the heading says when <paramref name="group"/> is null.</param>
|
||
/// <param name="onlyWhenOccupied">Whether an empty section is left out altogether.</param>
|
||
/// <param name="ungroupedKey">
|
||
/// What the ungrouped heading folds under, where this is one — the vault's id on the desktop's
|
||
/// per-vault board, <see cref="Guid.Empty"/> on the phone's single list. See
|
||
/// <see cref="SidebarGroupHeader.CollapseKey"/>.
|
||
/// </param>
|
||
private void AddFlatSection(
|
||
List<ISidebarRow> rows,
|
||
IReadOnlyList<HostRowViewModel> shown,
|
||
HostGroupRowViewModel? group,
|
||
Func<HostRowViewModel, bool> belongs,
|
||
string ungroupedLabel,
|
||
bool onlyWhenOccupied = false,
|
||
Guid ungroupedKey = default)
|
||
{
|
||
var members = shown.Where(belongs).ToArray();
|
||
|
||
if (onlyWhenOccupied && members.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var key = group?.EntityId ?? ungroupedKey;
|
||
var expanded = !collapsedGroups.Contains(key);
|
||
|
||
rows.Add(new SidebarGroupHeader(
|
||
group?.EntityId,
|
||
group?.Label ?? ungroupedLabel,
|
||
members.Length,
|
||
expanded)
|
||
{
|
||
VaultBadge = group?.VaultBadge ?? string.Empty,
|
||
CollapseKey = key,
|
||
});
|
||
|
||
if (!expanded)
|
||
{
|
||
return;
|
||
}
|
||
|
||
rows.AddRange(members);
|
||
}
|
||
|
||
/// <summary>Whether a host passes the toolbar's Group ▾ and Tag ▾ flyouts, on top of the ordinary filters.</summary>
|
||
/// <remarks>
|
||
/// Empty means every group or every tag, which is what "All groups" and "All tags" reset the checked
|
||
/// sets to — an empty filter narrows nothing, on the same reading <see cref="MatchesFilters"/> gives an
|
||
/// empty find box. A host passes the tag half by wearing <em>any</em> checked tag, not all of them.
|
||
/// </remarks>
|
||
private bool MatchesHostBoardFilters(HostRowViewModel row) =>
|
||
MatchesFilters(row)
|
||
&& (checkedGroupFilterIds.Count == 0
|
||
|| (row.Host.GroupId is { } groupId && checkedGroupFilterIds.Contains(groupId)))
|
||
&& (checkedTagFilterIds.Count == 0 || row.Host.TagIds.Any(checkedTagFilterIds.Contains));
|
||
|
||
/// <summary>Refills the toolbar's Group ▾ entries from <see cref="Groups"/> and the checked set.</summary>
|
||
private void RebuildGroupFilterChoices()
|
||
{
|
||
GroupFilterChoices.Clear();
|
||
|
||
var several = session.ReadableVaults.Take(2).Count() > 1;
|
||
|
||
foreach (var group in Groups)
|
||
{
|
||
GroupFilterChoices.Add(new GroupFilterChoice(
|
||
group.EntityId,
|
||
several && group.HasVaultBadge ? $"{group.Label} ({group.VaultName})" : group.Label,
|
||
checkedGroupFilterIds.Contains(group.EntityId)));
|
||
}
|
||
}
|
||
|
||
/// <summary>Refills the toolbar's Tag ▾ entries from every readable vault's tags and the checked set.</summary>
|
||
/// <remarks>
|
||
/// Read from <see cref="tagsById"/> rather than <see cref="Tags"/>, which is the active vault's alone —
|
||
/// see the remark on <see cref="ReloadTagsAsync"/>. A filter that could only narrow to the active vault's
|
||
/// tags would leave a team's own tags unreachable from this flyout while still drawn as chips on cards.
|
||
/// </remarks>
|
||
private void RebuildTagFilterChoices()
|
||
{
|
||
TagFilterChoices.Clear();
|
||
|
||
foreach (var tag in tagsById.OrderBy(pair => pair.Value.Label, StringComparer.CurrentCulture))
|
||
{
|
||
TagFilterChoices.Add(new TagFilterChoice(
|
||
tag.Key, tag.Value.Label, checkedTagFilterIds.Contains(tag.Key)));
|
||
}
|
||
}
|
||
|
||
/// <summary>Ticks or unticks one group in the toolbar's Group ▾ flyout.</summary>
|
||
[RelayCommand]
|
||
private void ToggleGroupFilter(GroupFilterChoice? choice)
|
||
{
|
||
if (choice is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!checkedGroupFilterIds.Remove(choice.GroupId))
|
||
{
|
||
checkedGroupFilterIds.Add(choice.GroupId);
|
||
}
|
||
|
||
RebuildGroupFilterChoices();
|
||
RebuildHostSections();
|
||
OnPropertyChanged(nameof(HasActiveGroupFilter));
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
}
|
||
|
||
/// <summary>"All groups" — the reset at the foot of the Group ▾ flyout's checked entries.</summary>
|
||
[RelayCommand]
|
||
private void ResetGroupFilter()
|
||
{
|
||
if (checkedGroupFilterIds.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
checkedGroupFilterIds.Clear();
|
||
RebuildGroupFilterChoices();
|
||
RebuildHostSections();
|
||
OnPropertyChanged(nameof(HasActiveGroupFilter));
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
}
|
||
|
||
/// <summary>Ticks or unticks one tag in the toolbar's Tag ▾ flyout.</summary>
|
||
[RelayCommand]
|
||
private void ToggleTagFilter(TagFilterChoice? choice)
|
||
{
|
||
if (choice is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!checkedTagFilterIds.Remove(choice.TagId))
|
||
{
|
||
checkedTagFilterIds.Add(choice.TagId);
|
||
}
|
||
|
||
RebuildTagFilterChoices();
|
||
RebuildHostSections();
|
||
OnPropertyChanged(nameof(HasActiveTagFilter));
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
}
|
||
|
||
/// <summary>"All tags" — the reset at the foot of the Tag ▾ flyout's checked entries.</summary>
|
||
[RelayCommand]
|
||
private void ResetTagFilter()
|
||
{
|
||
if (checkedTagFilterIds.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
checkedTagFilterIds.Clear();
|
||
RebuildTagFilterChoices();
|
||
RebuildHostSections();
|
||
OnPropertyChanged(nameof(HasActiveTagFilter));
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
}
|
||
|
||
/// <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.
|
||
///
|
||
/// Rebuilds both <see cref="SidebarRows"/> and <see cref="HostSections"/>: the set they fold against is
|
||
/// shared, and whichever head is actually drawing needs its own collection rebuilt regardless.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ToggleGroup(SidebarGroupHeader? header)
|
||
{
|
||
if (header is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var key = header.CollapseKey;
|
||
|
||
if (!collapsedGroups.Remove(key))
|
||
{
|
||
collapsedGroups.Add(key);
|
||
}
|
||
|
||
RebuildSidebarRows();
|
||
RebuildHostSections();
|
||
SelectedSidebarRow = SelectedHost;
|
||
OnPropertyChanged(nameof(CollapseAllLabel));
|
||
}
|
||
|
||
/// <summary>Collapse all / Expand all, on the desktop board's first heading.</summary>
|
||
/// <remarks>
|
||
/// Collapses everything when anything is open, and expands everything only once all of it already is —
|
||
/// see <see cref="CollapseAllLabel"/>. A no-op on a groupless keychain, which draws no headings to fold.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ToggleAllGroups()
|
||
{
|
||
var headers = HostSections.Select(section => section.Header).OfType<SidebarGroupHeader>().ToList();
|
||
|
||
if (headers.Count == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var collapsing = headers.Any(header => header.IsExpanded);
|
||
|
||
foreach (var header in headers)
|
||
{
|
||
var key = header.CollapseKey;
|
||
|
||
if (collapsing)
|
||
{
|
||
collapsedGroups.Add(key);
|
||
}
|
||
else
|
||
{
|
||
collapsedGroups.Remove(key);
|
||
}
|
||
}
|
||
|
||
RebuildSidebarRows();
|
||
RebuildHostSections();
|
||
SelectedSidebarRow = SelectedHost;
|
||
OnPropertyChanged(nameof(CollapseAllLabel));
|
||
}
|
||
|
||
/// <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>();
|
||
|
||
var readableVaults = session.ReadableVaults.ToList();
|
||
var several = readableVaults.Count > 1;
|
||
|
||
foreach (var vault in 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)
|
||
{
|
||
// Only when there is something to tell apart, as the host grid's badge is.
|
||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||
}));
|
||
}
|
||
|
||
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>();
|
||
|
||
var readableVaults = session.ReadableVaults.ToList();
|
||
var several = readableVaults.Count > 1;
|
||
|
||
foreach (var vault in 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)
|
||
{
|
||
// Only when there is something to tell apart, as the host grid's badge is.
|
||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||
}));
|
||
}
|
||
|
||
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: a tap once the phone's bar is up, and a Ctrl-click on the desktop.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Taking the last tick off leaves selection mode, which is what every list of either kind 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>
|
||
/// A Ctrl-click: the card joins or leaves the set, and the set starts at whatever card is lit.
|
||
/// </summary>
|
||
/// <param name="row">The card that was Ctrl-clicked.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>The lit card was not in the set, and every list this one is modelled on says it should be.</b>
|
||
/// The board always has a selection — a fresh unlock lights the first host so that CONNECT has something
|
||
/// under it, see <see cref="SelectionAfterReload"/> — so "one card lit, nothing ticked" is the state
|
||
/// this screen opens in, not an edge case. Ctrl-clicking a second card there used to produce a set of
|
||
/// one: the machine the user was looking at was silently left out, and the count said 1 while two cards
|
||
/// were marked on screen. Delete, Move and Change group all read the set, so the omission was one the
|
||
/// user could act on without noticing.
|
||
/// </para>
|
||
/// <para>
|
||
/// Only into an <em>empty</em> set, and never the card being clicked. The first is what makes it a
|
||
/// starting point rather than a card that keeps re-adding itself: once anything is ticked the lit card
|
||
/// is either already in the set or was deliberately taken out of it. The second is what keeps every
|
||
/// Ctrl-click visibly doing something — folding the lit card in and then toggling that same card off
|
||
/// again would leave the set empty and the card still lit, which is a gesture with no effect and no way
|
||
/// to tell it was heard.
|
||
/// </para>
|
||
/// <para>
|
||
/// Separate from <see cref="ToggleHostChoice"/> rather than folded into it, because that one is also the
|
||
/// phone's tap and the phone has no lit card to fold: there a tap only ever reaches the set once a long
|
||
/// press has already put something in it, and a selection nothing draws is not one anybody can be
|
||
/// surprised to find in a count.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ExtendHostChoice(HostRowViewModel? row)
|
||
{
|
||
if (row is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
FoldTheLitCardIn(besides: row);
|
||
ToggleHostChoice(row);
|
||
}
|
||
|
||
/// <summary>Puts the lit card into an empty set, which is what a Ctrl gesture builds on.</summary>
|
||
/// <param name="besides">A card to leave out — the one the gesture is about. See
|
||
/// <see cref="ExtendHostChoice"/> for why.</param>
|
||
/// <remarks>
|
||
/// The board's order rather than <see cref="Hosts"/>, for the reason Ctrl+A takes it from there: a
|
||
/// selected host inside a folded section is not on screen, and starting a set with a card nobody can see
|
||
/// is the version of this that ends in a deletion. <see cref="ApplyTheChosenHosts"/> is left to the
|
||
/// caller, which runs it once for the whole gesture.
|
||
/// </remarks>
|
||
private void FoldTheLitCardIn(HostRowViewModel? besides = null)
|
||
{
|
||
if (chosenHostIds.Count > 0
|
||
|| SelectedHost is not { } lit
|
||
|| ReferenceEquals(lit, besides)
|
||
|| !HostBoardOrder.Contains(lit))
|
||
{
|
||
return;
|
||
}
|
||
|
||
chosenHostIds.Add(lit.EntityId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ticks a whole run of hosts at once, which is what the desktop's pointer builds a selection out of.
|
||
/// </summary>
|
||
/// <param name="rows">The hosts to tick.</param>
|
||
/// <param name="replacing">
|
||
/// Whether this run is the selection now, or is being added to whatever is already ticked — which is the
|
||
/// difference between a band dragged across the grid and the same band dragged with Ctrl held.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>A method rather than a command, and it is the one entry into this set that is not a control.</b>
|
||
/// Every other one is pressed: a long press, a tap, the cross on the bar. This one is handed a run the
|
||
/// head has just worked out from the pointer — the cards a rubber band crossed, or the cards between two
|
||
/// clicks — and a command takes a single argument, so binding it would mean inventing a parameter object
|
||
/// for a caller that is not a binding. See <c>HostsScreen.axaml.cs</c> on the desktop.
|
||
/// </para>
|
||
/// <para>
|
||
/// Replacing with nothing goes through <see cref="ClearHostChoice"/> rather than merely emptying the set,
|
||
/// for the reason <see cref="ToggleHostChoice"/> does: the panels and the menu are about the set, and a
|
||
/// picker asking which vault to move nothing to is not a state worth having. That is the ordinary end of
|
||
/// a band dragged across empty space, and of a plain click on the grid's background.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal void ChooseHosts(IEnumerable<HostRowViewModel> rows, bool replacing)
|
||
{
|
||
if (replacing)
|
||
{
|
||
chosenHostIds.Clear();
|
||
}
|
||
else
|
||
{
|
||
// A band dragged with Ctrl adds to what was already ticked, and on this board "what was already
|
||
// ticked" starts at the card the grid has lit. Same rule as a Ctrl-click; see ExtendHostChoice
|
||
// for the whole of the argument. No card is left out here, because a band adds rather than
|
||
// toggles and cannot undo its own fold.
|
||
FoldTheLitCardIn();
|
||
}
|
||
|
||
foreach (var row in rows)
|
||
{
|
||
chosenHostIds.Add(row.EntityId);
|
||
}
|
||
|
||
if (chosenHostIds.Count == 0)
|
||
{
|
||
ClearHostChoice();
|
||
return;
|
||
}
|
||
|
||
ApplyTheChosenHosts();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ticks every host between two cards, which is what a shift-click on the desktop means.
|
||
/// </summary>
|
||
/// <param name="anchor">Where the run starts: the last card clicked without shift.</param>
|
||
/// <param name="to">The card that was shift-clicked.</param>
|
||
/// <param name="replacing">Whether the run is the selection now, or is added to it.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The order is the board's own — <see cref="HostBoardOrder"/>, which is <see cref="HostSections"/>
|
||
/// flattened back out in the order the cards are drawn — so "between" means what the eye says it means,
|
||
/// with whatever the find box and the two toolbar flyouts have already narrowed it to, and every section
|
||
/// a fold has not hidden. Taking it from <see cref="Hosts"/> instead would tick machines that are not on
|
||
/// the screen, which is the version of this mistake that ends in a deletion.
|
||
/// </para>
|
||
/// <para>
|
||
/// Either end missing from that order ticks nothing rather than guessing. That is a shift-click arriving
|
||
/// after the run's other end has been filtered away, or folded inside a collapsed section, and the
|
||
/// honest answer to it is no run.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal void ChooseHostRun(HostRowViewModel? anchor, HostRowViewModel? to, bool replacing)
|
||
{
|
||
if (anchor is null || to is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var order = HostBoardOrder.ToList();
|
||
var from = order.IndexOf(anchor);
|
||
var until = order.IndexOf(to);
|
||
|
||
if (from < 0 || until < 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var first = Math.Min(from, until);
|
||
var last = Math.Max(from, until);
|
||
|
||
ChooseHosts(order.Skip(first).Take(last - first + 1).ToList(), replacing);
|
||
}
|
||
|
||
/// <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 — checked once here, over the whole set, rather than inside
|
||
/// <see cref="ConfirmRegroupChosenHostsAsync"/> once 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 only way a set is filed since v5: dragging a ticked card onto a group card went with the group
|
||
/// grid it was dragged onto, and nothing replaced the gesture — the picker this confirms was always the
|
||
/// phone's only route and is now both heads'. 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;
|
||
}
|
||
|
||
IsRegroupingChosenHosts = false;
|
||
ChosenHostGroupChoices.Clear();
|
||
SelectedChosenHostGroup = null;
|
||
|
||
await FileTheChosenHostsUnderAsync(
|
||
choice.EntityId,
|
||
choice.EntityId is null ? "no group" : choice.Label,
|
||
cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The write behind filing a set of ticked hosts under a group, from the group picker.
|
||
/// </summary>
|
||
/// <param name="groupId">The group they end up in, or null for none.</param>
|
||
/// <param name="name">What to call it on the status line.</param>
|
||
/// <param name="cancellationToken">The caller's lifetime.</param>
|
||
/// <remarks>
|
||
/// <see cref="ConfirmRegroupChosenHostsAsync"/>'s one caller today; kept as a method of its own rather
|
||
/// than folded back into it because what "filing a set" means — the hosts a newer version wrote are
|
||
/// skipped, the ones already there are counted as done, and the selection is dropped once the reload has
|
||
/// replaced every row — reads better named than inlined, regardless of how many callers it has.
|
||
/// </remarks>
|
||
private async Task FileTheChosenHostsUnderAsync(
|
||
Guid? groupId, string name, CancellationToken cancellationToken)
|
||
{
|
||
var rows = ChosenHosts;
|
||
|
||
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 == groupId)
|
||
{
|
||
// 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 = groupId },
|
||
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;
|
||
|
||
// What the count below is about, so that the question goes if the set stops being it.
|
||
deletionAskedAbout.Clear();
|
||
deletionAskedAbout.UnionWith(chosenHostIds);
|
||
|
||
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);
|
||
}
|
||
|
||
// A question asked about six hosts is not a question about these seven. See deletionAskedAbout.
|
||
if (IsConfirmingChosenHostDeletion && !chosenHostIds.SetEquals(deletionAskedAbout))
|
||
{
|
||
PendingDeletion = null;
|
||
}
|
||
|
||
// 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(ChosenHostsSummary));
|
||
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 320 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();
|
||
EditorPinnedPaths.Clear();
|
||
EditorNewPin = string.Empty;
|
||
LoadEditorPortForwards(PortForwardList.Empty);
|
||
|
||
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
|
||
ClearEditorCredentialForm();
|
||
|
||
// 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 GroupTarget names, on the terms that property's own remarks give —
|
||
// nothing sets it to a value today, so this is the fallback path rather than the ordinary one it was
|
||
// through v4. 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();
|
||
|
||
// As in NewHost, and for the password it can be holding.
|
||
ClearEditorCredentialForm();
|
||
|
||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||
LoadEditorPortForwards(row.Host.PortForwards);
|
||
|
||
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 to fall back to <see cref="GroupTarget"/> — see that property's own
|
||
/// remarks for why nothing today actually relies on the fallback.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// Taking it as an argument rather than reading a selection is what lets both heads reach this the same
|
||
/// way: <see cref="EditGroupFromHeading"/> resolves a row from the heading that was pressed and hands it
|
||
/// straight in, on the desktop and the phone alike, so neither has to put a group on a selection a host
|
||
/// card might be holding — see <see cref="SelectedHost"/>.
|
||
/// </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>
|
||
/// ◆ <b>Both heads need this command now, and both reach it the same way: a menu entry rather than a
|
||
/// button that stands on its own.</b> That has been true since v4's drawer replaced the desktop's own bar
|
||
/// with an editor asked for on demand; v5 changes only where the entry lives — "New group…" at the foot
|
||
/// of the Group ▾ filter flyout, since the flyout is what the toolbar now has where GROUPS used to be a
|
||
/// heading of its own. The phone reaches the same command from its add sheet. Raising a stale editor
|
||
/// would offer the last group's default key to the new one without anybody choosing it, which is what
|
||
/// <see cref="ClearGroupEditor"/> prevents, on both heads alike.
|
||
/// </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"; the other two callers are a cancel and a save, and neither is asking for a
|
||
/// group anywhere.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The group <see cref="GroupTarget"/> names, and deliberately not the card that is selected</b> —
|
||
/// which is where this differs from <see cref="NewHost"/> only in which selection it would have read.
|
||
/// Nothing today sets <see cref="GroupFilter"/> to a value worth reading, per that property's own
|
||
/// remarks, so in practice this always falls through to the standing "new items go to" preference below.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>And in that group's vault</b>, which <see cref="NewHost"/> 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, on the rare occasion there is one.
|
||
/// </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.
|
||
/// </para>
|
||
/// <para>
|
||
/// Aims where Edit does: at the group it is handed, and failing that at <see cref="GroupTarget"/> — see
|
||
/// that property's own remarks for why nothing today actually reaches this through the fallback.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Both heads pass the row directly rather than leaning on a selection.</b> <see cref="DeleteGroupFromHeading"/>
|
||
/// resolves it from the heading that was pressed, on the desktop's menu and the phone's sheet alike —
|
||
/// see that command's own remarks.
|
||
/// </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;
|
||
|
||
// The form goes with the editor it lives in, password and all. A credential already added through it
|
||
// stays in the keychain — see AddEditorCredentialAsync — but what was still being typed does not.
|
||
ClearEditorCredentialForm();
|
||
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;
|
||
|
||
// As CancelEdit does, for the same password.
|
||
ClearEditorCredentialForm();
|
||
|
||
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>
|
||
/// <param name="requests">The hosts to store, and the key files any of them named.</param>
|
||
/// <param name="vaultId">
|
||
/// Which vault to write them into, or null for the active one.
|
||
/// </param>
|
||
/// <param name="cancellationToken">Cancellation.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// For the two importers, which are the only things that produce hosts in bulk. It goes through the same
|
||
/// repositories, the same outbox and the same automatic push as saving one — an import screen decides
|
||
/// <em>which</em> hosts and <em>where</em> 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>
|
||
/// ◆ <b>The vault is a parameter rather than the active one, and null still means the active one.</b>
|
||
/// The <c>ssh_config</c> importer reads a file belonging to this machine's own account and passes null;
|
||
/// the Hetzner one reads somebody's estate and offers a picker, because forty servers belonging to a
|
||
/// team are exactly the case where "wherever I happen to be standing" is the wrong answer. Everything
|
||
/// written by one call goes to one vault — the hosts and any key material with them — so a batch cannot
|
||
/// end up split across two.
|
||
/// </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,
|
||
Guid? vaultId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(requests);
|
||
|
||
var target = vaultId ?? session.ActiveVaultId;
|
||
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, target, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
if (created)
|
||
{
|
||
keys++;
|
||
}
|
||
|
||
var host = keyId is { } id ? request.Host with { SshKeyId = id } : request.Host;
|
||
|
||
await session.Hosts
|
||
.CreateAsync(target, 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,
|
||
Guid vaultId,
|
||
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(vaultId, 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;
|
||
|
||
// ◆ The panel is drawn in the drawer's footer and nowhere else, so raising it means raising the
|
||
// drawer. Free when this was the drawer's own ⋯ menu — the pane is already open there — and the
|
||
// whole of what makes the board's context menu entry work, since a right click on a card does not
|
||
// open anything. Set before the flag, so the pane and its footer arrive in one layout pass.
|
||
IsHostPaneOpen = true;
|
||
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, 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's own remarks say why nothing sets it to a value worth reading here — resolving from the
|
||
/// panel is what lets both heads' MOVE button work regardless, and it says the honest thing besides: 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 () =>
|
||
{
|
||
await ReSealTheSubtreeAsync(subtree, filed, target.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
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>
|
||
/// Opens the tag editor for a specific row, from the settings page's per-row edit icon.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A thin wrapper around <see cref="EditTag"/> rather than a second implementation of what it does. The
|
||
/// keychain screen's own table drives <see cref="EditTag"/> off a <c>ListBox</c> selection; the settings
|
||
/// page draws one card per tag with no such selection to lean on, so this puts the row on
|
||
/// <see cref="SelectedTag"/> first and then asks the command that already knows how to open it — the
|
||
/// same trick <see cref="EditGroupFromHeading"/> plays for a group, except that command took the row as
|
||
/// an argument from the start and this one did not, because nothing needed it to until now.
|
||
/// </remarks>
|
||
/// <param name="row">The tag to edit.</param>
|
||
[RelayCommand]
|
||
private void EditTagRow(TagRowViewModel? row)
|
||
{
|
||
SelectedTag = row;
|
||
EditTagCommand.Execute(null);
|
||
}
|
||
|
||
/// <summary>Arms the delete confirmation for a specific row, from the settings page's per-row delete icon.</summary>
|
||
/// <inheritdoc cref="EditTagRow" path="/remarks" />
|
||
/// <param name="row">The tag to ask about deleting.</param>
|
||
[RelayCommand]
|
||
private void DeleteTagRow(TagRowViewModel? row)
|
||
{
|
||
SelectedTag = row;
|
||
DeleteTagCommand.Execute(null);
|
||
}
|
||
|
||
/// <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>
|
||
/// Puts the selected pin's fingerprint on the clipboard.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The twin of <see cref="CopyPublicKeyAsync"/> and the opposite call about secrecy: a host key
|
||
/// fingerprint is not one. Operators publish theirs on purpose, and the whole workflow this screen exists
|
||
/// for is comparing a pinned one against what was published — which is a copy-and-paste somebody should
|
||
/// not have to retype by hand out of a box that refuses to trim it.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task CopyPinFingerprintAsync()
|
||
{
|
||
if (SelectedKnownHost is not { } row)
|
||
{
|
||
Status = "Choose a pinned key first.";
|
||
return;
|
||
}
|
||
|
||
if (copyToClipboard is null)
|
||
{
|
||
Status = "This machine has no clipboard.";
|
||
return;
|
||
}
|
||
|
||
await copyToClipboard(row.Fingerprint).ConfigureAwait(true);
|
||
|
||
Status = $"Copied the fingerprint for {row.Host}.";
|
||
}
|
||
|
||
/// <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>Turns the handshake's phases into this attempt's progress events.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Forwarded rather than accumulated, because the tab is the thing that knows what has already happened
|
||
/// and this object deliberately does not: a connection here is one straight line from renderer to
|
||
/// session, and a running total of where it had got to would be a second copy of the state the card
|
||
/// already draws from the first.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Deliberately not <c>System.Progress<T></c></b>, which captures whatever synchronisation
|
||
/// context it happens to be constructed on and posts to it. That reads like a convenience and is really
|
||
/// a second place the marshalling decision gets made: silently, differently under a test with no
|
||
/// context, and — because a post is a later turn — out of order with respect to the failure or the
|
||
/// session that follows the phase. Raised inline instead, and the shell marshals once where it can be
|
||
/// seen. See <c>MainWindowViewModel.OnVaultConnectionProgress</c>.
|
||
/// </para>
|
||
/// </remarks>
|
||
private PhaseReporter ReporterFor(ConnectionAttemptEventArgs attempt) => new(phase =>
|
||
ConnectionProgress?.Invoke(this, new ConnectionProgressEventArgs(attempt.AttemptId, StepFor(phase))));
|
||
|
||
/// <summary>The step a handshake phase is reported to the shell as.</summary>
|
||
/// <remarks>
|
||
/// The whole of the mapping between the SSH assembly's four phases and the card's five steps, in one
|
||
/// place. <see cref="ConnectionStep.PreparingTerminal"/> is not here because nothing reports it: the tab
|
||
/// starts on it, and the first phase to arrive is what closes it.
|
||
/// </remarks>
|
||
private static ConnectionStep StepFor(SshConnectionPhase phase) => phase switch
|
||
{
|
||
SshConnectionPhase.Reaching => ConnectionStep.Reaching,
|
||
SshConnectionPhase.CheckingHostKey => ConnectionStep.CheckingHostKey,
|
||
SshConnectionPhase.Authenticating => ConnectionStep.Authenticating,
|
||
SshConnectionPhase.OpeningShell => ConnectionStep.OpeningShell,
|
||
_ => ConnectionStep.Reaching,
|
||
};
|
||
|
||
/// <summary>Hands each phase straight to a delegate, on the thread that reported it.</summary>
|
||
/// <remarks>
|
||
/// The whole type, and it exists to be the thing <c>System.Progress<T></c> is not — see the remark
|
||
/// at its one use. A lambda cannot implement an interface, and the alternative was widening the
|
||
/// workspace's parameter to <c>Action<T></c>, which would have put a non-standard progress
|
||
/// contract into three assemblies to save one class here.
|
||
/// </remarks>
|
||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
||
{
|
||
private int furthest = -1;
|
||
|
||
/// <summary>
|
||
/// The furthest phase reported, or null if the handshake never reported one at all.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Kept because a failure cannot be read without it: <see cref="ExplainAsync"/> probes the endpoint
|
||
/// only for a connection that stopped while reaching, and this is the record of how far it got.
|
||
/// Nothing else asks — the card is driven by the events, not by this.
|
||
/// </para>
|
||
/// <para>
|
||
/// ◆ <b>An <see cref="int"/> behind a <see cref="Volatile"/> pair, because the write and the read are
|
||
/// on different threads.</b> Phases are reported from whichever thread the handshake is on — SSH.NET
|
||
/// raises host key verification on its own message thread, which is where
|
||
/// <see cref="SshConnectionPhase.CheckingHostKey"/> and
|
||
/// <see cref="SshConnectionPhase.Authenticating"/> come from — and this is read on the UI thread
|
||
/// after the attempt has failed. The <c>await</c> in between orders them in practice; nothing in the
|
||
/// language says so, and a cached read here would probe a host that had already offered a key.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Monotonic, so a phase cannot go backwards.</b> The enum only ever advances today, and the
|
||
/// maximum rather than the latest is what keeps that an observation rather than something this type
|
||
/// relies on: a rekey mid-handshake raising <c>HostKeyReceived</c> a second time must not be able to
|
||
/// walk the record back to <see cref="SshConnectionPhase.CheckingHostKey"/> from
|
||
/// <see cref="SshConnectionPhase.OpeningShell"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
public SshConnectionPhase? Furthest =>
|
||
Volatile.Read(ref furthest) is var seen && seen < 0 ? null : (SshConnectionPhase)seen;
|
||
|
||
public void Report(SshConnectionPhase value)
|
||
{
|
||
var reported = (int)value;
|
||
|
||
// Read-compare-write rather than an interlocked maximum, which the BCL has no primitive for. The
|
||
// race it loses is two phases reported at the same instant from two threads, which this
|
||
// handshake does not do — it is one connection making one call at a time — and the cost of
|
||
// losing it would be one phase forgotten rather than a wrong answer.
|
||
if (reported > Volatile.Read(ref furthest))
|
||
{
|
||
Volatile.Write(ref furthest, reported);
|
||
}
|
||
|
||
report(value);
|
||
}
|
||
}
|
||
|
||
/// <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),
|
||
target.HostId);
|
||
|
||
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 whichever host-key decision is showing, without pinning or forgetting anything.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// One command for both, because both are dismissals and the two states are mutually exclusive — which is
|
||
/// what <c>TransfersViewModel.RejectHostKey</c> has always done for the same pair of cards. The mismatch
|
||
/// has nothing else it may offer: withdrawing a pin is a deliberate act performed in the host's editor,
|
||
/// away from the moment of connecting, and a button here would be "continue anyway" with two clicks
|
||
/// instead of one.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>It used to clear only the pending key, which left the mismatch with no working exit.</b> That was
|
||
/// invisible for as long as the refusal was a banner at the top of a screen — nothing was drawn over, so
|
||
/// nothing was trapped, and the next connection attempt cleared it. It is not invisible now: both heads
|
||
/// draw the refusal over the surface, and the phone's is an opaque full-screen panel whose one button runs
|
||
/// this command. Leaving <see cref="HostKeyMismatch"/> set would have kept that panel up over every screen
|
||
/// the user went to afterwards, including the host editor they were being sent to.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void RejectHostKey()
|
||
{
|
||
var wasOffered = PendingHostKey is not null;
|
||
|
||
PendingHostKey = null;
|
||
HostKeyMismatch = null;
|
||
|
||
Status = wasOffered
|
||
? "The host key was not trusted, so nothing was connected."
|
||
: "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)
|
||
{
|
||
// Built here rather than inside the call, because the catch blocks below read it: how far the
|
||
// handshake got is what says whether a failure is worth probing the endpoint over. See ExplainAsync.
|
||
var reporter = ReporterFor(attempt);
|
||
|
||
try
|
||
{
|
||
await ConnectAndAnnounceAsync(attempt, target, authentication, reporter, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
catch (TimeoutException)
|
||
{
|
||
Abandon(attempt, RendererNeverStarted);
|
||
}
|
||
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,
|
||
await ExplainAsync(exception, target, reporter, cancellationToken).ConfigureAwait(true));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// What to tell the user about a connection that failed, having gone and looked when the message alone
|
||
/// does not say.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// ◆ <b>Because "Connection has timed out." is what SSH.NET says for two unrelated faults.</b> A machine
|
||
/// that never answered and a machine that answered TCP and then never spoke SSH arrive here as the same
|
||
/// five words, and they have nothing in common to do about them — the first is a network, a firewall or a
|
||
/// wrong address, the second is an <c>sshd</c> that is wedged or a port that is not SSH. That was the
|
||
/// whole of what this screen could say about the failure users hit most, so it now goes and finds out;
|
||
/// see <see cref="ISshReachabilityProbe"/> for what it does and deliberately does not do.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Only for a failure that happened while reaching, and the phase is what settles that.</b> The window
|
||
/// is exactly <see cref="SshConnectionPhase.Reaching"/>, and it is closed at both ends. Past it the
|
||
/// machine has demonstrably been reached — it offered a host key — so a refused password or a key the
|
||
/// remote would not take is not a reachability question, and probing the port would answer one nobody
|
||
/// asked while telling somebody their network is fine when they already know.
|
||
/// </para>
|
||
/// <para>
|
||
/// ◆ <b>And before it, nothing has been dialled yet, which is the half that would lie.</b> No phase at
|
||
/// all means the attempt never reached <c>ConnectAsync</c>: a renderer that did not attach, a credential
|
||
/// that would not decrypt, a private key that would not parse — <c>BuildConnectionInfo</c> throws on that
|
||
/// last one before the factory reports anything. Probing there finds a healthy port and appends "the SSH
|
||
/// handshake is what did not finish" to a message about an unreadable key, which is a confident wrong
|
||
/// answer where there was an accurate one. The phases are reported at the instant each begins and are not
|
||
/// guesses; see the enum.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The original message is kept and the finding is added to it.</b> What SSH.NET said is the fact, and
|
||
/// this is a reading of a second measurement — dropping the first would leave nothing to check the second
|
||
/// against, and would lose the one case where the exception is already specific.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A probe that itself fails changes nothing.</b> The user is being told about a connection that did
|
||
/// not happen; a diagnostic that cannot run is not a second failure worth reporting, and must not be able
|
||
/// to replace the first with its own exception. Cancellation is in that: a user who closed the tab while
|
||
/// this ran is not waiting for the answer.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task<string> ExplainAsync(
|
||
Exception exception,
|
||
ConnectionTarget target,
|
||
PhaseReporter reporter,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// A relay refusal already says exactly what happened, and a relayed host is reached by a path the
|
||
// probe does not take: it would dial the host directly — the route the user chose the relay because
|
||
// they cannot use — and report on that instead.
|
||
if (reporter.Furthest != SshConnectionPhase.Reaching || exception is ServerRelayException || target.Relayed)
|
||
{
|
||
return exception.Message;
|
||
}
|
||
|
||
try
|
||
{
|
||
var report = await Reachability
|
||
.ProbeAsync(target.Hostname, target.Port, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// A probe with nothing to say leaves the message exactly as it was, rather than appending a
|
||
// space to it. The real one always has a sentence; this is what keeps a substitute that does not
|
||
// from changing the text of every failure that goes past it.
|
||
return report.Summary is { Length: > 0 } finding
|
||
? $"{exception.Message} {finding}"
|
||
: exception.Message;
|
||
}
|
||
catch (Exception probe) when (probe is not OutOfMemoryException)
|
||
{
|
||
return exception.Message;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc cref="DefaultReachability" />
|
||
private ISshReachabilityProbe Reachability => reachability ?? DefaultReachability;
|
||
|
||
/// <summary>What a renderer that never attached is reported as.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The wait is translated rather than reported for the reason <see cref="OpenSessionAsync"/> gives —
|
||
/// <see cref="TimeoutException"/> says only "The operation has timed out" — and the whole value of the
|
||
/// translation is naming where to look. Which is why it cannot be one sentence: the desktop's answer is
|
||
/// a runtime this application does not install, and the phone has no such runtime and no such answer.
|
||
/// Telling somebody on a handset to install Microsoft Edge WebView2 is worse than saying nothing, at the
|
||
/// one moment they are trying to work out what went wrong.
|
||
/// </para>
|
||
/// <para>
|
||
/// A runtime check rather than a constructor parameter, for the reason
|
||
/// <c>MainWindowViewModel.GestureWait</c> records at length: which renderer is behind the terminal is a
|
||
/// fact about the platform this assembly is running on, not about one installation of it.
|
||
/// </para>
|
||
/// </remarks>
|
||
private static string RendererNeverStarted =>
|
||
OperatingSystem.IsAndroid()
|
||
? "The terminal did not start, so nothing was connected. Android's WebView is probably "
|
||
+ "disabled or updating; check it in Settings and try again."
|
||
: "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||
+ "runtime is probably missing or blocked; install it and try again.";
|
||
|
||
/// <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,
|
||
PhaseReporter reporter,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// Not reported before the await: the tab is constructed with this step already running — see
|
||
// TerminalTabViewModel — because there is no moment between the two worth telling anybody about.
|
||
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
var request = new SshConnectionRequest(
|
||
target.Hostname,
|
||
target.Port,
|
||
authentication.Username,
|
||
authentication.Credential);
|
||
|
||
var sessionId = await OpenThroughAsync(target, request, reporter, 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}.";
|
||
|
||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||
// read back here rather than carried forward from somewhere upstream that never had them. Absent only
|
||
// for a session that has already ended in the instant between opening it and asking, which the empty
|
||
// string collapses to on the status bar exactly as a session with nothing to report would.
|
||
var facts = workspace.GetSessionFacts(sessionId);
|
||
|
||
// 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),
|
||
facts?.Cipher ?? string.Empty,
|
||
facts?.HostKeyAlgorithm ?? string.Empty,
|
||
authentication.IdentityLabel,
|
||
target.HostId,
|
||
target.Row?.Host.PortForwards));
|
||
|
||
// 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>
|
||
/// Opens a relay to a host set to connect through the server, or says why there is none.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>There is no falling back to a direct connection.</b> A user who ticked the relay did so because
|
||
/// the direct path does not work, or goes somewhere they do not mean — and dialling it anyway on a
|
||
/// failure would be the old defect in reverse: the box would say one thing and the connection do
|
||
/// another. So a relay that cannot be opened is the failure, stated as such.
|
||
/// </para>
|
||
/// <para>
|
||
/// The server is resolved the way a synchronisation resolves it, which comes back online quietly when a
|
||
/// sign-in is remembered. Offline is its own sentence, because it is the one case the user can fix from
|
||
/// this machine without touching the host.
|
||
/// </para>
|
||
/// <para>
|
||
/// Internal because file transfer opens a connection of its own and a relayed host needs the relay
|
||
/// there too — or the transfers screen would fail for exactly the hosts the relay exists for.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal async Task<RelayRoute> OpenRelayAsync(string label, Guid hostId, CancellationToken cancellationToken)
|
||
{
|
||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||
{
|
||
throw new ServerRelayException(
|
||
$"{label} connects through the server relay, and this machine is not connected to the "
|
||
+ "server right now. Try again once it is back online.");
|
||
}
|
||
|
||
return await server.Relay.OpenAsync(hostId, cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens the session, through the relay when there is one, and puts the relay's reason on a failure.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A relay that hung up reaches SSH.NET as a stream that ended, and it reports that the way it reports
|
||
/// every lost transport — a sentence that reads exactly like the direct failure the relay was ticked to
|
||
/// get around. When the relay said why it closed, that sentence is the true one and replaces it. The
|
||
/// host key refusals are left alone: a key was presented, so the relay did its job.
|
||
/// </para>
|
||
/// <para>
|
||
/// The relay is disposed on every failure. The connection owns it once it is handed over and disposes
|
||
/// it on its own failure paths too, but a throw before the factory is reached would otherwise leave a
|
||
/// bridge listening until its own timeout; disposing twice is safe.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task<uint> OpenThroughAsync(
|
||
ConnectionTarget target,
|
||
SshConnectionRequest request,
|
||
PhaseReporter reporter,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// After the renderer rather than before it: a ticket is dead thirty seconds after issue, and the
|
||
// renderer's wait is the one step of a connect with no bound a user would recognise.
|
||
var relay = target.Relayed
|
||
? await OpenRelayAsync(target.Label, target.HostId!.Value, cancellationToken).ConfigureAwait(true)
|
||
: null;
|
||
|
||
try
|
||
{
|
||
return await workspace
|
||
.OpenSessionAsync(
|
||
relay is null ? request : request with { Proxy = relay.Proxy },
|
||
TerminalSize.Default,
|
||
reporter,
|
||
cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
catch (Exception exception) when (relay is not null)
|
||
{
|
||
await relay.DisposeAsync().ConfigureAwait(true);
|
||
|
||
if (relay.Failure is { } failure
|
||
&& exception is not (OperationCanceledException or SshHostKeyUnknownException or SshHostKeyMismatchException))
|
||
{
|
||
throw new ServerRelayException(failure, exception);
|
||
}
|
||
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <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>
|
||
/// <param name="IdentityLabel">
|
||
/// The display name of the keychain item that authenticates — a key's or a credential's own
|
||
/// <c>Label</c> — or null when the password was typed rather than filed. Carried alongside the credential
|
||
/// so the session shell's status bar can name what actually authenticated without a second lookup back
|
||
/// into <see cref="Keys"/> or <see cref="Credentials"/> once the connection has moved on.
|
||
/// </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, string? IdentityLabel = null);
|
||
|
||
/// <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>
|
||
/// Whether this is reached through the server relay rather than dialled.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Only a keychain host can be: the relay dials the address the server holds for an item, and a
|
||
/// machine typed into the manual box has no item and therefore nothing on the server to dial.
|
||
/// </remarks>
|
||
internal bool Relayed => Row?.Host.RelayEnabled is true;
|
||
}
|
||
|
||
/// <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,
|
||
out string? identityLabel,
|
||
[NotNullWhen(false)] out string? reason)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(host);
|
||
|
||
var resolved = Resolve(host);
|
||
|
||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||
{
|
||
request = null;
|
||
identityLabel = null;
|
||
return false;
|
||
}
|
||
|
||
request = new SshConnectionRequest(
|
||
host.Hostname,
|
||
resolved.Port.Value,
|
||
authentication.Username,
|
||
authentication.Credential);
|
||
|
||
identityLabel = authentication.IdentityLabel;
|
||
|
||
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),
|
||
credential.Label,
|
||
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),
|
||
key.Label,
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
// No item to name: a typed password has nothing filed in the keychain, and IdentityLabel stays null —
|
||
// the default Complete's own signature gives it — rather than the placeholder text a display-only
|
||
// fallback would tempt someone to invent.
|
||
return Complete(
|
||
resolved.Username.Value,
|
||
new SshPasswordCredential(typedPassword),
|
||
identityLabel: null,
|
||
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,
|
||
string? identityLabel,
|
||
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, identityLabel);
|
||
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,
|
||
|
||
// The editor's own staged list — see EditorPinnedPaths — canonicalised on the way out the same
|
||
// way TagIds is: PinnedPathList.Create dedupes and keeps order, so a path added twice by some
|
||
// path this box's own guard in AddEditorPin did not catch still lands on the host once rather
|
||
// than twice.
|
||
PinnedPaths = PinnedPathList.Create(EditorPinnedPaths),
|
||
|
||
// The staged list, in the order it was built; each rule keeps the id it arrived with.
|
||
PortForwards = PortForwardList.Create(EditorPortForwards),
|
||
|
||
// 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)
|
||
{
|
||
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 deletion is disarmed against <see cref="GroupTarget"/> rather than against the group itself: a
|
||
/// reload hands this a new row object for whatever <see cref="GroupFilter"/> names, 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. <see cref="RebuildVisibleHosts"/> runs
|
||
/// after, since a filter narrowing the board is still live even though nothing sets it any more — see
|
||
/// <see cref="GroupFilter"/>'s own remarks.
|
||
/// </remarks>
|
||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||
{
|
||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||
CloseTheGroupMovePanelIfAimedElsewhere();
|
||
|
||
OnPropertyChanged(nameof(GroupTarget));
|
||
|
||
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. Both boards rebuild — the
|
||
/// phone's flat list and the desktop's sectioned one — since the box is the one filter they share.
|
||
/// </remarks>
|
||
partial void OnHostFilterChanged(string value)
|
||
{
|
||
RebuildVisibleHosts();
|
||
RebuildHostSections();
|
||
OnPropertyChanged(nameof(NoHostBoardMessage));
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
// A tag's own "used by" is the host count it already carries — the same fact
|
||
// Description prints, so the two never disagree.
|
||
UsedBySummary = tag.HostCount > 0 ? tag.Description : string.Empty,
|
||
});
|
||
}
|
||
}
|
||
|
||
/// <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>
|
||
/// ── v5b ── Which hosts authenticate with a key or a credential, short-form: "a", "a, b" or "a +N".
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Reads the same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning, so the
|
||
/// table's own USED BY column and that warning can never disagree about who is counted. Three names and a
|
||
/// count past that would be the deletion warning's own budget; this one is a 1.4fr table column, so it
|
||
/// stops at two names plus a count — see Keychain.dc.html's own "prod-api-01 +2" sample.
|
||
/// </remarks>
|
||
private string UsedBySummaryFor(ResolvedBindingKind kind, Guid entityId)
|
||
{
|
||
var names = Hosts
|
||
.Where(row => row.Resolved.Binding is { } binding
|
||
&& binding.Kind == kind && binding.EntityId == entityId)
|
||
.Select(row => row.Label)
|
||
.ToList();
|
||
|
||
return names.Count switch
|
||
{
|
||
0 => string.Empty,
|
||
1 => names[0],
|
||
2 => $"{names[0]}, {names[1]}",
|
||
_ => $"{names[0]} +{names.Count - 1}",
|
||
};
|
||
}
|
||
|
||
/// <summary>── v5b ── Narrows <see cref="VaultItems"/> to rows <see cref="ItemFilter"/> matches.</summary>
|
||
/// <remarks>
|
||
/// Its own method for the reason <see cref="AddTagRows"/> is out of <see cref="RebuildVaultItems"/> —
|
||
/// length — and applied last, by removal, rather than threaded into each of the four kind-specific
|
||
/// loops: one pass over the merged list is simpler than four copies of the same three-field check, and
|
||
/// the filter has no opinion about which kind a row is.
|
||
/// </remarks>
|
||
private void ApplyItemFilter()
|
||
{
|
||
var needle = ItemFilter.Trim();
|
||
|
||
if (needle.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
for (var i = VaultItems.Count - 1; i >= 0; i--)
|
||
{
|
||
var row = VaultItems[i];
|
||
|
||
if (!Contains(row.Name) && !Contains(row.Type) && !Contains(row.Detail))
|
||
{
|
||
VaultItems.RemoveAt(i);
|
||
}
|
||
}
|
||
|
||
bool Contains(string value) => value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||
}
|
||
|
||
/// <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)
|
||
{
|
||
VaultBadge = key.VaultBadge,
|
||
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.SshKey, key.EntityId),
|
||
});
|
||
}
|
||
}
|
||
|
||
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)
|
||
{
|
||
VaultBadge = credential.VaultBadge,
|
||
UsedBySummary = UsedBySummaryFor(ResolvedBindingKind.Credential, credential.EntityId),
|
||
});
|
||
}
|
||
}
|
||
|
||
AddTagRows();
|
||
AddBucketRows();
|
||
ApplyItemFilter();
|
||
|
||
// 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));
|
||
|
||
RebuildSelectedItemUsage(value);
|
||
|
||
// 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);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// ── v5b ── Rebuilds the detail pane's own USED BY list for whichever key or credential is selected.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The same resolved-binding scan <see cref="HostsBoundTo"/> reads for the deletion warning and
|
||
/// <see cref="UsedBySummaryFor"/> reads for the table's own column, kept in step so none of the three
|
||
/// ever disagrees about who authenticates with the selected item. Empty for every other kind.
|
||
/// </remarks>
|
||
private void RebuildSelectedItemUsage(VaultItemRowViewModel? value)
|
||
{
|
||
SelectedItemUsedByHosts.Clear();
|
||
|
||
ResolvedBindingKind? kind = value?.Kind switch
|
||
{
|
||
VaultItemKind.Key => ResolvedBindingKind.SshKey,
|
||
VaultItemKind.Credential => ResolvedBindingKind.Credential,
|
||
_ => null,
|
||
};
|
||
|
||
if (kind is { } resolvedKind && value is not null)
|
||
{
|
||
foreach (var row in Hosts.Where(row => row.Resolved.Binding is { } binding
|
||
&& binding.Kind == resolvedKind && binding.EntityId == value.EntityId))
|
||
{
|
||
SelectedItemUsedByHosts.Add(new UsedByHostRowViewModel(row.Label, row.IsConnected));
|
||
}
|
||
}
|
||
|
||
OnPropertyChanged(nameof(HasSelectedItemUsedByHosts));
|
||
OnPropertyChanged(nameof(SelectedItemInUseSummary));
|
||
OnPropertyChanged(nameof(HasSelectedItemInUseSummary));
|
||
}
|
||
|
||
/// <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));
|
||
|
||
/// <summary>── v5b ── Re-narrows the table as the filter box is typed into.</summary>
|
||
partial void OnItemFilterChanged(string value) => RebuildVaultItems();
|
||
|
||
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));
|
||
}
|