Public Access
- Choosing a vault while the screen was busy went through a gate that did nothing, so the members
panel kept showing the previous vault's people under the new name. The read now starts regardless;
the selection generation discards answers for an earlier choice.
- Locking or signing out from Settings left the window in settings mode under the lock card, with no
title bar to drag, minimise or close, and unlock returned to Settings. Leaving the unlocked state
leaves Settings and closes the members panel; Escape closes that panel before leaving Settings.
- Both importers reported success when the keychain was busy and skipped the batch, or failed partway.
ImportOutcome now carries refused hosts and why a batch stopped, and the screens say so.
- Host cards: the sync badge took 70 of 214 pixels from the name ("bastion" drew as "bast…", two k8s
workers both as "k8s…"); it now sits on the address line. Tag chips wrap and trim inside the card.
Keychain names trim inside their column instead of printing over TYPE, and the Security page's
caption wraps inside its card.
570 lines
26 KiB
C#
570 lines
26 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Globalization;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using DodoSSH.Client.Domain;
|
|
using DodoSSH.Client.Import;
|
|
|
|
namespace DodoSSH.Client.Shell.ViewModels;
|
|
|
|
/// <summary>One host an import is about to store, with the key file it named if that is coming too.</summary>
|
|
/// <remarks>
|
|
/// The key travels beside the host rather than inside it because the two are separate vault items: a host
|
|
/// carries an <c>SshKeyId</c>, and the id does not exist until the key has been written. So the screen says
|
|
/// which file a host named and the vault decides what id that file ends up with — see
|
|
/// <c>VaultViewModel.ImportHostsAsync</c>, which is where one key per file is enforced.
|
|
/// </remarks>
|
|
/// <param name="Host">The host as it would be stored, with no binding on it yet.</param>
|
|
/// <param name="Key">The key read off the disk, or null when none was asked for or none could be read.</param>
|
|
/// <param name="KeyPath">
|
|
/// The file <paramref name="Key"/> came out of. It is what two hosts naming one key are compared on, so it
|
|
/// travels even though nothing stores it.
|
|
/// </param>
|
|
internal sealed record ImportedHostRequest(HostSecret Host, SshKeySecret? Key, string? KeyPath);
|
|
|
|
/// <summary>What an import stored, and what it did not.</summary>
|
|
/// <param name="Hosts">Hosts written.</param>
|
|
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
|
/// <param name="Refused">Hosts skipped because they would not validate — no hostname, a bad port.</param>
|
|
/// <param name="Failure">Why the batch stopped before the end, or null if it did not.</param>
|
|
/// <remarks>
|
|
/// The last two are what the importing screen needs to not claim a success it did not have. The vault's own
|
|
/// status line says these things too, but it is hidden in settings mode, which is where both importers live.
|
|
/// </remarks>
|
|
internal sealed record ImportOutcome(int Hosts, int Keys, int Refused = 0, string? Failure = null)
|
|
{
|
|
/// <summary>What to add after the screen's own sentence about what was stored.</summary>
|
|
internal string Caveats =>
|
|
(Refused switch
|
|
{
|
|
0 => string.Empty,
|
|
1 => " 1 was skipped because it is not a host this keychain can connect to.",
|
|
_ => $" {Refused} were skipped because they are not hosts this keychain can connect to.",
|
|
})
|
|
+ (Failure is { } failure ? $" The import stopped early: {failure}" : string.Empty);
|
|
}
|
|
|
|
/// <summary>One host an <c>ssh_config</c> offered, as a row somebody decides about.</summary>
|
|
/// <remarks>
|
|
/// The checkbox is the whole point of this type. Nothing is written until somebody has looked at the list
|
|
/// and pressed the button, which is what makes reading a file out of the user's home directory an offer
|
|
/// rather than an action.
|
|
/// </remarks>
|
|
internal sealed partial class ImportRowViewModel : ObservableObject
|
|
{
|
|
private readonly ImportedHost host;
|
|
|
|
internal ImportRowViewModel(ImportedHost host, bool alreadyPresent, bool importsItsKey)
|
|
{
|
|
this.host = host;
|
|
AlreadyPresent = alreadyPresent;
|
|
this.importsItsKey = importsItsKey && HasKeyFile;
|
|
|
|
// A host already in the keychain starts unticked. Importing it again is allowed — a second bookmark
|
|
// for one machine is a thing people genuinely want — but it should take a click rather than be the
|
|
// default.
|
|
IsSelected = !alreadyPresent;
|
|
}
|
|
|
|
internal ImportedHost Host => host;
|
|
|
|
internal string Alias => host.Alias;
|
|
|
|
internal string Address => host.Address;
|
|
|
|
/// <summary>What <c>HostName</c> said, on its own — the v5c table's own column, beside <see cref="User"/>
|
|
/// and <see cref="Port"/> rather than folded into <see cref="Address"/>.</summary>
|
|
internal string Hostname => host.Hostname;
|
|
|
|
/// <summary>What <c>User</c> said, or an em dash where the entry named none.</summary>
|
|
internal string User => host.Username is { Length: > 0 } user ? user : "—";
|
|
|
|
/// <summary>What <c>Port</c> said, defaulting to 22 the same way <see cref="ImportedHost"/> does.</summary>
|
|
internal string Port => host.Port.ToString(CultureInfo.InvariantCulture);
|
|
|
|
/// <summary>Whether a host with this address is already in the keychain.</summary>
|
|
internal bool AlreadyPresent { get; }
|
|
|
|
internal string Badge => AlreadyPresent ? "already here" : string.Empty;
|
|
|
|
internal bool HasBadge => AlreadyPresent;
|
|
|
|
/// <summary>
|
|
/// What the v5c table's WHAT THIS MEANS chip says, mapped honestly off the two facts this row actually
|
|
/// carries — nothing this screen cannot back up. Skipped patterns (a wildcard <c>Host</c> block) never
|
|
/// become a row at all, so there is no third, "skipped" state to draw here; a row's own per-host
|
|
/// warnings, from <see cref="HasWarnings"/>, are the amber case instead — a flattened <c>ProxyJump</c> or
|
|
/// a dropped directive is exactly the kind of thing "quieter than the file" that <c>ImportScreen.axaml</c>'s
|
|
/// own remark says has to be told before it looks like data loss.
|
|
/// </summary>
|
|
internal string Meaning => HasWarnings ? Warnings : AlreadyPresent ? "already here" : "new host";
|
|
|
|
/// <summary>The warned case wins over "already here" — a warning is the more actionable of the two facts.</summary>
|
|
internal bool IsMeaningWarned => HasWarnings;
|
|
|
|
internal bool IsMeaningExisting => !HasWarnings && AlreadyPresent;
|
|
|
|
internal bool IsMeaningNew => !HasWarnings && !AlreadyPresent;
|
|
|
|
/// <summary>Whether this row's <c>ssh_config</c> entry named a key at all.</summary>
|
|
/// <remarks>
|
|
/// Most do not, and the tick above the list is about the ones that do. Kept as a property rather than
|
|
/// asked of <see cref="ImportedHost"/> at each call site so that "this row has a key file" is one
|
|
/// sentence in one place.
|
|
/// </remarks>
|
|
internal bool HasKeyFile => host.IdentityFiles.Count > 0;
|
|
|
|
/// <summary>What this row's <c>IdentityFile</c> named, or empty where it named none.</summary>
|
|
internal string KeyPath => HasKeyFile ? host.IdentityFiles[0] : string.Empty;
|
|
|
|
/// <summary>How this would authenticate, in the terms the preview can honestly offer.</summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// "a key on disk" rather than "a key" when the material is staying where it is, because nothing is
|
|
/// imported then: the path is recorded and the host will ask for a password until somebody binds it to a
|
|
/// keychain key. Saying "key" there would promise a connection that does not work.
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ With the tick on it says the opposite, and has to: the same row now means a key that will be read,
|
|
/// stored and bound, and leaving it reading "on disk" would understate the one thing on this screen
|
|
/// somebody explicitly agreed to.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal string Authentication => (host.IdentityFiles.Count, ImportsItsKey) switch
|
|
{
|
|
(0, _) => "password",
|
|
(_, true) => $"a key, imported · {host.IdentityFiles[0]}",
|
|
(1, _) => $"a key on disk · {host.IdentityFiles[0]}",
|
|
var (count, _) => $"{count} keys on disk · {host.IdentityFiles[0]}",
|
|
};
|
|
|
|
internal bool HasWarnings => host.Warnings.Count > 0;
|
|
|
|
internal string Warnings => string.Join(" ", host.Warnings);
|
|
|
|
[ObservableProperty]
|
|
private bool isSelected;
|
|
|
|
/// <summary>Whether the import will read this row's key file as well as its host.</summary>
|
|
/// <remarks>
|
|
/// Set from the one tick above the list rather than owned per row. A key file is very often shared by a
|
|
/// dozen entries, so a per-row choice would be a dozen ticks deciding one thing — and the decision being
|
|
/// made is about reading <c>~/.ssh</c> at all, which is a decision about the directory rather than about
|
|
/// any host in it.
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(Authentication))]
|
|
private bool importsItsKey;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reading <c>~/.ssh/config</c> and offering what it found.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Two steps, and the first one writes nothing.</b> Scanning reads the file and shows what it means;
|
|
/// importing is a separate press. That split is the feature: an <c>ssh_config</c> is a file this
|
|
/// application did not write and may contain forty entries for machines that no longer exist, so the
|
|
/// interesting question is not "can it be parsed" but "which of these did you actually want".
|
|
/// </para>
|
|
/// <para>
|
|
/// ◆ <b>Private keys are read only if somebody ticks the box, and it starts unticked.</b> Pulling a
|
|
/// <c>~/.ssh/id_ed25519</c> into a keychain is exactly the act this product exists to make deliberate, and
|
|
/// doing it as a side effect of "import my config" would be the wrong default — so it stays a default nobody
|
|
/// gets by accident, and the sentence beside the tick names the directory it will read. What changed is that
|
|
/// it is now <em>possible</em>: an import that recorded a path and left every host asking for a password was
|
|
/// an import whose result did not connect, and the answer to that was a manual paste per key.
|
|
/// </para>
|
|
/// <para>
|
|
/// With the tick on, each host's first <c>IdentityFile</c> is read, stored as a keychain key and bound to
|
|
/// the host. One key per file however many hosts named it, and a file already in the keychain is bound to
|
|
/// rather than stored twice; both of those live in <c>VaultViewModel.ImportHostsAsync</c>. What cannot be
|
|
/// read off a disk is a passphrase, so a protected key arrives without one and the screen says which — see
|
|
/// <see cref="KeyReport"/>.
|
|
/// </para>
|
|
/// </remarks>
|
|
internal sealed partial class ImportViewModel(
|
|
VaultViewModel vault,
|
|
SshConfigLocator locator,
|
|
Action? onCancel = null) : ObservableObject
|
|
{
|
|
internal ObservableCollection<ImportRowViewModel> Rows { get; } = [];
|
|
|
|
/// <summary>What was skipped or flattened, at document level.</summary>
|
|
internal ObservableCollection<string> Warnings { get; } = [];
|
|
|
|
/// <summary>The file this would read, shown so nobody has to guess which one it means.</summary>
|
|
internal string ConfigPath => locator.ConfigPath;
|
|
|
|
[ObservableProperty]
|
|
private string status = string.Empty;
|
|
|
|
[ObservableProperty]
|
|
private bool hasScanned;
|
|
|
|
[ObservableProperty]
|
|
private bool isBusy;
|
|
|
|
/// <summary>
|
|
/// Whether the import should also read the key files the config names.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Off, and it has to be the sort of off that survives somebody not reading the screen. This is the only
|
|
/// control in the application that reads private key material out of a directory the user did not point
|
|
/// at file by file, and the whole of what makes that acceptable is that it took a deliberate press.
|
|
/// </para>
|
|
/// <para>
|
|
/// Not persisted in <c>ClientSettings</c>, deliberately. A remembered "yes" would make the next import —
|
|
/// possibly on another machine, possibly of a colleague's config — read <c>~/.ssh</c> without asking
|
|
/// again, which is the answer to a question that was never put twice.
|
|
/// </para>
|
|
/// </remarks>
|
|
[ObservableProperty]
|
|
private bool importsKeys;
|
|
|
|
/// <summary>What happened to the key files, one line each. Empty until an import has run.</summary>
|
|
/// <remarks>
|
|
/// Reported after the fact rather than previewed, because previewing means reading them — and a screen
|
|
/// that read every key in order to tell you it had not imported them would be doing the thing the tick
|
|
/// exists to gate. So the list is what the import found: which were stored, which are protected by a
|
|
/// passphrase this cannot know, and which were not there at all.
|
|
/// </remarks>
|
|
internal ObservableCollection<string> KeyReport { get; } = [];
|
|
|
|
internal bool HasRows => Rows.Count > 0;
|
|
|
|
internal bool HasWarnings => Warnings.Count > 0;
|
|
|
|
internal bool HasKeyReport => KeyReport.Count > 0;
|
|
|
|
/// <summary>Whether anything in the scan named a key file at all.</summary>
|
|
/// <remarks>
|
|
/// The tick is drawn only when it would do something. A config of forty password hosts has no keys to
|
|
/// offer, and a permanently visible offer to read <c>~/.ssh</c> on a screen where it would read nothing
|
|
/// is a control that teaches people to ignore it.
|
|
/// </remarks>
|
|
internal bool HasKeyFiles => Rows.Any(row => row.HasKeyFile);
|
|
|
|
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
|
|
|
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
|
|
|
/// <summary>Whether every row is ticked — what the v5c table's header tick-all box shows.</summary>
|
|
internal bool AllTicked => Rows.Count > 0 && SelectedCount == Rows.Count;
|
|
|
|
/// <summary>
|
|
/// The v5c header's own mono status line: the file this reads, and whether it has been read yet.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Two real facts and nothing invented — <see cref="ConfigPath"/> and <see cref="HasScanned"/>. The
|
|
/// fuller narrative belongs to <see cref="Status"/>, which this does not replace: what happened on a scan
|
|
/// or an import is a sentence, not a fact this header line has room to state honestly in a handful of
|
|
/// words.
|
|
/// </remarks>
|
|
internal string HeaderStatus => HasScanned ? $"{ConfigPath} · scanned" : $"{ConfigPath} · not scanned yet";
|
|
|
|
/// <summary>
|
|
/// The key-material card's own always-visible sentence, ahead of the tick.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The count is hosts naming a key file, not raw <c>IdentityFile</c> lines — a fact <see cref="Rows"/>
|
|
/// actually carries, where a literal line count would not survive a host that names more than one and is
|
|
/// only ever bound to the first. The rest of the sentence is <c>ImportViewModel</c>'s own long-standing
|
|
/// claim, restated in the design's words after checking it against <c>SshConfigLocator</c>: this type is
|
|
/// the only place in the application that reads a private key out of a directory nobody pointed at file
|
|
/// by file, and <see cref="ImportAsync"/> is the only place that ever calls
|
|
/// <see cref="SshConfigLocator.ReadIdentity"/> — never <see cref="ScanAsync"/> — so nothing is read until
|
|
/// IMPORT is pressed.
|
|
/// </remarks>
|
|
internal string KeyMaterialIntro
|
|
{
|
|
get
|
|
{
|
|
var count = Rows.Count(row => row.HasKeyFile);
|
|
var directory = Path.GetDirectoryName(ConfigPath) ?? ConfigPath;
|
|
var noun = count == 1 ? "host names" : "hosts name";
|
|
|
|
return $"The scan found {count} {noun} a key file in {directory}. This is the only control in "
|
|
+ "DodoSSH that opens key material from a directory you did not point at file by file — "
|
|
+ "nothing is read until Import is pressed.";
|
|
}
|
|
}
|
|
|
|
/// <summary>The vault every import lands in — see <see cref="VaultViewModel.ImportHostsAsync"/>.</summary>
|
|
/// <remarks>
|
|
/// Fixed rather than offered as a picker: the import goes through the same
|
|
/// <c>session.ActiveVaultId</c> every other bulk write does, and there is no per-import target choice to
|
|
/// bind — see design-notes/v5c-fidelity-notes.md. Printed as a fact instead of drawn as a dropdown.
|
|
/// </remarks>
|
|
internal string VaultName => vault.VaultName;
|
|
|
|
/// <summary>The v5c footer's own sentence: how many are ticked, out of how many, and where they land.</summary>
|
|
internal string SelectionSummary
|
|
{
|
|
get
|
|
{
|
|
var noun = Rows.Count == 1 ? "entry" : "entries";
|
|
|
|
return $"{SelectedCount} of {Rows.Count} {noun} selected · saving to {VaultName}";
|
|
}
|
|
}
|
|
|
|
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
|
[RelayCommand]
|
|
private async Task ScanAsync(CancellationToken cancellationToken)
|
|
{
|
|
Rows.Clear();
|
|
Warnings.Clear();
|
|
KeyReport.Clear();
|
|
HasScanned = false;
|
|
|
|
if (!locator.Exists)
|
|
{
|
|
Status = $"There is no {locator.ConfigPath} on this machine.";
|
|
RaiseListState();
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
var import = await locator.ReadAsync(cancellationToken).ConfigureAwait(true);
|
|
|
|
foreach (var host in import.Hosts)
|
|
{
|
|
Rows.Add(new ImportRowViewModel(host, IsAlreadyPresent(host), ImportsKeys));
|
|
}
|
|
|
|
foreach (var warning in import.Warnings)
|
|
{
|
|
Warnings.Add(warning);
|
|
}
|
|
|
|
HasScanned = true;
|
|
|
|
Status = Rows.Count == 0
|
|
? "Nothing in that file could be imported as a host."
|
|
: $"Found {Rows.Count} host(s). Nothing is stored until you press the button below.";
|
|
}
|
|
catch (IOException failure)
|
|
{
|
|
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
|
}
|
|
catch (UnauthorizedAccessException failure)
|
|
{
|
|
Status = $"Could not read {locator.ConfigPath}: {failure.Message}";
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>Stores the ticked hosts, and the keys they name if that was asked for.</summary>
|
|
/// <remarks>
|
|
/// The key files are read here rather than during the scan, and that ordering is the tick's whole
|
|
/// meaning: until this press nothing has opened a private key, so changing your mind after scanning
|
|
/// costs nothing and leaves nothing read.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private async Task ImportAsync(CancellationToken cancellationToken)
|
|
{
|
|
var chosen = Rows.Where(row => row.IsSelected).ToList();
|
|
|
|
if (chosen.Count == 0)
|
|
{
|
|
Status = "Nothing is ticked.";
|
|
return;
|
|
}
|
|
|
|
IsBusy = true;
|
|
|
|
try
|
|
{
|
|
KeyReport.Clear();
|
|
|
|
var requests = chosen.Select(BuildRequest).ToList();
|
|
|
|
var outcome = await vault.ImportHostsAsync(requests, vaultId: null, cancellationToken).ConfigureAwait(true);
|
|
|
|
// Rebuilt rather than cleared, so the rows that were imported now say so — which is what makes
|
|
// pressing the button twice harmless and visible rather than harmless and confusing.
|
|
foreach (var row in Rows.ToList())
|
|
{
|
|
Rows[Rows.IndexOf(row)] =
|
|
new ImportRowViewModel(row.Host, IsAlreadyPresent(row.Host), ImportsKeys);
|
|
}
|
|
|
|
// This screen's own sentence rather than the vault's. The vault writes one too and then pushes,
|
|
// and the push overwrites it with a sync report — which is right on a screen somebody is about
|
|
// to leave, and wrong on the one they are still standing on.
|
|
Status = Describe(outcome);
|
|
}
|
|
finally
|
|
{
|
|
IsBusy = false;
|
|
RaiseListState();
|
|
}
|
|
}
|
|
|
|
/// <summary>What the import did, in this screen's own words.</summary>
|
|
/// <remarks>
|
|
/// The keys are counted separately and named only when there are any, because reading key material is
|
|
/// the half of this somebody agreed to rather than the half they asked for. The count is files, not
|
|
/// hosts: a dozen entries sharing one key is one key, and saying twelve would be describing the config
|
|
/// rather than what was stored.
|
|
/// </remarks>
|
|
private static string Describe(ImportOutcome outcome)
|
|
{
|
|
var material = outcome.Keys switch
|
|
{
|
|
0 => string.Empty,
|
|
1 => " 1 private key came with them, and every host that named it is bound to it.",
|
|
_ => $" {outcome.Keys} private keys came with them, and every host that named one is bound to it.",
|
|
};
|
|
|
|
return $"Imported {outcome.Hosts} host(s). They are on the Hosts screen.{material}{outcome.Caveats}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Turns a ticked row into what the vault stores, reading its key file where that was agreed to.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// The first <c>IdentityFile</c> only, which is the same one <see cref="ImportedHost.ToSecret"/> records
|
|
/// as a directive. A host may list several and ssh tries each in turn; a keychain host binds one key, so
|
|
/// importing all of them would be storing keys nothing is bound to and calling it authentication.
|
|
/// </para>
|
|
/// <para>
|
|
/// Every outcome is a line in <see cref="KeyReport"/> rather than a failure, including the good one. A
|
|
/// key that could not be read leaves the host importable and unbound, which is exactly what the import
|
|
/// did for every host before the tick existed.
|
|
/// </para>
|
|
/// </remarks>
|
|
private ImportedHostRequest BuildRequest(ImportRowViewModel row)
|
|
{
|
|
var host = row.Host.ToSecret();
|
|
|
|
if (!ImportsKeys || !row.HasKeyFile)
|
|
{
|
|
return new ImportedHostRequest(host, Key: null, KeyPath: null);
|
|
}
|
|
|
|
var identity = locator.ReadIdentity(row.KeyPath);
|
|
|
|
if (!identity.WasRead)
|
|
{
|
|
KeyReport.Add($"{row.Alias}: {identity.Path} was not imported — {identity.Failure}.");
|
|
|
|
return new ImportedHostRequest(host, Key: null, KeyPath: null);
|
|
}
|
|
|
|
// Named for the file rather than for the host, because one file is very often a dozen hosts and a
|
|
// key called "web-01" that four other machines also use is a name that misleads on five screens.
|
|
var key = new SshKeySecret
|
|
{
|
|
Label = Path.GetFileName(identity.Path),
|
|
PrivateKeyPem = identity.PrivateKeyPem,
|
|
PublicKey = identity.PublicKey,
|
|
Notes = $"Imported from {identity.Path} with {locator.ConfigPath}.",
|
|
};
|
|
|
|
KeyReport.Add(identity.IsEncrypted
|
|
? $"{row.Alias}: {identity.Path} was imported and is protected by a passphrase. Nothing on "
|
|
+ "disk says what it is, so add it to the key on the keychain screen or the host will not "
|
|
+ "connect."
|
|
: $"{row.Alias}: {identity.Path} was imported.");
|
|
|
|
return new ImportedHostRequest(host, key, identity.Path);
|
|
}
|
|
|
|
/// <summary>Ticks or unticks everything at once.</summary>
|
|
[RelayCommand]
|
|
private void ToggleAll()
|
|
{
|
|
var target = SelectedCount < Rows.Count;
|
|
|
|
foreach (var row in Rows)
|
|
{
|
|
row.IsSelected = target;
|
|
}
|
|
|
|
RaiseListState();
|
|
}
|
|
|
|
internal void NoteSelectionChanged() => RaiseListState();
|
|
|
|
/// <summary>The footer's own Cancel button: back to the Preferences page, nothing stored.</summary>
|
|
/// <remarks>
|
|
/// A delegate rather than a reference up to <c>MainWindowViewModel</c>, on the same reasoning
|
|
/// <c>VaultViewModel</c>'s own <c>copyToClipboard</c> is one: this type has no business knowing settings
|
|
/// mode exists, and a null delegate — nothing wired, as in a layout test that builds this directly — makes
|
|
/// the button a no-op rather than a crash.
|
|
/// </remarks>
|
|
[RelayCommand]
|
|
private void Cancel() => onCancel?.Invoke();
|
|
|
|
/// <remarks>
|
|
/// The rows carry the answer as well as the view model, because each one says what it will authenticate
|
|
/// with and that sentence changes with the tick. Pushed rather than bound per row: a row cannot see a
|
|
/// property of the screen it is in without a parent binding, and that is markup for something the list
|
|
/// already has to be walked for.
|
|
/// </remarks>
|
|
partial void OnImportsKeysChanged(bool value)
|
|
{
|
|
foreach (var row in Rows)
|
|
{
|
|
row.ImportsItsKey = value && row.HasKeyFile;
|
|
}
|
|
}
|
|
|
|
/// <remarks>
|
|
/// Matched on where a host points rather than on what it is called. Two entries with different aliases
|
|
/// for one machine are the ordinary shape of an <c>ssh_config</c>, and matching on the name would offer
|
|
/// to import a duplicate of something already stored under another name.
|
|
/// </remarks>
|
|
/// <summary>
|
|
/// Whether this block describes a machine the vault already has.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Compared against the resolved host, not the stored one.</b> A stored host that takes its port and
|
|
/// username from its group is the same machine as an imported block naming them outright — and comparing
|
|
/// the stored fields would leave it unmatched, so the import screen would offer to add a duplicate of
|
|
/// every host that inherits anything. Duplicates offered by a screen whose whole job is to say what is
|
|
/// new are worse than a missed match: they get accepted.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>An imported block with no <c>Port</c> still pins 22 rather than inheriting</b>, which
|
|
/// <c>SshConfigResolver</c> already does and this deliberately leaves alone. Nothing imported is filed
|
|
/// into a group — there is no group picker here — so an inherited port would resolve to 22 anyway, and
|
|
/// the two would differ only in which of them a later edit to some group could change underneath the
|
|
/// user. An absent <c>Port</c> in an ssh_config means 22; storing that is the faithful reading.
|
|
/// </para>
|
|
/// </remarks>
|
|
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
|
|
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
|
|
&& existing.Resolved.Port.Value == host.Port
|
|
&& string.Equals(
|
|
existing.Resolved.Username.Value, host.Username, StringComparison.OrdinalIgnoreCase));
|
|
|
|
private void RaiseListState()
|
|
{
|
|
OnPropertyChanged(nameof(HasRows));
|
|
OnPropertyChanged(nameof(HasWarnings));
|
|
OnPropertyChanged(nameof(HasKeyFiles));
|
|
OnPropertyChanged(nameof(HasKeyReport));
|
|
OnPropertyChanged(nameof(SelectedCount));
|
|
OnPropertyChanged(nameof(ImportLabel));
|
|
OnPropertyChanged(nameof(AllTicked));
|
|
OnPropertyChanged(nameof(KeyMaterialIntro));
|
|
OnPropertyChanged(nameof(SelectionSummary));
|
|
}
|
|
|
|
/// <remarks>The header's own status line is a function of <see cref="HasScanned"/> alone.</remarks>
|
|
partial void OnHasScannedChanged(bool value) => OnPropertyChanged(nameof(HeaderStatus));
|
|
}
|