diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs
index e2d3b90..36408ff 100644
--- a/src/SharpClient.Core/Connection/ITelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs
@@ -21,6 +21,15 @@ public interface ITelnetConnection : IAsyncDisposable
public ConnectionState State { get; }
+ /// Wrap the connection in TLS. Read on every connect and reconnect.
+ public bool UseTls { get; set; }
+
+ ///
+ /// Accept a TLS certificate that fails validation (self-signed, expired, wrong host). Many MUSHes
+ /// run TLS with a self-signed certificate, so this is a per-world opt-in, not the default.
+ ///
+ public bool AllowInvalidCertificate { get; set; }
+
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 77f8560..25e300a 100644
--- a/src/SharpClient.Core/Connection/TelnetConnection.cs
+++ b/src/SharpClient.Core/Connection/TelnetConnection.cs
@@ -1,5 +1,7 @@
+using System.Net.Security;
using System.Net.Sockets;
using System.Text;
+using SharpClient.Core.Rendering;
using TelnetNegotiationCore.Builders;
using TelnetNegotiationCore.Interpreters;
using TelnetNegotiationCore.Models;
@@ -13,6 +15,8 @@ public sealed class TelnetConnection : ITelnetConnection
private readonly ReconnectOptions _reconnect;
private TcpClient? _client;
+ // The client's stream, or the SslStream over it on a TLS world. Disposed with the client.
+ private Stream? _stream;
private TelnetInterpreter? _interpreter;
private Task? _readTask;
private Task? _monitorTask;
@@ -34,6 +38,8 @@ public sealed class TelnetConnection : ITelnetConnection
// long-running (e.g. in-car) connection, without meaningful battery cost.
private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45);
+ private static readonly TimeSpan TlsHandshakeTimeout = TimeSpan.FromSeconds(20);
+
// 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")
@@ -57,6 +63,12 @@ public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? rec
public ConnectionState State { get; private set; } = ConnectionState.Disconnected;
+ ///
+ public bool UseTls { get; set; }
+
+ ///
+ public bool AllowInvalidCertificate { get; set; }
+
///
/// Telnet CHARSET (RFC 2066) preference order offered during negotiation, most
/// preferred first: UTF-8, then Latin-1 (ISO-8859-1), then US-ASCII. The negotiated
@@ -82,8 +94,7 @@ public async Task ConnectAsync(string host, int port, CancellationToken cancella
}
catch
{
- _client?.Dispose();
- _client = null;
+ ReleaseTransport();
_interpreter = null;
SetState(ConnectionState.Error);
throw;
@@ -173,8 +184,7 @@ public async Task DisconnectAsync()
_interpreter = null;
}
- _client?.Dispose();
- _client = null;
+ ReleaseTransport();
if (_readTask is not null)
{
@@ -301,15 +311,59 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
// TCP would otherwise retransmit for ~15 min before failing, so the client sits "connected"
// with no responses. This makes that surface in ~20s instead.
TrySetTcpUserTimeout(client.Client);
- await client.ConnectAsync(host, port, cancellationToken);
+ Stream? stream = null;
+ try
+ {
+ await client.ConnectAsync(host, port, cancellationToken);
+ stream = client.GetStream();
+ if (UseTls)
+ {
+ var ssl = new SslStream(stream, leaveInnerStreamOpen: false, ValidateCertificate);
+ stream = ssl;
+
+ // A peer that accepts the TCP connection but never answers the ClientHello (a plain
+ // telnet port, say) would otherwise hold the connect, or a reconnect attempt, forever.
+ using var handshake = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ handshake.CancelAfter(TlsHandshakeTimeout);
+ await ssl.AuthenticateAsClientAsync(
+ new SslClientAuthenticationOptions { TargetHost = host }, handshake.Token);
+ }
+
+ await StartInterpreterAsync(client, stream, host, port, cancellationToken);
+ }
+ catch
+ {
+ if (stream is not null)
+ {
+ await stream.DisposeAsync();
+ }
+
+ client.Dispose();
+ throw;
+ }
+ }
+
+ // Closes the TLS session (when there is one) and the socket under it.
+ private void ReleaseTransport()
+ {
+ _stream?.Dispose();
+ _stream = null;
+ _client?.Dispose();
+ _client = null;
+ }
+ private async Task StartInterpreterAsync(TcpClient client, Stream stream, string host, int port, CancellationToken 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.
+ // NAWSProtocol, and a second plugin for the same option is rejected. Plus Pueblo, MCCP and
+ // NEW-ENVIRON.
// 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);
+ mxp.OnMXPEnabled(OnMxpEnabledAsync)
+ .OnMxpSupportRequested(asked => OnMxpSupportRequestedAsync(mxp, asked))
+ .OnMxpVersionRequested(() => OnMxpVersionRequestedAsync(mxp));
var pueblo = new PuebloProtocol();
pueblo.OnPuebloOffered(() => OnPuebloOfferedAsync(pueblo, mxp)).OnPuebloEnabled(OnPuebloEnabledAsync);
@@ -326,9 +380,14 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT
.AddPlugin()
.AddPlugin(mxp)
.AddPlugin(pueblo)
- .BuildAndStartAsync(client, cancellationToken);
+ // Both only answer the server: MCCP2 starts on the server's WILL, NEW-ENVIRON on its DO,
+ // and NEW-ENVIRON sends only what WithClientIdentity says (CLIENT_NAME, TERMINAL_TYPE, MTTS).
+ .AddPlugin().OnCompressionEnabled(OnCompressionAsync)
+ .AddPlugin()
+ .BuildAndStartAsync(stream, cancellationToken);
_client = client;
+ _stream = stream;
_interpreter = interpreter;
_readTask = readTask;
_host = host;
@@ -373,8 +432,7 @@ private async Task ReconnectLoopAsync(CancellationToken token)
// Tear down the dead client before the next attempt; keep the existing
// state (Reconnecting) so we don't flicker through Disconnected.
- _client?.Dispose();
- _client = null;
+ ReleaseTransport();
_interpreter = null;
try
@@ -445,6 +503,44 @@ private static async ValueTask OnPuebloOfferedAsync(PuebloProtocol pueblo, MXPPr
}
}
+ private bool ValidateCertificate(
+ object sender,
+ System.Security.Cryptography.X509Certificates.X509Certificate? certificate,
+ System.Security.Cryptography.X509Certificates.X509Chain? chain,
+ SslPolicyErrors errors) =>
+ errors == SslPolicyErrors.None || AllowInvalidCertificate;
+
+ private static async ValueTask OnMxpSupportRequestedAsync(MXPProtocol mxp, IReadOnlyList asked)
+ {
+ var (supported, unsupported) = MxpTags.Answer(asked);
+ try
+ {
+ await mxp.SendSupportsAsync(supported, unsupported);
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
+ {
+ // The connection went away mid-answer.
+ }
+ }
+
+ private static async ValueTask OnMxpVersionRequestedAsync(MXPProtocol mxp)
+ {
+ try
+ {
+ await mxp.SendVersionAsync(new MxpVersion(MxpTags.MxpRevision, null, Identity.Name, null, null));
+ }
+ catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException)
+ {
+ // The connection went away mid-answer.
+ }
+ }
+
+ private ValueTask OnCompressionAsync(int version, bool enabled)
+ {
+ NegotiationReceived?.Invoke(new NegotiationEvent($"MCCP{version}", enabled ? "compressing" : "stopped"));
+ return ValueTask.CompletedTask;
+ }
+
private ValueTask OnPuebloEnabledAsync(PuebloClient client)
{
PuebloEnabled?.Invoke();
diff --git a/src/SharpClient.Core/Domain/World.cs b/src/SharpClient.Core/Domain/World.cs
index 4678a46..7444174 100644
--- a/src/SharpClient.Core/Domain/World.cs
+++ b/src/SharpClient.Core/Domain/World.cs
@@ -6,6 +6,12 @@ public sealed class World
public string Name { get; set; } = string.Empty;
public string Host { get; set; } = string.Empty;
public int Port { get; set; }
+
+ /// Connect over TLS.
+ public bool UseTls { get; set; }
+
+ /// With , accept a self-signed or otherwise invalid certificate.
+ public bool AllowInvalidCertificate { get; set; }
public List Characters { get; set; } = [];
public List Triggers { get; set; } = []; // world-scope
public List Aliases { get; set; } = []; // world-scope
diff --git a/src/SharpClient.Core/Presentation/SettingsViewModel.cs b/src/SharpClient.Core/Presentation/SettingsViewModel.cs
index 1d0c4d7..e994c88 100644
--- a/src/SharpClient.Core/Presentation/SettingsViewModel.cs
+++ b/src/SharpClient.Core/Presentation/SettingsViewModel.cs
@@ -48,6 +48,7 @@ public sealed class SettingsViewModel
private string _accent;
private bool _glow;
private bool _scanlines;
+ private bool _showImages;
public event Action? Changed;
@@ -60,6 +61,7 @@ public SettingsViewModel(IPreferences prefs)
_accent = prefs.GetString("Accent", "#9b7ed4");
_glow = prefs.GetBool ("Glow", true);
_scanlines = prefs.GetBool ("Scanlines", true);
+ _showImages = prefs.GetBool ("ShowImages", true);
// Clamp unknown accent to default so --acc2/soft/line don't desync.
if (!s_accentMeta.ContainsKey(_accent))
@@ -105,6 +107,16 @@ public bool Scanlines
set { _scanlines = value; _prefs.SetBool("Scanlines", value); Changed?.Invoke(); }
}
+ ///
+ /// Load MXP/Pueblo inline images. Loading one tells the image host this device's address, so it
+ /// can be turned off; the image's name is shown in its place.
+ ///
+ public bool ShowImages
+ {
+ get => _showImages;
+ set { _showImages = value; _prefs.SetBool("ShowImages", value); Changed?.Invoke(); }
+ }
+
public string FontFamily =>
s_fontFamilies.TryGetValue(_outputFont, out var f) ? f
: "'JetBrains Mono',ui-monospace,monospace";
diff --git a/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs b/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs
index aca7f15..fa890cd 100644
--- a/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs
+++ b/src/SharpClient.Core/Presentation/WorldManagerViewModel.cs
@@ -44,9 +44,25 @@ public async Task LoadAsync(CancellationToken cancellationToken = default)
RaiseChanged();
}
- public async Task AddWorldAsync(string name, string host, int port, CancellationToken cancellationToken = default)
+ public Task AddWorldAsync(string name, string host, int port, CancellationToken cancellationToken = default) =>
+ AddWorldAsync(name, host, port, useTls: false, allowInvalidCertificate: false, cancellationToken);
+
+ public async Task AddWorldAsync(
+ string name,
+ string host,
+ int port,
+ bool useTls,
+ bool allowInvalidCertificate,
+ CancellationToken cancellationToken = default)
{
- var world = new World { Name = name, Host = host, Port = port };
+ var world = new World
+ {
+ Name = name,
+ Host = host,
+ Port = port,
+ UseTls = useTls,
+ AllowInvalidCertificate = allowInvalidCertificate,
+ };
await _store.AddWorldAsync(world, cancellationToken);
await LoadAsync(cancellationToken);
}
diff --git a/src/SharpClient.Core/Rendering/MxpTags.cs b/src/SharpClient.Core/Rendering/MxpTags.cs
new file mode 100644
index 0000000..305cb6e
--- /dev/null
+++ b/src/SharpClient.Core/Rendering/MxpTags.cs
@@ -0,0 +1,80 @@
+namespace SharpClient.Core.Rendering;
+
+///
+/// What SharpClient's MXP rendering () acts on, for answering a server's
+/// <SUPPORT>. A tag or attribute is listed only when the parser does something with it:
+/// claiming more makes a server send markup that is then shown stripped.
+///
+public static class MxpTags
+{
+ /// The MXP revision answered to <VERSION>.
+ public const string MxpRevision = "1.0";
+
+ // Each rendered tag with the attributes the parser reads from it.
+ private static readonly (string Tag, string[] Attributes)[] s_rendered =
+ [
+ ("send", ["href", "hint"]),
+ ("image", ["fname", "url", "t"]),
+ ];
+
+ public static IReadOnlyList Supported { get; } = [.. s_rendered.Select(r => r.Tag)];
+
+ private static readonly Dictionary s_attributes =
+ s_rendered.ToDictionary(r => r.Tag, r => r.Attributes, StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// The answer to a <SUPPORT> naming : every supported tag when
+ /// it named nothing, otherwise each entry as supported or not. tag.attr is supported when the
+ /// parser reads that attribute; tag.* expands to each attribute of a supported tag, as the
+ /// specification asks, and is -tag.* for a tag this client does not render.
+ ///
+ public static (IReadOnlyList Supported, IReadOnlyList Unsupported) Answer(IReadOnlyList asked)
+ {
+ if (asked.Count == 0)
+ {
+ return (Supported, []);
+ }
+
+ var supported = new List();
+ var unsupported = new List();
+ foreach (var entry in asked)
+ {
+ var name = entry.Trim('"', '\'');
+ if (name.Length == 0)
+ {
+ continue;
+ }
+
+ var dot = name.IndexOf('.', StringComparison.Ordinal);
+ if (dot < 0)
+ {
+ (IsSupported(name) ? supported : unsupported).Add(name);
+ continue;
+ }
+
+ var tag = name[..dot];
+ var attribute = name[(dot + 1)..];
+ var tagSupported = s_attributes.TryGetValue(tag, out var known);
+ var attributes = known ?? Array.Empty();
+ if (attribute == "*")
+ {
+ if (tagSupported)
+ {
+ supported.AddRange(attributes.Select(a => $"{tag}.{a}"));
+ }
+ else
+ {
+ unsupported.Add(name);
+ }
+ }
+ else
+ {
+ (attributes.Contains(attribute, StringComparer.OrdinalIgnoreCase) ? supported : unsupported).Add(name);
+ }
+ }
+
+ return (supported, unsupported);
+ }
+
+ private static bool IsSupported(string tag) => s_attributes.ContainsKey(tag);
+}
diff --git a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs
index cb4e9ca..303477b 100644
--- a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs
+++ b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs
@@ -46,6 +46,8 @@ public async Task LaunchAsync(
CancellationToken cancellationToken = default)
{
var connection = _connFactory.CreateConnection();
+ connection.UseTls = world.UseTls;
+ connection.AllowInvalidCertificate = world.UseTls && world.AllowInvalidCertificate;
var aliasRules = MergeAliases(world.Aliases, character.Aliases);
var triggerRules = MergeTriggers(world.Triggers, character.Triggers);
diff --git a/src/SharpClient.Data/WorldStore.cs b/src/SharpClient.Data/WorldStore.cs
index 8697dc3..4a5a24d 100644
--- a/src/SharpClient.Data/WorldStore.cs
+++ b/src/SharpClient.Data/WorldStore.cs
@@ -105,6 +105,75 @@ private async Task EnsureSchemaAsync(CancellationToken cancellationToken)
{
if (_schemaEnsured) return;
await _db.Database.EnsureCreatedAsync(cancellationToken);
+ await AddMissingColumnsAsync(cancellationToken);
_schemaEnsured = true;
}
+
+ // The schema comes from EnsureCreated, which does nothing to a database that already exists, so a
+ // column added to the model after a user's database was created has to be added here. Each entry
+ // is NOT NULL with a default, so existing rows read as they did before the column existed.
+ private static readonly (string Table, string Column, string Definition)[] s_addedColumns =
+ [
+ ("Worlds", nameof(World.UseTls), "INTEGER NOT NULL DEFAULT 0"),
+ ("Worlds", nameof(World.AllowInvalidCertificate), "INTEGER NOT NULL DEFAULT 0"),
+ ];
+
+ private async Task AddMissingColumnsAsync(CancellationToken cancellationToken)
+ {
+ var connection = _db.Database.GetDbConnection();
+ var opened = connection.State != System.Data.ConnectionState.Open;
+ if (opened)
+ {
+ await connection.OpenAsync(cancellationToken);
+ }
+
+ try
+ {
+ foreach (var group in s_addedColumns.GroupBy(c => c.Table))
+ {
+ var existing = new HashSet(StringComparer.OrdinalIgnoreCase);
+ await using (var query = connection.CreateCommand())
+ {
+ // Table names are the constants above, never input.
+#pragma warning disable CA2100
+ query.CommandText = $"PRAGMA table_info(\"{group.Key}\")";
+#pragma warning restore CA2100
+ await using var reader = await query.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ existing.Add(reader.GetString(1));
+ }
+ }
+
+ foreach (var (table, column, definition) in group)
+ {
+ if (existing.Contains(column))
+ {
+ continue;
+ }
+
+ await using var alter = connection.CreateCommand();
+#pragma warning disable CA2100
+ alter.CommandText = $"ALTER TABLE \"{table}\" ADD COLUMN \"{column}\" {definition}";
+#pragma warning restore CA2100
+ try
+ {
+ await alter.ExecuteNonQueryAsync(cancellationToken);
+ }
+ catch (System.Data.Common.DbException ex)
+ when (ex.Message.Contains("duplicate column name", StringComparison.OrdinalIgnoreCase))
+ {
+ // Another store on the same database added it between the check and here.
+ }
+ }
+ }
+ }
+ finally
+ {
+ if (opened)
+ {
+ await connection.CloseAsync();
+ }
+ }
+ }
}
diff --git a/src/SharpClient.UI/Components/OutputView.razor b/src/SharpClient.UI/Components/OutputView.razor
index 90390be..fc8ecac 100644
--- a/src/SharpClient.UI/Components/OutputView.razor
+++ b/src/SharpClient.UI/Components/OutputView.razor
@@ -7,7 +7,7 @@
@foreach (var segment in line.Segments)
{
- @if (segment.ImageUrl is not null)
+ @if (segment.ImageUrl is not null && ShowImages)
{
@* no-referrer: the image host learns nothing about where it was shown. *@
@if (segment.Command is not null && OnSendCommand is not null)
@@ -43,4 +43,8 @@
/// Invoked when a clickable MXP/Pueblo command-link is clicked.
[Parameter]
public Func? OnSendCommand { get; set; }
+
+ /// When false, an image segment renders as its text (its name or alt text) instead.
+ [Parameter]
+ public bool ShowImages { get; set; } = true;
}
diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor
index 9d76fe8..12882dd 100644
--- a/src/SharpClient.UI/Components/SessionScreen.razor
+++ b/src/SharpClient.UI/Components/SessionScreen.razor
@@ -60,7 +60,7 @@
}
else
{
-
+
}
@if (_showProtocol && ProtocolVm is not null)
diff --git a/src/SharpClient.UI/Components/SettingsView.razor b/src/SharpClient.UI/Components/SettingsView.razor
index 99e2c02..b8b58ff 100644
--- a/src/SharpClient.UI/Components/SettingsView.razor
+++ b/src/SharpClient.UI/Components/SettingsView.razor
@@ -87,6 +87,16 @@
+