Public Access
6829 lines
295 KiB
C#
6829 lines
295 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>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;
|
||
|
||
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 team 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.
|
||
/// </remarks>
|
||
internal string Display => IsPersonal ? Name : $"{Name} · TEAM";
|
||
}
|
||
|
||
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>
|
||
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) : 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>
|
||
/// 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>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 sidebar is showing: the filter applied, nothing else.</summary>
|
||
/// <remarks>
|
||
/// A second collection rather than a filtered view over the first, because the sidebar's list 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.
|
||
/// </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>
|
||
/// Three answers rather than one, because "there are no hosts", "this group is empty" and "nothing
|
||
/// matches what you typed" are three 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.
|
||
/// </remarks>
|
||
internal string NoVisibleHostsMessage => (Hosts.Count, 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.",
|
||
(_, not null, 0) =>
|
||
"Nothing is filed under this group yet. Press SHOW ALL, then drag a host card onto this group's "
|
||
+ "card — or choose the group in a host's own editor.",
|
||
_ => "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>
|
||
internal ObservableCollection<HostGroupRowViewModel> Groups { get; } = [];
|
||
|
||
/// <summary>The saved commands in this vault, unpushed local state included.</summary>
|
||
/// <remarks>
|
||
/// Held here rather than on the screen that shows them, for the reason every other list is: this is where
|
||
/// the reload and the automatic push are wired, and a second copy of that wiring is a second place for it
|
||
/// to be forgotten. <c>SnippetsViewModel</c> is the filter and the editor over the top.
|
||
/// </remarks>
|
||
internal ObservableCollection<SnippetRowViewModel> Snippets { get; } = [];
|
||
|
||
/// <summary>
|
||
/// What the sidebar's one group heading says.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The vault's name, because the vault is the only grouping a host has — there are no tags and no
|
||
/// folders on <c>HostSecret</c>, and deriving a group from a naming convention would be a guess
|
||
/// presented as structure.
|
||
/// <para>
|
||
/// One heading while one vault is reachable, which is the ordinary case. Since M3 a session can hold
|
||
/// several, and then the heading stops naming one of them and each row names its own — a heading that
|
||
/// went on saying "PERSONAL" over a list containing a team's hosts would be the sort of quiet lie this
|
||
/// interface is otherwise careful about.
|
||
/// </para>
|
||
/// </remarks>
|
||
internal string HostsHeading =>
|
||
session.ReadableVaults.Take(2).Count() > 1 ? "ALL VAULTS" : VaultName.ToUpperInvariant();
|
||
|
||
/// <summary>The SSH keys to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<SshKeyRowViewModel> Keys { get; } = [];
|
||
|
||
/// <summary>The stored credentials to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<CredentialRowViewModel> Credentials { get; } = [];
|
||
|
||
/// <summary>The buckets to show, unpushed local state included.</summary>
|
||
internal ObservableCollection<ObjectStoreRowViewModel> ObjectStores { get; } = [];
|
||
|
||
/// <summary>The tags to show, with the number of hosts wearing each.</summary>
|
||
/// <remarks>
|
||
/// Counted like a group's rows are, and read after the hosts for the same reason: the count is a
|
||
/// property of the host list rather than of the tag.
|
||
/// </remarks>
|
||
internal ObservableCollection<TagRowViewModel> Tags { get; } = [];
|
||
|
||
/// <summary>The host keys this user has approved.</summary>
|
||
internal ObservableCollection<KnownHostRowViewModel> KnownHostPins { get; } = [];
|
||
|
||
/// <summary>Whatever the merge had to override and the user has not acknowledged.</summary>
|
||
internal ObservableCollection<ConflictRowViewModel> Conflicts { get; } = [];
|
||
|
||
internal string VaultName =>
|
||
session.Vaults.FirstOrDefault(vault => vault.VaultId == session.ActiveVaultId)?.Name ?? "Keychain";
|
||
|
||
/// <summary>
|
||
/// The vaults a new item may be filed into: readable, and writable by this account.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Both conditions, not either. A vault this session cannot read has no key to encrypt with, and one
|
||
/// it can read but not write is a team vault this member is a viewer of — offering either would end
|
||
/// in a Save that fails, one of them locally and one at the server.
|
||
/// </remarks>
|
||
internal ObservableCollection<VaultChoiceViewModel> TargetVaults { get; } = [];
|
||
|
||
/// <summary>
|
||
/// Where the next new item goes.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Falls back to the session's active vault, which is the personal one wherever there is one. Filing
|
||
/// into a team's vault has to be chosen, never defaulted into: an item put in the wrong vault is
|
||
/// visible to people who should not have it, and moving it afterwards means deleting and retyping.
|
||
/// </remarks>
|
||
internal Guid TargetVaultId => SelectedTargetVault?.VaultId ?? session.ActiveVaultId;
|
||
|
||
/// <summary>Whether there is more than one vault to choose between.</summary>
|
||
/// <remarks>
|
||
/// The picker is hidden entirely at one, rather than shown disabled. A control offering one option is
|
||
/// a question with no answer, and for most people this stays at one for ever.
|
||
/// </remarks>
|
||
internal bool HasVaultChoice => TargetVaults.Count > 1;
|
||
|
||
[ObservableProperty]
|
||
private VaultChoiceViewModel? selectedTargetVault;
|
||
|
||
[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;
|
||
|
||
[ObservableProperty]
|
||
private HostGroupRowViewModel? selectedGroup;
|
||
|
||
/// <summary>
|
||
/// The group the hosts grid is narrowed to, or null for every host.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// The desktop draws its groups as cards above the hosts, and pressing one narrows what is under it.
|
||
/// This is that choice. <see cref="ClearGroupFilterCommand"/> is the way back to all of them, and it is
|
||
/// an explicit control rather than a second press on the chosen card: the cards are a
|
||
/// <c>ListBox</c> so that the selected one is marked by the same style every other list in this
|
||
/// application uses, and a <c>ListBox</c> does not unselect on a second click.
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Separate from <see cref="SelectedGroup"/>, and it sets it.</b> The two answer different questions —
|
||
/// "what is the grid showing" and "what would EDIT and DELETE act on" — and on the desktop pressing a
|
||
/// card means both, which is why the change handler assigns one from the other. They are not one
|
||
/// property because the phone sets <see cref="SelectedGroup"/> on its own account:
|
||
/// <see cref="EditGroupFromHeading"/> selects a group in order to open its editor, and a single property
|
||
/// would have made opening that editor silently filter the phone's host list to the group being renamed.
|
||
/// </para>
|
||
/// </remarks>
|
||
[ObservableProperty]
|
||
private HostGroupRowViewModel? groupFilter;
|
||
|
||
/// <summary>Whether the grid is showing one group rather than every host.</summary>
|
||
internal bool IsFilteredByGroup => GroupFilter is not null;
|
||
|
||
/// <summary>Shows every host again.</summary>
|
||
[RelayCommand]
|
||
private void ClearGroupFilter()
|
||
{
|
||
GroupFilter = null;
|
||
SelectedGroup = null;
|
||
}
|
||
|
||
/// <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))]
|
||
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))]
|
||
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 || 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 && SelectedHost is not null;
|
||
|
||
/// <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.</summary>
|
||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
||
|
||
[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>Whether the group panel's buttons are showing.</summary>
|
||
internal bool ShowsGroupActions => !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,
|
||
};
|
||
|
||
/// <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>Refills the "file this into" picker from the vaults this session can read and write.</summary>
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </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 = [];
|
||
|
||
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;
|
||
}
|
||
|
||
if (vault.VaultId == session.ActiveVaultId)
|
||
{
|
||
groupItems =
|
||
[.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
||
}
|
||
}
|
||
|
||
groupsById = resolvable;
|
||
|
||
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)
|
||
{
|
||
Tags.Add(new TagRowViewModel(
|
||
tag, Hosts.Count(row => row.Host.TagIds.Contains(tag.EntityId))));
|
||
}
|
||
|
||
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)
|
||
{
|
||
var count = Hosts.Count(row => row.Host.GroupId == group.EntityId);
|
||
|
||
Groups.Add(new HostGroupRowViewModel(group, count));
|
||
}
|
||
|
||
// Never defaulted to the first row, as the key and credential lists are not: this selection is what
|
||
// RENAME and DELETE aim at, and a background sync that picked a group would point them at one nobody
|
||
// chose.
|
||
SelectedGroup = Groups.FirstOrDefault(row => row.EntityId == selectedId);
|
||
|
||
// Re-resolved by id for the reason the selection above is: every row object here is replaced on
|
||
// every reload, so a filter holding the old one would go on narrowing the grid to a group that is no
|
||
// longer in the list — and the card the user could press to clear it would be a different object
|
||
// that never matched. A group deleted by a sync clears the filter, which is the honest answer: the
|
||
// grid comes back to every host rather than to none.
|
||
//
|
||
// This assignment is a new row object whenever there is a filter 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 SelectedGroup and IsFilteredByGroup with it.
|
||
GroupFilter = Groups.FirstOrDefault(row => row.EntityId == filteredId);
|
||
|
||
OnPropertyChanged(nameof(HasGroups));
|
||
}
|
||
|
||
/// <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;
|
||
|
||
VisibleHosts.Clear();
|
||
|
||
foreach (var host in Hosts.Where(Matches))
|
||
{
|
||
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();
|
||
|
||
if (Groups.Count == 0)
|
||
{
|
||
foreach (var host in VisibleHosts)
|
||
{
|
||
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 = VisibleHosts.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);
|
||
}
|
||
|
||
/// <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 Matches(HostRowViewModel row)
|
||
{
|
||
// The group cards, and they narrow before the box does — a host outside the chosen group is out
|
||
// whatever was typed. The two are deliberately not one control: the box is what you type when you
|
||
// know the name, and the cards are what you press when you do not.
|
||
if (GroupFilter is { } group && row.Host.GroupId != group.EntityId)
|
||
{
|
||
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);
|
||
}
|
||
|
||
/// <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.Sync, 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.Sync, 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>
|
||
/// 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.
|
||
/// </remarks>
|
||
private async Task<IReadOnlyList<VaultSyncReport>?> SyncOnceAsync(
|
||
ISyncApi api,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (!await syncGate.WaitAsync(0, cancellationToken).ConfigureAwait(true))
|
||
{
|
||
return null;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 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(api, 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();
|
||
}
|
||
}
|
||
|
||
/// <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>Starts a new host.</summary>
|
||
[RelayCommand]
|
||
private void NewHost()
|
||
{
|
||
IsAddSheetOpen = false;
|
||
|
||
if (AHostEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
editingEntityId = null;
|
||
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();
|
||
|
||
// A new host opens in whichever group is selected beside the list, if one is, because adding three
|
||
// machines to the group somebody has just made is the ordinary case. Before the picker, because
|
||
// whether there is a group to inherit from decides whether the picker offers to.
|
||
BuildGroupChoices(SelectedGroup?.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;
|
||
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();
|
||
|
||
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;
|
||
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 selected group's name into the box, so saving renames it.</summary>
|
||
[RelayCommand]
|
||
private void EditGroup()
|
||
{
|
||
if (SelectedGroup 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.</summary>
|
||
/// <remarks>
|
||
/// 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.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void NewGroup()
|
||
{
|
||
IsAddSheetOpen = false;
|
||
|
||
if (AHostEditorIsInTheWay() || AGroupEditorIsInTheWay())
|
||
{
|
||
return;
|
||
}
|
||
|
||
ClearGroupEditor();
|
||
|
||
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 selected group 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.
|
||
/// </remarks>
|
||
[RelayCommand]
|
||
private void DeleteGroup()
|
||
{
|
||
if (SelectedGroup 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>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>
|
||
private void BuildGroupChoices(Guid? groupId)
|
||
{
|
||
EditorGroupChoices.Clear();
|
||
EditorGroupChoices.Add(GroupChoice.None);
|
||
|
||
foreach (var group in Groups)
|
||
{
|
||
EditorGroupChoices.Add(new GroupChoice(group.EntityId, group.Label));
|
||
}
|
||
|
||
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)
|
||
{
|
||
notes.Add("this keychain was rekeyed and your access needs re-issuing");
|
||
}
|
||
|
||
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));
|
||
|
||
// The drawer opens on a selection and closes when there is none, so both of these move with it.
|
||
OnPropertyChanged(nameof(IsDrawerOpen));
|
||
OnPropertyChanged(nameof(IsShowingHostDetail));
|
||
|
||
// 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);
|
||
}
|
||
|
||
/// <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, value?.EntityId);
|
||
}
|
||
|
||
/// <remarks>
|
||
/// Sets the selection as well as the filter, because on the desktop pressing a card means both — see the
|
||
/// property. Assigning the same value again is a no-op, so this and
|
||
/// <see cref="ClearGroupFilterCommand"/> cannot chase each other.
|
||
/// </remarks>
|
||
partial void OnGroupFilterChanged(HostGroupRowViewModel? value)
|
||
{
|
||
SelectedGroup = value;
|
||
|
||
OnPropertyChanged(nameof(IsFilteredByGroup));
|
||
|
||
RebuildVisibleHosts();
|
||
}
|
||
|
||
partial void OnPendingDeletionChanged(DeletionRequest? value)
|
||
{
|
||
OnPropertyChanged(nameof(IsConfirmingDeletion));
|
||
OnPropertyChanged(nameof(IsConfirmingHostDeletion));
|
||
OnPropertyChanged(nameof(IsConfirmingGroupDeletion));
|
||
OnPropertyChanged(nameof(ShowsHostActions));
|
||
OnPropertyChanged(nameof(ShowsGroupActions));
|
||
OnPropertyChanged(nameof(ShowsItemActions));
|
||
}
|
||
|
||
partial void OnEditingGroupIdChanged(Guid? value) => OnPropertyChanged(nameof(GroupSaveLabel));
|
||
|
||
/// <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.
|
||
/// </remarks>
|
||
private void RebuildVaultItems()
|
||
{
|
||
var selectedId = SelectedVaultItem?.EntityId;
|
||
|
||
VaultItems.Clear();
|
||
|
||
if (Section is VaultSection.All or VaultSection.Keys)
|
||
{
|
||
foreach (var key in Keys)
|
||
{
|
||
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)
|
||
{
|
||
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));
|
||
}
|