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:
@@ -27,6 +27,19 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Terminal/DodoSSH.Client.Terminal.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.Transfer/DodoSSH.Client.Transfer.csproj" />
|
||||
|
||||
<!--
|
||||
Both arrived with the view models rather than being chosen here. ImportViewModel reads an
|
||||
~/.ssh/config, and TransfersViewModel puts a bucket behind IRemoteFileStore beside an SFTP host.
|
||||
|
||||
Worth knowing for the Android head, which gets both transitively and will use neither at first:
|
||||
scoped storage means there is no ~/.ssh/config to find, and file transfer is out of its first
|
||||
scope by decision. Neither is a problem — they are managed assemblies that simply go unused — but
|
||||
the day the phone grows a file screen, the bucket is the half that ports and the local pane is not.
|
||||
See docs/android-port.md.
|
||||
-->
|
||||
<ProjectReference Include="../DodoSSH.Client.Import/DodoSSH.Client.Import.csproj" />
|
||||
<ProjectReference Include="../DodoSSH.Client.ObjectStore/DodoSSH.Client.ObjectStore.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Sync;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Which log the screen is showing.</summary>
|
||||
internal enum LogSection
|
||||
{
|
||||
/// <summary>Connections that were made.</summary>
|
||||
Connections,
|
||||
|
||||
/// <summary>Changes made to keychain items.</summary>
|
||||
Activity,
|
||||
}
|
||||
|
||||
/// <summary>One connection, as a row.</summary>
|
||||
internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> entry, bool isLive)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string HostLabel => entry.Secret.HostLabel;
|
||||
|
||||
internal string Address => entry.Secret.Address;
|
||||
|
||||
/// <summary>When it started, in the reader's own conventions.</summary>
|
||||
/// <remarks>
|
||||
/// The user's locale, unlike the transfers screen's deliberately invariant UTC column — and the
|
||||
/// difference is the reason each is right. There, two panes are read against one another and a
|
||||
/// sortable, unambiguous format wins; here there is one column and it answers "when was I on that
|
||||
/// machine", which is a question about the reader's own day. <c>InvariantGlobalization</c> is false in
|
||||
/// the client csproj precisely so this works.
|
||||
/// </remarks>
|
||||
internal string Started =>
|
||||
entry.Secret.StartedAt.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>
|
||||
/// How long it lasted, or that it has not finished.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>"still open" and not a dash.</b> A dash reads as "nothing was recorded", and the two are opposite
|
||||
/// facts — one is an entry the log is missing, the other is a connection that is happening now. A live
|
||||
/// session has no entry at all until it closes, so this state comes from the workspace rather than from
|
||||
/// the vault; see <see cref="LogsViewModel"/>.
|
||||
/// </remarks>
|
||||
internal string Duration => isLive
|
||||
? "still open"
|
||||
: Humanise(entry.Secret.Duration);
|
||||
|
||||
internal bool IsLive => isLive;
|
||||
|
||||
internal string Outcome => entry.Secret.Outcome switch
|
||||
{
|
||||
ConnectionOutcome.Failed => "failed",
|
||||
ConnectionOutcome.Refused => "host key refused",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
internal bool HasOutcome => Outcome.Length > 0;
|
||||
|
||||
/// <summary>Whether this was a terminal or the file browser.</summary>
|
||||
internal string Kind => entry.Secret.Kind is ConnectionKind.Sftp ? "files" : "terminal";
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
|
||||
/// <remarks>
|
||||
/// Rounded to whole units and never to more than two of them. A connection log is read to answer "about
|
||||
/// how long was I on that machine", and "1h 4m" answers it where "1:04:37.482" makes the reader do the
|
||||
/// rounding themselves.
|
||||
/// </remarks>
|
||||
private static string Humanise(TimeSpan duration)
|
||||
{
|
||||
if (duration < TimeSpan.FromMinutes(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalSeconds}s");
|
||||
}
|
||||
|
||||
if (duration < TimeSpan.FromHours(1))
|
||||
{
|
||||
return string.Create(CultureInfo.CurrentCulture, $"{(int)duration.TotalMinutes}m");
|
||||
}
|
||||
|
||||
return string.Create(
|
||||
CultureInfo.CurrentCulture, $"{(int)duration.TotalHours}h {duration.Minutes}m");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One keychain change, as a row.</summary>
|
||||
internal sealed class ActivityLogRowViewModel(VaultItem<ActivityLogSecret> entry)
|
||||
{
|
||||
internal Guid EntityId => entry.EntityId;
|
||||
|
||||
internal string ItemLabel => entry.Secret.ItemLabel;
|
||||
|
||||
internal string ItemKind => entry.Secret.ItemKind;
|
||||
|
||||
internal string Operation => entry.Secret.Operation switch
|
||||
{
|
||||
ActivityOperation.Created => "created",
|
||||
ActivityOperation.Deleted => "deleted",
|
||||
_ => "changed",
|
||||
};
|
||||
|
||||
/// <inheritdoc cref="ConnectionLogRowViewModel.Started" />
|
||||
internal string At => entry.Secret.At.ToLocalTime().ToString("g", CultureInfo.CurrentCulture);
|
||||
|
||||
/// <summary>Which fields changed. Never what they changed to.</summary>
|
||||
internal string ChangedFields => entry.Secret.ChangedFields;
|
||||
|
||||
internal bool HasChangedFields => ChangedFields.Length > 0;
|
||||
|
||||
internal string DeviceName => entry.Secret.DeviceName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What has been connected to, and what has been changed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// A wrapper over the vault, as the pins and snippets screens are. What is its own is the two lists, the
|
||||
/// section switch and one thing neither log knows: which connections are happening <em>now</em>. An entry is
|
||||
/// written once, when a connection closes, so a live session is not in the vault at all — it is in the
|
||||
/// workspace, and this screen is where the two are put side by side.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Read on demand rather than kept in step.</b> Unlike the host list, a log is not something a background
|
||||
/// sync has to keep fresh on screen — nobody is waiting for their own connection from an hour ago to appear
|
||||
/// — and reading two full logs on every pass would decrypt thousands of entries a minute for a screen
|
||||
/// nobody is looking at.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class LogsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultSession session;
|
||||
private readonly Func<IReadOnlyList<LiveConnection>> live;
|
||||
|
||||
/// <param name="session">The open vault, which holds both logs.</param>
|
||||
/// <param name="live">
|
||||
/// The connections that are open right now. A function rather than a list, because tabs open and close
|
||||
/// while this screen is showing and it is not told about either.
|
||||
/// </param>
|
||||
internal LogsViewModel(VaultSession session, Func<IReadOnlyList<LiveConnection>> live)
|
||||
{
|
||||
this.session = session;
|
||||
this.live = live;
|
||||
}
|
||||
|
||||
/// <summary>Connections, newest first, with anything still open at the top.</summary>
|
||||
internal ObservableCollection<ConnectionLogRowViewModel> Connections { get; } = [];
|
||||
|
||||
/// <summary>Keychain changes, newest first.</summary>
|
||||
internal ObservableCollection<ActivityLogRowViewModel> Activity { get; } = [];
|
||||
|
||||
/// <remarks>
|
||||
/// Settable, and the markup binds two buttons to a command rather than a selector's selection — the same
|
||||
/// idiom the keychain screen's categories use, and for the same reason: a selection binding moves before
|
||||
/// a command can refuse it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private LogSection section;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool ShowsConnections => Section is LogSection.Connections;
|
||||
|
||||
internal bool ShowsActivity => Section is LogSection.Activity;
|
||||
|
||||
internal bool HasConnections => Connections.Count > 0;
|
||||
|
||||
internal bool HasActivity => Activity.Count > 0;
|
||||
|
||||
internal string EmptyMessage => Section is LogSection.Connections
|
||||
? "Nothing here yet. A connection is recorded when it closes, so an open terminal appears at the "
|
||||
+ "top and gets its line when you close the tab."
|
||||
: "Nothing here yet. Adding, editing or deleting anything in the keychain is recorded here — the "
|
||||
+ "names of the fields that changed, never their contents.";
|
||||
|
||||
/// <summary>Shows one of the two logs.</summary>
|
||||
[RelayCommand]
|
||||
private void ShowSection(LogSection section) => Section = section;
|
||||
|
||||
/// <summary>Re-reads both logs.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
Status = string.Empty;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Leaving the screen.
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads both logs into the lists.</summary>
|
||||
internal async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var connections = await session.ConnectionLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
var activity = await session.ActivityLog
|
||||
.ListAsync(session.ActiveVaultId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Connections.Clear();
|
||||
|
||||
// The live ones first and above everything, because they are the only rows in this list that are
|
||||
// still changing. They carry no entity id — there is no vault item for them yet — which is why they
|
||||
// are built from a different source and marked as live rather than merged into the same shape.
|
||||
foreach (var open in live())
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(
|
||||
new VaultItem<ConnectionLogSecret>(
|
||||
Guid.Empty,
|
||||
new ConnectionLogSecret
|
||||
{
|
||||
HostLabel = open.HostLabel,
|
||||
Address = open.Address,
|
||||
StartedAt = open.StartedAt,
|
||||
DeviceName = open.DeviceName,
|
||||
},
|
||||
Version: 0,
|
||||
HasUnsyncedChanges: false,
|
||||
IsBlocked: false,
|
||||
IsReadOnly: false),
|
||||
isLive: true));
|
||||
}
|
||||
|
||||
foreach (var entry in connections.Items.OrderByDescending(item => item.Secret.StartedAt))
|
||||
{
|
||||
Connections.Add(new ConnectionLogRowViewModel(entry, isLive: false));
|
||||
}
|
||||
|
||||
Activity.Clear();
|
||||
|
||||
foreach (var entry in activity.Items.OrderByDescending(item => item.Secret.At))
|
||||
{
|
||||
Activity.Add(new ActivityLogRowViewModel(entry));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasConnections));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
|
||||
partial void OnSectionChanged(LogSection value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsConnections));
|
||||
OnPropertyChanged(nameof(ShowsActivity));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A connection that is open right now.</summary>
|
||||
/// <param name="HostLabel">What the host is called.</param>
|
||||
/// <param name="Address">The address as dialled.</param>
|
||||
/// <param name="StartedAt">When it opened.</param>
|
||||
/// <param name="DeviceName">This machine.</param>
|
||||
/// <remarks>
|
||||
/// Supplied by the shell, which owns the tabs. It is deliberately not read out of the vault: a connection
|
||||
/// that is still running has no entry there, because an entry is written once and at close — which is what
|
||||
/// keeps a synced log from needing a merge.
|
||||
/// </remarks>
|
||||
internal sealed record LiveConnection(
|
||||
string HostLabel,
|
||||
string Address,
|
||||
DateTimeOffset StartedAt,
|
||||
string DeviceName);
|
||||
@@ -6,6 +6,8 @@ using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Import;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
@@ -61,7 +63,7 @@ internal enum ShellState
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
{
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
/// <summary>The host list, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
@@ -75,6 +77,52 @@ internal enum ShellScreen
|
||||
|
||||
/// <summary>Preferences.</summary>
|
||||
Preferences = 4,
|
||||
|
||||
/// <summary>The host keys this keychain has approved.</summary>
|
||||
/// <remarks>
|
||||
/// Appended rather than slotted in beside the keychain screen it came out of. These values are written
|
||||
/// into <c>NavRail.axaml</c> as <c>x:Static</c> literals and read by tests; renumbering them would be a
|
||||
/// silent change to what every one of those means.
|
||||
/// </remarks>
|
||||
KnownHosts = 5,
|
||||
|
||||
/// <summary>Importing hosts from the machine's own <c>~/.ssh/config</c>.</summary>
|
||||
/// <remarks>
|
||||
/// Reachable from preferences and not from the nav rail, unlike every other member here. It is a task
|
||||
/// done once rather than a place to be, and a seventh rail entry would cost every screen a slot for
|
||||
/// something almost nobody is looking at.
|
||||
/// </remarks>
|
||||
Import = 6,
|
||||
|
||||
/// <summary>The saved commands in this keychain.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Snippets = 7,
|
||||
|
||||
/// <summary>What has been connected to, and what has been changed.</summary>
|
||||
/// <inheritdoc cref="KnownHosts" path="/remarks" />
|
||||
Logs = 8,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What the area beside the nav rail is showing: one of the rail's screens, or a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two properties rather than a sixth <see cref="ShellScreen"/>, and the reason is that a terminal is not a
|
||||
/// destination in the same sense the rail's entries are. The tab strip is always visible, so a terminal can
|
||||
/// be opened from any screen — and when it is dismissed the user expects to be back where they were, which
|
||||
/// means "which page" has to survive "a terminal is showing". Folding the terminal into
|
||||
/// <see cref="ShellScreen"/> would need a private field remembering the page underneath, which is this pair
|
||||
/// with one half hidden.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellSurface
|
||||
{
|
||||
/// <summary>The screen named by <see cref="MainWindowViewModel.Screen"/>.</summary>
|
||||
Page = 0,
|
||||
|
||||
/// <summary>The pane of the tab named by <see cref="MainWindowViewModel.SelectedTab"/>.</summary>
|
||||
Terminal = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -126,6 +174,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <remarks>
|
||||
/// Held here only to hand to each vault as it is opened. The shell has nothing to copy of its own; the
|
||||
/// keychain screen does. Null on a machine with no clipboard, which is a state that reports itself
|
||||
/// rather than one that fails silently — see <see cref="VaultViewModel"/>.
|
||||
/// </remarks>
|
||||
private readonly Func<string, Task>? copyToClipboard;
|
||||
|
||||
/// <remarks>
|
||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||
@@ -134,6 +189,17 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Where connections are recorded, for as long as a vault is open to record them into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A process-lifetime object with session-scoped contents, exactly like the known-host store beside it,
|
||||
/// and for the same reason: the thing that calls it — the workspace — outlives every lock.
|
||||
/// </remarks>
|
||||
private readonly ConnectionRecorder connectionLog;
|
||||
|
||||
private readonly TeamsViewModel teams;
|
||||
|
||||
private IVaultServer? connection;
|
||||
|
||||
/// <summary>The refresh token last written to the cache, so a rotation is noticed without reading it back.</summary>
|
||||
@@ -188,7 +254,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null,
|
||||
ResumeHandler? resume = null)
|
||||
ResumeHandler? resume = null,
|
||||
Func<string, Task>? copyToClipboard = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
this.caches = caches;
|
||||
@@ -199,9 +266,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.resume = resume;
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
this.copyToClipboard = copyToClipboard;
|
||||
|
||||
transfers = new TransfersViewModel(sftpSessions, clock);
|
||||
|
||||
// Built once, like the workspace it writes for, and given a vault only while one is open. It has to
|
||||
// outlive every lock for the same reason the workspace does: a shell opened before a lock is still
|
||||
// running after it, and the entry it eventually produces belongs to the vault it was made in.
|
||||
connectionLog = new ConnectionRecorder(clock, Environment.MachineName);
|
||||
this.workspace.ConnectionLog = connectionLog;
|
||||
|
||||
// Both dependencies as functions rather than values: the connection arrives after sign-in and the
|
||||
// session after unlock, and both go away again on lock. Capturing either would give this screen a
|
||||
// reference that outlives what it points at — which for a session means holding vault keys past the
|
||||
// moment locking is supposed to have zeroed them.
|
||||
teams = new TeamsViewModel(() => connection, () => Vault?.Session);
|
||||
|
||||
// Subscribed for the life of the process, because the workspace lives that long and so does the tab
|
||||
// list. Detached in DisposeAsync, which is the only point either of them ends.
|
||||
this.workspace.SessionEnded += OnWorkspaceSessionEnded;
|
||||
@@ -273,6 +353,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <summary>The approved-host-keys screen, which exists exactly as long as the vault behind it does.</summary>
|
||||
/// <remarks>
|
||||
/// Assigned from <see cref="OnVaultChanged"/> and nowhere else, so the three paths that open or close a
|
||||
/// vault — unlocking, locking and signing out — cannot get out of step with it.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private KnownHostsViewModel? knownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private ImportViewModel? importScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private SnippetsViewModel? snippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="KnownHostsScreen" />
|
||||
[ObservableProperty]
|
||||
private LogsViewModel? logsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen, which the window binds to whether or not a vault is open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, for the reason <see cref="Transfers"/> is not: the screen reads a
|
||||
/// server rather than a vault, and both of its dependencies are fetched through a function at the
|
||||
/// moment they are needed. That means a lock does not have to tear it down and an unlock does not have
|
||||
/// to rebuild it, and the list it is showing survives both.
|
||||
/// </remarks>
|
||||
internal TeamsViewModel Teams => teams;
|
||||
|
||||
/// <summary>The transfers screen, which the window binds to whether or not a vault is open.</summary>
|
||||
/// <remarks>
|
||||
/// Not nullable and never replaced, unlike <see cref="Vault"/>. The screen is unreachable while locked —
|
||||
@@ -370,9 +481,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// ---- Which screen is showing ----
|
||||
|
||||
/// <summary>
|
||||
/// Which of the nav rail's screens the page area holds.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This always names a page, even while a terminal is showing over it — see <see cref="ShellSurface"/>.
|
||||
/// It is what dismissing a terminal returns to.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private ShellScreen screen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the page area is showing rather than a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Bound by the one wrapper that holds every screen, rather than by each screen. Avalonia cannot express
|
||||
/// <c>IsHostsScreen && IsShowingPages</c> in a binding, so the alternative is five compound
|
||||
/// properties — and, worse, a way to add a sixth screen and forget one. A screen that fails to collapse
|
||||
/// does not merely look wrong: it is drawn underneath the terminal's native child window and its buttons
|
||||
/// cannot be clicked. See <see cref="IsTerminalShowing"/>.
|
||||
/// </remarks>
|
||||
internal bool IsShowingPages => Surface is ShellSurface.Page;
|
||||
|
||||
internal bool IsHostsScreen => Screen is ShellScreen.Hosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
@@ -387,6 +517,51 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsPreferencesScreen => Screen is ShellScreen.Preferences;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsKnownHostsScreen => Screen is ShellScreen.KnownHosts;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsImportScreen => Screen is ShellScreen.Import;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsSnippetsScreen => Screen is ShellScreen.Snippets;
|
||||
|
||||
/// <inheritdoc cref="IsHostsScreen" />
|
||||
internal bool IsLogsScreen => Screen is ShellScreen.Logs;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the nav rail should light its Hosts entry.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not the same question as <see cref="IsHostsScreen"/>, and the rail has to ask this one. A terminal
|
||||
/// opened from the hosts screen leaves <see cref="Screen"/> on Hosts — deliberately, so closing the tab
|
||||
/// comes back here — and a rail that lit HOSTS while a terminal filled the window would be pointing at a
|
||||
/// screen that is not showing. The selected tab is already marked in the strip; two "you are here" marks
|
||||
/// at once is one too many.
|
||||
/// </remarks>
|
||||
internal bool IsHostsShowing => IsShowingPages && IsHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTransfersShowing => IsShowingPages && IsTransfersScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsVaultShowing => IsShowingPages && IsVaultScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsTeamShowing => IsShowingPages && IsTeamScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsPreferencesShowing => IsShowingPages && IsPreferencesScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsKnownHostsShowing => IsShowingPages && IsKnownHostsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsSnippetsShowing => IsShowingPages && IsSnippetsScreen;
|
||||
|
||||
/// <inheritdoc cref="IsHostsShowing" />
|
||||
internal bool IsLogsShowing => IsShowingPages && IsLogsScreen;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the terminal's WebView may be on screen at this instant.
|
||||
/// </summary>
|
||||
@@ -396,16 +571,28 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// and a child window composites above everything its parent paints — so whatever Avalonia draws in the
|
||||
/// same rectangle is drawn underneath it and its buttons cannot be clicked. Anything that covers the
|
||||
/// terminal's area has to collapse the terminal instead, and that is every one of the conditions here: a
|
||||
/// locked vault (the unlock card), a screen that is not Hosts (the vault, team, transfers and preferences
|
||||
/// screens all use the full width), and the quick-connect palette.
|
||||
/// locked vault (the unlock card), the page area (every screen uses the full width), and the
|
||||
/// quick-connect palette.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> That was tried, so that the empty terminal could carry a
|
||||
/// sentence saying what to do — and it puts the WebView's first appearance in the same turn as the
|
||||
/// <c>Focus()</c> that hands it the keyboard, which is the one moment on the connect path that has to
|
||||
/// work. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing a control
|
||||
/// that became visible microseconds earlier is a race against exactly the thing it depends on. The
|
||||
/// empty-state sentence lives in the tab strip instead, which Avalonia draws and nothing occludes.
|
||||
/// <b>The terminal and the pages are exclusive, and that is the whole of the rule.</b> They share one
|
||||
/// rectangle, so exactly one of <see cref="IsShowingPages"/> and this may be true. That is why
|
||||
/// <see cref="Surface"/> exists as a single enum rather than as two independent flags a caller could set
|
||||
/// to the same value.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Not gated on there being a tab.</b> Closing the last tab returns <see cref="Surface"/> to
|
||||
/// <see cref="ShellSurface.Page"/> instead, so the empty case never arises — and gating here as well
|
||||
/// would be a second answer to one question. The empty-state sentence lives in the tab strip, which
|
||||
/// Avalonia draws and nothing occludes.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Revealing and focusing now happen in the same turn, routinely.</b> Opening a terminal from the
|
||||
/// files screen, or clicking a tab while a page is showing, both flip this from false to true and then
|
||||
/// want the keyboard. <c>NativeControlHost</c> re-pushes its bounds on the next layout pass, so focusing
|
||||
/// microseconds ahead of that pass races the thing the focus depends on. The view answers that by
|
||||
/// posting the focus at <c>DispatcherPriority.Loaded</c> — see <c>MainWindow.axaml.cs</c>. It is not
|
||||
/// answered here, and it cannot be: this property has no way to know when layout ran.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Collapsing is cheap and safe. <c>NativeControlHost</c> creates the native control on attach rather
|
||||
@@ -414,11 +601,24 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// safe — that detaches it and destroys the whole WebView2 process tree.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal bool IsTerminalShowing => IsUnlocked && IsHostsScreen && !IsSearching;
|
||||
internal bool IsTerminalShowing => IsUnlocked && Surface is ShellSurface.Terminal && !IsSearching;
|
||||
|
||||
/// <inheritdoc cref="ShellSurface" />
|
||||
[ObservableProperty]
|
||||
private ShellSurface surface;
|
||||
|
||||
/// <summary>Points the nav rail at a screen.</summary>
|
||||
/// <remarks>
|
||||
/// Dismisses the terminal as well as moving the page, because the rail is how a user says "show me
|
||||
/// something else" and a rail click that changed a screen nobody could see would do nothing visible.
|
||||
/// The tab itself is untouched: its shell goes on running and the strip goes on naming it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void ShowScreen(ShellScreen target) => Screen = target;
|
||||
private void ShowScreen(ShellScreen target)
|
||||
{
|
||||
Screen = target;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
// ---- Open terminals ----
|
||||
|
||||
@@ -470,6 +670,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
: Tabs[Math.Clamp(index - 1, 0, Tabs.Count - 1)];
|
||||
}
|
||||
|
||||
// The one place the surface is forced back to a page. Closing a tab that leaves others open keeps the
|
||||
// terminal showing — the neighbour above is what it shows — but closing the last one would otherwise
|
||||
// leave a visible WebView with no pane in it, which reads as the application having broken.
|
||||
if (Tabs.Count == 0)
|
||||
{
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
|
||||
RaiseTabState();
|
||||
|
||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||
@@ -566,7 +774,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
CloseSearch();
|
||||
|
||||
// The hosts page, and the page rather than a terminal, before the connect is awaited. An unknown or
|
||||
// changed host key is answered by a prompt drawn on that page, and the palette can be opened from any
|
||||
// screen — so connecting from the files screen without this would put the question behind the screen
|
||||
// that asked it, with the connection blocked on an answer the user cannot reach. The session opening
|
||||
// is what moves the surface to the terminal, and only if there is one.
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
vault.SelectedHost = vault.Hosts.FirstOrDefault(host => host.EntityId == row.EntityId);
|
||||
|
||||
// Null, not the token. A [RelayCommand] over a method whose only parameter is a CancellationToken
|
||||
@@ -746,7 +960,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
"Creating your vault. This deliberately takes a moment…",
|
||||
"Creating your keychain. This deliberately takes a moment…",
|
||||
async () =>
|
||||
{
|
||||
var chosen = Passphrase;
|
||||
@@ -796,7 +1010,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
if (Passphrase.Length == 0)
|
||||
{
|
||||
StatusMessage = "Enter your vault passphrase.";
|
||||
StatusMessage = "Enter your keychain passphrase.";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -950,23 +1164,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
private async Task AdoptAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
await AttachStoresAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Vault = new VaultViewModel(session, workspace, knownHosts, () => connection, ReconnectAsync);
|
||||
Vault = new VaultViewModel(
|
||||
session,
|
||||
workspace,
|
||||
knownHosts,
|
||||
() => connection,
|
||||
ReconnectAsync,
|
||||
copyToClipboard,
|
||||
connectionLog);
|
||||
State = ShellState.Unlocked;
|
||||
|
||||
// Offered only where it can actually be honoured: a machine that can keep a key, and a profile that
|
||||
@@ -984,7 +1191,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// After the load, because what the transfers screen takes from the vault is the host list and an
|
||||
// empty one would leave its picker blank until the next unlock.
|
||||
transfers.Attach(Vault, knownHosts);
|
||||
transfers.Attach(Vault, knownHosts, connectionLog, new S3ObjectStoreFactory());
|
||||
|
||||
// After the list exists, and it matters after a lock rather than after the first unlock: shells kept
|
||||
// running while the vault was closed, so some of these hosts are connected before their rows are a
|
||||
@@ -1007,6 +1214,37 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
Vault.StartAutoSync();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Points the two process-lifetime stores at the session that has just opened.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both live longer than any vault — the known-host store answers the SSH handshake, the recorder is
|
||||
/// called by the workspace — so both are attached here rather than constructed per session, and both are
|
||||
/// released together on every path that closes a vault.
|
||||
/// </remarks>
|
||||
private async Task AttachStoresAsync(VaultSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
// Before the vault view model, so the first connection after an unlock already knows which host keys
|
||||
// this user has approved. Reading them is one listing; doing it here rather than lazily is what
|
||||
// keeps it off the SSH handshake thread.
|
||||
try
|
||||
{
|
||||
await knownHosts.OpenAsync(session, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Nothing owns the session yet, so nothing else would ever dispose it — and an undisposed
|
||||
// session is vault keys left in memory for the life of the process, which is precisely what
|
||||
// unlocking must be able to undo.
|
||||
await session.DisposeAsync().ConfigureAwait(true);
|
||||
throw;
|
||||
}
|
||||
|
||||
// The actor is the account that unlocked, which is what makes this an audit record rather than a
|
||||
// list of events with nobody attached to them.
|
||||
connectionLog.Open(session, session.Profile.UserId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets this machine online if it is not, and keeps the remembered sign-in current if it is.
|
||||
/// </summary>
|
||||
@@ -1214,6 +1452,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.Close();
|
||||
|
||||
// Beside it, and for the mirror-image reason: no new connection may be filed into a vault that is
|
||||
// about to be disposed. Tickets already open keep the repository they were opened against, so a
|
||||
// shell still running closes out into the vault it was actually made in.
|
||||
connectionLog.Close();
|
||||
|
||||
// Before the vault goes, because its host rows carry decrypted secrets and the transfers screen is
|
||||
// holding references to them. What it does not give up is its connection or its queue — a transfer
|
||||
// in flight is exactly the work this method exists not to destroy.
|
||||
@@ -1264,7 +1507,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
{
|
||||
(false, _) =>
|
||||
"Anything this machine changed and has not sent to the server yet will be lost. It cannot be "
|
||||
+ "counted from here, because the vault is locked.",
|
||||
+ "counted from here, because the keychain is locked.",
|
||||
(true, 0) =>
|
||||
"Everything this machine has changed has reached the server, so nothing will be lost.",
|
||||
(true, 1) =>
|
||||
@@ -1328,6 +1571,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
// As Lock does, and before the session it reads from goes.
|
||||
knownHosts.Close();
|
||||
connectionLog.Close();
|
||||
|
||||
// The same detach locking does, and the same reasoning carried one step further: the host
|
||||
// rows go because the vault behind them is about to be disposed, and the session and its
|
||||
@@ -1364,8 +1608,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
RaiseSyncState();
|
||||
|
||||
StatusMessage = "Signed out. This machine's copy of the vault has been deleted; the vault "
|
||||
+ "itself is untouched. Sign in to set this machine up again.";
|
||||
StatusMessage = "Signed out. This machine's copy of the keychain has been deleted; the "
|
||||
+ "keychain itself is untouched. Sign in to set this machine up again.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
@@ -1412,6 +1656,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
// Detached before it is disposed, so a session torn down after this point finds nothing to post to
|
||||
// rather than a completed channel. Disposed rather than merely closed, because it owns a background
|
||||
// task — and it waits only as long as that task takes to stop, never for the queue to drain.
|
||||
workspace.ConnectionLog = null;
|
||||
await connectionLog.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
// Before the vault, and it waits: a transfer still writing has an open remote file and an open local
|
||||
// one, and a process that exits while those are in flight leaves a part file longer than the bytes
|
||||
// that reached it.
|
||||
@@ -1547,6 +1797,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
newValue.Hosts.CollectionChanged += OnVaultHostsChanged;
|
||||
}
|
||||
|
||||
// Built from the vault and thrown away with it, here rather than at each of the three places a
|
||||
// vault is opened or closed. It holds a subscription to the vault's pin list, so leaving one behind
|
||||
// would keep a disposed vault alive and repaint a screen nobody can reach.
|
||||
KnownHostsScreen?.Detach();
|
||||
KnownHostsScreen = newValue is null ? null : new KnownHostsViewModel(newValue);
|
||||
ImportScreen = newValue is null ? null : new ImportViewModel(newValue, new SshConfigLocator());
|
||||
|
||||
SnippetsScreen?.Detach();
|
||||
SnippetsScreen = newValue is null
|
||||
? null
|
||||
: new SnippetsViewModel(newValue, CurrentInsertTarget, workspace.PasteAsync);
|
||||
|
||||
LogsScreen = newValue is null ? null : new LogsViewModel(newValue.Session, LiveConnections);
|
||||
|
||||
RaiseSyncState();
|
||||
}
|
||||
|
||||
@@ -1579,13 +1843,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The tab is added before the event is forwarded, so the handler that hands the terminal the keyboard
|
||||
/// runs against a tab strip that already shows the session it is focusing.
|
||||
/// The vault opens SSH sessions and this shell owns the strip they appear in, so this is the seam between
|
||||
/// them and nothing more — everything about becoming a tab is in <see cref="AdoptTab"/>.
|
||||
/// </remarks>
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e)
|
||||
{
|
||||
var tab = new TerminalTabViewModel(e.SessionId, e.Label, e.Address);
|
||||
private void OnVaultSessionOpened(object? sender, TerminalSessionEventArgs e) =>
|
||||
AdoptTab(new TerminalTabViewModel(e.SessionId, e.Label, e.Address));
|
||||
|
||||
/// <summary>
|
||||
/// Takes a newly opened session into the tab strip and shows it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One method rather than one per way of opening a session, so the order of these four steps is decided
|
||||
/// once. It is not arbitrary: the tab is in the strip before the event is forwarded, so the handler that
|
||||
/// hands the terminal the keyboard runs against a strip that already shows what it is focusing.
|
||||
/// </remarks>
|
||||
private void AdoptTab(TerminalTabViewModel tab)
|
||||
{
|
||||
Tabs.Add(tab);
|
||||
RaiseTabState();
|
||||
|
||||
@@ -1594,6 +1867,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// and load-bearing for every one after it.
|
||||
SelectedTab = tab;
|
||||
|
||||
// The surface, but deliberately not the screen. A session opened from the files screen shows its
|
||||
// terminal — that is what was asked for — and leaves Screen on Transfers, so closing the tab or
|
||||
// clicking away comes back to the transfer that is presumably still running.
|
||||
Surface = ShellSurface.Terminal;
|
||||
|
||||
TerminalSessionOpened?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
@@ -1617,15 +1895,50 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
RefreshConnectedHosts();
|
||||
|
||||
// The snippets screen names the terminal its buttons will type into, and it has no way to learn that
|
||||
// a different tab is selected — the tab list is the shell's, and a subscription the other way would
|
||||
// be a screen keeping the shell alive.
|
||||
SnippetsScreen?.TargetChanged();
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
_ = workspace.ActivateSessionAsync(value.SessionId, CancellationToken.None).AsTask();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front.</summary>
|
||||
/// <summary>Which terminal a snippet would go into right now.</summary>
|
||||
/// <remarks>
|
||||
/// The selected tab, and nothing cleverer. A snippet is typed into the terminal the user is working in,
|
||||
/// so "which one" has exactly the same answer as "which pane is on screen" — and a screen that picked,
|
||||
/// say, the most recently opened would send a command somewhere the user is not looking.
|
||||
/// </remarks>
|
||||
/// <summary>The connections that are open and therefore have no log entry yet.</summary>
|
||||
/// <remarks>
|
||||
/// Read from the recorder rather than from the tab strip, so the rows on the logs screen appear and
|
||||
/// vanish in step with the entries that will replace them. A tab is a nearly-but-not-quite equivalent —
|
||||
/// an SFTP session has no tab at all, and a tab whose remote hung up still has one.
|
||||
/// </remarks>
|
||||
private IReadOnlyList<LiveConnection> LiveConnections() =>
|
||||
[
|
||||
.. connectionLog.Open().Select(open => new LiveConnection(
|
||||
open.HostLabel, open.Address, open.StartedAt, Environment.MachineName)),
|
||||
];
|
||||
|
||||
private InsertTarget CurrentInsertTarget() =>
|
||||
SelectedTab is { } tab ? new InsertTarget(tab.SessionId, tab.Label) : InsertTarget.None;
|
||||
|
||||
/// <summary>Brings one terminal's pane to the front, and shows it.</summary>
|
||||
/// <remarks>
|
||||
/// Both halves are needed. The strip is visible from every screen, so a click on it is as often "come
|
||||
/// back to my terminal" as it is "switch between two of them" — and selecting a pane the user cannot see
|
||||
/// would answer only one of those.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void SelectTab(TerminalTabViewModel tab) => SelectedTab = tab;
|
||||
private void SelectTab(TerminalTabViewModel tab)
|
||||
{
|
||||
SelectedTab = tab;
|
||||
Surface = ShellSurface.Terminal;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a tab dead when its shell ends on its own.
|
||||
@@ -1689,10 +2002,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
RaiseSyncState();
|
||||
|
||||
// Locking leaves the rail wherever it was, and unlocking should not resume on the vault's key list.
|
||||
// The hosts screen is what this application is for.
|
||||
// The hosts screen is what this application is for. The surface as well as the screen: shells outlive
|
||||
// a lock, so there can be a selected tab from before it, and coming back to a terminal rather than to
|
||||
// the application would not be what "unlocked" looks like.
|
||||
if (value is ShellState.Unlocked)
|
||||
{
|
||||
Screen = ShellScreen.Hosts;
|
||||
Surface = ShellSurface.Page;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1702,12 +2018,57 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// directions, and raising only the one that became true leaves the old button lit.
|
||||
/// </remarks>
|
||||
partial void OnScreenChanged(ShellScreen value)
|
||||
{
|
||||
RaiseSurfaceState();
|
||||
|
||||
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
|
||||
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
|
||||
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
|
||||
if (value is ShellScreen.Logs && LogsScreen is { } logs)
|
||||
{
|
||||
_ = logs.RefreshCommand.ExecuteAsync(null);
|
||||
}
|
||||
|
||||
// Teams are read from the server rather than from the vault, so there is nothing to show until
|
||||
// somebody asks for it — and asking for it on every unlock would be a request per launch for a
|
||||
// screen most people never open. Fire-and-forget because a property change cannot await, and
|
||||
// because the view model turns every failure into its own status line rather than throwing.
|
||||
if (value is ShellScreen.Team)
|
||||
{
|
||||
_ = teams.LoadAsync(CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="OnScreenChanged" />
|
||||
partial void OnSurfaceChanged(ShellSurface value) => RaiseSurfaceState();
|
||||
|
||||
/// <remarks>
|
||||
/// Both changes raise the same set, and they have to: <see cref="IsHostsShowing"/> and its four siblings
|
||||
/// read <see cref="Screen"/> and <see cref="Surface"/> together, so which of the two moved does not
|
||||
/// narrow what became stale.
|
||||
/// </remarks>
|
||||
private void RaiseSurfaceState()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsHostsScreen));
|
||||
OnPropertyChanged(nameof(IsTransfersScreen));
|
||||
OnPropertyChanged(nameof(IsVaultScreen));
|
||||
OnPropertyChanged(nameof(IsTeamScreen));
|
||||
OnPropertyChanged(nameof(IsPreferencesScreen));
|
||||
OnPropertyChanged(nameof(IsKnownHostsScreen));
|
||||
OnPropertyChanged(nameof(IsImportScreen));
|
||||
OnPropertyChanged(nameof(IsSnippetsScreen));
|
||||
OnPropertyChanged(nameof(IsLogsScreen));
|
||||
|
||||
OnPropertyChanged(nameof(IsShowingPages));
|
||||
OnPropertyChanged(nameof(IsHostsShowing));
|
||||
OnPropertyChanged(nameof(IsTransfersShowing));
|
||||
OnPropertyChanged(nameof(IsVaultShowing));
|
||||
OnPropertyChanged(nameof(IsTeamShowing));
|
||||
OnPropertyChanged(nameof(IsPreferencesShowing));
|
||||
OnPropertyChanged(nameof(IsKnownHostsShowing));
|
||||
OnPropertyChanged(nameof(IsSnippetsShowing));
|
||||
OnPropertyChanged(nameof(IsLogsShowing));
|
||||
|
||||
OnPropertyChanged(nameof(IsTerminalShowing));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>Where a snippet is about to be inserted, and whether it can be.</summary>
|
||||
/// <param name="SessionId">The terminal, or null when there is none open.</param>
|
||||
/// <param name="Label">What that terminal is called, for the button.</param>
|
||||
internal sealed record InsertTarget(uint? SessionId, string Label)
|
||||
{
|
||||
/// <summary>The answer when no tab is open.</summary>
|
||||
internal static InsertTarget None { get; } = new(null, string.Empty);
|
||||
|
||||
/// <summary>Whether there is somewhere to insert into.</summary>
|
||||
internal bool IsAvailable => SessionId is not null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The saved commands in this keychain, and how to get one into a terminal.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>A wrapper over the vault, as <c>KnownHostsViewModel</c> is</b>, and for the same reason: reading
|
||||
/// snippets, storing one and pushing the change already live on <see cref="VaultViewModel"/>, wired into its
|
||||
/// reload and its automatic sync. What belongs here is the filter, the editor and the insert — none of which
|
||||
/// the vault has any use for.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>The safety story is the copy, not the code.</b> A terminal is one input stream with no notion of being
|
||||
/// at a prompt: the remote may be in <c>vi</c>, or at a <c>sudo</c> password prompt with echo off, and
|
||||
/// without shell integration this client cannot tell. So inserting is always "type this into whatever is
|
||||
/// there", which is what <see cref="InsertLabel"/> says, and the Enter is the user's unless the snippet was
|
||||
/// deliberately marked as one that runs — see <see cref="SnippetSecret.RunsOnInsert"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class SnippetsViewModel : ObservableObject
|
||||
{
|
||||
private readonly VaultViewModel vault;
|
||||
private readonly Func<InsertTarget> target;
|
||||
private readonly Func<uint, string, bool, CancellationToken, Task<bool>> insert;
|
||||
|
||||
/// <param name="vault">The open keychain, which owns the list and the writing.</param>
|
||||
/// <param name="target">
|
||||
/// Which terminal is selected right now. A function rather than a value, because the answer changes every
|
||||
/// time the user clicks a tab and this screen is not told about that.
|
||||
/// </param>
|
||||
/// <param name="insert">
|
||||
/// Puts text into a terminal. Injected rather than taking the workspace, so the screen can be tested
|
||||
/// without a renderer — the thing worth testing here is which text goes and whether Enter follows it, and
|
||||
/// neither of those is a property of the transport.
|
||||
/// </param>
|
||||
internal SnippetsViewModel(
|
||||
VaultViewModel vault,
|
||||
Func<InsertTarget> target,
|
||||
Func<uint, string, bool, CancellationToken, Task<bool>> insert)
|
||||
{
|
||||
this.vault = vault;
|
||||
this.target = target;
|
||||
this.insert = insert;
|
||||
|
||||
vault.Snippets.CollectionChanged += OnSnippetsChanged;
|
||||
|
||||
Rebuild();
|
||||
}
|
||||
|
||||
/// <summary>The snippets this filter admits, in the order the vault produced them.</summary>
|
||||
internal ObservableCollection<SnippetRowViewModel> Visible { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private string filter = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private SnippetRowViewModel? selected;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isEditing;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorLabel = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorCommand = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string editorNotes = string.Empty;
|
||||
|
||||
/// <summary>Whether the snippet being edited is one that presses Enter for you.</summary>
|
||||
/// <remarks>
|
||||
/// Off for every new snippet, and the checkbox says what it means rather than what it is called. It is
|
||||
/// per snippet rather than a preference, because <c>ls -la</c> and <c>rm -rf /var/lib/postgresql</c> do
|
||||
/// not want the same answer and one switch would end up left on by whoever needed it for the first.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool editorRunsOnInsert;
|
||||
|
||||
/// <summary>The snippet being edited, or null when the editor would create one.</summary>
|
||||
[ObservableProperty]
|
||||
private Guid? editingId;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
internal bool HasSnippets => vault.Snippets.Count > 0;
|
||||
|
||||
internal bool HasVisible => Visible.Count > 0;
|
||||
|
||||
internal bool HasSelection => Selected is not null;
|
||||
|
||||
/// <summary>Whether there is a terminal to insert into at all.</summary>
|
||||
internal bool CanInsert => HasSelection && target().IsAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// What the insert button says, naming the terminal it will type into.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The tab is named on the button on purpose. This screen is not the terminal — the strip above it is —
|
||||
/// so "INSERT" alone would leave the user to work out which of six open tabs is about to receive a
|
||||
/// command, at the moment that is least convenient to be wrong about.
|
||||
/// </remarks>
|
||||
internal string InsertLabel => target() is { IsAvailable: true } open
|
||||
? $"TYPE INTO {open.Label}"
|
||||
: "NO TERMINAL OPEN";
|
||||
|
||||
/// <summary>What the run button says, or empty when the selected snippet does not run.</summary>
|
||||
internal string RunLabel => target() is { IsAvailable: true } open ? $"RUN IN {open.Label}" : string.Empty;
|
||||
|
||||
/// <summary>Whether the selected snippet is one marked as running on its own.</summary>
|
||||
internal bool SelectionRuns => Selected?.RunsOnInsert is true;
|
||||
|
||||
internal string EmptyMessage => HasSnippets
|
||||
? "No snippet matches that."
|
||||
: "Nothing saved yet. A snippet is a command you keep, so you can put it into a terminal without "
|
||||
+ "typing it again.";
|
||||
|
||||
/// <summary>Starts a new snippet.</summary>
|
||||
[RelayCommand]
|
||||
private void New()
|
||||
{
|
||||
EditingId = null;
|
||||
EditorLabel = string.Empty;
|
||||
EditorCommand = string.Empty;
|
||||
EditorNotes = string.Empty;
|
||||
EditorRunsOnInsert = false;
|
||||
IsEditing = true;
|
||||
Status = "Adding a snippet.";
|
||||
}
|
||||
|
||||
/// <summary>Opens the selected snippet for editing.</summary>
|
||||
[RelayCommand]
|
||||
private void Edit()
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (row.IsReadOnly)
|
||||
{
|
||||
Status = "This snippet was written by a newer version of DodoSSH. Update before editing it.";
|
||||
return;
|
||||
}
|
||||
|
||||
EditingId = row.EntityId;
|
||||
EditorLabel = row.Snippet.Label;
|
||||
EditorCommand = row.Snippet.Command;
|
||||
EditorNotes = row.Snippet.Notes ?? string.Empty;
|
||||
EditorRunsOnInsert = row.Snippet.RunsOnInsert;
|
||||
IsEditing = true;
|
||||
Status = $"Editing {row.Label}.";
|
||||
}
|
||||
|
||||
/// <summary>Abandons the editor.</summary>
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Stores the editor's contents.</summary>
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var snippet = new SnippetSecret
|
||||
{
|
||||
Label = EditorLabel.Trim(),
|
||||
|
||||
// Not trimmed, and this is the field where that matters most. A here-document's terminator has
|
||||
// to arrive on a line of its own; tidying the trailing newline off it leaves the shell waiting
|
||||
// for one that never comes, which reads as the snippet having hung the terminal.
|
||||
Command = EditorCommand,
|
||||
Notes = string.IsNullOrWhiteSpace(EditorNotes) ? null : EditorNotes,
|
||||
RunsOnInsert = EditorRunsOnInsert,
|
||||
};
|
||||
|
||||
var saved = await vault.SaveSnippetAsync(EditingId, snippet, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
if (!saved)
|
||||
{
|
||||
Status = vault.Status;
|
||||
return;
|
||||
}
|
||||
|
||||
IsEditing = false;
|
||||
EditingId = null;
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>Deletes the selected snippet.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DeleteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await vault.DeleteSnippetAsync(row.EntityId, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = vault.Status;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal, without pressing Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The button that does not run anything, and it is the one a user should reach for. What it inserts
|
||||
/// arrives as pasted text — bracketed, when the remote has asked for that — so a multi-line snippet sits
|
||||
/// at the prompt as text and waits for a person to look at it.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task InsertAsync(CancellationToken cancellationToken) => SendAsync(false, cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Types the selected snippet into the selected terminal and presses Enter.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only offered for a snippet whose own <see cref="SnippetSecret.RunsOnInsert"/> is set, so that "this
|
||||
/// one runs" is a decision made once, while writing the snippet, rather than a button sitting next to
|
||||
/// every one of them.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private Task RunAsync(CancellationToken cancellationToken) =>
|
||||
SelectionRuns ? SendAsync(true, cancellationToken) : Task.CompletedTask;
|
||||
|
||||
internal void Detach() => vault.Snippets.CollectionChanged -= OnSnippetsChanged;
|
||||
|
||||
/// <summary>Re-reads which terminal is selected, after the shell says one has changed.</summary>
|
||||
/// <remarks>
|
||||
/// Pushed by the shell rather than observed from here. The tab list belongs to the shell and outlives
|
||||
/// this screen — a session survives locking the keychain — so a subscription in this direction would be
|
||||
/// a screen holding the shell alive.
|
||||
/// </remarks>
|
||||
internal void TargetChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(InsertLabel));
|
||||
OnPropertyChanged(nameof(RunLabel));
|
||||
}
|
||||
|
||||
private async Task SendAsync(bool execute, CancellationToken cancellationToken)
|
||||
{
|
||||
if (Selected is not { } row || target() is not { SessionId: { } sessionId } open)
|
||||
{
|
||||
Status = "Open a terminal first — a snippet has to go somewhere.";
|
||||
return;
|
||||
}
|
||||
|
||||
var delivered = await insert(sessionId, row.Snippet.Command, execute, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = delivered
|
||||
? execute
|
||||
? $"Ran '{row.Label}' in {open.Label}."
|
||||
: $"Typed '{row.Label}' into {open.Label}. Press Enter there to run it."
|
||||
: $"{open.Label} is no longer connected, so nothing was sent.";
|
||||
}
|
||||
|
||||
partial void OnFilterChanged(string value) => Rebuild();
|
||||
|
||||
partial void OnSelectedChanged(SnippetRowViewModel? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanInsert));
|
||||
OnPropertyChanged(nameof(SelectionRuns));
|
||||
}
|
||||
|
||||
partial void OnEditingIdChanged(Guid? value) => OnPropertyChanged(nameof(IsCreating));
|
||||
|
||||
/// <summary>Whether the editor would create a snippet rather than replace one.</summary>
|
||||
internal bool IsCreating => EditingId is null;
|
||||
|
||||
private void OnSnippetsChanged(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;
|
||||
|
||||
Visible.Clear();
|
||||
|
||||
foreach (var snippet in vault.Snippets.Where(Matches))
|
||||
{
|
||||
Visible.Add(snippet);
|
||||
}
|
||||
|
||||
Selected = Visible.FirstOrDefault(row => row.EntityId == selectedId);
|
||||
|
||||
OnPropertyChanged(nameof(HasSnippets));
|
||||
OnPropertyChanged(nameof(HasVisible));
|
||||
OnPropertyChanged(nameof(EmptyMessage));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The command is searched as well as the name and the notes, because half of what somebody remembers
|
||||
/// about a saved command is a word that was in it.
|
||||
/// </remarks>
|
||||
private bool Matches(SnippetRowViewModel row)
|
||||
{
|
||||
var needle = Filter.Trim();
|
||||
|
||||
if (needle.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Contains(row.Label) || Contains(row.Snippet.Command) || Contains(row.Snippet.Notes);
|
||||
|
||||
bool Contains(string? value) =>
|
||||
value is not null && value.Contains(needle, StringComparison.CurrentCultureIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Api;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Contracts;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>One team, as a row in the list.</summary>
|
||||
internal sealed record TeamRowViewModel(TeamSummary Team)
|
||||
{
|
||||
internal Guid TeamId => Team.TeamId;
|
||||
|
||||
internal string Name => Team.Name;
|
||||
|
||||
internal string Slug => Team.Slug;
|
||||
|
||||
/// <summary>The caller's own role, as the chip the list shows.</summary>
|
||||
internal string Role => Team.Role.ToString().ToUpperInvariant();
|
||||
|
||||
internal string Detail => string.Create(
|
||||
CultureInfo.CurrentCulture,
|
||||
$"{Team.MemberCount} member(s) · {Team.VaultCount} vault(s)");
|
||||
|
||||
/// <summary>Whether this account may add members and create vaults here.</summary>
|
||||
internal bool CanAdminister =>
|
||||
Team.Role is TeamMemberRole.Admin or TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One member, as a row in the members table.</summary>
|
||||
internal sealed record TeamMemberRowViewModel(TeamMemberSummary Member, bool IsSelf)
|
||||
{
|
||||
internal Guid UserId => Member.UserId;
|
||||
|
||||
/// <summary>What to call them. The address, or the id when the account has neither.</summary>
|
||||
/// <remarks>
|
||||
/// Falling through to the id rather than to "Unknown": an account with no display name and no email is
|
||||
/// rare and is exactly the row somebody needs to be able to identify in order to remove it.
|
||||
/// </remarks>
|
||||
internal string Name =>
|
||||
Member.DisplayName ?? Member.Email ?? Member.UserId.ToString();
|
||||
|
||||
internal string Email => Member.Email ?? "—";
|
||||
|
||||
internal string Role => Member.Role.ToString().ToUpperInvariant();
|
||||
|
||||
/// <summary>
|
||||
/// What the account can be given, in one phrase.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not a two-factor column, not a last-active column. The server records neither: there is no
|
||||
/// second-factor concept anywhere in it, and <c>LastSeenAtUtc</c> is written at provisioning and at
|
||||
/// enrollment and nowhere else, so a column headed "last active" would be reporting something else.
|
||||
/// What is true and worth a column is whether a vault key can be wrapped to them at all.
|
||||
/// </remarks>
|
||||
internal string KeyState => Member.IsEnrolled
|
||||
? "key published"
|
||||
: "no key yet — cannot be given a vault";
|
||||
|
||||
internal bool CanBeRemoved => Member.Role != TeamMemberRole.Owner;
|
||||
}
|
||||
|
||||
/// <summary>One vault of the selected team, with what this account can do to it.</summary>
|
||||
internal sealed record TeamVaultRowViewModel(Guid VaultId, string Name, bool IsReadable, bool RekeyRequired)
|
||||
{
|
||||
/// <summary>What the row says about itself.</summary>
|
||||
/// <remarks>
|
||||
/// The unreadable case is the one that has to read clearly, because it is normal rather than broken:
|
||||
/// somebody has been added to a team and nobody has wrapped the vault key to them yet.
|
||||
/// </remarks>
|
||||
internal string State => (IsReadable, RekeyRequired) switch
|
||||
{
|
||||
(false, _) => "waiting for a key — ask a member who has one to share it",
|
||||
(true, true) => "readable · a rekey is owed after a membership change",
|
||||
_ => "readable",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The teams screen: who is in a team, what they may do, and which vaults they hold a key to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Two separate acts, and the screen is built around saying so.</b> Adding somebody to a team is a
|
||||
/// server-side authorization change and takes effect immediately. Giving them a vault key is a
|
||||
/// cryptographic act only a machine with that key can perform, and until somebody does it their vault
|
||||
/// list shows an entry they cannot open. Every product that hides this ends up implying the server can
|
||||
/// hand out access on its own — which, here, it cannot. See <c>TeamService</c> and ADR 0001.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Nothing on this screen is cached across a lock. It reads the server on open and after each change,
|
||||
/// because membership is not vault content and has no local mirror — a team list in the encrypted cache
|
||||
/// would be a second copy of something the server is authoritative for.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TeamsViewModel(
|
||||
Func<IVaultServer?> connection,
|
||||
Func<VaultSession?> session) : ObservableObject
|
||||
{
|
||||
/// <summary>Teams this account belongs to.</summary>
|
||||
internal ObservableCollection<TeamRowViewModel> Teams { get; } = [];
|
||||
|
||||
/// <summary>Members of the selected team.</summary>
|
||||
internal ObservableCollection<TeamMemberRowViewModel> Members { get; } = [];
|
||||
|
||||
/// <summary>Vaults the selected team owns, as far as this account can see them.</summary>
|
||||
internal ObservableCollection<TeamVaultRowViewModel> Vaults { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamRowViewModel? selectedTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamMemberRowViewModel? selectedMember;
|
||||
|
||||
[ObservableProperty]
|
||||
private TeamVaultRowViewModel? selectedVault;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
// ---- Creating a team ----
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isCreatingTeam;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamName = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string newTeamSlug = string.Empty;
|
||||
|
||||
// ---- Adding a member ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string inviteEmail = string.Empty;
|
||||
|
||||
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||
internal bool IsOnline => connection() is not null;
|
||||
|
||||
/// <summary>Whether the selected team can be administered by this account.</summary>
|
||||
internal bool CanAdministerSelected => SelectedTeam?.CanAdminister == true;
|
||||
|
||||
/// <summary>Whether there is anything to show below the team list.</summary>
|
||||
internal bool HasSelection => SelectedTeam is not null;
|
||||
|
||||
internal bool HasTeams => Teams.Count > 0;
|
||||
|
||||
/// <summary>Reads the teams this account belongs to, and the selected one's detail.</summary>
|
||||
internal Task LoadAsync(CancellationToken cancellationToken) =>
|
||||
RunAsync(() => ReloadAsync(cancellationToken));
|
||||
|
||||
/// <summary>
|
||||
/// The reload itself, without the busy gate.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Separate from <see cref="LoadAsync"/> because every command ends by reloading, and a command that
|
||||
/// called the gated version would find the gate held by itself and skip the reload silently — leaving
|
||||
/// a team that was created moments ago missing from the list it was just added to.
|
||||
/// </remarks>
|
||||
private async Task ReloadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Teams.Clear();
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
RaiseState();
|
||||
|
||||
Status = "Offline. Teams are read from the server, so this screen needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedId = SelectedTeam?.TeamId;
|
||||
|
||||
var teams = await server.Teams.ListTeamsAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Teams.Clear();
|
||||
|
||||
foreach (var team in teams)
|
||||
{
|
||||
Teams.Add(new TeamRowViewModel(team));
|
||||
}
|
||||
|
||||
SelectedTeam =
|
||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||
|
||||
RaiseState();
|
||||
|
||||
await LoadSelectedAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = Teams.Count == 0
|
||||
? "You are not in a team yet. Create one to share hosts and credentials with colleagues."
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void NewTeam()
|
||||
{
|
||||
NewTeamName = string.Empty;
|
||||
NewTeamSlug = string.Empty;
|
||||
IsCreatingTeam = true;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Abandons the create-a-team form.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelNewTeam()
|
||||
{
|
||||
IsCreatingTeam = false;
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Creates a team, with this account as its owner.</summary>
|
||||
/// <remarks>
|
||||
/// The id is generated here, which is what makes a create whose response was lost safe to send again —
|
||||
/// the server treats an identical repeat as the same team rather than a second one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task CreateTeamAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server)
|
||||
{
|
||||
Status = "Offline. Creating a team needs a connection.";
|
||||
return;
|
||||
}
|
||||
|
||||
var name = NewTeamName.Trim();
|
||||
var slug = NewTeamSlug.Trim().ToLowerInvariant();
|
||||
|
||||
if (name.Length == 0 || slug.Length == 0)
|
||||
{
|
||||
Status = "A team needs a name and a slug.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var created = await server.Teams
|
||||
.CreateTeamAsync(
|
||||
new CreateTeamRequest(Guid.CreateVersion7(), name, slug, null), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
IsCreatingTeam = false;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
SelectedTeam = Teams.FirstOrDefault(row => row.TeamId == created.TeamId) ?? SelectedTeam;
|
||||
|
||||
Status = $"Created '{created.Name}'. Add a vault to it, then share that vault's key with "
|
||||
+ "whoever needs it.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a member, by looking their address up in the directory first.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two calls rather than one, and the order is the point: the directory is what turns an address into
|
||||
/// an account and a public key, and the key that gets verified before any sharing is the one that
|
||||
/// lookup returned. Letting the server resolve an address to an account inside the add would put an
|
||||
/// unwitnessed step between the two.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task AddMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var email = InviteEmail.Trim();
|
||||
|
||||
if (email.Length == 0)
|
||||
{
|
||||
Status = "Type the email address of somebody who has signed in to this server.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var found = await server.Directory.LookupByEmailAsync(email, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
if (found.Count == 0)
|
||||
{
|
||||
Status = $"No account here has the address '{email}'. They have to sign in to this "
|
||||
+ "server once before they can be added — that is what publishes the key a vault "
|
||||
+ "would be shared with.";
|
||||
return;
|
||||
}
|
||||
|
||||
var member = await server.Teams
|
||||
.AddTeamMemberAsync(
|
||||
team.TeamId,
|
||||
new AddTeamMemberRequest(found[0].UserId, TeamMemberRole.Member),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
InviteEmail = string.Empty;
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// Said out loud, every time. The single most common misunderstanding this design invites is
|
||||
// that adding somebody gave them the vault.
|
||||
Status = $"Added {member.Email ?? member.DisplayName ?? "the account"} as a member. They "
|
||||
+ "cannot read anything yet — select a vault below and share its key.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Removes a member, revoking every vault key grant they hold from this team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveMemberAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedTeam is not { } team
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
await server.Teams
|
||||
.RemoveTeamMemberAsync(team.TeamId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// The honest sentence, not the reassuring one. See ADR 0001: revocation is not retroactive,
|
||||
// and a message implying otherwise is the one thing this screen must not say.
|
||||
Status = $"Removed {member.Name}. They can no longer fetch this team's vaults, and anything "
|
||||
+ "they had already downloaded is still on their machine — rotate the credentials that "
|
||||
+ "matter.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Creates a vault owned by the selected team.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var vault = await open
|
||||
.CreateTeamVaultAsync(server.Teams, team.TeamId, team.Name, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created the vault '{vault.Name}'. It is yours alone until you share its key; new "
|
||||
+ "hosts and credentials can be filed into it from the Vault screen.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps the selected vault's key to the selected member.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Everything that makes this safe happens inside <see cref="VaultSession.ShareVaultAsync"/>: the key
|
||||
/// log is read and its chain verified, and the directory's answer has to appear in it unchanged before
|
||||
/// anything is wrapped. A refusal is reported here in full rather than as "sharing failed", because
|
||||
/// the reasons are not interchangeable — one of them means somebody is substituting keys.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task ShareVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| session() is not { } open
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (member.IsSelf)
|
||||
{
|
||||
Status = "You already hold this vault's key.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var outcome = await open
|
||||
.ShareVaultAsync(server.Grants, server.Directory, vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
Status = outcome.Shared
|
||||
? $"Shared '{vault.Name}' with {member.Name}. {outcome.Message}"
|
||||
: $"Did not share '{vault.Name}': {outcome.Message}";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Withdraws the selected member's key to the selected vault.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RevokeVaultAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (connection() is not { } server
|
||||
|| SelectedVault is not { } vault
|
||||
|| SelectedMember is not { } member)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(async () =>
|
||||
{
|
||||
var revoked = await server.Grants
|
||||
.RevokeVaultGrantAsync(vault.VaultId, member.UserId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = revoked
|
||||
? $"Withdrew {member.Name}'s key to '{vault.Name}'. Future reads are blocked; what they "
|
||||
+ "already have is unaffected."
|
||||
: $"{member.Name} held no key to '{vault.Name}'.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
partial void OnSelectedTeamChanged(TeamRowViewModel? value)
|
||||
{
|
||||
RaiseState();
|
||||
|
||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
||||
// from a list box, which has no cancellation token and no way to await. Failures land in Status
|
||||
// through RunAsync exactly as a command's would.
|
||||
_ = LoadSelectedAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>Reads the selected team's members and vaults.</summary>
|
||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
Members.Clear();
|
||||
Vaults.Clear();
|
||||
|
||||
if (connection() is not { } server || SelectedTeam is not { } team)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var open = session();
|
||||
var selfId = open?.Profile.UserId;
|
||||
|
||||
var members = await server.Teams
|
||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
foreach (var member in members)
|
||||
{
|
||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||
}
|
||||
|
||||
if (open is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
|
||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
||||
// keyring and not something the server can answer.
|
||||
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
|
||||
|
||||
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
|
||||
{
|
||||
Vaults.Add(new TeamVaultRowViewModel(
|
||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||
}
|
||||
|
||||
SelectedVault = Vaults.FirstOrDefault();
|
||||
}
|
||||
|
||||
private void RaiseState()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasTeams));
|
||||
OnPropertyChanged(nameof(HasSelection));
|
||||
OnPropertyChanged(nameof(CanAdministerSelected));
|
||||
OnPropertyChanged(nameof(IsOnline));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One place that raises the busy flag and turns a failure into a sentence. An API exception's message
|
||||
/// is the server's problem detail, which is written for a person to read — see <c>Problems</c> — so it
|
||||
/// is shown rather than replaced with something vaguer.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (DodoSshApiException exception)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException
|
||||
and not OperationCanceledException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,24 @@ using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Domain;
|
||||
using DodoSSH.Client.ObjectStore;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.Shell.ViewModels;
|
||||
|
||||
/// <summary>What sort of remote the file browser's right-hand pane is showing.</summary>
|
||||
internal enum RemoteKind
|
||||
{
|
||||
/// <summary>A host, over SFTP.</summary>
|
||||
Host,
|
||||
|
||||
/// <summary>An S3-compatible bucket.</summary>
|
||||
Bucket,
|
||||
}
|
||||
|
||||
/// <summary>One segment of a path, as a button in a breadcrumb trail.</summary>
|
||||
/// <param name="Name">What the segment is called.</param>
|
||||
/// <param name="Path">The absolute path that reaches it.</param>
|
||||
@@ -240,7 +252,20 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private IRemoteFileStore? session;
|
||||
private ConnectionRecorder? connectionLog;
|
||||
|
||||
/// <summary>How a bucket is opened, or null in a build that was not given one.</summary>
|
||||
private IObjectStoreFactory? objectStores;
|
||||
|
||||
/// <summary>The open SFTP connection, as the log will record it, or null when there is none.</summary>
|
||||
/// <remarks>
|
||||
/// Held rather than rebuilt at close time, because by then the session is being disposed and the host
|
||||
/// row it came from may have been replaced by a background sync. The address is the one that was
|
||||
/// actually dialled, which is the whole point of capturing it at connect.
|
||||
/// </remarks>
|
||||
private (string Address, string HostLabel, Guid HostId, DateTimeOffset StartedAt)? connected;
|
||||
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
@@ -249,7 +274,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
// The supplier answers with whatever session is current at the moment a transfer starts, which is
|
||||
// what lets a queue survive a disconnect and reconnect without every queued row failing.
|
||||
queue = new FileTransferQueue(_ => Task.FromResult(RequireSession()), clock);
|
||||
queue = new FileTransferQueue(_ => Task.FromResult<IRemoteFileStore>(RequireSession()), clock);
|
||||
queue.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
@@ -271,6 +296,49 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <summary>The buckets that can be browsed, which is the vault's list.</summary>
|
||||
/// <inheritdoc cref="Hosts" path="/remarks" />
|
||||
internal ObservableCollection<ObjectStoreRowViewModel> Buckets { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private ObjectStoreRowViewModel? selectedBucket;
|
||||
|
||||
/// <summary>
|
||||
/// Which sort of remote the right-hand pane is about to open.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Two buttons and a command rather than one picker holding both kinds, which is the opposite of what
|
||||
/// the host editor's authentication picker does — and the reason is that these two are not
|
||||
/// interchangeable the way a key and a password are. A host brings a password box, a host key prompt and
|
||||
/// a mismatch refusal with it; a bucket brings none of those and has no equivalent. One picker would
|
||||
/// mean a form whose surrounding half appears and disappears with the selection, which is a worse thing
|
||||
/// to look at than two clearly separate choices.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Settable, and the markup binds buttons rather than a selector's selection, for the reason the
|
||||
/// keychain's categories do: a selection binding moves before a command could refuse it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private RemoteKind remote;
|
||||
|
||||
/// <summary>Whether the picker is showing hosts.</summary>
|
||||
internal bool ShowsHostPicker => Remote is RemoteKind.Host;
|
||||
|
||||
/// <summary>Whether the picker is showing buckets.</summary>
|
||||
internal bool ShowsBucketPicker => Remote is RemoteKind.Bucket;
|
||||
|
||||
/// <summary>
|
||||
/// What the button that opens the remote says.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Connect" is wrong for a bucket and worth not saying: S3 is request-per-operation, so nothing is
|
||||
/// connected and nothing stays open. A word that implied otherwise would make the absence of a
|
||||
/// DISCONNECT step look like a bug rather than the shape of the protocol.
|
||||
/// </remarks>
|
||||
internal string ConnectLabel => Remote is RemoteKind.Bucket ? "OPEN" : "CONNECT";
|
||||
|
||||
/// <remarks>
|
||||
/// The transfers screen's own box, and deliberately not the one on the hosts screen. This connection is a
|
||||
/// separate authentication, so a password typed to open a terminal has not been offered here — and a
|
||||
@@ -288,6 +356,26 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>
|
||||
/// Whether something is being dragged over the local pane, and whether it would be accepted.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Two flags rather than one tri-state, because the markup binds visibility and Avalonia has no
|
||||
/// three-way binding — and because the refusing state is worth showing rather than merely not showing
|
||||
/// the accepting one. A pane that lights up nowhere while something is dragged over it reads as a
|
||||
/// window that has stopped responding.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private bool isLocalDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropTarget;
|
||||
|
||||
/// <inheritdoc cref="IsLocalDropTarget" />
|
||||
[ObservableProperty]
|
||||
private bool isRemoteDropRefused;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
@@ -304,7 +392,7 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
|
||||
/// <summary>Whether the chosen host will want something typed into the password box.</summary>
|
||||
internal bool SelectedHostAsksForAPassword =>
|
||||
SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
ShowsHostPicker && SelectedHost is null or { Host: { SshKeyId: null, CredentialId: null } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
@@ -376,10 +464,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
internal bool CanUpload => IsConnected && SelectedLocalEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Takes an unlocked vault, so the host list has something in it.</summary>
|
||||
internal void Attach(VaultViewModel openVault, VaultKnownHostStore hostKeys)
|
||||
/// <param name="openVault">The open keychain.</param>
|
||||
/// <param name="hostKeys">The pins this screen's own trust decisions are written to.</param>
|
||||
/// <param name="log">
|
||||
/// Where an SFTP session is recorded, or null to record none. Arrives here rather than being read off
|
||||
/// the vault, for the reason the recorder itself exists: it outlives the vault, and a session still open
|
||||
/// when the keychain locks still ends somewhere.
|
||||
/// </param>
|
||||
internal void Attach(
|
||||
VaultViewModel openVault,
|
||||
VaultKnownHostStore hostKeys,
|
||||
ConnectionRecorder? log = null,
|
||||
IObjectStoreFactory? buckets = null)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
connectionLog = log;
|
||||
objectStores = buckets;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
@@ -408,13 +509,78 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
Buckets.Clear();
|
||||
SelectedHost = null;
|
||||
SelectedBucket = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
/// <summary>Shows one of the two kinds of remote in the picker.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
private void ShowRemote(RemoteKind kind) => Remote = kind;
|
||||
|
||||
/// <summary>Opens the chosen remote, whichever kind it is.</summary>
|
||||
[RelayCommand]
|
||||
private Task ConnectAsync(CancellationToken cancellationToken) =>
|
||||
Remote is RemoteKind.Bucket
|
||||
? OpenBucketAsync(cancellationToken)
|
||||
: ConnectToHostAsync(cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens the chosen bucket.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// No host key prompt, no password box, and no connect step: S3 is request-per-operation, so the factory
|
||||
/// only builds a client and the first listing is what actually tests the keys and the endpoint. That is
|
||||
/// why the failure this reports is a listing failure rather than a connection one — there is no
|
||||
/// connection to fail.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It goes through the same session field, the same queue and the same panes as a host, because by this
|
||||
/// point it is an <c>IRemoteFileStore</c> like any other. Everything below this method was written for
|
||||
/// SFTP and needed no change.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private async Task OpenBucketAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (objectStores is not { } factory)
|
||||
{
|
||||
Status = "This build cannot open buckets.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (SelectedBucket is not { } row)
|
||||
{
|
||||
Status = "Choose a bucket first.";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Opening {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
session = factory.Open(row.Store);
|
||||
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture, $"s3://{row.Store.Bucket}");
|
||||
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Opened {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
private async Task ConnectToHostAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
@@ -463,6 +629,12 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
// Recorded, and not hidden because it is "only" the file browser. Opening this is a second
|
||||
// login as far as the remote's own auth.log is concerned, so a log of ours that omitted it
|
||||
// would disagree with the host's — and anybody comparing the two would be right to believe
|
||||
// the host.
|
||||
connected = (ConnectedTo, row.Label, row.EntityId, TimeProvider.System.GetUtcNow());
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
@@ -624,34 +796,147 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } row)
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on the host to download.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = Path.Combine(LocalPath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Download, destination, row.FullPath, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for download into {LocalPath}.";
|
||||
QueueDownloads([row]);
|
||||
}
|
||||
|
||||
/// <summary>Queues the chosen local file for upload into the remote directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Upload()
|
||||
{
|
||||
if (SelectedLocalEntry is not { IsFile: true } row)
|
||||
if (SelectedLocalEntry is not { } row)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
QueueUploads([row.FullPath]);
|
||||
}
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
/// <summary>
|
||||
/// Queues every one of these local paths for upload into the remote directory showing.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The one path both the button and a drop go through, so there is one set of rules about what can be
|
||||
/// queued rather than two that have to agree. The button hands it one path; a drop hands it however many
|
||||
/// were dragged, from this window's own pane or from the file manager.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Directories are skipped and counted.</b> The queue moves files: there is no recursive upload, and
|
||||
/// silently ignoring the folder somebody just dragged would look like a transfer that failed to start.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Reported per item, not per drop.</b> The queue refuses to overwrite, so a drop of five files where
|
||||
/// two names already exist is three transfers and two refusals — and "the drop failed" would be wrong
|
||||
/// about all five.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal void QueueUploads(IReadOnlyList<string> paths)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var path in paths)
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Between the drag starting and the drop landing, a file can be moved or deleted — and the
|
||||
// paths in an OS drop come from another process, which is not obliged to be right about them.
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
missing++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var length = new FileInfo(path).Length;
|
||||
var destination = SftpPath.Combine(RemotePath, Path.GetFileName(path));
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, path, destination, length);
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "upload into", RemotePath, directories, missing);
|
||||
}
|
||||
|
||||
/// <summary>Queues every one of these remote entries for download into the local directory showing.</summary>
|
||||
/// <inheritdoc cref="QueueUploads" path="/remarks" />
|
||||
internal void QueueDownloads(IReadOnlyList<RemoteEntryRowViewModel> rows)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rows);
|
||||
|
||||
if (!IsConnected)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
var queued = 0;
|
||||
var directories = 0;
|
||||
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (!row.IsFile)
|
||||
{
|
||||
directories++;
|
||||
continue;
|
||||
}
|
||||
|
||||
queue.Enqueue(
|
||||
TransferDirection.Download,
|
||||
Path.Combine(LocalPath, row.Name),
|
||||
row.FullPath,
|
||||
row.Entry.Length);
|
||||
|
||||
queued++;
|
||||
}
|
||||
|
||||
Status = Describe(queued, "download into", LocalPath, directories, missing: 0);
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// One sentence for both directions and every shape of partial success. What it must never do is stay
|
||||
/// silent about the difference: a drop of six that queued four and reported "queued 4" leaves somebody
|
||||
/// looking for the other two in a queue they are not in.
|
||||
/// </remarks>
|
||||
private static string Describe(int queued, string verb, string destination, int directories, int missing)
|
||||
{
|
||||
var files = queued == 1 ? "1 file" : $"{queued} files";
|
||||
var said = queued == 0
|
||||
? "Nothing was queued."
|
||||
: $"Queued {files} for {verb} {destination}.";
|
||||
|
||||
if (directories > 0)
|
||||
{
|
||||
var folders = directories == 1 ? "1 folder was" : $"{directories} folders were";
|
||||
said += $" {folders} skipped — only files can be transferred.";
|
||||
}
|
||||
|
||||
if (missing > 0)
|
||||
{
|
||||
var gone = missing == 1 ? "1 item was" : $"{missing} items were";
|
||||
said += $" {gone} no longer there.";
|
||||
}
|
||||
|
||||
return said;
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
@@ -919,10 +1204,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
|
||||
Buckets.Clear();
|
||||
|
||||
foreach (var bucket in open.ObjectStores)
|
||||
{
|
||||
Buckets.Add(bucket);
|
||||
}
|
||||
|
||||
SelectedBucket ??= Buckets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
private IRemoteFileStore RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
@@ -934,6 +1228,23 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
// Written whole here rather than through an open/close ticket, because this connection is not one
|
||||
// the terminal workspace ever knew about — it has no session id, and borrowing one would collide
|
||||
// with a real terminal's.
|
||||
if (connected is { } record)
|
||||
{
|
||||
connected = null;
|
||||
|
||||
connectionLog?.Record(
|
||||
record.Address,
|
||||
record.HostLabel,
|
||||
record.HostId,
|
||||
ConnectionKind.Sftp,
|
||||
record.StartedAt,
|
||||
TimeProvider.System.GetUtcNow(),
|
||||
ConnectionOutcome.Closed);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
@@ -996,6 +1307,19 @@ internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDispo
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
/// <remarks>
|
||||
/// The password box follows this as well as the host, because it is shown only for a host that asks for
|
||||
/// one — and a bucket never does. Without this, switching to BUCKET would leave a password box beside a
|
||||
/// picker that has nothing to do with passwords.
|
||||
/// </remarks>
|
||||
partial void OnRemoteChanged(RemoteKind value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowsHostPicker));
|
||||
OnPropertyChanged(nameof(ShowsBucketPicker));
|
||||
OnPropertyChanged(nameof(ConnectLabel));
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
}
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,7 @@ const SERVER_SESSION_OPENED = 2;
|
||||
const SERVER_SESSION_CLOSED = 3;
|
||||
const SERVER_SESSION_ACTIVATED = 4;
|
||||
const SERVER_SESSION_REMOVED = 5;
|
||||
const SERVER_PASTE = 6;
|
||||
|
||||
const CLIENT_INPUT = 1;
|
||||
const CLIENT_ACKNOWLEDGE = 2;
|
||||
@@ -275,6 +276,39 @@ function handleFrame(buffer) {
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_PASTE: {
|
||||
const session = sessions.get(sessionId);
|
||||
|
||||
if (!session || payload.length < 1) {
|
||||
break;
|
||||
}
|
||||
|
||||
const execute = payload[0] !== 0;
|
||||
const text = new TextDecoder().decode(payload.subarray(1));
|
||||
|
||||
/*
|
||||
term.paste rather than term.input, and that is the whole reason this frame exists rather than
|
||||
the host writing the bytes into the pump. paste() wraps the text in bracketed-paste markers
|
||||
when the remote has turned that mode on — xterm tracks \e[?2004h from the output stream, which
|
||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
||||
markers treats every newline as text. Without them it treats each one as "run this", so a
|
||||
three-line snippet runs three commands the moment it is inserted.
|
||||
*/
|
||||
session.term.paste(text);
|
||||
|
||||
/*
|
||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||
pasted text would be bracketed along with it and arrive at the shell as a literal carriage
|
||||
return, so nothing would run — which is the failure that looks like the feature working right
|
||||
up until somebody wonders why RUN does not.
|
||||
*/
|
||||
if (execute) {
|
||||
session.term.input('\r');
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case SERVER_SESSION_CLOSED: {
|
||||
const session = sessions.get(sessionId);
|
||||
const reason = new TextDecoder().decode(payload);
|
||||
|
||||
@@ -170,6 +170,21 @@
|
||||
"dodossh.client.domain": {
|
||||
"type": "Project"
|
||||
},
|
||||
"dodossh.client.import": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.objectstore": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, )",
|
||||
"AWSSDK.S3": "[4.0.101.6, )",
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.session": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
@@ -178,12 +193,14 @@
|
||||
"DodoSSH.Client.Domain": "[1.0.0, )",
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )",
|
||||
"DodoSSH.Client.Storage": "[1.0.0, )",
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )"
|
||||
"DodoSSH.Client.Sync": "[1.0.0, )",
|
||||
"DodoSSH.Client.Terminal": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"NSec.Cryptography": "[26.4.0, )",
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
@@ -227,6 +244,21 @@
|
||||
"NSec.Cryptography": "[26.4.0, )"
|
||||
}
|
||||
},
|
||||
"AWSSDK.Core": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.100.9, )",
|
||||
"resolved": "4.0.100.9",
|
||||
"contentHash": "OPYy41jZjXwxxcYRotaq24HDrwUnVtBB/mvg1IwB9D1ICXAtHqMa1sp2hpmlVJCZwjlcrPcTCJIejInvV1vp5g=="
|
||||
},
|
||||
"AWSSDK.S3": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[4.0.101.6, )",
|
||||
"resolved": "4.0.101.6",
|
||||
"contentHash": "LsVXGc3lyJuUJe+EbGubkFeR0cVmtmj4YdMChqsqSIsjZtCMzPg2BXR7cJqcrIBGoHab3q3RS6K8T9QD2tbhhQ==",
|
||||
"dependencies": {
|
||||
"AWSSDK.Core": "[4.0.100.9, 5.0.0)"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
|
||||
Reference in New Issue
Block a user