Public Access
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
4660 lines
193 KiB
C#
4660 lines
193 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 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 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,
|
|
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;
|
|
|
|
internal HostSecret Host => host.Secret;
|
|
|
|
internal string Label => host.Secret.Label;
|
|
|
|
internal string Address => string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{host.Secret.Username ?? "—"}@{host.Secret.Hostname}:{host.Secret.Port}");
|
|
|
|
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>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal string Authentication => host.Secret switch
|
|
{
|
|
{ CredentialId: not null } => "credential",
|
|
{ SshKeyId: not null } => "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, and never stored.</summary>
|
|
Typed,
|
|
|
|
/// <summary>An SSH key in this vault.</summary>
|
|
SshKey,
|
|
|
|
/// <summary>A username and password in this vault.</summary>
|
|
Credential,
|
|
}
|
|
|
|
/// <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>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(uint sessionId, string label, string address) : EventArgs
|
|
{
|
|
internal uint SessionId { get; } = sessionId;
|
|
|
|
internal string Label { get; } = label;
|
|
|
|
internal string Address { get; } = address;
|
|
}
|
|
|
|
/// <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>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>
|
|
/// 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 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 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>
|
|
/// 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>Whether the host list under the heading is folded away.</summary>
|
|
[ObservableProperty]
|
|
private bool areHostsExpanded = true;
|
|
|
|
/// <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 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>What the group name box holds, for both creating and renaming.</summary>
|
|
[ObservableProperty]
|
|
private string groupEditorLabel = string.Empty;
|
|
|
|
/// <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 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;
|
|
|
|
/// <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",
|
|
_ => "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>
|
|
internal int TotalItemCount => Keys.Count + Credentials.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;
|
|
|
|
/// <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.",
|
|
_ => "Nothing in the keychain but your hosts. Add an SSH key or a password to stop typing one.",
|
|
};
|
|
|
|
// ---- The editor ----
|
|
|
|
[ObservableProperty]
|
|
private bool isEditing;
|
|
|
|
[ObservableProperty]
|
|
private string editorLabel = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private string editorHostname = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private int editorPort = HostSecret.DefaultPort;
|
|
|
|
[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]
|
|
private GroupChoice? editorSelectedGroup;
|
|
|
|
/// <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>
|
|
internal string GroupSaveLabel => EditingGroupId is null ? "ADD" : "RENAME";
|
|
|
|
/// <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, never persisted, and now only reached by a host bound to nothing. It stays because
|
|
/// not every password is worth storing — a one-off on a machine somebody will never open again, or one
|
|
/// they would rather this vault did not hold — and because a credential has to be created before it can be
|
|
/// bound, which means the first connection to a new host happens through this box.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private string connectPassword = string.Empty;
|
|
|
|
/// <summary>
|
|
/// Whether the selected host will want something typed into the password box.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal bool SelectedHostAsksForAPassword =>
|
|
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
|
|
|
/// <summary>
|
|
/// What the terminal column says in place of the password box, or nothing when the box is showing.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
internal string SelectedHostAuthenticationNote => SelectedHost?.Host switch
|
|
{
|
|
{ CredentialId: not null } => "This host uses a password stored in your keychain.",
|
|
{ SshKeyId: not null } => "This host authenticates with its SSH key.",
|
|
_ => 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;
|
|
|
|
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.
|
|
var unreadable = await ReloadGroupsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
unreadable += await ReloadHostsAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
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;
|
|
|
|
rows.AddRange(listing.Items.Select(
|
|
item => new HostRowViewModel(item, 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,
|
|
}));
|
|
}
|
|
|
|
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>The active vault only, unlike every other list on this screen.</b> Hosts, keys, credentials and
|
|
/// pins are read across every vault this session holds a key for; groups are not, so a host in a team's
|
|
/// vault that a teammate filed appears under UNGROUPED. That is the same thing the sidebar already shows
|
|
/// for a group that has been deleted, and it is deliberate here rather than an oversight: reading them
|
|
/// across vaults means a group row has to carry the vault it lives in — 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>
|
|
/// </remarks>
|
|
private async Task<int> ReloadGroupsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var listing = await session.HostGroups
|
|
.ListAsync(session.ActiveVaultId, cancellationToken)
|
|
.ConfigureAwait(true);
|
|
|
|
groupItems = [.. listing.Items.OrderBy(group => group.Secret.Label, StringComparer.CurrentCulture)];
|
|
|
|
return listing.Unreadable;
|
|
}
|
|
|
|
/// <summary>Refills <see cref="Groups"/>, counting the hosts filed under each.</summary>
|
|
private void RebuildGroups()
|
|
{
|
|
var selectedId = SelectedGroup?.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);
|
|
|
|
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;
|
|
}
|
|
|
|
/// <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;
|
|
}
|
|
|
|
/// <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)
|
|
{
|
|
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, host.Host.Port))
|
|
.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>Starts a new host.</summary>
|
|
[RelayCommand]
|
|
private void NewHost()
|
|
{
|
|
if (AHostEditorIsInTheWay())
|
|
{
|
|
return;
|
|
}
|
|
|
|
editingEntityId = null;
|
|
editingHostVaultId = TargetVaultId;
|
|
EditorLabel = string.Empty;
|
|
EditorHostname = string.Empty;
|
|
EditorPort = HostSecret.DefaultPort;
|
|
EditorUsername = string.Empty;
|
|
EditorNotes = string.Empty;
|
|
EditorRelayEnabled = false;
|
|
BuildAuthenticationChoices(boundKeyId: null, boundCredentialId: null);
|
|
|
|
// 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.
|
|
BuildGroupChoices(SelectedGroup?.EntityId);
|
|
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;
|
|
EditorPort = row.Host.Port;
|
|
EditorUsername = row.Host.Username ?? string.Empty;
|
|
EditorNotes = row.Host.Notes ?? string.Empty;
|
|
EditorRelayEnabled = row.Host.RelayEnabled;
|
|
BuildAuthenticationChoices(row.Host.SshKeyId, row.Host.CredentialId);
|
|
BuildGroupChoices(row.Host.GroupId);
|
|
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;
|
|
|
|
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;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// <summary>Folds the host list away, or brings it back.</summary>
|
|
[RelayCommand]
|
|
private void ToggleHosts() => AreHostsExpanded = !AreHostsExpanded;
|
|
|
|
/// <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() };
|
|
|
|
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);
|
|
}
|
|
|
|
GroupEditorLabel = string.Empty;
|
|
EditingGroupId = null;
|
|
|
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
Status = renaming is null ? $"Added the group '{group.Label}'." : $"Renamed to '{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;
|
|
Status = $"Renaming {row.Label}.";
|
|
}
|
|
|
|
/// <summary>Abandons a rename, leaving the box ready to create one instead.</summary>
|
|
[RelayCommand]
|
|
private void CancelGroupEdit()
|
|
{
|
|
EditingGroupId = null;
|
|
GroupEditorLabel = string.Empty;
|
|
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(host => host.SshKeyId, 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>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(host => host.CredentialId, 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;
|
|
|
|
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>
|
|
/// 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" />.
|
|
/// </remarks>
|
|
private string HostsBoundTo(Func<HostSecret, Guid?> binding, Guid entityId)
|
|
{
|
|
var bound = Hosts
|
|
.Where(row => binding(row.Host) == 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>
|
|
[RelayCommand]
|
|
private async Task ConnectAsync(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, ConnectPassword, out var authentication, out var refusal))
|
|
{
|
|
Status = refusal;
|
|
return;
|
|
}
|
|
|
|
PendingHostKey = null;
|
|
HostKeyMismatch = null;
|
|
|
|
await RunAsync(
|
|
$"Connecting to {row.Label}…",
|
|
() => OpenSessionAsync(row, 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;
|
|
|
|
await ConnectAsync(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;
|
|
var port = row.Host.Port;
|
|
|
|
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);
|
|
}
|
|
|
|
/// <remarks>
|
|
/// 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…".
|
|
/// </remarks>
|
|
private async Task OpenSessionAsync(
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
await ConnectAndAnnounceAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
|
}
|
|
catch (TimeoutException)
|
|
{
|
|
// The renderer never attached, so nothing was connected. Reported here rather than left to
|
|
// RunAsync's generic handler because 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.
|
|
Status = "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)
|
|
{
|
|
// First contact. The user has to decide, and they need the fingerprint to do it.
|
|
//
|
|
// Deliberately not logged. Nothing was refused and nothing failed — 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.
|
|
PendingHostKey = exception.Presentation;
|
|
Status = "This host has not been seen before.";
|
|
}
|
|
catch (SshHostKeyMismatchException exception)
|
|
{
|
|
// Logged, and this is the entry the connection log most exists for. A changed host key is
|
|
// refused outright with no way past it, so the only trace it would otherwise leave is a status
|
|
// line the user dismisses — and a run of these against one machine is what somebody reviewing a
|
|
// log needs to see.
|
|
RecordFailure(row, authentication, ConnectionOutcome.Refused);
|
|
|
|
HostKeyMismatch = exception.Message;
|
|
Status = "The host key has changed. The connection was refused.";
|
|
}
|
|
catch (Exception exception) when (exception is not OperationCanceledException)
|
|
{
|
|
// Everything else: an unreachable host, a rejected password, a key the remote will not take.
|
|
// Caught by shape rather than by type because this project's SSH layer defines only the two
|
|
// host-key exceptions above and everything else arrives from SSH.NET, which the client
|
|
// deliberately does not reference.
|
|
//
|
|
// Recorded and rethrown, so RunAsync goes on reporting it exactly as it did. The log is an
|
|
// observer here and must never become the thing that swallows an error. Cancellation is excluded
|
|
// because a user who gave up did not fail to connect.
|
|
RecordFailure(row, authentication, ConnectionOutcome.Failed);
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <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(
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
var request = new SshConnectionRequest(
|
|
row.Host.Hostname,
|
|
row.Host.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.
|
|
connectionLog?.Identify(sessionId, row.Label, row.EntityId);
|
|
|
|
Status = $"Connected to {row.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(sessionId, row.Label, Dialled(row, authentication)));
|
|
}
|
|
|
|
/// <summary>The address as actually dialled.</summary>
|
|
/// <remarks>
|
|
/// Built from what was dialled rather than from the host's own fields, because a bound credential can
|
|
/// supply the username — so a host saved with no username of its own still has one here, and it is the
|
|
/// one the remote saw.
|
|
/// </remarks>
|
|
private static string Dialled(HostRowViewModel row, HostAuthentication authentication) =>
|
|
string.Create(
|
|
CultureInfo.InvariantCulture,
|
|
$"{authentication.Username}@{row.Host.Hostname}:{row.Host.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(
|
|
HostRowViewModel row,
|
|
HostAuthentication authentication,
|
|
ConnectionOutcome outcome)
|
|
{
|
|
var at = TimeProvider.System.GetUtcNow();
|
|
|
|
connectionLog?.Record(
|
|
Dialled(row, authentication),
|
|
row.Label,
|
|
row.EntityId,
|
|
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>
|
|
/// 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)
|
|
{
|
|
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
|
|
{
|
|
request = null;
|
|
return false;
|
|
}
|
|
|
|
request = new SshConnectionRequest(
|
|
host.Hostname, host.Port, authentication.Username, authentication.Credential);
|
|
|
|
return true;
|
|
}
|
|
|
|
private bool TryBuildAuthentication(
|
|
HostSecret host,
|
|
string typedPassword,
|
|
[NotNullWhen(true)] out HostAuthentication? authentication,
|
|
[NotNullWhen(false)] out string? reason)
|
|
{
|
|
if (host.CredentialId is { } credentialId)
|
|
{
|
|
if (Credentials.FirstOrDefault(row => row.EntityId == credentialId) is not { } credential)
|
|
{
|
|
return Refuse(
|
|
$"'{host.Label}' authenticates with a credential that is not in this keychain any more. "
|
|
+ "Edit the host to choose another one, or set it back to a typed password.",
|
|
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 host's covers the ordinary
|
|
// case of a shared password used under each machine's own account.
|
|
return Complete(
|
|
credential.Credential.Username ?? host.Username,
|
|
new SshPasswordCredential(credential.Credential.Password),
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
if (host.SshKeyId is { } keyId)
|
|
{
|
|
if (Keys.FirstOrDefault(row => row.EntityId == keyId) is not { } key)
|
|
{
|
|
return Refuse(
|
|
$"'{host.Label}' authenticates with an SSH key that is not in this keychain any more. "
|
|
+ "Edit the host to choose another key, or set it back to a password.",
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
return Complete(
|
|
host.Username,
|
|
new SshPrivateKeyCredential(
|
|
Encoding.UTF8.GetBytes(key.Key.PrivateKeyPem), key.Key.Passphrase),
|
|
out authentication,
|
|
out reason);
|
|
}
|
|
|
|
return Complete(
|
|
host.Username, 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>
|
|
/// 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.
|
|
/// </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(),
|
|
Port = EditorPort,
|
|
Username = string.IsNullOrWhiteSpace(EditorUsername) ? null : EditorUsername.Trim(),
|
|
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
|
RelayEnabled = EditorRelayEnabled,
|
|
|
|
// 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>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
private void BuildAuthenticationChoices(Guid? boundKeyId, Guid? boundCredentialId)
|
|
{
|
|
EditorAuthenticationChoices.Clear();
|
|
EditorAuthenticationChoices.Add(AuthenticationChoice.Typed);
|
|
|
|
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);
|
|
}
|
|
|
|
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>
|
|
/// 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.
|
|
/// </remarks>
|
|
private AuthenticationChoice Selected(Guid? boundKeyId, Guid? boundCredentialId) =>
|
|
(boundKeyId, boundCredentialId) switch
|
|
{
|
|
({ } key, _) => Find(AuthenticationKind.SshKey, key),
|
|
(_, { } credential) => Find(AuthenticationKind.Credential, credential),
|
|
_ => AuthenticationChoice.Typed,
|
|
};
|
|
|
|
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) 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.",
|
|
_ => Status,
|
|
};
|
|
|
|
return IsEditingKey || IsEditingCredential || IsGeneratingKey || IsEditingObjectStore;
|
|
}
|
|
|
|
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));
|
|
|
|
// 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);
|
|
}
|
|
|
|
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>
|
|
/// 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));
|
|
}
|
|
}
|
|
|
|
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));
|
|
|
|
// Both kinds this table can delete, because one selection covers both lists.
|
|
DisarmIfAimedElsewhere(DeletionTarget.Key, value?.EntityId);
|
|
DisarmIfAimedElsewhere(DeletionTarget.Credential, value?.EntityId);
|
|
DisarmIfAimedElsewhere(DeletionTarget.ObjectStore, 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;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
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(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));
|
|
}
|