From 2cf62acc884b23dd5c6ed114c48fc2ff473e845c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:26:28 +0000 Subject: [PATCH 1/4] chore(deps): upgrade TelnetNegotiationCore 2.5.3 -> 4.6.1 - SendNAWS was removed in TNC 2.8.0; report the window size through NAWSProtocol.SendWindowSizeAsync, clamped to RFC 1073's 0..65535 (TNC now throws outside it instead of wrapping). - TNC 2.8.0 stopped naming the client: TTYPE would answer UNKNOWN. Set WithClientIdentity("SharpClient") with TERMINAL_TYPE XTERM and MTTS ANSI|UTF-8|256 colours (truecolour is not rendered, so not claimed). - Bump directly referenced Microsoft.Extensions.* to 10.0.14, the floor TNC 4.6.1 depends on, so restore does not report a downgrade (NU1605). - TNC 4.0 dropped FSharp.Core, so Android Release AOT is re-enabled. Trimming stays off until a trimmed APK is checked on a device. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TbSP3xxcS6oaZwo3C --- src/SharpClient.App/SharpClient.App.csproj | 26 ++- .../Connection/TelnetConnection.cs | 22 ++- src/SharpClient.Core/SharpClient.Core.csproj | 4 +- src/SharpClient.UI/SharpClient.UI.csproj | 2 +- src/SharpClient.Web/SharpClient.Web.csproj | 2 +- .../Connection/ClientIdentityAndNawsTests.cs | 155 ++++++++++++++++++ .../SharpClient.Tests.csproj | 4 +- 7 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj index 5c4764d..6078f6f 100644 --- a/src/SharpClient.App/SharpClient.App.csproj +++ b/src/SharpClient.App/SharpClient.App.csproj @@ -48,19 +48,14 @@ 10.0.17763.0 - + - false - false false @@ -119,8 +114,11 @@ 2.5.3 fixes client NAWS (RFC 1073): the client now offers WILL NAWS and only sends an SB NAWS window-size update once the server enables it (DO NAWS). Previously it sent an unsolicited SB NAWS that made SharpMUSH swallow the next line, so typed logins - intermittently bounced back to the login screen. --> - + intermittently bounced back to the login screen. + 4.6.1: SendNAWS moved to NAWSProtocol.SendWindowSizeAsync, the client names itself + only through WithClientIdentity (TelnetConnection sets it), and FSharp.Core is gone, + which is what let Android Release AOT come back on. --> + diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index 163453d..c486a6d 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -3,6 +3,7 @@ using TelnetNegotiationCore.Builders; using TelnetNegotiationCore.Interpreters; using TelnetNegotiationCore.Models; +using TelnetNegotiationCore.Protocols; namespace SharpClient.Core.Connection; @@ -32,6 +33,15 @@ public sealed class TelnetConnection : ITelnetConnection // long-running (e.g. in-car) connection, without meaningful battery cost. private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45); + // TNC names nobody unless told to: without this, TTYPE answers UNKNOWN and NEW-ENVIRON sends no + // CLIENT_NAME. MTTS claims only what AnsiParser renders: 16 and 256 colours over UTF-8. Truecolour + // is parsed but drawn as the default colour, so claiming it would make servers send colours we drop. + private static ClientIdentity Identity { get; } = new("SharpClient") + { + TerminalType = "XTERM", + Mtts = MttsCapabilities.Ansi | MttsCapabilities.Utf8 | MttsCapabilities.Colors256, + }; + public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null) { _factory = factory; @@ -139,7 +149,16 @@ public async Task SendNawsAsync(int width, int height) return; } - await _interpreter.SendNAWS((short)width, (short)height); + var naws = _interpreter.PluginManager?.GetPlugin(); + if (naws is null) + { + return; + } + + // RFC 1073 fields are 16-bit unsigned and TNC throws outside that range rather than wrapping. + await naws.SendWindowSizeAsync( + Math.Clamp(width, 0, NAWSProtocol.MaxWindowDimension), + Math.Clamp(height, 0, NAWSProtocol.MaxWindowDimension)); } public async Task DisconnectAsync() @@ -294,6 +313,7 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT onMSSP: OnMsspAsync, onMXPEnabled: OnMxpEnabledAsync, charsetOrder: CharsetPreference) + .WithClientIdentity(Identity) .BuildAndStartAsync(client, cancellationToken); _client = client; diff --git a/src/SharpClient.Core/SharpClient.Core.csproj b/src/SharpClient.Core/SharpClient.Core.csproj index 6b83780..d58c403 100644 --- a/src/SharpClient.Core/SharpClient.Core.csproj +++ b/src/SharpClient.Core/SharpClient.Core.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/src/SharpClient.UI/SharpClient.UI.csproj b/src/SharpClient.UI/SharpClient.UI.csproj index 5e319a2..7eb2d72 100644 --- a/src/SharpClient.UI/SharpClient.UI.csproj +++ b/src/SharpClient.UI/SharpClient.UI.csproj @@ -10,7 +10,7 @@ - + diff --git a/src/SharpClient.Web/SharpClient.Web.csproj b/src/SharpClient.Web/SharpClient.Web.csproj index 2d757e8..a8630e5 100644 --- a/src/SharpClient.Web/SharpClient.Web.csproj +++ b/src/SharpClient.Web/SharpClient.Web.csproj @@ -12,7 +12,7 @@ - + diff --git a/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs b/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs new file mode 100644 index 0000000..c347d48 --- /dev/null +++ b/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs @@ -0,0 +1,155 @@ +using System.Text; +using Microsoft.Extensions.DependencyInjection; +using SharpClient.Core.Connection; +using TelnetNegotiationCore.Builders; + +namespace SharpClient.Tests.Connection; + +/// +/// Wire-level checks for what TNC 2.8+ stopped doing on our behalf: naming the client in TTYPE, and +/// reporting the window size through the NAWS plugin once the server has asked for it. +/// +public sealed class ClientIdentityAndNawsTests +{ + private const byte Iac = 255; + private const byte Do = 253; + private const byte Sb = 250; + private const byte Se = 240; + private const byte Ttype = 24; + private const byte Naws = 31; + private const byte TtypeIs = 0; + private const byte TtypeSend = 1; + + private ServiceProvider? _serviceProvider; + + private ITelnetInterpreterFactory CreateFactory() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddTelnetClient(); + _serviceProvider = services.BuildServiceProvider(); + return _serviceProvider.GetRequiredService(); + } + + [After(Test)] + public async Task TearDown() + { + if (_serviceProvider is not null) + { + await _serviceProvider.DisposeAsync(); + _serviceProvider = null; + } + } + + [Test] + public async Task TerminalTypeNamesSharpClientThenXtermThenMtts() + { + await using var server = new LoopbackServer(); + await using var connection = new TelnetConnection(CreateFactory()); + + var accept = server.AcceptAsync(); + await connection.ConnectAsync("127.0.0.1", server.Port); + await accept; + + await server.SendBytesAsync([Iac, Do, Ttype]); + var names = new List(); + for (var i = 0; i < 3; i++) + { + await server.SendBytesAsync([Iac, Sb, Ttype, TtypeSend, Iac, Se]); + names.Add(await ReadTtypeIsAsync(server)); + } + + await Assert.That(names[0]).IsEqualTo("SharpClient"); + await Assert.That(names[1]).IsEqualTo("XTERM"); + // MTTS 1 (ANSI) | 4 (UTF-8) | 8 (256 colours) from SharpClient, plus whatever the library + // observes about its own stack; truecolour (256) must not be claimed. + await Assert.That(names[2]).StartsWith("MTTS "); + var bits = int.Parse(names[2]["MTTS ".Length..], System.Globalization.CultureInfo.InvariantCulture); + await Assert.That(bits & 13).IsEqualTo(13); + await Assert.That(bits & 256).IsEqualTo(0); + } + + [Test] + public async Task NawsIsSentAfterDoNawsAndClampedToSixteenBits() + { + await using var server = new LoopbackServer(); + await using var connection = new TelnetConnection(CreateFactory()); + + var accept = server.AcceptAsync(); + await connection.ConnectAsync("127.0.0.1", server.Port); + await accept; + + await server.SendBytesAsync([Iac, Do, Naws]); + // Let the client process DO NAWS before reporting; a report sent earlier is dropped (RFC 1073). + await DrainAsync(server, TimeSpan.FromMilliseconds(300)); + + await connection.SendNawsAsync(120, 40); + var report = await ReadUntilAsync(server, [Iac, Sb, Naws], TimeSpan.FromSeconds(3), requireEnd: true); + await Assert.That(ContainsSequence(report, [Iac, Sb, Naws, 0, 120, 0, 40, Iac, Se])).IsTrue(); + + // 70000 does not fit RFC 1073's unsigned 16-bit field; TNC throws, so we clamp to 65535, + // whose 0xFF bytes are doubled on the wire. + await connection.SendNawsAsync(70_000, 24); + report = await ReadUntilAsync(server, [Iac, Sb, Naws], TimeSpan.FromSeconds(3), requireEnd: true); + await Assert.That(ContainsSequence(report, [Iac, Sb, Naws, Iac, Iac, Iac, Iac, 0, 24, Iac, Se])).IsTrue(); + } + + private static async Task ReadTtypeIsAsync(LoopbackServer server) + { + var bytes = await ReadUntilAsync(server, [Iac, Sb, Ttype, TtypeIs], TimeSpan.FromSeconds(3), requireEnd: true); + var start = IndexOf(bytes, [Iac, Sb, Ttype, TtypeIs]) + 4; + var end = IndexOf(bytes[start..], [Iac, Se]) + start; + return Encoding.ASCII.GetString(bytes, start, end - start); + } + + private static async Task ReadUntilAsync( + LoopbackServer server, byte[] marker, TimeSpan timeout, bool requireEnd = false) + { + var buffer = new List(); + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + var at = IndexOf([.. buffer], marker); + if (at >= 0 && (!requireEnd || IndexOf([.. buffer.Skip(at + marker.Length)], [Iac, Se]) >= 0)) + { + return [.. buffer]; + } + + try + { + buffer.AddRange(await server.ReadAvailableBytesAsync(TimeSpan.FromMilliseconds(200))); + } + catch (OperationCanceledException) { } + } + + return [.. buffer]; + } + + private static async Task DrainAsync(LoopbackServer server, TimeSpan window) + { + var deadline = DateTime.UtcNow + window; + while (DateTime.UtcNow < deadline) + { + try + { + await server.ReadAvailableBytesAsync(TimeSpan.FromMilliseconds(100)); + } + catch (OperationCanceledException) { } + } + } + + private static bool ContainsSequence(byte[] haystack, byte[] needle) => IndexOf(haystack, needle) >= 0; + + private static int IndexOf(byte[] haystack, byte[] needle) + { + for (var i = 0; i + needle.Length <= haystack.Length; i++) + { + if (haystack.AsSpan(i, needle.Length).SequenceEqual(needle)) + { + return i; + } + } + + return -1; + } +} diff --git a/tests/SharpClient.Tests/SharpClient.Tests.csproj b/tests/SharpClient.Tests/SharpClient.Tests.csproj index f9fae2b..a413c0b 100644 --- a/tests/SharpClient.Tests/SharpClient.Tests.csproj +++ b/tests/SharpClient.Tests/SharpClient.Tests.csproj @@ -12,8 +12,8 @@ - - + + From e4b5878a8d13e263493859635eb79009b18267e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:28:27 +0000 Subject: [PATCH 2/4] fix(connection): hold the window size until DO NAWS and across reconnects TNC drops a NAWS report made before the server's DO NAWS, as RFC 1073 requires, and nothing reported it again: the size the UI measured at connect and the size carried over an auto-reconnect never reached the server until the next resize. WindowSizeProtocol keeps the last size and reports it when NAWS is agreed, the way SharpMUTerm does. The default protocol set is spelled out because AddDefaultMUDProtocols always registers the base NAWSProtocol. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TbSP3xxcS6oaZwo3C --- .../Connection/TelnetConnection.cs | 30 ++++---- .../Connection/WindowSizeProtocol.cs | 75 +++++++++++++++++++ .../Connection/ClientIdentityAndNawsTests.cs | 25 ++++++- 3 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 src/SharpClient.Core/Connection/WindowSizeProtocol.cs diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index c486a6d..c2c1624 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -21,6 +21,7 @@ public sealed class TelnetConnection : ITelnetConnection private string? _host; private int _port; private bool _intentionalDisconnect; + private (int Width, int Height)? _windowSize; private CancellationTokenSource? _heartbeatCts; private Task? _heartbeatTask; @@ -144,21 +145,17 @@ public async Task SendAsync(string line) public async Task SendNawsAsync(int width, int height) { + // Kept across reconnects: each new interpreter reports it once the server sends DO NAWS. + _windowSize = (width, height); if (_interpreter is null) { return; } - var naws = _interpreter.PluginManager?.GetPlugin(); - if (naws is null) + if (_interpreter.PluginManager?.GetPlugin() is WindowSizeProtocol naws) { - return; + await naws.ReportAsync(width, height); } - - // RFC 1073 fields are 16-bit unsigned and TNC throws outside that range rather than wrapping. - await naws.SendWindowSizeAsync( - Math.Clamp(width, 0, NAWSProtocol.MaxWindowDimension), - Math.Clamp(height, 0, NAWSProtocol.MaxWindowDimension)); } public async Task DisconnectAsync() @@ -306,14 +303,21 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT TrySetTcpUserTimeout(client.Client); await client.ConnectAsync(host, port, cancellationToken); + // The protocols AddDefaultMUDProtocols registers (NAWS, GMCP, MSDP, MSSP, TTYPE, CHARSET, EOR, + // SGA, MXP), spelled out so NAWS can be WindowSizeProtocol: the helper always adds the base + // NAWSProtocol, and a second plugin for the same option is rejected. var (interpreter, readTask) = await _factory.CreateBuilder() .OnSubmit(OnSubmitAsync) - .AddDefaultMUDProtocols( - onGMCPMessage: OnGmcpMessageAsync, - onMSSP: OnMsspAsync, - onMXPEnabled: OnMxpEnabledAsync, - charsetOrder: CharsetPreference) .WithClientIdentity(Identity) + .AddPlugin(new WindowSizeProtocol(_windowSize)) + .AddPlugin().OnGMCPMessage(OnGmcpMessageAsync) + .AddPlugin() + .AddPlugin().OnMSSP(OnMsspAsync) + .AddPlugin() + .AddPlugin().WithCharsetOrder(CharsetPreference) + .AddPlugin() + .AddPlugin() + .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) .BuildAndStartAsync(client, cancellationToken); _client = client; diff --git a/src/SharpClient.Core/Connection/WindowSizeProtocol.cs b/src/SharpClient.Core/Connection/WindowSizeProtocol.cs new file mode 100644 index 0000000..2ec5d1c --- /dev/null +++ b/src/SharpClient.Core/Connection/WindowSizeProtocol.cs @@ -0,0 +1,75 @@ +using TelnetNegotiationCore.Protocols; + +namespace SharpClient.Core.Connection; + +/// +/// NAWS that keeps the latest window size until the server enables NAWS, then reports it. The base +/// plugin drops a report made before DO NAWS (RFC 1073 forbids sending it), and nothing would +/// report again until the next resize: the size the UI measured at connect, or the one carried over an +/// auto-reconnect, never reached the server. Registers under 's type, so +/// GetPlugin<NAWSProtocol>() finds it. +/// +internal sealed class WindowSizeProtocol : NAWSProtocol +{ + private readonly SemaphoreSlim _gate = new(1, 1); + private (int Width, int Height)? _requested; + private (int Width, int Height)? _reported; + + public WindowSizeProtocol((int Width, int Height)? initial = null) => + _requested = initial is { } size ? (Clamp(size.Width), Clamp(size.Height)) : null; + + public async ValueTask ReportAsync(int width, int height) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + _requested = (Clamp(width), Clamp(height)); + await ReportIfReadyAsync().ConfigureAwait(false); + } + finally + { + _gate.Release(); + } + } + + protected override async ValueTask OnNegotiationChangedAsync(bool isNegotiated) + { + await _gate.WaitAsync().ConfigureAwait(false); + try + { + // A fresh agreement needs its own initial report even if the size has not changed. + if (!isNegotiated) + { + _reported = null; + } + else + { + await ReportIfReadyAsync().ConfigureAwait(false); + } + } + finally + { + _gate.Release(); + } + } + + protected override async ValueTask OnDisposeAsync() + { + await base.OnDisposeAsync().ConfigureAwait(false); + _gate.Dispose(); + } + + // RFC 1073 fields are 16-bit unsigned; TNC throws outside that range rather than wrapping. + private static int Clamp(int value) => Math.Clamp(value, 0, MaxWindowDimension); + + private async ValueTask ReportIfReadyAsync() + { + if (!IsNegotiated || !WindowSizeReportingEnabled || _requested is not { } size || _reported == size) + { + return; + } + + await SendWindowSizeAsync(size.Width, size.Height).ConfigureAwait(false); + _reported = size; + } +} diff --git a/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs b/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs index c347d48..a30c3fc 100644 --- a/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs +++ b/tests/SharpClient.Tests/Connection/ClientIdentityAndNawsTests.cs @@ -7,7 +7,7 @@ namespace SharpClient.Tests.Connection; /// /// Wire-level checks for what TNC 2.8+ stopped doing on our behalf: naming the client in TTYPE, and -/// reporting the window size through the NAWS plugin once the server has asked for it. +/// reporting the window size once the server has asked for it, including a size given earlier. /// public sealed class ClientIdentityAndNawsTests { @@ -69,6 +69,26 @@ public async Task TerminalTypeNamesSharpClientThenXtermThenMtts() await Assert.That(bits & 256).IsEqualTo(0); } + [Test] + public async Task SizeReportedBeforeDoNawsIsSentWhenTheServerAsks() + { + await using var server = new LoopbackServer(); + await using var connection = new TelnetConnection(CreateFactory()); + + var accept = server.AcceptAsync(); + await connection.ConnectAsync("127.0.0.1", server.Port); + await accept; + + // The UI measures the screen before the server gets round to DO NAWS. RFC 1073 forbids + // sending it yet, so it must be held, not dropped. + await connection.SendNawsAsync(100, 30); + await DrainAsync(server, TimeSpan.FromMilliseconds(300)); + + await server.SendBytesAsync([Iac, Do, Naws]); + var report = await ReadUntilAsync(server, [Iac, Sb, Naws], TimeSpan.FromSeconds(3), requireEnd: true); + await Assert.That(ContainsSequence(report, [Iac, Sb, Naws, 0, 100, 0, 30, Iac, Se])).IsTrue(); + } + [Test] public async Task NawsIsSentAfterDoNawsAndClampedToSixteenBits() { @@ -80,14 +100,13 @@ public async Task NawsIsSentAfterDoNawsAndClampedToSixteenBits() await accept; await server.SendBytesAsync([Iac, Do, Naws]); - // Let the client process DO NAWS before reporting; a report sent earlier is dropped (RFC 1073). await DrainAsync(server, TimeSpan.FromMilliseconds(300)); await connection.SendNawsAsync(120, 40); var report = await ReadUntilAsync(server, [Iac, Sb, Naws], TimeSpan.FromSeconds(3), requireEnd: true); await Assert.That(ContainsSequence(report, [Iac, Sb, Naws, 0, 120, 0, 40, Iac, Se])).IsTrue(); - // 70000 does not fit RFC 1073's unsigned 16-bit field; TNC throws, so we clamp to 65535, + // 70000 does not fit RFC 1073's unsigned 16-bit field; TNC throws, so it is clamped to 65535, // whose 0xFF bytes are doubled on the wire. await connection.SendNawsAsync(70_000, 24); report = await ReadUntilAsync(server, [Iac, Sb, Naws], TimeSpan.FromSeconds(3), requireEnd: true); From e95c04c82e2d1ad6f252ce4531aae9423184ab28 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:30:44 +0000 Subject: [PATCH 3/4] fix(build): keep Android AOT off; .NET for Android requires trimming for AOT (XA1030) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TbSP3xxcS6oaZwo3C --- src/SharpClient.App/SharpClient.App.csproj | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj index 6078f6f..c185067 100644 --- a/src/SharpClient.App/SharpClient.App.csproj +++ b/src/SharpClient.App/SharpClient.App.csproj @@ -48,14 +48,15 @@ 10.0.17763.0 - + + false + false false @@ -117,7 +118,7 @@ intermittently bounced back to the login screen. 4.6.1: SendNAWS moved to NAWSProtocol.SendWindowSizeAsync, the client names itself only through WithClientIdentity (TelnetConnection sets it), and FSharp.Core is gone, - which is what let Android Release AOT come back on. --> + so AOT and trimming can be turned back on once a trimmed APK is checked on a device. --> From 974332996f2b23b5e421bd4efb833d2849a0b8e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 17:37:42 +0000 Subject: [PATCH 4/4] feat(rendering): 24-bit colour, inline MXP/Pueblo images, Pueblo via TNC - SGR 38;2 / 48;2 now render as the given colour (AnsiColorKind.Rgb) instead of the default, and MTTS claims truecolour. - MXP on a Secure line and Pueblo render as in the output, inside a / link when one is open. Only http(s) and data:image URLs are accepted; images load with no-referrer. - Pueblo is detected by TNC's PuebloProtocol (the server's hello and its start sequence, both consumed) instead of a substring match on any line, which let a player's pose switch the client into HTML mode and sent PUEBLOCLIENT as a game command. A server that agreed MXP stays on MXP, as SharpMUTerm does. MXP/Pueblo state resets when the connection drops, so a reconnect negotiates it again. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Rqqt7TbSP3xxcS6oaZwo3C --- .../Connection/ITelnetConnection.cs | 6 + .../Connection/TelnetConnection.cs | 45 +++++++- src/SharpClient.Core/Rendering/AnsiColor.cs | 6 + src/SharpClient.Core/Rendering/AnsiParser.cs | 108 +++++++++++++++++- src/SharpClient.Core/Rendering/ImageSource.cs | 95 +++++++++++++++ .../Rendering/MxpParserState.cs | 2 +- .../Rendering/SegmentStyle.cs | 16 +-- .../Rendering/StyledSegment.cs | 6 + src/SharpClient.Core/Sessions/Session.cs | 28 +++-- .../Components/OutputView.razor | 16 ++- src/SharpClient.UI/wwwroot/app.css | 15 +++ .../Connection/ClientIdentityAndNawsTests.cs | 7 +- .../Rendering/AnsiParserTests.cs | 38 +++++- .../Rendering/InlineImageTests.cs | 101 ++++++++++++++++ .../Rendering/SegmentStyleTests.cs | 12 ++ .../Sessions/FakeTelnetConnection.cs | 3 + .../Sessions/SessionMxpTests.cs | 38 +++++- .../OutputViewLinkTests.cs | 31 +++++ 18 files changed, 533 insertions(+), 40 deletions(-) create mode 100644 src/SharpClient.Core/Rendering/ImageSource.cs create mode 100644 tests/SharpClient.Tests/Rendering/InlineImageTests.cs diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs index 46f26a9..e2d3b90 100644 --- a/src/SharpClient.Core/Connection/ITelnetConnection.cs +++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs @@ -13,6 +13,12 @@ public interface ITelnetConnection : IAsyncDisposable /// Raised when MXP (telnet option 91) negotiation completes successfully. public event Action? MxpEnabled; + /// + /// Raised when the server switches this connection into Pueblo HTML mode, which it does only after + /// its own hello and our PUEBLOCLIENT answer. A hello quoted in a player's text starts nothing. + /// + public event Action? PuebloEnabled; + public ConnectionState State { get; } public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default); diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index c2c1624..77f8560 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -35,12 +35,11 @@ public sealed class TelnetConnection : ITelnetConnection private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45); // TNC names nobody unless told to: without this, TTYPE answers UNKNOWN and NEW-ENVIRON sends no - // CLIENT_NAME. MTTS claims only what AnsiParser renders: 16 and 256 colours over UTF-8. Truecolour - // is parsed but drawn as the default colour, so claiming it would make servers send colours we drop. + // CLIENT_NAME. MTTS claims what AnsiParser renders: 16, 256 and 24-bit colour over UTF-8. private static ClientIdentity Identity { get; } = new("SharpClient") { TerminalType = "XTERM", - Mtts = MttsCapabilities.Ansi | MttsCapabilities.Utf8 | MttsCapabilities.Colors256, + Mtts = MttsCapabilities.Ansi | MttsCapabilities.Utf8 | MttsCapabilities.Colors256 | MttsCapabilities.Truecolor, }; public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null) @@ -54,6 +53,7 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec public event Action? GmcpReceived; public event Action? NegotiationReceived; public event Action? MxpEnabled; + public event Action? PuebloEnabled; public ConnectionState State { get; private set; } = ConnectionState.Disconnected; @@ -305,7 +305,14 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT // The protocols AddDefaultMUDProtocols registers (NAWS, GMCP, MSDP, MSSP, TTYPE, CHARSET, EOR, // SGA, MXP), spelled out so NAWS can be WindowSizeProtocol: the helper always adds the base - // NAWSProtocol, and a second plugin for the same option is rejected. + // NAWSProtocol, and a second plugin for the same option is rejected. Plus Pueblo. + // Kept as locals because the server's banner (and so the Pueblo hello) can arrive before + // BuildAndStartAsync returns and _interpreter is set. + var mxp = new MXPProtocol(); + mxp.OnMXPEnabled(OnMxpEnabledAsync); + var pueblo = new PuebloProtocol(); + pueblo.OnPuebloOffered(() => OnPuebloOfferedAsync(pueblo, mxp)).OnPuebloEnabled(OnPuebloEnabledAsync); + var (interpreter, readTask) = await _factory.CreateBuilder() .OnSubmit(OnSubmitAsync) .WithClientIdentity(Identity) @@ -317,7 +324,8 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT .AddPlugin().WithCharsetOrder(CharsetPreference) .AddPlugin() .AddPlugin() - .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) + .AddPlugin(mxp) + .AddPlugin(pueblo) .BuildAndStartAsync(client, cancellationToken); _client = client; @@ -417,6 +425,33 @@ private ValueTask OnMxpEnabledAsync() return ValueTask.CompletedTask; } + // SharpMUSH offers MXP and Pueblo both. Answering the Pueblo hello switches the connection to + // Pueblo, and the server then sends no MXP, so a server that agreed MXP is left on MXP. That is + // the option (WILL MXP), which arrives before the banner; MXP mode itself may start later. + private static async ValueTask OnPuebloOfferedAsync(PuebloProtocol pueblo, MXPProtocol mxp) + { + if (mxp.IsMXPActive) + { + return; + } + + try + { + await pueblo.AnnounceAsync(); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + // The connection went away under the handshake; the reconnect starts it over. + } + } + + private ValueTask OnPuebloEnabledAsync(PuebloClient client) + { + PuebloEnabled?.Invoke(); + NegotiationReceived?.Invoke(new NegotiationEvent("Pueblo", $"enabled ({client.Version})")); + return ValueTask.CompletedTask; + } + private ValueTask OnMsspAsync(MSSPConfig cfg) { var parts = new List(); diff --git a/src/SharpClient.Core/Rendering/AnsiColor.cs b/src/SharpClient.Core/Rendering/AnsiColor.cs index d1d15b5..9a9a2c2 100644 --- a/src/SharpClient.Core/Rendering/AnsiColor.cs +++ b/src/SharpClient.Core/Rendering/AnsiColor.cs @@ -4,6 +4,9 @@ public enum AnsiColorKind { Default, Indexed, + + /// 24-bit colour (SGR 38;2 / 48;2). holds 0xRRGGBB. + Rgb, } public readonly record struct AnsiColor(AnsiColorKind Kind, int Index) @@ -11,4 +14,7 @@ public readonly record struct AnsiColor(AnsiColorKind Kind, int Index) public static AnsiColor Default => new(AnsiColorKind.Default, 0); public static AnsiColor Indexed(int index) => new(AnsiColorKind.Indexed, index); + + public static AnsiColor Rgb(int r, int g, int b) => + new(AnsiColorKind.Rgb, (Math.Clamp(r, 0, 255) << 16) | (Math.Clamp(g, 0, 255) << 8) | Math.Clamp(b, 0, 255)); } diff --git a/src/SharpClient.Core/Rendering/AnsiParser.cs b/src/SharpClient.Core/Rendering/AnsiParser.cs index c2f2ffc..b0407d3 100644 --- a/src/SharpClient.Core/Rendering/AnsiParser.cs +++ b/src/SharpClient.Core/Rendering/AnsiParser.cs @@ -36,6 +36,22 @@ void Flush() text.Clear(); } + void AddImage(string? url, string label) + { + if (url is null) + { + return; + } + + Flush(); + segments.Add(new StyledSegment(label, style) + { + ImageUrl = url, + Command = pendingCommand, + Hint = pendingHint, + }); + } + void HandleTag(string raw) { var body = raw.Trim(); @@ -85,6 +101,21 @@ void HandleTag(string raw) inAnchor = true; } } + else if (name == "image" && mxp!.IsMxpActive && mxp.IsSecure) + { + // IMAGE is not an open tag, so like SEND it needs a Secure line. + var fileName = GetAttr(attrs, "fname") ?? FirstPositional(attrs); + var url = fileName is null + ? null + : ImageSource.ResolveMxp(fileName, GetAttr(attrs, "url"), GetAttr(attrs, "t")); + AddImage(url, ImageSource.DisplayName(fileName)); + } + else if (name == "img" && mxp!.IsPuebloActive) + { + // is Pueblo's mode switch, not a picture: it has no src. + var src = GetAttr(attrs, "src"); + AddImage(src is null ? null : ImageSource.ResolvePueblo(src), GetAttr(attrs, "alt") ?? ImageSource.DisplayName(src)); + } // Any other tag (or a non-command anchor) is stripped silently. } else @@ -249,6 +280,71 @@ void HandleTag(string raw) return null; } + /// The first attribute given without a name (MXP allows <IMAGE map.png URL=...>). + private static string? FirstPositional(string body) + { + var i = 0; + while (i < body.Length) + { + while (i < body.Length && body[i] is ' ' or '\t') + { + i++; + } + + if (i >= body.Length) + { + return null; + } + + var quoted = body[i] is '"' or '\''; + var start = i; + if (quoted) + { + var quote = body[i++]; + start = i; + while (i < body.Length && body[i] != quote) + { + i++; + } + + var value = body[start..i]; + i++; + return DecodeEntities(value); + } + + var isNamed = false; + while (i < body.Length && body[i] is not (' ' or '\t')) + { + if (body[i] == '=') + { + isNamed = true; + i++; + if (i < body.Length && body[i] is '"' or '\'') + { + var quote = body[i++]; + while (i < body.Length && body[i] != quote) + { + i++; + } + + i++; + } + + continue; + } + + i++; + } + + if (!isNamed) + { + return DecodeEntities(body[start..i]); + } + } + + return null; + } + /// /// Scan from for the '>' that closes a tag, skipping over single- or /// double-quoted attribute values so a literal '>' inside an attribute doesn't end the tag early @@ -475,9 +571,17 @@ private static bool TryReadExtended(string[] codes, ref int i, out AnsiColor col if (codes[i + 1] == "2") { - // Truecolor (24-bit): consume "2" + R + G + B components. - // Phase-1 scope: graceful degradation — colour stays AnsiColor.Default. + // Truecolor: "2" + R + G + B. A short or malformed sequence is still consumed, so its + // components are not read as SGR codes of their own, and leaves the colour at default. var remaining = codes.Length - 1 - i; + if (remaining >= 4 + && int.TryParse(codes[i + 2], out var r) + && int.TryParse(codes[i + 3], out var g) + && int.TryParse(codes[i + 4], out var b)) + { + colour = AnsiColor.Rgb(r, g, b); + } + i += Math.Min(4, remaining); return true; } diff --git a/src/SharpClient.Core/Rendering/ImageSource.cs b/src/SharpClient.Core/Rendering/ImageSource.cs new file mode 100644 index 0000000..a12fae3 --- /dev/null +++ b/src/SharpClient.Core/Rendering/ImageSource.cs @@ -0,0 +1,95 @@ +namespace SharpClient.Core.Rendering; + +/// +/// Turns MXP and Pueblo image references into a URL the output view may load. Only http, https and +/// data:image are accepted: any other scheme (javascript:, file:, content:) would be the world choosing +/// what the client opens. +/// +public static class ImageSource +{ + private const int MaxNameLength = 48; + + /// + /// MXP <IMAGE>: "The classname is appended to the URL, along with the name of the + /// graphics file itself", so URL=http://h/img/ T=maps map.png is http://h/img/maps/map.png. + /// Without URL= the name must itself be absolute; a bare file name refers to a local media + /// directory this client does not have. + /// + public static string? ResolveMxp(string fileName, string? url, string? type) + { + var name = fileName.Trim(); + if (name.Length == 0) + { + return null; + } + + if (IsDataImage(name)) + { + return string.IsNullOrWhiteSpace(url) ? name : null; + } + + if (string.IsNullOrWhiteSpace(url)) + { + return Absolute(name); + } + + var basePath = EnsureSlash(url.Trim()); + if (!string.IsNullOrWhiteSpace(type)) + { + basePath = EnsureSlash(basePath + type.Trim().Trim('/')); + } + + if (!Uri.TryCreate(basePath, UriKind.Absolute, out var baseUri) || !IsWeb(baseUri)) + { + return null; + } + + return Uri.TryCreate(baseUri, name, out var full) && IsWeb(full) ? full.AbsoluteUri : null; + } + + /// Pueblo <img src>, which is plain HTML: an absolute URL or a data:image URI. + public static string? ResolvePueblo(string src) + { + var value = src.Trim(); + return IsDataImage(value) ? value : Absolute(value); + } + + /// What to show in place of the picture: the last path segment of its name, bounded. + public static string DisplayName(string? fileName) + { + var name = fileName?.Trim() ?? string.Empty; + if (name.Length == 0 || name.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + return "image"; + } + + var query = name.IndexOfAny(['?', '#']); + if (query >= 0) + { + name = name[..query]; + } + + var slash = name.TrimEnd('/').LastIndexOf('/'); + if (slash >= 0) + { + name = name[(slash + 1)..].TrimEnd('/'); + } + + if (name.Length == 0) + { + return "image"; + } + + return name.Length <= MaxNameLength ? name : name[..(MaxNameLength - 1)] + "…"; + } + + private static bool IsDataImage(string value) => + value.StartsWith("data:image/", StringComparison.OrdinalIgnoreCase); + + private static string? Absolute(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && IsWeb(uri) ? uri.AbsoluteUri : null; + + private static string EnsureSlash(string value) => value.EndsWith('/') ? value : value + "/"; + + private static bool IsWeb(Uri uri) => uri.Scheme is "http" or "https"; +} diff --git a/src/SharpClient.Core/Rendering/MxpParserState.cs b/src/SharpClient.Core/Rendering/MxpParserState.cs index fce2d0f..1cad530 100644 --- a/src/SharpClient.Core/Rendering/MxpParserState.cs +++ b/src/SharpClient.Core/Rendering/MxpParserState.cs @@ -10,7 +10,7 @@ public sealed class MxpParserState /// Whether MXP telnet option 91 was successfully negotiated. public bool IsMxpActive { get; set; } - /// Whether the Pueblo banner was seen and the PUEBLOCLIENT handshake sent. + /// Whether the server switched this connection into Pueblo HTML mode. public bool IsPuebloActive { get; set; } /// diff --git a/src/SharpClient.Core/Rendering/SegmentStyle.cs b/src/SharpClient.Core/Rendering/SegmentStyle.cs index 74a45e1..72478be 100644 --- a/src/SharpClient.Core/Rendering/SegmentStyle.cs +++ b/src/SharpClient.Core/Rendering/SegmentStyle.cs @@ -11,9 +11,9 @@ public static string ToCss(StyledSegment segment) { var style = segment.Style; var fg = Resolve(style.Foreground, AnsiPalette.PhosphorDefault); - string? bg = style.Background.Kind == AnsiColorKind.Indexed - ? AnsiPalette.ToHex(style.Background.Index) - : null; + string? bg = style.Background.Kind == AnsiColorKind.Default + ? null + : Resolve(style.Background, AnsiPalette.PhosphorDefault); if (style.Inverse) { @@ -41,8 +41,10 @@ public static string ToCss(StyledSegment segment) return sb.ToString(); } - private static string Resolve(AnsiColor color, string fallback) => - color.Kind == AnsiColorKind.Indexed - ? AnsiPalette.ToHex(color.Index) - : fallback; + private static string Resolve(AnsiColor color, string fallback) => color.Kind switch + { + AnsiColorKind.Indexed => AnsiPalette.ToHex(color.Index), + AnsiColorKind.Rgb => $"#{color.Index & 0xFFFFFF:x6}", + _ => fallback, + }; } diff --git a/src/SharpClient.Core/Rendering/StyledSegment.cs b/src/SharpClient.Core/Rendering/StyledSegment.cs index f7f0621..1ed1b15 100644 --- a/src/SharpClient.Core/Rendering/StyledSegment.cs +++ b/src/SharpClient.Core/Rendering/StyledSegment.cs @@ -10,4 +10,10 @@ public readonly record struct StyledSegment(string Text, TextStyle Style) /// Optional tooltip for a command link. Null if unspecified. public string? Hint { get; init; } + + /// + /// If non-null, this segment is an inline image (MXP <IMAGE> or Pueblo <img>) and + /// is its alternative text. Always an absolute http(s) URL or a data:image URI. + /// + public string? ImageUrl { get; init; } } diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs index 3995cd1..f1da93d 100644 --- a/src/SharpClient.Core/Sessions/Session.cs +++ b/src/SharpClient.Core/Sessions/Session.cs @@ -67,6 +67,7 @@ public Session( _connection.GmcpReceived += OnGmcpReceived; _connection.NegotiationReceived += OnNegotiationReceived; _connection.MxpEnabled += OnMxpEnabled; + _connection.PuebloEnabled += OnPuebloEnabled; } public IReadOnlyList Scrollback @@ -144,9 +145,16 @@ public async ValueTask DisposeAsync() _connection.GmcpReceived -= OnGmcpReceived; _connection.NegotiationReceived -= OnNegotiationReceived; _connection.MxpEnabled -= OnMxpEnabled; + _connection.PuebloEnabled -= OnPuebloEnabled; await _connection.DisposeAsync(); } + private void OnPuebloEnabled() + { + _mxp.IsPuebloActive = true; + _protocolChanged?.Invoke(); + } + private void OnMxpEnabled() { _mxp.IsMxpActive = true; @@ -155,15 +163,6 @@ private void OnMxpEnabled() private async void OnLineReceived(string raw) { - // Pueblo negotiation is banner-based (no telnet option): the server announces - // itself in plain text and the client replies with PUEBLOCLIENT over the data channel. - if (!_mxp.IsPuebloActive && raw.Contains("This world is Pueblo", StringComparison.OrdinalIgnoreCase)) - { - _mxp.IsPuebloActive = true; - _protocolChanged?.Invoke(); - await _connection.SendAsync("PUEBLOCLIENT 2.01"); - } - // Reset per-line MXP mode to the negotiated default before parsing this line. _mxp.BeginLine(); @@ -216,6 +215,17 @@ private void OnStateChanged(ConnectionState state) { var previous = _lastState; _lastState = state; + + // MXP and Pueblo belong to one connection: a reconnect negotiates them again, and the + // server it reaches may not offer either. + if (previous == ConnectionState.Connected && state != ConnectionState.Connected) + { + _mxp.IsMxpActive = false; + _mxp.IsPuebloActive = false; + _mxp.DefaultLineMode = 0; + _mxp.LineMode = 0; + } + StateChanged?.Invoke(state); // On every transition into Connected — the initial connect AND each automatic reconnect — diff --git a/src/SharpClient.UI/Components/OutputView.razor b/src/SharpClient.UI/Components/OutputView.razor index dfabf90..90390be 100644 --- a/src/SharpClient.UI/Components/OutputView.razor +++ b/src/SharpClient.UI/Components/OutputView.razor @@ -7,7 +7,21 @@
@foreach (var segment in line.Segments) { - @if (segment.Command is not null && OnSendCommand is not null) + @if (segment.ImageUrl is not null) + { + @* no-referrer: the image host learns nothing about where it was shown. *@ + @if (segment.Command is not null && OnSendCommand is not null) + { + + } + else + { + @segment.Text + } + } + else if (segment.Command is not null && OnSendCommand is not null) {