Public Access
Move files to and from a host over SFTP
M2's file transfer, built bottom-up: an SFTP session on the SSH layer, a transfer queue in a project of its own, and the two-pane browser the design asked for replacing the screen that said it did not exist. Remote listings carry names, sizes, modification times and a real drwxr-xr-x — nothing in this repository could render a POSIX mode before — and the queue moves one file at a time with progress, throughput and resume. The design import assumed this would be an SFTP subsystem channel on ISshConnection, beside the shell on a transport that is already up. SSH.NET does not offer that: SftpClient derives from BaseClient and owns its own transport, and there is no supported way to hand it an SshClient's session. So file transfer opens a second authenticated connection, and it is named for that rather than dressed up as a channel — OpenSftpAsync is on ISftpSessionFactory, not on a connection. The difference is visible to a user: the host records a second login, and a host whose password is typed each time asks for it again on this screen. It goes through the same host key gate, the same pin and the same two refusals a shell does, so a fingerprint approved for a terminal is approved here and one approved here reaches the other machines with the next sync. docs/design-import-gaps.md is corrected, and marked as the one row where what shipped differs from what it predicted. Nothing is written at its final name until it is complete. Every transfer goes to a .dodossh-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 what this screen is actually for, which is copying a build artefact onto a server and then running 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's process is serving is the worse of the two failures. The remote pane has DELETE and MKDIR so that refusal is not a dead end. A test against the container pins the assumption underneath all of this — that SFTP's rename does not clobber. Resume works within a run of the application and not across a restart, and the limit is deliberate rather than unfinished. Nothing records which source wrote a part file, and resuming one on the strength of its name matching is how a corrupt artefact gets delivered with nothing reporting a failure; a part file found at startup is started over. Making it survive a restart needs the preferences store this client still has not got. The offset a resume starts at is the part file's own length rather than the transfer's recorded progress: a cancellation can land between a write completing and the counter moving, and only one of those two is a fact about the bytes that are there. The queue and its connection outlive a lock, as shells do. LockAsync already argues that locking must not destroy work in flight — it is what somebody does when they walk away from the machine, which is exactly when a long transfer is most likely to be running — so TransfersViewModel is created once and the vault is attached on unlock and detached on lock. What locking takes is the host list, and it has to: those rows carry decrypted secrets. DodoSSH.Client.Transfer is a new project rather than more of Client.Ssh. The two answer different questions — one is about reaching a host, the other about moving bytes and what to do when moving them stops halfway — and this is the only client project that deliberately touches the local filesystem. Three defects the tests found, none of which review would have. SftpPath.Name answered an empty string for the root. NavigateRemoteAsync wrapped itself in the busy guard, so navigating from inside another command did nothing at all and the remote pane simply stayed empty after connecting, with no failure anywhere to explain it. And opening an SFTP session per test made two handshakes per test — this client learns a host key by being refused — which pushed the SSH assembly past sshd's MaxStartups and failed a different few unrelated tests each run; the session is shared through the fixture now, with the reason written where the next person will hit it. 1004 tests green across 18 projects, 24 of them new: the SFTP subsystem against the OpenSSH container, the queue against a real temporary directory and a fake host, and three more layout measurements because a screen this window has never laid out is a screen never checked. Not verified: the screen has not been looked at running. The layout harness measures it at the window's minimum in three shapes, which is the class of defect that has shipped here before, but reaching it in the application needs the compose stack, the migrations, the API and a browser sign-in. What is still absent — the status bar's transfer count, dragging between the panes, transferring a directory, and sftp over a bastion — is in docs/design-import-gaps.md.
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