Public Access
Merge branch 'main' into the Android head
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.
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
using System.Collections.ObjectModel;
|
||||
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 <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)
|
||||
{
|
||||
this.host = host;
|
||||
AlreadyPresent = alreadyPresent;
|
||||
|
||||
// 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>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>How this would authenticate, in the terms the preview can honestly offer.</summary>
|
||||
/// <remarks>
|
||||
/// "a key on disk" rather than "a key", because nothing is imported: the path is recorded and the host
|
||||
/// will ask for a password until somebody binds it to a keychain key. Saying "key" here would promise a
|
||||
/// connection that does not work.
|
||||
/// </remarks>
|
||||
internal string Authentication => host.IdentityFiles.Count switch
|
||||
{
|
||||
0 => "password",
|
||||
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>
|
||||
/// 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>Nothing reads a private key.</b> An <c>IdentityFile</c> becomes a directive and a note recording the
|
||||
/// path. Pulling someone's <c>~/.ssh/id_ed25519</c> into a keychain as a side effect of importing a config
|
||||
/// is the one thing this screen must not do quietly; there is a GENERATE KEY button on the keychain screen
|
||||
/// for making one deliberately, and pasting an existing one is a deliberate act too.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class ImportViewModel(VaultViewModel vault, SshConfigLocator locator) : 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;
|
||||
|
||||
internal bool HasRows => Rows.Count > 0;
|
||||
|
||||
internal bool HasWarnings => Warnings.Count > 0;
|
||||
|
||||
internal int SelectedCount => Rows.Count(row => row.IsSelected);
|
||||
|
||||
internal string ImportLabel => SelectedCount == 1 ? "IMPORT 1 HOST" : $"IMPORT {SelectedCount} HOSTS";
|
||||
|
||||
/// <summary>Reads the file and shows what it found. Writes nothing.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ScanAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Rows.Clear();
|
||||
Warnings.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)));
|
||||
}
|
||||
|
||||
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.</summary>
|
||||
[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
|
||||
{
|
||||
var imported = await vault
|
||||
.ImportHostsAsync([.. chosen.Select(row => row.Host.ToSecret())], 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));
|
||||
}
|
||||
|
||||
Status = $"Imported {imported} host(s). They are on the Hosts screen.";
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
RaiseListState();
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
/// <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>
|
||||
private bool IsAlreadyPresent(ImportedHost host) => vault.Hosts.Any(existing =>
|
||||
string.Equals(existing.Host.Hostname, host.Hostname, StringComparison.OrdinalIgnoreCase)
|
||||
&& existing.Host.Port == host.Port
|
||||
&& string.Equals(existing.Host.Username, host.Username, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
private void RaiseListState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRows));
|
||||
OnPropertyChanged(nameof(HasWarnings));
|
||||
OnPropertyChanged(nameof(SelectedCount));
|
||||
OnPropertyChanged(nameof(ImportLabel));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user