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,245 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One pinned host key, as a row in the list.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The only list of the four whose rows nobody created on purpose. A pin appears because somebody approved a
|
||||
/// fingerprint at the moment of connecting, and it outlives whatever they approved it for — deleting a host
|
||||
/// leaves its pin, and so does changing a host's address. Both are correct as <em>trust</em> decisions: the
|
||||
/// address may still be reached by another host, and a pin is about the endpoint rather than the bookmark.
|
||||
/// What was wrong was that nothing ever showed them.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing here is secret. A host key fingerprint is published by operators on purpose, and the whole point
|
||||
/// of pinning one is to compare it with what they published.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class KnownHostRowViewModel(
|
||||
VaultItem<KnownHostSecret> pin,
|
||||
bool isDialledByAHost,
|
||||
Guid vaultId,
|
||||
string vaultName)
|
||||
{
|
||||
/// <summary>Which vault this pin lives in. See <see cref="HostRowViewModel.VaultId"/>.</summary>
|
||||
internal Guid VaultId => vaultId;
|
||||
|
||||
/// <summary>The vault's display name.</summary>
|
||||
internal string VaultName => vaultName;
|
||||
|
||||
internal Guid EntityId => pin.EntityId;
|
||||
|
||||
internal KnownHostSecret Pin => pin.Secret;
|
||||
|
||||
internal string Host => pin.Secret.Host;
|
||||
|
||||
internal int Port => pin.Secret.Port;
|
||||
|
||||
internal string Algorithm => pin.Secret.Algorithm;
|
||||
|
||||
/// <summary>The endpoint and algorithm, which is what a pin actually identifies.</summary>
|
||||
internal string Label => pin.Secret.Label;
|
||||
|
||||
/// <summary>The fingerprint, in full.</summary>
|
||||
/// <remarks>
|
||||
/// Not truncated. The only thing anybody does with a fingerprint is compare it against one an operator
|
||||
/// published, and a shortened one cannot be compared — it can only be glanced at, which is the habit
|
||||
/// this whole mechanism exists to replace.
|
||||
/// </remarks>
|
||||
internal string Fingerprint => pin.Secret.Fingerprint;
|
||||
|
||||
/// <summary>
|
||||
/// Whether any host in this vault actually dials the endpoint this pin is for.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The reason this list exists rather than a plain enumeration. It is a hint and not a verdict: reaching
|
||||
/// a machine without a bookmark for it is ordinary, so an unmatched pin is worth pointing at and not
|
||||
/// worth deleting on the user's behalf.
|
||||
/// </remarks>
|
||||
internal bool IsDialledByAHost { get; } = isDialledByAHost;
|
||||
|
||||
internal bool HasUnsyncedChanges => pin.HasUnsyncedChanges;
|
||||
|
||||
internal string Badge => IsDialledByAHost
|
||||
? ItemBadge.For(pin.IsBlocked, pin.IsReadOnly, pin.HasUnsyncedChanges)
|
||||
: "no host uses this";
|
||||
|
||||
/// <summary>
|
||||
/// When this pin was approved, as far as anything here can tell.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Derived from the entity id, which this client mints with <see cref="Guid.CreateVersion7()"/> — see
|
||||
/// <see cref="Uuid7Timestamp"/>. No vault item carries a timestamp, so the alternative was no column at
|
||||
/// all. Two honest limits, both stated on the screen rather than only here: it is when the pin was
|
||||
/// created and not when it was last re-approved, and an id minted by anything that does not use v7
|
||||
/// renders as a dash rather than as a guess.
|
||||
/// </remarks>
|
||||
internal string Approved => Uuid7Timestamp.Of(EntityId) is { } stamped
|
||||
? stamped.ToLocalTime().ToString("d MMM yyyy", CultureInfo.CurrentCulture)
|
||||
: "—";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The host keys this keychain has approved, and how to withdraw one.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault rather than a view model of its own.</b> Everything about a pin — reading
|
||||
/// them, forgetting one, pushing the change — already lives on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. Lifting that out would mean re-deriving that wiring and keeping two
|
||||
/// copies of it in step. What is genuinely this screen's own is the part below: a filter and the collection
|
||||
/// it produces, neither of which the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The filter matches fingerprints, deliberately.</b> The workflow this screen exists for is "the
|
||||
/// operator published SHA256:xyz — do I have that one?", and a filter that searched only host names would
|
||||
/// answer a question nobody is asking.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class KnownHostsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
|
||||
internal KnownHostsViewModel(VaultViewModel vault)
|
||||
{
|
||||
this.vault = vault;
|
||||
|
||||
// The vault rebuilds this list on every reload and every sync pass, and a screen showing a stale
|
||||
// copy of a trust decision is the one kind of staleness that matters here.
|
||||
vault.KnownHostPins.CollectionChanged += OnPinsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The pins this filter admits, in the order the vault produced them.</summary>
|
||||
/// <remarks>
|
||||
/// A second collection rather than a filtered view over the first, which is the idiom the host sidebar
|
||||
/// already uses: a view would have to be re-sorted and re-notified anyway, and the vault's own ordering
|
||||
/// — host, then port, then algorithm — is the one worth keeping.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<KnownHostRowViewModel> VisiblePins { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
/// <summary>The row the list has selected, mirrored onto the vault so its command can act on it.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed down rather than duplicated: <c>ForgetPinCommand</c> reads <c>VaultViewModel.SelectedKnownHost</c>
|
||||
/// and there is no reason for it to learn about this screen.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostRowViewModel? selected;
|
||||
|
||||
internal bool HasPins => vault.KnownHostPins.Count > 0;
|
||||
|
||||
internal bool HasVisiblePins => VisiblePins.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>What the whole list amounts to, in one line.</summary>
|
||||
/// <remarks>
|
||||
/// The unused count is the one worth putting here. A pin nothing dials is not a defect — reaching a
|
||||
/// machine without a bookmark for it is ordinary — but it is the only thing about this list a person
|
||||
/// might want to act on, and counting them is cheaper than reading a badge column.
|
||||
/// </remarks>
|
||||
internal string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = vault.KnownHostPins.Count;
|
||||
|
||||
if (total == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var unused = vault.KnownHostPins.Count(pin => !pin.IsDialledByAHost);
|
||||
var pins = total == 1 ? "1 approved host key" : $"{total} approved host keys";
|
||||
|
||||
return unused == 0
|
||||
? pins
|
||||
: string.Create(CultureInfo.CurrentCulture, $"{pins} · {unused} that no host dials");
|
||||
}
|
||||
}
|
||||
|
||||
internal string EmptyMessage => HasPins
|
||||
? "No approved host key matches that."
|
||||
: "Nothing approved yet. The first time you connect to a host, its fingerprint is shown for you to "
|
||||
+ "check — approving it puts it here.";
|
||||
|
||||
/// <summary>Withdraws trust in the selected pin.</summary>
|
||||
/// <remarks>
|
||||
/// Forwarded, because the vault's version does three things in an order that matters: forget, reload,
|
||||
/// then push. The push is the load-bearing one — the machines still refusing to connect to a rebuilt
|
||||
/// server are the other ones.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ForgetSelectedAsync()
|
||||
{
|
||||
if (Selected is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.ForgetPinCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
internal void Detach() => vault.KnownHostPins.CollectionChanged -= OnPinsChanged;
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(KnownHostRowViewModel? value)
|
||||
{
|
||||
vault.SelectedKnownHost = value;
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
}
|
||||
|
||||
private void OnPinsChanged(object? sender, NotifyCollectionChangedEventArgs e) => Rebuild();
|
||||
|
||||
private void Rebuild()
|
||||
{
|
||||
// Captured and restored around the refill, for the reason the host sidebar's rebuild is written the
|
||||
// way it is: Clear() is a Reset the ListBox answers by nulling its own selection, and the binding
|
||||
// writes that null straight back before the refill can matter.
|
||||
var selectedId = Selected?.EntityId;
|
||||
|
||||
VisiblePins.Clear();
|
||||
|
||||
foreach (var pin in vault.KnownHostPins.Where(Matches))
|
||||
{
|
||||
VisiblePins.Add(pin);
|
||||
}
|
||||
|
||||
Selected = VisiblePins.FirstOrDefault(pin => pin.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasPins));
|
||||
OnPropertyChanged(nameof(HasVisiblePins));
|
||||
OnPropertyChanged(nameof(Summary));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
private bool Matches(KnownHostRowViewModel pin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(Filter))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
var needle = Filter.Trim();
|
||||
|
||||
return Contains(pin.Host, needle)
|
||||
|| Contains(pin.Algorithm, needle)
|
||||
|| Contains(pin.Fingerprint, needle)
|
||||
|| Contains(pin.Port.ToString(CultureInfo.InvariantCulture), needle);
|
||||
}
|
||||
|
||||
private static bool Contains(string haystack, string needle) =>
|
||||
haystack.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
Reference in New Issue
Block a user