Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions src/SharpClient.App/SharpClient.App.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,12 @@
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
</PropertyGroup>

<!-- Android Release defaults to Mono AOT, which fails to precompile
TelnetNegotiationCore.Functional.dll (it depends on FSharp.Core, which the
AOT compiler cannot resolve). Disable AOT for Android Release so `dotnet
publish -c Release` produces an APK; the app runs under the JIT/interpreter.

PublishTrimmed=false: the Release trimmer ("optimize assemblies for size") fails on
FSharp.Core's embedded ILLink.Substitutions.xml — it tries to remove F# signature/
optimization resources it can't find and aborts with IL2040 / NETSDK1144. That substitution
step can't be suppressed, so disable trimming for the Android Release build. The APK is
larger but correct; size-trim can be revisited if FSharp.Core's linker metadata is fixed. -->
<!-- AOT and trimming are off for Android Release. They were turned off because TNC 2.x shipped
TelnetNegotiationCore.Functional.dll, whose FSharp.Core dependency broke both the Mono AOT
compiler and the trimmer (IL2040 / NETSDK1144). TNC 4.0 dropped FSharp.Core, so that reason is
gone. They stay off for now because .NET for Android allows AOT only with trimming (XA1030),
and EF Core and the Blazor WebView use reflection, so a trimmed APK has to be run on a device
before it ships. -->
<PropertyGroup Condition="'$(Configuration)' == 'Release' and $([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">
<RunAOTCompilation>false</RunAOTCompilation>
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
Expand Down Expand Up @@ -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. -->
<PackageReference Include="TelnetNegotiationCore" Version="2.5.3" />
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. -->
<PackageReference Include="TelnetNegotiationCore" Version="4.6.1" />
</ItemGroup>

<ItemGroup>
Expand Down
6 changes: 6 additions & 0 deletions src/SharpClient.Core/Connection/ITelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ public interface ITelnetConnection : IAsyncDisposable
/// <summary>Raised when MXP (telnet option 91) negotiation completes successfully.</summary>
public event Action? MxpEnabled;

/// <summary>
/// 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.
/// </summary>
public event Action? PuebloEnabled;

public ConnectionState State { get; }

public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default);
Expand Down
71 changes: 65 additions & 6 deletions src/SharpClient.Core/Connection/TelnetConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using TelnetNegotiationCore.Builders;
using TelnetNegotiationCore.Interpreters;
using TelnetNegotiationCore.Models;
using TelnetNegotiationCore.Protocols;

namespace SharpClient.Core.Connection;

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -43,6 +53,7 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec
public event Action<GmcpMessage>? GmcpReceived;
public event Action<NegotiationEvent>? NegotiationReceived;
public event Action? MxpEnabled;
public event Action? PuebloEnabled;

public ConnectionState State { get; private set; } = ConnectionState.Disconnected;

Expand Down Expand Up @@ -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<NAWSProtocol>() is WindowSizeProtocol naws)
{
await naws.ReportAsync(width, height);
}
}

public async Task DisconnectAsync()
Expand Down Expand Up @@ -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<GMCPProtocol>().OnGMCPMessage(OnGmcpMessageAsync)
.AddPlugin<MSDPProtocol>()
.AddPlugin<MSSPProtocol>().OnMSSP(OnMsspAsync)
.AddPlugin<TerminalTypeProtocol>()
.AddPlugin<CharsetProtocol>().WithCharsetOrder(CharsetPreference)
.AddPlugin<EORProtocol>()
.AddPlugin<SuppressGoAheadProtocol>()
.AddPlugin(mxp)
.AddPlugin(pueblo)
.BuildAndStartAsync(client, cancellationToken);

_client = client;
Expand Down Expand Up @@ -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<string>();
Expand Down
75 changes: 75 additions & 0 deletions src/SharpClient.Core/Connection/WindowSizeProtocol.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
using TelnetNegotiationCore.Protocols;

namespace SharpClient.Core.Connection;

/// <summary>
/// NAWS that keeps the latest window size until the server enables NAWS, then reports it. The base
/// plugin drops a report made before <c>DO NAWS</c> (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 <see cref="NAWSProtocol"/>'s type, so
/// <c>GetPlugin&lt;NAWSProtocol&gt;()</c> finds it.
/// </summary>
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;
}
}
6 changes: 6 additions & 0 deletions src/SharpClient.Core/Rendering/AnsiColor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ public enum AnsiColorKind
{
Default,
Indexed,

/// <summary>24-bit colour (SGR 38;2 / 48;2). <see cref="AnsiColor.Index"/> holds 0xRRGGBB.</summary>
Rgb,
}

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));
}
Loading
Loading