Public Access
553 lines
25 KiB
C#
553 lines
25 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.</summary>
|
|
/// <param name="Hosts">Hosts written.</param>
|
|
/// <param name="Keys">Private keys written. Counts files read, not hosts bound to one.</param>
|
|
internal sealed record ImportOutcome(int Hosts, int Keys);
|
|
|
|
/// <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, 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}";
|
|
}
|
|
|
|
/// <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));
|
|
}
|