diff --git a/src/SharpClient.App/SharpClient.App.csproj b/src/SharpClient.App/SharpClient.App.csproj index 5c4764d..c185067 100644 --- a/src/SharpClient.App/SharpClient.App.csproj +++ b/src/SharpClient.App/SharpClient.App.csproj @@ -48,16 +48,12 @@ 10.0.17763.0 - + false false @@ -119,8 +115,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, + so AOT and trimming can be turned back on once a trimmed APK is checked on a device. --> + 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 163453d..77f8560 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; @@ -20,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; @@ -32,6 +34,14 @@ 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 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 | MttsCapabilities.Truecolor, + }; + public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null) { _factory = factory; @@ -43,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; @@ -134,12 +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; } - await _interpreter.SendNAWS((short)width, (short)height); + if (_interpreter.PluginManager?.GetPlugin() is WindowSizeProtocol naws) + { + await naws.ReportAsync(width, height); + } } public async Task DisconnectAsync() @@ -287,13 +303,29 @@ 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. 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) - .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(mxp) + .AddPlugin(pueblo) .BuildAndStartAsync(client, cancellationToken); _client = client; @@ -393,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/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/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.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/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) {