From a45b4523abc99490ec3c8051c0ef644c54c52904 Mon Sep 17 00:00:00 2001 From: Mark Junker Date: Wed, 6 Nov 2019 12:43:13 +0100 Subject: [PATCH] Shell for test FTP server can now list and close connections Copied ReadLine into third-party and changed to allow async auto completion handlers. --- FubarDev.FtpServer.sln | 10 +- .../TestFtpServer.Api/FtpConnectionStatus.cs | 31 ++ samples/TestFtpServer.Api/IFtpServerHost.cs | 13 + .../Commands/CloseConnectionCommandHandler.cs | 87 ++++ .../Commands/ContinueCommandHandler.cs | 5 +- .../Commands/ExitCommandHandler.cs | 23 +- .../Commands/HelpCommandHandler.cs | 5 +- .../Commands/PauseCommandHandler.cs | 5 +- .../Commands/ShowCommandHandler.cs | 21 +- .../Commands/ShowConnectionsCommandInfo.cs | 58 +++ .../Commands/StatusCommandHandler.cs | 4 +- .../Commands/StopCommandHandler.cs | 5 +- .../FtpShellCommandAutoCompletion.cs | 28 +- samples/TestFtpServer.Shell/ICommandInfo.cs | 4 +- samples/TestFtpServer.Shell/ServerShell.cs | 9 +- .../TestFtpServer.Shell.csproj | 5 +- samples/TestFtpServer/FtpServerHostApi.cs | 49 +++ src/FubarDev.FtpServer/FtpConnection.cs | 6 + third-party/ReadLine/Abstractions/Console2.cs | 41 ++ third-party/ReadLine/Abstractions/IConsole.cs | 16 + third-party/ReadLine/IAutoCompleteHandler.cs | 12 + third-party/ReadLine/KeyHandler.cs | 406 ++++++++++++++++++ third-party/ReadLine/LICENSE | 21 + third-party/ReadLine/ReadLine.cs | 67 +++ third-party/ReadLine/ReadLine.projitems | 18 + third-party/ReadLine/ReadLine.shproj | 13 + 26 files changed, 926 insertions(+), 36 deletions(-) create mode 100644 samples/TestFtpServer.Api/FtpConnectionStatus.cs create mode 100644 samples/TestFtpServer.Shell/Commands/CloseConnectionCommandHandler.cs create mode 100644 samples/TestFtpServer.Shell/Commands/ShowConnectionsCommandInfo.cs create mode 100644 third-party/ReadLine/Abstractions/Console2.cs create mode 100644 third-party/ReadLine/Abstractions/IConsole.cs create mode 100644 third-party/ReadLine/IAutoCompleteHandler.cs create mode 100644 third-party/ReadLine/KeyHandler.cs create mode 100644 third-party/ReadLine/LICENSE create mode 100644 third-party/ReadLine/ReadLine.cs create mode 100644 third-party/ReadLine/ReadLine.projitems create mode 100644 third-party/ReadLine/ReadLine.shproj diff --git a/FubarDev.FtpServer.sln b/FubarDev.FtpServer.sln index 98ec1938..395d831b 100644 --- a/FubarDev.FtpServer.sln +++ b/FubarDev.FtpServer.sln @@ -9,13 +9,13 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution ProjectSection(SolutionItems) = preProject .dockerignore = .dockerignore .editorconfig = .editorconfig + azure-pipelines.yml = azure-pipelines.yml FubarDev.FtpServer.ruleset = FubarDev.FtpServer.ruleset Global.props = Global.props LICENSE.md = LICENSE.md PackageLibrary.props = PackageLibrary.props README.md = README.md stylecop.json = stylecop.json - azure-pipelines.yml = azure-pipelines.yml EndProjectSection EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FubarDev.FtpServer.FileSystem.DotNet", "src\FubarDev.FtpServer.FileSystem.DotNet\FubarDev.FtpServer.FileSystem.DotNet.csproj", "{9A83C6F5-378B-48B3-B32A-151DB90B390C}" @@ -60,14 +60,17 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFtpServer.Shell", "samp EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestFtpServer.Api", "samples\TestFtpServer.Api\TestFtpServer.Api.csproj", "{02D103B2-B9CA-4A7F-AB79-6FEC68C62B47}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickStart.GenericHost", "samples\QuickStart.GenericHost\QuickStart.GenericHost.csproj", "{23752176-0F1A-4352-B677-CD7878B7E8FC}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QuickStart.GenericHost", "samples\QuickStart.GenericHost\QuickStart.GenericHost.csproj", "{23752176-0F1A-4352-B677-CD7878B7E8FC}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "QuickStart.AspNetCoreHost", "samples\QuickStart.AspNetCoreHost\QuickStart.AspNetCoreHost.csproj", "{D03594D1-84CC-4B55-A56D-20ED446CFDB1}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QuickStart.AspNetCoreHost", "samples\QuickStart.AspNetCoreHost\QuickStart.AspNetCoreHost.csproj", "{D03594D1-84CC-4B55-A56D-20ED446CFDB1}" +Project("{D954291E-2A0B-460D-934E-DC6B0785DB48}") = "ReadLine", "third-party\ReadLine\ReadLine.shproj", "{F68F448F-E8A4-4536-9A83-12018B6294B0}" EndProject Global GlobalSection(SharedMSBuildProjectFiles) = preSolution src\FubarDev.FtpServer.Shared\FubarDev.FtpServer.Shared.projitems*{52126b62-de83-4597-a832-d144f9a5a870}*SharedItemsImports = 13 third-party\DotNet.Glob\DotNet.Glob.projitems*{a45290b4-20f7-48e9-8543-c69240bfb9f8}*SharedItemsImports = 13 + third-party\ReadLine\ReadLine.projitems*{f68f448f-e8a4-4536-9a83-12018b6294b0}*SharedItemsImports = 13 third-party\GnuSslStream\GnuSslStream.projitems*{fd524518-e097-4ac0-aac8-d5ea24f31545}*SharedItemsImports = 13 EndGlobalSection GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -160,6 +163,7 @@ Global {02D103B2-B9CA-4A7F-AB79-6FEC68C62B47} = {7004709E-5C0D-4D3E-AF27-89968FA47C19} {23752176-0F1A-4352-B677-CD7878B7E8FC} = {7004709E-5C0D-4D3E-AF27-89968FA47C19} {D03594D1-84CC-4B55-A56D-20ED446CFDB1} = {7004709E-5C0D-4D3E-AF27-89968FA47C19} + {F68F448F-E8A4-4536-9A83-12018B6294B0} = {441F13FB-D457-402E-8DAC-4B6485AFCE30} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {DAA7D00E-C61D-4640-A54E-D307E4682263} diff --git a/samples/TestFtpServer.Api/FtpConnectionStatus.cs b/samples/TestFtpServer.Api/FtpConnectionStatus.cs new file mode 100644 index 00000000..1ab59795 --- /dev/null +++ b/samples/TestFtpServer.Api/FtpConnectionStatus.cs @@ -0,0 +1,31 @@ +// +// Copyright (c) Fubar Development Junker. All rights reserved. +// + +namespace TestFtpServer.Api +{ + /// + /// Information about a single FTP connection. + /// + public class FtpConnectionStatus + { + /// + /// Initializes a new instance of the class. + /// + /// The ID of the connection. + public FtpConnectionStatus(string id) + { + Id = id; + } + + /// + /// Gets or sets the ID of the connection. + /// + public string Id { get; set; } + + /// + /// Gets or sets a value indicating whether the connection is alive (read: not expired). + /// + public bool IsAlive { get; set; } + } +} diff --git a/samples/TestFtpServer.Api/IFtpServerHost.cs b/samples/TestFtpServer.Api/IFtpServerHost.cs index a60d7592..94559284 100644 --- a/samples/TestFtpServer.Api/IFtpServerHost.cs +++ b/samples/TestFtpServer.Api/IFtpServerHost.cs @@ -30,6 +30,19 @@ public interface IFtpServerHost /// The task. Task StopAsync(); + /// + /// Gets all active connections. + /// + /// + ICollection GetConnections(); + + /// + /// Closes the connection. + /// + /// The ID of the connection to be closed. + /// The task. + Task CloseConnectionAsync(string connectionId); + /// /// Get the list of registered simple modules. /// diff --git a/samples/TestFtpServer.Shell/Commands/CloseConnectionCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/CloseConnectionCommandHandler.cs new file mode 100644 index 00000000..f5cbcb6f --- /dev/null +++ b/samples/TestFtpServer.Shell/Commands/CloseConnectionCommandHandler.cs @@ -0,0 +1,87 @@ +// +// Copyright (c) Fubar Development Junker. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +using JKang.IpcServiceFramework; + +using TestFtpServer.Api; + +namespace TestFtpServer.Shell.Commands +{ + /// + /// Command handler for closing a client FTP connection. + /// + public class CloseConnectionCommandHandler : ICommandInfo + { + private readonly IpcServiceClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The IPC client. + public CloseConnectionCommandHandler(IpcServiceClient client) + { + _client = client; + } + + /// + public string Name { get; } = "connection"; + + /// + public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + + /// + public async IAsyncEnumerable GetSubCommandsAsync( + [EnumeratorCancellation] CancellationToken cancellationToken) + { + var connections = await _client + .InvokeAsync(host => host.GetConnections(), cancellationToken) + .ConfigureAwait(false); + foreach (var connection in connections) + { + yield return new CloseConnectionFinalCommandHandler(_client, connection.Id); + } + } + + private class CloseConnectionFinalCommandHandler : IExecutableCommandInfo + { + private readonly IpcServiceClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The IPC client. + /// The FTP connection ID. + public CloseConnectionFinalCommandHandler( + IpcServiceClient client, + string connectionId) + { + _client = client; + Name = connectionId; + } + + /// + public string Name { get; } + + /// + public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + + /// + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); + + /// + public Task ExecuteAsync(CancellationToken cancellationToken) + { + return _client.InvokeAsync(host => host.CloseConnectionAsync(Name), cancellationToken); + } + } + } +} diff --git a/samples/TestFtpServer.Shell/Commands/ContinueCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/ContinueCommandHandler.cs index c40d0f10..a38f665e 100644 --- a/samples/TestFtpServer.Shell/Commands/ContinueCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/ContinueCommandHandler.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -33,8 +34,10 @@ public ContinueCommandHandler(IpcServiceClient client) /// public IReadOnlyCollection AlternativeNames { get; } = new[] { "resume" }; + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/ExitCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/ExitCommandHandler.cs index 01bcba11..42a65b18 100644 --- a/samples/TestFtpServer.Shell/Commands/ExitCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/ExitCommandHandler.cs @@ -2,11 +2,15 @@ // Copyright (c) Fubar Development Junker. All rights reserved. // -using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; +using JKang.IpcServiceFramework; + +using TestFtpServer.Api; + namespace TestFtpServer.Shell.Commands { /// @@ -15,24 +19,35 @@ namespace TestFtpServer.Shell.Commands public class ExitCommandHandler : IRootCommandInfo, IExecutableCommandInfo { private readonly IShellStatus _status; + private readonly IAsyncEnumerable _subCommands; /// /// Initializes a new instance of the class. /// + /// The IPC client. /// The shell status. - public ExitCommandHandler(IShellStatus status) + public ExitCommandHandler( + IpcServiceClient client, + IShellStatus status) { _status = status; + _subCommands = new ICommandInfo[] + { + new CloseConnectionCommandHandler(client), + } + .ToAsyncEnumerable(); } /// public string Name { get; } = "exit"; /// - public IReadOnlyCollection AlternativeNames { get; } = new[] { "quit" }; + public IReadOnlyCollection AlternativeNames { get; } = new[] { "quit", "close" }; + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => _subCommands; /// public Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/HelpCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/HelpCommandHandler.cs index 29ec3a46..a7cab7e0 100644 --- a/samples/TestFtpServer.Shell/Commands/HelpCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/HelpCommandHandler.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -28,8 +29,10 @@ public HelpCommandHandler( /// public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/PauseCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/PauseCommandHandler.cs index 58fefc1d..86dfe6be 100644 --- a/samples/TestFtpServer.Shell/Commands/PauseCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/PauseCommandHandler.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -35,8 +36,10 @@ public PauseCommandHandler(IpcServiceClient client) /// public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/ShowCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/ShowCommandHandler.cs index b187e262..a6f0bcee 100644 --- a/samples/TestFtpServer.Shell/Commands/ShowCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/ShowCommandHandler.cs @@ -19,6 +19,8 @@ namespace TestFtpServer.Shell.Commands /// public class ShowCommandHandler : IRootCommandInfo { + private readonly IAsyncEnumerable _subCommands; + /// /// Initializes a new instance of the class. /// @@ -28,19 +30,26 @@ public ShowCommandHandler( IpcServiceClient client, IShellStatus status) { - SubCommands = status.ExtendedModuleInfoName + _subCommands = status.ExtendedModuleInfoName .Select(x => new ModuleCommandInfo(client, x)) - .ToList(); + .Concat( + new ICommandInfo[] + { + new ShowConnectionsCommandInfo(client), + }) + .ToList() + .ToAsyncEnumerable(); } /// public string Name { get; } = "show"; /// - public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + public IReadOnlyCollection AlternativeNames { get; } = new[] { "list" }; + /// /// - public IReadOnlyCollection SubCommands { get; } + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) => _subCommands; private class ModuleCommandInfo : IExecutableCommandInfo { @@ -65,8 +74,10 @@ public ModuleCommandInfo( /// public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public async Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/ShowConnectionsCommandInfo.cs b/samples/TestFtpServer.Shell/Commands/ShowConnectionsCommandInfo.cs new file mode 100644 index 00000000..9261503e --- /dev/null +++ b/samples/TestFtpServer.Shell/Commands/ShowConnectionsCommandInfo.cs @@ -0,0 +1,58 @@ +// +// Copyright (c) Fubar Development Junker. All rights reserved. +// + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using JKang.IpcServiceFramework; + +using TestFtpServer.Api; + +namespace TestFtpServer.Shell.Commands +{ + /// + /// Command handler for showing all active connections. + /// + public class ShowConnectionsCommandInfo : IExecutableCommandInfo + { + private readonly IpcServiceClient _client; + + /// + /// Initializes a new instance of the class. + /// + /// The IPC client. + public ShowConnectionsCommandInfo(IpcServiceClient client) + { + _client = client; + } + + /// + public string Name { get; } = "connections"; + + /// + public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + + /// + /// + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); + + /// + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + var connections = await _client + .InvokeAsync(host => host.GetConnections(), cancellationToken) + .ConfigureAwait(false); + + Console.WriteLine("ID \tIs alive?"); + foreach (var connection in connections) + { + Console.WriteLine($"{connection.Id}\t{connection.IsAlive}"); + } + } + } +} diff --git a/samples/TestFtpServer.Shell/Commands/StatusCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/StatusCommandHandler.cs index d613b1cf..1fec358c 100644 --- a/samples/TestFtpServer.Shell/Commands/StatusCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/StatusCommandHandler.cs @@ -37,8 +37,10 @@ public StatusCommandHandler( /// public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public async Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/Commands/StopCommandHandler.cs b/samples/TestFtpServer.Shell/Commands/StopCommandHandler.cs index e30d2983..3a36efe2 100644 --- a/samples/TestFtpServer.Shell/Commands/StopCommandHandler.cs +++ b/samples/TestFtpServer.Shell/Commands/StopCommandHandler.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -38,8 +39,10 @@ public StopCommandHandler( /// public IReadOnlyCollection AlternativeNames { get; } = Array.Empty(); + /// /// - public IReadOnlyCollection SubCommands { get; } = Array.Empty(); + public IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken) + => AsyncEnumerable.Empty(); /// public Task ExecuteAsync(CancellationToken cancellationToken) diff --git a/samples/TestFtpServer.Shell/FtpShellCommandAutoCompletion.cs b/samples/TestFtpServer.Shell/FtpShellCommandAutoCompletion.cs index 27e47652..ace6588c 100644 --- a/samples/TestFtpServer.Shell/FtpShellCommandAutoCompletion.cs +++ b/samples/TestFtpServer.Shell/FtpShellCommandAutoCompletion.cs @@ -5,6 +5,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; +using System.Threading.Tasks; namespace TestFtpServer.Shell { @@ -28,9 +30,10 @@ public FtpShellCommandAutoCompletion( /// /// Gets the executable command for the given text. /// - /// - /// - public IExecutableCommandInfo? GetCommand(string text) + /// The text to find the command for. + /// The cancellation token. + /// The found executable command. + public async ValueTask GetCommandAsync(string text, CancellationToken cancellationToken) { var words = text.Trim().Split( Separators, @@ -41,7 +44,10 @@ public FtpShellCommandAutoCompletion( { var word = words[i]; current = next.FindCommandInfo(word); - next = current.SelectMany(x => x.SubCommands).ToList(); + next = await current.ToAsyncEnumerable() + .SelectMany(x => x.GetSubCommandsAsync(cancellationToken)) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); } if (current.Count != 1) @@ -53,12 +59,11 @@ public FtpShellCommandAutoCompletion( } /// - public string[] GetSuggestions(string? text, int index) + public async Task GetSuggestionsAsync(string? text, int index) { if (string.IsNullOrWhiteSpace(text)) { - return _commands - .Select(x => x.Name).ToArray(); + return _commands.Select(x => x.Name).ToArray(); } var words = text!.Substring(0, index) @@ -70,7 +75,11 @@ public string[] GetSuggestions(string? text, int index) { var word = words[i]; var found = current.FindCommandInfo(word); - current = found.SelectMany(x => x.SubCommands).ToList(); + current = await found + .ToAsyncEnumerable() + .SelectMany(x => x.GetSubCommandsAsync(CancellationToken.None)) + .ToListAsync() + .ConfigureAwait(false); } var lastWord = text.Substring(index).Trim(); @@ -79,11 +88,12 @@ public string[] GetSuggestions(string? text, int index) current = current.FindCommandInfo(lastWord); } - return current + var result = current .Select(x => x.Name) .Concat(current.SelectMany(x => x.AlternativeNames)) .Where(x => x.StartsWith(lastWord, StringComparison.OrdinalIgnoreCase)) .ToArray(); + return result; } /// diff --git a/samples/TestFtpServer.Shell/ICommandInfo.cs b/samples/TestFtpServer.Shell/ICommandInfo.cs index 95084ebd..d6bbebd5 100644 --- a/samples/TestFtpServer.Shell/ICommandInfo.cs +++ b/samples/TestFtpServer.Shell/ICommandInfo.cs @@ -3,6 +3,7 @@ // using System.Collections.Generic; +using System.Threading; namespace TestFtpServer.Shell { @@ -24,6 +25,7 @@ public interface ICommandInfo /// /// Gets the sub-commands. /// - IReadOnlyCollection SubCommands { get; } + /// The cancellation token. + IAsyncEnumerable GetSubCommandsAsync(CancellationToken cancellationToken); } } diff --git a/samples/TestFtpServer.Shell/ServerShell.cs b/samples/TestFtpServer.Shell/ServerShell.cs index 97b42872..1efd8acc 100644 --- a/samples/TestFtpServer.Shell/ServerShell.cs +++ b/samples/TestFtpServer.Shell/ServerShell.cs @@ -31,12 +31,7 @@ public async Task RunAsync(CancellationToken cancellationToken) while (!_status.Closed) { - var readTask = Task.Run( - () => - { - var input = ReadLine.Read("> "); - return Task.FromResult(input); - }); + var readTask = Task.Run(() => ReadLine.ReadAsync("> "), CancellationToken.None); var waitTask = await Task.WhenAny(readTask, Task.Delay(-1, cancellationToken)) .ConfigureAwait(false); @@ -51,7 +46,7 @@ public async Task RunAsync(CancellationToken cancellationToken) continue; } - var handler = _autoCompletionHandler.GetCommand(command); + var handler = await _autoCompletionHandler.GetCommandAsync(command, cancellationToken); if (handler == null) { if (string.IsNullOrEmpty(command)) diff --git a/samples/TestFtpServer.Shell/TestFtpServer.Shell.csproj b/samples/TestFtpServer.Shell/TestFtpServer.Shell.csproj index 78cfa157..72ea5b3e 100644 --- a/samples/TestFtpServer.Shell/TestFtpServer.Shell.csproj +++ b/samples/TestFtpServer.Shell/TestFtpServer.Shell.csproj @@ -1,4 +1,5 @@  + Exe @@ -20,8 +21,8 @@ - - + + diff --git a/samples/TestFtpServer/FtpServerHostApi.cs b/samples/TestFtpServer/FtpServerHostApi.cs index c194a3fb..7d4c2c1f 100644 --- a/samples/TestFtpServer/FtpServerHostApi.cs +++ b/samples/TestFtpServer/FtpServerHostApi.cs @@ -1,12 +1,16 @@ +using System; using System.Collections.Generic; using System.Linq; using System.Threading; using System.Threading.Tasks; using FubarDev.FtpServer; +using FubarDev.FtpServer.Features; +using FubarDev.FtpServer.ServerCommands; using Microsoft.Extensions.Hosting; +using TestFtpServer.Api; using TestFtpServer.ServerInfo; namespace TestFtpServer @@ -77,6 +81,51 @@ public IDictionary> GetSimpleModuleInfo( return output; } + /// + public ICollection GetConnections() + { + var result = new List(); + var connections = ((FtpServer)_ftpServer).GetConnections(); + foreach (var connection in connections) + { + try + { + var keepAliveFeature = connection.Features.Get(); + var ftpConnection = (FtpConnection)connection; + var connectionId = ftpConnection.ConnectionId; + var isAlive = keepAliveFeature.IsAlive; + result.Add( + new FtpConnectionStatus(connectionId) + { + IsAlive = isAlive, + }); + } + catch + { + // Ignore errors. Connection might have been closed. + } + } + + return result; + } + + /// + public async Task CloseConnectionAsync(string connectionId) + { + var ftpConnection = ((FtpServer)_ftpServer) + .GetConnections() + .Cast() + .SingleOrDefault(x => string.Equals(connectionId, x.ConnectionId, StringComparison.OrdinalIgnoreCase)); + if (ftpConnection == null) + { + return; + } + + var serverCommandFeature = ftpConnection.Features.Get(); + await serverCommandFeature.ServerCommandWriter.WriteAsync( + new CloseConnectionServerCommand()); + } + /// public ICollection GetSimpleModules() { diff --git a/src/FubarDev.FtpServer/FtpConnection.cs b/src/FubarDev.FtpServer/FtpConnection.cs index 63f383df..15e6e22d 100644 --- a/src/FubarDev.FtpServer/FtpConnection.cs +++ b/src/FubarDev.FtpServer/FtpConnection.cs @@ -425,6 +425,12 @@ private void Abort() // Dispose all features (if disposable) foreach (var featureItem in Features) { + if (featureItem.Value is FtpConnection) + { + // Never dispose the connection itself. + continue; + } + try { (featureItem.Value as IDisposable)?.Dispose(); diff --git a/third-party/ReadLine/Abstractions/Console2.cs b/third-party/ReadLine/Abstractions/Console2.cs new file mode 100644 index 00000000..0ecc961d --- /dev/null +++ b/third-party/ReadLine/Abstractions/Console2.cs @@ -0,0 +1,41 @@ +// + +using System; + +namespace Internal.ReadLine.Abstractions +{ + internal class Console2 : IConsole + { + public int CursorLeft => Console.CursorLeft; + + public int CursorTop => Console.CursorTop; + + public int BufferWidth => Console.BufferWidth; + + public int BufferHeight => Console.BufferHeight; + + public bool PasswordMode { get; set; } + + public void SetBufferSize(int width, int height) => Console.SetBufferSize(width, height); + + public void SetCursorPosition(int left, int top) + { + if (!PasswordMode) + { + Console.SetCursorPosition(left, top); + } + } + + public void Write(string value) + { + if (PasswordMode) + { + value = new string(default(char), value.Length); + } + + Console.Write(value); + } + + public void WriteLine(string value) => Console.WriteLine(value); + } +} diff --git a/third-party/ReadLine/Abstractions/IConsole.cs b/third-party/ReadLine/Abstractions/IConsole.cs new file mode 100644 index 00000000..a86d5e99 --- /dev/null +++ b/third-party/ReadLine/Abstractions/IConsole.cs @@ -0,0 +1,16 @@ +// + +namespace Internal.ReadLine.Abstractions +{ + internal interface IConsole + { + int CursorLeft { get; } + int CursorTop { get; } + int BufferWidth { get; } + int BufferHeight { get; } + void SetCursorPosition(int left, int top); + void SetBufferSize(int width, int height); + void Write(string value); + void WriteLine(string value); + } +} diff --git a/third-party/ReadLine/IAutoCompleteHandler.cs b/third-party/ReadLine/IAutoCompleteHandler.cs new file mode 100644 index 00000000..ae2dffdc --- /dev/null +++ b/third-party/ReadLine/IAutoCompleteHandler.cs @@ -0,0 +1,12 @@ +// + +using System.Threading.Tasks; + +namespace System +{ + internal interface IAutoCompleteHandler + { + char[] Separators { get; set; } + Task GetSuggestionsAsync(string text, int index); + } +} diff --git a/third-party/ReadLine/KeyHandler.cs b/third-party/ReadLine/KeyHandler.cs new file mode 100644 index 00000000..26be4619 --- /dev/null +++ b/third-party/ReadLine/KeyHandler.cs @@ -0,0 +1,406 @@ +// + +using Internal.ReadLine.Abstractions; + +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; + +namespace Internal.ReadLine +{ + internal class KeyHandler + { + private readonly StringBuilder _text; + private readonly List _history; + private readonly Dictionary> _keyActions; + private readonly IConsole _console; + private int _cursorPos; + private int _cursorLimit; + private int _historyIndex; + private ConsoleKeyInfo _keyInfo; + private string[] _completions; + private int _completionStart; + private int _completionsIndex; + + private bool IsStartOfLine() => _cursorPos == 0; + + private bool IsEndOfLine() => _cursorPos == _cursorLimit; + + private bool IsStartOfBuffer() => _console.CursorLeft == 0; + + private bool IsEndOfBuffer() => _console.CursorLeft == _console.BufferWidth - 1; + private bool IsInAutoCompleteMode() => _completions != null; + + private Task MoveCursorLeft() + { + if (IsStartOfLine()) + { + return Task.CompletedTask; + } + + if (IsStartOfBuffer()) + { + _console.SetCursorPosition(_console.BufferWidth - 1, _console.CursorTop - 1); + } + else + { + _console.SetCursorPosition(_console.CursorLeft - 1, _console.CursorTop); + } + + _cursorPos--; + return Task.CompletedTask; + } + + private async Task MoveCursorHome() + { + while (!IsStartOfLine()) + { + await MoveCursorLeft().ConfigureAwait(false); + } + } + + private string BuildKeyInput() + { + return (_keyInfo.Modifiers != ConsoleModifiers.Control && _keyInfo.Modifiers != ConsoleModifiers.Shift) ? + _keyInfo.Key.ToString() : _keyInfo.Modifiers.ToString() + _keyInfo.Key.ToString(); + } + + private Task MoveCursorRight() + { + if (IsEndOfLine()) + { + return Task.CompletedTask; + } + + if (IsEndOfBuffer()) + { + _console.SetCursorPosition(0, _console.CursorTop + 1); + } + else + { + _console.SetCursorPosition(_console.CursorLeft + 1, _console.CursorTop); + } + + _cursorPos++; + + return Task.CompletedTask; + } + + private async Task MoveCursorEnd() + { + while (!IsEndOfLine()) + { + await MoveCursorRight().ConfigureAwait(false); + } + } + + private async Task ClearLine() + { + await MoveCursorEnd().ConfigureAwait(false); + while (!IsStartOfLine()) + { + await Backspace().ConfigureAwait(false); + } + } + + private async Task WriteNewString(string str) + { + await ClearLine().ConfigureAwait(false); + foreach (var character in str) + { + WriteChar(character); + } + } + + private void WriteString(string str) + { + foreach (var character in str) + { + WriteChar(character); + } + } + + private void WriteChar() => WriteChar(_keyInfo.KeyChar); + + private void WriteChar(char c) + { + if (IsEndOfLine()) + { + _text.Append(c); + _console.Write(c.ToString()); + _cursorPos++; + } + else + { + var left = _console.CursorLeft; + var top = _console.CursorTop; + var str = _text.ToString().Substring(_cursorPos); + _text.Insert(_cursorPos, c); + _console.Write(c.ToString() + str); + _console.SetCursorPosition(left, top); + MoveCursorRight(); + } + + _cursorLimit++; + } + + private async Task Backspace() + { + if (IsStartOfLine()) + { + return; + } + + await MoveCursorLeft().ConfigureAwait(false); + var index = _cursorPos; + _text.Remove(index, 1); + var replacement = _text.ToString().Substring(index); + var left = _console.CursorLeft; + var top = _console.CursorTop; + _console.Write($"{replacement} "); + _console.SetCursorPosition(left, top); + _cursorLimit--; + } + + private Task Delete() + { + if (IsEndOfLine()) + { + return Task.CompletedTask; + } + + var index = _cursorPos; + _text.Remove(index, 1); + var replacement = _text.ToString().Substring(index); + var left = _console.CursorLeft; + var top = _console.CursorTop; + _console.Write($"{replacement} "); + _console.SetCursorPosition(left, top); + _cursorLimit--; + return Task.CompletedTask; + } + + private async Task TransposeChars() + { + // local helper functions + bool AlmostEndOfLine() => (_cursorLimit - _cursorPos) == 1; + static int IncrementIf(Func expression, int index) => expression() ? index + 1 : index; + static int DecrementIf(Func expression, int index) => expression() ? index - 1 : index; + + if (IsStartOfLine()) { return; } + + var firstIdx = DecrementIf(IsEndOfLine, _cursorPos - 1); + var secondIdx = DecrementIf(IsEndOfLine, _cursorPos); + + var secondChar = _text[secondIdx]; + _text[secondIdx] = _text[firstIdx]; + _text[firstIdx] = secondChar; + + var left = IncrementIf(AlmostEndOfLine, _console.CursorLeft); + var cursorPosition = IncrementIf(AlmostEndOfLine, _cursorPos); + + await WriteNewString(_text.ToString()).ConfigureAwait(false); + + _console.SetCursorPosition(left, _console.CursorTop); + _cursorPos = cursorPosition; + + await MoveCursorRight().ConfigureAwait(false); + } + + private async Task StartAutoComplete() + { + while (_cursorPos > _completionStart) + { + await Backspace().ConfigureAwait(false); + } + + _completionsIndex = 0; + + WriteString(_completions[_completionsIndex]); + } + + private async Task NextAutoComplete() + { + while (_cursorPos > _completionStart) + { + await Backspace().ConfigureAwait(false); + } + + _completionsIndex++; + + if (_completionsIndex == _completions.Length) + { + _completionsIndex = 0; + } + + WriteString(_completions[_completionsIndex]); + } + + private async Task PreviousAutoComplete() + { + while (_cursorPos > _completionStart) + { + await Backspace().ConfigureAwait(false); + } + + _completionsIndex--; + + if (_completionsIndex == -1) + { + _completionsIndex = _completions.Length - 1; + } + + WriteString(_completions[_completionsIndex]); + } + + private Task PrevHistory() + { + if (_historyIndex > 0) + { + _historyIndex--; + return WriteNewString(_history[_historyIndex]); + } + + return Task.CompletedTask; + } + + private Task NextHistory() + { + if (_historyIndex < _history.Count) + { + _historyIndex++; + if (_historyIndex == _history.Count) + { + return ClearLine(); + } + + return WriteNewString(_history[_historyIndex]); + } + + return Task.CompletedTask; + } + + private void ResetAutoComplete() + { + _completions = null; + _completionsIndex = 0; + } + + public string Text => _text.ToString(); + + public KeyHandler(IConsole console, List history, IAutoCompleteHandler autoCompleteHandler) + { + _console = console; + + _history = history ?? new List(); + _historyIndex = _history.Count; + _text = new StringBuilder(); + _keyActions = new Dictionary>(); + + _keyActions["LeftArrow"] = MoveCursorLeft; + _keyActions["Home"] = MoveCursorHome; + _keyActions["End"] = MoveCursorEnd; + _keyActions["ControlA"] = MoveCursorHome; + _keyActions["ControlB"] = MoveCursorLeft; + _keyActions["RightArrow"] = MoveCursorRight; + _keyActions["ControlF"] = MoveCursorRight; + _keyActions["ControlE"] = MoveCursorEnd; + _keyActions["Backspace"] = Backspace; + _keyActions["Delete"] = Delete; + _keyActions["ControlD"] = Delete; + _keyActions["ControlH"] = Backspace; + _keyActions["ControlL"] = ClearLine; + _keyActions["Escape"] = ClearLine; + _keyActions["UpArrow"] = PrevHistory; + _keyActions["ControlP"] = PrevHistory; + _keyActions["DownArrow"] = NextHistory; + _keyActions["ControlN"] = NextHistory; + _keyActions["ControlU"] = async () => + { + while (!IsStartOfLine()) + { + await Backspace().ConfigureAwait(false); + } + }; + _keyActions["ControlK"] = async () => + { + var pos = _cursorPos; + await MoveCursorEnd().ConfigureAwait(false); + while (_cursorPos > pos) + { + await Backspace().ConfigureAwait(false); + } + }; + _keyActions["ControlW"] = async () => + { + while (!IsStartOfLine() && _text[_cursorPos - 1] != ' ') + { + await Backspace().ConfigureAwait(false); + } + }; + _keyActions["ControlT"] = TransposeChars; + + _keyActions["Tab"] = async () => + { + if (IsInAutoCompleteMode()) + { + await NextAutoComplete().ConfigureAwait(false); + } + else + { + if (autoCompleteHandler == null || !IsEndOfLine()) + { + return; + } + + var text = _text.ToString(); + + _completionStart = text.LastIndexOfAny(autoCompleteHandler.Separators); + _completionStart = _completionStart == -1 ? 0 : _completionStart + 1; + + _completions = await autoCompleteHandler.GetSuggestionsAsync(text, _completionStart) + .ConfigureAwait(false); + _completions = _completions?.Length == 0 ? null : _completions; + + if (_completions == null) + { + return; + } + + await StartAutoComplete().ConfigureAwait(false); + } + }; + + _keyActions["ShiftTab"] = () => + { + if (IsInAutoCompleteMode()) + { + return PreviousAutoComplete(); + } + + return Task.CompletedTask; + }; + } + + public Task Handle(ConsoleKeyInfo keyInfo) + { + _keyInfo = keyInfo; + + // If in auto complete mode and Tab wasn't pressed + if (IsInAutoCompleteMode() && _keyInfo.Key != ConsoleKey.Tab) + { + ResetAutoComplete(); + } + + if (_keyActions.TryGetValue(BuildKeyInput(), out var action)) + { + return action(); + } + + WriteChar(); + + return Task.CompletedTask; + } + } +} diff --git a/third-party/ReadLine/LICENSE b/third-party/ReadLine/LICENSE new file mode 100644 index 00000000..06a704d3 --- /dev/null +++ b/third-party/ReadLine/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2017 Toni Solarin-Sodara + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/third-party/ReadLine/ReadLine.cs b/third-party/ReadLine/ReadLine.cs new file mode 100644 index 00000000..68c6423b --- /dev/null +++ b/third-party/ReadLine/ReadLine.cs @@ -0,0 +1,67 @@ +// + +using Internal.ReadLine; +using Internal.ReadLine.Abstractions; + +using System.Collections.Generic; +using System.Threading.Tasks; + +namespace System +{ + internal static class ReadLine + { + private static List _history; + + static ReadLine() + { + _history = new List(); + } + + public static void AddHistory(params string[] text) => _history.AddRange(text); + public static List GetHistory() => _history; + public static void ClearHistory() => _history = new List(); + public static bool HistoryEnabled { get; set; } + public static IAutoCompleteHandler AutoCompletionHandler { private get; set; } + + public static async Task ReadAsync(string prompt = "", string @default = "") + { + Console.Write(prompt); + var keyHandler = new KeyHandler(new Console2(), _history, AutoCompletionHandler); + var text = await GetTextAsync(keyHandler).ConfigureAwait(false); + + if (string.IsNullOrWhiteSpace(text) && !string.IsNullOrWhiteSpace(@default)) + { + text = @default; + } + else + { + if (HistoryEnabled) + { + _history.Add(text); + } + } + + return text; + } + + public static Task ReadPasswordAsync(string prompt = "") + { + Console.Write(prompt); + var keyHandler = new KeyHandler(new Console2() { PasswordMode = true }, null, null); + return GetTextAsync(keyHandler); + } + + private static async Task GetTextAsync(KeyHandler keyHandler) + { + var keyInfo = Console.ReadKey(true); + while (keyInfo.Key != ConsoleKey.Enter) + { + await keyHandler.Handle(keyInfo).ConfigureAwait(false); + keyInfo = Console.ReadKey(true); + } + + Console.WriteLine(); + return keyHandler.Text; + } + } +} diff --git a/third-party/ReadLine/ReadLine.projitems b/third-party/ReadLine/ReadLine.projitems new file mode 100644 index 00000000..2574921a --- /dev/null +++ b/third-party/ReadLine/ReadLine.projitems @@ -0,0 +1,18 @@ + + + + $(MSBuildAllProjects);$(MSBuildThisFileFullPath) + true + f68f448f-e8a4-4536-9a83-12018b6294b0 + + + ReadLine + + + + + + + + + \ No newline at end of file diff --git a/third-party/ReadLine/ReadLine.shproj b/third-party/ReadLine/ReadLine.shproj new file mode 100644 index 00000000..f84d2fa0 --- /dev/null +++ b/third-party/ReadLine/ReadLine.shproj @@ -0,0 +1,13 @@ + + + + f68f448f-e8a4-4536-9a83-12018b6294b0 + 14.0 + + + + + + + +