Public Access
The one thing the host editor's vault picker has always been unable to offer, and the comment beside it said so: an existing host's vault was not a field because the two vaults are encrypted under different keys. That is still true. What changed is that it is no longer a reason to have nothing. **A move is a copy and a tombstone, and it cannot be anything else.** A payload is sealed under its vault's key and its AAD binds the vault, the entity id and the item version, so no edit moves one and no server call could — the server holds ciphertext it cannot read. What crosses is the plaintext, in this process, between an unwrap under one key and a seal under another. VaultItemRepository gained MoveAsync for it, so the three decisions below live in one place with their reasons rather than being re-derived at each call site. The item takes a new id. Keeping it would put one entity id in two vaults, and the item table is keyed on the type and the id rather than on the vault — so the destination's row and the source's tombstone would be the same row, and the move would delete what it had just written. The write comes first and the tombstone second, which decides what an interruption leaves: a copy in both vaults, visible and deletable, rather than a tombstone with nothing on the other side. Both are queued rather than sent, so the window is a crash between two local writes; it is still worth being on the survivable side of. Two activity lines rather than one, because that is what the two vaults actually record. A single "moved" line would have to be written to one of them and would be missing from the other's history. **The group and the tags stay behind, and that is the half that makes this honest.** Both are items of the vault the host is leaving: the editor's group picker offers one vault's groups and the chips are drawn from one vault's tags. A host carrying either across would resolve it on the machine that moved it — groups and tags are resolved over every readable vault — and dangle for everybody else in the destination. The mover and their colleagues would be looking at two different hosts. Cleared and reported beats carried and invisible. The key or password binding is kept, and the difference is not inconsistency. Those genuinely resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for — so clearing them would take a working host and make one that cannot connect. What the message does instead is name a binding that is now outside the destination, because that is precisely what the other members of it will not be able to resolve. **It is not in the editor**, on either head: the desktop puts it in the detail pane's ⋯ menu above the separator Delete sits below, and the phone beside EDIT. A picker inside the form would move a machine as a side effect of correcting a port, which is the bug the editor's own vault picker was fenced off to prevent in the first place. The panel takes the footer as the deletion question does, and says what will be left behind before the tap rather than after it — on a phone, where the status line afterwards is one line on a screen somebody has already navigated away from, that is the only place it reliably gets read. The phone hides the button where there is nowhere to go rather than offering one that answers with a refusal; the desktop keeps its menu entry either way, because a menu that grew and shrank would be a menu whose items move. One thing found while writing the test and deliberately not changed. The pass that follows every write on this screen reports what it moved and supersedes the confirmation — for a save and a delete as much as for a move — so the move's own sentence is what somebody sees offline. The test asserts it in that state and says why. Making confirmations survive their own sync pass is a question about the whole screen rather than about this. Four places said an item could never be moved, two of them sentences on screen in both heads. All four now say what is true, including the design gaps document, where the chevron beside the vault name stays undrawn for a different reason: a chevron on a subtitle implies an edit, and this is a re-seal, a new id and two references left behind.
8015 lines
354 KiB
C#
8015 lines
354 KiB
C#
using System.Collections.ObjectModel;
|
||
using System.Diagnostics.CodeAnalysis;
|
||
using System.Globalization;
|
||
using System.Text;
|
||
using CommunityToolkit.Mvvm.ComponentModel;
|
||
using CommunityToolkit.Mvvm.Input;
|
||
using DodoSSH.Client.Api;
|
||
using DodoSSH.Client.Domain;
|
||
using DodoSSH.Client.Session;
|
||
using DodoSSH.Client.Ssh;
|
||
using DodoSSH.Client.Sync;
|
||
using DodoSSH.Client.Terminal;
|
||
|
||
namespace DodoSSH.Client.Shell.ViewModels;
|
||
|
||
/// <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 chevron, as text, because the heading is drawn in the list's own item template.</summary>
|
||
internal string Chevron => IsExpanded ? "▾" : "▸";
|
||
}
|
||
|
||
/// <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(VaultItem<HostGroupSecret> group, int hostCount)
|
||
{
|
||
internal Guid EntityId => group.EntityId;
|
||
|
||
internal HostGroupSecret Group => group.Secret;
|
||
|
||
internal string Label => group.Secret.Label;
|
||
|
||
internal int HostCount => hostCount;
|
||
|
||
internal bool IsReadOnly => group.IsReadOnly;
|
||
|
||
internal string Badge => ItemBadge.For(group.IsBlocked, group.IsReadOnly, group.HasUnsyncedChanges);
|
||
|
||
/// <summary>What the row says under the name.</summary>
|
||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||
}
|
||
|
||
/// <summary>One step of the path into the groups, as a button in a breadcrumb trail.</summary>
|
||
/// <param name="Name">What the step is called.</param>
|
||
/// <param name="Group">The group it opens, or null for the step that shows every host again.</param>
|
||
/// <remarks>
|
||
/// The same shape the transfers screen's trail uses — see <c>CrumbViewModel</c> — and drawn the same way,
|
||
/// because it answers the same question: a directory pane and a grid of groups both have to say where the
|
||
/// thing on screen came from and offer a way back out. The row rather than its id, because
|
||
/// <see cref="VaultViewModel.GroupFilter"/> holds a row, and an id would only be looked up again.
|
||
/// </remarks>
|
||
internal sealed record GroupCrumbViewModel(string Name, HostGroupRowViewModel? Group);
|
||
|
||
/// <summary>An entry in the host editor's group picker.</summary>
|
||
/// <param name="EntityId">The group, or null for "no group".</param>
|
||
/// <param name="Label">What to show.</param>
|
||
/// <remarks>
|
||
/// A sentinel entry rather than a nullable selection, for the reason <see cref="AuthenticationChoice"/> gives:
|
||
/// a ComboBox with nothing selected and a ComboBox meaning "no group" look identical and are not the same
|
||
/// thing. As there, a group the vault no longer has keeps a placeholder entry, so that editing a host's port
|
||
/// cannot quietly unfile it.
|
||
/// </remarks>
|
||
internal sealed record GroupChoice(Guid? EntityId, string Label)
|
||
{
|
||
/// <summary>The "not in a group" entry, always first.</summary>
|
||
internal static GroupChoice None { get; } = new(null, "No group");
|
||
}
|
||
|
||
/// <summary>One host, and the group it is being filed under.</summary>
|
||
/// <param name="Host">The host to move.</param>
|
||
/// <param name="GroupId">The group it should end up in, or null for none.</param>
|
||
/// <remarks>
|
||
/// A pair rather than two command parameters, because a command takes one — and a pair rather than the two
|
||
/// ids, because the host row is what the caller is holding: it is the thing that was dragged, and it already
|
||
/// carries the vault the edit has to return to.
|
||
/// </remarks>
|
||
internal sealed record HostGroupMove(HostRowViewModel Host, Guid? GroupId);
|
||
|
||
/// <summary>One snippet, as a row in the list.</summary>
|
||
/// <remarks>
|
||
/// Carries the decrypted <see cref="SnippetSecret"/> so opening the editor needs no second decryption, in
|
||
/// the same way a host row does — and so that inserting one is a read from memory rather than a decryption
|
||
/// per click.
|
||
/// </remarks>
|
||
internal sealed class SnippetRowViewModel(VaultItem<SnippetSecret> snippet)
|
||
{
|
||
internal Guid EntityId => snippet.EntityId;
|
||
|
||
internal SnippetSecret Snippet => snippet.Secret;
|
||
|
||
internal string Label => snippet.Secret.Label;
|
||
|
||
internal bool RunsOnInsert => snippet.Secret.RunsOnInsert;
|
||
|
||
internal bool IsReadOnly => snippet.IsReadOnly;
|
||
|
||
internal bool HasUnsyncedChanges => snippet.HasUnsyncedChanges;
|
||
|
||
internal string Badge => ItemBadge.For(snippet.IsBlocked, snippet.IsReadOnly, snippet.HasUnsyncedChanges);
|
||
|
||
/// <summary>
|
||
/// The command, on one line, for the list.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Newlines become <c>⏎</c> rather than being dropped or wrapped. A three-line snippet shown as one run
|
||
/// of text would read as a single command, which is exactly the thing the user is deciding about when
|
||
/// they look at this row.
|
||
/// </remarks>
|
||
internal string Preview => snippet.Secret.Command
|
||
.ReplaceLineEndings("\n")
|
||
.Replace("\n", " ⏎ ", StringComparison.Ordinal)
|
||
.Trim();
|
||
}
|
||
|
||
/// <summary>One tag, as a row in the keychain table.</summary>
|
||
/// <remarks>
|
||
/// The narrowest row on that screen — a tag is a name — and it is there rather than only inside the host
|
||
/// editor for the reason the type exists at all: a tag is worth being an item because renaming it is one
|
||
/// write instead of twenty, and a rename needs somewhere to happen. Deleting needs somewhere too, or the
|
||
/// picker fills with names nobody uses and never empties.
|
||
/// </remarks>
|
||
internal sealed class TagRowViewModel(VaultItem<TagSecret> tag, int hostCount)
|
||
{
|
||
internal Guid EntityId => tag.EntityId;
|
||
|
||
internal TagSecret Tag => tag.Secret;
|
||
|
||
internal string Label => tag.Secret.Label;
|
||
|
||
internal int HostCount => hostCount;
|
||
|
||
internal bool IsReadOnly => tag.IsReadOnly;
|
||
|
||
internal bool HasUnsyncedChanges => tag.HasUnsyncedChanges;
|
||
|
||
internal string Badge => ItemBadge.For(tag.IsBlocked, tag.IsReadOnly, tag.HasUnsyncedChanges);
|
||
|
||
/// <summary>What the row says under the name.</summary>
|
||
/// <remarks>
|
||
/// The count, because it is the only fact a tag has beyond its name and it is the one that decides
|
||
/// whether deleting it matters. A tag on nothing is a tidy-up; a tag on twenty machines is a filter
|
||
/// somebody relies on.
|
||
/// </remarks>
|
||
internal string Description => hostCount == 1 ? "1 host" : $"{hostCount} hosts";
|
||
}
|
||
|
||
/// <summary>
|
||
/// One tag in the host editor's picker, and whether this host wears it.
|
||
/// </summary>
|
||
/// <param name="EntityId">The tag item.</param>
|
||
/// <param name="Label">What to show on the chip.</param>
|
||
/// <param name="IsWorn">Whether the host being edited currently carries it.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A chip that toggles rather than a multi-select list, because that is what a tag looks like everywhere
|
||
/// else on both heads — the row already draws worn tags as chips, and a picker drawn as anything else
|
||
/// would make the user match a list entry to a chip they can see two inches away.
|
||
/// </para>
|
||
/// <para>
|
||
/// Rebuilt whenever the set changes rather than mutated, so the chip is a value and equality is contents.
|
||
/// A mutable <c>IsWorn</c> would need change notification on every chip in the vault to make one toggle
|
||
/// redraw.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed record TagChoice(Guid EntityId, string Label, bool IsWorn);
|
||
|
||
/// <summary>One host, as a row in the list.</summary>
|
||
/// <remarks>
|
||
/// Carries the decrypted <see cref="HostSecret"/> so opening the editor needs no second decryption, and
|
||
/// the flags the list has to show: an edit this machine has not pushed, a change the server refused, and
|
||
/// an item a newer client wrote that must not be re-encoded here.
|
||
/// </remarks>
|
||
internal sealed partial class HostRowViewModel(
|
||
VaultItem<HostSecret> host,
|
||
ResolvedHost resolved,
|
||
IReadOnlyList<string> tagLabels,
|
||
Guid vaultId,
|
||
string vaultName) : ObservableObject, ISidebarRow
|
||
{
|
||
internal Guid EntityId => host.EntityId;
|
||
|
||
/// <summary>
|
||
/// Which vault this host lives in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Carried on the row rather than read from the session, because a session now holds several and an
|
||
/// edit has to return to the vault the item came from. Writing it to the active vault instead would
|
||
/// create a second copy in the personal vault and leave the team's original untouched — a silent fork
|
||
/// that only shows up when somebody else wonders why their change never arrived.
|
||
/// </remarks>
|
||
internal Guid VaultId => vaultId;
|
||
|
||
/// <summary>The vault's display name, for the heading the sidebar groups under.</summary>
|
||
internal string VaultName => vaultName;
|
||
|
||
/// <summary>
|
||
/// The vault name to print on this row, or empty when there is only one vault to be in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Decided by the list rather than by the row, because "is there more than one vault" is not
|
||
/// something a row can see — and the alternative, a binding that reaches out to the parent view
|
||
/// model from inside an item template, is the kind of thing that silently resolves to nothing.
|
||
/// </remarks>
|
||
internal string VaultBadge { get; init; } = string.Empty;
|
||
|
||
/// <summary>Whether this row has a vault to name.</summary>
|
||
internal bool HasVaultBadge => VaultBadge.Length > 0;
|
||
|
||
/// <summary>
|
||
/// The name of the group this host is filed under, or empty for a host that is in none.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// What the desktop's grid draws as a chip on the card. It is the one thing the fold-away group headings
|
||
/// between the cards used to say, and the reason removing them cost nothing: a card that names its own
|
||
/// group answers the question per host, where a heading answered it per run of cards and needed the grid
|
||
/// to be sorted into runs to do it. The phone still draws headings — its list has no room for the row of
|
||
/// group cards the desktop puts above the grid — so <see cref="SidebarGroupHeader"/> stays.
|
||
/// </para>
|
||
/// <para>
|
||
/// Resolved once when the list is built, like <see cref="TagLabels"/> and for the same two reasons: the
|
||
/// row stores an id and a chip shows a name, and the answer cannot change without the list being rebuilt.
|
||
/// <b>A group id that does not resolve leaves this empty</b> rather than printing the id — the reference
|
||
/// is allowed to dangle, deleting a group deliberately does not rewrite the hosts in it, and "the group
|
||
/// this names is not here" and "this names no group" are the same thing to look at. See
|
||
/// <see cref="HostSecret.GroupId"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string GroupLabel { get; init; } = string.Empty;
|
||
|
||
/// <summary>Whether this host is filed under a group the vault can name.</summary>
|
||
internal bool HasGroup => GroupLabel.Length > 0;
|
||
|
||
internal HostSecret Host => host.Secret;
|
||
|
||
/// <summary>
|
||
/// The same host with its group chain applied: what it dials, not what was typed into it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Resolved once, when the list is built, rather than per property. Every label on this row wants the
|
||
/// same three answers, and re-walking the chain for each of them would be a walk per property per redraw.
|
||
/// It also means a row cannot disagree with itself — the address the list shows and the port the connect
|
||
/// command dials come from one value.
|
||
/// </remarks>
|
||
internal ResolvedHost Resolved => resolved;
|
||
|
||
/// <summary>
|
||
/// The names of the tags this host wears, in the order the chips are drawn.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Names rather than ids, resolved once when the list is built, because a chip shows a name and the
|
||
/// host stores an id — and resolving per chip per redraw would be a dictionary lookup per tag per row
|
||
/// per frame for a string that cannot change without the list being rebuilt.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A tag that does not resolve is left out rather than drawn as its id.</b> It means the tag was
|
||
/// deleted on another machine, or belongs to a vault this session cannot read; either way the honest
|
||
/// answer is a host with one chip fewer, not one wearing a GUID. That the host still carries the id is
|
||
/// the point — nothing rewrites twenty payloads to clear one deleted tag, so the chip comes back if the
|
||
/// tag does. See <see cref="HostSecret.TagIds"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal IReadOnlyList<string> TagLabels => tagLabels;
|
||
|
||
/// <summary>Whether this host has any chips to draw.</summary>
|
||
internal bool HasTags => tagLabels.Count > 0;
|
||
|
||
internal string Label => host.Secret.Label;
|
||
|
||
/// <summary>
|
||
/// What this row dials, as one string.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The <em>resolved</em> address, which is not decoration: <c>MainWindowViewModel.Rank</c> searches this,
|
||
/// so a host inheriting 2222 that displayed 22 would be unfindable by the port it actually answers on —
|
||
/// and the user would be searching for the number the machine really uses.
|
||
/// </remarks>
|
||
internal string Address => string.Create(
|
||
CultureInfo.InvariantCulture,
|
||
$"{DisplayUsername}@{host.Secret.Hostname}:{resolved.Port.Value}");
|
||
|
||
/// <remarks>
|
||
/// An em dash for "nobody", which covers both a host that states no username and a host whose group
|
||
/// states none either. The two are the same thing to look at and the same thing at connect time: refused,
|
||
/// with a message asking for one.
|
||
/// </remarks>
|
||
private string DisplayUsername =>
|
||
string.IsNullOrEmpty(resolved.Username.Value) ? "—" : resolved.Username.Value;
|
||
|
||
/// <summary>
|
||
/// The one line under the name on a card: the transport, the account, and every tag, comma-separated.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The address is deliberately not in it, and it used to be the whole line.</b> A card carrying
|
||
/// <c>root@10.0.4.12:22</c> and a second row of tag chips is three facts and a wrap in a 232-pixel tile,
|
||
/// and the two that a person scanning forty machines actually reads are the name and what kind of
|
||
/// machine it is. The address is on the card's tooltip and in the drawer, which is where somebody
|
||
/// checking an address is looking anyway. See <c>HostsScreen.axaml</c>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>"ssh" is a constant today and is printed anyway</b>, which is the one thing on this row worth
|
||
/// arguing about — this codebase omits constants dressed up as readings, and by that rule the word
|
||
/// should not be here. It is here because it is the first item of a list whose other items vary, and a
|
||
/// list that begins with the account on one card and with a tag on the next has no shape to scan. It
|
||
/// becomes a real fact the day a second transport exists; until then it is a label, not a reading.
|
||
/// </para>
|
||
/// <para>
|
||
/// The account is the <em>resolved</em> one, so a host taking its group's user says that user rather
|
||
/// than nothing, and a host nobody has given one to is one item shorter rather than saying "—". Tags
|
||
/// come last because there can be any number of them and the two before them are at most one each.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string Summary => string.Join(", ", SummaryParts());
|
||
|
||
private IEnumerable<string> SummaryParts()
|
||
{
|
||
yield return "ssh";
|
||
|
||
if (!string.IsNullOrEmpty(resolved.Username.Value))
|
||
{
|
||
yield return resolved.Username.Value;
|
||
}
|
||
|
||
foreach (var tag in tagLabels)
|
||
{
|
||
yield return tag;
|
||
}
|
||
}
|
||
|
||
internal bool HasUnsyncedChanges => host.HasUnsyncedChanges;
|
||
|
||
internal bool IsBlocked => host.IsBlocked;
|
||
|
||
internal bool IsReadOnly => host.IsReadOnly;
|
||
|
||
/// <summary>How this host authenticates, in one word.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Worth a word in the list because the three behave differently at the moment of connecting: only one of
|
||
/// them needs the password box filled in, and a user staring at an empty password box on a
|
||
/// key-authenticated host has no other way to know it is not needed. "password" is the typed kind, which
|
||
/// is why the stored kind is "credential" rather than a second sort of password.
|
||
/// </para>
|
||
/// <para>
|
||
/// Read from the resolved binding rather than the host's own two ids, because the question this answers
|
||
/// is "will the password box be used" — and a host that inherits its group's key would otherwise say
|
||
/// "password" while quietly not needing one. Where the binding came from a group the word is the same;
|
||
/// the group is named in <see cref="VaultViewModel.SelectedHostAuthenticationNote"/>, which has room for
|
||
/// a sentence rather than a word.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string Authentication => resolved.Binding.Kind switch
|
||
{
|
||
ResolvedBindingKind.Credential => "credential",
|
||
ResolvedBindingKind.SshKey => "key",
|
||
_ => "password",
|
||
};
|
||
|
||
/// <summary>A short marker for the row, so the list says what it knows without a tooltip.</summary>
|
||
internal string Badge => ItemBadge.For(host.IsBlocked, host.IsReadOnly, host.HasUnsyncedChanges);
|
||
|
||
/// <summary>
|
||
/// Whether a terminal is open on this host right now.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The one thing on a host row that is not a property of the host. It is written by the shell, which owns
|
||
/// the tab list, because a session outlives the vault that opened it — so the vault cannot be the one
|
||
/// holding the answer. The design's status dot is this, and it is the reason the row is observable at
|
||
/// all: everything else here is fixed for the row's lifetime and a reload replaces the row outright.
|
||
/// </para>
|
||
/// <para>
|
||
/// Deliberately not "reachable" or "up". Nothing here pings anything, and a dot that meant availability
|
||
/// would be a claim this application never checks.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private bool isConnected;
|
||
}
|
||
|
||
/// <summary>What a host can authenticate with.</summary>
|
||
internal enum AuthenticationKind
|
||
{
|
||
/// <summary>Typed at the moment of connecting.</summary>
|
||
/// <remarks>
|
||
/// Nothing is stored under this kind. Ticking the connect bar's REMEMBER does not change that — it
|
||
/// creates a credential and moves the host to <see cref="Credential"/>, so a stored password is always
|
||
/// an item somebody can find, rename and delete rather than a fourth place a secret quietly lives. See
|
||
/// <see cref="VaultViewModel.RemembersConnectPassword"/>.
|
||
/// </remarks>
|
||
Typed,
|
||
|
||
/// <summary>An SSH key in this vault.</summary>
|
||
SshKey,
|
||
|
||
/// <summary>A username and password in this vault.</summary>
|
||
Credential,
|
||
|
||
/// <summary>
|
||
/// Whatever the group above this host says, or a typed password if it says nothing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The entry that arrived with inheritance, and the reason <see cref="HostSecret.AsksForPassword"/>
|
||
/// exists. Naming neither a key nor a credential used to be the way to say "type one each time"; it now
|
||
/// means this, so the old meaning needs a value of its own — otherwise a host under a group that binds a
|
||
/// key could not opt out of it.
|
||
/// </remarks>
|
||
Inherited,
|
||
}
|
||
|
||
/// <summary>An entry in the host editor's authentication picker.</summary>
|
||
/// <param name="Kind">Which of the three ways this entry means.</param>
|
||
/// <param name="EntityId">The bound item's id, or null for a typed password.</param>
|
||
/// <param name="Label">What to show.</param>
|
||
/// <param name="Qualifier">
|
||
/// What kind of thing the label names, shown beside it. Empty for the typed-password entry, which is not a
|
||
/// thing in the vault.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>One picker for all three, not two pickers.</b> A host authenticates with a key <i>or</i> a stored
|
||
/// credential <i>or</i> a typed password, never two of them — <c>HostSecret.TryValidate</c> refuses a host
|
||
/// naming both. Two controls would express the illegal state and then reject it at save time; one control
|
||
/// cannot express it at all. That is the same reason this is a sentinel entry rather than a nullable
|
||
/// selection: a ComboBox with nothing selected and a ComboBox meaning "type a password" look identical and
|
||
/// are not the same thing — the first is a host whose binding has not been decided, the second is a decision.
|
||
/// </para>
|
||
/// <para>
|
||
/// The qualifier is load-bearing rather than decoration. Keys and credentials are named by the user and often
|
||
/// named the same thing — a key called <c>deploy</c> and the deploy account's password are the ordinary case —
|
||
/// so a list of bare labels would offer two indistinguishable entries that authenticate completely
|
||
/// differently.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed record AuthenticationChoice(
|
||
AuthenticationKind Kind,
|
||
Guid? EntityId,
|
||
string Label,
|
||
string Qualifier)
|
||
{
|
||
/// <summary>The "type it each time" entry, always first.</summary>
|
||
/// <remarks>
|
||
/// Named for what it costs rather than for what it lacks. "Password (no key)" described the old two-way
|
||
/// choice from the key's side; with credentials in the same list the distinction a user needs is between a
|
||
/// password this vault knows and one they will be asked for.
|
||
/// </remarks>
|
||
internal static AuthenticationChoice Typed { get; } =
|
||
new(AuthenticationKind.Typed, null, "Password (ask each time)", string.Empty);
|
||
|
||
/// <summary>The "whatever the group says" entry, offered only to a host that is in one.</summary>
|
||
/// <remarks>
|
||
/// Named for where the answer comes from rather than for what it will be, because what it will be
|
||
/// changes when somebody edits the group — which is the point of it. It is deliberately not offered to
|
||
/// an ungrouped host: with nothing above it this and <see cref="Typed"/> do the same thing, and two
|
||
/// entries that behave identically are two entries a user has to guess between.
|
||
/// </remarks>
|
||
internal static AuthenticationChoice Inherited { get; } =
|
||
new(AuthenticationKind.Inherited, null, "Inherit from group", string.Empty);
|
||
|
||
/// <summary>The "this group lends no binding" entry, first in the group editor's picker.</summary>
|
||
/// <remarks>
|
||
/// A sentinel rather than a null selection, for the reason <see cref="Typed"/> is one: a picker showing
|
||
/// nothing and a group that deliberately lends nothing look identical and are not the same thing. It is
|
||
/// not <see cref="Typed"/> under another name — a group cannot assert "everything under here types its
|
||
/// password", because that is already what a host gets when the chain lends nothing.
|
||
/// </remarks>
|
||
internal static AuthenticationChoice NoDefault { get; } =
|
||
new(AuthenticationKind.Typed, null, "No default binding", string.Empty);
|
||
|
||
/// <summary>An SSH key that is in the vault.</summary>
|
||
internal static AuthenticationChoice ForKey(Guid entityId, string label) =>
|
||
new(AuthenticationKind.SshKey, entityId, label, "SSH key");
|
||
|
||
/// <summary>A credential that is in the vault.</summary>
|
||
internal static AuthenticationChoice ForCredential(Guid entityId, string label) =>
|
||
new(AuthenticationKind.Credential, entityId, label, "credential");
|
||
|
||
/// <summary>
|
||
/// A stand-in for something the host names and the vault no longer has.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Kept in the list, and kept selected, so that opening a host to change its port does not silently
|
||
/// convert it to a typed password on save. The id and the kind are both preserved — the kind because
|
||
/// dropping it would rebind a dangling credential as a dangling key — and only the label admits the
|
||
/// problem.
|
||
/// </remarks>
|
||
internal static AuthenticationChoice Missing(AuthenticationKind kind, Guid entityId) => new(
|
||
kind,
|
||
entityId,
|
||
kind is AuthenticationKind.Credential
|
||
? "(a credential that is no longer here)"
|
||
: "(a key that is no longer here)",
|
||
kind is AuthenticationKind.Credential ? "credential" : "SSH key");
|
||
}
|
||
|
||
/// <summary>One SSH key, as a row in the list.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Carries the decrypted <see cref="SshKeySecret"/>, as the host row carries its host, so opening the
|
||
/// editor or connecting with the key needs no second decryption.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Nothing here exposes the private key to the view.</b> <see cref="Key"/> is what the editor and the
|
||
/// connect path read, and the members the XAML binds are the label, a description and a badge. That is not
|
||
/// a security boundary — the same object holds the material either way — but it does mean no template,
|
||
/// tooltip or accessibility surface can end up rendering a private key by being pointed at the obvious
|
||
/// property.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal sealed class SshKeyRowViewModel(VaultItem<SshKeySecret> key, Guid vaultId, string vaultName)
|
||
{
|
||
internal Guid EntityId => key.EntityId;
|
||
|
||
/// <summary>Which vault this key lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||
internal Guid VaultId => vaultId;
|
||
|
||
/// <summary>The vault's display name.</summary>
|
||
internal string VaultName => vaultName;
|
||
|
||
internal SshKeySecret Key => key.Secret;
|
||
|
||
internal string Label => key.Secret.Label;
|
||
|
||
/// <summary>What the list shows under the name: what is known about the key, never the key.</summary>
|
||
internal string Description => key.Secret switch
|
||
{
|
||
{ Passphrase: not null, PublicKey: not null } => "passphrase · public half stored",
|
||
{ Passphrase: not null } => "passphrase · no public half",
|
||
{ PublicKey: not null } => "no passphrase · public half stored",
|
||
_ => "no passphrase · no public half",
|
||
};
|
||
|
||
internal bool HasUnsyncedChanges => key.HasUnsyncedChanges;
|
||
|
||
internal bool IsBlocked => key.IsBlocked;
|
||
|
||
internal bool IsReadOnly => key.IsReadOnly;
|
||
|
||
internal string Badge => ItemBadge.For(key.IsBlocked, key.IsReadOnly, key.HasUnsyncedChanges);
|
||
}
|
||
|
||
/// <summary>One stored credential, as a row in the list.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The same arrangement as <see cref="SshKeyRowViewModel"/>, for the same two reasons: the decrypted secret
|
||
/// travels with the row so opening the editor or connecting needs no second decryption, and
|
||
/// <b>nothing here exposes the password to the view</b>. <see cref="Credential"/> is what the editor and the
|
||
/// connect path read; what the XAML binds is a label, a description and a badge. Not a security boundary —
|
||
/// the same object holds the password either way — but it means no template, tooltip or accessibility surface
|
||
/// can render a password by being pointed at the obvious property.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <summary>One bucket, as a row in the list.</summary>
|
||
internal sealed class ObjectStoreRowViewModel(VaultItem<ObjectStoreSecret> store)
|
||
{
|
||
internal Guid EntityId => store.EntityId;
|
||
|
||
internal ObjectStoreSecret Store => store.Secret;
|
||
|
||
internal string Label => store.Secret.Label;
|
||
|
||
/// <summary>What the list shows under the name: where it is, never the keys.</summary>
|
||
/// <remarks>
|
||
/// The bucket and the service, because those are what tell two entries apart — the same bucket name in
|
||
/// two accounts is the ordinary case. The access key id is an identifier rather than a secret and is
|
||
/// still not here: it is long, it is noise in a list, and it belongs in the detail pane.
|
||
/// </remarks>
|
||
internal string Description => store.Secret.Endpoint is { } endpoint
|
||
? $"{store.Secret.Bucket} at {endpoint}"
|
||
: $"{store.Secret.Bucket} · {store.Secret.Region}";
|
||
|
||
internal bool HasUnsyncedChanges => store.HasUnsyncedChanges;
|
||
|
||
internal bool IsBlocked => store.IsBlocked;
|
||
|
||
internal bool IsReadOnly => store.IsReadOnly;
|
||
|
||
internal string Badge => ItemBadge.For(store.IsBlocked, store.IsReadOnly, store.HasUnsyncedChanges);
|
||
}
|
||
|
||
internal sealed class CredentialRowViewModel(
|
||
VaultItem<CredentialSecret> credential,
|
||
Guid vaultId,
|
||
string vaultName)
|
||
{
|
||
internal Guid EntityId => credential.EntityId;
|
||
|
||
/// <summary>Which vault this credential lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||
internal Guid VaultId => vaultId;
|
||
|
||
/// <summary>The vault's display name.</summary>
|
||
internal string VaultName => vaultName;
|
||
|
||
internal CredentialSecret Credential => credential.Secret;
|
||
|
||
internal string Label => credential.Secret.Label;
|
||
|
||
/// <summary>What the list shows under the name: the account, never the password.</summary>
|
||
/// <remarks>
|
||
/// The username is the whole reason a credential is a separate item rather than two fields on a host, so
|
||
/// it is what the row has to show. Absent means "whatever the host says", which is a different statement
|
||
/// from a blank one and is written out rather than left as an empty line.
|
||
/// </remarks>
|
||
internal string Description => credential.Secret.Username is { } username
|
||
? username
|
||
: "uses each host's own username";
|
||
|
||
internal bool HasUnsyncedChanges => credential.HasUnsyncedChanges;
|
||
|
||
internal bool IsBlocked => credential.IsBlocked;
|
||
|
||
internal bool IsReadOnly => credential.IsReadOnly;
|
||
|
||
internal string Badge =>
|
||
ItemBadge.For(credential.IsBlocked, credential.IsReadOnly, credential.HasUnsyncedChanges);
|
||
}
|
||
|
||
/// <summary>The one-word marker a row shows for its sync state.</summary>
|
||
/// <remarks>
|
||
/// Shared by both row types rather than written twice, because the three states mean the same thing for
|
||
/// every item type and a list where one kind said "not synced" and the other "unsynced" would read as two
|
||
/// different conditions.
|
||
/// </remarks>
|
||
internal static class ItemBadge
|
||
{
|
||
internal static string For(bool isBlocked, bool isReadOnly, bool hasUnsyncedChanges) =>
|
||
(isBlocked, isReadOnly, hasUnsyncedChanges) switch
|
||
{
|
||
(true, _, _) => "rejected",
|
||
(_, true, _) => "newer version",
|
||
(_, _, true) => "not synced",
|
||
_ => string.Empty,
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// A terminal session that has just opened.
|
||
/// </summary>
|
||
/// <param name="sessionId">Identifies the session to the renderer and to the workspace.</param>
|
||
/// <param name="label">The host's name, as the vault has it.</param>
|
||
/// <param name="address">The account and endpoint actually dialled.</param>
|
||
internal sealed class TerminalSessionEventArgs(
|
||
Guid attemptId,
|
||
uint sessionId,
|
||
string label,
|
||
string address) : EventArgs
|
||
{
|
||
/// <summary>Which attempt this session came out of.</summary>
|
||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
|
||
internal Guid AttemptId { get; } = attemptId;
|
||
|
||
internal uint SessionId { get; } = sessionId;
|
||
|
||
internal string Label { get; } = label;
|
||
|
||
internal string Address { get; } = address;
|
||
}
|
||
|
||
/// <summary>A connection that has been asked for, and has not answered yet.</summary>
|
||
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
|
||
/// <param name="label">The host's name, as the vault has it.</param>
|
||
/// <param name="address">Who this will be logged in as, and where.</param>
|
||
/// <remarks>
|
||
/// The vault says a connection has started before it says whether it worked, so that the shell can put a
|
||
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
|
||
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
|
||
/// has no vault to read it from.
|
||
/// </remarks>
|
||
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs
|
||
{
|
||
/// <summary>Identifies this attempt for the whole of its life.</summary>
|
||
/// <remarks>
|
||
/// Carried by all three events, because several connections can be in flight at once now that one no
|
||
/// longer blocks the window — so "which tab is this about" cannot be answered by "the most recent one".
|
||
/// </remarks>
|
||
internal Guid AttemptId { get; } = attemptId;
|
||
|
||
internal string Label { get; } = label;
|
||
|
||
internal string Address { get; } = address;
|
||
}
|
||
|
||
/// <summary>A connection that was asked for and did not happen.</summary>
|
||
/// <param name="attemptId">The attempt that has just ended.</param>
|
||
/// <param name="reason">What to say about it, in the tab.</param>
|
||
/// <param name="isAwaitingAnAnswer">
|
||
/// Whether the connection stopped on a question rather than on a failure.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// The two kinds are genuinely different and the shell treats them differently. A refusal is a dead end and
|
||
/// the tab keeps it: connecting no longer blocks the window, so the user may well be looking at something
|
||
/// else by now, and a tab that vanished would take the only account of what went wrong with it. An unknown
|
||
/// or changed host key is not a dead end — it is a prompt drawn on the hosts screen, and the connection
|
||
/// resumes the moment it is answered — so the tab goes and the window shows the question instead.
|
||
/// </remarks>
|
||
internal sealed class ConnectionFailedEventArgs(Guid attemptId, string reason, bool isAwaitingAnAnswer)
|
||
: EventArgs
|
||
{
|
||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
||
internal Guid AttemptId { get; } = attemptId;
|
||
|
||
internal string Reason { get; } = reason;
|
||
|
||
/// <inheritdoc cref="ConnectionFailedEventArgs" path="/param[@name='isAwaitingAnAnswer']" />
|
||
internal bool IsAwaitingAnAnswer { get; } = isAwaitingAnAnswer;
|
||
}
|
||
|
||
/// <summary>A conflict, as a row.</summary>
|
||
internal sealed class ConflictRowViewModel(ConflictNotice notice)
|
||
{
|
||
internal Guid Id => notice.Id;
|
||
|
||
internal string Summary => notice.Summary;
|
||
|
||
/// <summary>
|
||
/// The overridden values, one line each.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// This is the whole justification for resolving a conflict automatically. If these were not shown,
|
||
/// the merge would be last-writer-wins with a longer explanation.
|
||
/// </remarks>
|
||
// The lambda parameter is 'entry' rather than the obvious 'field': C# 14 made that a contextual
|
||
// keyword inside a property accessor, and this whole expression is one.
|
||
internal string Detail => notice.Fields.Count == 0
|
||
? string.Empty
|
||
: string.Join(
|
||
Environment.NewLine,
|
||
notice.Fields.Select(entry => entry.DiscardedWasRemoval
|
||
? $"{entry.Field}: a removal was overridden; '{entry.Kept}' was kept"
|
||
: $"{entry.Field}: kept '{entry.Kept}', discarded '{entry.Discarded}'"));
|
||
|
||
internal bool HasDetail => notice.Fields.Count > 0;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which kind of item the vault column is showing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One at a time, chosen by a selector at the top of the column. The alternative was every kind stacked in
|
||
/// one scrolling column, which is what the column did with two of them and would not survive a third: two
|
||
/// lists and two editors already only just fit at the window's minimum height, and credentials are coming.
|
||
/// A section is also the unit the column's own layout is measured in — see
|
||
/// <c>DodoSSH.Client.App.Layout.Tests</c>, which lays out one of these at a time because that is all a user
|
||
/// can ever see at once.
|
||
/// </remarks>
|
||
internal enum VaultSection
|
||
{
|
||
/// <summary>Every kind at once, which is where the screen opens.</summary>
|
||
All,
|
||
|
||
/// <summary>The SSH keys hosts authenticate with.</summary>
|
||
Keys,
|
||
|
||
/// <summary>The usernames and passwords they authenticate with instead.</summary>
|
||
/// <remarks>
|
||
/// There was a fourth, for the host keys this user has approved. It is a screen of its own now — see
|
||
/// <c>KnownHostsScreen</c> — and the member is gone rather than left unused, because a value this
|
||
/// screen's command would still accept and no longer draw anything for is a trap with no upside.
|
||
/// </remarks>
|
||
Credentials,
|
||
|
||
/// <summary>The tags hosts wear.</summary>
|
||
/// <remarks>
|
||
/// The odd one out, and here anyway. A tag holds no secret at all — it is a name, and the reason it is
|
||
/// an item rather than a string on a host is that renaming it should be one write instead of twenty.
|
||
/// That rename needs somewhere to happen, and deleting does too, or the host editor's picker fills with
|
||
/// names nobody uses and never empties. This is where every other item kind already lives.
|
||
/// </remarks>
|
||
Tags,
|
||
|
||
/// <summary>S3-compatible buckets, and the keys that reach them.</summary>
|
||
/// <remarks>
|
||
/// A category here rather than a screen of its own, unlike the approved host keys: a bucket is something
|
||
/// somebody creates and edits and whose secret has to be kept, which is exactly what the other two
|
||
/// categories are. A pin is not.
|
||
/// </remarks>
|
||
Buckets,
|
||
}
|
||
|
||
/// <summary>What kind of thing a row in the vault table is.</summary>
|
||
/// <remarks>
|
||
/// Two, since the pins left. Both are things somebody created on purpose and can edit, which is what the
|
||
/// table's shared shape now assumes throughout.
|
||
/// </remarks>
|
||
internal enum VaultItemKind
|
||
{
|
||
/// <summary>An SSH key.</summary>
|
||
Key,
|
||
|
||
/// <summary>A stored username and password.</summary>
|
||
Credential,
|
||
|
||
/// <summary>An S3-compatible bucket.</summary>
|
||
ObjectStore,
|
||
|
||
/// <summary>A tag a host can wear.</summary>
|
||
Tag,
|
||
}
|
||
|
||
/// <summary>
|
||
/// One row of the vault table, whatever kind of item it is.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The table has one shape and a <c>TYPE</c> column, which is what lets a single category show every kind
|
||
/// at once — and that is the only reason this projection exists. It is deliberately a view of a typed row
|
||
/// rather than a replacement for one: selecting here sets the typed selection the editors and the delete
|
||
/// commands already act on, so nothing downstream had to learn about a second way of naming an item.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Nothing here is a secret.</b> <see cref="Detail"/> is what is <em>known about</em> an item — whether a
|
||
/// key has a passphrase stored, which account a password is for, what a pin's fingerprint is — never the key
|
||
/// or the password itself. The same rule the three list templates already followed, now in one place where
|
||
/// it is harder to break by pointing a template at the obvious property.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="Kind">Which of the three lists this came from.</param>
|
||
/// <param name="EntityId">The item, so a selection can be mapped back.</param>
|
||
/// <param name="Name">The label the user gave it.</param>
|
||
/// <param name="Type">The one-word kind, for the table's TYPE column.</param>
|
||
/// <param name="Detail">What is known about it.</param>
|
||
/// <param name="Badge">Its sync state, or empty.</param>
|
||
/// <param name="HasUnsyncedChanges">
|
||
/// Whether this machine has a change to this item that the server has not accepted. Carried separately from
|
||
/// the badge rather than read back out of it, because the badge is a sentence for a person and a count built
|
||
/// by comparing it against the literal "not synced" would break the day that wording improves.
|
||
/// </param>
|
||
/// <summary>One vault, as an option in the "file this into" picker.</summary>
|
||
/// <param name="VaultId">The vault.</param>
|
||
/// <param name="Name">Its display name, which is plaintext as all vault names are.</param>
|
||
/// <param name="IsPersonal">Whether this is the caller's own vault rather than a team's.</param>
|
||
internal sealed record VaultChoiceViewModel(Guid VaultId, string Name, bool IsPersonal)
|
||
{
|
||
/// <summary>
|
||
/// What the picker shows.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A shared vault is marked as one. The whole risk this picker introduces is putting a credential
|
||
/// somewhere more people can read it, so the option that does that must not look like the option
|
||
/// that does not. It says SHARED rather than TEAM because a team is no longer something the person
|
||
/// choosing has been shown — see <c>VaultsViewModel</c>.
|
||
/// </remarks>
|
||
internal string Display => IsPersonal ? Name : $"{Name} · SHARED";
|
||
}
|
||
|
||
internal sealed record VaultItemRowViewModel(
|
||
VaultItemKind Kind,
|
||
Guid EntityId,
|
||
string Name,
|
||
string Type,
|
||
string Detail,
|
||
string Badge,
|
||
bool HasUnsyncedChanges)
|
||
{
|
||
/// <summary>Whether this row has anything to say about its sync state.</summary>
|
||
internal bool HasBadge => Badge.Length > 0;
|
||
}
|
||
|
||
/// <summary>Which list a deletion that has been asked for is aimed at.</summary>
|
||
internal enum DeletionTarget
|
||
{
|
||
/// <summary>A host, from the sidebar beside the terminal.</summary>
|
||
Host,
|
||
|
||
/// <summary>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>
|
||
/// Gets this machine online, if it can be.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Supplied by the shell, which owns the connection and the remembered sign-in behind it. It is asked
|
||
/// once per synchronisation pass rather than once per vault, and that is what makes coming back from a
|
||
/// closed lid automatic: a laptop that unlocks on a train has no connection and gets one within a minute
|
||
/// of reaching a network, with nothing pressed.
|
||
/// </para>
|
||
/// <para>
|
||
/// Returns null for every reason a machine may be offline — no remembered sign-in, no network, a token
|
||
/// the provider has stopped accepting — because the vault's answer to all of them is the same: work
|
||
/// locally and queue.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal delegate Task<IVaultServer?> ServerReconnectHandler(CancellationToken cancellationToken);
|
||
|
||
/// <summary>
|
||
/// An open vault: the host list, the editor, syncing, and connecting a terminal.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The list is the local mirror with unpushed changes laid over it, so an edit appears immediately and a
|
||
/// delete disappears immediately whether or not the network is there. Nothing here waits on a server to
|
||
/// show a change.
|
||
/// </para>
|
||
/// <para>
|
||
/// Syncing then happens on its own: once when the vault opens, straight after any local change, and on a
|
||
/// timer while it stays open. The Sync button remains, because a person who has just been handed a
|
||
/// credential wants to know now rather than within the minute — but nothing depends on it being pressed.
|
||
/// A background pass is deliberately quieter than the button: see <see cref="AutoSyncAsync" />.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Being offline is a state a pass tries to leave, not one it gives up on.</b> Every pass asks the
|
||
/// shell for a connection rather than reading one it was handed at unlock — see
|
||
/// <see cref="ServerReconnectHandler" /> — so a machine that unlocked with no network comes online by
|
||
/// itself once it has one, and a sign-in survives a restart without a browser opening.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Everything a connection needs is in the vault.</b> Keys, passwords and host key trust are all synced
|
||
/// items, so each is stored once and available on every machine — approving a fingerprint here approves it on
|
||
/// every device and survives a restart. A typed password is what is left when a host is bound to nothing, and
|
||
/// that is now a choice rather than the only option this interface offered.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A key or a password belongs to a host.</b> Each host names the one thing it authenticates with, or
|
||
/// nothing, and that choice is a field in its encrypted payload — so it follows the host to every machine
|
||
/// rather than being made again per connection. The two are mutually exclusive and the editor offers them
|
||
/// through a single picker, which is what makes the illegal combination unrepresentable rather than merely
|
||
/// invalid. The cost is a payload schema version, paid only by hosts that actually bind something: see
|
||
/// <c>HostSecretCodec.CurrentSchemaVersion</c>.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="copyToClipboard">
|
||
/// Puts one line of text on the system clipboard, or null where there is none.
|
||
/// <para>
|
||
/// A delegate rather than Avalonia's <c>IClipboard</c>, for the reason <c>SignInHandler</c> is one: the
|
||
/// clipboard is reached through <c>TopLevel.GetTopLevel(control)</c>, so taking it directly would make this
|
||
/// view model need a visual — and every test that drives it need a window. Null is a machine that has no
|
||
/// clipboard rather than one that failed to copy, and the difference is worth saying out loud.
|
||
/// </para>
|
||
/// </param>
|
||
/// <param name="visibility">
|
||
/// Which vaults this machine has been asked to leave off the screens, or null where nothing is hidden.
|
||
/// <para>
|
||
/// Read by <see cref="IsVaultShown"/> and by nothing else in here, which is the whole of how this stays a
|
||
/// display filter — see that method. Null rather than a required argument because "no preference" is the
|
||
/// state every caller that does not care about this is in, including a locked launch and every test.
|
||
/// </para>
|
||
/// </param>
|
||
internal sealed partial class VaultViewModel(
|
||
VaultSession session,
|
||
TerminalWorkspace workspace,
|
||
VaultKnownHostStore knownHosts,
|
||
Func<IVaultServer?> connection,
|
||
ServerReconnectHandler? reconnect = null,
|
||
Func<string, Task>? copyToClipboard = null,
|
||
ConnectionRecorder? connectionLog = null,
|
||
VaultVisibility? visibility = null) : ObservableObject, IAsyncDisposable
|
||
{
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </remarks>
|
||
private static readonly TimeSpan AutoSyncInterval = TimeSpan.FromMinutes(1);
|
||
|
||
/// <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 vault, 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"/>.
|
||
/// </remarks>
|
||
private IReadOnlyList<VaultItem<HostGroupSecret>> groupItems = [];
|
||
|
||
/// <summary>
|
||
/// The same groups by id, which is the shape the inheritance walk takes.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Cached beside <see cref="groupItems"/> rather than built per call, because resolving is on the path
|
||
/// that draws every host row and on the path that opens every shell — and a dictionary rebuilt per host
|
||
/// would be one allocation per row per redraw.
|
||
/// </remarks>
|
||
private Dictionary<Guid, HostGroupSecret> groupsById = [];
|
||
|
||
/// <summary>
|
||
/// Every readable vault's groups, kept apart by the vault they live in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// What the host editor's group picker is built from, and it has to be per vault rather than the one
|
||
/// list <see cref="Groups"/> holds. A group is an item like any other, so it lives in exactly one
|
||
/// vault; offering the personal vault's groups while a host is being filed into a shared one would
|
||
/// produce a host whose group id nobody else in that vault can resolve — a colleague would see it
|
||
/// filed under nothing, which is the quietest kind of wrong. See <see cref="BuildGroupChoices"/>.
|
||
/// </remarks>
|
||
private Dictionary<Guid, List<GroupChoice>> groupsByVault = [];
|
||
|
||
/// <summary>
|
||
/// The tags as they came out of the vault, before the host counts are attached.
|
||
/// </summary>
|
||
/// <inheritdoc cref="groupItems" path="/remarks" />
|
||
private IReadOnlyList<VaultItem<TagSecret>> tagItems = [];
|
||
|
||
/// <summary>
|
||
/// The same tags by id, which is what a host's <see cref="HostSecret.TagIds"/> resolves through.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Every readable vault's, like <see cref="groupsById"/> and for a weaker version of the same reason.
|
||
/// A tag id that does not resolve draws no chip, so a host in a team's vault would silently look
|
||
/// untagged — which costs a label rather than a connection, but is just as confusing to look at and
|
||
/// costs nothing to avoid.
|
||
/// </remarks>
|
||
private Dictionary<Guid, TagSecret> tagsById = [];
|
||
|
||
/// <summary>The groups whose hosts are folded away, by id, with <see cref="Guid.Empty"/> for ungrouped.</summary>
|
||
private readonly HashSet<Guid> collapsedGroups = [];
|
||
|
||
private CancellationTokenSource? autoSync;
|
||
private Task? autoSyncLoop;
|
||
private bool disposed;
|
||
|
||
/// <summary>
|
||
/// The open vault this view model is showing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Exposed for the operations only an open session can perform — sealing the bundle to a new device key,
|
||
/// chiefly — which the shell drives rather than this view model. Ownership does not move: this type
|
||
/// disposes it, and a caller must not.
|
||
/// </remarks>
|
||
internal VaultSession Session => session;
|
||
|
||
/// <summary>Whether a vault's items are drawn on the screens that list them.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The one place the visibility preference is read, and it is read only by the projections a person
|
||
/// looks at</b> — <see cref="Matches"/>, <see cref="RebuildVaultItems"/>, the group and tag card counts,
|
||
/// and the pin list. Every <c>Reload*Async</c> above stays complete, and that is not tidiness:
|
||
/// </para>
|
||
/// <list type="bullet">
|
||
/// <item>
|
||
/// <see cref="Keys"/> and <see cref="Credentials"/> are what <see cref="TryBuildAuthentication"/>
|
||
/// resolves a host's binding out of, and a host in one vault may legitimately name a key filed in
|
||
/// another. Filtering the lists rather than the table would make hiding a vault break connections to
|
||
/// hosts that are still on screen.
|
||
/// </item>
|
||
/// <item>
|
||
/// <see cref="groupsById"/> decides what port a host dials. Hiding a vault must never change that.
|
||
/// </item>
|
||
/// <item>
|
||
/// The dialled-endpoint set in <see cref="ReloadKnownHostsAsync"/> decides which pins are described as
|
||
/// unused, which is a hint that invites deleting trust.
|
||
/// </item>
|
||
/// </list>
|
||
/// <para>
|
||
/// Nothing outside those projections asks. Sync walks <c>session.ReadableVaults</c>, the keyring is
|
||
/// filled from the same list, and the trust the SSH handshake consults is read straight out of
|
||
/// <c>VaultKnownHostStore</c> — none of which has ever come through this type.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal bool IsVaultShown(Guid vaultId) => visibility?.IsShown(vaultId) ?? true;
|
||
|
||
/// <summary>Whether anything at all is being kept off the screens.</summary>
|
||
/// <remarks>
|
||
/// What lets an empty grid say why it is empty rather than implying the vault is. Computed from the
|
||
/// vaults this session can read rather than from the hidden set, because a hidden vault whose grant has
|
||
/// since been withdrawn is not a reason to tell somebody to go and unhide something.
|
||
/// </remarks>
|
||
internal bool HasHiddenVaults =>
|
||
visibility is not null && session.ReadableVaults.Any(vault => visibility.IsHidden(vault.VaultId));
|
||
|
||
/// <summary>The hosts to show, unpushed local state included.</summary>
|
||
/// <remarks>
|
||
/// Every host, unfiltered. This is what the connect path resolves bindings against and what the pinned
|
||
/// host key list checks itself against, so a filter applied here would change what the application can
|
||
/// do rather than what it shows. <see cref="VisibleHosts"/> is the filtered view.
|
||
/// </remarks>
|
||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||
|
||
/// <summary>The hosts the grid is showing: one level of the group tree, narrowed by the box.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A second collection rather than a filtered view over the first, because the grid has to be one
|
||
/// <c>ListBox</c> — it owns <see cref="SelectedHost"/> and it is where the keyboard lands when the
|
||
/// terminal gives it back, and neither of those survives being split across several lists.
|
||
/// </para>
|
||
/// <para>
|
||
/// What "one level" means, and why the box escapes it, is in <see cref="Matches"/>. It is the desktop's
|
||
/// alone: the phone draws <see cref="SidebarRows"/>, which is the same hosts flattened under headings.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<HostRowViewModel> VisibleHosts { get; } = [];
|
||
|
||
/// <summary>Whether the grid has anything to draw.</summary>
|
||
/// <remarks>
|
||
/// A property rather than <c>{Binding !VisibleHosts.Count}</c> in the markup. Avalonia's <c>!</c> is a
|
||
/// boolean operator: against an <c>int</c> it produces a binding error, <c>IsVisible</c> falls back to
|
||
/// its default of true, and the empty-state sentence is shown permanently — under a grid of hosts.
|
||
/// </remarks>
|
||
internal bool HasVisibleHosts => VisibleHosts.Count > 0;
|
||
|
||
/// <summary>
|
||
/// What the hosts grid says when it has nothing in it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One answer per reason the grid can be empty, because "there are no hosts", "you have set a vault
|
||
/// aside", "this group is empty", "they are all filed away" and "nothing matches what you typed" are
|
||
/// different situations and only the first is an invitation to add something. Telling somebody with
|
||
/// thirty machines to add their first one is answering a question they did not ask.
|
||
/// <para>
|
||
/// The hidden-vault answer comes before the group and the search box, because it is the one an empty
|
||
/// grid cannot otherwise explain: a filter the user typed is still in front of them, and an open group
|
||
/// is still lit on a card, but a vault switched off in a menu two screens ago leaves nothing on screen
|
||
/// to read.
|
||
/// </para>
|
||
/// <para>
|
||
/// The fourth is what the grid holding one level of the tree cost: a keychain whose every host is filed
|
||
/// under a group shows no host cards at the outermost level, and without a sentence saying so that is
|
||
/// indistinguishable from a keychain that has lost them. See <see cref="Matches"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string NoVisibleHostsMessage =>
|
||
(Hosts.Count, HasHiddenVaults, GroupFilter, HostFilter.Trim().Length) switch
|
||
{
|
||
(0, _, _, _) =>
|
||
"No hosts yet. Press + NEW HOST to add one, or import the machines already in this "
|
||
+ "computer's ~/.ssh/config from Preferences.",
|
||
(_, true, null, 0) =>
|
||
"Every host here is in a vault you have switched off. Press the ⌄ beside Vaults in the tab "
|
||
+ "strip to switch one back on.",
|
||
(_, _, not null, 0) =>
|
||
"Nothing is filed under this group yet. Press ALL HOSTS above, then drag a host card onto "
|
||
+ "this group's card — or choose the group in a host's own editor.",
|
||
(_, _, null, 0) =>
|
||
"Every host here is filed under a group. Double-press one of the cards above to open it, or "
|
||
+ "type in the box at the top to search all of them at once.",
|
||
(_, _, not null, _) =>
|
||
"No host in this group, or in anything under it, matches that. Press ALL HOSTS above to "
|
||
+ "search every machine.",
|
||
_ => "No host matches that. The name, the address and the notes are all searched.",
|
||
};
|
||
|
||
/// <summary>
|
||
/// What the sidebar's list actually holds: the visible hosts, with group headings between them.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>A vault with no groups produces no headings at all</b>, so this is <see cref="VisibleHosts"/> in
|
||
/// the same order, and the sidebar looks exactly as it did before groups existed. The feature is
|
||
/// invisible until it is used, which is the point: somebody with eleven machines and no wish to file them
|
||
/// should not be shown a heading saying so.
|
||
/// </para>
|
||
/// <para>
|
||
/// Kept beside <see cref="VisibleHosts"/> rather than replacing it. The count on the section heading, the
|
||
/// filter's own arithmetic and every test that asks what the sidebar is showing all mean hosts — a
|
||
/// collection whose <c>Count</c> silently included headings would be wrong in each of them.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<ISidebarRow> SidebarRows { get; } = [];
|
||
|
||
/// <summary>The groups in this vault, with the number of hosts filed under each.</summary>
|
||
/// <remarks>
|
||
/// Every one of them, flat. This is what a group is looked up in and what the phone's headings are built
|
||
/// from; <see cref="VisibleGroups"/> is the desktop's one level of it.
|
||
/// </remarks>
|
||
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
||
|
||
/// <summary>
|
||
/// The group cards the desktop is drawing: what is inside the group that is open, or the outermost
|
||
/// groups when none is.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <see cref="Groups"/> is to this what <see cref="Hosts"/> is to <see cref="VisibleHosts"/>: the whole
|
||
/// collection beside the part of it on screen. The grid used to draw every group at once, which was the
|
||
/// only honest thing to do while pressing a card meant nothing but "narrow the list" — a card was a
|
||
/// filter, and every filter has to be reachable. Opening one is navigation, so the cards became the
|
||
/// contents of wherever the trail says you are.
|
||
/// </para>
|
||
/// <para>
|
||
/// A group whose parent this vault has not got is drawn at the outermost level rather than nowhere, and
|
||
/// so is one caught in a parent cycle. Both are states two offline edits can produce and neither can be
|
||
/// repaired from a screen that will not draw the group — see <see cref="HostGroupSecret.ParentId"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<HostGroupRowViewModel> VisibleGroups { get; } = [];
|
||
|
||
/// <summary>
|
||
/// The path down to the group that is open: every host, then each group above it, then it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Always at least one crumb, and the first one is the way back to every host — which is what it is for.
|
||
/// A grid whose cards are one level of a tree needs somewhere to say which level, and the same control
|
||
/// is the way out of it; without that the only way back would be a button that says so, which is what
|
||
/// SHOW ALL was and what this replaces.
|
||
/// </remarks>
|
||
internal ObservableCollection<GroupCrumbViewModel> GroupTrail { get; } = [];
|
||
|
||
/// <summary>Whether there are any group cards to draw at this level.</summary>
|
||
/// <remarks>
|
||
/// Separate from <see cref="HasGroups"/>, which is about the vault. A group with nothing inside it is an
|
||
/// ordinary thing to open, and the trail and the group's own buttons have to stay on screen when it is —
|
||
/// so it is the card grid alone that folds away, not the panel around it.
|
||
/// </remarks>
|
||
internal bool HasVisibleGroups => VisibleGroups.Count > 0;
|
||
|
||
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
|
||
/// <remarks>
|
||
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
|
||
/// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it
|
||
/// to be forgotten. <c>SnippetsViewModel</c> is the filter and the editor over the top.
|
||
/// </remarks>
|
||
internal ObservableCollection<SnippetRowViewModel> Snippets { get; } = [];
|
||
|
||
/// <summary>
|
||
/// What the sidebar's one group heading says.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
|
||
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
|
||
/// presented as structure.
|
||
/// <para>
|
||
/// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
|
||
/// several, and then the heading stops naming one of them and each row names its own — a heading that
|
||
/// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
|
||
/// interface is otherwise careful about.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string HostsHeading =>
|
||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||
|
||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||
|
||
/// <summary>The stored credentials to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
||
|
||
/// <summary>The buckets to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
||
|
||
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
||
/// <remarks>
|
||
/// Counted like a group's rows are, and read after the hosts for the same reason: the count is a
|
||
/// property of the host list rather than of the tag.
|
||
/// </remarks>
|
||
internal ObservableCollection<TagRowViewModel> Tags { get; } = [];
|
||
|
||
/// <summary>The host keys this user has approved.</summary>
|
||
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
|
||
|
||
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
|
||
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
|
||
|
||
internal string VaultName =>
|
||
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
|
||
|
||
/// <summary>
|
||
/// The vaults a new item may be filed into: readable, and writable by this account.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
|
||
/// it can read but not write is a team vault this member is a viewer of — offering either would end
|
||
/// in a Save that fails, one of them locally and one at the server.
|
||
/// </remarks>
|
||
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
|
||
|
||
/// <summary>
|
||
/// Where the next new item goes.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
|
||
/// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
|
||
/// visible to people who should not have it, and moving it afterwards means deleting and retyping.
|
||
/// </remarks>
|
||
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
|
||
|
||
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||
/// <remarks>
|
||
/// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
|
||
/// a question with no answer, and for most people this stays at one for ever.
|
||
/// </remarks>
|
||
internal bool HasVaultChoice => TargetVaults.Count > 1;
|
||
|
||
[ObservableProperty]
|
||
private VaultChoiceViewModel? selectedTargetVault;
|
||
|
||
[ObservableProperty]
|
||
private HostRowViewModel? selectedHost;
|
||
|
||
/// <summary>
|
||
/// What the sidebar's <c>ListBox</c> has selected, which may be a heading.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The list holds two kinds of row and only one of them is a host, so the control's selection and the
|
||
/// application's selection are no longer the same thing. This is the control's; <see cref="SelectedHost"/>
|
||
/// stays the application's, and everything that acts on a host — connecting, editing, deleting — goes on
|
||
/// reading that one.
|
||
/// </para>
|
||
/// <para>
|
||
/// A heading click is bounced back to whatever was selected before it rather than being left highlighted
|
||
/// or clearing the selection. Clearing would mean the buttons at the foot of the sidebar quietly stopped
|
||
/// working because somebody folded a group away; leaving it highlighted would mean a selected row that
|
||
/// none of those buttons act on.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private ISidebarRow? selectedSidebarRow;
|
||
|
||
/// <summary>
|
||
/// The group card that is selected, or null when none is.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One click, and nothing more than a highlight: it is what the group's own EDIT and DELETE act on. What
|
||
/// it deliberately no longer does is narrow the grid — see <see cref="OpenGroup"/>.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private HostGroupRowViewModel? selectedGroup;
|
||
|
||
/// <summary>
|
||
/// The group that is open: the one whose contents the screen is showing, or null for every host.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Set by <see cref="OpenGroup"/> and by nothing else. It decides three things at once — which hosts the
|
||
/// grid holds, which groups the cards hold, and what the trail says — which is what makes it "where you
|
||
/// are" rather than a filter that happens to be on.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Separate from <see cref="SelectedGroup"/>, and no longer sets it.</b> The two answer different
|
||
/// questions — "what is on screen" and "what would EDIT and DELETE act on" — and while one click meant
|
||
/// both there was no way to name a group without also narrowing the grid to it. Two gestures, two
|
||
/// properties; <see cref="GroupTarget"/> is where the two meet.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private HostGroupRowViewModel? groupFilter;
|
||
|
||
/// <summary>
|
||
/// Opens a group, or every host when handed null.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A double-click on a card, or a press on a crumb of the trail. Deliberately not a single click, which
|
||
/// is what it was: a card is the only place a group can be selected, and a gesture that both selected a
|
||
/// group and threw the rest of the grid away left no way to rename one without first losing sight of
|
||
/// everything else. Double-clicking to go inside something is what the transfers screen's directories do
|
||
/// and what the host cards beneath these do to open a shell, so the grid now has one vocabulary rather
|
||
/// than one per list.
|
||
/// </para>
|
||
/// <para>
|
||
/// The selection is dropped first, and it has to be: the cards are about to be redrawn one level along,
|
||
/// and a selection pointing at a card that is no longer on screen would aim EDIT and DELETE at something
|
||
/// nobody can see. Null is a real argument here rather than a missing one — it is the trail's first
|
||
/// crumb, and it is the way back out.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void OpenGroup(HostGroupRowViewModel? group)
|
||
{
|
||
SelectedGroup = null;
|
||
GroupFilter = group;
|
||
}
|
||
|
||
/// <summary>What the group name box holds, for both creating and renaming.</summary>
|
||
[ObservableProperty]
|
||
private string groupEditorLabel = string.Empty;
|
||
|
||
/// <summary>
|
||
/// The port hosts in this group take when they state none, empty for no default.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Empty is a real answer here rather than a missing one: a group that states no port lends none, and
|
||
/// the hosts beneath it walk further up. Distinguishing that from 22 is the difference between "these
|
||
/// machines are on 22" and "these machines have not been told".
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private int? groupEditorDefaultPort;
|
||
|
||
/// <summary>The user hosts in this group log in as when they state none.</summary>
|
||
/// <inheritdoc cref="GroupEditorDefaultPort" path="/remarks" />
|
||
[ObservableProperty]
|
||
private string groupEditorDefaultUsername = string.Empty;
|
||
|
||
/// <summary>
|
||
/// What the group's parent picker offers: "no parent", then every group that may legally be one.
|
||
/// </summary>
|
||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||
internal ObservableCollection<GroupChoice> GroupEditorParentChoices { get; } = [];
|
||
|
||
[ObservableProperty]
|
||
private GroupChoice? groupEditorSelectedParent;
|
||
|
||
/// <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]
|
||
private Guid? editingGroupId;
|
||
|
||
[ObservableProperty]
|
||
private SshKeyRowViewModel? selectedKey;
|
||
|
||
[ObservableProperty]
|
||
private CredentialRowViewModel? selectedCredential;
|
||
|
||
[ObservableProperty]
|
||
private ObjectStoreRowViewModel? selectedObjectStore;
|
||
|
||
[ObservableProperty]
|
||
private TagRowViewModel? selectedTag;
|
||
|
||
// ---- The tag editor ----
|
||
// A fifth set, and the smallest by a long way: a tag is a name. It is still its own pair rather than
|
||
// sharing the group editor's box, for the reason the other four are separate — a half-typed tag
|
||
// appearing inside a group's name field is the kind of thing that only shows up in a bug report.
|
||
|
||
[ObservableProperty]
|
||
private bool isEditingTag;
|
||
|
||
[ObservableProperty]
|
||
private string tagEditorLabel = string.Empty;
|
||
|
||
/// <summary>The tag being edited, or null when creating.</summary>
|
||
private Guid? editingTagId;
|
||
|
||
/// <summary>Which vault the tag editor will write to.</summary>
|
||
/// <inheritdoc cref="editingHostVaultId" path="/remarks" />
|
||
private Guid editingTagVaultId;
|
||
|
||
[ObservableProperty]
|
||
private KnownHostRowViewModel? selectedKnownHost;
|
||
|
||
/// <summary>
|
||
/// What the sidebar's filter box holds.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Matched against the label, the address and the notes, case-insensitively, because those are the three
|
||
/// things a person remembers a machine by. It narrows <see cref="HostGroups"/> only: the selection, the
|
||
/// connect path and everything else read <see cref="Hosts"/>, so filtering can never make a host
|
||
/// unusable — only unlisted.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string hostFilter = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string status = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private int pendingChanges;
|
||
|
||
/// <summary>
|
||
/// Whether the last synchronisation attempt failed to reach the server.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Holding a connection object is not the same as being able to reach anything, and this is the
|
||
/// difference. <c>IVaultServer</c> is obtained once at sign-in and never dropped, so a laptop whose lid
|
||
/// has been shut all afternoon still has one — and the background pass swallows its socket errors on
|
||
/// purpose, which means nothing else would ever notice.
|
||
/// </para>
|
||
/// <para>
|
||
/// It exists because the titlebar makes a claim now. A green dot saying SYNCED over a machine that has
|
||
/// not reached the server since lunch is exactly the sort of thing this project writes down instead of
|
||
/// implying — and an empty outbox does not rule it out, because an empty outbox on an unreachable
|
||
/// machine is the ordinary state of a laptop nobody has changed anything on.
|
||
/// </para>
|
||
/// <para>
|
||
/// False until proven otherwise rather than the reverse. The auto-sync loop runs a pass the moment the
|
||
/// vault opens, so the honest answer arrives within a moment of unlocking, and starting pessimistic
|
||
/// would flash UNREACHABLE at every launch by somebody who is not.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private bool lastSyncFailed;
|
||
|
||
[ObservableProperty]
|
||
private int unreadableItems;
|
||
|
||
[ObservableProperty]
|
||
private bool isBusy;
|
||
|
||
// ---- Which kind of item is showing ----
|
||
|
||
/// <remarks>
|
||
/// Settable, and the markup deliberately does not bind a selector's selection to it. A selection binding
|
||
/// would move before <see cref="ShowSection" /> could refuse, leaving a selector highlighting a section
|
||
/// the column is not showing; two plain buttons and a command carry no state of their own and cannot
|
||
/// disagree with this. Tests set it directly, which is the same thing the command does once it has
|
||
/// decided.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private VaultSection section;
|
||
|
||
/// <summary>Whether every kind is showing at once.</summary>
|
||
internal bool ShowsAll => Section is VaultSection.All;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool ShowsKeys => Section is VaultSection.Keys;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool ShowsCredentials => Section is VaultSection.Credentials;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool ShowsBuckets => Section is VaultSection.Buckets;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool ShowsTags => Section is VaultSection.Tags;
|
||
|
||
/// <summary>
|
||
/// The rows the vault table is showing, for whichever category is selected.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Rebuilt whenever the category changes or the lists reload, from the typed lists rather than from
|
||
/// storage — so it costs one pass over what is already decrypted in memory and can never disagree with
|
||
/// the lists the editors act on.
|
||
/// </remarks>
|
||
internal ObservableCollection<VaultItemRowViewModel> VaultItems { get; } = [];
|
||
|
||
/// <summary>
|
||
/// The selected row of the vault table.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Setting it sets the matching typed selection, which is what every editor and every delete command
|
||
/// reads. The typed selections stay the state; this is the way the table names one of them.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private VaultItemRowViewModel? selectedVaultItem;
|
||
|
||
/// <summary>What the vault screen's header calls the category showing.</summary>
|
||
internal string SectionTitle => Section switch
|
||
{
|
||
VaultSection.Keys => "SSH KEYS",
|
||
VaultSection.Credentials => "PASSWORDS",
|
||
VaultSection.Buckets => "BUCKETS",
|
||
VaultSection.Tags => "TAGS",
|
||
_ => "ALL ITEMS",
|
||
};
|
||
|
||
/// <summary>
|
||
/// What the header says about the category, under its name.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The design says "18 items · 12 shared". Sharing does not exist — every item in this vault is this
|
||
/// user's — so the second half is what this build can actually count instead: how many of the ones on
|
||
/// screen this machine has not managed to push yet.
|
||
/// <para>
|
||
/// Counted over the rows showing rather than over the whole outbox, which is what this said first and
|
||
/// was wrong in a way a screenshot made obvious: "7 items · 12 not synced" under a list of seven reads
|
||
/// as twelve of those seven. The outbox counts hosts too, and hosts are a different screen.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string SectionSummary
|
||
{
|
||
get
|
||
{
|
||
var items = VaultItems.Count == 1 ? "1 item" : $"{VaultItems.Count} items";
|
||
var waiting = VaultItems.Count(row => row.HasUnsyncedChanges);
|
||
|
||
return waiting == 0 ? items : $"{items} · {waiting} not synced";
|
||
}
|
||
}
|
||
|
||
/// <summary>Everything on this screen, which is the keychain less the hosts and the pins.</summary>
|
||
/// <remarks>
|
||
/// Both of those have screens of their own now. Counting a pin here would put a number on the ALL
|
||
/// category that the ALL category does not list.
|
||
/// </remarks>
|
||
/// <summary>What the ALL chip counts, which is everything ALL actually shows.</summary>
|
||
/// <remarks>
|
||
/// Buckets were already missing from this before tags were, so the number under a chip labelled ALL has
|
||
/// been smaller than the list it opens for as long as there have been four kinds. Fixed here rather
|
||
/// than left consistent: a count that disagrees with the rows beneath it is worse than no count, and
|
||
/// adding a fifth kind to the same expression would have widened the gap rather than caused it.
|
||
/// </remarks>
|
||
internal int TotalItemCount =>
|
||
Keys.Count + Credentials.Count + ObjectStores.Count + Tags.Count;
|
||
|
||
internal bool HasVaultItems => VaultItems.Count > 0;
|
||
|
||
internal bool HasSelectedVaultItem => SelectedVaultItem is not null;
|
||
|
||
/// <summary>Whether the selected row is one with an editor behind it.</summary>
|
||
internal bool SelectedItemIsEditable => SelectedVaultItem?.Kind is
|
||
VaultItemKind.Key or VaultItemKind.Credential or VaultItemKind.ObjectStore or VaultItemKind.Tag;
|
||
|
||
/// <summary>Whether the selected row is an SSH key, which is the only kind with a public half to copy.</summary>
|
||
internal bool SelectedItemIsKey => SelectedVaultItem?.Kind is VaultItemKind.Key;
|
||
|
||
/// <summary>What the detail pane calls the block under the chips.</summary>
|
||
internal string SelectedDetailHeading => SelectedVaultItem?.Kind switch
|
||
{
|
||
VaultItemKind.Credential => "ACCOUNT",
|
||
_ => "WHAT IS STORED",
|
||
};
|
||
|
||
internal bool HasUnreadableItems => UnreadableItems > 0;
|
||
|
||
/// <remarks>
|
||
/// A sentence rather than a number, because the number alone reads as a count of something you have
|
||
/// rather than of something you cannot open — and what to do about it is not guessable.
|
||
/// </remarks>
|
||
internal string UnreadableSummary => UnreadableItems == 1
|
||
? "1 item will not decrypt"
|
||
: $"{UnreadableItems} items will not decrypt";
|
||
|
||
/// <summary>What an empty category says instead of showing an empty grid.</summary>
|
||
internal string EmptySectionMessage => Section switch
|
||
{
|
||
VaultSection.Keys =>
|
||
"No SSH keys yet. Paste one in and bind a host to it, and that host stops asking for a password.",
|
||
VaultSection.Credentials =>
|
||
"No stored passwords yet. Add one to stop typing the same password into every connection.",
|
||
VaultSection.Buckets =>
|
||
"No buckets yet. Add one to browse S3-compatible storage beside a host on the Files screen.",
|
||
VaultSection.Tags =>
|
||
"No tags yet. Add one here, or from a host's editor, and it becomes a chip you can put on "
|
||
+ "twenty machines and rename once.",
|
||
_ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
|
||
};
|
||
|
||
// ---- The editor ----
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(AnEditorIsOpen))]
|
||
[NotifyPropertyChangedFor(nameof(ShowsConnectBar))]
|
||
[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(ShowsConnectBar))]
|
||
[NotifyPropertyChangedFor(nameof(IsDrawerOpen))]
|
||
[NotifyPropertyChangedFor(nameof(IsShowingHostDetail))]
|
||
[NotifyPropertyChangedFor(nameof(ShowsHostPaneActions))]
|
||
[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>
|
||
/// 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>
|
||
/// The vault's name and not a picker for it, although the design draws one with a chevron. A host can
|
||
/// be moved between vaults now — see <see cref="MoveHost"/> — and it is still not a field: the move is
|
||
/// a re-seal under another key and a tombstone under this one, so binding it to a control that saves
|
||
/// with the rest of the form would let somebody correcting a port move a machine by leaving a picker
|
||
/// where they found it. It has its own panel and its own button. Where a *new* item goes is chosen on
|
||
/// the keychain screen's own picker; see <see cref="TargetVaults"/>.
|
||
/// </remarks>
|
||
internal string DrawerSubtitle => (IsEditing, IsEditingGroup) switch
|
||
{
|
||
(_, 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(ShowsConnectBar))]
|
||
private bool isAddSheetOpen;
|
||
|
||
/// <summary>
|
||
/// Whether anything the host screen can put over its list is showing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One property rather than three tests at each call site, and it exists because two controls need
|
||
/// exactly this question and would otherwise each answer it their own way: the floating <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 || IsEditing || IsEditingGroup;
|
||
|
||
/// <summary>
|
||
/// Whether the phone's connect bar has anything to be about.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A host is chosen and nothing is covering the list. Both halves are needed and the second is the one
|
||
/// worth stating: the editor cards replace the list rather than floating over it, so a bar left showing
|
||
/// underneath would carry CONNECT and EDIT for a host that is no longer on screen — and under the host
|
||
/// editor, for the very record being typed into.
|
||
/// </remarks>
|
||
internal bool ShowsConnectBar => SelectedHost is not null && !AnEditorIsOpen;
|
||
|
||
[ObservableProperty]
|
||
private string editorLabel = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string editorHostname = string.Empty;
|
||
|
||
/// <summary>
|
||
/// The port box, empty when the host is to take its group's.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Nullable rather than defaulting to 22, which is the difference between a form that states a port and
|
||
/// one that leaves it to the group. An empty box shows <see cref="EditorPortPlaceholder"/>, so the form
|
||
/// says what leaving it blank will get you rather than making the user guess — and a new host under a
|
||
/// group that says 2222 is created wanting 2222, without anybody typing it.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
|
||
private int? editorPort;
|
||
|
||
[ObservableProperty]
|
||
private string editorUsername = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string editorNotes = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private bool editorRelayEnabled;
|
||
|
||
/// <summary>
|
||
/// What the authentication picker offers: a typed password, then every key, then every credential.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Rebuilt when the editor opens rather than kept in step with the two lists. A background sync could pull
|
||
/// a new key while a host is being edited, and having the picker's contents change under the user mid-edit
|
||
/// is worse than the list being a minute stale — only one editor may be open at a time, so the only way to
|
||
/// add a key or a credential is to close this one anyway.
|
||
/// </remarks>
|
||
internal ObservableCollection<AuthenticationChoice> EditorAuthenticationChoices { get; } = [];
|
||
|
||
[ObservableProperty]
|
||
private AuthenticationChoice? editorSelectedAuthentication;
|
||
|
||
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
|
||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
||
|
||
/// <summary>
|
||
/// Which vault a host being created will be filed into.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The picker in the host editor itself, and it is a second one rather than the keychain screen's
|
||
/// <see cref="TargetVaults"/> reused: that one is a standing preference about where new items go and
|
||
/// this is a field of the host in front of you. Binding both to one selection would mean the box under
|
||
/// SSH KEYS moved every time somebody put a host somewhere, and — the other way round — that a host
|
||
/// half-typed on this screen could be moved by a click on that one, which is the bug
|
||
/// <see cref="editingHostVaultId"/> was introduced to prevent.
|
||
/// </para>
|
||
/// <para>
|
||
/// Filled from the same source, so what it offers is what the keychain screen offers: vaults this
|
||
/// session can both read and write.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ObservableCollection<VaultChoiceViewModel> EditorVaultChoices { get; } = [];
|
||
|
||
[ObservableProperty]
|
||
private VaultChoiceViewModel? editorSelectedVault;
|
||
|
||
/// <summary>
|
||
/// Whether the editor should be asking which vault this host goes into.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Only while creating, and only where there is more than one vault to choose between. An existing
|
||
/// host's vault is not a field of this form and the picker is not shown disabled beside it: the two
|
||
/// are encrypted under different keys, so moving one is a re-seal and a tombstone rather than a save.
|
||
/// That is offered — by <see cref="MoveHost"/>, from the pane's own menu — and it is a separate act
|
||
/// precisely because it must not happen as a side effect of saving something else.
|
||
/// </para>
|
||
/// <para>
|
||
/// Hidden at one vault rather than shown with a single option, which is the rule
|
||
/// <see cref="HasVaultChoice"/> already applies for the same reason: a control offering one answer is
|
||
/// a question nobody was asked.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal bool ShowsEditorVaultChoice => editingEntityId is null && EditorVaultChoices.Count > 1;
|
||
|
||
[ObservableProperty]
|
||
[NotifyPropertyChangedFor(nameof(EditorPortPlaceholder))]
|
||
[NotifyPropertyChangedFor(nameof(EditorUsernamePlaceholder))]
|
||
private GroupChoice? editorSelectedGroup;
|
||
|
||
/// <summary>
|
||
/// Keeps the authentication picker in step with whether there is a group to inherit from.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Filing a host into a group must not pin it to a typed password, and without this it did.</b> An
|
||
/// ungrouped host is offered no "Inherit from group" entry — with nothing above it the two entries would
|
||
/// behave identically — so its picker sits on "Password (ask each time)". Choose a group and save, and
|
||
/// that selection would be written as <see cref="HostSecret.AsksForPassword"/>: the host would be pinned
|
||
/// to a prompt it never asked for, by a user who was only filing it, and the group's key would never
|
||
/// reach it.
|
||
/// </para>
|
||
/// <para>
|
||
/// The selection moves to "Inherit from group" rather than staying, because for an ungrouped host the
|
||
/// two are the same thing and the stored value was "nothing stated". Reading a deliberate refusal into
|
||
/// a choice the user could not have made differently would be inventing an intent; inheriting is what
|
||
/// the record already said.
|
||
/// </para>
|
||
/// </remarks>
|
||
partial void OnEditorSelectedGroupChanged(GroupChoice? value)
|
||
{
|
||
var grouped = value?.EntityId is not null;
|
||
|
||
if (grouped == EditorAuthenticationChoices.Contains(AuthenticationChoice.Inherited))
|
||
{
|
||
return;
|
||
}
|
||
|
||
var selected = EditorSelectedAuthentication;
|
||
|
||
BuildAuthenticationChoices(
|
||
Bound(AuthenticationKind.SshKey),
|
||
Bound(AuthenticationKind.Credential),
|
||
asksForPassword: !grouped && selected?.Kind == AuthenticationKind.Typed,
|
||
grouped);
|
||
}
|
||
|
||
/// <summary>What an empty port box will dial.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Recomputed when the group picker moves, which is the whole point of it being a placeholder rather
|
||
/// than a pre-filled value: filing a host into a group is supposed to visibly change what leaving the
|
||
/// box empty means. A pre-filled 2222 would have been indistinguishable from a port the user typed, and
|
||
/// saving it would have pinned it.
|
||
/// </para>
|
||
/// <para>
|
||
/// Reads the group chain from the picker's current selection rather than from the host being edited,
|
||
/// because the two differ for exactly as long as the editor is open and unsaved — which is when this is
|
||
/// read.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string EditorPortPlaceholder =>
|
||
InheritedFromEditorGroup(group => group.DefaultPort?.ToString(CultureInfo.InvariantCulture))
|
||
?? HostSecret.DefaultPort.ToString(CultureInfo.InvariantCulture);
|
||
|
||
/// <summary>What an empty username box will log in as, or a prompt when nothing supplies one.</summary>
|
||
/// <inheritdoc cref="EditorPortPlaceholder" path="/remarks" />
|
||
internal string EditorUsernamePlaceholder =>
|
||
InheritedFromEditorGroup(group => group.DefaultUsername) ?? "username";
|
||
|
||
/// <remarks>
|
||
/// Walks through <see cref="HostInheritance.Chain"/> rather than looking only at the selected group, so
|
||
/// a placeholder shows the value that will actually be used — which may come from three levels up — and
|
||
/// so that a cycle assembled elsewhere cannot hang the editor either.
|
||
/// </remarks>
|
||
private string? InheritedFromEditorGroup(Func<HostGroupSecret, string?> read) =>
|
||
HostInheritance
|
||
.Chain(EditorSelectedGroup?.EntityId, groupsById)
|
||
.Select(entry => read(entry.Group))
|
||
.FirstOrDefault(value => value is not null);
|
||
|
||
/// <summary>
|
||
/// The tags the host being edited wears, as the picker leaves them.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The authority while an editor is open, not <see cref="EditorTagChoices"/>: the chips are a
|
||
/// projection of this and are rebuilt from it, so a tag the vault no longer offers — deleted on another
|
||
/// machine between the editor opening and Save — is still carried through rather than dropped by an
|
||
/// edit that was about something else.
|
||
/// </remarks>
|
||
private TagSet editorTagIds = TagSet.Empty;
|
||
|
||
/// <summary>
|
||
/// Every tag in the vault, as a chip, with whether the host being edited wears it.
|
||
/// </summary>
|
||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||
internal ObservableCollection<TagChoice> EditorTagChoices { get; } = [];
|
||
|
||
/// <summary>Whether there is any tag to offer, which is what draws the picker at all.</summary>
|
||
/// <remarks>
|
||
/// A vault with no tags shows the new-tag box and nothing else. An empty row of chips with a heading
|
||
/// over it would be a control that looks broken rather than one that has nothing to say.
|
||
/// </remarks>
|
||
internal bool HasTagChoices => EditorTagChoices.Count > 0;
|
||
|
||
/// <summary>The name in the editor's "new tag" box.</summary>
|
||
[ObservableProperty]
|
||
private string editorNewTag = string.Empty;
|
||
|
||
/// <summary>
|
||
/// Puts a tag on the host being edited, or takes it off.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Applies to the editor's own set rather than saving anything. The host is written by
|
||
/// <see cref="BuildHost"/> like every other field, so cancelling an edit drops the tagging with the
|
||
/// rest of it — which is what a user who pressed CANCEL asked for.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ToggleEditorTag(TagChoice? choice)
|
||
{
|
||
if (choice is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
editorTagIds = choice.IsWorn
|
||
? TagSet.Create(editorTagIds.Where(id => id != choice.EntityId))
|
||
: TagSet.Create([.. editorTagIds, choice.EntityId]);
|
||
|
||
BuildTagChoices();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Creates a tag from the editor's box and puts it on the host being edited.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The moment a tag is wanted is the moment somebody is tagging a host and finds it does not exist yet,
|
||
/// so this is where creating one belongs. The keychain screen has the list for renaming and deleting;
|
||
/// making a user go there first, come back, and find their half-typed host gone would be the wrong way
|
||
/// round.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>It writes to the vault immediately, unlike everything else in this editor.</b> A tag is a shared
|
||
/// item with an id, and a host can only name an id that exists — so there is nothing to defer. The
|
||
/// consequence is honest rather than hidden: cancelling the host edit leaves the tag behind, because
|
||
/// the tag was never part of the host.
|
||
/// </para>
|
||
/// <para>
|
||
/// A name that already exists is offered rather than duplicated. Two tags called "staging" are storable
|
||
/// — see <see cref="TagSecret.TryValidate"/> for why refusing the second would be worse — but creating
|
||
/// one by hand, from a box, beside a chip of the same name is a slip rather than an intention.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task AddEditorTagAsync(CancellationToken cancellationToken)
|
||
{
|
||
var label = EditorNewTag.Trim();
|
||
|
||
if (label.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// Ordinal-ignore-case rather than the current culture's. A tag name is a filter token people type
|
||
// and re-type — "PCI" and "pci" are one tag by anybody's reading — and culture-aware casing would
|
||
// make whether they are the same depend on the phone's locale, which is not a property of the
|
||
// keychain the two machines share.
|
||
if (Tags.FirstOrDefault(
|
||
row => string.Equals(row.Label, label, StringComparison.OrdinalIgnoreCase)) is { } existing)
|
||
{
|
||
editorTagIds = TagSet.Create([.. editorTagIds, existing.EntityId]);
|
||
EditorNewTag = string.Empty;
|
||
BuildTagChoices();
|
||
Status = $"'{existing.Label}' is already in this keychain, so it was used rather than repeated.";
|
||
return;
|
||
}
|
||
|
||
var tag = new TagSecret { Label = label };
|
||
|
||
if (!tag.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
// The active vault, not the host's. The picker lists the active vault's tags — exactly as
|
||
// the group picker lists the active vault's groups — so creating one anywhere else would
|
||
// put a chip on the host that the editor beside it could not show. A host in a team's vault
|
||
// can therefore end up naming a personal tag, which is the same cross-vault reference a
|
||
// group already allows and is recorded with it in docs/design-import-gaps.md.
|
||
var entityId = await session.Tags
|
||
.CreateAsync(session.ActiveVaultId, tag, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
editorTagIds = TagSet.Create([.. editorTagIds, entityId]);
|
||
EditorNewTag = string.Empty;
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
BuildTagChoices();
|
||
|
||
Status = $"Added the tag '{tag.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Refills the editor's chips from the vault's tags and the set being edited.</summary>
|
||
/// <remarks>
|
||
/// Rebuilt wholesale rather than mutated, because <see cref="TagChoice"/> is a record and its
|
||
/// <c>IsWorn</c> is part of its value. One toggle therefore replaces the collection, which for a list of
|
||
/// chips is cheaper than raising change notification on each of them.
|
||
/// </remarks>
|
||
private void BuildTagChoices()
|
||
{
|
||
EditorTagChoices.Clear();
|
||
|
||
foreach (var tag in Tags)
|
||
{
|
||
EditorTagChoices.Add(
|
||
new TagChoice(tag.EntityId, tag.Label, editorTagIds.Contains(tag.EntityId)));
|
||
}
|
||
|
||
OnPropertyChanged(nameof(HasTagChoices));
|
||
}
|
||
|
||
/// <summary>The item being edited, or null when creating.</summary>
|
||
private Guid? editingEntityId;
|
||
|
||
/// <summary>
|
||
/// Which vault the editor will write to.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Captured when the editor opens rather than read at save time, and there are two different reasons
|
||
/// for that depending on which way the editor was opened. Editing an existing item, it is the vault
|
||
/// that item came from — saving to anywhere else would fork it. Creating one, it is whatever the
|
||
/// target picker said <em>at that moment</em>, so changing the picker afterwards cannot silently move
|
||
/// a half-typed host into a team's vault.
|
||
/// </remarks>
|
||
private Guid editingHostVaultId;
|
||
|
||
/// <summary>Which vault the key editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
||
private Guid editingKeyVaultId;
|
||
|
||
/// <summary>Which vault the credential editor will write to. See <see cref="editingHostVaultId"/>.</summary>
|
||
private Guid editingCredentialVaultId;
|
||
|
||
/// <summary>
|
||
/// Whether the editor is showing a host that could have a pinned key to forget.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Read by the editor to hide the button while a host is being created, where there is nothing to
|
||
/// withdraw yet. It does not claim a pin exists — answering that would mean a second question to the
|
||
/// known-host store for a button's visibility, and the command already says plainly when there was
|
||
/// nothing to forget.
|
||
/// </remarks>
|
||
internal bool CanForgetHostKey => IsEditing && editingEntityId is not null;
|
||
|
||
// ---- The key editor ----
|
||
// A second set of editor state rather than a shared one. The two editors hold unrelated fields, and
|
||
// sharing them would mean a half-typed host reappearing inside a key editor.
|
||
|
||
[ObservableProperty]
|
||
private bool isEditingKey;
|
||
|
||
[ObservableProperty]
|
||
private string keyEditorLabel = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Bound to a text box the user pastes a private key into, so this holds key material for as long as
|
||
/// the editor is open, and <see cref="CancelKeyEdit" /> clears it. Neither that nor anything else here
|
||
/// can wipe it — see <c>SshKeySecret</c>, which explains why a .NET string is the honest choice for
|
||
/// this and what it does not buy.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string keyEditorPrivateKey = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string keyEditorPassphrase = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string keyEditorPublicKey = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string keyEditorNotes = string.Empty;
|
||
|
||
/// <summary>The key being edited, or null when creating.</summary>
|
||
private Guid? editingKeyId;
|
||
|
||
// ---- Generating a key ----
|
||
|
||
/// <summary>
|
||
/// Whether the small form in front of generating a key is showing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// A step of its own rather than two more boxes in the key editor, because the two are opposite
|
||
/// directions: the editor is where a key that already exists is pasted in, and this makes one that does
|
||
/// not exist yet. What it produces lands in that editor unsaved, so there is still exactly one thing in
|
||
/// this application that writes a key, and it is still SAVE.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private bool isGeneratingKey;
|
||
|
||
/// <summary>
|
||
/// What the generated key is called, and the comment written into it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One field for both. The comment is the only thing in a host's <c>authorized_keys</c> that will ever
|
||
/// say where a key came from, and a key whose name here and comment there disagree is one nobody can
|
||
/// match up months later when they are deciding which line to delete.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string generateComment = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private SshKeyAlgorithm generateAlgorithm = SshKeyAlgorithm.Ed25519;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool GeneratesEd25519 => GenerateAlgorithm is SshKeyAlgorithm.Ed25519;
|
||
|
||
/// <inheritdoc cref="ShowsAll" />
|
||
internal bool GeneratesRsa => GenerateAlgorithm is SshKeyAlgorithm.Rsa4096;
|
||
|
||
// ---- The credential editor ----
|
||
// A third set, on the same reasoning as the second: three editors holding unrelated fields, and sharing
|
||
// them would mean a half-typed key reappearing inside a credential.
|
||
|
||
[ObservableProperty]
|
||
private bool isEditingCredential;
|
||
|
||
[ObservableProperty]
|
||
private string credentialEditorLabel = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Optional, and what makes a credential worth being its own item: one account on twenty machines is
|
||
/// rotated in one place. Blank means "use each host's own username" — see <c>CredentialSecret.Username</c>,
|
||
/// which normalises the two spellings of that to one.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string credentialEditorUsername = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Holds a password for as long as the editor is open, and <see cref="CancelCredentialEdit" /> clears it —
|
||
/// the same bargain, and the same limits, as the private key box. See <c>CredentialSecret</c>.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string credentialEditorPassword = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string credentialEditorNotes = string.Empty;
|
||
|
||
/// <summary>The credential being edited, or null when creating.</summary>
|
||
private Guid? editingCredentialId;
|
||
|
||
// ---- The bucket editor ----
|
||
// A fourth set, on the same reasoning as the third.
|
||
|
||
[ObservableProperty]
|
||
private bool isEditingObjectStore;
|
||
|
||
[ObservableProperty]
|
||
private string bucketEditorLabel = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string bucketEditorBucket = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string bucketEditorAccessKeyId = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Holds a secret access key for as long as the editor is open, and cancelling clears it — the same
|
||
/// bargain, and the same limits, as the password box. A secret access key is a password.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string bucketEditorSecretAccessKey = string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private string bucketEditorRegion = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Blank means Amazon and the region resolves the host. Anything else is a full URL, which is what makes
|
||
/// this work against a MinIO on somebody's own network.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string bucketEditorEndpoint = string.Empty;
|
||
|
||
/// <remarks>
|
||
/// Defaulted on for a new bucket, which is the opposite of the AWS default and the right guess here:
|
||
/// somebody adding a bucket with a custom endpoint is nearly always pointing at a self-hosted service,
|
||
/// and those have no wildcard DNS. Somebody adding an AWS bucket leaves the endpoint blank, and
|
||
/// <see cref="NewObjectStore"/> turns it off for them.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private bool bucketEditorUsePathStyle;
|
||
|
||
[ObservableProperty]
|
||
private string bucketEditorNotes = string.Empty;
|
||
|
||
/// <summary>The bucket being edited, or null when creating.</summary>
|
||
private Guid? editingObjectStoreId;
|
||
|
||
// ---- Deleting ----
|
||
|
||
/// <summary>The deletion that has been asked for, or null when nothing has been.</summary>
|
||
/// <remarks>
|
||
/// One at a time, and one for all three kinds. Two armed deletions cannot be told apart by a user
|
||
/// looking at two cards, and this application only ever has one selected item per screen to aim a
|
||
/// question at.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private DeletionRequest? pendingDeletion;
|
||
|
||
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;
|
||
|
||
/// <summary>
|
||
/// What the group panel's EDIT and DELETE act on: the card that is selected, or the group that is open.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Two answers, because a card is no longer where the user is. Selecting one aims the buttons at it,
|
||
/// which is what a click has always done; with nothing selected they aim at the group whose contents are
|
||
/// on screen — the one the trail ends with. Without that fallback a group with no groups inside it could
|
||
/// be opened and then never edited, because opening a group is exactly what takes its own card off the
|
||
/// screen. It is also what a file manager does: act on the selection, and on the current folder when
|
||
/// there is none.
|
||
/// </remarks>
|
||
internal HostGroupRowViewModel? GroupTarget => SelectedGroup ?? GroupFilter;
|
||
|
||
/// <summary>Whether the group panel's buttons are showing.</summary>
|
||
/// <remarks>
|
||
/// Hidden with nothing to act on as well as while the question is up. A pair of buttons over a grid of
|
||
/// cards nobody has chosen between is a pair whose subject the user has to work out, and the answer
|
||
/// would be "neither" — see <see cref="GroupTarget"/>.
|
||
/// </remarks>
|
||
internal bool ShowsGroupActions => GroupTarget is not null && !IsConfirmingGroupDeletion;
|
||
|
||
/// <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;
|
||
|
||
// ---- 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>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
||
/// <remarks>
|
||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
||
/// would make the ordinary case three taps between three keyboards on a phone.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string manualTarget = string.Empty;
|
||
|
||
/// <inheritdoc cref="ConnectPassword" />
|
||
/// <remarks>
|
||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
||
/// not a password offered on the other.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string manualPassword = string.Empty;
|
||
|
||
/// <summary>Why the manual box refused, if it did.</summary>
|
||
/// <remarks>
|
||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private string manualStatus = string.Empty;
|
||
|
||
/// <summary>
|
||
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Set on every attempt rather than only on the ones that stop, because whether a question is coming is
|
||
/// not knowable until the handshake has run. It is never cleared: a stale pair costs nothing, since the
|
||
/// only thing that reads it is a trust decision, and one of those can only exist for the attempt that
|
||
/// raised it.
|
||
/// </remarks>
|
||
private (ConnectionTarget Target, HostAuthentication Authentication)? pendingRetry;
|
||
|
||
/// <summary>
|
||
/// Whether a password typed here should be kept, so this host stops asking for it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// What it produces is an ordinary keychain credential bound to the host, and not a fourth place a
|
||
/// password can live. The two-step chore it replaces — add a password under Keychain, then open the host
|
||
/// and bind it — is what the box's tooltip used to instruct people to do by hand, and doing it by hand
|
||
/// means typing the secret into a second screen while the first one already has it.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Off by default, and it stays a decision.</b> The reason a typed password exists at all is that not
|
||
/// every password belongs in a synchronised vault; remembering silently would move each of them there and
|
||
/// tell nobody. It also only takes effect once the handshake has succeeded — see
|
||
/// <see cref="RememberTypedPasswordAsync"/> — because a password that has just been refused is precisely
|
||
/// the one not worth keeping.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private bool remembersConnectPassword;
|
||
|
||
/// <summary>
|
||
/// Whether the selected host will want something typed into the password box.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// True with nothing selected, which is deliberate: the box is the resting state of that corner of the
|
||
/// window, and an empty terminal column with no password box in it reads as a column that is still
|
||
/// loading.
|
||
/// </para>
|
||
/// <para>
|
||
/// The resolved binding, not the host's own two ids. A host that names neither used to mean "type one",
|
||
/// and now means "take the group's" — so reading the ids directly would put a password box in front of
|
||
/// every host under a group that binds a key, and the box would do nothing.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal bool SelectedHostAsksForAPassword =>
|
||
SelectedHost is null
|
||
|| SelectedHost.Resolved.Binding.Kind is ResolvedBindingKind.TypedPassword;
|
||
|
||
/// <summary>
|
||
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A sentence rather than a hidden box on its own, because "nothing needs typing" and "something needs
|
||
/// typing and the box has not appeared yet" look identical, and only one of them is fine. Which of the two
|
||
/// bindings is doing it matters to the reader: a stored password can be wrong and re-typed here if this
|
||
/// said nothing, and a key cannot.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>An inherited binding says so.</b> This is the one place with room for the sentence, and it is worth
|
||
/// spending: a user looking at a host that says nothing about keys, being told it authenticates with one,
|
||
/// would go looking in the wrong editor. Naming the group points at the record that can actually be
|
||
/// changed.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string SelectedHostAuthenticationNote => SelectedHost?.Resolved.Binding switch
|
||
{
|
||
{ Kind: ResolvedBindingKind.Credential } binding =>
|
||
$"This host uses a password stored in your keychain{From(binding)}.",
|
||
{ Kind: ResolvedBindingKind.SshKey } binding =>
|
||
$"This host authenticates with an SSH key{From(binding)}.",
|
||
_ => string.Empty,
|
||
};
|
||
|
||
/// <summary>
|
||
/// The port the drawer prints, which is the one this host would dial.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Resolved rather than stored, like everything else the pane draws: a host that states no port of its
|
||
/// own and sits under a group on 2222 shows 2222 here, because the question the pane answers is what
|
||
/// happens when CONNECT is pressed. The editor shows the same number as a <em>placeholder</em> behind an
|
||
/// empty box, which is the same fact said the other way round — see <see cref="EditorPortPlaceholder"/>.
|
||
/// </remarks>
|
||
internal string SelectedHostPortLabel =>
|
||
SelectedHost?.Resolved.Port.Value.ToString(CultureInfo.InvariantCulture) ?? string.Empty;
|
||
|
||
/// <summary>Whether that port came from a group rather than from the host.</summary>
|
||
/// <remarks>
|
||
/// Drawn as a word beside the value rather than folded into it. "2222" and "2222, inherited" are the
|
||
/// same connection and different edits: clearing the group's default moves the first host and the
|
||
/// second, and only somebody who knows which is which can predict that.
|
||
/// </remarks>
|
||
internal bool SelectedHostPortIsInherited => SelectedHost?.Resolved.Port.IsInherited ?? false;
|
||
|
||
/// <inheritdoc cref="SelectedHostPortLabel" />
|
||
/// <remarks>
|
||
/// A sentence for "nobody" rather than the em dash the card uses. The card is a column of aligned facts
|
||
/// where a dash reads as "none"; this is a field in a form, and an empty-looking one would read as a
|
||
/// value that had not loaded.
|
||
/// </remarks>
|
||
internal string SelectedHostUsernameLabel => SelectedHost?.Resolved.Username.Value is { Length: > 0 } user
|
||
? user
|
||
: "no account set";
|
||
|
||
/// <inheritdoc cref="SelectedHostPortIsInherited" />
|
||
internal bool SelectedHostUsernameIsInherited => SelectedHost?.Resolved.Username.IsInherited ?? false;
|
||
|
||
/// <summary>
|
||
/// What the pane names in the credentials row: the key or password this host authenticates with.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The item's own label, resolved here rather than carried on the row, because the answer changes when
|
||
/// somebody renames a key on the keychain screen and the host row is not rebuilt for that.
|
||
/// </para>
|
||
/// <para>
|
||
/// A binding whose target the vault no longer holds says so instead of printing an id — the same rule
|
||
/// <see cref="AuthenticationChoice.Missing"/> follows in the editor's picker, and for the same reason:
|
||
/// the reference is allowed to dangle, and a GUID in a field is not an answer to anything.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string SelectedHostBindingLabel => SelectedHost?.Resolved.Binding switch
|
||
{
|
||
{ Kind: ResolvedBindingKind.SshKey, EntityId: { } id } =>
|
||
Keys.FirstOrDefault(key => key.EntityId == id)?.Label ?? "(a key that is no longer here)",
|
||
{ Kind: ResolvedBindingKind.Credential, EntityId: { } id } =>
|
||
Credentials.FirstOrDefault(credential => credential.EntityId == id)?.Label
|
||
?? "(a password that is no longer here)",
|
||
_ => string.Empty,
|
||
};
|
||
|
||
/// <remarks>
|
||
/// Named rather than merely marked as inherited, because "from its group" leaves a user with a tree to
|
||
/// search. A group that has since been deleted leaves the binding dangling, which the connect path
|
||
/// reports on its own — this only has to avoid claiming a name it cannot read.
|
||
/// </remarks>
|
||
private string From(ResolvedBinding binding) =>
|
||
binding.FromGroupId is { } groupId && groupsById.TryGetValue(groupId, out var group)
|
||
? $", from the group {group.Label}"
|
||
: string.Empty;
|
||
|
||
[ObservableProperty]
|
||
private HostKeyPresentation? pendingHostKey;
|
||
|
||
[ObservableProperty]
|
||
private string? hostKeyMismatch;
|
||
|
||
/// <summary>
|
||
/// Raised once a terminal session is open and its renderer has it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// An event rather than a property because handing the terminal the keyboard is something that
|
||
/// happens, not something that is true: connecting a second host while one is already open has to
|
||
/// move focus again, and no state change describes that. Raised on the UI thread — every await on
|
||
/// the path from the command to here uses <c>ConfigureAwait(true)</c> — so a handler may touch
|
||
/// controls directly.
|
||
/// </para>
|
||
/// <para>
|
||
/// It carries the session, because the shell opens a tab for it and the shell is where tabs live. The
|
||
/// vault is the only thing that knows what this session is <em>of</em> — a host's name is a decrypted
|
||
/// item — so the naming happens here and the tab list happens there.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal event EventHandler<TerminalSessionEventArgs>? SessionOpened;
|
||
|
||
/// <summary>
|
||
/// Raised the moment a connection is asked for, before anything has been dialled.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The other half of <see cref="SessionOpened"/>, and the reason connecting no longer makes the window
|
||
/// sit still: the shell opens a tab from this, so the strip shows what is being connected to while the
|
||
/// handshake is still happening, and every other screen stays usable. Exactly one of
|
||
/// <see cref="SessionOpened"/> and <see cref="ConnectionFailed"/> follows it, carrying the same
|
||
/// <c>AttemptId</c>.
|
||
/// </remarks>
|
||
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
||
|
||
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
||
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
||
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
||
|
||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||
|
||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||
|
||
internal bool HasConflicts => Conflicts.Count > 0;
|
||
|
||
/// <summary>Reads the vault into the list and says what is in it.</summary>
|
||
internal async Task LoadAsync(CancellationToken cancellationToken)
|
||
{
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
// Built from whatever is there rather than enumerated per combination. Two item types were four cases;
|
||
// three would be eight, and the fourth kind the selector will eventually grow — pinned host keys —
|
||
// would be sixteen.
|
||
var counted = new List<string>(3);
|
||
|
||
if (Hosts.Count > 0)
|
||
{
|
||
counted.Add($"{Hosts.Count} host(s)");
|
||
}
|
||
|
||
if (Keys.Count > 0)
|
||
{
|
||
counted.Add($"{Keys.Count} key(s)");
|
||
}
|
||
|
||
if (Credentials.Count > 0)
|
||
{
|
||
counted.Add($"{Credentials.Count} credential(s)");
|
||
}
|
||
|
||
var contents = string.Join(", ", counted);
|
||
|
||
// "No hosts yet" survives as its own case, because it is the one nudge this line gives: a vault holding
|
||
// keys and credentials and no hosts is set up but unused, and "2 key(s) in Personal." would read as
|
||
// though everything were in order.
|
||
Status = (Hosts.Count, counted.Count) switch
|
||
{
|
||
(0, 0) => "No hosts yet. Add one.",
|
||
(0, _) => $"No hosts yet, and {contents} in {VaultName}.",
|
||
_ => $"{contents} in {VaultName}.",
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads the vault into the list, silently.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Separate from <see cref="LoadAsync" /> because every caller except the first load has something
|
||
/// better to say afterwards — a save, a deletion, or a sync report — and a background pass has nothing
|
||
/// to say at all. Rebuilding the list used to repaint the status line unconditionally, which made
|
||
/// "the background pass is quiet" false on the one path that mattered.
|
||
/// </remarks>
|
||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||
{
|
||
// First, because the four lists below are read across the same set and a vault admitted by the
|
||
// last refresh should appear in the picker on the same pass its items do.
|
||
RebuildTargetVaults();
|
||
|
||
// Before the hosts, because the sidebar's headings are drawn from the groups and the hosts are what
|
||
// gets counted under them — so the host reload is the pass that can put both together. Tags are read
|
||
// in the same place and for the same reason: a host row draws a chip per tag, and it can only draw
|
||
// the name if the tag was read first.
|
||
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
unreadable += await ReloadTagsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
// After the hosts, because a tag row says how many wear it.
|
||
RebuildTags();
|
||
|
||
unreadable += await ReloadKeysAsync(cancellationToken).ConfigureAwait(true);
|
||
unreadable += await ReloadCredentialsAsync(cancellationToken).ConfigureAwait(true);
|
||
unreadable += await ReloadObjectStoresAsync(cancellationToken).ConfigureAwait(true);
|
||
unreadable += await ReloadSnippetsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
// Last, because it reads the host list to work out which pins nothing dials any more.
|
||
unreadable += await ReloadKnownHostsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
UnreadableItems = unreadable;
|
||
PendingChanges = await session.PendingChangeCountAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
// After all four lists, because the table is a projection of three of them.
|
||
RebuildVaultItems();
|
||
|
||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Redraws every list from the vault, without saying anything about it.</summary>
|
||
/// <remarks>
|
||
/// For the two things that change which vaults exist or which are drawn without going through this type
|
||
/// at all: a vault created on the Teams screen, and a switch in the tab strip's vault menu. Both leave
|
||
/// the lists on screen describing the world as it was a moment ago, and neither has a sentence worth
|
||
/// printing — which is exactly what the quiet reload is for. Also refreshes the empty-state sentence,
|
||
/// which is computed and has no change notification of its own.
|
||
/// </remarks>
|
||
internal async Task RefreshVaultsAsync(CancellationToken cancellationToken)
|
||
{
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
OnPropertyChanged(nameof(HasHiddenVaults));
|
||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||
}
|
||
|
||
/// <summary>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The selection is restored by id rather than kept, because the option objects are rebuilt. Where the
|
||
/// previously selected vault has gone — a grant withdrawn, a team left — it falls back to the active
|
||
/// vault rather than to nothing, so the next Save still has somewhere to go.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Deliberately not filtered by <see cref="IsVaultShown"/>.</b> Hiding is a preference about reading,
|
||
/// and a destination you cannot choose is a vault you cannot put anything in — so switching a team's
|
||
/// vault off to get its forty hosts out of the way would quietly stop you filing anything into it, which
|
||
/// nobody asked for. The same goes for the transfers screen's host picker, which reads
|
||
/// <see cref="Hosts"/> for the same reason.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void RebuildTargetVaults()
|
||
{
|
||
var selectedId = TargetVaultId;
|
||
|
||
TargetVaults.Clear();
|
||
|
||
foreach (var vault in session.ReadableVaults
|
||
.Where(vault => vault.CanWrite)
|
||
.OrderByDescending(vault => vault.IsPersonal)
|
||
.ThenBy(vault => vault.Name, StringComparer.CurrentCulture))
|
||
{
|
||
TargetVaults.Add(new VaultChoiceViewModel(vault.VaultId, vault.Name, vault.IsPersonal));
|
||
}
|
||
|
||
SelectedTargetVault =
|
||
TargetVaults.FirstOrDefault(choice => choice.VaultId == selectedId)
|
||
?? TargetVaults.FirstOrDefault(choice => choice.VaultId == session.ActiveVaultId)
|
||
?? TargetVaults.FirstOrDefault();
|
||
|
||
OnPropertyChanged(nameof(HasVaultChoice));
|
||
}
|
||
|
||
/// <returns>How many hosts would not decrypt.</returns>
|
||
private async Task<int> ReloadHostsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var selectedId = SelectedHost?.EntityId;
|
||
var unreadable = 0;
|
||
var rows = new List<HostRowViewModel>();
|
||
|
||
// Every vault this session holds a key for, not only the one new items are filed into. A team
|
||
// vault whose hosts never reached this list would make sharing look as though it had not worked.
|
||
var readable = session.ReadableVaults.ToList();
|
||
var several = readable.Count > 1;
|
||
|
||
foreach (var vault in readable)
|
||
{
|
||
var listing = await session.Hosts
|
||
.ListAsync(vault.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
unreadable += listing.Unreadable;
|
||
|
||
// Resolved here, which is why ReloadGroupsAsync and ReloadTagsAsync both run before this: a host
|
||
// resolved against a stale group list would show one port and dial another, and one resolved
|
||
// against a stale tag list would draw a chip that has been renamed.
|
||
rows.AddRange(listing.Items.Select(
|
||
item => new HostRowViewModel(
|
||
item, Resolve(item.Secret), LabelsFor(item.Secret.TagIds), vault.VaultId, vault.Name)
|
||
{
|
||
// Only when there is something to tell apart. A badge on every row of a
|
||
// single-vault list is noise that says the same thing on all of them.
|
||
VaultBadge = several ? vault.Name.ToUpperInvariant() : string.Empty,
|
||
|
||
// Every readable vault's groups, not the active one's, because a host in a team's
|
||
// vault is filed under that team's group — and looked up here rather than on the row
|
||
// for the reason the tag names are: the map is the list's, and a row that reached for
|
||
// it would be a lookup per chip per redraw.
|
||
GroupLabel = GroupLabelFor(item.Secret.GroupId),
|
||
}));
|
||
}
|
||
|
||
Hosts.Clear();
|
||
|
||
// Grouped by vault, with the one new items go into first, then by name inside each. Two vaults can
|
||
// hold a host with the same label and both are shown: which vault it is in is what tells them
|
||
// apart, which is why the row carries the name rather than the list deduplicating.
|
||
foreach (var host in rows
|
||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||
{
|
||
Hosts.Add(host);
|
||
}
|
||
|
||
// Selection survives a reload. Losing it on every sync would move the terminal's target out from
|
||
// under the user.
|
||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == selectedId) ?? Hosts.FirstOrDefault();
|
||
|
||
// Both, in this order: the group rows carry a host count, and the sidebar's headings are built from
|
||
// the group rows.
|
||
RebuildGroups();
|
||
RebuildVisibleHosts();
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <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>
|
||
/// 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.
|
||
/// </remarks>
|
||
private async Task<int> ReloadSnippetsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var listing = await session.Snippets
|
||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
Snippets.Clear();
|
||
|
||
foreach (var snippet in listing.Items
|
||
.OrderBy(snippet => snippet.Secret.Label, StringComparer.CurrentCulture))
|
||
{
|
||
Snippets.Add(new SnippetRowViewModel(snippet));
|
||
}
|
||
|
||
return listing.Unreadable;
|
||
}
|
||
|
||
/// <summary>Stores one snippet, encrypted, and queues it for the server.</summary>
|
||
/// <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>
|
||
/// 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.
|
||
/// </remarks>
|
||
internal async Task<bool> SaveSnippetAsync(
|
||
Guid? entityId,
|
||
SnippetSecret snippet,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(snippet);
|
||
|
||
if (!snippet.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return false;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (entityId is { } existing)
|
||
{
|
||
await session.Snippets
|
||
.UpdateAsync(session.ActiveVaultId, existing, snippet, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
await session.Snippets
|
||
.CreateAsync(session.ActiveVaultId, snippet, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = connection() is null
|
||
? $"Saved '{snippet.Label}'. It will sync when you are online."
|
||
: $"Saved '{snippet.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for one snippet.</summary>
|
||
internal async Task DeleteSnippetAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Snippets.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That snippet is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.Snippets
|
||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Deleted '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <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>Two reads, and they cover different vaults on purpose.</b> The editable list — the rows the
|
||
/// sidebar draws headings from and the group editor renames — is the active vault's alone. The
|
||
/// resolution map is every readable vault's.
|
||
/// </para>
|
||
/// <para>
|
||
/// The list stays narrow for the reasons it always did: a row shown across vaults has to carry which
|
||
/// vault it lives in, because rename and delete both need it, and two vaults may hold groups with the
|
||
/// same name, which the one-heading-per-group layout cannot tell apart. Both are worth doing and neither
|
||
/// is a merge's business. Recorded in <c>docs/design-import-gaps.md</c>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The map could not stay narrow, and that changed with inheritance.</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 — the same thing the sidebar shows for a group that has been deleted. Since a group
|
||
/// began lending a port, a username and a binding, the same omission silently drops all three: that host
|
||
/// would dial 22 as nobody, while the machine it names is on 2222 as <c>deploy</c>, and nothing on
|
||
/// screen would say why. A missing heading is cosmetic; a missing port is a connection to the wrong
|
||
/// place.
|
||
/// </para>
|
||
/// <para>
|
||
/// Widening the map costs nothing the narrow list was protecting. Group ids are UUIDv7 and unique across
|
||
/// vaults, so there is no name collision to resolve here and no vault to carry — the map is only ever
|
||
/// asked "what does this id say", which is exactly the question a host's <c>GroupId</c> poses.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var unreadable = 0;
|
||
var resolvable = new Dictionary<Guid, HostGroupSecret>();
|
||
|
||
// Emptied before the loop rather than assigned inside it, because the active vault may not be in
|
||
// the readable set at all — a grant withdrawn mid-session is exactly that — and a loop that only
|
||
// ever writes on a match would leave the last readable vault's groups on screen as though they were
|
||
// still this one's.
|
||
groupItems = [];
|
||
|
||
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;
|
||
}
|
||
|
||
perVault[vault.VaultId] =
|
||
[
|
||
.. listing.Items
|
||
.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)
|
||
.Select(group => new GroupChoice(group.EntityId, group.Secret.Label)),
|
||
];
|
||
|
||
if (vault.VaultId == session.ActiveVaultId)
|
||
{
|
||
groupItems =
|
||
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||
}
|
||
}
|
||
|
||
groupsById = resolvable;
|
||
groupsByVault = perVault;
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <returns>How many tags would not decrypt.</returns>
|
||
/// <remarks>
|
||
/// The same two reads as <see cref="ReloadGroupsAsync"/>, and the same split: the editable list is the
|
||
/// active vault's, the resolution map is every readable vault's. A tag id that does not resolve simply
|
||
/// draws no chip, so the cost of a narrow map here is a host that looks untagged rather than one that
|
||
/// dials the wrong port — cheaper than the group case, and no cheaper to get right.
|
||
/// </remarks>
|
||
private async Task<int> ReloadTagsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var unreadable = 0;
|
||
var resolvable = new Dictionary<Guid, TagSecret>();
|
||
|
||
tagItems = [];
|
||
|
||
foreach (var vault in session.ReadableVaults)
|
||
{
|
||
var listing = await session.Tags
|
||
.ListAsync(vault.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
unreadable += listing.Unreadable;
|
||
|
||
foreach (var tag in listing.Items)
|
||
{
|
||
resolvable[tag.EntityId] = tag.Secret;
|
||
}
|
||
|
||
if (vault.VaultId == session.ActiveVaultId)
|
||
{
|
||
tagItems = [.. listing.Items.OrderBy(tag => tag.Secret.Label, StringComparer.CurrentCulture)];
|
||
}
|
||
}
|
||
|
||
tagsById = resolvable;
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <summary>Refills <see cref="Tags"/>, counting the hosts wearing each.</summary>
|
||
/// <remarks>
|
||
/// Counted over <see cref="Hosts"/>, which spans every readable vault, while the rows themselves are
|
||
/// the active vault's. That asymmetry is deliberate and is the same one the delete warning needs: a tag
|
||
/// worn by a teammate's host is still worn, and a count that ignored those would tell somebody a tag
|
||
/// was unused just before they deleted it out from under twenty machines.
|
||
/// </remarks>
|
||
private void RebuildTags()
|
||
{
|
||
var selectedId = SelectedTag?.EntityId;
|
||
|
||
Tags.Clear();
|
||
|
||
foreach (var tag in tagItems)
|
||
{
|
||
// Over the shown vaults, for the reason the group counts are — see RebuildGroups.
|
||
Tags.Add(new TagRowViewModel(
|
||
tag,
|
||
Hosts.Count(row =>
|
||
row.Host.TagIds.Contains(tag.EntityId) && IsVaultShown(row.VaultId))));
|
||
}
|
||
|
||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == selectedId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// A host with its group chain applied: the port to dial, the user to log in as, and how to
|
||
/// authenticate.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The one place this view model turns a stored host into a dialled one. A host may leave any of the
|
||
/// three unset and take its group's, so reading <c>host.Port</c> or <c>host.SshKeyId</c> directly
|
||
/// answers "what did the user type into this host" and not "what happens when this is connected" — and
|
||
/// almost everything on screen wants the second question. See <see cref="HostInheritance"/>.
|
||
/// </para>
|
||
/// <para>
|
||
/// Reads <see cref="groupsById"/>, which is refilled by <see cref="ReloadGroupsAsync"/> before the hosts
|
||
/// are read. That ordering is not incidental: a host resolved against a stale group list would show one
|
||
/// port and dial another.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal ResolvedHost Resolve(HostSecret host) => HostInheritance.Resolve(host, groupsById);
|
||
|
||
/// <summary>
|
||
/// The names behind a host's tag ids, sorted for display, skipping any that do not resolve.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Sorted by name rather than kept in the set's own order. <see cref="TagSet"/> sorts by id, which is a
|
||
/// UUIDv7 and therefore by when the tag was created — an order that means nothing on screen and changes
|
||
/// what a row looks like depending on which machine made which tag first.
|
||
/// </remarks>
|
||
private IReadOnlyList<string> LabelsFor(TagSet tagIds) =>
|
||
[
|
||
.. tagIds
|
||
.Where(tagsById.ContainsKey)
|
||
.Select(id => tagsById[id].Label)
|
||
.OrderBy(label => label, StringComparer.CurrentCulture),
|
||
];
|
||
|
||
/// <summary>
|
||
/// The name behind a host's group id, or empty where there is none to show.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Empty covers both "this host is in no group" and "the group it names is not in this vault any more",
|
||
/// which is the same answer the list gives a dangling reference everywhere else. See
|
||
/// <see cref="HostRowViewModel.GroupLabel"/>.
|
||
/// </remarks>
|
||
private string GroupLabelFor(Guid? groupId) =>
|
||
groupId is { } id && groupsById.TryGetValue(id, out var group) ? group.Label : string.Empty;
|
||
|
||
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
||
private void RebuildGroups()
|
||
{
|
||
var selectedId = SelectedGroup?.EntityId;
|
||
var filteredId = GroupFilter?.EntityId;
|
||
|
||
Groups.Clear();
|
||
|
||
foreach (var group in groupItems)
|
||
{
|
||
// 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.EntityId && IsVaultShown(row.VaultId));
|
||
|
||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||
}
|
||
|
||
// Re-resolved by id rather than kept: every row object here is replaced on every reload, so an open
|
||
// group holding the old one would go on showing a group that is no longer in the list — and the
|
||
// crumb the user could press to leave it would be a different object that never matched. A group
|
||
// deleted by a sync closes itself, which is the honest answer: the screen comes back to every host
|
||
// rather than to none.
|
||
//
|
||
// This assignment is a new row object whenever a group is open at all, so it always fires
|
||
// OnGroupFilterChanged and therefore an extra RebuildVisibleHosts before the caller's own. That is
|
||
// wasted work rather than a bug — Hosts is already filled by the time this runs, so both passes see
|
||
// the same thing — and it is left rather than dodged by writing the backing field, because writing
|
||
// the field would skip the cards, the trail and GroupTarget with it.
|
||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||
|
||
// Unconditionally, because the assignment above is a no-op — and fires nothing — whenever no group
|
||
// was open, and the cards still have to be rebuilt out of the row objects this pass just made.
|
||
RebuildGroupLevel();
|
||
|
||
// Out of the cards on screen rather than out of every group, and after the level has been rebuilt:
|
||
// this is the card ListBox's own selection, and a row it is not showing is one the control would
|
||
// null straight back out again.
|
||
//
|
||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||
// EDIT and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||
// chose.
|
||
SelectedGroup = VisibleGroups.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
OnPropertyChanged(nameof(HasGroups));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refills the group cards and the trail from whichever group is open.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Both together because they are two halves of one answer: the cards are what is inside the open group
|
||
/// and the trail is how it was reached, and a pass that rebuilt one without the other would draw a level
|
||
/// under a path that does not lead to it.
|
||
/// </remarks>
|
||
private void RebuildGroupLevel()
|
||
{
|
||
var parents = EffectiveParents();
|
||
var open = GroupFilter?.EntityId;
|
||
|
||
VisibleGroups.Clear();
|
||
|
||
foreach (var row in Groups.Where(row => parents.GetValueOrDefault(row.EntityId) == open))
|
||
{
|
||
VisibleGroups.Add(row);
|
||
}
|
||
|
||
GroupTrail.Clear();
|
||
|
||
// Always first, always there, and it is the way out — see GroupTrail. The name is what the grid
|
||
// below shows when nothing is open, rather than the vault's, because that is the choice being
|
||
// offered: this crumb widens the screen back to every machine in it.
|
||
GroupTrail.Add(new GroupCrumbViewModel("ALL HOSTS", null));
|
||
|
||
foreach (var row in Ancestry(open, parents))
|
||
{
|
||
GroupTrail.Add(new GroupCrumbViewModel(row.Label, row));
|
||
}
|
||
|
||
OnPropertyChanged(nameof(HasVisibleGroups));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Which group each one sits under, with anything a walk upwards cannot get out of promoted to the
|
||
/// outermost level.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Two things are promoted, and both are states this application has decided to survive rather than
|
||
/// prevent. A parent id this vault has not got is a group deleted on another machine — the reference is
|
||
/// allowed to dangle, because preventing it would mean one delete rewriting every item naming the
|
||
/// deleted thing. A cycle is two clients each re-parenting A under B and B under A while offline, which
|
||
/// no merge can see because the pointer is inside the payload. See
|
||
/// <see cref="HostGroupSecret.ParentId"/>.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Both have to end up somewhere the user can reach.</b> The repair for either is the group's own
|
||
/// editor, and the editor is opened from the card — so a group left inside a card nobody can open, or
|
||
/// inside a cycle no walk terminates in, would be a broken state with the fix locked inside it. Promoting
|
||
/// to a root is the same degradation the resolver's visited set produces for inheritance: a cycle reads
|
||
/// as a flat run of top-level groups.
|
||
/// </para>
|
||
/// </remarks>
|
||
private Dictionary<Guid, Guid?> EffectiveParents()
|
||
{
|
||
var stated = Groups.ToDictionary(row => row.EntityId, row => row.Group.ParentId);
|
||
var parents = new Dictionary<Guid, Guid?>(stated.Count);
|
||
|
||
foreach (var (id, parent) in stated)
|
||
{
|
||
parents[id] = parent is { } wanted && stated.ContainsKey(wanted) ? wanted : null;
|
||
}
|
||
|
||
foreach (var id in stated.Keys)
|
||
{
|
||
if (!ReachesTheTop(id))
|
||
{
|
||
parents[id] = null;
|
||
}
|
||
}
|
||
|
||
return parents;
|
||
|
||
bool ReachesTheTop(Guid id)
|
||
{
|
||
var visited = new HashSet<Guid>();
|
||
Guid? current = id;
|
||
|
||
while (current is { } step && visited.Add(step))
|
||
{
|
||
current = parents[step];
|
||
}
|
||
|
||
return current is null;
|
||
}
|
||
}
|
||
|
||
/// <summary>The groups from the outermost down to the one that is open, or nothing when none is.</summary>
|
||
/// <remarks>
|
||
/// Walked against <see cref="EffectiveParents"/> rather than against the stated parents, so the trail
|
||
/// cannot lead through a group the cards will not draw — and so that it terminates, which is what the
|
||
/// promotion above buys: a cycle has no parent left to follow.
|
||
/// </remarks>
|
||
private List<HostGroupRowViewModel> Ancestry(Guid? open, Dictionary<Guid, Guid?> parents)
|
||
{
|
||
var trail = new List<HostGroupRowViewModel>();
|
||
var current = open;
|
||
|
||
while (current is { } id
|
||
&& Groups.FirstOrDefault(row => row.EntityId == id) is { } row)
|
||
{
|
||
trail.Add(row);
|
||
current = parents.GetValueOrDefault(id);
|
||
}
|
||
|
||
trail.Reverse();
|
||
|
||
return trail;
|
||
}
|
||
|
||
/// <summary>Refills the sidebar's list from <see cref="Hosts"/> and the filter.</summary>
|
||
/// <remarks>
|
||
/// The selection is captured and restored around the rebuild, and that is not tidiness — it is what
|
||
/// keeps this method from undoing its own caller. <c>ListBox.SelectedItem</c> is two-way bound to
|
||
/// <see cref="SelectedHost"/>, so <c>VisibleHosts.Clear()</c> is a <c>Reset</c> the list reacts to by
|
||
/// nulling its selection, and the binding writes that null straight back — silently, and before this
|
||
/// method's own refill has a chance to matter. <see cref="ReloadHostsAsync"/> restores the selection and
|
||
/// calls this immediately after, which used to mean every reload undid what it had just restored, and
|
||
/// every keystroke in the filter box did the same.
|
||
/// </remarks>
|
||
private void RebuildVisibleHosts()
|
||
{
|
||
var selected = SelectedHost;
|
||
|
||
// Built once and handed down rather than rebuilt inside the predicate: deciding where a host sits is
|
||
// a walk up the group tree, and this is the map that walk is made against.
|
||
var parents = EffectiveParents();
|
||
|
||
VisibleHosts.Clear();
|
||
|
||
foreach (var host in Hosts.Where(host => Matches(host, parents)))
|
||
{
|
||
VisibleHosts.Add(host);
|
||
}
|
||
|
||
RebuildSidebarRows();
|
||
|
||
// Restored when it still matches, and explicitly cleared when it does not — rather than left alone
|
||
// and trusted to whatever a live SelectedItem binding happens to do about it. A filter that hides
|
||
// the selected host has to mean nothing is selected: Connect, Edit and Delete all read this
|
||
// property directly, and a host that is not on screen is not one any of them should act on.
|
||
SelectedHost = selected is not null && VisibleHosts.Contains(selected) ? selected : null;
|
||
|
||
// After the host selection, not before: this mirrors it, and the ListBox's own answer to the Clear()
|
||
// above is a null that has to be overwritten rather than read.
|
||
SelectedSidebarRow = SelectedHost;
|
||
|
||
// The grid's empty state. Both of these are computed rather than stored, and neither has a change
|
||
// notification of its own — VisibleHosts raises collection changes, which is not the same event.
|
||
OnPropertyChanged(nameof(HasVisibleHosts));
|
||
OnPropertyChanged(nameof(NoVisibleHostsMessage));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lays the visible hosts out under their group headings.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>No groups means no headings.</b> The sidebar of a vault nobody has filed anything in is the list it
|
||
/// always was, which is what makes this feature cost nothing to ignore.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A host whose group has been deleted falls under the ungrouped heading</b> rather than disappearing
|
||
/// or keeping an empty heading of its own. The reference is allowed to dangle — see
|
||
/// <see cref="HostSecret.GroupId"/> for why deleting a group deliberately does not rewrite the hosts in
|
||
/// it — so "the group this names is not here" and "this names no group" have to look the same, because to
|
||
/// the user they are the same thing.
|
||
/// </para>
|
||
/// <para>
|
||
/// An empty group still gets its heading, and a group emptied by the <em>filter</em> does not. The first
|
||
/// is a thing the user made and can file hosts into; the second is an absence of search results, and a
|
||
/// heading with nothing under it would read as a group that had lost its contents.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void RebuildSidebarRows()
|
||
{
|
||
SidebarRows.Clear();
|
||
|
||
// Its own pass over the hosts rather than a read of VisibleHosts, which has been one level of the
|
||
// tree since the desktop's grid became a folder pane — see Matches. This list is the flat answer to
|
||
// the same question: every group it has as a heading, every host filed under one of them, and no way
|
||
// to go inside anything. The phone that draws it has no group cards and nowhere to open one into, so
|
||
// a list narrowed to the outermost level would be a list showing only the hosts nobody had filed.
|
||
var shown = Hosts.Where(MatchesFilters).ToArray();
|
||
|
||
if (Groups.Count == 0)
|
||
{
|
||
foreach (var host in shown)
|
||
{
|
||
SidebarRows.Add(host);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
var known = Groups.Select(group => group.EntityId).ToHashSet();
|
||
|
||
foreach (var group in Groups)
|
||
{
|
||
AddSection(group.EntityId, group.Label, host => host.Host.GroupId == group.EntityId);
|
||
}
|
||
|
||
AddSection(
|
||
null,
|
||
"UNGROUPED",
|
||
host => host.Host.GroupId is not { } id || !known.Contains(id),
|
||
onlyWhenOccupied: true);
|
||
|
||
void AddSection(
|
||
Guid? groupId,
|
||
string label,
|
||
Func<HostRowViewModel, bool> belongs,
|
||
bool onlyWhenOccupied = false)
|
||
{
|
||
var members = shown.Where(belongs).ToArray();
|
||
|
||
if (onlyWhenOccupied && members.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var expanded = !collapsedGroups.Contains(groupId ?? Guid.Empty);
|
||
|
||
SidebarRows.Add(new SidebarGroupHeader(groupId, label, members.Length, expanded));
|
||
|
||
if (!expanded)
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var member in members)
|
||
{
|
||
SidebarRows.Add(member);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Folds one group's hosts away, or brings them back.</summary>
|
||
/// <remarks>
|
||
/// Keyed on the group id in a set of the folded ones rather than on a flag on the row, because the rows
|
||
/// are rebuilt from scratch on every filter keystroke and every background sync — a flag would be
|
||
/// forgotten a minute after it was set. The ungrouped heading uses <see cref="Guid.Empty"/>, which is not
|
||
/// a legal group id: <c>HostSecret.TryValidate</c> refuses one.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ToggleGroup(SidebarGroupHeader? header)
|
||
{
|
||
if (header is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var key = header.GroupId ?? Guid.Empty;
|
||
|
||
if (!collapsedGroups.Remove(key))
|
||
{
|
||
collapsedGroups.Add(key);
|
||
}
|
||
|
||
RebuildSidebarRows();
|
||
SelectedSidebarRow = SelectedHost;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Files one host under one group, or under none.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// What dragging a host card onto a group card does, and the only thing in this application that changes
|
||
/// a host without opening the editor. That is the justification for it existing at all: filing thirty
|
||
/// imported machines meant thirty rounds of open, pick, save, and the field being changed is the one
|
||
/// field of a host that is about arrangement rather than about the machine.
|
||
/// </para>
|
||
/// <para>
|
||
/// It writes the saved host rather than the editor's contents, and refuses while the editor is open. A
|
||
/// drop is a gesture on the list, not on the form: rewriting the item under a half-typed edit of the same
|
||
/// host would be a save the user never asked for, and one they would then be unable to cancel.
|
||
/// </para>
|
||
/// <para>
|
||
/// A group id that is not in this vault is not refused — it is treated as no group at all, which is what
|
||
/// the list already does with a dangling reference. See <see cref="RebuildSidebarRows"/>.
|
||
/// </para>
|
||
/// <para>
|
||
/// No cancellation token, for the reason <see cref="ConnectAsync"/> has none: a command generated over a
|
||
/// method that takes one cancels the previous execution's token on every invocation, and two drops in
|
||
/// quick succession are two writes rather than one superseding the other. This is one row's one field
|
||
/// and it is over in a moment.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <param name="request">The host to move, and where to.</param>
|
||
[RelayCommand]
|
||
private async Task MoveHostToGroupAsync(HostGroupMove? request)
|
||
{
|
||
if (request is not { Host: { } row })
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
// The same refusal editing makes, and for the same reason: re-encoding an item a newer client
|
||
// wrote would drop the fields this build has no concept of.
|
||
Status = "This host was written by a newer version of DodoSSH. Update before filing it.";
|
||
return;
|
||
}
|
||
|
||
if (IsEditing)
|
||
{
|
||
Status = "Finish or cancel the host you are editing first.";
|
||
return;
|
||
}
|
||
|
||
Guid? target = request.GroupId is { } wanted && Groups.Any(group => group.EntityId == wanted)
|
||
? wanted
|
||
: null;
|
||
|
||
if (row.Host.GroupId == target)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var moved = row.Host with { GroupId = target };
|
||
var name = target is null
|
||
? "no group"
|
||
: Groups.First(group => group.EntityId == target).Label;
|
||
|
||
await RunAsync(
|
||
$"Filing {row.Label} under {name}…",
|
||
async () =>
|
||
{
|
||
await session.Hosts
|
||
.UpdateAsync(row.VaultId, row.EntityId, moved, CancellationToken.None)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(CancellationToken.None).ConfigureAwait(true);
|
||
|
||
// Re-found rather than kept: the reload replaces every row, so the object that was dragged is
|
||
// no longer the one in the list, and leaving the selection pointing at it would light nothing.
|
||
SelectedHost = Hosts.FirstOrDefault(candidate => candidate.EntityId == row.EntityId);
|
||
|
||
Status = target is null
|
||
? $"'{row.Label}' is no longer in a group."
|
||
: $"Filed '{row.Label}' under '{name}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
// Pushed straight away, as a save from the editor is: this is a save from the editor, minus the
|
||
// editor.
|
||
await AutoSyncAsync(CancellationToken.None).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Whether one host belongs on the grid at the level it is currently showing.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The grid holds one level of the tree, the way a directory pane holds one directory.</b> A host
|
||
/// filed under a group is inside that group and nowhere else — it is not also on the screen the group's
|
||
/// own card sits on. While it was both, a card was a heading over a grid that already held everything
|
||
/// underneath it, so opening one could only ever take hosts away; a card is now the only place its hosts
|
||
/// are, which is what makes it a folder rather than a filter that happens to be switched off.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The find box is the exception, and deliberately.</b> Typed into, it searches the open group and
|
||
/// everything under it — which, with nothing open, is every host in the keychain. A search that looked
|
||
/// only in the level it was typed on would answer "no host matches that" about a machine this keychain
|
||
/// has got, which is the one answer a search box must never give; and finding a machine without first
|
||
/// remembering where it was filed is most of what the box is for.
|
||
/// </para>
|
||
/// </remarks>
|
||
private bool Matches(HostRowViewModel row, Dictionary<Guid, Guid?> parents)
|
||
{
|
||
if (!MatchesFilters(row))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var open = GroupFilter?.EntityId;
|
||
var group = EffectiveGroupOf(row, parents);
|
||
|
||
return HostFilter.Trim().Length == 0 ? group == open : IsUnder(open, group, parents);
|
||
}
|
||
|
||
/// <summary>Whether one host survives the vault switches and the find box — where it sits aside.</summary>
|
||
/// <remarks>
|
||
/// An empty filter matches everything rather than nothing, which is the only reading that makes an empty
|
||
/// box mean "not filtering". The notes are searched as well as the name and the address: what somebody
|
||
/// wrote down about a machine is often the only place its purpose is recorded.
|
||
/// </remarks>
|
||
private bool MatchesFilters(HostRowViewModel row)
|
||
{
|
||
// First, and ahead of the box, because it is not a search: a hidden vault's host is out however the
|
||
// grid is narrowed, and a count taken after this reflects what is on screen.
|
||
if (!IsVaultShown(row.VaultId))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var filter = HostFilter.Trim();
|
||
|
||
if (filter.Length == 0)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
return Contains(row.Label) || Contains(row.Address) || Contains(row.Host.Notes);
|
||
|
||
bool Contains(string? value) =>
|
||
value is not null && value.Contains(filter, StringComparison.CurrentCultureIgnoreCase);
|
||
}
|
||
|
||
/// <summary>The group a host is actually drawn under, or none.</summary>
|
||
/// <remarks>
|
||
/// An id this vault has not got reads as no group at all, which is what the chip on the card, the
|
||
/// ungrouped heading and the group picker each already do with one — see
|
||
/// <see cref="RebuildSidebarRows"/>. It matters more here than in any of them: a host naming a group
|
||
/// deleted on another machine would otherwise sit at a level nothing on screen can open, and now that
|
||
/// the grid is one level at a time there would be nothing left that ever drew it.
|
||
/// </remarks>
|
||
private static Guid? EffectiveGroupOf(HostRowViewModel row, Dictionary<Guid, Guid?> parents) =>
|
||
row.Host.GroupId is { } id && parents.ContainsKey(id) ? id : null;
|
||
|
||
/// <summary>Whether a group is the open one or lies somewhere beneath it.</summary>
|
||
/// <remarks>
|
||
/// Nothing open means everything is under it, which is what makes the search box reach the whole keychain
|
||
/// from the outermost level. The walk terminates because it is made against
|
||
/// <see cref="EffectiveParents"/>, where anything caught in a cycle has already been promoted to a root.
|
||
/// </remarks>
|
||
private static bool IsUnder(Guid? open, Guid? group, Dictionary<Guid, Guid?> parents)
|
||
{
|
||
if (open is null)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
var current = group;
|
||
|
||
while (current is { } id)
|
||
{
|
||
if (id == open)
|
||
{
|
||
return true;
|
||
}
|
||
|
||
current = parents.GetValueOrDefault(id);
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/// <returns>How many keys would not decrypt.</returns>
|
||
/// <remarks>
|
||
/// Unlike the host list, the selection is <em>not</em> defaulted to the first row: it is what
|
||
/// <see cref="DeleteKey" /> aims at, and a list that picked a row on every background sync would point
|
||
/// that button at a key nobody chose.
|
||
/// </remarks>
|
||
private async Task<int> ReloadKeysAsync(CancellationToken cancellationToken)
|
||
{
|
||
var selectedId = SelectedKey?.EntityId;
|
||
var unreadable = 0;
|
||
var rows = new List<SshKeyRowViewModel>();
|
||
|
||
foreach (var vault in session.ReadableVaults)
|
||
{
|
||
var listing = await session.SshKeys
|
||
.ListAsync(vault.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
unreadable += listing.Unreadable;
|
||
|
||
rows.AddRange(listing.Items.Select(
|
||
item => new SshKeyRowViewModel(item, vault.VaultId, vault.Name)));
|
||
}
|
||
|
||
Keys.Clear();
|
||
|
||
foreach (var key in rows
|
||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||
{
|
||
Keys.Add(key);
|
||
}
|
||
|
||
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <returns>How many credentials would not decrypt.</returns>
|
||
/// <remarks>
|
||
/// An existing selection survives a reload and a reload never invents one, which is the same pair of rules
|
||
/// as the key list and matters more here. <see cref="DeleteCredential" /> reads the selection, so a list
|
||
/// that fell back to its first row would point the deletion — and the question in front of it — at a
|
||
/// password nobody chose.
|
||
/// </remarks>
|
||
private async Task<int> ReloadCredentialsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var selectedId = SelectedCredential?.EntityId;
|
||
var unreadable = 0;
|
||
var rows = new List<CredentialRowViewModel>();
|
||
|
||
foreach (var vault in session.ReadableVaults)
|
||
{
|
||
var listing = await session.Credentials
|
||
.ListAsync(vault.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
unreadable += listing.Unreadable;
|
||
|
||
rows.AddRange(listing.Items.Select(
|
||
item => new CredentialRowViewModel(item, vault.VaultId, vault.Name)));
|
||
}
|
||
|
||
Credentials.Clear();
|
||
|
||
foreach (var credential in rows
|
||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||
{
|
||
Credentials.Add(credential);
|
||
}
|
||
|
||
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <returns>How many pins would not decrypt.</returns>
|
||
/// <remarks>
|
||
/// Read through the repository rather than through <c>VaultKnownHostStore</c>, which holds a snapshot
|
||
/// shaped for the SSH handshake — one pin per endpoint, deduplicated, and with no entity ids. This list
|
||
/// has to show duplicates, because a duplicate is one of the things worth seeing.
|
||
/// </remarks>
|
||
private async Task<int> ReloadKnownHostsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var selectedId = SelectedKnownHost?.EntityId;
|
||
var unreadable = 0;
|
||
var rows = new List<KnownHostRowViewModel>();
|
||
|
||
// Built once rather than searched per pin. A vault with a hundred of each would otherwise be a
|
||
// hundred scans of the host list on every background sync.
|
||
var dialled = Hosts
|
||
.Select(host => Endpoint(host.Host.Hostname, Resolve(host.Host).Port.Value))
|
||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||
|
||
// Listed across every readable vault, unlike the trust the SSH handshake consults, which stays in
|
||
// the active vault alone. The difference is deliberate and is stated in the README: a pin in a
|
||
// team vault is something a teammate can write, and letting it answer for a host in somebody's
|
||
// personal vault would let one member suppress another's first-contact prompt. Showing them is
|
||
// safe and is the only way somebody can see what their team has trusted.
|
||
foreach (var vault in session.ReadableVaults)
|
||
{
|
||
var listing = await session.KnownHosts
|
||
.ListAsync(vault.VaultId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
unreadable += listing.Unreadable;
|
||
|
||
rows.AddRange(listing.Items.Select(item => new KnownHostRowViewModel(
|
||
item,
|
||
dialled.Contains(Endpoint(item.Secret.Host, item.Secret.Port)),
|
||
vault.VaultId,
|
||
vault.Name)));
|
||
}
|
||
|
||
KnownHostPins.Clear();
|
||
|
||
foreach (var pin in rows
|
||
.OrderByDescending(row => row.VaultId == session.ActiveVaultId)
|
||
.ThenBy(row => row.VaultName, StringComparer.CurrentCulture)
|
||
.ThenBy(row => row.Label, StringComparer.CurrentCulture))
|
||
{
|
||
KnownHostPins.Add(pin);
|
||
}
|
||
|
||
SelectedKnownHost = KnownHostPins.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
return unreadable;
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Case-insensitively, because a host name is, and <c>KnownHostIdentity</c> keys the store the same way.
|
||
/// A pin written as <c>DB.internal</c> and a host saved as <c>db.internal</c> are the same machine, and a
|
||
/// list that called one of them unused would be inviting somebody to delete trust they rely on.
|
||
/// </remarks>
|
||
private static string Endpoint(string host, int port) =>
|
||
string.Create(CultureInfo.InvariantCulture, $"{host}:{port}");
|
||
|
||
/// <summary>Runs a synchronisation pass, if this machine can reach a server.</summary>
|
||
/// <remarks>
|
||
/// The offline branch is inside <see cref="RunAsync" /> rather than in front of it, because getting
|
||
/// online is now part of what this button does: resuming a remembered sign-in is a network round trip
|
||
/// and belongs under the same busy flag as the pass it leads to.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task SyncAsync(CancellationToken cancellationToken)
|
||
{
|
||
await RunAsync(
|
||
"Synchronising…",
|
||
async () =>
|
||
{
|
||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||
{
|
||
LastSyncFailed = true;
|
||
Status = "Offline. Changes are queued and will be sent as soon as this machine "
|
||
+ "can reach the server again.";
|
||
return;
|
||
}
|
||
|
||
var report = await SyncOnceAsync(server, cancellationToken).ConfigureAwait(true);
|
||
|
||
// Null means a background pass held the gate. Saying so beats reporting a sync that this
|
||
// press did not perform.
|
||
Status = report is null
|
||
? "A synchronisation is already running."
|
||
: Describe(report);
|
||
}).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Finds a server to sync against, getting this machine online if it is not.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The handler is asked even when a connection is already held, which looks redundant and is not: the
|
||
/// shell is the thing that persists the refresh token so a later launch can resume, and identity
|
||
/// providers rotate that token on every refresh. Asking once per pass is what keeps the remembered
|
||
/// sign-in current without an event and without this view model knowing what a token is.
|
||
/// </remarks>
|
||
private Task<IVaultServer?> ResolveServerAsync(CancellationToken cancellationToken) =>
|
||
reconnect is null ? Task.FromResult(connection()) : reconnect(cancellationToken);
|
||
|
||
/// <summary>
|
||
/// Starts syncing in the background until the vault is disposed.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Explicit rather than started from the constructor, so that a test can drive
|
||
/// <see cref="AutoSyncAsync" /> a pass at a time instead of racing a timer.
|
||
/// </remarks>
|
||
internal void StartAutoSync()
|
||
{
|
||
if (autoSync is not null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
autoSync = new CancellationTokenSource();
|
||
autoSyncLoop = RunAutoSyncLoopAsync(autoSync.Token);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One background synchronisation pass, which stays out of the way.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Deliberately not routed through <see cref="RunAsync" />. That would raise the busy flag every
|
||
/// interval — disabling Connect and Save for the duration — and repaint the status line with
|
||
/// "Synchronising…" while the user was reading something else. A background pass that makes the
|
||
/// application feel intermittently broken is worse than a Sync button.
|
||
/// </para>
|
||
/// <para>
|
||
/// So it is silent unless it has something to say: the status line changes only when the pass actually
|
||
/// moved an item or produced something needing attention. It also yields to the user — a pass is
|
||
/// skipped outright while a command is running, rather than queueing behind it.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal async Task AutoSyncAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (IsBusy)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// One background synchronisation pass, run whether or not a command is in flight.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The same quiet pass as <see cref="AutoSyncAsync" /> without the one thing that made it useless at
|
||
/// the moment it matters most. The loop is started from inside the unlock command, so the busy flag a
|
||
/// timed pass yields to is raised by the very command that opened the vault — and the pass on open
|
||
/// therefore never ran, silently, putting the first synchronisation a full minute after unlock.
|
||
/// </para>
|
||
/// <para>
|
||
/// Yielding is right for every later pass, because by then a busy flag means a person is doing
|
||
/// something. It is wrong for this one, because the thing it would be yielding to is the unlock.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal async Task SyncOnOpenAsync(CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
if (await ResolveServerAsync(cancellationToken).ConfigureAwait(true) is not { } server)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var report = await SyncOnceAsync(server, cancellationToken).ConfigureAwait(true);
|
||
|
||
if (report is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// A vault that failed is recorded by SyncOnceAsync and deliberately not announced here: it
|
||
// gets the treatment the catch below gives a total failure, the fact kept and the message
|
||
// swallowed. Otherwise a laptop with a lid shut all afternoon replaces whatever the user was
|
||
// reading, once a minute, with the name of a vault it could not reach. Pressing Sync still
|
||
// names the vault and the reason, because somebody who pressed it is waiting for an answer.
|
||
if (IsWorthReporting(report))
|
||
{
|
||
Status = Describe(report);
|
||
}
|
||
|
||
await PruneLogsIfDueAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Locking, or closing.
|
||
}
|
||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||
{
|
||
// The message is swallowed on purpose, and this is the one place in the view model where that
|
||
// is right: a laptop that has been closed all afternoon would otherwise replace whatever the
|
||
// user was reading with a socket error once a minute. Pressing Sync still reports the reason.
|
||
//
|
||
// The *fact* is not swallowed, and that is the half that used to be missing. Recording it is
|
||
// what lets the titlebar stop claiming to be up to date with a server it cannot reach.
|
||
LastSyncFailed = true;
|
||
}
|
||
}
|
||
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The gate is shared with the manual command, so a press and a tick can never overlap. Taken with a
|
||
/// zero timeout rather than awaited: a pass that arrives while another is running has nothing to add by
|
||
/// waiting for it, and queueing them would turn a slow server into a backlog of identical work.
|
||
/// </para>
|
||
/// <para>
|
||
/// Takes the whole server rather than its sync half, because the first thing a pass does is ask
|
||
/// <em>which vaults there are</em> — see <see cref="AdmitNewVaultsAsync"/>. A pass that only synced the
|
||
/// vaults it already knew could never discover one somebody had just shared.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
|
||
IVaultServer server,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
try
|
||
{
|
||
// Before the sync, so a vault admitted here is one of the vaults that pass then pulls. The
|
||
// other order would show a newly shared vault as an empty one until the minute after.
|
||
await AdmitNewVaultsAsync(server.Account, cancellationToken).ConfigureAwait(true);
|
||
|
||
// Every vault this session can read, not only the one new items are filed into. A team's
|
||
// vault that never synced would show its hosts exactly once — at the unlock that first
|
||
// pulled it — and then quietly stop, which reads as the feature not working.
|
||
var report = await session.SyncAllAsync(server.Sync, cancellationToken).ConfigureAwait(true);
|
||
|
||
// Not unconditionally false, which it was while a pass was one vault and a failure was an
|
||
// exception. A failure is now a report — one unreachable team vault must not stop the others
|
||
// syncing — so clearing the flag here regardless would light the titlebar green over a vault
|
||
// that had just failed to sync, which is exactly the lie that flag exists to prevent.
|
||
LastSyncFailed = report.Any(vault => !vault.Succeeded);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
// Host key trust arrives with the rest of the vault, and the store the SSH handshake asks holds a
|
||
// snapshot rather than reading per lookup — so a pass that pulled a pin has to hand it over here,
|
||
// or a host a colleague approved stays a first-contact prompt until the next unlock.
|
||
await knownHosts.RefreshAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
return report;
|
||
}
|
||
finally
|
||
{
|
||
syncGate.Release();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Re-reads which vaults this account can reach, and opens any that have become readable.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>This is the whole of how a shared vault arrives.</b> Sharing is two acts on two machines: the
|
||
/// person sharing wraps the vault key to the recipient, and the recipient's own client has to notice.
|
||
/// The recipient is handed nothing — there is no push channel — so 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>
|
||
/// 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);
|
||
|
||
try
|
||
{
|
||
// A pass on open, before the first tick. A vault edited on another machine should be current by
|
||
// the time the user has finished reading the list, not a minute afterwards.
|
||
//
|
||
// Deliberately not through AutoSyncAsync, and this is not a shortcut. This loop is started from
|
||
// inside the unlock command, so the busy flag that pass yields to is raised by the very command
|
||
// that opened the vault — and the pass on open therefore never ran at all. It was a silent
|
||
// no-op that put the first synchronisation a full minute after unlock, on the launch where
|
||
// being current matters most. The later passes keep the check: by then, a busy flag means a
|
||
// user is doing something.
|
||
await SyncOnOpenAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(true))
|
||
{
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Locking, or closing.
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
|
||
/// <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>
|
||
/// Puts the drawer away, whichever of the three panels is in it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One button for all three, because what it means is "give the grid its 304 pixels back" rather than
|
||
/// "cancel". An open editor is abandoned by it — the same thing its own CANCEL does, and the same thing
|
||
/// the arrow has to mean, since a header button that refused while a form was open would be a control
|
||
/// that is sometimes furniture and sometimes a decision. The selection survives: the card stays lit and
|
||
/// the pencil on it opens the pane again.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void CloseDrawer()
|
||
{
|
||
if (IsEditing)
|
||
{
|
||
CancelEditCommand.Execute(null);
|
||
}
|
||
|
||
if (IsEditingGroup)
|
||
{
|
||
CancelGroupEditCommand.Execute(null);
|
||
}
|
||
|
||
IsHostPaneOpen = false;
|
||
}
|
||
|
||
/// <summary>Starts a new host.</summary>
|
||
[RelayCommand]
|
||
private void NewHost()
|
||
{
|
||
IsAddSheetOpen = false;
|
||
|
||
if (AHostEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
editingEntityId = null;
|
||
|
||
// The keychain screen's picker is the default rather than the answer: the editor has a picker of its
|
||
// own from here on, and moving that one is what decides where this host lands. See
|
||
// EditorVaultChoices.
|
||
editingHostVaultId = TargetVaultId;
|
||
|
||
EditorLabel = string.Empty;
|
||
EditorHostname = string.Empty;
|
||
|
||
// Empty rather than 22, so a new host under a group that says 2222 is created wanting 2222 without
|
||
// anybody typing it — and one under no group still dials 22, because that is where the chain ends.
|
||
EditorPort = null;
|
||
EditorUsername = string.Empty;
|
||
EditorNotes = string.Empty;
|
||
EditorRelayEnabled = false;
|
||
editorTagIds = TagSet.Empty;
|
||
EditorNewTag = string.Empty;
|
||
BuildTagChoices();
|
||
|
||
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||
BuildEditorVaultChoices(editingHostVaultId);
|
||
|
||
// A new host opens in the group the screen is already about — the card that is selected, or failing
|
||
// that the group whose contents are showing. Adding three machines to the group somebody has just
|
||
// made is the ordinary case, and since the grid holds one level at a time the alternative is worse
|
||
// than a default nobody chose: a host created inside a group and filed under none would vanish from
|
||
// the screen it was created on. Only when that group is in the vault this host is going into,
|
||
// though — the grid draws the active vault's groups, and inheriting one into a shared vault would
|
||
// file the host under something nobody else in it can resolve. Before the authentication picker,
|
||
// because whether there is a group to inherit from decides whether that one offers to.
|
||
BuildGroupChoices(GroupInEditingVault(GroupTarget?.EntityId));
|
||
|
||
BuildAuthenticationChoices(
|
||
boundKeyId: null,
|
||
boundCredentialId: null,
|
||
asksForPassword: false,
|
||
grouped: EditorSelectedGroup?.EntityId is not null);
|
||
IsEditing = true;
|
||
Status = "Adding a host.";
|
||
}
|
||
|
||
/// <summary>Opens the selected host for editing.</summary>
|
||
[RelayCommand]
|
||
private void EditSelectedHost()
|
||
{
|
||
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
// Re-encoding would drop fields this build has no concept of, so the honest answer is to
|
||
// refuse rather than to silently lose a colleague's data.
|
||
Status = "This host was written by a newer version of DodoSSH. Update before editing it.";
|
||
return;
|
||
}
|
||
|
||
editingEntityId = row.EntityId;
|
||
|
||
// The host's own vault, and it does not move: the two are encrypted under different keys, so
|
||
// saving anywhere else would fork it rather than move it. The picker is hidden for an existing
|
||
// host — see ShowsEditorVaultChoice — and is filled anyway so that it is not showing the last
|
||
// host's vault behind the panel.
|
||
editingHostVaultId = row.VaultId;
|
||
|
||
EditorLabel = row.Host.Label;
|
||
EditorHostname = row.Host.Hostname;
|
||
|
||
// The stored port, not the resolved one, and the difference is the whole feature: an inheriting host
|
||
// opens with an empty box showing its group's value as a placeholder. Loading the resolved value
|
||
// instead would fill the box, and saving would then pin what the host had deliberately left open.
|
||
EditorPort = row.Host.Port;
|
||
EditorUsername = row.Host.Username ?? string.Empty;
|
||
EditorNotes = row.Host.Notes ?? string.Empty;
|
||
EditorRelayEnabled = row.Host.RelayEnabled;
|
||
editorTagIds = row.Host.TagIds;
|
||
EditorNewTag = string.Empty;
|
||
BuildTagChoices();
|
||
|
||
BuildEditorVaultChoices(editingHostVaultId);
|
||
BuildGroupChoices(row.Host.GroupId);
|
||
|
||
BuildAuthenticationChoices(
|
||
row.Host.SshKeyId,
|
||
row.Host.CredentialId,
|
||
row.Host.AsksForPassword is true,
|
||
grouped: row.Host.GroupId is not null);
|
||
|
||
IsEditing = true;
|
||
|
||
// So that saving lands on this host's own pane rather than closing the drawer. The editor is reached
|
||
// from that pane most of the time and the flag is already true; it is not when EDIT was chosen from
|
||
// the grid's context menu, and coming back to a collapsed column after a save reads as the edit
|
||
// having been thrown away. See IsHostPaneOpen.
|
||
IsHostPaneOpen = true;
|
||
Status = $"Editing {row.Label}.";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens whichever editor the selected row belongs to.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// One button over three kinds, because the table is one table. It delegates rather than duplicating:
|
||
/// each kind's own command already knows how to refuse a read-only item and how to load an editor
|
||
/// without a second decryption, and a merged copy of that would be a second place to get it wrong.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void EditSelectedItem()
|
||
{
|
||
switch (SelectedVaultItem?.Kind)
|
||
{
|
||
case VaultItemKind.Key:
|
||
EditSelectedKeyCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.Credential:
|
||
EditSelectedCredentialCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.ObjectStore:
|
||
EditObjectStoreCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.Tag:
|
||
EditTagCommand.Execute(null);
|
||
break;
|
||
|
||
default:
|
||
// A pin has no editor. Its button is Forget, and it is elsewhere on the pane.
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>Asks about deleting whatever the selected row is.</summary>
|
||
/// <remarks>
|
||
/// Pins are not deleted from here even though they can be. Withdrawing trust applies to an endpoint
|
||
/// rather than to a row — every pin for the address goes — and calling that "delete" beside two buttons
|
||
/// that remove exactly one item would misdescribe it. It has its own button, named for what it does.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteSelectedItem()
|
||
{
|
||
switch (SelectedVaultItem?.Kind)
|
||
{
|
||
case VaultItemKind.Key:
|
||
DeleteKeyCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.Credential:
|
||
DeleteCredentialCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.ObjectStore:
|
||
DeleteObjectStoreCommand.Execute(null);
|
||
break;
|
||
|
||
case VaultItemKind.Tag:
|
||
DeleteTagCommand.Execute(null);
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
// AreHostsExpanded and ToggleHosts were here, and they went with the control that used them. They folded
|
||
// the sidebar's whole host list away under its one heading — an affordance that existed because that
|
||
// list was 268 pixels wide and the editor beneath it needed the room. The grid has neither the heading
|
||
// nor the problem. Folding one *group* away is a different thing and is still here: see ToggleGroup.
|
||
|
||
/// <summary>Stores whatever the group name box holds, as a new group or as a rename.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// One box and one button for both, because a group is one field: a separate "rename" form would be the
|
||
/// same text box with a different title. <see cref="EditingGroupId"/> is what decides which of the two
|
||
/// this is, and it is set by <see cref="EditGroup"/> and cleared by everything else.
|
||
/// </para>
|
||
/// <para>
|
||
/// Duplicate names are allowed. Two groups called "staging" are confusing and they are not
|
||
/// <em>wrong</em> — hosts point at ids, so the two are genuinely separate folders — and refusing the
|
||
/// second one would mean a name somebody chose on another machine could block one they choose here, at
|
||
/// the next sync, with the rename already saved.
|
||
/// </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(session.ActiveVaultId, entityId, group, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
await session.HostGroups
|
||
.CreateAsync(session.ActiveVaultId, group, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
ClearGroupEditor();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Saved '{group.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Loads the group being acted on into the box, so saving renames it.</summary>
|
||
/// <remarks>The selected card, or the open group when no card is selected. See <see cref="GroupTarget"/>.</remarks>
|
||
[RelayCommand]
|
||
private void EditGroup()
|
||
{
|
||
if (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;
|
||
GroupEditorLabel = row.Label;
|
||
GroupEditorDefaultPort = row.Group.DefaultPort;
|
||
GroupEditorDefaultUsername = row.Group.DefaultUsername ?? string.Empty;
|
||
|
||
BuildGroupParentChoices(row.EntityId, row.Group.ParentId);
|
||
BuildGroupAuthenticationChoices(row.Group.DefaultSshKeyId, row.Group.DefaultCredentialId);
|
||
|
||
IsEditingGroup = true;
|
||
Status = $"Editing {row.Label}.";
|
||
}
|
||
|
||
/// <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 button, and the button needs a command that takes
|
||
/// 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>
|
||
/// Resolves the heading back to a row rather than trusting it, because a heading carries an id and a
|
||
/// label and the editor needs the record. A heading whose group has gone — the ungrouped heading, or one
|
||
/// deleted by a sync between the list being drawn and the button being pressed — is ignored rather than
|
||
/// opening an editor on nothing.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void EditGroupFromHeading(SidebarGroupHeader? header)
|
||
{
|
||
if (header?.GroupId is not { } groupId
|
||
|| Groups.FirstOrDefault(row => row.EntityId == groupId) is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
SelectedGroup = row;
|
||
EditGroupCommand.Execute(null);
|
||
}
|
||
|
||
/// <summary>Starts a new group, inside whichever one the screen is showing.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The desktop never needed this command: its group editor is a bar that is always on screen, so
|
||
/// "adding" is what happens when nothing has been loaded into it. A phone has to be told, because its
|
||
/// editor is a card that has to be raised — and raising it from a stale state would offer the last
|
||
/// group's default key to the new one without anybody choosing it, which is what
|
||
/// <see cref="ClearGroupEditor"/> prevents.
|
||
/// </para>
|
||
/// <para>
|
||
/// The parent is defaulted after that clearing rather than inside it, and only here. This is the one
|
||
/// path that means "make one", and a group made inside the group that is open is what + NEW GROUP has to
|
||
/// mean now that the cards are one level of a tree — filed at the outermost level it would disappear
|
||
/// from the screen it was made on. The other two callers are a cancel and a save, and neither is asking
|
||
/// for a group anywhere.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The group that is open, and deliberately not the card that is selected</b> — which is where this
|
||
/// differs from <see cref="NewHost"/>. A selected card is what EDIT and DELETE are aimed at; reading it
|
||
/// as "and the next group goes inside it" would nest one because somebody had highlighted something,
|
||
/// while the open group is the screen everybody can see they are on.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void NewGroup()
|
||
{
|
||
IsAddSheetOpen = false;
|
||
|
||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
ClearGroupEditor();
|
||
|
||
// 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>
|
||
/// </remarks>
|
||
private void BuildGroupParentChoices(Guid groupId, Guid? parentId)
|
||
{
|
||
GroupEditorParentChoices.Clear();
|
||
GroupEditorParentChoices.Add(GroupChoice.None);
|
||
|
||
foreach (var candidate in groupItems.Where(item => item.EntityId != groupId))
|
||
{
|
||
var descends = HostInheritance
|
||
.Chain(candidate.EntityId, groupsById)
|
||
.Any(entry => entry.Id == groupId);
|
||
|
||
if (!descends)
|
||
{
|
||
GroupEditorParentChoices.Add(new GroupChoice(candidate.EntityId, candidate.Secret.Label));
|
||
}
|
||
}
|
||
|
||
// A parent that is no longer selectable keeps a placeholder, so that editing a group's default port
|
||
// cannot unparent it as a side effect — the same reason the host's group picker keeps one.
|
||
if (parentId is { } bound && !GroupEditorParentChoices.Any(choice => choice.EntityId == bound))
|
||
{
|
||
GroupEditorParentChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
|
||
}
|
||
|
||
GroupEditorSelectedParent =
|
||
GroupEditorParentChoices.FirstOrDefault(choice => choice.EntityId == parentId)
|
||
?? GroupChoice.None;
|
||
}
|
||
|
||
/// <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;
|
||
|
||
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.</summary>
|
||
/// <remarks>
|
||
/// The count is the whole reason this asks rather than acting. Deleting a group does not delete the hosts
|
||
/// in it and deliberately does not rewrite them either — they keep an id that no longer resolves and turn
|
||
/// up under the ungrouped heading — so what the user needs to know is exactly how many machines are about
|
||
/// to move, and that none of them are going anywhere else.
|
||
/// <para>
|
||
/// Aims where EDIT does: at the selected card, or at the open group when no card is selected. See
|
||
/// <see cref="GroupTarget"/>.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteGroup()
|
||
{
|
||
if (GroupTarget is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.Group,
|
||
row.EntityId,
|
||
$"Delete the group '{row.Label}'?",
|
||
HowFarADeletionGoes("The group"),
|
||
row.HostCount switch
|
||
{
|
||
0 => string.Empty,
|
||
1 => "1 host is filed under it. The host stays; it moves to UNGROUPED.",
|
||
_ => $"{row.HostCount} hosts are filed under it. They stay; they move to UNGROUPED.",
|
||
});
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the group that was agreed to.</summary>
|
||
private async Task DeleteGroupNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Groups.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That group is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.HostGroups
|
||
.DeleteAsync(session.ActiveVaultId, row.EntityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
if (EditingGroupId == entityId)
|
||
{
|
||
EditingGroupId = null;
|
||
GroupEditorLabel = string.Empty;
|
||
}
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Deleted the group '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Abandons the editor.</summary>
|
||
[RelayCommand]
|
||
private void CancelEdit()
|
||
{
|
||
IsEditing = false;
|
||
editingEntityId = null;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Stores the editor's contents, encrypted, and queues it for the server.</summary>
|
||
[RelayCommand]
|
||
private async Task SaveHostAsync(CancellationToken cancellationToken)
|
||
{
|
||
var host = BuildHost();
|
||
|
||
if (!host.TryValidate(out var error))
|
||
{
|
||
Status = error;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (editingEntityId is { } entityId)
|
||
{
|
||
await session.Hosts
|
||
.UpdateAsync(editingHostVaultId, entityId, host, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
editingEntityId = await session.Hosts
|
||
.CreateAsync(editingHostVaultId, host, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
IsEditing = false;
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
||
editingEntityId = null;
|
||
|
||
Status = connection() is null
|
||
? $"Saved '{host.Label}'. It will sync when you are online."
|
||
: $"Saved '{host.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
// Pushed now rather than at the next tick. A change the user just made is the one they are most
|
||
// likely to be about to look for on another machine.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stores several hosts at once, the way one save does.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// For the <c>ssh_config</c> import, which is the only thing that produces hosts in bulk. It goes
|
||
/// through the same repository, the same outbox and the same automatic push as saving one — the import
|
||
/// screen decides <em>which</em> hosts and nothing else, so there is no second way for a host to be
|
||
/// written and no second place for the sync wiring to be forgotten.
|
||
/// </para>
|
||
/// <para>
|
||
/// One reload and one push for the whole batch, rather than per host: thirty saves would otherwise be
|
||
/// thirty rebuilds of the host list and thirty sync passes, which on a slow link is minutes of the
|
||
/// window doing nothing visible.
|
||
/// </para>
|
||
/// <para>
|
||
/// A host that fails validation is skipped and counted rather than aborting the batch. Twenty-nine good
|
||
/// hosts thrown away because the thirtieth had no hostname is not what anybody wants from an import.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal async Task<int> ImportHostsAsync(
|
||
IReadOnlyList<HostSecret> hosts,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(hosts);
|
||
|
||
var imported = 0;
|
||
var refused = 0;
|
||
|
||
await RunAsync(
|
||
hosts.Count == 1 ? "Importing 1 host…" : $"Importing {hosts.Count} hosts…",
|
||
async () =>
|
||
{
|
||
foreach (var host in hosts)
|
||
{
|
||
if (!host.TryValidate(out _))
|
||
{
|
||
refused++;
|
||
continue;
|
||
}
|
||
|
||
await session.Hosts
|
||
.CreateAsync(session.ActiveVaultId, host, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
imported++;
|
||
}
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
var refusals = refused == 0 ? string.Empty : $" {refused} could not be stored and were skipped.";
|
||
|
||
Status = connection() is null
|
||
? $"Imported {imported} host(s). They will sync when you are online.{refusals}"
|
||
: $"Imported {imported} host(s).{refusals}";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
return imported;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens the panel that asks which vault the selected host should move to.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A panel rather than a picker in the host editor, and the reason is what a move is underneath: the
|
||
/// item is re-sealed under another vault's key and the one it came from gets a tombstone — see
|
||
/// <c>VaultItemRepository.MoveAsync</c>. That is not a field of the host and must not be saved with
|
||
/// one, or somebody correcting a port would move a machine into a colleague's vault by leaving a
|
||
/// picker where they found it.
|
||
/// </para>
|
||
/// <para>
|
||
/// Refused for a host written by a newer client, exactly as editing one is: the move re-encodes the
|
||
/// payload, so a field this build cannot represent would be dropped on the way across.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void MoveHost()
|
||
{
|
||
if (SelectedHost is not { } row || AHostEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = "This host was written by a newer version of DodoSSH. Moving it would re-encode it "
|
||
+ "here and lose what this build cannot read. Update first.";
|
||
return;
|
||
}
|
||
|
||
BuildMoveVaultChoices(row.VaultId);
|
||
|
||
if (MoveVaultChoices.Count == 0)
|
||
{
|
||
// The one-vault case, and the honest sentence rather than an empty picker. It is also what
|
||
// somebody in a team whose only other vault is read-only sees.
|
||
Status = $"There is nowhere to move '{row.Label}' to: this is the only vault you can write to.";
|
||
return;
|
||
}
|
||
|
||
// Disarms a deletion aimed at the same host. Two questions about one machine, one of which
|
||
// destroys it, is not a pane anybody should have to read carefully.
|
||
PendingDeletion = null;
|
||
movingHostId = row.EntityId;
|
||
IsMovingHost = true;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Abandons the move panel.</summary>
|
||
[RelayCommand]
|
||
private void CancelMoveHost()
|
||
{
|
||
IsMovingHost = false;
|
||
movingHostId = null;
|
||
MoveVaultChoices.Clear();
|
||
SelectedMoveVault = null;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Moves the selected host into the chosen vault.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The group and the tags are left behind, and that is the whole of what makes this honest.</b> Both
|
||
/// are items of the vault the host is leaving: the group picker in the editor offers one vault's groups
|
||
/// and the tag chips are drawn from one vault's tags, so a host carrying either across would point at
|
||
/// something the destination does not contain. On this machine it would still resolve — groups and tags
|
||
/// are resolved across every readable vault — and for everybody else in the destination it would dangle,
|
||
/// which means the mover and their colleagues would see two different hosts. Cleared and reported beats
|
||
/// carried and invisible.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The key or password binding is kept</b>, and the difference is not inconsistency. Those genuinely
|
||
/// resolve across vaults — one key on twenty hosts in three vaults is the arrangement they exist for —
|
||
/// so clearing them would take a working host and make it one that cannot connect. What it can do is say
|
||
/// when the binding is now in a different vault from the host, because that is exactly what the other
|
||
/// members of the destination will not be able to resolve.
|
||
/// </para>
|
||
/// <para>
|
||
/// The row is re-selected by its new id afterwards. A move that left the pane on a host that no longer
|
||
/// exists would read as the machine having been deleted.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task ConfirmMoveHostAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (SelectedHost is not { } row || SelectedMoveVault is not { } target)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var name = target.Name;
|
||
var dropped = WhatWasLeftBehind(row.Host);
|
||
var stranded = BindingOutside(row.Host, target.VaultId);
|
||
|
||
var moved = row.Host with { GroupId = null, TagIds = TagSet.Empty };
|
||
|
||
IsMovingHost = false;
|
||
movingHostId = null;
|
||
MoveVaultChoices.Clear();
|
||
SelectedMoveVault = null;
|
||
|
||
await RunAsync(
|
||
"Moving…",
|
||
async () =>
|
||
{
|
||
var entityId = await session.Hosts
|
||
.MoveAsync(row.VaultId, target.VaultId, row.EntityId, moved, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
SelectedHost = Hosts.FirstOrDefault(host => host.EntityId == entityId);
|
||
|
||
Status = $"Moved '{row.Label}' to {name}.{dropped}{stranded}";
|
||
}).ConfigureAwait(true);
|
||
|
||
// As a save and a deletion do. A move is two writes in two vaults, and a machine that syncs one of
|
||
// them and not the other shows the host twice or not at all until the next pass.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <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 warning about a key or password that is not in the vault the host has moved to.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Named rather than counted, because which one it is decides what to do about it — and the answer is
|
||
/// usually to put a copy of that key in the destination vault, which needs to know which key.
|
||
/// </remarks>
|
||
private string BindingOutside(HostSecret host, Guid vaultId)
|
||
{
|
||
if (host.SshKeyId is { } keyId
|
||
&& Keys.FirstOrDefault(row => row.EntityId == keyId) is { } key
|
||
&& key.VaultId != vaultId)
|
||
{
|
||
return $" It still authenticates with the key '{key.Label}', which is in another vault — "
|
||
+ "everybody else in this one will find that binding unresolvable.";
|
||
}
|
||
|
||
if (host.CredentialId is { } credentialId
|
||
&& Credentials.FirstOrDefault(row => row.EntityId == credentialId) is { } credential
|
||
&& credential.VaultId != vaultId)
|
||
{
|
||
return $" It still authenticates with the password '{credential.Label}', which is in another "
|
||
+ "vault — everybody else in this one will find that binding unresolvable.";
|
||
}
|
||
|
||
return string.Empty;
|
||
}
|
||
|
||
/// <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 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)))
|
||
{
|
||
MoveVaultChoices.Add(choice);
|
||
}
|
||
|
||
SelectedMoveVault = MoveVaultChoices.FirstOrDefault();
|
||
}
|
||
|
||
/// <summary>Asks whether the selected host should go.</summary>
|
||
/// <remarks>
|
||
/// A terminal already open on the host is disclosed rather than prevented, because deleting a host does
|
||
/// not close one — a session outlives the row that opened it, exactly as it outlives a lock. Somebody
|
||
/// deleting a machine they are still working on should know that is what they have done.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteHost()
|
||
{
|
||
if (SelectedHost is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.Host,
|
||
row.EntityId,
|
||
$"Delete the host '{row.Label}'?",
|
||
HowFarADeletionGoes("The host and everything saved about it"),
|
||
row.IsConnected
|
||
? "A terminal is open on this host. It stays open — deleting the host does not close it, and "
|
||
+ "nothing will reopen it afterwards."
|
||
: string.Empty);
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the host that was agreed to.</summary>
|
||
private async Task DeleteHostNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
// Gone between the question and the answer — a sync that pulled somebody else's deletion is the
|
||
// realistic way. Saying so beats a silent no-op under a card that has just been agreed to.
|
||
Status = "That host is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.Hosts
|
||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Deleted '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
// As with saving: a tombstone is worth pushing straight away, so the item does not reappear on
|
||
// another machine that syncs before the next tick.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Starts a new SSH key.</summary>
|
||
[RelayCommand]
|
||
private void NewKey()
|
||
{
|
||
if (AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Keys;
|
||
editingKeyId = null;
|
||
editingKeyVaultId = TargetVaultId;
|
||
ClearKeyEditor();
|
||
IsEditingKey = true;
|
||
Status = "Adding an SSH key.";
|
||
}
|
||
|
||
/// <summary>Opens the selected key for editing.</summary>
|
||
/// <remarks>
|
||
/// The private key is loaded into the editor, which is the only way an edit can preserve it: the
|
||
/// codec has no notion of a partial update, so saving re-encodes every field.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void EditSelectedKey()
|
||
{
|
||
if (SelectedKey is not { } row || AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = "This key was written by a newer version of DodoSSH. Update before editing it.";
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Keys;
|
||
editingKeyId = row.EntityId;
|
||
editingKeyVaultId = row.VaultId;
|
||
KeyEditorLabel = row.Key.Label;
|
||
KeyEditorPrivateKey = row.Key.PrivateKeyPem;
|
||
KeyEditorPassphrase = row.Key.Passphrase ?? string.Empty;
|
||
KeyEditorPublicKey = row.Key.PublicKey ?? string.Empty;
|
||
KeyEditorNotes = row.Key.Notes ?? string.Empty;
|
||
IsEditingKey = true;
|
||
Status = $"Editing {row.Label}.";
|
||
}
|
||
|
||
/// <summary>Opens the form in front of generating a key.</summary>
|
||
[RelayCommand]
|
||
private void NewGeneratedKey()
|
||
{
|
||
if (AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Keys;
|
||
GenerateComment = $"{Environment.UserName}@{Environment.MachineName}";
|
||
GenerateAlgorithm = SshKeyAlgorithm.Ed25519;
|
||
IsGeneratingKey = true;
|
||
Status = "Generating a new SSH key.";
|
||
}
|
||
|
||
/// <summary>Chooses which kind of key to make.</summary>
|
||
/// <remarks>
|
||
/// A command and two buttons rather than a selector bound to <see cref="GenerateAlgorithm"/>, which is
|
||
/// the idiom the category rail already uses and for the same reason: a selector moves its own highlight
|
||
/// before anything here can decide, so it can end up showing a choice that was not made.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void ChooseKeyAlgorithm(SshKeyAlgorithm algorithm) => GenerateAlgorithm = algorithm;
|
||
|
||
/// <summary>Abandons the generate form without making anything.</summary>
|
||
[RelayCommand]
|
||
private void CancelGenerateKey()
|
||
{
|
||
IsGeneratingKey = false;
|
||
GenerateComment = string.Empty;
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Makes a new key pair and drops it into the key editor, unsaved.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>It does not save.</b> What comes back lands in the editor and waits for SAVE, so the whole
|
||
/// storage path — validation, encoding, the outbox, the push — is the one that already exists and this
|
||
/// command has no second version of it. It also means a generated key can be renamed or annotated
|
||
/// before it is written, and abandoned by pressing CANCEL.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Off the UI thread.</b> RSA at 4096 bits is seconds of solid CPU, which on this thread is a frozen
|
||
/// window at the moment somebody is watching it — the same reason key derivation runs on a worker. The
|
||
/// generator does not know which algorithm is cheap, and neither should this.
|
||
/// </para>
|
||
/// <para>
|
||
/// The private key exists in memory from here until the editor is cleared, as a pasted one does. See
|
||
/// <c>SshKeySecret</c> for why a .NET string is the honest choice for that and what it does not buy.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task GenerateKeyAsync(CancellationToken cancellationToken)
|
||
{
|
||
var algorithm = GenerateAlgorithm;
|
||
var comment = string.IsNullOrWhiteSpace(GenerateComment)
|
||
? $"{Environment.UserName}@{Environment.MachineName}"
|
||
: GenerateComment.Trim();
|
||
|
||
var kind = algorithm is SshKeyAlgorithm.Rsa4096 ? "RSA 4096-bit" : "Ed25519";
|
||
|
||
await RunAsync(
|
||
$"Generating a {kind} key…",
|
||
async () =>
|
||
{
|
||
var generated = await Task
|
||
.Run(() => SshKeyGenerator.Generate(algorithm, comment), cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
IsGeneratingKey = false;
|
||
editingKeyId = null;
|
||
|
||
// Filed where a pasted key would be, and set here rather than left over from whatever was
|
||
// edited last: this path opens the same editor without going through NewKey, so without
|
||
// this a key generated after editing a team's key would be saved into that team's vault.
|
||
editingKeyVaultId = TargetVaultId;
|
||
|
||
ClearKeyEditor();
|
||
|
||
KeyEditorLabel = comment;
|
||
KeyEditorPrivateKey = generated.PrivateKeyArmour;
|
||
KeyEditorPublicKey = generated.PublicKeyLine;
|
||
KeyEditorNotes = $"Generated by DodoSSH. {generated.Fingerprint}";
|
||
|
||
IsEditingKey = true;
|
||
|
||
Status = $"Generated {generated.Fingerprint}. Nothing is stored until you press SAVE.";
|
||
}).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Puts the selected key's public half on the clipboard.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The public half only, and there is deliberately no command for the other one. Installing a key means
|
||
/// pasting this line into a host's <c>authorized_keys</c>; a private key on a clipboard is a private key
|
||
/// in every application on the machine and in whatever syncs it between them.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task CopyPublicKeyAsync()
|
||
{
|
||
if (SelectedKey is not { } row)
|
||
{
|
||
Status = "Choose a key first.";
|
||
return;
|
||
}
|
||
|
||
if (row.Key.PublicKey is not { Length: > 0 } line)
|
||
{
|
||
// Not derivable here: SshKeySecret stores whatever armour it was given and declines to parse
|
||
// it, so a key imported without its .pub has no public half to offer. Saying so beats copying
|
||
// an empty string.
|
||
Status = $"'{row.Label}' has no public half stored. Paste it into the key's editor to keep it.";
|
||
return;
|
||
}
|
||
|
||
if (copyToClipboard is null)
|
||
{
|
||
Status = "This machine has no clipboard.";
|
||
return;
|
||
}
|
||
|
||
await copyToClipboard(line).ConfigureAwait(true);
|
||
|
||
Status = $"Copied the public key for '{row.Label}'. Add it to the host's ~/.ssh/authorized_keys.";
|
||
}
|
||
|
||
/// <summary>Abandons the key editor, clearing the material out of it.</summary>
|
||
[RelayCommand]
|
||
private void CancelKeyEdit()
|
||
{
|
||
IsEditingKey = false;
|
||
editingKeyId = null;
|
||
ClearKeyEditor();
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Stores the key editor's contents, encrypted, and queues it for the server.</summary>
|
||
[RelayCommand]
|
||
private async Task SaveKeyAsync(CancellationToken cancellationToken)
|
||
{
|
||
var key = BuildKey();
|
||
|
||
if (!key.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (editingKeyId is { } entityId)
|
||
{
|
||
await session.SshKeys
|
||
.UpdateAsync(editingKeyVaultId, entityId, key, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
editingKeyId = await session.SshKeys
|
||
.CreateAsync(editingKeyVaultId, key, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
IsEditingKey = false;
|
||
ClearKeyEditor();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == editingKeyId);
|
||
editingKeyId = null;
|
||
|
||
Status = connection() is null
|
||
? $"Saved '{key.Label}'. It will sync when you are online."
|
||
: $"Saved '{key.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Asks whether the selected key should go.</summary>
|
||
/// <remarks>
|
||
/// The private key is the thing this vault holds that is least likely to exist anywhere else, which is
|
||
/// why the question says so. What it does not say is that the key is gone from the machines it was
|
||
/// installed on: deleting it here removes this vault's copy, and the <c>authorized_keys</c> file on a
|
||
/// server is not something this application has ever written to.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteKey()
|
||
{
|
||
if (SelectedKey is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.Key,
|
||
row.EntityId,
|
||
$"Delete the SSH key '{row.Label}'?",
|
||
HowFarADeletionGoes("The private key, its passphrase and everything saved with them")
|
||
+ " If this key is not on disk anywhere else, this is the only copy.",
|
||
HostsBoundTo(ResolvedBindingKind.SshKey, row.EntityId));
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the key that was agreed to.</summary>
|
||
private async Task DeleteKeyNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Keys.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That key is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.SshKeys
|
||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Deleted '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Starts a new credential.</summary>
|
||
[RelayCommand]
|
||
private void NewCredential()
|
||
{
|
||
if (AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Credentials;
|
||
editingCredentialId = null;
|
||
editingCredentialVaultId = TargetVaultId;
|
||
ClearCredentialEditor();
|
||
IsEditingCredential = true;
|
||
Status = "Adding a credential.";
|
||
}
|
||
|
||
/// <summary>Opens the selected credential for editing.</summary>
|
||
/// <remarks>
|
||
/// The password is loaded into the editor, as the private key is and for the same reason: the codec has no
|
||
/// notion of a partial update, so saving re-encodes every field.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void EditSelectedCredential()
|
||
{
|
||
if (SelectedCredential is not { } row || AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = "This credential was written by a newer version of DodoSSH. Update before editing it.";
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Credentials;
|
||
editingCredentialId = row.EntityId;
|
||
editingCredentialVaultId = row.VaultId;
|
||
CredentialEditorLabel = row.Credential.Label;
|
||
CredentialEditorUsername = row.Credential.Username ?? string.Empty;
|
||
CredentialEditorPassword = row.Credential.Password;
|
||
CredentialEditorNotes = row.Credential.Notes ?? string.Empty;
|
||
IsEditingCredential = true;
|
||
Status = $"Editing {row.Label}.";
|
||
}
|
||
|
||
/// <summary>Starts a new bucket.</summary>
|
||
/// <remarks>
|
||
/// Path-style addressing starts off, which is the AWS default — and <see cref="EditObjectStore"/> loads
|
||
/// whatever was stored. Somebody adding a self-hosted bucket turns it on, and the field says why.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void NewObjectStore()
|
||
{
|
||
if (AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Buckets;
|
||
editingObjectStoreId = null;
|
||
ClearObjectStoreEditor();
|
||
IsEditingObjectStore = true;
|
||
Status = "Adding a bucket.";
|
||
}
|
||
|
||
/// <summary>Opens the selected bucket for editing.</summary>
|
||
[RelayCommand]
|
||
private void EditObjectStore()
|
||
{
|
||
if (SelectedObjectStore is not { } row || AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = "This bucket was written by a newer version of DodoSSH. Update before editing it.";
|
||
return;
|
||
}
|
||
|
||
Section = VaultSection.Buckets;
|
||
editingObjectStoreId = row.EntityId;
|
||
BucketEditorLabel = row.Store.Label;
|
||
BucketEditorBucket = row.Store.Bucket;
|
||
BucketEditorAccessKeyId = row.Store.AccessKeyId;
|
||
BucketEditorSecretAccessKey = row.Store.SecretAccessKey;
|
||
BucketEditorRegion = row.Store.Region ?? string.Empty;
|
||
BucketEditorEndpoint = row.Store.Endpoint ?? string.Empty;
|
||
BucketEditorUsePathStyle = row.Store.UsePathStyle;
|
||
BucketEditorNotes = row.Store.Notes ?? string.Empty;
|
||
IsEditingObjectStore = true;
|
||
Status = $"Editing {row.Label}.";
|
||
}
|
||
|
||
/// <summary>Abandons the bucket editor, clearing the secret access key out of it.</summary>
|
||
[RelayCommand]
|
||
private void CancelObjectStoreEdit()
|
||
{
|
||
IsEditingObjectStore = false;
|
||
editingObjectStoreId = null;
|
||
ClearObjectStoreEditor();
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Stores the bucket editor's contents, encrypted, and queues it for the server.</summary>
|
||
[RelayCommand]
|
||
private async Task SaveObjectStoreAsync(CancellationToken cancellationToken)
|
||
{
|
||
var store = BuildObjectStore();
|
||
|
||
if (!store.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (editingObjectStoreId is { } entityId)
|
||
{
|
||
await session.ObjectStores
|
||
.UpdateAsync(session.ActiveVaultId, entityId, store, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
editingObjectStoreId = await session.ObjectStores
|
||
.CreateAsync(session.ActiveVaultId, store, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
IsEditingObjectStore = false;
|
||
ClearObjectStoreEditor();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
SelectedObjectStore = ObjectStores
|
||
.FirstOrDefault(row => row.EntityId == editingObjectStoreId);
|
||
editingObjectStoreId = null;
|
||
|
||
Status = connection() is null
|
||
? $"Saved '{store.Label}'. It will sync when you are online."
|
||
: $"Saved '{store.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Starts a new tag from the keychain screen.</summary>
|
||
/// <remarks>
|
||
/// The second way to make one. The first — the host editor's box — is where it usually happens, because
|
||
/// wanting a tag and tagging a host are the same moment. This is for the times they are not: setting up
|
||
/// a scheme before there is anything to put it on.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void NewTag()
|
||
{
|
||
if (AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
editingTagId = null;
|
||
|
||
// The active vault, not the "new items go to" picker, and this is the one place the difference
|
||
// bites. Keys and credentials are read across every readable vault, so filing one into a team's is
|
||
// safe — it comes back in the list. Tags are read like groups and buckets are: the editable list is
|
||
// the active vault's alone. A tag filed anywhere else would be created, queued for push, reported
|
||
// as added, and then invisible — no row, no count, no entry in any host editor's picker, and
|
||
// nothing on this screen able to rename or delete it, because there is no active-vault switcher to
|
||
// go and find it with. NewObjectStore ignores the picker for exactly this reason.
|
||
editingTagVaultId = session.ActiveVaultId;
|
||
TagEditorLabel = string.Empty;
|
||
IsEditingTag = true;
|
||
Status = "Adding a tag.";
|
||
}
|
||
|
||
/// <summary>Loads the selected tag's name into the box, so saving renames it.</summary>
|
||
[RelayCommand]
|
||
private void EditTag()
|
||
{
|
||
if (SelectedTag is not { } row || AVaultEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = "This tag was written by a newer version of DodoSSH. Update before editing it.";
|
||
return;
|
||
}
|
||
|
||
editingTagId = row.EntityId;
|
||
editingTagVaultId = session.ActiveVaultId;
|
||
TagEditorLabel = row.Label;
|
||
IsEditingTag = true;
|
||
Status = $"Renaming {row.Label}.";
|
||
}
|
||
|
||
/// <summary>Abandons the tag editor.</summary>
|
||
[RelayCommand]
|
||
private void CancelTagEdit()
|
||
{
|
||
IsEditingTag = false;
|
||
editingTagId = null;
|
||
TagEditorLabel = string.Empty;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Stores the tag in the box, encrypted, and queues it for the server.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <b>A rename is one write, and that is the whole reason this type exists.</b> Every host wearing the
|
||
/// tag names its id, so none of them is touched — which is what makes renaming safe to do casually and
|
||
/// what a string repeated inside twenty payloads could never have offered. See <see cref="TagSecret"/>.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task SaveTagAsync(CancellationToken cancellationToken)
|
||
{
|
||
var tag = new TagSecret { Label = TagEditorLabel.Trim() };
|
||
|
||
if (!tag.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
var renaming = editingTagId;
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (renaming is { } entityId)
|
||
{
|
||
await session.Tags
|
||
.UpdateAsync(editingTagVaultId, entityId, tag, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
await session.Tags
|
||
.CreateAsync(editingTagVaultId, tag, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
CancelTagEdit();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = renaming is null
|
||
? $"Added the tag '{tag.Label}'."
|
||
: $"Renamed to '{tag.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Asks whether the selected tag should go.</summary>
|
||
/// <remarks>
|
||
/// The consequence is worth counting rather than describing. Deleting a tag does not touch the hosts
|
||
/// wearing it — rewriting N payloads inside one delete is what keeping membership on the host exists to
|
||
/// avoid — so what actually happens is that N rows lose a chip, and the number is the difference
|
||
/// between a tidy-up and losing a filter somebody relies on.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteTag()
|
||
{
|
||
if (SelectedTag is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.Tag,
|
||
row.EntityId,
|
||
$"Delete the tag '{row.Label}'?",
|
||
HowFarADeletionGoes("The tag"),
|
||
row.HostCount == 0
|
||
? string.Empty
|
||
: row.HostCount == 1
|
||
? "1 host wears it and will simply stop showing the chip. Nothing else about it changes."
|
||
: $"{row.HostCount} hosts wear it and will simply stop showing the chip. Nothing else "
|
||
+ "about them changes.");
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the tag that was agreed to.</summary>
|
||
private async Task DeleteTagNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Tags.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That tag is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.Tags
|
||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
if (editingTagId == entityId)
|
||
{
|
||
CancelTagEdit();
|
||
}
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = $"Deleted the tag '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Asks whether the selected bucket should go.</summary>
|
||
/// <remarks>
|
||
/// The bucket itself is untouched, and the question says so. Removing the entry here removes this
|
||
/// keychain's way of reaching it — the objects in it are somebody else's to delete, and a confirmation
|
||
/// that did not distinguish the two would be genuinely frightening.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteObjectStore()
|
||
{
|
||
if (SelectedObjectStore is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.ObjectStore,
|
||
row.EntityId,
|
||
$"Remove the bucket '{row.Label}'?",
|
||
HowFarADeletionGoes("The bucket's address and its keys"),
|
||
"Nothing in the bucket is touched. This removes the way this keychain reaches it, not the "
|
||
+ "objects in it.");
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the bucket that was agreed to.</summary>
|
||
private async Task DeleteObjectStoreNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (ObjectStores.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That bucket is no longer here, so nothing was removed.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Removing…",
|
||
async () =>
|
||
{
|
||
await session.ObjectStores
|
||
.DeleteAsync(session.ActiveVaultId, entityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Removed '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Empties the bucket editor, including the secret access key.</summary>
|
||
private void ClearObjectStoreEditor()
|
||
{
|
||
BucketEditorLabel = string.Empty;
|
||
BucketEditorBucket = string.Empty;
|
||
BucketEditorAccessKeyId = string.Empty;
|
||
BucketEditorSecretAccessKey = string.Empty;
|
||
BucketEditorRegion = string.Empty;
|
||
BucketEditorEndpoint = string.Empty;
|
||
BucketEditorUsePathStyle = false;
|
||
BucketEditorNotes = string.Empty;
|
||
}
|
||
|
||
private ObjectStoreSecret BuildObjectStore() =>
|
||
new()
|
||
{
|
||
Label = BucketEditorLabel.Trim(),
|
||
Bucket = BucketEditorBucket.Trim(),
|
||
|
||
// Trimmed, both of them. A pasted access key with a trailing newline signs every request wrongly
|
||
// and the service answers "SignatureDoesNotMatch", which names neither the field nor the paste.
|
||
AccessKeyId = BucketEditorAccessKeyId.Trim(),
|
||
SecretAccessKey = BucketEditorSecretAccessKey.Trim(),
|
||
|
||
// Blank is a real answer for both — no region, or no custom endpoint — and is stored as null so
|
||
// that ObjectStoreSecret can tell "not set" from "set to nothing".
|
||
Region = string.IsNullOrWhiteSpace(BucketEditorRegion) ? null : BucketEditorRegion.Trim(),
|
||
Endpoint = string.IsNullOrWhiteSpace(BucketEditorEndpoint)
|
||
? null
|
||
: BucketEditorEndpoint.Trim().TrimEnd('/'),
|
||
UsePathStyle = BucketEditorUsePathStyle,
|
||
Notes = string.IsNullOrWhiteSpace(BucketEditorNotes) ? null : BucketEditorNotes,
|
||
};
|
||
|
||
/// <summary>Abandons the credential editor, clearing the password out of it.</summary>
|
||
[RelayCommand]
|
||
private void CancelCredentialEdit()
|
||
{
|
||
IsEditingCredential = false;
|
||
editingCredentialId = null;
|
||
ClearCredentialEditor();
|
||
Status = string.Empty;
|
||
}
|
||
|
||
/// <summary>Stores the credential editor's contents, encrypted, and queues it for the server.</summary>
|
||
[RelayCommand]
|
||
private async Task SaveCredentialAsync(CancellationToken cancellationToken)
|
||
{
|
||
var credential = BuildCredential();
|
||
|
||
if (!credential.TryValidate(out var reason))
|
||
{
|
||
Status = reason;
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Saving…",
|
||
async () =>
|
||
{
|
||
if (editingCredentialId is { } entityId)
|
||
{
|
||
await session.Credentials
|
||
.UpdateAsync(editingCredentialVaultId, entityId, credential, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
else
|
||
{
|
||
editingCredentialId = await session.Credentials
|
||
.CreateAsync(editingCredentialVaultId, credential, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
IsEditingCredential = false;
|
||
ClearCredentialEditor();
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
SelectedCredential = Credentials
|
||
.FirstOrDefault(row => row.EntityId == editingCredentialId);
|
||
editingCredentialId = null;
|
||
|
||
Status = connection() is null
|
||
? $"Saved '{credential.Label}'. It will sync when you are online."
|
||
: $"Saved '{credential.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Asks whether the selected credential should go.</summary>
|
||
[RelayCommand]
|
||
private void DeleteCredential()
|
||
{
|
||
if (SelectedCredential is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = new DeletionRequest(
|
||
DeletionTarget.Credential,
|
||
row.EntityId,
|
||
$"Delete the password '{row.Label}'?",
|
||
HowFarADeletionGoes("The password and the account saved with it"),
|
||
HostsBoundTo(ResolvedBindingKind.Credential, row.EntityId));
|
||
}
|
||
|
||
/// <summary>Queues a tombstone for the credential that was agreed to.</summary>
|
||
private async Task DeleteCredentialNowAsync(Guid entityId, CancellationToken cancellationToken)
|
||
{
|
||
if (Credentials.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
Status = "That password is no longer here, so nothing was deleted.";
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
"Deleting…",
|
||
async () =>
|
||
{
|
||
await session.Credentials
|
||
.DeleteAsync(row.VaultId, row.EntityId, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
Status = $"Deleted '{row.Label}'.";
|
||
}).ConfigureAwait(true);
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Carries out the deletion that was asked about.</summary>
|
||
/// <remarks>
|
||
/// Disarmed before the work rather than after it, so that the card goes the moment it is answered and a
|
||
/// second press during a slow round trip has nothing left to agree to.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task ConfirmDeleteAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (PendingDeletion is not { } request)
|
||
{
|
||
return;
|
||
}
|
||
|
||
PendingDeletion = null;
|
||
|
||
switch (request.Target)
|
||
{
|
||
case DeletionTarget.Host:
|
||
await DeleteHostNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
case DeletionTarget.Key:
|
||
await DeleteKeyNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
case DeletionTarget.Credential:
|
||
await DeleteCredentialNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
case DeletionTarget.Group:
|
||
await DeleteGroupNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
case DeletionTarget.ObjectStore:
|
||
await DeleteObjectStoreNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
case DeletionTarget.Tag:
|
||
await DeleteTagNowAsync(request.EntityId, cancellationToken).ConfigureAwait(true);
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>Thinks better of it.</summary>
|
||
[RelayCommand]
|
||
private void CancelDelete() => PendingDeletion = null;
|
||
|
||
/// <summary>
|
||
/// Where a deleted item goes, and how far.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The offline branch is the same distinction saving makes, and it matters more here: a tombstone that
|
||
/// has not been pushed is a deletion the other machines have not heard about, and somebody deleting a
|
||
/// credential because it leaked should be told which of those two they have just done.
|
||
/// </remarks>
|
||
private string HowFarADeletionGoes(string what) => connection() is null
|
||
? $"{what} goes from this machine now, and from your other machines once this one is online again. "
|
||
+ "There is no undo."
|
||
: $"{what} goes from this machine now, and from your other machines at the next synchronisation. "
|
||
+ "There is no undo.";
|
||
|
||
/// <summary>
|
||
/// What the hosts that authenticate with an item would be left with, or nothing when none do.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Counted rather than warned about in general terms. The number is the difference between a sentence
|
||
/// somebody reads and one they click past, and what happens next is worth stating exactly: a host bound
|
||
/// to something the vault no longer has is refused at connect time rather than quietly falling back to a
|
||
/// typed password — see <see cref="TryBuildAuthentication" />.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Counted over the resolved binding, so a group's default is counted too.</b> Reading each host's own
|
||
/// ids would miss a key that only a group names — which is the worst case rather than an edge one: a key
|
||
/// bound once on a group and inherited by twenty hosts would warn about nobody, be deleted, and then
|
||
/// refuse all twenty at connect time.
|
||
/// </para>
|
||
/// </remarks>
|
||
private string HostsBoundTo(ResolvedBindingKind kind, Guid entityId)
|
||
{
|
||
var bound = Hosts
|
||
.Where(row => row.Resolved.Binding is { } binding
|
||
&& binding.Kind == kind
|
||
&& binding.EntityId == entityId)
|
||
.Select(row => row.Label)
|
||
.ToArray();
|
||
|
||
if (bound.Length == 0)
|
||
{
|
||
return string.Empty;
|
||
}
|
||
|
||
// Three names and a count past that, because this is read in a 244-pixel column and a vault with
|
||
// twenty hosts on one key would otherwise put a paragraph of names where a warning should be.
|
||
var named = bound.Length <= 3
|
||
? string.Join(", ", bound)
|
||
: $"{string.Join(", ", bound.Take(3))} and {bound.Length - 3} more";
|
||
|
||
return bound.Length == 1
|
||
? $"{named} authenticates with it, and will refuse to connect rather than fall back to a typed "
|
||
+ "password."
|
||
: $"{bound.Length} hosts authenticate with it — {named} — and will refuse to connect rather than "
|
||
+ "fall back to a typed password.";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Withdraws trust from the selected pin's endpoint.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Goes through the same <c>ForgetAsync</c> as the host editor's button, which withdraws every pin for
|
||
/// the endpoint rather than the one row that was selected. That is deliberate and not a shortcut: trust
|
||
/// is about an address, a second pin for the same address under another algorithm would go on being
|
||
/// offered at the next handshake, and a user who has decided to stop trusting a machine has not decided
|
||
/// to stop trusting one of its keys. The status line says how many went.
|
||
/// </para>
|
||
/// <para>
|
||
/// No confirmation. Withdrawing trust costs one fingerprint check on the next connection, and it is the
|
||
/// safe direction to be wrong in — the dangerous button is the one that adds trust, and that one is the
|
||
/// prompt at connect time.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task ForgetPinAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (SelectedKnownHost is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await RunAsync(
|
||
$"Forgetting the pinned host key for {row.Host}…",
|
||
async () =>
|
||
{
|
||
var forgotten = await knownHosts
|
||
.ForgetAsync(row.Host, row.Port, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// A mismatch the user was staring at is about a pin that may have just gone.
|
||
HostKeyMismatch = null;
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = forgotten == 1
|
||
? $"Forgot the pinned key for {row.Host}:{row.Port}."
|
||
: $"Forgot {forgotten} pinned key(s) for {row.Host}:{row.Port}.";
|
||
}).ConfigureAwait(true);
|
||
|
||
// Pushed straight away, as trusting is: the other machines are the ones still refusing to connect to
|
||
// a server that has been rebuilt.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens a terminal on the selected host.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Deliberately not inside <see cref="RunAsync"/>, unlike every other command here.</b> That gate is
|
||
/// what makes the vault do one thing at a time, and connecting is the one operation that must not hold
|
||
/// it: a handshake is a network round trip against a machine that may be asleep, and holding the gate
|
||
/// for it means a window in which nothing else can be saved, edited or even connected to. The strip
|
||
/// carries the feedback instead — <see cref="ConnectionStarting"/> puts a tab there before anything is
|
||
/// dialled — so the wait is visible without being in the way. Everything <c>RunAsync</c> would have done
|
||
/// for the failures is done by <see cref="OpenSessionAsync"/>, which reports every one of them.
|
||
/// </para>
|
||
/// <para>
|
||
/// Several connections can therefore be in flight at once, which is why an attempt has an id and why
|
||
/// this command allows concurrent executions. That is a feature rather than a tolerated race: opening
|
||
/// three machines is one of the ordinary things to do with a tabbed client, and it used to mean waiting
|
||
/// for each in turn. Without the flag the generated command refuses a second call outright while the
|
||
/// first is running — silently, as a no-op — which would be the old one-at-a-time behaviour with none of
|
||
/// the explanation.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>It takes no cancellation token, and that is what makes the flag above mean anything.</b> A
|
||
/// <c>[RelayCommand]</c> over a method that takes one generates a command which cancels the previous
|
||
/// execution's token every time it is invoked — so a second connection would quietly abandon the first,
|
||
/// which is the exact opposite of what opening two machines at once is supposed to do. Measured: the
|
||
/// first tab disappeared with "Cancelled." the instant the second was asked for. What is given up by not
|
||
/// having one is a way to abort a handshake from here; closing the tab is that, and the session it
|
||
/// abandons is adopted rather than lost. See <c>MainWindowViewModel.CloseTabAsync</c>.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||
private Task ConnectAsync() => ConnectToSelectedHostAsync(CancellationToken.None);
|
||
|
||
/// <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;
|
||
}
|
||
|
||
await ConnectToAsync(
|
||
new ConnectionTarget(row.Label, row.Host.Hostname, row.Resolved.Port.Value, row),
|
||
authentication,
|
||
cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Opens a terminal on somewhere that is not in the keychain.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
||
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
||
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
||
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
||
/// been given the address of.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
||
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
||
/// written there at length. A key is a reason to save the host.
|
||
/// </para>
|
||
/// <para>
|
||
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
||
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||
/// </para>
|
||
/// <para>
|
||
/// It takes no cancellation token and allows concurrent executions, for the two reasons
|
||
/// <see cref="ConnectAsync"/> carries.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||
private Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
||
|
||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
||
{
|
||
ManualStatus = refusal;
|
||
return;
|
||
}
|
||
|
||
if (ManualPassword.Length == 0)
|
||
{
|
||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
||
return;
|
||
}
|
||
|
||
ManualStatus = string.Empty;
|
||
|
||
await ConnectToAsync(
|
||
// Labelled by what was typed rather than by the hostname alone. Two accounts on one box are two
|
||
// different connections, and a strip showing the same name twice would be the tab equivalent of
|
||
// the log entry this also names.
|
||
new ConnectionTarget(
|
||
$"{endpoint.Username}@{endpoint.Hostname}",
|
||
endpoint.Hostname,
|
||
endpoint.Port),
|
||
new HostAuthentication(endpoint.Username, new SshPasswordCredential(ManualPassword)),
|
||
cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Reads <c>user@host</c>, with an optional <c>:port</c>, or says why it cannot.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The username is required rather than defaulted to this device's account name, which is what
|
||
/// <c>ssh</c> itself would do. A phone has no account name worth borrowing — the value there is the
|
||
/// Android user, which is never a login on anything — so a default would be a guess that fails at the
|
||
/// remote with "authentication failed" rather than here with a sentence.
|
||
/// </para>
|
||
/// <para>
|
||
/// The port defaults to 22 and refuses anything outside 1–65535, which is the range
|
||
/// <c>HostSecret.TryValidate</c> already enforces for a saved host. A target that cannot be stored is
|
||
/// not one this path should be able to dial either.
|
||
/// </para>
|
||
/// <para>
|
||
/// IPv6 in brackets is not accepted, and the refusal says so rather than silently reading
|
||
/// <c>::1</c>'s last colon as a port separator. Nothing else in this application accepts a bracketed
|
||
/// address — <c>HostSecret.Hostname</c> is a bare string dialled as it stands — so accepting one here
|
||
/// would make this the only field in the product with its own address grammar.
|
||
/// </para>
|
||
/// </remarks>
|
||
private static bool TryParseManualTarget(
|
||
string typed,
|
||
[NotNullWhen(true)] out ManualEndpoint? endpoint,
|
||
[NotNullWhen(false)] out string? reason)
|
||
{
|
||
endpoint = null;
|
||
|
||
var trimmed = typed.Trim();
|
||
|
||
if (trimmed.Length == 0)
|
||
{
|
||
reason = "Type a machine to connect to, as user@host.";
|
||
return false;
|
||
}
|
||
|
||
if (trimmed.Contains('[', StringComparison.Ordinal))
|
||
{
|
||
reason = "A bracketed IPv6 address is not accepted here. Save it as a host instead.";
|
||
return false;
|
||
}
|
||
|
||
var at = trimmed.LastIndexOf('@');
|
||
|
||
if (at <= 0 || at == trimmed.Length - 1)
|
||
{
|
||
reason = "Say who to log in as: user@host, or user@host:port.";
|
||
return false;
|
||
}
|
||
|
||
var username = trimmed[..at];
|
||
var host = trimmed[(at + 1)..];
|
||
var port = 22;
|
||
|
||
if (host.LastIndexOf(':') is var colon && colon >= 0)
|
||
{
|
||
if (!int.TryParse(
|
||
host[(colon + 1)..],
|
||
NumberStyles.None,
|
||
CultureInfo.InvariantCulture,
|
||
out port)
|
||
|| port is < 1 or > 65535)
|
||
{
|
||
reason = "The port has to be a number between 1 and 65535.";
|
||
return false;
|
||
}
|
||
|
||
host = host[..colon];
|
||
}
|
||
|
||
if (host.Length == 0)
|
||
{
|
||
reason = "Say which machine: user@host, or user@host:port.";
|
||
return false;
|
||
}
|
||
|
||
endpoint = new ManualEndpoint(username, host, port);
|
||
reason = null;
|
||
return true;
|
||
}
|
||
|
||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||
/// <remarks>
|
||
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
||
/// this level — its account comes out of <see cref="TryBuildAuthentication"/>, possibly from a bound
|
||
/// credential rather than from the host — so a username on the shared record would be a field that is
|
||
/// null for every connection but this one.
|
||
/// </remarks>
|
||
private sealed record ManualEndpoint(string Username, string Hostname, int Port);
|
||
|
||
/// <summary>Everything both connect paths share, from the tab appearing to the session opening.</summary>
|
||
/// <remarks>
|
||
/// One method rather than two, and it is the same argument <see cref="TryBuildConnectionRequest"/> makes
|
||
/// about there being one authentication resolution: the ladder of refusals below this, the host-key
|
||
/// question, the log entry and the tab's own lifecycle are the parts nobody should be able to get
|
||
/// subtly different for one kind of connection.
|
||
/// </remarks>
|
||
private async Task ConnectToAsync(
|
||
ConnectionTarget target,
|
||
HostAuthentication authentication,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
PendingHostKey = null;
|
||
HostKeyMismatch = null;
|
||
|
||
// Remembered so that answering the host-key question retries *this* attempt. It used to re-run the
|
||
// selected host unconditionally, which was right while that was the only way to connect and would
|
||
// now dial the wrong machine — or refuse, with nothing selected — for a manual one.
|
||
pendingRetry = (target, authentication);
|
||
|
||
// Before the first await, so the tab is in the strip in the same turn the user asked for it. The
|
||
// address is the one that will actually be dialled — a bound credential can supply the username —
|
||
// rather than the host's own fields, so the tab does not rename itself on connecting.
|
||
var attempt = new ConnectionAttemptEventArgs(
|
||
Guid.CreateVersion7(),
|
||
target.Label,
|
||
Dialled(target, authentication));
|
||
|
||
ConnectionStarting?.Invoke(this, attempt);
|
||
Status = $"Connecting to {target.Label}…";
|
||
|
||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Pins the offered host key and retries.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The pin goes into the vault, so this writes to the local cache and queues a change for every other
|
||
/// machine — which is why the write is guarded and the connection is only retried once it has landed.
|
||
/// It used to be a dictionary insert that could not fail; reporting a failed write as a failed
|
||
/// connection would send the user looking at the host.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (PendingHostKey is not { } presentation)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
Status = "Cancelled.";
|
||
return;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
||
return;
|
||
}
|
||
|
||
PendingHostKey = null;
|
||
|
||
// The attempt that raised the question, replayed as it stood. Re-running the selected host was
|
||
// right while that was the only way to connect; with a manual target it would dial whichever host
|
||
// happens to be selected, or refuse with "choose a host first" over a machine the user has just
|
||
// agreed to trust.
|
||
if (pendingRetry is { } retry)
|
||
{
|
||
await ConnectToAsync(retry.Target, retry.Authentication, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
|
||
// After connecting, not before. A pin is worth pushing straight away — the same host on another
|
||
// machine should not ask again — but not at the cost of delaying the connection the user asked for.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>Dismisses the trust prompt without pinning anything.</summary>
|
||
[RelayCommand]
|
||
private void RejectHostKey()
|
||
{
|
||
PendingHostKey = null;
|
||
Status = "The host key was not trusted, so nothing was connected.";
|
||
}
|
||
|
||
/// <summary>
|
||
/// Withdraws trust from every key pinned for the host being edited.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The counterpart to trust that now outlives the process, and the reason it exists at all: a mismatch is
|
||
/// a hard refusal with no way past it, so a server that is legitimately rebuilt would be unreachable for
|
||
/// ever without this. It is deliberately <em>here</em> — in the host's editor, reached by choosing to edit
|
||
/// a host — and not on the refusal itself. A "forget this key" button next to the warning is the same
|
||
/// button as "continue anyway" with two clicks instead of one.
|
||
/// </para>
|
||
/// <para>
|
||
/// Applies to the host's <em>saved</em> address rather than whatever the editor's boxes currently hold.
|
||
/// The pin belongs to the endpoint that was actually dialled, and someone halfway through retyping a
|
||
/// hostname has not moved it yet.
|
||
/// </para>
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task ForgetHostKeyAsync(CancellationToken cancellationToken)
|
||
{
|
||
if (editingEntityId is not { } entityId
|
||
|| Hosts.FirstOrDefault(row => row.EntityId == entityId) is not { } row)
|
||
{
|
||
return;
|
||
}
|
||
|
||
var address = row.Host.Hostname;
|
||
|
||
// The dialled port, because that is what the pin is filed under. A host inheriting 2222 from its
|
||
// group was pinned at 2222, and forgetting under 22 would leave the pin that caused the mismatch
|
||
// exactly where it was.
|
||
var port = Resolve(row.Host).Port.Value;
|
||
|
||
await RunAsync(
|
||
$"Forgetting the pinned host key for {address}…",
|
||
async () =>
|
||
{
|
||
var forgotten = await knownHosts
|
||
.ForgetAsync(address, port, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// The refusal that sent the user here is about a pin that no longer exists.
|
||
HostKeyMismatch = null;
|
||
|
||
PendingChanges = await session
|
||
.PendingChangeCountAsync(cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
if (forgotten == 0)
|
||
{
|
||
Status = $"Nothing was pinned for {address}:{port}.";
|
||
return;
|
||
}
|
||
|
||
Status = $"Forgot the pinned host key for {address}:{port}. The next connection will "
|
||
+ "ask you to check its fingerprint again.";
|
||
}).ConfigureAwait(true);
|
||
|
||
// Pushed straight away, as a save or a deletion is: a withdrawal that stayed on this machine would
|
||
// leave the other ones refusing to connect to a server that has been rebuilt.
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Marks every shown conflict as seen.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Acknowledged rather than deleted, so the discarded values stay retrievable afterwards. Someone who
|
||
/// dismisses this and realises a minute later that they wanted the other value should still be able to
|
||
/// get it.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private async Task AcknowledgeAllConflictsAsync(CancellationToken cancellationToken)
|
||
{
|
||
foreach (var conflict in Conflicts.ToArray())
|
||
{
|
||
await session.AcknowledgeConflictAsync(conflict.Id, cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
await LoadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
disposed = true;
|
||
|
||
// The loop is stopped and awaited before the session goes, not merely signalled. A pass in flight
|
||
// holds the vault keys and the cache; letting it run on into a disposed session is how locking
|
||
// turns into an ObjectDisposedException on a background thread that nobody sees.
|
||
if (autoSync is not null)
|
||
{
|
||
await autoSync.CancelAsync().ConfigureAwait(false);
|
||
}
|
||
|
||
if (autoSyncLoop is not null)
|
||
{
|
||
await autoSyncLoop.ConfigureAwait(false);
|
||
}
|
||
|
||
autoSync?.Dispose();
|
||
syncGate.Dispose();
|
||
|
||
await session.DisposeAsync().ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>Connects, and turns every way of not connecting into something a tab can carry.</summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The renderer has to be attached before a session opens: the transport drops frames when nothing is
|
||
/// connected, so a session opened earlier would lose its <c>SessionOpened</c> frame and then stream
|
||
/// output at a terminal that was never created. That wait is bounded and takes this command's token, so
|
||
/// a renderer that never arrives ends as a message rather than as a window stuck on "Connecting…".
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The timeout is translated rather than reported.</b> <see cref="TimeoutException"/> says only "The
|
||
/// operation has timed out", and the one thing worth saying is where to look: a runtime this application
|
||
/// does not install.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>An unknown host key is not a failure and is deliberately not logged.</b> Nothing was refused and
|
||
/// nothing broke — the connection is paused on a question, and it becomes a session the moment the user
|
||
/// answers it. An entry here would record a failure that did not happen, once per new host. A changed
|
||
/// key <em>is</em> logged, and it is the entry the connection log most exists for: it is refused outright
|
||
/// with no way past it, so the only trace it would otherwise leave is a status line the user dismisses.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Everything else is caught by shape rather than by type.</b> This project's SSH layer defines only
|
||
/// the two host-key exceptions; an unreachable host, a rejected password and a key the remote will not
|
||
/// take all arrive from SSH.NET, which the client deliberately does not reference. Each is recorded
|
||
/// before it is reported — the log is an observer here and must never become the thing that swallows an
|
||
/// error. Cancellation is excluded from that, because a user who gave up did not fail to connect.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task OpenSessionAsync(
|
||
ConnectionAttemptEventArgs attempt,
|
||
ConnectionTarget target,
|
||
HostAuthentication authentication,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
await ConnectAndAnnounceAsync(attempt, target, authentication, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
catch (TimeoutException)
|
||
{
|
||
Abandon(
|
||
attempt,
|
||
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||
+ "runtime is probably missing or blocked; install it and try again.");
|
||
}
|
||
catch (SshHostKeyUnknownException exception)
|
||
{
|
||
PendingHostKey = exception.Presentation;
|
||
Answer(attempt, "This host has not been seen before.");
|
||
}
|
||
catch (SshHostKeyMismatchException exception)
|
||
{
|
||
RecordFailure(target, authentication, ConnectionOutcome.Refused);
|
||
|
||
HostKeyMismatch = exception.Message;
|
||
Answer(attempt, "The host key has changed. The connection was refused.");
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
Answer(attempt, "Cancelled.");
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
RecordFailure(target, authentication, ConnectionOutcome.Failed);
|
||
Abandon(attempt, exception.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
||
/// <remarks>
|
||
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
||
/// looking, and the tab is where somebody who navigated away will find it — which is now the ordinary
|
||
/// case, because connecting does not hold the window still any more.
|
||
/// </remarks>
|
||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||
{
|
||
Status = reason;
|
||
|
||
ConnectionFailed?.Invoke(
|
||
this,
|
||
new ConnectionFailedEventArgs(attempt.AttemptId, reason, isAwaitingAnAnswer: false));
|
||
}
|
||
|
||
/// <summary>
|
||
/// The same, for an attempt that stopped on something the user has to answer rather than on a failure.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The difference is what the shell does with the tab: a refusal keeps it, and a question takes it away
|
||
/// so the window can show the question instead. See <see cref="ConnectionFailedEventArgs"/>. Cancelling
|
||
/// counts as a question in the sense that matters here — the tab is going either way, and nothing about
|
||
/// it is worth keeping on screen.
|
||
/// </remarks>
|
||
private void Answer(ConnectionAttemptEventArgs attempt, string status)
|
||
{
|
||
Status = status;
|
||
|
||
ConnectionFailed?.Invoke(
|
||
this,
|
||
new ConnectionFailedEventArgs(attempt.AttemptId, status, isAwaitingAnAnswer: true));
|
||
}
|
||
|
||
/// <summary>Opens the session and tells the shell about it. Every failure is a throw.</summary>
|
||
/// <remarks>
|
||
/// Split from the handlers around it for length, and the split falls where it should: this is the whole
|
||
/// happy path, and everything above it is one <c>catch</c> per way of not having one.
|
||
/// </remarks>
|
||
private async Task ConnectAndAnnounceAsync(
|
||
ConnectionAttemptEventArgs attempt,
|
||
ConnectionTarget target,
|
||
HostAuthentication authentication,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
var request = new SshConnectionRequest(
|
||
target.Hostname,
|
||
target.Port,
|
||
authentication.Username,
|
||
authentication.Credential);
|
||
|
||
var sessionId = await workspace
|
||
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||
// connected. What it could not know is which keychain item this was — an SshConnectionRequest has no
|
||
// notion of one — so the name is added here rather than the ticket being replaced, which would move
|
||
// the start time to now. A target with no item still gets its name, and a null id: the entry is the
|
||
// only record that machine was reached at all.
|
||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||
|
||
Status = $"Connected to {target.Label}.";
|
||
|
||
// Only now, and only on success. The page's own term.focus() focuses the textarea inside the
|
||
// document, which does nothing while the window's keyboard focus is still on the Connect button — so
|
||
// without this the first keystrokes of the session go to the shell's UI instead of the remote shell.
|
||
SessionOpened?.Invoke(
|
||
this,
|
||
new TerminalSessionEventArgs(
|
||
attempt.AttemptId,
|
||
sessionId,
|
||
target.Label,
|
||
Dialled(target, authentication)));
|
||
|
||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||
// for must not wait on a vault write to appear.
|
||
//
|
||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||
// screen it is typed on says so.
|
||
if (target.Row is { } row)
|
||
{
|
||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Turns the password that just worked into a keychain credential bound to this host.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Only after a handshake the remote accepted.</b> Storing a password the moment it is typed would
|
||
/// bind whatever was in the box — including the typo that is about to be refused — and the host would
|
||
/// then stop asking, leaving a machine that cannot be connected to until somebody works out that the
|
||
/// keychain is where the wrong password now lives.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>A credential rather than a field on the host, which is why nothing else here had to change.</b>
|
||
/// It syncs, merges, appears in the keychain, can be renamed, deleted and — the reason the item type
|
||
/// exists — bound to the other nineteen machines that share the account. See <see cref="HostSecret"/>
|
||
/// on why the binding is an id and not a copy.
|
||
/// </para>
|
||
/// <para>
|
||
/// The credential carries no username of its own, so it keeps taking the host's — which is what the
|
||
/// connection that just succeeded did. Copying the resolved username into it would pin whatever the
|
||
/// group happened to say at this moment, and quietly stop following the group afterwards.
|
||
/// </para>
|
||
/// <para>
|
||
/// Every failure is reported and swallowed. The caller's <c>catch</c> blocks describe a connection that
|
||
/// did not happen, and this one did: a vault write that fails here must not tell the user their terminal
|
||
/// was abandoned, and a cancellation must not report it as cancelled.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task RememberTypedPasswordAsync(
|
||
HostRowViewModel row,
|
||
HostAuthentication authentication,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
// The password as dialled, not as the box currently reads: the two can differ by now, because a
|
||
// handshake takes time and the box stays typeable throughout it.
|
||
if (!RemembersConnectPassword
|
||
|| row.Resolved.Binding.Kind is not ResolvedBindingKind.TypedPassword
|
||
|| authentication.Credential is not SshPasswordCredential { Password.Length: > 0 } typed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (row.IsReadOnly)
|
||
{
|
||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
||
return;
|
||
}
|
||
|
||
var credential = new CredentialSecret { Label = row.Label, Password = typed.Password };
|
||
|
||
try
|
||
{
|
||
var credentialId = await session.Credentials
|
||
.CreateAsync(row.VaultId, credential, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
|
||
// Into the same vault as the host, deliberately: a credential in the personal vault bound to a
|
||
// team's host is a binding every other member can see and none of them can resolve.
|
||
await session.Hosts
|
||
.UpdateAsync(
|
||
row.VaultId,
|
||
row.EntityId,
|
||
row.Host with { CredentialId = credentialId, AsksForPassword = null },
|
||
cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
||
return;
|
||
}
|
||
|
||
// Cleared together. The box is about to disappear — the host answers "credential" now — and a tick
|
||
// left behind would apply to the next host somebody selects.
|
||
RemembersConnectPassword = false;
|
||
ConnectPassword = string.Empty;
|
||
|
||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
||
+ "will not be asked for again.";
|
||
|
||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||
}
|
||
|
||
/// <summary>The address as actually dialled.</summary>
|
||
/// <remarks>
|
||
/// Built from what was dialled rather than from the host's own fields, because neither half of it need
|
||
/// come from the host. A bound credential can supply the username, and a group can supply the port and
|
||
/// the username both — so a host saved with neither still has both here, and they are the ones the
|
||
/// remote saw. This string is what the terminal tab and the connection log are labelled with, and a log
|
||
/// naming a port nothing dialled is worse than no log.
|
||
/// </remarks>
|
||
private static string Dialled(ConnectionTarget target, HostAuthentication authentication) =>
|
||
string.Create(
|
||
CultureInfo.InvariantCulture,
|
||
$"{authentication.Username}@{target.Hostname}:{target.Port}");
|
||
|
||
/// <summary>
|
||
/// Removes log entries this vault has agreed to stop keeping, at most once every few hours.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Rate-limited, because pruning writes.</b> Log entries are synced items, so removing one is a real
|
||
/// tombstone that goes to the server — and a prune on every sync tick would be a machine that writes to
|
||
/// a server once a minute for ever. It rides the existing loop rather than a timer of its own, so a
|
||
/// laptop that is asleep prunes nothing and one that is awake prunes when it was going to talk to the
|
||
/// server anyway.
|
||
/// </para>
|
||
/// <para>
|
||
/// The first pass after a vault opens always runs, which is what makes a machine that has been off for a
|
||
/// month tidy up as soon as it comes back.
|
||
/// </para>
|
||
/// <para>
|
||
/// Failures are swallowed. Retention is housekeeping; a vault that could not prune is not a vault
|
||
/// somebody needs to be told about mid-sync.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task PruneLogsIfDueAsync(CancellationToken cancellationToken)
|
||
{
|
||
var now = TimeProvider.System.GetUtcNow();
|
||
|
||
if (lastPruned is { } previous && now - previous < PruneInterval)
|
||
{
|
||
return;
|
||
}
|
||
|
||
lastPruned = now;
|
||
|
||
try
|
||
{
|
||
await LogPruner.PruneAsync(session, LogRetention.Default, now, cancellationToken)
|
||
.ConfigureAwait(true);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// Locking, or closing.
|
||
}
|
||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||
{
|
||
// Housekeeping. Nothing the user did failed, and there is nothing for them to do about it.
|
||
}
|
||
}
|
||
|
||
/// <summary>Records a connection that never became a session.</summary>
|
||
/// <remarks>
|
||
/// Start and end are the same instant, which is what a connection that never opened actually looks like:
|
||
/// the duration is zero and the outcome carries the meaning.
|
||
/// </remarks>
|
||
private void RecordFailure(
|
||
ConnectionTarget target,
|
||
HostAuthentication authentication,
|
||
ConnectionOutcome outcome)
|
||
{
|
||
var at = TimeProvider.System.GetUtcNow();
|
||
|
||
connectionLog?.Record(
|
||
Dialled(target, authentication),
|
||
target.Label,
|
||
target.HostId,
|
||
ConnectionKind.Terminal,
|
||
at,
|
||
at,
|
||
outcome);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Everything the SSH stack needs to authenticate as somebody on a host.
|
||
/// </summary>
|
||
/// <param name="Username">The account to log in as, after any credential has had its say.</param>
|
||
/// <param name="Credential">What proves it.</param>
|
||
/// <remarks>
|
||
/// The two travel together because a credential can change both. Returning only the secret and reading the
|
||
/// username off the host separately is what the connect path used to do, and it would have sent a stored
|
||
/// credential's password under the host's username — which is the one combination that is wrong in a way
|
||
/// the server reports as "authentication failed".
|
||
/// </remarks>
|
||
private sealed record HostAuthentication(string Username, SshCredential Credential);
|
||
|
||
/// <summary>
|
||
/// The machine a connection is being made to, however it was named.
|
||
/// </summary>
|
||
/// <param name="Label">What to call it — a keychain host's alias, or what was typed.</param>
|
||
/// <param name="Hostname">The address to dial.</param>
|
||
/// <param name="Port">The port to dial, already resolved through any group.</param>
|
||
/// <param name="Row">
|
||
/// The keychain host this came from, or null for a machine that is not in the keychain.
|
||
/// </param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// This exists so the connect path stops being shaped like <see cref="HostRowViewModel"/>. Everything
|
||
/// below the resolution needs three facts and a row carries dozens; taking the three is what let a
|
||
/// connection to an address that has no keychain item share the ladder rather than grow a second one.
|
||
/// <see cref="ConnectionRecorder.Record"/> and <c>Identify</c> both take a nullable id already, so the
|
||
/// log has always been able to hold a connection with no item behind it.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>The row is still here, and only two things read it.</b> Both are things that can only be done to
|
||
/// a keychain item rather than to an address: identifying the log entry, and binding the password that
|
||
/// just worked. Null is not missing data — it is the whole of what makes the manual path different, and
|
||
/// having it here rather than as a separate id keeps "was this a keychain host" one question with one
|
||
/// answer.
|
||
/// </para>
|
||
/// </remarks>
|
||
private sealed record ConnectionTarget(
|
||
string Label,
|
||
string Hostname,
|
||
int Port,
|
||
HostRowViewModel? Row = null)
|
||
{
|
||
/// <summary>The keychain item, or null for a machine that is not in it.</summary>
|
||
internal Guid? HostId => Row?.EntityId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Works out how a host authenticates, or says why it cannot.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The key material is handed over as UTF-8 bytes, which is what <c>PrivateKeyFile</c> reads from a
|
||
/// <c>MemoryStream</c> — so the key reaches SSH.NET without ever becoming a file on disk. The
|
||
/// passphrase goes with it: a key stored in the vault together with its passphrase is the whole point
|
||
/// of a vault, and <c>SshKeySecret</c> says why. It is passed straight through with no empty-to-null
|
||
/// check, because <c>SshKeySecret.Passphrase</c> cannot hold an empty string.
|
||
/// </para>
|
||
/// <para>
|
||
/// False is a refusal, not a fallback, and the caller must treat it as one. A dangling reference means the
|
||
/// key or credential was deleted on another machine — plausible, and no reason to start sending a typed
|
||
/// password to a host somebody deliberately set up not to accept one.
|
||
/// </para>
|
||
/// <para>
|
||
/// The credential branch comes first because the two bindings are mutually exclusive and a host carrying
|
||
/// both is already invalid; reading the credential first means a host that somehow acquired both is
|
||
/// answered by the more specific of the two rather than by whichever the code happened to check.
|
||
/// </para>
|
||
/// </remarks>
|
||
/// <summary>
|
||
/// Works out how to reach a host, or says why it cannot.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The same resolution the Connect button performs, exposed because file transfer opens its own
|
||
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
|
||
/// username" would be a second place for a dangling binding to be silently turned back into a typed
|
||
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
|
||
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
|
||
/// password offered on the other.
|
||
/// </remarks>
|
||
internal bool TryBuildConnectionRequest(
|
||
HostSecret host,
|
||
string typedPassword,
|
||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||
[NotNullWhen(false)] out string? reason)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(host);
|
||
|
||
var resolved = Resolve(host);
|
||
|
||
if (!TryBuildAuthentication(host, resolved, typedPassword, out var authentication, out reason))
|
||
{
|
||
request = null;
|
||
return false;
|
||
}
|
||
|
||
request = new SshConnectionRequest(
|
||
host.Hostname,
|
||
resolved.Port.Value,
|
||
authentication.Username,
|
||
authentication.Credential);
|
||
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Turns a host into a key, a password or a prompt, or says why it cannot.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// <b>Takes the resolved host as well as the stored one, and this is where group context used to be
|
||
/// lost.</b> Both callers of this and of <see cref="TryBuildConnectionRequest"/> used to hand over a
|
||
/// bare <see cref="HostSecret"/>, which answers "what did the user type into this host" rather than
|
||
/// "what happens when it is connected". It is the only authentication resolution in the product — both
|
||
/// heads and both transports come through here — so a host inheriting its binding would otherwise have
|
||
/// been offered a password prompt on every screen at once.
|
||
/// </para>
|
||
/// <para>
|
||
/// The refusal messages name the group when the binding came from one. A user told that "this host
|
||
/// authenticates with a key that is not in this keychain any more" would go looking at a host that says
|
||
/// nothing about keys.
|
||
/// </para>
|
||
/// </remarks>
|
||
private bool TryBuildAuthentication(
|
||
HostSecret host,
|
||
ResolvedHost resolved,
|
||
string typedPassword,
|
||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||
[NotNullWhen(false)] out string? reason)
|
||
{
|
||
var binding = resolved.Binding;
|
||
var where = binding.IsInherited ? $"'{host.Label}' inherits" : $"'{host.Label}' authenticates";
|
||
var repair = binding.IsInherited
|
||
? "Edit the group it is filed under, or bind the host itself."
|
||
: "Edit the host to choose another one, or set it back to a typed password.";
|
||
|
||
if (binding is { Kind: ResolvedBindingKind.Credential, EntityId: { } credentialId })
|
||
{
|
||
if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
|
||
{
|
||
return Refuse(
|
||
$"{where} a credential that is not in this keychain any more. {repair}",
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
// The credential's username wins where it has one, which is the whole reason it can carry one: one
|
||
// account on twenty machines is described once. Falling back to the resolved username covers the
|
||
// ordinary case of a shared password used under each machine's own account — and it is the
|
||
// resolved one rather than the host's, so the fallback has three levels rather than two.
|
||
return Complete(
|
||
credential.Credential.Username ?? resolved.Username.Value,
|
||
new SshPasswordCredential(credential.Credential.Password),
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
if (binding is { Kind: ResolvedBindingKind.SshKey, EntityId: { } keyId })
|
||
{
|
||
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
|
||
{
|
||
return Refuse(
|
||
$"{where} an SSH key that is not in this keychain any more. {repair}",
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
return Complete(
|
||
resolved.Username.Value,
|
||
new SshPrivateKeyCredential(
|
||
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
return Complete(
|
||
resolved.Username.Value,
|
||
new SshPasswordCredential(typedPassword),
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
/// <summary>
|
||
/// The last thing every branch has to agree on: there is somebody to log in as.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Checked here rather than at the top of <see cref="TryBuildAuthentication" /> because the answer depends
|
||
/// on which branch was taken — a host with no username of its own is perfectly usable through a credential
|
||
/// that carries one, and refusing it up front would have made the credential's most useful property
|
||
/// unreachable.
|
||
/// </para>
|
||
/// <para>
|
||
/// It is also why every caller passes a username the group chain has already been consulted for. Refusing
|
||
/// before the chain is walked would refuse exactly the hosts inheritance exists to serve: the twenty
|
||
/// machines filed under one group that says <c>deploy</c> once.
|
||
/// </para>
|
||
/// </remarks>
|
||
private static bool Complete(
|
||
string? username,
|
||
SshCredential credential,
|
||
out HostAuthentication? authentication,
|
||
[NotNullWhen(false)] out string? reason)
|
||
{
|
||
if (string.IsNullOrEmpty(username))
|
||
{
|
||
return Refuse(
|
||
"This host has no username. Edit it and add one, or bind it to a credential that carries one.",
|
||
out authentication,
|
||
out reason);
|
||
}
|
||
|
||
authentication = new HostAuthentication(username, credential);
|
||
reason = null;
|
||
return true;
|
||
}
|
||
|
||
private static bool Refuse(
|
||
string reason,
|
||
out HostAuthentication? authentication,
|
||
out string? refusal)
|
||
{
|
||
authentication = null;
|
||
refusal = reason;
|
||
return false;
|
||
}
|
||
|
||
private HostSecret BuildHost() =>
|
||
new()
|
||
{
|
||
Label = EditorLabel.Trim(),
|
||
Hostname = EditorHostname.Trim(),
|
||
|
||
// Empty means "take the group's" in both directions, which is what makes the placeholder honest:
|
||
// what the box showed while empty is what the host will use. A relay host is the exception —
|
||
// HostSecret.TryValidate refuses one with no port of its own, so the box is pre-filled and
|
||
// required there; see EditorRelayEnabled.
|
||
Port = EditorRelayEnabled ? EditorPort ?? HostSecret.DefaultPort : EditorPort,
|
||
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
|
||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||
RelayEnabled = EditorRelayEnabled,
|
||
|
||
// Only ever true or null, never false: the two would mean the same thing, and writing false would
|
||
// change the bytes of every host that has never touched this. See HostSecret.AsksForPassword.
|
||
//
|
||
// And only for a host in a group, because only then was there another entry to choose instead —
|
||
// an ungrouped host's picker offers no "Inherit", so its "Password (ask each time)" is the
|
||
// absence of a decision rather than one, and storing it as a decision would pin every ungrouped
|
||
// host in the vault the first time it was edited.
|
||
AsksForPassword = EditorSelectedAuthentication?.Kind == AuthenticationKind.Typed
|
||
&& EditorSelectedGroup?.EntityId is not null
|
||
? true
|
||
: null,
|
||
|
||
// The picker's set, and it is the picker's rather than the chips' — see editorTagIds. An id the
|
||
// vault cannot currently offer, because the tag was deleted on another machine between this
|
||
// editor opening and Save, is carried through rather than dropped by an edit that was about
|
||
// something else.
|
||
TagIds = editorTagIds,
|
||
|
||
// Both read off the one picker, including the id of something that has gone missing. Reading them
|
||
// from the picker rather than carrying the originals through is what lets a binding be removed at
|
||
// all, and preserving a missing id is what stops an unrelated edit removing one by accident. One
|
||
// control means the two can never both be set: mutual exclusion by construction, rather than
|
||
// HostSecret.TryValidate catching it after the fact.
|
||
SshKeyId = Bound(AuthenticationKind.SshKey),
|
||
CredentialId = Bound(AuthenticationKind.Credential),
|
||
|
||
// Read off the picker for the same reason, including the id of a group that has gone missing:
|
||
// an unrelated edit must not unfile a host as a side effect.
|
||
GroupId = EditorSelectedGroup?.EntityId,
|
||
};
|
||
|
||
/// <summary>The picker's selection, if it names something of this kind.</summary>
|
||
private Guid? Bound(AuthenticationKind kind) =>
|
||
EditorSelectedAuthentication is { } choice && choice.Kind == kind ? choice.EntityId : null;
|
||
|
||
/// <summary>
|
||
/// Fills the authentication picker, keeping whatever the host is currently bound to selectable.
|
||
/// </summary>
|
||
/// <param name="boundKeyId">The key the host names, if any.</param>
|
||
/// <param name="boundCredentialId">The credential the host names, if any.</param>
|
||
/// <param name="asksForPassword">Whether the host is pinned to a typed password.</param>
|
||
/// <param name="grouped">Whether the host is filed under a group, and so has anything to inherit.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// A binding whose target is no longer in the vault gets a placeholder entry rather than being dropped.
|
||
/// Without one the picker would open on "Password (ask each time)", and someone editing the host's port
|
||
/// would convert it to a typed password by saving — which is the quiet version of the failure the connect
|
||
/// path refuses outright.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Four entries where there were three, and only for a host in a group.</b> The three states two
|
||
/// nullable ids could carry became four when naming neither came to mean "inherit"; see
|
||
/// <see cref="AuthenticationKind.Inherited"/>. For an ungrouped host the fourth would behave exactly like
|
||
/// the first, so it is left out rather than offered and then explained.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void BuildAuthenticationChoices(
|
||
Guid? boundKeyId,
|
||
Guid? boundCredentialId,
|
||
bool asksForPassword,
|
||
bool grouped)
|
||
{
|
||
EditorAuthenticationChoices.Clear();
|
||
EditorAuthenticationChoices.Add(AuthenticationChoice.Typed);
|
||
|
||
if (grouped)
|
||
{
|
||
EditorAuthenticationChoices.Add(AuthenticationChoice.Inherited);
|
||
}
|
||
|
||
foreach (var key in Keys)
|
||
{
|
||
EditorAuthenticationChoices.Add(AuthenticationChoice.ForKey(key.EntityId, key.Label));
|
||
}
|
||
|
||
foreach (var credential in Credentials)
|
||
{
|
||
EditorAuthenticationChoices.Add(
|
||
AuthenticationChoice.ForCredential(credential.EntityId, credential.Label));
|
||
}
|
||
|
||
// At most one of the two is set on a valid host, so at most one placeholder is ever added.
|
||
AddMissing(AuthenticationKind.SshKey, boundKeyId);
|
||
AddMissing(AuthenticationKind.Credential, boundCredentialId);
|
||
|
||
EditorSelectedAuthentication =
|
||
Selected(boundKeyId, boundCredentialId, asksForPassword, grouped);
|
||
}
|
||
|
||
private void AddMissing(AuthenticationKind kind, Guid? boundId)
|
||
{
|
||
if (boundId is { } bound
|
||
&& !EditorAuthenticationChoices.Any(choice => choice.Kind == kind && choice.EntityId == bound))
|
||
{
|
||
EditorAuthenticationChoices.Add(AuthenticationChoice.Missing(kind, bound));
|
||
}
|
||
}
|
||
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Matched on the kind as well as the id. Ids are v7 GUIDs and a collision is not the worry — selecting the
|
||
/// right row for the wrong reason is, because a lookup by id alone would compile, pass, and silently pick a
|
||
/// key when the host named a credential the day the two ever shared an id.
|
||
/// </para>
|
||
/// <para>
|
||
/// The last two arms are where the third state has to be told from the fourth. A host that names neither
|
||
/// binding and does not ask for a password is inheriting; one that asks is not. An ungrouped host has no
|
||
/// "Inherit" entry to select, so it falls to the typed one — which is what it resolves to anyway.
|
||
/// </para>
|
||
/// </remarks>
|
||
private AuthenticationChoice Selected(
|
||
Guid? boundKeyId,
|
||
Guid? boundCredentialId,
|
||
bool asksForPassword,
|
||
bool grouped) =>
|
||
(boundKeyId, boundCredentialId) switch
|
||
{
|
||
({ } key, _) => Find(AuthenticationKind.SshKey, key),
|
||
(_, { } credential) => Find(AuthenticationKind.Credential, credential),
|
||
_ when asksForPassword || !grouped => AuthenticationChoice.Typed,
|
||
_ => AuthenticationChoice.Inherited,
|
||
};
|
||
|
||
private AuthenticationChoice Find(AuthenticationKind kind, Guid entityId) =>
|
||
EditorAuthenticationChoices
|
||
.FirstOrDefault(choice => choice.Kind == kind && choice.EntityId == entityId)
|
||
?? AuthenticationChoice.Typed;
|
||
|
||
/// <summary>Fills the group picker, keeping whatever the host is currently filed under selectable.</summary>
|
||
/// <param name="groupId">The group the host names, if any.</param>
|
||
/// <remarks>
|
||
/// A group that is no longer in the vault gets a placeholder, for the reason
|
||
/// <see cref="BuildAuthenticationChoices"/> gives: without one the picker would open on "No group", and
|
||
/// somebody editing the host's port would unfile it by saving. It says the group is gone rather than
|
||
/// naming it, because there is nothing left to read the name off.
|
||
/// </remarks>
|
||
/// <summary>Refills the host editor's vault picker, landing on the vault the editor will write to.</summary>
|
||
/// <remarks>
|
||
/// Filled from <see cref="TargetVaults"/>, which is already the readable-and-writable set and is kept
|
||
/// in step with the session by <see cref="RebuildTargetVaults"/>. The options are shared objects rather
|
||
/// than copies, so the two pickers show the same names without either one being able to move the other:
|
||
/// what they do not share is the selection.
|
||
/// </remarks>
|
||
private void BuildEditorVaultChoices(Guid vaultId)
|
||
{
|
||
EditorVaultChoices.Clear();
|
||
|
||
foreach (var choice in TargetVaults)
|
||
{
|
||
EditorVaultChoices.Add(choice);
|
||
}
|
||
|
||
// Null where the host's vault is one this session cannot write — a team vault this account is a
|
||
// viewer of. The picker is hidden for an existing host anyway, and leaving the box empty is a
|
||
// better answer than adding an option that would move the host if it were touched.
|
||
EditorSelectedVault = EditorVaultChoices.FirstOrDefault(choice => choice.VaultId == vaultId);
|
||
|
||
OnPropertyChanged(nameof(ShowsEditorVaultChoice));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Moves a half-typed host into the vault just chosen for it.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Only while creating, and this guard is what makes that true rather than the view merely not drawing
|
||
/// the control. An existing host <em>can</em> change vaults — see <see cref="MoveHost"/> — but not this
|
||
/// way and not as part of a save: reassigning it here on an edit would write the host into a second
|
||
/// vault and leave the original behind, which is a fork rather than a move.
|
||
/// </remarks>
|
||
partial void OnEditorSelectedVaultChanged(VaultChoiceViewModel? value)
|
||
{
|
||
if (value is null || editingEntityId is not null || editingHostVaultId == value.VaultId)
|
||
{
|
||
return;
|
||
}
|
||
|
||
editingHostVaultId = value.VaultId;
|
||
|
||
var authentication = EditorSelectedAuthentication;
|
||
|
||
// The group picker is the vault's, so it has to be rebuilt — and whatever was chosen in it belongs
|
||
// to the vault just left, so it is kept only if the new one has it too. Which in practice means it
|
||
// is dropped, because a group is one item in one vault.
|
||
BuildGroupChoices(GroupInEditingVault(EditorSelectedGroup?.EntityId));
|
||
|
||
// Rebuilt after it, because "inherit from group" is offered only to a host that is in one — and
|
||
// whether this one still is has just been decided above. The key and credential entries are not
|
||
// filtered by vault, unlike the groups: the key list spans every readable vault by design, and a
|
||
// host authenticating with a key from another vault is a thing this application already supports.
|
||
BuildAuthenticationChoices(
|
||
authentication?.Kind == AuthenticationKind.SshKey ? authentication.EntityId : null,
|
||
authentication?.Kind == AuthenticationKind.Credential ? authentication.EntityId : null,
|
||
asksForPassword: authentication?.Kind == AuthenticationKind.Typed,
|
||
grouped: EditorSelectedGroup?.EntityId is not null);
|
||
}
|
||
|
||
/// <summary>The group, if the vault being written to actually has it; otherwise none.</summary>
|
||
private Guid? GroupInEditingVault(Guid? groupId) =>
|
||
groupId is { } id
|
||
&& groupsByVault.TryGetValue(editingHostVaultId, out var groups)
|
||
&& groups.Any(choice => choice.EntityId == id)
|
||
? id
|
||
: null;
|
||
|
||
/// <summary>Refills the host editor's group picker for one vault.</summary>
|
||
/// <param name="groupId">The group to land on, or null for none.</param>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The vault's own groups and no others — see <see cref="groupsByVault"/>. A vault this session cannot
|
||
/// read has no entry there and gets an empty list rather than the active vault's, which is the right
|
||
/// answer for a picker: there is nothing in it that this host could be filed under.
|
||
/// </para>
|
||
/// <para>
|
||
/// A group the vault no longer has keeps a placeholder entry, so that editing a host's port cannot
|
||
/// quietly unfile it.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void BuildGroupChoices(Guid? groupId)
|
||
{
|
||
EditorGroupChoices.Clear();
|
||
EditorGroupChoices.Add(GroupChoice.None);
|
||
|
||
if (groupsByVault.TryGetValue(editingHostVaultId, out var groups))
|
||
{
|
||
foreach (var group in groups)
|
||
{
|
||
EditorGroupChoices.Add(group);
|
||
}
|
||
}
|
||
|
||
if (groupId is { } bound && !EditorGroupChoices.Any(choice => choice.EntityId == bound))
|
||
{
|
||
EditorGroupChoices.Add(new GroupChoice(bound, "(a group that is no longer here)"));
|
||
}
|
||
|
||
EditorSelectedGroup = EditorGroupChoices.FirstOrDefault(choice => choice.EntityId == groupId)
|
||
?? GroupChoice.None;
|
||
}
|
||
|
||
private CredentialSecret BuildCredential() =>
|
||
new()
|
||
{
|
||
Label = CredentialEditorLabel.Trim(),
|
||
|
||
// Not trimmed and not emptied, exactly as a key's passphrase is not: leading or trailing spaces
|
||
// are legitimate in a password, and CredentialSecret refuses an empty one on its own.
|
||
Password = CredentialEditorPassword,
|
||
|
||
// Trimmed, unlike the password. A username with a trailing space is a different account name to
|
||
// sshd, and it is never the one somebody meant.
|
||
Username = CredentialEditorUsername.Trim(),
|
||
Notes = string.IsNullOrWhiteSpace(CredentialEditorNotes) ? null : CredentialEditorNotes,
|
||
};
|
||
|
||
/// <remarks>
|
||
/// The private key is not trimmed. Its armour is whitespace-significant and a client that tidied it up
|
||
/// would eventually tidy a format it did not fully understand — the same reason
|
||
/// <c>SshKeySecret.PrivateKeyPem</c> stores it verbatim. Everything else is trimmed, because a label
|
||
/// with a trailing space sorts oddly and reads as a different name.
|
||
/// </remarks>
|
||
private SshKeySecret BuildKey() =>
|
||
new()
|
||
{
|
||
Label = KeyEditorLabel.Trim(),
|
||
PrivateKeyPem = KeyEditorPrivateKey,
|
||
|
||
// Not trimmed and not emptied: leading or trailing spaces are legitimate in a passphrase, and
|
||
// the record turns an empty one into null on its own.
|
||
Passphrase = KeyEditorPassphrase,
|
||
PublicKey = string.IsNullOrWhiteSpace(KeyEditorPublicKey) ? null : KeyEditorPublicKey.Trim(),
|
||
Notes = string.IsNullOrWhiteSpace(KeyEditorNotes) ? null : KeyEditorNotes,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Whether the host editor has to be dealt with before the sidebar starts another one.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Scoped to the host editor alone, and that scoping is the point: the host editor lives in
|
||
/// <c>HostSidebar</c>, on the Hosts screen, and nothing on the Vault screen shares its column or its
|
||
/// visibility with it. A vault-screen editor being open says nothing about whether it is safe to start
|
||
/// editing a host — the two cannot even be looked at at the same time — so this no longer asks about
|
||
/// them. See <see cref="AVaultEditorIsInTheWay"/> for the reasoning this once shared with them, and why
|
||
/// splitting it was necessary rather than cosmetic: the earlier single check refused every host action
|
||
/// while a key editor sat open on a screen the sidebar was not showing, with a status message naming an
|
||
/// editor the user could not see and no way to reach it without abandoning what they had just started
|
||
/// on the Hosts screen.
|
||
/// </remarks>
|
||
private bool AHostEditorIsInTheWay()
|
||
{
|
||
if (IsEditing)
|
||
{
|
||
Status = "Finish or cancel the host you are editing first.";
|
||
}
|
||
|
||
return IsEditing;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether a vault-screen editor has to be dealt with before the rail or another editor opens.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// One editor open at a time on this screen, and the reason is the key editor: it holds a pasted
|
||
/// private key in a bound string for as long as it is open, and only <c>CancelKeyEdit</c> lets go of
|
||
/// it. Letting the rail move the category, or another editor open, with that editor still holding
|
||
/// material would leave a private key in a form nobody can see, with nothing on screen to say it is
|
||
/// there.
|
||
/// </para>
|
||
/// <para>
|
||
/// Refused rather than resolved by closing the open editor, because closing it would silently discard
|
||
/// what was typed there — and in the key editor that is a pasted private key the user may have nowhere
|
||
/// else. One sentence and one click is the cheaper of the two.
|
||
/// </para>
|
||
/// <para>
|
||
/// Does not ask about <see cref="IsEditing"/>. The host editor is a different screen's business now —
|
||
/// see <see cref="AHostEditorIsInTheWay"/> — and asking about it here is what used to leave three
|
||
/// quarters of this screen inert with a status line pointing at an editor the user was not looking at.
|
||
/// </para>
|
||
/// </remarks>
|
||
private bool AVaultEditorIsInTheWay()
|
||
{
|
||
Status = (IsEditingKey, IsEditingCredential, IsGeneratingKey, IsEditingObjectStore, IsEditingTag)
|
||
switch
|
||
{
|
||
(true, _, _, _, _) => "Finish or cancel the SSH key you are editing first.",
|
||
(_, true, _, _, _) => "Finish or cancel the credential you are editing first.",
|
||
(_, _, true, _, _) => "Finish or cancel the key you are generating first.",
|
||
(_, _, _, true, _) => "Finish or cancel the bucket you are editing first.",
|
||
(_, _, _, _, true) => "Finish or cancel the tag you are editing first.",
|
||
_ => Status,
|
||
};
|
||
|
||
return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore
|
||
|| IsEditingTag;
|
||
}
|
||
|
||
private void ClearKeyEditor()
|
||
{
|
||
KeyEditorLabel = string.Empty;
|
||
KeyEditorPrivateKey = string.Empty;
|
||
KeyEditorPassphrase = string.Empty;
|
||
KeyEditorPublicKey = string.Empty;
|
||
KeyEditorNotes = string.Empty;
|
||
}
|
||
|
||
private void ClearCredentialEditor()
|
||
{
|
||
CredentialEditorLabel = string.Empty;
|
||
CredentialEditorUsername = string.Empty;
|
||
CredentialEditorPassword = string.Empty;
|
||
CredentialEditorNotes = string.Empty;
|
||
}
|
||
|
||
private async Task LoadConflictsAsync(CancellationToken cancellationToken)
|
||
{
|
||
var notices = await session.ReadConflictsAsync(cancellationToken).ConfigureAwait(true);
|
||
|
||
Conflicts.Clear();
|
||
|
||
foreach (var notice in notices)
|
||
{
|
||
Conflicts.Add(new ConflictRowViewModel(notice));
|
||
}
|
||
|
||
OnPropertyChanged(nameof(HasConflicts));
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Deliberately reports the things a user has to act on rather than a count of successes. A pass that
|
||
/// resurrected a host or parked a change looks identical to a quiet one otherwise, and the whole point
|
||
/// of recording those is that somebody sees them.
|
||
/// </remarks>
|
||
/// <remarks>
|
||
/// Movement and attention only — deliberately not failure. A background pass that announced every
|
||
/// unreachable vault would be a socket error on screen once a minute, which is the thing
|
||
/// <see cref="AutoSyncAsync"/>'s catch block exists to avoid; the caller records
|
||
/// <see cref="LastSyncFailed"/> instead, and the titlebar stops claiming to be up to date. Pressing
|
||
/// Sync reports the failure in full, because somebody who pressed it is waiting for an answer.
|
||
/// </remarks>
|
||
/// <remarks>
|
||
/// The item counts rather than the raw ones. Every user action queues a log entry a moment after the
|
||
/// action's own status message, and this machine reads its own entries back on the next pull — so a
|
||
/// rule written against the raw numbers would overwrite that message after every single save, which is
|
||
/// exactly what it did until the report learned to tell the two apart.
|
||
/// </remarks>
|
||
private static bool IsWorthReporting(IReadOnlyList<VaultSyncReport> reports) =>
|
||
reports.Any(vault => vault.Succeeded
|
||
&& (vault.Report!.PulledItems > 0
|
||
|| vault.Report.PushedItems > 0
|
||
|| vault.Report.NeedsAttention
|
||
|
||
// A pass that had to start over says so even when it pulled nothing, which is the one
|
||
// place this rule is broken deliberately. A machine that silently re-read a whole vault
|
||
// has had something happen to it, and the alternative is that nobody ever finds out.
|
||
|| vault.Report.ResyncedFromStart));
|
||
|
||
/// <remarks>
|
||
/// Counts are summed across vaults, and a failure is named <em>with its reason</em>. Both halves
|
||
/// matter: "1 vault could not be synchronised" sends somebody hunting for which, and a name without a
|
||
/// reason sends them hunting for why. There are rarely more than a handful of vaults, so listing them
|
||
/// costs nothing.
|
||
/// </remarks>
|
||
private static string Describe(IReadOnlyList<VaultSyncReport> reports)
|
||
{
|
||
var failed = reports
|
||
.Where(vault => !vault.Succeeded)
|
||
.Select(vault => $"{vault.Name} ({vault.Failure?.Message})")
|
||
.ToList();
|
||
|
||
var succeeded = reports.Where(vault => vault.Succeeded).Select(vault => vault.Report!).ToList();
|
||
|
||
var line = succeeded.Count switch
|
||
{
|
||
0 => string.Empty,
|
||
1 => Describe(succeeded[0]),
|
||
_ => DescribeMany(succeeded),
|
||
};
|
||
|
||
if (failed.Count == 0)
|
||
{
|
||
return line.Length == 0 ? "Nothing to synchronise." : line;
|
||
}
|
||
|
||
var names = string.Join("; ", failed);
|
||
|
||
return line.Length == 0
|
||
? $"Could not synchronise {names}."
|
||
: $"{line} Could not synchronise {names}.";
|
||
}
|
||
|
||
private static string DescribeMany(List<SyncReport> reports)
|
||
{
|
||
var pulled = reports.Sum(report => report.Pulled);
|
||
var pushed = reports.Sum(report => report.Pushed);
|
||
var attention = reports.Count(report => report.NeedsAttention);
|
||
|
||
var line = pulled == 0 && pushed == 0
|
||
? $"Already up to date across {reports.Count} vaults."
|
||
: $"Synchronised {reports.Count} vaults: {pulled} in, {pushed} out.";
|
||
|
||
return attention == 0 ? line : $"{line} {attention} need attention — see the conflicts list.";
|
||
}
|
||
|
||
private static string Describe(SyncReport report)
|
||
{
|
||
// Said first, and in both branches, because it is the explanation for the numbers after it. A pass
|
||
// reporting "214 in" on a vault nobody has touched all week reads as something having gone wrong;
|
||
// this is what actually happened, and it needs nothing from the reader.
|
||
var replayed = report.ResyncedFromStart
|
||
? "The server no longer recognised this machine's position, so the keychain was read again from "
|
||
+ "the beginning. "
|
||
: string.Empty;
|
||
|
||
if (!report.NeedsAttention)
|
||
{
|
||
return replayed + (report.Pulled == 0 && report.Pushed == 0
|
||
? "Already up to date."
|
||
: $"Synchronised: {report.Pulled} in, {report.Pushed} out.");
|
||
}
|
||
|
||
var notes = new List<string>();
|
||
|
||
// "item(s)", not "host(s)": a vault now holds keys as well, and a report that named the wrong kind
|
||
// would send someone looking through the wrong list for something that was not there.
|
||
if (report.Resurrected > 0)
|
||
{
|
||
notes.Add($"{report.Resurrected} item(s) deleted elsewhere were kept under a new name");
|
||
}
|
||
|
||
if (report.DeletesAbandoned > 0)
|
||
{
|
||
notes.Add($"{report.DeletesAbandoned} deletion(s) were not applied because of a newer edit");
|
||
}
|
||
|
||
if (report.Parked > 0)
|
||
{
|
||
notes.Add($"{report.Parked} change(s) were refused and need attention");
|
||
}
|
||
|
||
if (report.Unreadable > 0)
|
||
{
|
||
notes.Add($"{report.Unreadable} item(s) could not be decrypted");
|
||
}
|
||
|
||
if (report.RekeyRequired)
|
||
{
|
||
// What is readable and what is not, because the two differ and the difference is the whole
|
||
// of what somebody in this state needs to know: the keys they hold still open everything
|
||
// written before the rotation, and nothing written since.
|
||
notes.Add(
|
||
"this keychain was rekeyed — you can still read what was here, and need the new key "
|
||
+ "before you can see anything written since");
|
||
}
|
||
|
||
return replayed + "Synchronised, but: " + string.Join("; ", notes) + ".";
|
||
}
|
||
|
||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||
{
|
||
if (IsBusy)
|
||
{
|
||
return;
|
||
}
|
||
|
||
IsBusy = true;
|
||
Status = busyMessage;
|
||
|
||
try
|
||
{
|
||
await work().ConfigureAwait(true);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
Status = "Cancelled.";
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
Status = exception.Message;
|
||
}
|
||
finally
|
||
{
|
||
IsBusy = false;
|
||
}
|
||
}
|
||
|
||
partial void OnSelectedHostChanged(HostRowViewModel? value)
|
||
{
|
||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||
OnPropertyChanged(nameof(SelectedHostAuthenticationNote));
|
||
OnPropertyChanged(nameof(ShowsConnectBar));
|
||
|
||
// Every field the drawer's detail pane draws. They are properties of the vault rather than of the
|
||
// row because two of them need the group chain read and one needs the keychain searched, and none of
|
||
// that can be done from inside an item template.
|
||
OnPropertyChanged(nameof(SelectedHostPortLabel));
|
||
OnPropertyChanged(nameof(SelectedHostPortIsInherited));
|
||
OnPropertyChanged(nameof(SelectedHostUsernameLabel));
|
||
OnPropertyChanged(nameof(SelectedHostUsernameIsInherited));
|
||
OnPropertyChanged(nameof(SelectedHostBindingLabel));
|
||
OnPropertyChanged(nameof(DrawerSubtitle));
|
||
|
||
// A selection no longer opens the drawer, but losing one still closes it — and takes the flag with
|
||
// it, so that the pane does not spring back open on the next card somebody merely selects. See
|
||
// IsHostPaneOpen.
|
||
if (value is null)
|
||
{
|
||
IsHostPaneOpen = false;
|
||
}
|
||
|
||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||
OnPropertyChanged(nameof(CanMoveSelectedHost));
|
||
|
||
// Kept in step so that selecting a host in code — a reload restoring one, the palette connecting to
|
||
// one — lights the right row. Assigning the same value again is a no-op, so the two do not chase each
|
||
// other.
|
||
SelectedSidebarRow = value;
|
||
|
||
DisarmIfAimedElsewhere(DeletionTarget.Host, value?.EntityId);
|
||
|
||
// The move panel goes with the selection, as the deletion question does — and by entity id for the
|
||
// same reason DisarmIfAimedElsewhere compares them: a background pass replaces every row object in
|
||
// the list, so a panel closed on row identity would fold up once a minute under somebody who was
|
||
// still choosing a vault in it. A click onto a different host is the case that needs handling, and
|
||
// it is cleared rather than re-aimed: which vault to move to is a choice about the host it was
|
||
// asked for.
|
||
if (IsMovingHost && movingHostId != value?.EntityId)
|
||
{
|
||
CancelMoveHostCommand.Execute(null);
|
||
}
|
||
}
|
||
|
||
/// <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;
|
||
}
|
||
}
|
||
|
||
partial void OnSelectedGroupChanged(HostGroupRowViewModel? value)
|
||
{
|
||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||
|
||
OnPropertyChanged(nameof(GroupTarget));
|
||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||
}
|
||
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Leaves the selection alone, which is the change that split one click into two gestures: this fires
|
||
/// from <see cref="OpenGroup"/>, and that command has already dropped the selection before assigning
|
||
/// here — see the property.
|
||
/// </para>
|
||
/// <para>
|
||
/// The cards are rebuilt before the hosts because both are the same move, and the deletion is disarmed
|
||
/// against <see cref="GroupTarget"/> rather than against the group itself: a reload hands this a new row
|
||
/// object for the group already open, and taking a question away from under somebody because the row
|
||
/// behind it was replaced is exactly what <see cref="DisarmIfAimedElsewhere"/> compares ids to avoid.
|
||
/// </para>
|
||
/// </remarks>
|
||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||
{
|
||
DisarmIfAimedElsewhere(DeletionTarget.Group, GroupTarget?.EntityId);
|
||
|
||
OnPropertyChanged(nameof(GroupTarget));
|
||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||
|
||
RebuildGroupLevel();
|
||
RebuildVisibleHosts();
|
||
}
|
||
|
||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||
{
|
||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
||
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
||
OnPropertyChanged(nameof(ShowsHostActions));
|
||
OnPropertyChanged(nameof(ShowsHostPaneActions));
|
||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||
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.
|
||
OnPropertyChanged(nameof(DrawerTitle));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Takes the question away when the selection it was asked about has moved on.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Compared by entity id rather than by row, and that is the whole point of the method. A reload
|
||
/// replaces every row object in the list, so a background pass a minute after the question would
|
||
/// otherwise take the card away from under somebody still reading it — while a click onto a different
|
||
/// item, which is the case that actually needs handling, leaves an armed deletion pointing at something
|
||
/// nobody is looking at any more.
|
||
/// </remarks>
|
||
private void DisarmIfAimedElsewhere(DeletionTarget target, Guid? entityId)
|
||
{
|
||
if (PendingDeletion is { } request && request.Target == target && request.EntityId != entityId)
|
||
{
|
||
PendingDeletion = null;
|
||
}
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Refilled as the box is typed into, which a list this size can afford: the work is one pass over the
|
||
/// hosts already in memory, with no decryption and nothing on disk behind it.
|
||
/// </remarks>
|
||
partial void OnHostFilterChanged(string value) => RebuildVisibleHosts();
|
||
|
||
/// <summary>Adds the tag rows to the table, when the table is showing them.</summary>
|
||
/// <remarks>
|
||
/// Its own method purely for length: <see cref="RebuildVaultItems"/> is one <c>if</c> per kind and a
|
||
/// fifth took it past what this project lets a method be. Split at the newest arm rather than the
|
||
/// prettiest place, so the diff that added it is the diff that moved it.
|
||
/// </remarks>
|
||
private void AddTagRows()
|
||
{
|
||
if (Section is not (VaultSection.All or VaultSection.Tags))
|
||
{
|
||
return;
|
||
}
|
||
|
||
foreach (var tag in Tags)
|
||
{
|
||
VaultItems.Add(new VaultItemRowViewModel(
|
||
VaultItemKind.Tag,
|
||
tag.EntityId,
|
||
tag.Label,
|
||
"TAG",
|
||
tag.Description,
|
||
tag.Badge,
|
||
tag.HasUnsyncedChanges));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Refills the vault table from the typed lists.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Ordered by name inside each kind, and by kind in the merged view — keys, then passwords, then pins.
|
||
/// Not one flat alphabetical run: the three behave completely differently, and a list that interleaved
|
||
/// them would put a pin nobody created between two things somebody did.
|
||
/// <para>
|
||
/// This is where a hidden vault's keys and passwords come off the keychain — the table rather than
|
||
/// <see cref="Keys"/> and <see cref="Credentials"/> themselves, which stay whole for the reason
|
||
/// <see cref="IsVaultShown"/> gives. Tags and buckets are read from the active vault alone, which
|
||
/// cannot be hidden, so neither needs a test of its own.
|
||
/// </para>
|
||
/// </remarks>
|
||
private void RebuildVaultItems()
|
||
{
|
||
var selectedId = SelectedVaultItem?.EntityId;
|
||
|
||
VaultItems.Clear();
|
||
|
||
if (Section is VaultSection.All or VaultSection.Keys)
|
||
{
|
||
foreach (var key in Keys.Where(row => IsVaultShown(row.VaultId)))
|
||
{
|
||
VaultItems.Add(new VaultItemRowViewModel(
|
||
VaultItemKind.Key,
|
||
key.EntityId,
|
||
key.Label,
|
||
"SSH KEY",
|
||
key.Description,
|
||
key.Badge,
|
||
key.HasUnsyncedChanges));
|
||
}
|
||
}
|
||
|
||
if (Section is VaultSection.All or VaultSection.Credentials)
|
||
{
|
||
foreach (var credential in Credentials.Where(row => IsVaultShown(row.VaultId)))
|
||
{
|
||
VaultItems.Add(new VaultItemRowViewModel(
|
||
VaultItemKind.Credential,
|
||
credential.EntityId,
|
||
credential.Label,
|
||
"PASSWORD",
|
||
credential.Description,
|
||
credential.Badge,
|
||
credential.HasUnsyncedChanges));
|
||
}
|
||
}
|
||
|
||
AddTagRows();
|
||
|
||
if (Section is VaultSection.All or VaultSection.Buckets)
|
||
{
|
||
foreach (var store in ObjectStores)
|
||
{
|
||
VaultItems.Add(new VaultItemRowViewModel(
|
||
VaultItemKind.ObjectStore,
|
||
store.EntityId,
|
||
store.Label,
|
||
"BUCKET",
|
||
store.Description,
|
||
store.Badge,
|
||
store.HasUnsyncedChanges));
|
||
}
|
||
}
|
||
|
||
// The selection survives a reload, as every other list's does, and for the same reason: a background
|
||
// sync every minute would otherwise move the detail pane out from under whoever was reading it.
|
||
SelectedVaultItem = VaultItems.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
OnPropertyChanged(nameof(SectionSummary));
|
||
OnPropertyChanged(nameof(HasVaultItems));
|
||
OnPropertyChanged(nameof(TotalItemCount));
|
||
OnPropertyChanged(nameof(EmptySectionMessage));
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Mapped onto the typed selection rather than mirrored into it, and only for the kind selected: leaving
|
||
/// the other two alone means switching category and back does not clear what an editor was pointing at.
|
||
/// </remarks>
|
||
partial void OnSelectedVaultItemChanged(VaultItemRowViewModel? value)
|
||
{
|
||
OnPropertyChanged(nameof(HasSelectedVaultItem));
|
||
OnPropertyChanged(nameof(SelectedItemIsEditable));
|
||
OnPropertyChanged(nameof(SelectedItemIsKey));
|
||
OnPropertyChanged(nameof(SelectedDetailHeading));
|
||
OnPropertyChanged(nameof(ShowsItemActions));
|
||
|
||
// Every kind this table can delete, because one selection covers all of their lists.
|
||
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
||
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
||
DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, value?.EntityId);
|
||
DisarmIfAimedElsewhere(DeletionTarget.Tag, value?.EntityId);
|
||
|
||
switch (value?.Kind)
|
||
{
|
||
case VaultItemKind.Key:
|
||
SelectedKey = Keys.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||
break;
|
||
|
||
case VaultItemKind.Credential:
|
||
SelectedCredential = Credentials.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||
break;
|
||
|
||
case VaultItemKind.ObjectStore:
|
||
SelectedObjectStore = ObjectStores.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||
break;
|
||
|
||
case VaultItemKind.Tag:
|
||
SelectedTag = Tags.FirstOrDefault(row => row.EntityId == value.EntityId);
|
||
break;
|
||
|
||
default:
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <remarks>
|
||
/// The tag editor joins the other four. Without this, arming a key's deletion and then pressing + TAG
|
||
/// left the confirmation card live in the detail pane with the tag's own boxes directly under it — so
|
||
/// the DELETE the user could see belonged to an item they were no longer looking at.
|
||
/// </remarks>
|
||
partial void OnIsEditingTagChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||
|
||
partial void OnPendingChangesChanged(int value) => OnPropertyChanged(nameof(SectionSummary));
|
||
|
||
partial void OnUnreadableItemsChanged(int value)
|
||
{
|
||
OnPropertyChanged(nameof(HasUnreadableItems));
|
||
OnPropertyChanged(nameof(UnreadableSummary));
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Both, on every change. A selector that highlights the showing section and a column that shows the
|
||
/// selected one are the same fact read from two directions, and raising only the one that became true
|
||
/// would leave the other button lit.
|
||
/// </remarks>
|
||
partial void OnSectionChanged(VaultSection value)
|
||
{
|
||
OnPropertyChanged(nameof(ShowsAll));
|
||
OnPropertyChanged(nameof(ShowsKeys));
|
||
OnPropertyChanged(nameof(ShowsCredentials));
|
||
OnPropertyChanged(nameof(ShowsBuckets));
|
||
OnPropertyChanged(nameof(ShowsTags));
|
||
OnPropertyChanged(nameof(SectionTitle));
|
||
|
||
RebuildVaultItems();
|
||
}
|
||
|
||
/// <remarks>
|
||
/// The editing id is set before this flips in every path that opens the editor, and cleared after it
|
||
/// flips back in every path that closes one, so this notification always observes the pair in a
|
||
/// consistent state.
|
||
/// </remarks>
|
||
partial void OnIsEditingChanged(bool value)
|
||
{
|
||
OnPropertyChanged(nameof(CanForgetHostKey));
|
||
OnPropertyChanged(nameof(ShowsHostActions));
|
||
|
||
DisarmOnceAnEditorIsOpen(value);
|
||
}
|
||
|
||
partial void OnIsEditingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||
|
||
partial void OnIsGeneratingKeyChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||
|
||
partial void OnGenerateAlgorithmChanged(SshKeyAlgorithm value)
|
||
{
|
||
OnPropertyChanged(nameof(GeneratesEd25519));
|
||
OnPropertyChanged(nameof(GeneratesRsa));
|
||
}
|
||
|
||
partial void OnIsEditingCredentialChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||
|
||
partial void OnIsEditingObjectStoreChanged(bool value) => DisarmOnceAnEditorIsOpen(value);
|
||
|
||
/// <summary>
|
||
/// Takes the question away when an editor opens over the pane it was asked in.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The sidebar's confirmation replaces the buttons that could open the host editor, so that half cannot
|
||
/// happen; the vault screen's Add buttons stay on screen beside the detail pane, so that half can. One
|
||
/// rule for both, rather than a guard on the three commands that would have to be remembered by the
|
||
/// fourth.
|
||
/// </remarks>
|
||
private void DisarmOnceAnEditorIsOpen(bool opened)
|
||
{
|
||
if (opened)
|
||
{
|
||
PendingDeletion = null;
|
||
}
|
||
}
|
||
|
||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||
|
||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||
}
|