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:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
@@ -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 &amp;&amp; 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);
+33 -1
View File
@@ -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, )",