Public Access
This commit is contained in:
@@ -58,9 +58,13 @@ internal sealed partial class DodoSshApp : Application
|
||||
// for why the handshake is answered from a snapshot rather than by reading the vault per lookup.
|
||||
var knownHosts = new VaultKnownHostStore();
|
||||
|
||||
// One factory for both kinds of connection. Shells and file transfers start with the same handshake
|
||||
// and the same host key decision, and composing two would mean two snapshots of the pins.
|
||||
var connections = new SshNetConnectionFactory(knownHosts);
|
||||
|
||||
var workspace = new TerminalWorkspace(
|
||||
new AvaloniaTerminalAssetProvider(),
|
||||
new SshNetConnectionFactory(knownHosts),
|
||||
connections,
|
||||
TimeProvider.System);
|
||||
|
||||
workspace.Start();
|
||||
@@ -81,7 +85,8 @@ internal sealed partial class DodoSshApp : Application
|
||||
async (url, cancellationToken) => await ServerConnection
|
||||
.SignInAsync(url, browser, TimeProvider.System, cancellationToken)
|
||||
.ConfigureAwait(false),
|
||||
TimeProvider.System);
|
||||
TimeProvider.System,
|
||||
connections);
|
||||
|
||||
desktop.MainWindow = new MainWindow { DataContext = viewModel };
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<ProjectReference Include="../DodoSSH.Client.Session/DodoSSH.Client.Session.csproj" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Auth;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Storage;
|
||||
using DodoSSH.Client.Terminal;
|
||||
using DodoSSH.Crypto;
|
||||
@@ -52,11 +53,10 @@ internal enum ShellState
|
||||
/// in you are, the other is what you are looking at once you are.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <see cref="Transfers"/> and <see cref="Team"/> are in this list without anything behind them, which is
|
||||
/// stated on the screens themselves rather than hidden by dropping them from the rail. See
|
||||
/// <c>docs/design-import-gaps.md</c>: file transfer is M2 and teams are M3, and a rail that quietly had
|
||||
/// three entries would make the eventual arrival of the other two look like a new product rather than a
|
||||
/// milestone.
|
||||
/// <see cref="Team"/> is in this list without anything behind it, which is stated on the screen itself
|
||||
/// rather than hidden by dropping it from the rail. See <c>docs/design-import-gaps.md</c>: teams are M3, and
|
||||
/// a rail that quietly had four entries would make its eventual arrival look like a new product rather than
|
||||
/// a milestone. <see cref="Transfers"/> was the other one until M2 built it.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal enum ShellScreen
|
||||
@@ -64,7 +64,7 @@ internal enum ShellScreen
|
||||
/// <summary>The host list and the terminals, which is where the application opens.</summary>
|
||||
Hosts = 0,
|
||||
|
||||
/// <summary>File transfer. Nothing implements it yet.</summary>
|
||||
/// <summary>File transfer over SFTP: two directory panes and a queue.</summary>
|
||||
Transfers = 1,
|
||||
|
||||
/// <summary>Everything in the vault that is not a host.</summary>
|
||||
@@ -118,6 +118,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
private readonly TimeProvider clock;
|
||||
private readonly Argon2Profile? passphraseProfile;
|
||||
|
||||
/// <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
|
||||
/// a transfer in flight any more than it closes a running shell. See <see cref="LockAsync"/>. The vault
|
||||
/// is attached to it on unlock and detached on lock, which is all the vault is for here — the host list.
|
||||
/// </remarks>
|
||||
private readonly TransfersViewModel transfers;
|
||||
|
||||
private IVaultServer? connection;
|
||||
private bool disposed;
|
||||
|
||||
@@ -132,6 +140,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
/// </remarks>
|
||||
internal delegate Task<IVaultServer> SignInHandler(Uri serverUrl, CancellationToken cancellationToken);
|
||||
|
||||
/// <param name="sftpSessions">
|
||||
/// How file-transfer sessions are opened. The same object as the connection factory in the composed
|
||||
/// application — one type implements both — and a separate parameter because it is a separate capability
|
||||
/// and the tests that drive this state machine have no use for it.
|
||||
/// </param>
|
||||
internal MainWindowViewModel(
|
||||
ClientPaths paths,
|
||||
ClientCacheFactory caches,
|
||||
@@ -140,6 +153,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
IDeviceKeyStore deviceKeys,
|
||||
SignInHandler signIn,
|
||||
TimeProvider clock,
|
||||
ISftpSessionFactory sftpSessions,
|
||||
Argon2Profile? passphraseProfile = null)
|
||||
{
|
||||
this.paths = paths;
|
||||
@@ -151,6 +165,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
this.clock = clock;
|
||||
this.passphraseProfile = passphraseProfile;
|
||||
|
||||
transfers = new TransfersViewModel(sftpSessions, clock);
|
||||
|
||||
// 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;
|
||||
@@ -222,6 +238,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
[ObservableProperty]
|
||||
private VaultViewModel? vault;
|
||||
|
||||
/// <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 —
|
||||
/// the whole shell is — but the object behind it is what holds a transfer that is still running, so a
|
||||
/// property that went null on lock would be a transfer nothing could report on afterwards.
|
||||
/// </remarks>
|
||||
internal TransfersViewModel Transfers => transfers;
|
||||
|
||||
/// <summary>
|
||||
/// Shells that were left running when the vault was locked.
|
||||
/// </summary>
|
||||
@@ -900,6 +924,10 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
await Vault.LoadAsync(cancellationToken).ConfigureAwait(true);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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
|
||||
// second old.
|
||||
@@ -947,6 +975,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
// reappearing behind a lock screen.
|
||||
knownHosts.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.
|
||||
transfers.Detach();
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
Vault = null;
|
||||
@@ -973,6 +1006,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
||||
|
||||
knownHosts.Close();
|
||||
|
||||
// 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.
|
||||
await transfers.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (Vault is { } open)
|
||||
{
|
||||
await open.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
@@ -0,0 +1,935 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Globalization;
|
||||
using Avalonia.Threading;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using DodoSSH.Client.Session;
|
||||
using DodoSSH.Client.Ssh;
|
||||
using DodoSSH.Client.Transfer;
|
||||
|
||||
namespace DodoSSH.Client.App.ViewModels;
|
||||
|
||||
/// <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>
|
||||
internal sealed record CrumbViewModel(string Name, string Path);
|
||||
|
||||
/// <summary>One remote file or directory, as a row.</summary>
|
||||
internal sealed class RemoteEntryRowViewModel(SftpEntry entry)
|
||||
{
|
||||
internal SftpEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsNavigable;
|
||||
|
||||
internal bool IsFile => entry.Kind is SftpEntryKind.File;
|
||||
|
||||
/// <remarks>
|
||||
/// A directory shows nothing rather than a zero. Its inode's size is a number no user has ever wanted,
|
||||
/// and a column of zeroes beside real sizes reads as a listing that failed to measure them.
|
||||
/// </remarks>
|
||||
internal string Size => entry.Kind is SftpEntryKind.File ? ByteSize.Format(entry.Length) : string.Empty;
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
|
||||
/// <summary>The mode as <c>drwxr-xr-x</c>, which is the design's <c>PERMS</c> column.</summary>
|
||||
internal string Permissions => entry.Permissions;
|
||||
}
|
||||
|
||||
/// <summary>One local file or directory, as a row.</summary>
|
||||
/// <remarks>
|
||||
/// The same shape as the remote row minus the permissions, which have no honest value here: this client is
|
||||
/// developed on Windows, where a POSIX mode is not a fact about a file. The column is empty on this side
|
||||
/// rather than filled with a plausible-looking <c>-rw-r--r--</c>.
|
||||
/// </remarks>
|
||||
internal sealed class LocalEntryRowViewModel(LocalEntry entry)
|
||||
{
|
||||
internal LocalEntry Entry => entry;
|
||||
|
||||
internal string Name => entry.Name;
|
||||
|
||||
internal string FullPath => entry.FullPath;
|
||||
|
||||
internal bool IsNavigable => entry.IsDirectory;
|
||||
|
||||
internal bool IsFile => !entry.IsDirectory;
|
||||
|
||||
internal string Size => entry.IsDirectory ? string.Empty : ByteSize.Format(entry.Length);
|
||||
|
||||
internal string Modified => Timestamps.Format(entry.LastWriteTimeUtc);
|
||||
}
|
||||
|
||||
/// <summary>How this screen writes a modification time.</summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// UTC and ISO-ordered, in one place, because both panes show this column side by side: a local pane in this
|
||||
/// machine's conventions beside a remote pane in the server's would invite comparing two timestamps that are
|
||||
/// not written the same way, which is the only thing anybody does with this column.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The one place in this application that deliberately ignores the user's locale — see the App project's
|
||||
/// <c>InvariantGlobalization</c>, which is false precisely so dates elsewhere follow it. Sortable order and
|
||||
/// an unambiguous zone beat familiarity when the two columns have to be read against each other.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class Timestamps
|
||||
{
|
||||
internal static string Format(DateTimeOffset moment) =>
|
||||
moment.UtcDateTime.ToString("yyyy-MM-dd HH:mm", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>One transfer, as a row in the queue.</summary>
|
||||
/// <remarks>
|
||||
/// Observable and long-lived, unlike the two listing rows, because a transfer's progress changes several
|
||||
/// times a second while its identity does not. It is refreshed from <see cref="TransferSnapshot"/> rather
|
||||
/// than holding the queue's own object: the queue mutates its entries from a thread-pool thread, and this is
|
||||
/// only ever read from the UI thread.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransferRowViewModel(TransferSnapshot snapshot) : ObservableObject
|
||||
{
|
||||
[ObservableProperty]
|
||||
private TransferSnapshot transfer = snapshot;
|
||||
|
||||
internal Guid Id => Transfer.Id;
|
||||
|
||||
internal string Name => Transfer.Name;
|
||||
|
||||
/// <summary>Which way, as an arrow the eye can scan a column of.</summary>
|
||||
internal string Arrow => Transfer.Direction is TransferDirection.Download ? "↓" : "↑";
|
||||
|
||||
/// <summary>The end that is not this machine, which is the one worth showing.</summary>
|
||||
internal string Path => Transfer.Direction is TransferDirection.Download
|
||||
? Transfer.RemotePath
|
||||
: Transfer.LocalPath;
|
||||
|
||||
internal double Percent => Transfer.Fraction * 100;
|
||||
|
||||
/// <summary>
|
||||
/// What the row says about where it has got to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The bytes and the rate together while it runs, because either alone leaves the obvious question
|
||||
/// unanswered — a rate with no total cannot say how long is left, and a total with no rate cannot say
|
||||
/// whether it is still moving.
|
||||
/// </remarks>
|
||||
internal string Progress => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "queued",
|
||||
TransferState.Running =>
|
||||
$"{ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}"
|
||||
+ $" · {ByteSize.Format((long)Transfer.BytesPerSecond)}/s",
|
||||
TransferState.Completed => ByteSize.Format(Transfer.Length),
|
||||
TransferState.Cancelled when Transfer.Transferred > 0 =>
|
||||
$"stopped at {ByteSize.Format(Transfer.Transferred)} of {ByteSize.Format(Transfer.Length)}",
|
||||
TransferState.Cancelled => "stopped",
|
||||
_ => Transfer.Failure ?? "failed",
|
||||
};
|
||||
|
||||
internal string StateLabel => Transfer.State switch
|
||||
{
|
||||
TransferState.Queued => "QUEUED",
|
||||
TransferState.Running => "RUNNING",
|
||||
TransferState.Completed => "DONE",
|
||||
TransferState.Cancelled => "STOPPED",
|
||||
_ => "FAILED",
|
||||
};
|
||||
|
||||
internal bool IsRunning => Transfer.State is TransferState.Running or TransferState.Queued;
|
||||
|
||||
internal bool IsFinished => Transfer.IsFinished;
|
||||
|
||||
internal bool CanResume => Transfer.CanResume;
|
||||
|
||||
internal bool HasFailed => Transfer.State is TransferState.Failed;
|
||||
|
||||
/// <summary>Whether a stopped transfer is worth offering to run again at all.</summary>
|
||||
/// <remarks>
|
||||
/// Wider than <see cref="CanResume"/>: a transfer that failed before it moved a byte — the host was
|
||||
/// unreachable, the destination was occupied — is worth retrying from the start, and only the button's
|
||||
/// wording differs. See <see cref="RetryLabel"/>.
|
||||
/// </remarks>
|
||||
internal bool CanRetry => Transfer.IsFinished && Transfer.State is not TransferState.Completed;
|
||||
|
||||
internal string RetryLabel => CanResume ? "RESUME" : "RETRY";
|
||||
|
||||
/// <remarks>
|
||||
/// Every derived member at once. They are one fact — the snapshot — read from eight directions, and
|
||||
/// raising only the ones that happened to change is how a progress bar moves under a label that still
|
||||
/// says "queued".
|
||||
/// </remarks>
|
||||
partial void OnTransferChanged(TransferSnapshot value)
|
||||
{
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
OnPropertyChanged(nameof(Percent));
|
||||
OnPropertyChanged(nameof(StateLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsFinished));
|
||||
OnPropertyChanged(nameof(CanResume));
|
||||
OnPropertyChanged(nameof(CanRetry));
|
||||
OnPropertyChanged(nameof(HasFailed));
|
||||
OnPropertyChanged(nameof(RetryLabel));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfers screen: a host, two directory panes, and the queue between them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Its connection is its own.</b> SSH.NET cannot open an SFTP subsystem on a transport that is already
|
||||
/// carrying a shell — see <c>ISftpSession</c> — so this screen authenticates separately, and connecting here
|
||||
/// is a deliberate act with its own button rather than something that happens because a terminal is open.
|
||||
/// The consequence a user sees is that the host records a second login, and that a host whose password is
|
||||
/// typed each time asks for it again here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>It outlives a lock, as terminals do.</b> This object is created once and the vault is attached to it
|
||||
/// on unlock and detached on lock, the same arrangement <c>VaultKnownHostStore</c> has and for the same
|
||||
/// reason: <c>MainWindowViewModel.LockAsync</c> argues that locking must not destroy work in flight, and a
|
||||
/// half-finished transfer is the clearest case of work in flight there is. What locking takes away is the
|
||||
/// host list — those are decrypted vault items — and not the connection or the queue.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersViewModel : ObservableObject, IAsyncDisposable
|
||||
{
|
||||
private readonly ISftpSessionFactory sftp;
|
||||
private readonly FileTransferQueue queue;
|
||||
|
||||
private VaultViewModel? vault;
|
||||
private VaultKnownHostStore? knownHosts;
|
||||
private ISftpSession? session;
|
||||
private bool disposed;
|
||||
|
||||
internal TransfersViewModel(ISftpSessionFactory sftp, TimeProvider clock)
|
||||
{
|
||||
this.sftp = sftp;
|
||||
|
||||
// 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.Changed += OnTransferChanged;
|
||||
|
||||
// The three "is there anything in it" flags follow their collections rather than being raised by
|
||||
// hand at each of the eight places that add or clear a row. Subscribed for the life of this object,
|
||||
// which is the life of the process.
|
||||
RemoteEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasRemoteEntries));
|
||||
LocalEntries.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasLocalEntries));
|
||||
Transfers.CollectionChanged += (_, _) => OnPropertyChanged(nameof(HasTransfers));
|
||||
}
|
||||
|
||||
/// <summary>The hosts that can be connected to, which is the vault's list.</summary>
|
||||
/// <remarks>
|
||||
/// A collection of its own rather than the vault's, because it is empty while locked and the vault's is
|
||||
/// not this object's to clear. The rows are shared: they carry the decrypted host, and copying that would
|
||||
/// be a second decrypted copy of a secret for no gain.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<HostRowViewModel> Hosts { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private HostRowViewModel? selectedHost;
|
||||
|
||||
/// <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
|
||||
/// screen that quietly reused it would make a one-time password appear to work twice.
|
||||
/// </remarks>
|
||||
[ObservableProperty]
|
||||
private string typedPassword = string.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
private string status = "Choose a host and connect to browse its files.";
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isBusy;
|
||||
|
||||
[ObservableProperty]
|
||||
private bool isConnected;
|
||||
|
||||
/// <summary>The account and endpoint actually dialled, once connected.</summary>
|
||||
[ObservableProperty]
|
||||
private string? connectedTo;
|
||||
|
||||
[ObservableProperty]
|
||||
private HostKeyPresentation? pendingHostKey;
|
||||
|
||||
[ObservableProperty]
|
||||
private string? hostKeyMismatch;
|
||||
|
||||
internal bool HasPendingHostKey => PendingHostKey is not null;
|
||||
|
||||
internal bool HasHostKeyMismatch => HostKeyMismatch is not null;
|
||||
|
||||
/// <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 } };
|
||||
|
||||
// ---- The remote pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string remotePath = string.Empty;
|
||||
|
||||
internal ObservableCollection<RemoteEntryRowViewModel> RemoteEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> RemoteTrail { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private RemoteEntryRowViewModel? selectedRemoteEntry;
|
||||
|
||||
/// <summary>What a new directory would be called, when the user is making one.</summary>
|
||||
[ObservableProperty]
|
||||
private string newRemoteFolder = string.Empty;
|
||||
|
||||
internal bool HasRemoteEntries => RemoteEntries.Count > 0;
|
||||
|
||||
// ---- The local pane ----
|
||||
|
||||
[ObservableProperty]
|
||||
private string localPath = LocalDirectory.Home;
|
||||
|
||||
internal ObservableCollection<LocalEntryRowViewModel> LocalEntries { get; } = [];
|
||||
|
||||
internal ObservableCollection<CrumbViewModel> LocalTrail { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// The drives this machine has, as somewhere the local pane can jump to.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The remote pane's breadcrumb reaches everywhere, because a POSIX filesystem has one root. This one
|
||||
/// does not: above <c>C:\</c> is a list of drives rather than a directory, so without this the pane
|
||||
/// could be walked to the top of the drive it opened on and no further — and a file on <c>D:</c> would
|
||||
/// be unreachable from an application whose whole purpose on this screen is to move one.
|
||||
/// </remarks>
|
||||
internal ObservableCollection<CrumbViewModel> LocalRoots { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private LocalEntryRowViewModel? selectedLocalEntry;
|
||||
|
||||
internal bool HasLocalEntries => LocalEntries.Count > 0;
|
||||
|
||||
// ---- The queue ----
|
||||
|
||||
internal ObservableCollection<TransferRowViewModel> Transfers { get; } = [];
|
||||
|
||||
internal bool HasTransfers => Transfers.Count > 0;
|
||||
|
||||
/// <summary>Whether a download of the chosen remote file would have somewhere to go.</summary>
|
||||
internal bool CanDownload => IsConnected && SelectedRemoteEntry is { IsFile: true };
|
||||
|
||||
/// <summary>Whether an upload of the chosen local file would have somewhere to go.</summary>
|
||||
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)
|
||||
{
|
||||
vault = openVault;
|
||||
knownHosts = hostKeys;
|
||||
|
||||
RefreshHosts();
|
||||
|
||||
// Read once per unlock rather than per navigation: a drive appearing while the application is open
|
||||
// is possible and rare, and probing every removable drive on every click into a folder is not.
|
||||
LocalRoots.Clear();
|
||||
|
||||
foreach (var root in LocalDirectory.Roots())
|
||||
{
|
||||
LocalRoots.Add(new CrumbViewModel(root.TrimEnd(Path.DirectorySeparatorChar), root));
|
||||
}
|
||||
|
||||
RefreshLocalCommand.Execute(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gives up the vault, keeping the connection and anything in flight.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The host list goes because those rows carry decrypted secrets and the vault they came from is being
|
||||
/// disposed. The session and the queue stay, which is the whole point: see the remark on this type.
|
||||
/// </remarks>
|
||||
internal void Detach()
|
||||
{
|
||||
vault = null;
|
||||
knownHosts = null;
|
||||
|
||||
Hosts.Clear();
|
||||
SelectedHost = null;
|
||||
TypedPassword = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Opens a file-transfer session on the chosen host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task ConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (vault is not { } open || SelectedHost is not { } row)
|
||||
{
|
||||
Status = "Choose a host first.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!open.TryBuildConnectionRequest(row.Host, TypedPassword, out var request, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
await RunAsync(
|
||||
$"Connecting to {row.Label}…",
|
||||
async () =>
|
||||
{
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
try
|
||||
{
|
||||
session = await sftp.OpenSftpAsync(request, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (SshHostKeyUnknownException exception)
|
||||
{
|
||||
// First contact, decided here rather than inherited from a terminal. File transfer is
|
||||
// its own connection, so it makes its own trust decision — and the pin it writes is the
|
||||
// same pin a shell would then find.
|
||||
PendingHostKey = exception.Presentation;
|
||||
Status = "This host has not been seen before.";
|
||||
return;
|
||||
}
|
||||
catch (SshHostKeyMismatchException exception)
|
||||
{
|
||||
HostKeyMismatch = exception.Message;
|
||||
Status = "The host key has changed. Nothing was connected.";
|
||||
return;
|
||||
}
|
||||
|
||||
TypedPassword = string.Empty;
|
||||
IsConnected = true;
|
||||
ConnectedTo = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{request.Username}@{request.Host}:{request.Port}");
|
||||
|
||||
await ListRemoteAsync(session.HomeDirectory, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Connected to {row.Label}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Closes the file-transfer session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Refuses while the queue has work, rather than cancelling it. Disconnecting is a tidy-up and stopping
|
||||
/// a transfer is a decision about somebody's file; a button that did both would make the second one by
|
||||
/// accident.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DisconnectAsync()
|
||||
{
|
||||
if (queue.IsBusy)
|
||||
{
|
||||
Status = "There are transfers still running. Stop them first, or let them finish.";
|
||||
return;
|
||||
}
|
||||
|
||||
await CloseSessionAsync().ConfigureAwait(true);
|
||||
|
||||
Status = "Disconnected. Anything already transferred is where it landed.";
|
||||
}
|
||||
|
||||
/// <summary>Pins the offered host key and connects.</summary>
|
||||
[RelayCommand]
|
||||
private async Task TrustHostKeyAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (PendingHostKey is not { } presentation || knownHosts is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await knownHosts.TrustAsync(presentation, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
Status = $"The host key could not be stored, so nothing was connected: {exception.Message}";
|
||||
return;
|
||||
}
|
||||
|
||||
PendingHostKey = null;
|
||||
|
||||
await ConnectAsync(cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dismisses whichever host key card is showing, without pinning or forgetting anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One command for both cards, because both are dismissals and the two states are mutually exclusive.
|
||||
/// The mismatch card has nothing else it may offer: withdrawing a pin is a deliberate act performed in
|
||||
/// the host's editor, away from the moment of connecting, and a button here would be "continue anyway"
|
||||
/// with two clicks instead of one.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void RejectHostKey()
|
||||
{
|
||||
var wasOffered = PendingHostKey is not null;
|
||||
|
||||
PendingHostKey = null;
|
||||
HostKeyMismatch = null;
|
||||
|
||||
Status = wasOffered
|
||||
? "The host key was not trusted, so nothing was connected."
|
||||
: "Nothing was connected.";
|
||||
}
|
||||
|
||||
// ---- Navigation ----
|
||||
|
||||
/// <summary>Goes to a remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task GoRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
await NavigateRemoteAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>Goes up one remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoteUpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(SftpPath.Parent(RemotePath), cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the remote directory.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RefreshRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (RemotePath.Length > 0)
|
||||
{
|
||||
await NavigateRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens whatever is selected in the remote pane, if it is somewhere to go.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A symbolic link is tried as a directory: a listing carries <c>lstat</c> attributes, so a link to a
|
||||
/// directory reports as a link and resolving every one of them would be a round trip per row. The
|
||||
/// failure, when it is a link to a file, is the server's own and says so.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task OpenRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is { IsNavigable: true } row)
|
||||
{
|
||||
await NavigateRemoteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Goes to a local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void GoLocal(string path) => NavigateLocal(path);
|
||||
|
||||
/// <summary>Goes up one local directory, as far as the top of the drive.</summary>
|
||||
/// <remarks>
|
||||
/// Above a drive root there is no directory to list, so this stops there and says so. Getting to another
|
||||
/// drive is <see cref="LocalRoots"/>, which is a list of places rather than a step upwards.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private void LocalUp()
|
||||
{
|
||||
if (LocalDirectory.Parent(LocalPath) is { } parent)
|
||||
{
|
||||
NavigateLocal(parent);
|
||||
return;
|
||||
}
|
||||
|
||||
Status = "That is the top of this drive. Use the drive list to go to another one.";
|
||||
}
|
||||
|
||||
/// <summary>Re-reads the local directory.</summary>
|
||||
[RelayCommand]
|
||||
private void RefreshLocal() => NavigateLocal(LocalPath);
|
||||
|
||||
/// <summary>Opens whatever is selected in the local pane, if it is a directory.</summary>
|
||||
[RelayCommand]
|
||||
private void OpenLocal()
|
||||
{
|
||||
if (SelectedLocalEntry is { IsNavigable: true } row)
|
||||
{
|
||||
NavigateLocal(row.FullPath);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Moving files ----
|
||||
|
||||
/// <summary>Queues the chosen remote file for download into the local directory showing.</summary>
|
||||
[RelayCommand]
|
||||
private void Download()
|
||||
{
|
||||
if (SelectedRemoteEntry is not { IsFile: true } 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}.";
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
Status = "Choose a file on this machine to upload.";
|
||||
return;
|
||||
}
|
||||
|
||||
var destination = SftpPath.Combine(RemotePath, row.Name);
|
||||
|
||||
queue.Enqueue(TransferDirection.Upload, row.FullPath, destination, row.Entry.Length);
|
||||
|
||||
Status = $"Queued {row.Name} for upload into {RemotePath}.";
|
||||
}
|
||||
|
||||
/// <summary>Stops one transfer.</summary>
|
||||
[RelayCommand]
|
||||
private void CancelTransfer(TransferRowViewModel row) => queue.Cancel(row.Id);
|
||||
|
||||
/// <summary>Runs a stopped transfer again, resuming where there is something to resume from.</summary>
|
||||
[RelayCommand]
|
||||
private void RetryTransfer(TransferRowViewModel row) => queue.Retry(row.Id);
|
||||
|
||||
/// <summary>Removes one stopped transfer and whatever it left behind.</summary>
|
||||
[RelayCommand]
|
||||
private async Task DiscardTransferAsync(TransferRowViewModel row, CancellationToken cancellationToken)
|
||||
{
|
||||
// Only when the queue agreed. It refuses to discard a transfer that has not finished, and a row
|
||||
// removed anyway would take the only view of a transfer that was still running.
|
||||
if (await queue.DiscardAsync(row.Id, cancellationToken).ConfigureAwait(true))
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Clears the finished transfers, which have nothing left on disk.</summary>
|
||||
[RelayCommand]
|
||||
private void ClearCompleted()
|
||||
{
|
||||
queue.ClearCompleted();
|
||||
|
||||
foreach (var row in Transfers.Where(row => row.Transfer.State is TransferState.Completed).ToArray())
|
||||
{
|
||||
Transfers.Remove(row);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// ---- Changing the remote directory ----
|
||||
|
||||
/// <summary>Creates a directory on the host.</summary>
|
||||
[RelayCommand]
|
||||
private async Task CreateRemoteFolderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var name = NewRemoteFolder.Trim();
|
||||
|
||||
if (name.Length == 0)
|
||||
{
|
||||
Status = "Type a name for the new directory.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.Contains('/', StringComparison.Ordinal))
|
||||
{
|
||||
// One directory, whose parent must exist. A name with a separator in it would be a request to
|
||||
// create a path, and this button creates a directory in the one on screen.
|
||||
Status = "A directory name cannot contain '/'. Make one level at a time.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Creating {name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession()
|
||||
.CreateDirectoryAsync(SftpPath.Combine(RemotePath, name), cancellationToken)
|
||||
.ConfigureAwait(true);
|
||||
|
||||
NewRemoteFolder = string.Empty;
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Created {name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes the chosen remote file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Not recursive, and the refusal comes from the server rather than from a check here — see
|
||||
/// <c>ISftpSession.DeleteAsync</c>. It is offered because the queue refuses to overwrite: without a way
|
||||
/// to remove what is in the way, "that file is already there" would be a dead end.
|
||||
/// </remarks>
|
||||
[RelayCommand]
|
||||
private async Task DeleteRemoteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (SelectedRemoteEntry is not { } row)
|
||||
{
|
||||
Status = "Choose something on the host to delete.";
|
||||
return;
|
||||
}
|
||||
|
||||
await RunAsync(
|
||||
$"Deleting {row.Name}…",
|
||||
async () =>
|
||||
{
|
||||
await RequireSession().DeleteAsync(row.FullPath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
await ListRemoteAsync(RemotePath, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
Status = $"Deleted {row.Name}.";
|
||||
}).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
|
||||
queue.Changed -= OnTransferChanged;
|
||||
|
||||
// The queue first: it holds the session, and a transfer still writing into a part file has to finish
|
||||
// unwinding before the transport under it goes.
|
||||
await queue.DisposeAsync().ConfigureAwait(false);
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
await session.DisposeAsync().ConfigureAwait(false);
|
||||
session = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Goes to a remote directory, on its own.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Split from <see cref="ListRemoteAsync"/> so that the commands which navigate <em>as part of</em>
|
||||
/// something else — connecting, making a directory, deleting one — can list without going back through
|
||||
/// the busy guard. <see cref="RunAsync"/> returns immediately when a command is already running, so a
|
||||
/// nested call did nothing at all: the pane simply stayed empty after connecting, with no failure
|
||||
/// anywhere to explain it.
|
||||
/// </remarks>
|
||||
private Task NavigateRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
Status = "Connect to a host first.";
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
return RunAsync($"Reading {path}…", () => ListRemoteAsync(path, cancellationToken));
|
||||
}
|
||||
|
||||
private async Task ListRemoteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = await RequireSession().ListAsync(path, cancellationToken).ConfigureAwait(true);
|
||||
|
||||
RemotePath = path;
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
RemoteEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
RemoteEntries.Add(new RemoteEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RemoteTrail.Clear();
|
||||
|
||||
foreach (var (name, crumb) in SftpPath.Trail(path))
|
||||
{
|
||||
RemoteTrail.Add(new CrumbViewModel(name, crumb));
|
||||
}
|
||||
|
||||
|
||||
Status = string.Empty;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Synchronous, unlike its remote counterpart. A local directory listing is a filesystem call rather than
|
||||
/// a network round trip, and wrapping it in a task would put a state machine and a thread hop behind
|
||||
/// something that returns before the click has finished being handled.
|
||||
/// </remarks>
|
||||
private void NavigateLocal(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var entries = LocalDirectory.List(path);
|
||||
|
||||
LocalPath = Path.GetFullPath(path);
|
||||
SelectedLocalEntry = null;
|
||||
|
||||
LocalEntries.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
LocalEntries.Add(new LocalEntryRowViewModel(entry));
|
||||
}
|
||||
|
||||
RebuildLocalTrail();
|
||||
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// The pane stays where it was. A listing that failed leaves nothing to show, and emptying the
|
||||
// pane would look like a directory that had become empty.
|
||||
Status = $"{path} could not be read: {exception.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Built by walking up rather than by splitting on the separator, because a Windows path's first segment
|
||||
/// is <c>C:\</c> — a root with a separator inside it, which splitting turns into a crumb called
|
||||
/// <c>C:</c> that navigates to the process's current directory on that drive rather than to its root.
|
||||
/// </remarks>
|
||||
private void RebuildLocalTrail()
|
||||
{
|
||||
var crumbs = new List<CrumbViewModel>();
|
||||
|
||||
for (var walk = LocalPath; walk is not null; walk = LocalDirectory.Parent(walk))
|
||||
{
|
||||
crumbs.Insert(0, new CrumbViewModel(Path.GetFileName(walk) is { Length: > 0 } name ? name : walk, walk));
|
||||
}
|
||||
|
||||
LocalTrail.Clear();
|
||||
|
||||
foreach (var crumb in crumbs)
|
||||
{
|
||||
LocalTrail.Add(crumb);
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshHosts()
|
||||
{
|
||||
Hosts.Clear();
|
||||
|
||||
if (vault is not { } open)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var host in open.Hosts)
|
||||
{
|
||||
Hosts.Add(host);
|
||||
}
|
||||
|
||||
SelectedHost ??= Hosts.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>The session, or a failure a queue row can carry.</summary>
|
||||
private ISftpSession RequireSession() =>
|
||||
session ?? throw new InvalidOperationException(
|
||||
"This screen is not connected to a host, so there is nowhere to move the file.");
|
||||
|
||||
private async Task CloseSessionAsync()
|
||||
{
|
||||
if (session is { } open)
|
||||
{
|
||||
session = null;
|
||||
await open.DisposeAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
IsConnected = false;
|
||||
ConnectedTo = null;
|
||||
RemotePath = string.Empty;
|
||||
RemoteEntries.Clear();
|
||||
RemoteTrail.Clear();
|
||||
SelectedRemoteEntry = null;
|
||||
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// The queue raises this from whichever thread its pump is on, so everything here is marshalled. The row
|
||||
/// is created on first sight rather than at enqueue time, which keeps one path for "a transfer changed"
|
||||
/// instead of one for the first change and one for the rest.
|
||||
/// </remarks>
|
||||
private void OnTransferChanged(object? sender, TransferChangedEventArgs e) =>
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (Transfers.FirstOrDefault(row => row.Id == e.Transfer.Id) is { } existing)
|
||||
{
|
||||
existing.Transfer = e.Transfer;
|
||||
return;
|
||||
}
|
||||
|
||||
Transfers.Add(new TransferRowViewModel(e.Transfer));
|
||||
});
|
||||
|
||||
/// <remarks>
|
||||
/// The same funnel <c>VaultViewModel</c> uses, and here for the same reason: every command on this screen
|
||||
/// can fail with a path the server refused, and one that forgot to clear the busy flag would leave the
|
||||
/// pane permanently disabled.
|
||||
/// </remarks>
|
||||
private async Task RunAsync(string busyMessage, Func<Task> work)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
Status = busyMessage;
|
||||
|
||||
try
|
||||
{
|
||||
await work().ConfigureAwait(true);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Status = "Cancelled.";
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Status = exception.Message;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnSelectedHostChanged(HostRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(SelectedHostAsksForAPassword));
|
||||
|
||||
partial void OnIsConnectedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
}
|
||||
|
||||
partial void OnSelectedRemoteEntryChanged(RemoteEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanDownload));
|
||||
|
||||
partial void OnSelectedLocalEntryChanged(LocalEntryRowViewModel? value) =>
|
||||
OnPropertyChanged(nameof(CanUpload));
|
||||
|
||||
partial void OnPendingHostKeyChanged(HostKeyPresentation? value) =>
|
||||
OnPropertyChanged(nameof(HasPendingHostKey));
|
||||
|
||||
partial void OnHostKeyMismatchChanged(string? value) =>
|
||||
OnPropertyChanged(nameof(HasHostKeyMismatch));
|
||||
}
|
||||
@@ -1817,7 +1817,7 @@ internal sealed partial class VaultViewModel(
|
||||
// Refused rather than quietly falling back to the password box. A host set up for key-only access
|
||||
// that silently starts offering a password is the failure worth ruling out — the user asked for one
|
||||
// thing and got another, and the host is the last place that would say so.
|
||||
if (!TryBuildAuthentication(row.Host, out var authentication, out var refusal))
|
||||
if (!TryBuildAuthentication(row.Host, ConnectPassword, out var authentication, out var refusal))
|
||||
{
|
||||
Status = refusal;
|
||||
return;
|
||||
@@ -2087,8 +2087,38 @@ internal sealed partial class VaultViewModel(
|
||||
/// answered by the more specific of the two rather than by whichever the code happened to check.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <summary>
|
||||
/// Works out how to reach a host, or says why it cannot.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The same resolution the Connect button performs, exposed because file transfer opens its own
|
||||
/// connection — see <c>ISftpSession</c> — and a second copy of "which key, which password, whose
|
||||
/// username" would be a second place for a dangling binding to be silently turned back into a typed
|
||||
/// password. The typed password is a parameter rather than <see cref="ConnectPassword"/> because the
|
||||
/// transfers screen has its own box: they are different screens, and a password typed on one is not a
|
||||
/// password offered on the other.
|
||||
/// </remarks>
|
||||
internal bool TryBuildConnectionRequest(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out SshConnectionRequest? request,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
if (!TryBuildAuthentication(host, typedPassword, out var authentication, out reason))
|
||||
{
|
||||
request = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
request = new SshConnectionRequest(
|
||||
host.Hostname, host.Port, authentication.Username, authentication.Credential);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool TryBuildAuthentication(
|
||||
HostSecret host,
|
||||
string typedPassword,
|
||||
[NotNullWhen(true)] out HostAuthentication? authentication,
|
||||
[NotNullWhen(false)] out string? reason)
|
||||
{
|
||||
@@ -2133,7 +2163,7 @@ internal sealed partial class VaultViewModel(
|
||||
}
|
||||
|
||||
return Complete(
|
||||
host.Username, new SshPasswordCredential(ConnectPassword), out authentication, out reason);
|
||||
host.Username, new SshPasswordCredential(typedPassword), out authentication, out reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -184,20 +184,15 @@
|
||||
</Grid>
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
|
||||
Title="FILE TRANSFER"
|
||||
Milestone="MILESTONE M2"
|
||||
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
|
||||
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
|
||||
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
|
||||
@@ -7,11 +7,11 @@
|
||||
<!--
|
||||
Five destinations down the left edge.
|
||||
|
||||
Two of them — TRANSFERS and TEAM — reach screens that say they are not built. They are in the rail
|
||||
anyway rather than dropped, and the reasoning is in ShellScreen: the milestones are public, the screens
|
||||
behind these two say plainly what is missing, and a rail that quietly had three entries would make file
|
||||
transfer and sharing look like a change of product rather than the next two milestones. Both are
|
||||
recorded in docs/design-import-gaps.md.
|
||||
One of them — TEAM — reaches a screen that says it is not built. It is in the rail anyway rather than
|
||||
dropped, and the reasoning is in ShellScreen: the milestones are public, the screen behind it says
|
||||
plainly what is missing, and a rail that quietly had four entries would make sharing look like a change
|
||||
of product rather than the next milestone. It is recorded in docs/design-import-gaps.md. FILES was the
|
||||
other one until M2 built it.
|
||||
|
||||
Buttons rather than a TabStrip or a ListBox, for the same reason the vault's category rail is: all three
|
||||
of those hold the selection themselves, so a click moves the highlight before the shell can decide
|
||||
@@ -30,7 +30,7 @@
|
||||
<Button Classes="flat nav" Content="FILES" Classes.active="{Binding IsTransfersScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Transfers}"
|
||||
ToolTip.Tip="File transfer over SSH. Not built yet — see the screen for what is missing." />
|
||||
ToolTip.Tip="Move files to and from a host over SFTP" />
|
||||
<Button Classes="flat nav" Content="VAULT" Classes.active="{Binding IsVaultScreen}"
|
||||
Command="{Binding ShowScreenCommand}"
|
||||
CommandParameter="{x:Static vm:ShellScreen.Vault}"
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
x:Class="DodoSSH.Client.App.Views.TransfersScreen"
|
||||
x:DataType="vm:TransfersViewModel">
|
||||
|
||||
<!--
|
||||
File transfer: this machine on the left, the host on the right, and the queue underneath.
|
||||
|
||||
Two things about this screen are worth knowing before reading the markup. It has its own CONNECT
|
||||
button, because SSH.NET cannot open an SFTP subsystem on a transport that is already carrying a shell —
|
||||
so browsing a host's files is a second authenticated connection rather than a second channel, and
|
||||
pretending otherwise would hide a second login from the person whose audit log it appears in. And the
|
||||
panes are symmetrical apart from one column: PERMS is remote-only, because a POSIX mode is not a fact
|
||||
about a file on the machine this client is developed on.
|
||||
|
||||
What the design has and this does not: `sftp over bastion-eu`, which needs jump hosts the connection
|
||||
layer has not got. See docs/design-import-gaps.md.
|
||||
-->
|
||||
|
||||
<UserControl.Styles>
|
||||
<!--
|
||||
A directory is marked by colour rather than by an icon: this application ships no icon set, and the
|
||||
palette already reserves blue for "a directory, a distinct scope" — see App.axaml, where it is
|
||||
described as deliberately rare. This is the one place it is spent.
|
||||
-->
|
||||
<Style Selector="TextBlock.entry">
|
||||
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.entry.dir">
|
||||
<Setter Property="Foreground" Value="{StaticResource Info}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<Grid RowDefinitions="44,*,Auto">
|
||||
|
||||
<!-- ============ The host, and the connection ============ -->
|
||||
<Border Grid.Row="0" Padding="14,0" BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,180,Auto,Auto,Auto,*" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="FILES" FontSize="11" FontWeight="SemiBold"
|
||||
LetterSpacing="1" Foreground="{StaticResource Text}" VerticalAlignment="Center"
|
||||
Margin="0,0,12,0" />
|
||||
|
||||
<ComboBox Grid.Column="1" ItemsSource="{Binding Hosts}"
|
||||
SelectedItem="{Binding SelectedHost}"
|
||||
IsEnabled="{Binding !IsConnected}"
|
||||
PlaceholderText="choose a host">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="mono" Text="{Binding Label}" FontSize="11"
|
||||
Foreground="{StaticResource Text}" />
|
||||
<TextBlock Classes="mono" Text="{Binding Address}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
|
||||
<!--
|
||||
Only for a host bound to nothing, exactly as the hosts screen's box is — and it is a different box
|
||||
holding a different value. This connection authenticates separately, so a password typed to open a
|
||||
terminal was never offered here.
|
||||
-->
|
||||
<TextBox Grid.Column="2" Width="150" Margin="6,0,0,0" PasswordChar="•"
|
||||
Text="{Binding TypedPassword}" PlaceholderText="password"
|
||||
IsVisible="{Binding SelectedHostAsksForAPassword}"
|
||||
IsEnabled="{Binding !IsConnected}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="accent" Content="CONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsVisible="{Binding !IsConnected}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
|
||||
<Button Grid.Column="3" Classes="ghost" Content="DISCONNECT" Margin="6,0,0,0"
|
||||
Command="{Binding DisconnectCommand}"
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
|
||||
<Border Grid.Column="4" Classes="chip accent" Margin="8,0,0,0"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBlock Text="{Binding ConnectedTo}" />
|
||||
</Border>
|
||||
|
||||
<TextBlock Grid.Column="5" Classes="hint" Text="{Binding Status}" FontSize="10.5"
|
||||
Margin="12,0,0,0" VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ============ The two panes ============ -->
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,64,*">
|
||||
|
||||
<!-- ==== This machine ==== -->
|
||||
<Grid Grid.Column="0" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="THIS MACHINE" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<!--
|
||||
The drives, because the breadcrumb cannot reach them: above C:\ is a list rather than a
|
||||
directory. Without this the pane is stuck on whichever drive the user profile is on.
|
||||
-->
|
||||
<ItemsControl ItemsSource="{Binding LocalRoots}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<Button Classes="ghost" Content="{Binding Name}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding LocalUpCommand}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshLocalCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ItemsControl Grid.Row="1" ItemsSource="{Binding LocalTrail}" Margin="12,6,12,4">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoLocalCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
<TextBlock Classes="mono" Text="›" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="LocalList" ItemsSource="{Binding LocalEntries}"
|
||||
SelectedItem="{Binding SelectedLocalEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:LocalEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<TextBlock Grid.Row="3" Classes="hint" FontSize="11" Margin="24" MaxWidth="260"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" TextAlignment="Center"
|
||||
Text="Nothing in this folder. Use the trail above to go somewhere else."
|
||||
IsVisible="{Binding !HasLocalEntries}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ==== The two directions ==== -->
|
||||
<Border Grid.Column="1" Background="{StaticResource Sidebar}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0">
|
||||
<StackPanel VerticalAlignment="Center" Spacing="10" Margin="6">
|
||||
<!--
|
||||
Pointing at the pane the file is going to, which is the only reading that survives the panes
|
||||
being side by side: the left-hand pane is this machine, so an upload points right.
|
||||
-->
|
||||
<Button Classes="ghost" Content="→" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding UploadCommand}" IsEnabled="{Binding CanUpload}"
|
||||
ToolTip.Tip="Upload the selected file to the directory showing on the host" />
|
||||
<Button Classes="ghost" Content="←" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
Command="{Binding DownloadCommand}" IsEnabled="{Binding CanDownload}"
|
||||
ToolTip.Tip="Download the selected file into the folder showing on this machine" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ==== The host ==== -->
|
||||
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7" BorderBrush="{StaticResource BorderSubtle}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="HOST" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="ghost" Content="UP" Command="{Binding RemoteUpCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="ghost" Content="REFRESH" Command="{Binding RefreshRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteRemoteCommand}"
|
||||
IsEnabled="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid Grid.Row="1" ColumnDefinitions="*,Auto" Margin="12,6,12,4">
|
||||
<ItemsControl Grid.Column="0" ItemsSource="{Binding RemoteTrail}" VerticalAlignment="Center">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<WrapPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:CrumbViewModel">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Classes="mono" Text="/" FontSize="10"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<Button Classes="flat" Padding="3,1"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).GoRemoteCommand}"
|
||||
CommandParameter="{Binding Path}">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!--
|
||||
Making a directory sits here, beside the path it would be made in, rather than with the queue's
|
||||
controls. It exists because the queue refuses to overwrite: without somewhere else to put a file,
|
||||
"that name is already taken" is a dead end.
|
||||
-->
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding IsConnected}">
|
||||
<TextBox Width="140" Text="{Binding NewRemoteFolder}" PlaceholderText="new directory" />
|
||||
<Button Classes="ghost" Content="MKDIR" Command="{Binding CreateRemoteFolderCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="2" ColumnDefinitions="2,*,84,110,92" Margin="0,2,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="label" Text="NAME" FontSize="8.5" Margin="12,0,8,0" />
|
||||
<TextBlock Grid.Column="2" Classes="label" Text="SIZE" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="3" Classes="label" Text="MODIFIED" FontSize="8.5" />
|
||||
<TextBlock Grid.Column="4" Classes="label" Text="PERMS" FontSize="8.5" />
|
||||
</Grid>
|
||||
|
||||
<ListBox Grid.Row="3" x:Name="RemoteList" ItemsSource="{Binding RemoteEntries}"
|
||||
SelectedItem="{Binding SelectedRemoteEntry}">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:RemoteEntryRowViewModel">
|
||||
<Grid ColumnDefinitions="2,*,84,110,92" Margin="0,5,12,5">
|
||||
<Border Grid.Column="0" Classes="rowmark" />
|
||||
<TextBlock Grid.Column="1" Classes="mono entry" Classes.dir="{Binding IsNavigable}"
|
||||
Text="{Binding Name}" FontSize="11"
|
||||
Margin="12,0,8,0" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Grid.Column="2" Classes="mono" Text="{Binding Size}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Modified}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="4" Classes="mono" Text="{Binding Permissions}" FontSize="9.5"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
|
||||
<StackPanel Grid.Row="3" Spacing="10" Margin="24" MaxWidth="300"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !HasRemoteEntries}">
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Connect to a host to browse its files. This opens its own SFTP connection, so the host records a second login — it is not the same channel as a terminal."
|
||||
IsVisible="{Binding !IsConnected}" />
|
||||
<TextBlock Classes="hint" FontSize="11" TextAlignment="Center"
|
||||
Text="Nothing in this directory."
|
||||
IsVisible="{Binding IsConnected}" />
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ============ The queue ============ -->
|
||||
<Border Grid.Row="2" Background="{StaticResource Sidebar}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="0,1,0,0" MaxHeight="196">
|
||||
<Grid RowDefinitions="Auto,*">
|
||||
|
||||
<Border Grid.Row="0" Padding="12,7">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="label" Text="TRANSFERS" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Classes="mono" FontSize="9.5" Margin="10,0,0,0"
|
||||
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||
Text="one at a time · nothing lands at its final name until it is complete" />
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="hint" FontSize="10.5" Margin="12,4,12,14"
|
||||
IsVisible="{Binding !HasTransfers}"
|
||||
Text="Nothing queued. Choose a file in either pane and press the arrow pointing the way you want it to go." />
|
||||
|
||||
<ItemsControl ItemsSource="{Binding Transfers}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:TransferRowViewModel">
|
||||
<Grid ColumnDefinitions="16,150,*,190,Auto" Margin="12,4">
|
||||
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding Arrow}" FontSize="11"
|
||||
Foreground="{StaticResource Accent}" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Margin="0,0,8,0">
|
||||
<TextBlock Classes="mono" Text="{Binding Name}" FontSize="10.5"
|
||||
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis" />
|
||||
<TextBlock Classes="mono" Text="{Binding Path}" FontSize="9"
|
||||
Foreground="{StaticResource TextFaint}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
</StackPanel>
|
||||
|
||||
<ProgressBar Grid.Column="2" Height="4" Minimum="0" Maximum="100"
|
||||
Value="{Binding Percent}" VerticalAlignment="Center"
|
||||
Foreground="{StaticResource Accent}"
|
||||
Background="{StaticResource Raised}" />
|
||||
|
||||
<TextBlock Grid.Column="3" Classes="mono" Text="{Binding Progress}" FontSize="9.5"
|
||||
Margin="10,0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
Foreground="{StaticResource TextDim}" />
|
||||
|
||||
<StackPanel Grid.Column="4" Orientation="Horizontal" Spacing="6">
|
||||
<Border Classes="chip">
|
||||
<TextBlock Text="{Binding StateLabel}" />
|
||||
</Border>
|
||||
<Button Classes="ghost" Content="STOP" IsVisible="{Binding IsRunning}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).CancelTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="ghost" Content="{Binding RetryLabel}" IsVisible="{Binding CanRetry}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).RetryTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
<Button Classes="danger" Content="DISCARD" IsVisible="{Binding IsFinished}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TransfersViewModel)DataContext).DiscardTransferCommand}"
|
||||
CommandParameter="{Binding}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<Button Classes="ghost" Content="CLEAR FINISHED" Margin="12,6,12,12"
|
||||
HorizontalAlignment="Left" Command="{Binding ClearCompletedCommand}"
|
||||
IsVisible="{Binding HasTransfers}" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
First contact and a changed key, over the whole screen. The same two refusals a terminal makes, and
|
||||
they arrive here on their own because this is a separate connection — a host trusted for a shell is
|
||||
trusted for this too, but a host nobody has connected to at all is met here first.
|
||||
-->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasPendingHostKey}">
|
||||
<Border Classes="card">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Check this host's fingerprint" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="This host has not been seen before. Compare the fingerprint with what the operator published. Trusting it here also trusts it for terminals, and on your other machines." />
|
||||
<SelectableTextBlock Classes="mono" FontSize="11" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" Text="{Binding PendingHostKey.Fingerprint}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT" Command="{Binding TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL" Command="{Binding RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
<Border Grid.Row="0" Grid.RowSpan="3" Background="{StaticResource Canvas}"
|
||||
IsVisible="{Binding HasHostKeyMismatch}">
|
||||
<Border Classes="card" BorderBrush="{StaticResource DangerSoft}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="The host key has changed" Foreground="{StaticResource Danger}" />
|
||||
<TextBlock Classes="hint" Text="{Binding HostKeyMismatch}" />
|
||||
<TextBlock Classes="hint"
|
||||
Text="Nothing was connected, and there is no way to continue from here. If the server was legitimately rebuilt, edit the host on the Hosts screen and choose Forget host key." />
|
||||
<Button Classes="ghost" Content="CLOSE" Command="{Binding RejectHostKeyCommand}"
|
||||
HorizontalAlignment="Left" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,48 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using DodoSSH.Client.App.ViewModels;
|
||||
|
||||
namespace DodoSSH.Client.App.Views;
|
||||
|
||||
/// <summary>
|
||||
/// The two-pane file browser and the transfer queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its data context is the <c>TransfersViewModel</c>, which the shell owns for the life of the process — a
|
||||
/// transfer in flight has to survive a lock, the same policy that keeps shells running. See
|
||||
/// <c>MainWindowViewModel.LockAsync</c>.
|
||||
/// </remarks>
|
||||
internal sealed partial class TransfersScreen : UserControl
|
||||
{
|
||||
public TransfersScreen()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Wired here rather than in the markup because it is a gesture rather than a binding, and because
|
||||
// opening a directory has to be reachable without the mouse as well: both lists are ListBoxes, so
|
||||
// Enter on a keyboard-navigated row goes through the same commands from the buttons above them.
|
||||
LocalList.DoubleTapped += OnLocalActivated;
|
||||
RemoteList.DoubleTapped += OnRemoteActivated;
|
||||
}
|
||||
|
||||
private void OnLocalActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
transfers.OpenLocalCommand.Execute(null);
|
||||
}
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Fire-and-forget, which is what a double-click on a directory can be: the command reports its own
|
||||
/// failures onto the screen's status line, and awaiting it here would mean an event handler that returns
|
||||
/// a task nothing observes — the same thing with a warning suppressed.
|
||||
/// </remarks>
|
||||
private void OnRemoteActivated(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is TransfersViewModel transfers)
|
||||
{
|
||||
_ = transfers.OpenRemoteCommand.ExecuteAsync(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,6 +391,12 @@
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.client.transfer": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"DodoSSH.Client.Ssh": "[1.0.0, )"
|
||||
}
|
||||
},
|
||||
"dodossh.contracts": {
|
||||
"type": "Project"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
using System.Globalization;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>What kind of thing a remote directory entry is.</summary>
|
||||
/// <remarks>
|
||||
/// Taken from the attributes a listing already carries, which are <c>lstat</c> attributes: a symbolic link
|
||||
/// reports as a link whatever it points at. Resolving each one would be a round trip per entry, so
|
||||
/// <see cref="SftpEntryKind.SymbolicLink"/> stays its own answer and the caller finds out what it leads to by
|
||||
/// trying to list it — see <see cref="ISftpSession.ListAsync"/>.
|
||||
/// </remarks>
|
||||
public enum SftpEntryKind
|
||||
{
|
||||
/// <summary>An ordinary file.</summary>
|
||||
File = 0,
|
||||
|
||||
/// <summary>A directory.</summary>
|
||||
Directory = 1,
|
||||
|
||||
/// <summary>A symbolic link, to something this listing did not resolve.</summary>
|
||||
SymbolicLink = 2,
|
||||
|
||||
/// <summary>A socket, device, pipe or anything else that is not one of the three above.</summary>
|
||||
Other = 3,
|
||||
}
|
||||
|
||||
/// <summary>One entry in a remote directory.</summary>
|
||||
/// <param name="Name">The entry's own name, with no path.</param>
|
||||
/// <param name="FullPath">The absolute path, which is what every operation takes.</param>
|
||||
/// <param name="Kind">What it is.</param>
|
||||
/// <param name="Length">Size in bytes. Meaningless for anything that is not a file, and zero there.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <param name="Permissions">The mode as <c>drwxr-xr-x</c>; see <see cref="PosixMode"/>.</param>
|
||||
/// <remarks>
|
||||
/// A record of what the server said rather than a handle. Nothing here holds a channel open, so a listing can
|
||||
/// be kept on a screen after the session behind it has gone — which is what the transfers screen does while a
|
||||
/// connection is being re-established.
|
||||
/// </remarks>
|
||||
public sealed record SftpEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
SftpEntryKind Kind,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc,
|
||||
string Permissions)
|
||||
{
|
||||
/// <summary>Whether this is somewhere the file browser can navigate into.</summary>
|
||||
/// <remarks>
|
||||
/// True for a symbolic link as well as a directory, because a link to a directory is the ordinary way a
|
||||
/// remote filesystem is laid out and refusing to open one would make those paths unreachable. A link to a
|
||||
/// file fails the listing instead, which is the caller's cue that it was not a directory after all.
|
||||
/// </remarks>
|
||||
public bool IsNavigable => Kind is SftpEntryKind.Directory or SftpEntryKind.SymbolicLink;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renders POSIX permission bits the way <c>ls -l</c> does.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Nothing in this repository formatted a mode before, and the design's file listing has a <c>PERMS</c>
|
||||
/// column. It is written from the individual bits rather than from an octal mode because that is the shape
|
||||
/// SFTP hands over — <c>ISftpFile</c> exposes nine booleans and a set of kind predicates, and reassembling
|
||||
/// them into an octal number only to take it apart again would be a round trip through a representation
|
||||
/// neither end uses.
|
||||
/// <para>
|
||||
/// The setuid, setgid and sticky bits are not shown. SFTP's own file attributes carry them, SSH.NET does not
|
||||
/// surface them on <c>ISftpFile</c>, and a column that showed <c>rwx</c> where <c>rws</c> was true would be
|
||||
/// worse than one that never claims to render them.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class PosixMode
|
||||
{
|
||||
/// <summary>Formats one entry's mode, kind character included.</summary>
|
||||
public static string Format(
|
||||
SftpEntryKind kind,
|
||||
bool ownerRead,
|
||||
bool ownerWrite,
|
||||
bool ownerExecute,
|
||||
bool groupRead,
|
||||
bool groupWrite,
|
||||
bool groupExecute,
|
||||
bool othersRead,
|
||||
bool othersWrite,
|
||||
bool othersExecute)
|
||||
{
|
||||
Span<char> mode = stackalloc char[10];
|
||||
|
||||
mode[0] = kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 'd',
|
||||
SftpEntryKind.SymbolicLink => 'l',
|
||||
SftpEntryKind.File => '-',
|
||||
_ => '?',
|
||||
};
|
||||
|
||||
Write(mode[1..4], ownerRead, ownerWrite, ownerExecute);
|
||||
Write(mode[4..7], groupRead, groupWrite, groupExecute);
|
||||
Write(mode[7..10], othersRead, othersWrite, othersExecute);
|
||||
|
||||
return new string(mode);
|
||||
|
||||
static void Write(Span<char> triple, bool read, bool write, bool execute)
|
||||
{
|
||||
triple[0] = read ? 'r' : '-';
|
||||
triple[1] = write ? 'w' : '-';
|
||||
triple[2] = execute ? 'x' : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote paths, which are POSIX paths whatever this client is running on.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Not <see cref="Path"/>.</b> The BCL's path helpers use the local platform's separator, so on Windows
|
||||
/// <c>Path.Combine("/var", "log")</c> yields <c>/var\log</c> — a path the remote will not resolve and which
|
||||
/// fails as "no such file" somewhere the user cannot see the backslash. Every remote path in this codebase
|
||||
/// goes through here.
|
||||
/// </remarks>
|
||||
public static class SftpPath
|
||||
{
|
||||
/// <summary>The root of a remote filesystem.</summary>
|
||||
public const string Root = "/";
|
||||
|
||||
/// <summary>Joins a directory and a name.</summary>
|
||||
public static string Combine(string directory, string name)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||
|
||||
return directory.EndsWith('/') ? directory + name : directory + "/" + name;
|
||||
}
|
||||
|
||||
/// <summary>The directory holding a path, or the path itself when it is already the root.</summary>
|
||||
public static string Parent(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
|
||||
return slash switch
|
||||
{
|
||||
< 0 => Root,
|
||||
0 => Root,
|
||||
_ => trimmed[..slash],
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>The last segment of a path, or the root when that is all there is.</summary>
|
||||
/// <remarks>
|
||||
/// The root names itself. Taking the text after its only separator leaves an empty string, which as a
|
||||
/// heading or a tab label is a blank where a path should be.
|
||||
/// </remarks>
|
||||
public static string Name(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trimmed = path.Length > 1 ? path.TrimEnd('/') : path;
|
||||
var slash = trimmed.LastIndexOf('/');
|
||||
var name = slash < 0 ? trimmed : trimmed[(slash + 1)..];
|
||||
|
||||
return name.Length == 0 ? Root : name;
|
||||
}
|
||||
|
||||
/// <summary>Whether a path is already anchored at the root.</summary>
|
||||
public static bool IsAbsolute(string path) => path.StartsWith('/');
|
||||
|
||||
/// <summary>
|
||||
/// The segments of a path, for a breadcrumb trail.
|
||||
/// </summary>
|
||||
/// <returns>
|
||||
/// Each segment paired with the absolute path that reaches it, root first. An empty list for the root
|
||||
/// itself, which has no segment to name.
|
||||
/// </returns>
|
||||
public static IReadOnlyList<(string Name, string Path)> Trail(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||
|
||||
var trail = new List<(string, string)>();
|
||||
var walked = string.Empty;
|
||||
|
||||
foreach (var segment in path.Split('/', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
walked = Combine(walked.Length == 0 ? Root : walked, segment);
|
||||
trail.Add((segment, walked));
|
||||
}
|
||||
|
||||
return trail;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A file-transfer session on one host.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>This is a connection, not a channel.</b> The obvious shape would have been
|
||||
/// <c>ISshConnection.OpenSftpAsync</c>, opening the SFTP subsystem beside the shell on the transport that is
|
||||
/// already up — which is what SSH itself allows and what <c>docs/design-import-gaps.md</c> assumed it would
|
||||
/// take. SSH.NET does not offer it: <c>SftpClient</c> derives from <c>BaseClient</c> and owns its own
|
||||
/// transport, and there is no supported way to hand it an existing <c>SshClient</c>'s session. So opening one
|
||||
/// of these authenticates again.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// It is named for that rather than dressed up as a channel, because the difference is visible to a user: the
|
||||
/// host sees a second login, a one-time password would be asked for twice, and closing every terminal on a
|
||||
/// host does not close its file browser. <see cref="ISshConnectionFactory.OpenSftpAsync"/> is a
|
||||
/// <em>connect</em>, and host key trust is checked on it exactly as it is for a shell.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// One session is one channel, and everything on it shares that channel's window — so the queue that drives
|
||||
/// this runs one transfer at a time. That is a throughput decision rather than a safety one: two large
|
||||
/// transfers over one channel do not go faster than one, they arrive later and both at once. Browsing while a
|
||||
/// transfer runs is fine, and is the point of not opening a session per transfer.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface ISftpSession : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Whether the transport is still up.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>The host key that was accepted for this session.</summary>
|
||||
HostKeyPresentation HostKey { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Where the session starts, which is the account's home directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Absolute, because the server resolves it during the handshake. It is the only path this layer knows
|
||||
/// without asking, and it is what a file browser should open on.
|
||||
/// </remarks>
|
||||
string HomeDirectory { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <c>.</c> and <c>..</c> are dropped: every remote directory has them, no user is choosing between them,
|
||||
/// and the way up is a breadcrumb rather than a row. Ordered directories first and then by name, which is
|
||||
/// what a file browser has to show and what saves every caller sorting it again.
|
||||
/// </remarks>
|
||||
/// <exception cref="SftpPathException">The path is not a directory, or is not readable.</exception>
|
||||
Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>What one path is, or null when nothing is there.</summary>
|
||||
Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for reading, starting at an offset.
|
||||
/// </summary>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted download resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a remote file for writing, starting at an offset.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Creates the file when it is not there, and does not truncate one that is — an offset of zero over an
|
||||
/// existing file overwrites from the beginning and leaves any tail beyond what is written. Callers are
|
||||
/// expected to write to a path nothing else holds; the transfer queue writes to a part file for exactly
|
||||
/// this reason.
|
||||
/// </remarks>
|
||||
/// <param name="path">The file.</param>
|
||||
/// <param name="offset">Where to start, which is what makes an interrupted upload resumable.</param>
|
||||
/// <param name="cancellationToken">Abandons the open.</param>
|
||||
Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Creates one directory, whose parent must exist.</summary>
|
||||
Task CreateDirectoryAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a file, or an empty directory.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Deliberately not recursive. A recursive remote delete is the one operation on this screen that can
|
||||
/// destroy something no undo reaches, and offering it behind the same button as deleting one file is how
|
||||
/// that happens by accident. A non-empty directory fails, and says so.
|
||||
/// </remarks>
|
||||
Task DeleteAsync(string path, CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>Renames or moves a path within the same host.</summary>
|
||||
Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Something on the remote filesystem could not be reached, and the server said why.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// One exception for the whole surface rather than one per operation, because there is exactly one thing a
|
||||
/// caller does with any of them: show the message beside the path it was about. SSH.NET raises several
|
||||
/// unrelated types for what a user experiences as one condition — <c>SftpPathNotFoundException</c>,
|
||||
/// <c>SftpPermissionDeniedException</c>, and a bare <c>SshException</c> for the rest — and the path is not on
|
||||
/// all of them.
|
||||
/// </remarks>
|
||||
public sealed class SftpPathException(string path, string message, Exception? innerException = null)
|
||||
: Exception(message, innerException)
|
||||
{
|
||||
/// <summary>The path the failure was about.</summary>
|
||||
public string Path { get; } = path;
|
||||
}
|
||||
|
||||
/// <summary>Opens file-transfer sessions.</summary>
|
||||
/// <remarks>
|
||||
/// Declared beside <see cref="ISshConnectionFactory"/> and implemented by the same type, because both start
|
||||
/// with the same handshake and the same host key decision. See <see cref="ISftpSession"/> for why this is a
|
||||
/// separate connect rather than a channel on a connection that already exists.
|
||||
/// </remarks>
|
||||
public interface ISftpSessionFactory
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects, authenticates, and starts the SFTP subsystem.
|
||||
/// </summary>
|
||||
/// <exception cref="SshHostKeyUnknownException">
|
||||
/// The host has no pinned key. Resolved exactly as it is for a shell: show the fingerprint, record it on
|
||||
/// explicit confirmation, and retry.
|
||||
/// </exception>
|
||||
/// <exception cref="SshHostKeyMismatchException">
|
||||
/// The presented key differs from the pin. There is no retry path.
|
||||
/// </exception>
|
||||
Task<ISftpSession> OpenSftpAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Formats a byte count the way a file browser shows one.</summary>
|
||||
/// <remarks>
|
||||
/// Here rather than in the view model because the transfer queue's own progress reporting needs the same
|
||||
/// wording, and two spellings of "1.4 MB" in one window reads as two different measurements.
|
||||
/// </remarks>
|
||||
public static class ByteSize
|
||||
{
|
||||
private static readonly string[] Units = ["B", "KB", "MB", "GB", "TB"];
|
||||
|
||||
/// <summary>Formats a byte count to three significant figures.</summary>
|
||||
public static string Format(long bytes)
|
||||
{
|
||||
if (bytes < 1024)
|
||||
{
|
||||
return string.Create(CultureInfo.InvariantCulture, $"{bytes} B");
|
||||
}
|
||||
|
||||
double scaled = bytes;
|
||||
var unit = 0;
|
||||
|
||||
while (scaled >= 1024 && unit < Units.Length - 1)
|
||||
{
|
||||
scaled /= 1024;
|
||||
unit++;
|
||||
}
|
||||
|
||||
// One decimal below ten, none above: "9.4 MB" and "512 MB" are both three characters of information,
|
||||
// and "512.3 MB" is a precision the number does not have by the time it is that large.
|
||||
return scaled < 10
|
||||
? string.Create(CultureInfo.InvariantCulture, $"{scaled:0.0} {Units[unit]}")
|
||||
: string.Create(CultureInfo.InvariantCulture, $"{scaled:0} {Units[unit]}");
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,22 @@ namespace DodoSSH.Client.Ssh;
|
||||
/// blocking the handshake on a UI round trip. Anything the comparison cannot settle becomes an
|
||||
/// exception the caller resolves asynchronously.
|
||||
/// </remarks>
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshConnectionFactory
|
||||
public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
||||
: ISshConnectionFactory, ISftpSessionFactory
|
||||
{
|
||||
private static readonly TimeSpan DefaultConnectTimeout = TimeSpan.FromSeconds(15);
|
||||
|
||||
/// <summary>
|
||||
/// How much of a file SSH.NET reads or writes per SFTP request.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// SSH.NET's default is 32 KiB, which is a request per 32 KiB and a round trip's latency between each on
|
||||
/// a link the window would happily keep full. 64 KiB is the largest an OpenSSH server accepts without
|
||||
/// negotiation, so it is the ceiling rather than a guess — anything above it is answered with a shorter
|
||||
/// read, which SSH.NET handles but which buys nothing.
|
||||
/// </remarks>
|
||||
private const uint SftpBufferSize = 64 * 1024;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ISshConnection> ConnectAsync(
|
||||
SshConnectionRequest request,
|
||||
@@ -25,6 +37,62 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SshClient(BuildConnectionInfo(request));
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// A second connection to the host rather than a second channel on one that may already be open — see
|
||||
/// <see cref="ISftpSession"/> for why SSH.NET leaves no choice. Everything that guards a shell guards this
|
||||
/// too, because it is the same handshake: the same host key gate, the same pin, the same two refusals.
|
||||
/// </remarks>
|
||||
public async Task<ISftpSession> OpenSftpAsync(
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||
|
||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||
// on first read, so leaving it to the property would put a round trip behind something that reads
|
||||
// like a field — and the session's own remark promises this is the one path known without asking.
|
||||
string home;
|
||||
|
||||
try
|
||||
{
|
||||
home = client.WorkingDirectory;
|
||||
}
|
||||
catch
|
||||
{
|
||||
client.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetSftpSession(client, gate.Presented!, home);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the handshake with host key trust attached, and translates a refusal this factory caused.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Shared by the shell and the file-transfer paths over <c>BaseClient</c>, which is where SSH.NET puts
|
||||
/// both <c>ConnectAsync</c> and <c>HostKeyReceived</c>. The alternative was the same twelve lines twice,
|
||||
/// and the half worth getting wrong is the translation: without it a user who has never seen a host is
|
||||
/// told the connection was lost.
|
||||
/// </remarks>
|
||||
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||
BaseClient client,
|
||||
SshConnectionRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
||||
|
||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||
@@ -47,7 +115,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts) : ISshCo
|
||||
throw;
|
||||
}
|
||||
|
||||
return new SshNetConnection(client, gate.Presented!);
|
||||
return gate;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
using Renci.SshNet;
|
||||
using Renci.SshNet.Common;
|
||||
using Renci.SshNet.Sftp;
|
||||
|
||||
namespace DodoSSH.Client.Ssh;
|
||||
|
||||
/// <summary>An SSH.NET-backed file-transfer session.</summary>
|
||||
/// <remarks>
|
||||
/// Thin on purpose. Everything above this reasons about <see cref="SftpEntry"/> and <see cref="Stream"/>,
|
||||
/// which is what keeps the transfer queue and the file browser testable without a server — and the one thing
|
||||
/// this type does beyond forwarding calls is translate SSH.NET's several path failures into the single one a
|
||||
/// caller can act on.
|
||||
/// </remarks>
|
||||
internal sealed class SshNetSftpSession(SftpClient client, HostKeyPresentation hostKey, string homeDirectory)
|
||||
: ISftpSession
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public bool IsConnected => client.IsConnected;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HostKeyPresentation HostKey { get; } = hostKey;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string HomeDirectory { get; } = homeDirectory;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<SftpEntry>> ListAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
var entries = new List<SftpEntry>();
|
||||
|
||||
try
|
||||
{
|
||||
await foreach (var file in client
|
||||
.ListDirectoryAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
// Every directory has these and no user is choosing between them. The way up is the
|
||||
// breadcrumb trail, which cannot be mistaken for a file.
|
||||
if (file.Name is "." or "..")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.Add(Describe(file));
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
// Directories first and then by name, which is the order a file browser shows and the order that
|
||||
// saves every caller sorting it again. Ordinal, because a remote filesystem's names are bytes the
|
||||
// server never claimed a culture for, and a listing whose order depended on this machine's locale
|
||||
// would put the same directory in two orders on two of a user's machines.
|
||||
entries.Sort(static (left, right) => left.Kind == right.Kind
|
||||
? string.CompareOrdinal(left.Name, right.Name)
|
||||
: Rank(left.Kind).CompareTo(Rank(right.Kind)));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<SftpEntry?> StatAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var file = await client.GetAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return Describe(file);
|
||||
}
|
||||
catch (SftpPathNotFoundException)
|
||||
{
|
||||
// Absent is an answer rather than a failure. Every caller here is asking whether something is
|
||||
// already there, and turning "no" into an exception would put a try/catch at each of them.
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenReadAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.Open, FileAccess.Read, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream> OpenWriteAsync(string path, long offset, CancellationToken cancellationToken) =>
|
||||
OpenAsync(path, FileMode.OpenOrCreate, FileAccess.Write, offset, cancellationToken);
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task CreateDirectoryAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.CreateDirectoryAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DeleteAsync(string path, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// One call for both kinds: SSH.NET stats the path and issues rmdir or remove accordingly. A
|
||||
// non-empty directory fails here, which is the refusal this interface promises.
|
||||
await client.DeleteAsync(path, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task RenameAsync(string fromPath, string toPath, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await client.RenameFileAsync(fromPath, toPath, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
// Named for the destination, which is what the caller chose and what a collision is about. The
|
||||
// source is a path the transfer queue made up.
|
||||
throw Translate(toPath, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public ValueTask DisposeAsync()
|
||||
{
|
||||
client.Dispose();
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Seeking after the open rather than asking for an appending mode. <c>FileMode.Append</c> would give the
|
||||
/// right position for an upload and nothing for a download, and a resume has to be able to start at an
|
||||
/// offset that is <em>not</em> the end — a part file whose tail was written by an interrupted transfer is
|
||||
/// exactly that case, and the queue truncates to a known-good length before resuming.
|
||||
/// </remarks>
|
||||
private async Task<Stream> OpenAsync(
|
||||
string path,
|
||||
FileMode mode,
|
||||
FileAccess access,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegative(offset);
|
||||
|
||||
SftpFileStream stream;
|
||||
|
||||
try
|
||||
{
|
||||
stream = await client.OpenAsync(path, mode, access, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (IsPathFailure(exception))
|
||||
{
|
||||
throw Translate(path, exception);
|
||||
}
|
||||
|
||||
if (offset == 0)
|
||||
{
|
||||
return stream;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
stream.Seek(offset, SeekOrigin.Begin);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await stream.DisposeAsync().ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
|
||||
return stream;
|
||||
}
|
||||
|
||||
/// <summary>Directories first, then links, then everything else.</summary>
|
||||
private static int Rank(SftpEntryKind kind) => kind switch
|
||||
{
|
||||
SftpEntryKind.Directory => 0,
|
||||
SftpEntryKind.SymbolicLink => 1,
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
private static SftpEntry Describe(ISftpFile file)
|
||||
{
|
||||
// Checked in this order because the predicates are not exclusive: a symbolic link to a directory
|
||||
// answers true to both, and calling that a directory would hide the fact that opening it depends on
|
||||
// the server resolving a link. Asking about the link first is the honest reading of an lstat.
|
||||
var kind = file switch
|
||||
{
|
||||
{ IsSymbolicLink: true } => SftpEntryKind.SymbolicLink,
|
||||
{ IsDirectory: true } => SftpEntryKind.Directory,
|
||||
{ IsRegularFile: true } => SftpEntryKind.File,
|
||||
_ => SftpEntryKind.Other,
|
||||
};
|
||||
|
||||
return new SftpEntry(
|
||||
file.Name,
|
||||
file.FullName,
|
||||
kind,
|
||||
|
||||
// Only a file's length means anything. A directory's is the size of its own inode, which is a
|
||||
// number no user has ever wanted in a SIZE column.
|
||||
kind is SftpEntryKind.File ? file.Length : 0,
|
||||
new DateTimeOffset(file.LastWriteTimeUtc, TimeSpan.Zero),
|
||||
PosixMode.Format(
|
||||
kind,
|
||||
file.OwnerCanRead,
|
||||
file.OwnerCanWrite,
|
||||
file.OwnerCanExecute,
|
||||
file.GroupCanRead,
|
||||
file.GroupCanWrite,
|
||||
file.GroupCanExecute,
|
||||
file.OthersCanRead,
|
||||
file.OthersCanWrite,
|
||||
file.OthersCanExecute));
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// <see cref="SshException"/> covers the SFTP-specific types as well, since both derive from it. It is
|
||||
/// deliberately wide: the SFTP protocol returns a status code and a server-supplied message for
|
||||
/// everything from a missing file to a full disk, and SSH.NET surfaces most of them as a bare
|
||||
/// <see cref="SshException"/> carrying that message. What must <em>not</em> be caught is cancellation and
|
||||
/// the ordinary failures of this process, which is why this is a predicate rather than a bare catch.
|
||||
/// </remarks>
|
||||
private static bool IsPathFailure(Exception exception) =>
|
||||
exception is SshException or IOException or UnauthorizedAccessException;
|
||||
|
||||
private static SftpPathException Translate(string path, Exception exception) => exception switch
|
||||
{
|
||||
SftpPathNotFoundException => new SftpPathException(path, $"{path} is not there.", exception),
|
||||
|
||||
SftpPermissionDeniedException => new SftpPathException(
|
||||
path, $"The server refused access to {path}.", exception),
|
||||
|
||||
// The server's own words. They are the only description of a full disk, a quota or a read-only mount
|
||||
// that this client could produce, and inventing a friendlier sentence would lose them.
|
||||
_ => new SftpPathException(path, $"{path}: {exception.Message}", exception),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<!--
|
||||
The transfer engine: a queue, one transfer at a time, and the local half of a two-pane file
|
||||
browser. Its own project rather than more of DodoSSH.Client.Ssh, because the two answer
|
||||
different questions — that one is about reaching a host, this one is about moving bytes and
|
||||
what to do when moving them stops halfway — and because this is the only client project that
|
||||
deliberately touches the local filesystem.
|
||||
|
||||
Avalonia-free, like every client project but App. The queue is driven by tests against a real
|
||||
temporary directory and a fake SFTP session, with no UI thread anywhere.
|
||||
-->
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="../DodoSSH.Client.Ssh/DodoSSH.Client.Ssh.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DodoSSH.Client.Transfer.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,870 @@
|
||||
using System.Buffers;
|
||||
using DodoSSH.Client.Ssh;
|
||||
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>Which way the bytes are going.</summary>
|
||||
public enum TransferDirection
|
||||
{
|
||||
/// <summary>From the host to this machine.</summary>
|
||||
Download = 0,
|
||||
|
||||
/// <summary>From this machine to the host.</summary>
|
||||
Upload = 1,
|
||||
}
|
||||
|
||||
/// <summary>Where one transfer has got to.</summary>
|
||||
public enum TransferState
|
||||
{
|
||||
/// <summary>Waiting for the one in front of it.</summary>
|
||||
Queued = 0,
|
||||
|
||||
/// <summary>Moving bytes.</summary>
|
||||
Running = 1,
|
||||
|
||||
/// <summary>Finished, and the file is at its final name.</summary>
|
||||
Completed = 2,
|
||||
|
||||
/// <summary>Stopped by a failure, which <see cref="TransferSnapshot.Failure"/> describes.</summary>
|
||||
Failed = 3,
|
||||
|
||||
/// <summary>Stopped because the user asked.</summary>
|
||||
Cancelled = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One transfer, as the queue last saw it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A snapshot rather than a live object, because the queue mutates its entries from whichever thread the
|
||||
/// pump is on and the interface reads them from the UI thread. Handing out an immutable record per change is
|
||||
/// what lets the view model be a plain list of values with no locking of its own.
|
||||
/// </remarks>
|
||||
/// <param name="Id">Identifies the transfer for <see cref="FileTransferQueue.Cancel"/> and the rest.</param>
|
||||
/// <param name="Direction">Which way it is going.</param>
|
||||
/// <param name="Name">The file's own name, which is what a queue row is headed with.</param>
|
||||
/// <param name="LocalPath">The local end, whichever end that is.</param>
|
||||
/// <param name="RemotePath">The remote end.</param>
|
||||
/// <param name="Length">
|
||||
/// How many bytes there are in total, as the side that has the file reported when it was enqueued.
|
||||
/// </param>
|
||||
/// <param name="Transferred">How many have moved, resumed bytes included.</param>
|
||||
/// <param name="State">Where it has got to.</param>
|
||||
/// <param name="BytesPerSecond">Recent throughput, or zero when nothing is moving.</param>
|
||||
/// <param name="Failure">Why it stopped, when it stopped badly.</param>
|
||||
public sealed record TransferSnapshot(
|
||||
Guid Id,
|
||||
TransferDirection Direction,
|
||||
string Name,
|
||||
string LocalPath,
|
||||
string RemotePath,
|
||||
long Length,
|
||||
long Transferred,
|
||||
TransferState State,
|
||||
double BytesPerSecond,
|
||||
string? Failure)
|
||||
{
|
||||
/// <summary>How far along, between 0 and 1.</summary>
|
||||
/// <remarks>
|
||||
/// Zero for an empty file rather than one. A zero-byte transfer is finished the moment it starts and its
|
||||
/// row says <see cref="TransferState.Completed"/>; a progress bar that filled instead would be the one
|
||||
/// case where the bar told a different story from the state beside it.
|
||||
/// </remarks>
|
||||
public double Fraction => Length <= 0 ? 0 : Math.Clamp((double)Transferred / Length, 0, 1);
|
||||
|
||||
/// <summary>Whether this transfer will not move again on its own.</summary>
|
||||
public bool IsFinished =>
|
||||
State is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
/// <summary>
|
||||
/// Whether there is a partial file to carry on from.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only for a transfer this queue itself left partial. See <see cref="FileTransferQueue.Retry"/> for why
|
||||
/// a partial file found lying about is not resumed.
|
||||
/// </remarks>
|
||||
public bool CanResume =>
|
||||
State is TransferState.Failed or TransferState.Cancelled && Transferred > 0 && Transferred < Length;
|
||||
}
|
||||
|
||||
/// <summary>A transfer whose state or progress has moved.</summary>
|
||||
/// <param name="transfer">The transfer, as it now is.</param>
|
||||
public sealed class TransferChangedEventArgs(TransferSnapshot transfer) : EventArgs
|
||||
{
|
||||
/// <summary>The transfer.</summary>
|
||||
public TransferSnapshot Transfer { get; } = transfer;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The transfer queue: one file at a time, resumable, over one SFTP session.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>One at a time.</b> Everything here shares one channel's window, so a second concurrent transfer does
|
||||
/// not make the pair finish sooner — it makes both finish later, and it makes the progress of each
|
||||
/// unreadable. A serial queue also means the throughput figure on a row is the throughput of the link, which
|
||||
/// is the only reading of that number anybody acts on.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing is written at its final name until it is complete.</b> Every transfer goes to a part file
|
||||
/// beside its destination and is renamed into place at the end, so an interrupted transfer can never be
|
||||
/// mistaken for a finished one — which matters most for the thing people actually do with this screen, which
|
||||
/// is copy a build artefact onto a server and then run it. A destination that already exists is refused
|
||||
/// outright rather than overwritten: the queue has no way to ask, and silently replacing a file somebody
|
||||
/// else's process is serving is the worse of the two failures.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Resume is within a run of the application.</b> A part file left by an interrupted transfer is carried
|
||||
/// on from by <see cref="Retry"/>, which knows the source it came from. A part file found at startup is not:
|
||||
/// nothing here records what wrote it, and resuming a file from the middle on the strength of its name
|
||||
/// matching is how a corrupted artefact gets delivered without anything reporting a failure. Making that
|
||||
/// survive a restart needs somewhere to write the bookkeeping, which this client does not yet have — see
|
||||
/// <c>docs/design-import-gaps.md</c> on the missing preferences store.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class FileTransferQueue : IAsyncDisposable
|
||||
{
|
||||
/// <summary>What an in-flight transfer's destination is called until it is finished.</summary>
|
||||
/// <remarks>
|
||||
/// Long and specific rather than the conventional <c>.part</c>, because this file appears in a directory
|
||||
/// somebody else may be looking at — on a shared server, in a deployment directory — and a name that
|
||||
/// says which program left it there is the difference between a question and an incident.
|
||||
/// </remarks>
|
||||
internal const string PartSuffix = ".dodossh-part";
|
||||
|
||||
/// <remarks>
|
||||
/// The same 64 KiB the SFTP session reads in, so a copy is one read and one write per SFTP request with
|
||||
/// no re-chunking in between.
|
||||
/// </remarks>
|
||||
private const int BufferSize = 64 * 1024;
|
||||
|
||||
/// <remarks>
|
||||
/// Throughput over a window rather than since the start, because the number people read it for is "is it
|
||||
/// still going, and how fast now" — an average since the start of a resumed multi-gigabyte transfer
|
||||
/// answers a question nobody asked. Half a second is long enough that one slow request does not make it
|
||||
/// jump and short enough that a stall shows up before it is worth investigating.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ThroughputWindow = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
/// <remarks>
|
||||
/// A progress event per buffer would be some thousands a second on a fast link, each one marshalled to
|
||||
/// the UI thread to move a bar by a pixel. Ten a second is smooth to a person and free to the window.
|
||||
/// </remarks>
|
||||
private static readonly TimeSpan ProgressInterval = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
private readonly Func<CancellationToken, Task<ISftpSession>> sessions;
|
||||
private readonly TimeProvider clock;
|
||||
private readonly List<Entry> transfers = [];
|
||||
private readonly Lock gate = new();
|
||||
private readonly CancellationTokenSource lifetime = new();
|
||||
|
||||
private Task pump = Task.CompletedTask;
|
||||
private bool disposed;
|
||||
|
||||
/// <param name="sessions">
|
||||
/// Where the queue gets a session from, asked once per drain. A delegate rather than a session, because
|
||||
/// the screen owns the connection and may have re-established it since the last transfer ran — and a
|
||||
/// queue holding a stale session would fail every row with a socket error instead of reconnecting.
|
||||
/// </param>
|
||||
/// <param name="clock">Time source, so throughput is measurable without waiting for real seconds.</param>
|
||||
public FileTransferQueue(Func<CancellationToken, Task<ISftpSession>> sessions, TimeProvider clock)
|
||||
{
|
||||
this.sessions = sessions;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised whenever a transfer's state or progress changes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Raised on the pump's thread</b>, which is a thread-pool thread. A handler that touches an
|
||||
/// observable collection has to marshal; this project has no toolkit to do it with, which is exactly why
|
||||
/// it does not try. The same arrangement as <c>TerminalWorkspace.SessionEnded</c>.
|
||||
/// </remarks>
|
||||
public event EventHandler<TransferChangedEventArgs>? Changed;
|
||||
|
||||
/// <summary>Every transfer this queue knows about, in the order they were added.</summary>
|
||||
public IReadOnlyList<TransferSnapshot> Snapshot()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return [.. transfers.Select(Describe)];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Whether anything is queued or running.</summary>
|
||||
public bool IsBusy
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.Exists(entry =>
|
||||
entry.State is TransferState.Queued or TransferState.Running);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a transfer and starts the queue if it is not already running.
|
||||
/// </summary>
|
||||
/// <param name="direction">Which way.</param>
|
||||
/// <param name="localPath">The local end. For a download this is the file to create.</param>
|
||||
/// <param name="remotePath">The remote end. For an upload this is the file to create.</param>
|
||||
/// <param name="length">
|
||||
/// How many bytes there are, from the listing on the side that already has the file. Taken as given
|
||||
/// rather than measured here, because the pane the user dragged from has just read it and asking again
|
||||
/// would be a round trip to learn something already on screen.
|
||||
/// </param>
|
||||
/// <returns>The transfer's id.</returns>
|
||||
public Guid Enqueue(
|
||||
TransferDirection direction,
|
||||
string localPath,
|
||||
string remotePath,
|
||||
long length)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrEmpty(localPath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(remotePath);
|
||||
|
||||
var entry = new Entry
|
||||
{
|
||||
Id = Guid.CreateVersion7(),
|
||||
Direction = direction,
|
||||
LocalPath = localPath,
|
||||
RemotePath = remotePath,
|
||||
Name = direction is TransferDirection.Download
|
||||
? SftpPath.Name(remotePath)
|
||||
: Path.GetFileName(localPath),
|
||||
Length = length,
|
||||
State = TransferState.Queued,
|
||||
};
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
transfers.Add(entry);
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
|
||||
return entry.Id;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops a transfer, or takes a queued one out of the queue.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file is left where it is, which is what makes <see cref="Retry"/> a resume rather than a
|
||||
/// restart. <see cref="Discard"/> is how a row and its part file go.
|
||||
/// </remarks>
|
||||
public void Cancel(Guid id)
|
||||
{
|
||||
Entry? cancelled = null;
|
||||
CancellationTokenSource? running = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (entry.State)
|
||||
{
|
||||
case TransferState.Running:
|
||||
// Marked by the run itself when the cancellation lands, so a transfer that was already
|
||||
// finishing is not relabelled after the fact.
|
||||
running = entry.Cancellation;
|
||||
break;
|
||||
|
||||
case TransferState.Queued:
|
||||
entry.State = TransferState.Cancelled;
|
||||
cancelled = entry;
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
running?.Cancel();
|
||||
|
||||
if (cancelled is not null)
|
||||
{
|
||||
Announce(cancelled);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Puts a stopped transfer back in the queue, carrying on from where it stopped.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Resumes rather than restarts, and only because this object watched the part file being written: it
|
||||
/// knows which source those bytes came from. That is the whole of the bookkeeping the design's "resume
|
||||
/// supported" needs, and the reason it does not survive a restart — see the remark on this type.
|
||||
/// </remarks>
|
||||
public void Retry(Guid id)
|
||||
{
|
||||
Entry? retried = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(disposed, this);
|
||||
|
||||
if (Find(id) is not { State: TransferState.Failed or TransferState.Cancelled } entry)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
entry.State = TransferState.Queued;
|
||||
entry.Failure = null;
|
||||
entry.Resume = entry.Transferred > 0;
|
||||
retried = entry;
|
||||
|
||||
EnsurePumping();
|
||||
}
|
||||
|
||||
Announce(retried);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes one finished transfer, and whatever it left on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Named for the destruction rather than for the tidying. A cancelled transfer's part file holds real
|
||||
/// bytes that took real time to move, and the row is the only thing that knows the part file exists — so
|
||||
/// removing the row without removing the file would leave litter nobody could attribute, and removing it
|
||||
/// silently under a word like "clear" would throw away a resumable transfer without saying so.
|
||||
/// </remarks>
|
||||
/// <returns>Whether there was such a transfer to discard.</returns>
|
||||
public async Task<bool> DiscardAsync(Guid id, CancellationToken cancellationToken)
|
||||
{
|
||||
Entry? discarded;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (Find(id) is not { } entry || !IsFinished(entry.State))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
transfers.Remove(entry);
|
||||
discarded = entry;
|
||||
}
|
||||
|
||||
await RemovePartFileAsync(discarded, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Removes every completed transfer, which have nothing left on disk to clean up.</summary>
|
||||
/// <returns>How many rows went.</returns>
|
||||
public int ClearCompleted()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return transfers.RemoveAll(entry => entry.State is TransferState.Completed);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// Cancels whatever is running and waits for it, rather than abandoning the pump. A transfer in flight
|
||||
/// holds an open remote file and an open local one, and letting the process move on while they are still
|
||||
/// being written is how a part file ends up longer than the bytes that reached it.
|
||||
/// </remarks>
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
Task running;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
running = pump;
|
||||
}
|
||||
|
||||
await lifetime.CancelAsync().ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
await running.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The pump reports failures onto the rows it was running; a fault escaping here would be one
|
||||
// nothing is left to show.
|
||||
}
|
||||
|
||||
lifetime.Dispose();
|
||||
}
|
||||
|
||||
/// <remarks>
|
||||
/// Started under the gate and restarted whenever it has finished, which is what makes "one at a time"
|
||||
/// true without a dedicated thread waiting on an empty queue for the life of the application.
|
||||
/// </remarks>
|
||||
private void EnsurePumping()
|
||||
{
|
||||
if (pump.IsCompleted && !disposed)
|
||||
{
|
||||
pump = Task.Run(() => PumpAsync(lifetime.Token), lifetime.Token);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PumpAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (TakeNext() is { } entry)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
|
||||
ISftpSession session;
|
||||
|
||||
try
|
||||
{
|
||||
session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
continue;
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// The session is what every remaining row needs, so a failure to get one is reported on the
|
||||
// row that asked and the next iteration asks again. Failing the whole queue would hide which
|
||||
// transfer was affected behind a single message.
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
continue;
|
||||
}
|
||||
|
||||
await RunAsync(entry, session, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Takes the next queued transfer and marks it running.</summary>
|
||||
private Entry? TakeNext()
|
||||
{
|
||||
Entry? next;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
next = transfers.Find(entry => entry.State is TransferState.Queued);
|
||||
|
||||
if (next is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
next.State = TransferState.Running;
|
||||
next.Cancellation = CancellationTokenSource.CreateLinkedTokenSource(lifetime.Token);
|
||||
next.BytesPerSecond = 0;
|
||||
|
||||
// Reset unless this is a resume, so a retry from the start does not open with a bar most of the
|
||||
// way along that then jumps back.
|
||||
if (!next.Resume)
|
||||
{
|
||||
next.Transferred = 0;
|
||||
}
|
||||
}
|
||||
|
||||
Announce(next);
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
private async Task RunAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var token = entry.Cancellation?.Token ?? cancellationToken;
|
||||
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
await DownloadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
await UploadAsync(entry, session, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
Stop(entry, TransferState.Completed, failure: null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Stop(entry, TransferState.Cancelled, failure: null);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
Stop(entry, TransferState.Failed, exception.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CancellationTokenSource? source;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
source = entry.Cancellation;
|
||||
entry.Cancellation = null;
|
||||
}
|
||||
|
||||
source?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DownloadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.LocalPath;
|
||||
|
||||
if (File.Exists(destination))
|
||||
{
|
||||
throw new IOException(
|
||||
$"{Path.GetFileName(destination)} is already in that folder. Rename or remove it first — "
|
||||
+ "nothing here overwrites a file you already have.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var offset = ResumableLength(entry, new FileInfo(part));
|
||||
|
||||
if (offset == 0 && File.Exists(part))
|
||||
{
|
||||
// A part file this transfer is not resuming from. It belongs to an earlier attempt at the same
|
||||
// destination, and starting a fresh transfer by appending to it would produce a file that is
|
||||
// longer than the source and wrong in the middle.
|
||||
File.Delete(part);
|
||||
}
|
||||
|
||||
await ReadIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Only now, with both handles closed — which is what makes the copy its own method rather than a
|
||||
// block here. On Windows a move of a file still open for writing fails, and it is the one step whose
|
||||
// failure would leave a complete transfer looking like an incomplete one.
|
||||
File.Move(part, destination);
|
||||
}
|
||||
|
||||
private async Task ReadIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var remote = await session
|
||||
.OpenReadAsync(entry.RemotePath, offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
var local = new FileStream(
|
||||
part,
|
||||
offset == 0 ? FileMode.Create : FileMode.Open,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(remote, local, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task UploadAsync(Entry entry, ISftpSession session, CancellationToken cancellationToken)
|
||||
{
|
||||
var destination = entry.RemotePath;
|
||||
|
||||
if (await session.StatAsync(destination, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
throw new IOException(
|
||||
$"{SftpPath.Name(destination)} is already in that directory on the host. Rename or remove it "
|
||||
+ "first — nothing here overwrites a file that is already there.");
|
||||
}
|
||||
|
||||
var part = destination + PartSuffix;
|
||||
var existing = await session.StatAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
var offset = ResumableLength(entry, existing);
|
||||
|
||||
if (offset == 0 && existing is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await WriteIntoPartAsync(entry, session, part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await session.RenameAsync(part, destination, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task WriteIntoPartAsync(
|
||||
Entry entry,
|
||||
ISftpSession session,
|
||||
string part,
|
||||
long offset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var local = new FileStream(
|
||||
entry.LocalPath, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, useAsync: true);
|
||||
|
||||
await using var localScope = local.ConfigureAwait(false);
|
||||
|
||||
var remote = await session.OpenWriteAsync(part, offset, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
await using var remoteScope = remote.ConfigureAwait(false);
|
||||
|
||||
local.Seek(offset, SeekOrigin.Begin);
|
||||
|
||||
await CopyAsync(local, remote, entry, offset, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How far a resume may start, given what is actually on the destination.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The part file's own length, not the transfer's recorded progress, and never longer than the source.
|
||||
/// The two can disagree — a cancellation lands between a write completing and the counter moving, and a
|
||||
/// buffered write may not have reached the disk at all — and only one of them is a fact about the bytes
|
||||
/// that are there. Trusting the counter would resume past bytes that were never written, which is the
|
||||
/// one way a resumed transfer can produce a corrupt file that nothing reports.
|
||||
/// </remarks>
|
||||
private static long ResumableLength(Entry entry, FileInfo part) =>
|
||||
part.Exists ? ResumableLength(entry, part.Length) : 0;
|
||||
|
||||
private static long ResumableLength(Entry entry, SftpEntry? part) =>
|
||||
part is null ? 0 : ResumableLength(entry, part.Length);
|
||||
|
||||
private static long ResumableLength(Entry entry, long partLength)
|
||||
{
|
||||
if (!entry.Resume || partLength <= 0 || partLength >= entry.Length)
|
||||
{
|
||||
// A part file at or beyond the source's length is not a resume point; it is evidence that the
|
||||
// source changed under a previous attempt. Starting again is the only answer that ends with the
|
||||
// right bytes.
|
||||
return 0;
|
||||
}
|
||||
|
||||
return partLength;
|
||||
}
|
||||
|
||||
private async Task CopyAsync(
|
||||
Stream source,
|
||||
Stream destination,
|
||||
Entry entry,
|
||||
long startOffset,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
var meter = new ProgressMeter(clock, startOffset);
|
||||
|
||||
try
|
||||
{
|
||||
var transferred = startOffset;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var read = await source
|
||||
.ReadAsync(buffer.AsMemory(0, BufferSize), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
await destination
|
||||
.WriteAsync(buffer.AsMemory(0, read), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
transferred += read;
|
||||
|
||||
if (Record(entry, meter, transferred))
|
||||
{
|
||||
Announce(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// Flushed before the caller closes the handles and renames, so a write still sitting in a buffer
|
||||
// is not counted as delivered by a rename that beat it to the disk.
|
||||
await destination.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Writes progress onto the entry, and says whether it is worth telling anyone.</summary>
|
||||
private bool Record(Entry entry, ProgressMeter meter, long transferred)
|
||||
{
|
||||
var reading = meter.Read(transferred);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
entry.Transferred = transferred;
|
||||
|
||||
if (reading.BytesPerSecond is { } rate)
|
||||
{
|
||||
entry.BytesPerSecond = rate;
|
||||
}
|
||||
}
|
||||
|
||||
return reading.WorthAnnouncing;
|
||||
}
|
||||
|
||||
private void Stop(Entry entry, TransferState state, string? failure)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
entry.State = state;
|
||||
entry.Failure = failure;
|
||||
entry.BytesPerSecond = 0;
|
||||
entry.Resume = false;
|
||||
}
|
||||
|
||||
Announce(entry);
|
||||
}
|
||||
|
||||
private async Task RemovePartFileAsync(Entry entry, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (entry.Direction is TransferDirection.Download)
|
||||
{
|
||||
File.Delete(entry.LocalPath + PartSuffix);
|
||||
return;
|
||||
}
|
||||
|
||||
var session = await sessions(cancellationToken).ConfigureAwait(false);
|
||||
var part = entry.RemotePath + PartSuffix;
|
||||
|
||||
if (await session.StatAsync(part, cancellationToken).ConfigureAwait(false) is not null)
|
||||
{
|
||||
await session.DeleteAsync(part, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (exception is not OutOfMemoryException)
|
||||
{
|
||||
// Best effort, and deliberately silent. The row the user asked to remove is already gone, and a
|
||||
// remote part file that outlives it is litter rather than a failure — reporting it would mean
|
||||
// putting an error on a screen for an operation that did what was asked.
|
||||
}
|
||||
}
|
||||
|
||||
private Entry? Find(Guid id) => transfers.Find(entry => entry.Id == id);
|
||||
|
||||
private static bool IsFinished(TransferState state) =>
|
||||
state is TransferState.Completed or TransferState.Failed or TransferState.Cancelled;
|
||||
|
||||
private void Announce(Entry entry)
|
||||
{
|
||||
TransferSnapshot snapshot;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
snapshot = Describe(entry);
|
||||
}
|
||||
|
||||
Changed?.Invoke(this, new TransferChangedEventArgs(snapshot));
|
||||
}
|
||||
|
||||
/// <remarks>Callers hold <see cref="gate"/>: every field read here is written from the pump.</remarks>
|
||||
private static TransferSnapshot Describe(Entry entry) => new(
|
||||
entry.Id,
|
||||
entry.Direction,
|
||||
entry.Name,
|
||||
entry.LocalPath,
|
||||
entry.RemotePath,
|
||||
entry.Length,
|
||||
entry.Transferred,
|
||||
entry.State,
|
||||
entry.BytesPerSecond,
|
||||
entry.Failure);
|
||||
|
||||
/// <summary>
|
||||
/// Keeps the two clocks a copy loop needs: when throughput was last sampled, and when the interface was
|
||||
/// last told anything.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Its own type because both are stateful across iterations, and the alternative — four locals threaded
|
||||
/// through the loop by reference — is how the sampling and the announcing end up sharing a timestamp and
|
||||
/// quietly becoming one interval. They are deliberately different: half a second is the right window to
|
||||
/// measure a rate over, and a tenth of a second is the right rate to repaint at.
|
||||
/// </remarks>
|
||||
private sealed class ProgressMeter(TimeProvider clock, long startOffset)
|
||||
{
|
||||
private long sampleAt = clock.GetTimestamp();
|
||||
private long sampleBytes = startOffset;
|
||||
private long announcedAt = clock.GetTimestamp();
|
||||
|
||||
/// <param name="transferred">Total bytes moved, resumed bytes included.</param>
|
||||
/// <returns>
|
||||
/// A new throughput figure when the window has elapsed and null when it has not, so a rate is never
|
||||
/// recomputed from a sample too short to mean anything; and whether this is a moment to repaint.
|
||||
/// </returns>
|
||||
public (double? BytesPerSecond, bool WorthAnnouncing) Read(long transferred)
|
||||
{
|
||||
var now = clock.GetTimestamp();
|
||||
var sinceSample = clock.GetElapsedTime(sampleAt, now);
|
||||
|
||||
double? rate = null;
|
||||
|
||||
if (sinceSample >= ThroughputWindow)
|
||||
{
|
||||
rate = (transferred - sampleBytes) / sinceSample.TotalSeconds;
|
||||
sampleAt = now;
|
||||
sampleBytes = transferred;
|
||||
}
|
||||
|
||||
if (clock.GetElapsedTime(announcedAt, now) < ProgressInterval)
|
||||
{
|
||||
return (rate, false);
|
||||
}
|
||||
|
||||
announcedAt = now;
|
||||
|
||||
return (rate, true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One transfer's mutable state, which only the queue touches and only under its gate.</summary>
|
||||
private sealed class Entry
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
|
||||
public required TransferDirection Direction { get; init; }
|
||||
|
||||
public required string LocalPath { get; init; }
|
||||
|
||||
public required string RemotePath { get; init; }
|
||||
|
||||
public required string Name { get; init; }
|
||||
|
||||
public required long Length { get; init; }
|
||||
|
||||
public required TransferState State { get; set; }
|
||||
|
||||
public long Transferred { get; set; }
|
||||
|
||||
public double BytesPerSecond { get; set; }
|
||||
|
||||
public string? Failure { get; set; }
|
||||
|
||||
/// <summary>Whether the next run may carry on from a part file rather than starting again.</summary>
|
||||
public bool Resume { get; set; }
|
||||
|
||||
public CancellationTokenSource? Cancellation { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
namespace DodoSSH.Client.Transfer;
|
||||
|
||||
/// <summary>One entry in a local directory.</summary>
|
||||
/// <param name="Name">The entry's own name.</param>
|
||||
/// <param name="FullPath">The absolute path.</param>
|
||||
/// <param name="IsDirectory">Whether it can be navigated into.</param>
|
||||
/// <param name="Length">Size in bytes, or zero for a directory.</param>
|
||||
/// <param name="LastWriteTimeUtc">When it was last written.</param>
|
||||
/// <remarks>
|
||||
/// Deliberately the same shape as <c>SftpEntry</c> minus the permission string, because the two panes of the
|
||||
/// file browser show the same columns and a local mode rendered in POSIX notation would be a fiction on
|
||||
/// Windows — where the design's <c>PERMS</c> column has no honest value at all.
|
||||
/// </remarks>
|
||||
public sealed record LocalEntry(
|
||||
string Name,
|
||||
string FullPath,
|
||||
bool IsDirectory,
|
||||
long Length,
|
||||
DateTimeOffset LastWriteTimeUtc);
|
||||
|
||||
/// <summary>
|
||||
/// The local half of the file browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The first <c>System.IO</c> in the client outside the cache and the device key, and it stays behind this
|
||||
/// one type on purpose: everything above reasons about <see cref="LocalEntry"/>, so the transfers screen and
|
||||
/// its view model never enumerate a directory themselves.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Nothing here throws for one unreadable entry.</b> A local directory listing on Windows routinely
|
||||
/// contains things the user cannot stat — a junction into another profile, a file another process holds
|
||||
/// open — and a browser that failed the whole listing for one of them would be unable to show
|
||||
/// <c>C:\Users</c>. Those entries are skipped; a directory that cannot be opened at all is still a failure,
|
||||
/// because there is nothing to show.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class LocalDirectory
|
||||
{
|
||||
/// <summary>Where the local pane opens.</summary>
|
||||
/// <remarks>
|
||||
/// The user profile rather than the process's working directory, which for a desktop application is
|
||||
/// wherever it happened to be launched from — an installation directory nobody keeps files in.
|
||||
/// </remarks>
|
||||
public static string Home =>
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile, Environment.SpecialFolderOption.None);
|
||||
|
||||
/// <summary>
|
||||
/// Lists a directory, directories first and then by name.
|
||||
/// </summary>
|
||||
/// <exception cref="IOException">The directory could not be opened.</exception>
|
||||
/// <exception cref="UnauthorizedAccessException">The directory could not be opened.</exception>
|
||||
public static IReadOnlyList<LocalEntry> List(string path)
|
||||
{
|
||||
var directory = new DirectoryInfo(path);
|
||||
var entries = new List<LocalEntry>();
|
||||
|
||||
// EnumerateFileSystemInfos rather than GetFileSystemInfos: the enumerating form yields entries as it
|
||||
// reads them, so a directory of fifty thousand files does not have to be materialised twice.
|
||||
foreach (var entry in directory.EnumerateFileSystemInfos())
|
||||
{
|
||||
try
|
||||
{
|
||||
var isDirectory = entry.Attributes.HasFlag(FileAttributes.Directory);
|
||||
|
||||
entries.Add(new LocalEntry(
|
||||
entry.Name,
|
||||
entry.FullName,
|
||||
isDirectory,
|
||||
isDirectory ? 0 : ((FileInfo)entry).Length,
|
||||
new DateTimeOffset(entry.LastWriteTimeUtc, TimeSpan.Zero)));
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// One entry this process cannot stat. Skipped rather than shown as a row with no facts on
|
||||
// it, and skipped rather than failing the listing — see the remark on this type.
|
||||
}
|
||||
}
|
||||
|
||||
entries.Sort(static (left, right) => left.IsDirectory == right.IsDirectory
|
||||
? string.Compare(left.Name, right.Name, StringComparison.CurrentCultureIgnoreCase)
|
||||
: right.IsDirectory.CompareTo(left.IsDirectory));
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The directory holding a path, or null when it is already a root.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Null rather than the path itself, because the local pane's "up" has somewhere further to go than the
|
||||
/// remote's does: above <c>C:\</c> is the list of drives, which is not a directory. The remote pane stops
|
||||
/// at <c>/</c>, which is.
|
||||
/// </remarks>
|
||||
public static string? Parent(string path) => Path.GetDirectoryName(Path.TrimEndingDirectorySeparator(path));
|
||||
|
||||
/// <summary>
|
||||
/// Where the local pane can start from: the drives on Windows, and the root elsewhere.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Ready drives only. An empty optical drive or a disconnected network mapping is listed by
|
||||
/// <see cref="DriveInfo.GetDrives"/> and throws on the first attempt to read it, which would put a row on
|
||||
/// screen whose only behaviour is an error.
|
||||
/// </remarks>
|
||||
public static IReadOnlyList<string> Roots()
|
||||
{
|
||||
var roots = new List<string>();
|
||||
|
||||
foreach (var drive in DriveInfo.GetDrives())
|
||||
{
|
||||
try
|
||||
{
|
||||
if (drive.IsReady)
|
||||
{
|
||||
roots.Add(drive.RootDirectory.FullName);
|
||||
}
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// A drive that fails even to answer whether it is ready. Nothing to show.
|
||||
}
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"version": 2,
|
||||
"dependencies": {
|
||||
"net10.0": {
|
||||
"Meziantou.Analyzer": {
|
||||
"type": "Direct",
|
||||
"requested": "[3.0.137, )",
|
||||
"resolved": "3.0.137",
|
||||
"contentHash": "48n8WsokyefLOZt2qGPpk+hjfF9rQRa7MGK+8hMS6sO22QuIDtBztfbXV7RtVfX7JKjUwDI9e06I8aeCc5XZzQ=="
|
||||
},
|
||||
"Microsoft.CodeAnalysis.BannedApiAnalyzers": {
|
||||
"type": "Direct",
|
||||
"requested": "[5.6.0, )",
|
||||
"resolved": "5.6.0",
|
||||
"contentHash": "Kcobt3pnOdO0A+6CKiMHZdTEluJpsfxiV20axtZdmfBQnDmiWTKPJADlgAfdTuKNAnVarrkJa0UEGwuOo91muw=="
|
||||
},
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.2",
|
||||
"contentHash": "3iE7UF7MQkCv1cxzCahz+Y/guQbTqieyxyaWKhrRO91itI9cOKO76OHeQDahqG4MmW5umr3CcCvGmK92lWNlbg=="
|
||||
},
|
||||
"Microsoft.Extensions.Logging.Abstractions": {
|
||||
"type": "Transitive",
|
||||
"resolved": "8.0.3",
|
||||
"contentHash": "dL0QGToTxggRLMYY4ZYX5AMwBb+byQBd/5dMiZE07Nv73o6I5Are3C7eQTh7K2+A4ct0PVISSr7TZANbiNb2yQ==",
|
||||
"dependencies": {
|
||||
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.2"
|
||||
}
|
||||
},
|
||||
"dodossh.client.ssh": {
|
||||
"type": "Project",
|
||||
"dependencies": {
|
||||
"SSH.NET": "[2025.1.0, )"
|
||||
}
|
||||
},
|
||||
"BouncyCastle.Cryptography": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2.6.2, )",
|
||||
"resolved": "2.6.2",
|
||||
"contentHash": "7oWOcvnntmMKNzDLsdxAYqApt+AjpRpP2CShjMfIa3umZ42UQMvH0tl1qAliYPNYO6vTdcGMqnRrCPmsfzTI1w=="
|
||||
},
|
||||
"SSH.NET": {
|
||||
"type": "CentralTransitive",
|
||||
"requested": "[2025.1.0, )",
|
||||
"resolved": "2025.1.0",
|
||||
"contentHash": "jrnbtf0ItVaXAe6jE8X/kSLa6uC+0C+7W1vepcnRQB/rD88qy4IxG7Lf1FIbWmkoc4iVXv0pKrz+Wc6J4ngmHw==",
|
||||
"dependencies": {
|
||||
"BouncyCastle.Cryptography": "2.6.2",
|
||||
"Microsoft.Extensions.Logging.Abstractions": "8.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user