From cc8cdee40fbe2e3f96093728964a295bd3cb79ed Mon Sep 17 00:00:00 2001 From: HarryCordewener Date: Wed, 1 Jul 2026 07:48:45 -0500 Subject: [PATCH 1/2] fix(session): re-send auto-login on automatic reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session dropped and TelnetConnection reconnected on its own, the stored connect string was never re-sent — the launcher only sent it once, on the initial connect. So an auto-reconnected session was left sitting at the server's login screen (unauthenticated), and with an empty input box the Send button stays disabled, leaving the user stuck. Move the auto-login into the Session: it now runs on every transition into Connected (initial connect AND each auto-reconnect) via a provider that resolves the character's stored connect string from the secret store on demand. The launcher supplies the provider and no longer sends the credential itself. Adds SessionSendsAutoLoginOnConnectAndReconnect and SessionWithoutAutoLoginSendsNothingOnConnect; full Core suite (196) passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp --- src/SharpClient.Core/Sessions/Session.cs | 43 ++++++++++++++++++- .../Sessions/TelnetSessionLauncher.cs | 28 +++++------- .../Sessions/SessionStateTests.cs | 31 +++++++++++++ 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs index 88b05c5..fa43fe6 100644 --- a/src/SharpClient.Core/Sessions/Session.cs +++ b/src/SharpClient.Core/Sessions/Session.cs @@ -19,6 +19,12 @@ public sealed class Session : ISession private readonly INotifier? _notifier; private readonly ISessionHistory? _history; + // Auto-login command provider (resolves the character's stored connect string). Invoked on + // every transition into Connected so an auto-reconnected session re-authenticates instead of + // being left at the server's login screen. Null when the character has no stored credentials. + private readonly Func>? _autoLoginProvider; + private ConnectionState _lastState = ConnectionState.Disconnected; + // LineReceived fires off the network read thread while Blazor enumerates Scrollback on the // render thread — appending mid-enumeration throws "Collection was modified" and kills the UI. // _scrollbackLock guards every read and write of _scrollback; the Scrollback getter hands out an @@ -41,9 +47,11 @@ public Session( ITriggerEngine? triggerEngine = null, IReadOnlyList? triggerRules = null, INotifier? notifier = null, - ISessionHistory? history = null) + ISessionHistory? history = null, + Func>? autoLoginProvider = null) { _connection = connection; + _autoLoginProvider = autoLoginProvider; CharacterName = characterName; WorldName = worldName; WorldId = worldId; @@ -198,7 +206,38 @@ private async void OnLineReceived(string raw) } } - private void OnStateChanged(ConnectionState state) => StateChanged?.Invoke(state); + private void OnStateChanged(ConnectionState state) + { + var previous = _lastState; + _lastState = state; + StateChanged?.Invoke(state); + + // On every transition into Connected — the initial connect AND each automatic reconnect — + // re-send the stored auto-login, so a dropped-and-reconnected session lands logged in + // instead of stranded at the server's login screen. + if (state == ConnectionState.Connected + && previous != ConnectionState.Connected + && _autoLoginProvider is not null) + { + _ = SendAutoLoginAsync(); + } + } + + private async Task SendAutoLoginAsync() + { + try + { + var command = await _autoLoginProvider!().ConfigureAwait(false); + if (!string.IsNullOrWhiteSpace(command)) + { + await SendAsync(command).ConfigureAwait(false); + } + } + catch + { + // Best-effort: a failed auto-login just leaves the user at the login screen to retry. + } + } private void OnGmcpReceived(GmcpMessage msg) { diff --git a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs index 8782c56..cb4e9ca 100644 --- a/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs +++ b/src/SharpClient.Core/Sessions/TelnetSessionLauncher.cs @@ -50,6 +50,14 @@ public async Task LaunchAsync( var aliasRules = MergeAliases(world.Aliases, character.Aliases); var triggerRules = MergeTriggers(world.Triggers, character.Triggers); + // Resolve the character's stored connect string on demand. The Session invokes this on the + // initial connect AND on every automatic reconnect, so a dropped session re-authenticates + // itself instead of being left at the server's login screen. The secret is fetched from the + // store each time rather than held in the Session. + Func>? autoLoginProvider = character.ConnectSecretKey is { } key + ? async () => await _secrets.GetAsync(key) + : null; + var session = new Session( connection, character.Name, @@ -61,27 +69,11 @@ public async Task LaunchAsync( _triggerEngine, triggerRules, _notifier, - _history); + _history, + autoLoginProvider); await session.ConnectAsync(world.Host, world.Port, cancellationToken); - try - { - if (character.ConnectSecretKey is { } key) - { - var secret = await _secrets.GetAsync(key); - if (!string.IsNullOrWhiteSpace(secret)) - { - await session.SendAsync(secret); - } - } - } - catch - { - await session.DisposeAsync(); - throw; - } - return session; } diff --git a/tests/SharpClient.Tests/Sessions/SessionStateTests.cs b/tests/SharpClient.Tests/Sessions/SessionStateTests.cs index ebb2130..875cccf 100644 --- a/tests/SharpClient.Tests/Sessions/SessionStateTests.cs +++ b/tests/SharpClient.Tests/Sessions/SessionStateTests.cs @@ -33,6 +33,37 @@ public async Task SessionParsesLinesFromAnyConnection() await Assert.That(session.Scrollback[0].Segments[0].Text).IsEqualTo("plain"); } + [Test] + public async Task SessionSendsAutoLoginOnConnectAndReconnect() + { + var fake = new FakeTelnetConnection(); + await using var session = new Session( + fake, autoLoginProvider: () => ValueTask.FromResult("connect Foo secret")); + + await session.ConnectAsync("host", 1); // fake raises Connected + await Task.Delay(50); + // Simulate an unexpected drop + automatic reconnect. + fake.RaiseState(ConnectionState.Reconnecting); + fake.RaiseState(ConnectionState.Connected); + await Task.Delay(50); + + // Login is re-sent on the reconnect, not just the initial connect. + var expected = new[] { "connect Foo secret", "connect Foo secret" }; + await Assert.That(fake.Sent).IsEquivalentTo(expected); + } + + [Test] + public async Task SessionWithoutAutoLoginSendsNothingOnConnect() + { + var fake = new FakeTelnetConnection(); + await using var session = new Session(fake); + + await session.ConnectAsync("host", 1); + await Task.Delay(50); + + await Assert.That(fake.Sent).IsEmpty(); + } + [Test] public async Task ReconnectingAndErrorAreDistinctStates() { From ec4767b9f161ef323c70535b98f548234ee12b86 Mon Sep 17 00:00:00 2001 From: Harry Cordewener Date: Wed, 1 Jul 2026 08:36:06 -0500 Subject: [PATCH 2/2] feat(connection): resilient reconnect for changing networks + Disconnect button (#21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes a session survive the network churn of a long drive (WiFi↔cellular, tower handoffs, dead zones) and adds a manual escape hatch. Core: - ForceReconnectAsync() on TelnetConnection/ITelnetConnection — reconnect now to the last endpoint, tearing down the dead socket and any backoff; resumes even after auto-reconnect gave up (Error). No-op after an intentional disconnect. - Tune TCP keepalive (30s idle / 10s interval / 3 probes) so a silently-dead peer surfaces in ~1 min instead of the OS default (~2h). - Application-level IAC NOP heartbeat (45s) that keeps the server session alive and drops+reconnects when a write fails. - ReconnectOptions.Default retries far longer (MaxAttempts 6 → 60, ~30 min) so a long dead zone doesn't permanently give up. - Intentional DisconnectAsync + ForceReconnectAsync on ISession/Session. Android: - Keep-alive service now watches the DEFAULT network: on a change it pins the process to the new network (BindProcessToNetwork) and signals a reconnect; ConnectionKeepAliveCoordinator turns that into an immediate ForceReconnect on every session. NetworkChangeSignal bridges the two without the coordinator referencing Android-only types. UI: - Disconnect button in the session header (drops the connection / stops auto-reconnect) for when the client thinks it's connected but the socket is dead. Builds off the reconnect auto-login fix so a reconnected session re-authenticates. Core 198 / UI 47 / Data 17 tests pass; Android head builds clean. Claude-Session: https://claude.ai/code/session_01HHCRc5CJ6595iMzEgYYdQp Co-authored-by: Claude Opus 4.8 --- .../Android/ConnectionKeepAliveService.cs | 52 ++++++++-- .../ConnectionKeepAliveCoordinator.cs | 13 +++ .../Services/NetworkChangeSignal.cs | 17 ++++ .../Connection/ITelnetConnection.cs | 7 ++ .../Connection/ReconnectOptions.cs | 10 +- .../Connection/TelnetConnection.cs | 97 +++++++++++++++++++ .../Presentation/SessionsViewModel.cs | 11 +++ src/SharpClient.Core/Sessions/ISession.cs | 6 ++ src/SharpClient.Core/Sessions/Session.cs | 4 + .../Components/SessionScreen.razor | 17 ++++ src/SharpClient.UI/wwwroot/app.css | 27 ++++++ .../Sessions/FakeTelnetConnection.cs | 9 ++ .../Sessions/SessionStateTests.cs | 23 +++++ 13 files changed, 282 insertions(+), 11 deletions(-) create mode 100644 src/SharpClient.App/Services/NetworkChangeSignal.cs diff --git a/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs b/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs index 56b009a..1d0f533 100644 --- a/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs +++ b/src/SharpClient.App/Platforms/Android/ConnectionKeepAliveService.cs @@ -37,6 +37,8 @@ internal sealed class ConnectionKeepAliveService : Service private PowerManager.WakeLock? _wakeLock; private ConnectivityManager? _connectivity; private ConnectivityManager.NetworkCallback? _networkCallback; + private Network? _boundNetwork; + private bool _haveSeenNetwork; // ── Service lifecycle ───────────────────────────────────────────────────── @@ -101,9 +103,11 @@ private void ReleaseWakeLock() // ── Connectivity monitoring ─────────────────────────────────────────────── // - // Telnet reconnection itself is owned by the Core layer's auto-reconnect; this callback only - // refreshes the persistent notification when connectivity returns so the user sees the service - // is healthy and the process stays alive across network transitions. + // Watches the device's DEFAULT network. When it changes on the move (WiFi↔cellular, tower + // handoff, coming out of a dead zone) the old sockets are silently dead. We (a) pin the process + // to the new network so reconnects use the live interface at once, and (b) raise a signal that + // the coordinator turns into an immediate ForceReconnect on every session — instead of waiting + // out the Core backoff / keepalive. private void RegisterNetworkCallback() { @@ -119,10 +123,9 @@ private void RegisterNetworkCallback() } _networkCallback = new KeepAliveNetworkCallback(this); - var request = new NetworkRequest.Builder() - .AddCapability(NetCapability.Internet)! - .Build(); - _connectivity.RegisterNetworkCallback(request!, _networkCallback); + // Default-network callback (API 24+): fires OnAvailable when the default network changes and + // OnLost when it drops — exactly the transitions that kill a moving connection. + _connectivity.RegisterDefaultNetworkCallback(_networkCallback); } private void UnregisterNetworkCallback() @@ -139,17 +142,47 @@ private void UnregisterNetworkCallback() } } + // Release any network binding so the process isn't pinned once we stop. + if (_boundNetwork is not null) + { + try { _connectivity?.BindProcessToNetwork(null); } catch (Java.Lang.Exception) { } + _boundNetwork = null; + } + _haveSeenNetwork = false; + _networkCallback?.Dispose(); _networkCallback = null; _connectivity = null; } - private void OnNetworkAvailable() + private void OnNetworkAvailable(Network network) { + // Pin the process to the new default network so reconnects use the live interface immediately. + try { _connectivity?.BindProcessToNetwork(network); } catch (Java.Lang.Exception) { } + _boundNetwork = network; + + // The first callback is the network we're already connected on (service just started) — don't + // churn. Any later one is a genuine change/return, so kick every session to reconnect now. + if (_haveSeenNetwork) + { + Services.NetworkChangeSignal.RaiseChanged(); + } + _haveSeenNetwork = true; + var nm = (NotificationManager?)GetSystemService(NotificationService); nm?.Notify(NotificationId, BuildNotification("Connected")); } + private void OnNetworkLost(Network network) + { + // Drop the binding to the dead network so a new default can be used once it appears. + if (_boundNetwork is not null) + { + try { _connectivity?.BindProcessToNetwork(null); } catch (Java.Lang.Exception) { } + _boundNetwork = null; + } + } + // ── Helpers ─────────────────────────────────────────────────────────────── private void EnsureNotificationChannel() @@ -173,6 +206,7 @@ private Notification BuildNotification(string statusText) => private sealed class KeepAliveNetworkCallback(ConnectionKeepAliveService service) : ConnectivityManager.NetworkCallback { - public override void OnAvailable(Network network) => service.OnNetworkAvailable(); + public override void OnAvailable(Network network) => service.OnNetworkAvailable(network); + public override void OnLost(Network network) => service.OnNetworkLost(network); } } diff --git a/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs b/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs index 46cacdf..2d7fc5d 100644 --- a/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs +++ b/src/SharpClient.App/Services/ConnectionKeepAliveCoordinator.cs @@ -22,9 +22,21 @@ public ConnectionKeepAliveCoordinator(ISessionManager sessions, IConnectionKeepA _sessions = sessions; _keepAlive = keepAlive; _sessions.Changed += OnSessionsChanged; + NetworkChangeSignal.Changed += OnNetworkChanged; OnSessionsChanged(); } + // The device's default network changed (raised on Android by the keep-alive service). Every + // session's socket is now dead, so force each one to reconnect immediately rather than waiting + // out the Core backoff. ForceReconnectAsync is a no-op for intentionally-disconnected sessions. + private void OnNetworkChanged() + { + foreach (var session in _sessions.Sessions) + { + _ = session.ForceReconnectAsync(); + } + } + private void OnSessionsChanged() { lock (_gate) @@ -78,6 +90,7 @@ private void Evaluate() public void Dispose() { + NetworkChangeSignal.Changed -= OnNetworkChanged; lock (_gate) { _sessions.Changed -= OnSessionsChanged; diff --git a/src/SharpClient.App/Services/NetworkChangeSignal.cs b/src/SharpClient.App/Services/NetworkChangeSignal.cs new file mode 100644 index 0000000..3805dcf --- /dev/null +++ b/src/SharpClient.App/Services/NetworkChangeSignal.cs @@ -0,0 +1,17 @@ +namespace SharpClient.App.Services; + +/// +/// Process-wide bridge for "the device's default network changed" events. The Android +/// keep-alive service (which owns the connectivity callback but has no session references) +/// raises it; (which has the sessions but is +/// platform-agnostic) listens and forces a reconnect. Keeping it here — not in the Android +/// service type — lets the coordinator subscribe without referencing any Android-only API. +/// On platforms that never raise it, subscribers simply never fire. +/// +internal static class NetworkChangeSignal +{ + /// Raised when the active/default network changes (e.g. WiFi↔cellular, tower handoff). + public static event Action? Changed; + + public static void RaiseChanged() => Changed?.Invoke(); +} diff --git a/src/SharpClient.Core/Connection/ITelnetConnection.cs b/src/SharpClient.Core/Connection/ITelnetConnection.cs index 65cfd5e..ac56a7b 100644 --- a/src/SharpClient.Core/Connection/ITelnetConnection.cs +++ b/src/SharpClient.Core/Connection/ITelnetConnection.cs @@ -17,6 +17,13 @@ public interface ITelnetConnection : IAsyncDisposable public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default); + /// + /// Immediately (re)connects to the last endpoint, dropping the current socket and any reconnect + /// backoff. Used to recover fast when an external signal (e.g. an Android network-change + /// callback) indicates connectivity returned. No-op after an intentional disconnect. + /// + public Task ForceReconnectAsync(); + public Task SendAsync(string line); public Task DisconnectAsync(); diff --git a/src/SharpClient.Core/Connection/ReconnectOptions.cs b/src/SharpClient.Core/Connection/ReconnectOptions.cs index f50c637..dd17b21 100644 --- a/src/SharpClient.Core/Connection/ReconnectOptions.cs +++ b/src/SharpClient.Core/Connection/ReconnectOptions.cs @@ -8,8 +8,14 @@ namespace SharpClient.Core.Connection; /// public sealed record ReconnectOptions { - /// Maximum number of reconnect attempts before giving up with . - public int MaxAttempts { get; init; } = 6; + /// + /// Maximum number of reconnect attempts before giving up with . + /// Defaults high so a session survives long connectivity gaps (tunnels, rural dead zones on a + /// drive): with the backoff capped at , ~60 attempts is roughly half an + /// hour of retrying. A returning network can also resume sooner via + /// (e.g. driven by an Android connectivity callback). + /// + public int MaxAttempts { get; init; } = 60; /// Delay before the first reconnect attempt. public TimeSpan InitialDelay { get; init; } = TimeSpan.FromSeconds(1); diff --git a/src/SharpClient.Core/Connection/TelnetConnection.cs b/src/SharpClient.Core/Connection/TelnetConnection.cs index d1086e2..724e063 100644 --- a/src/SharpClient.Core/Connection/TelnetConnection.cs +++ b/src/SharpClient.Core/Connection/TelnetConnection.cs @@ -21,6 +21,17 @@ public sealed class TelnetConnection : ITelnetConnection private int _port; private bool _intentionalDisconnect; + private CancellationTokenSource? _heartbeatCts; + private Task? _heartbeatTask; + + // Telnet NOP (IAC NOP). Sent periodically as an application-level heartbeat: it keeps the MUSH + // from idle-timing us out and forces a small write, so a silently-dead socket surfaces quickly. + private static readonly byte[] s_iacNop = [255, 241]; + + // Heartbeat cadence. Short enough to detect a dead peer and keep the server session alive on a + // long-running (e.g. in-car) connection, without meaningful battery cost. + private static readonly TimeSpan HeartbeatInterval = TimeSpan.FromSeconds(45); + public TelnetConnection(ITelnetInterpreterFactory factory, ReconnectOptions? reconnectOptions = null) { _factory = factory; @@ -66,6 +77,27 @@ public async Task ConnectAsync(string host, int port, CancellationToken cancella SetState(ConnectionState.Error); throw; } + + StartHeartbeat(); + } + + /// + /// Immediately (re)connects to the last endpoint, tearing down the current — possibly dead — + /// socket and any in-flight reconnect backoff first. Intended for external "the network just + /// changed / came back" signals (e.g. an Android connectivity callback) so a session recovers + /// at once instead of waiting out the backoff, and resumes even after auto-reconnect gave up + /// (). No-op if the user intentionally disconnected or the + /// connection was never established. + /// + public async Task ForceReconnectAsync() + { + var host = _host; + if (_intentionalDisconnect || host is null) + { + return; + } + + await ConnectAsync(host, _port); } public async Task SendAsync(string line) @@ -101,6 +133,8 @@ public async Task DisconnectAsync() _intentionalDisconnect = true; _reconnectCts?.Cancel(); + await StopHeartbeatAsync(); + if (_interpreter is not null) { await _interpreter.DisposeAsync(); @@ -132,6 +166,66 @@ public async Task DisconnectAsync() public async ValueTask DisposeAsync() => await DisconnectAsync(); + private static void TrySetTcpKeepAlive(Socket socket) + { + // Each option is best-effort: not every platform supports all three. Values are in seconds: + // start probing after 30s idle, probe every 10s, drop after 3 unanswered probes (~60s to death). + try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveTime, 30); } catch { } + try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveInterval, 10); } catch { } + try { socket.SetSocketOption(SocketOptionLevel.Tcp, SocketOptionName.TcpKeepAliveRetryCount, 3); } catch { } + } + + // Application-level heartbeat: spans the whole connect lifetime (including auto-reconnect gaps), + // sending a telnet NOP while Connected. Started on connect, stopped on intentional disconnect. + private void StartHeartbeat() + { + _heartbeatCts = new CancellationTokenSource(); + _heartbeatTask = HeartbeatLoopAsync(_heartbeatCts.Token); + } + + private async Task StopHeartbeatAsync() + { + _heartbeatCts?.Cancel(); + if (_heartbeatTask is not null) + { + await AwaitQuietlyAsync(_heartbeatTask); + _heartbeatTask = null; + } + _heartbeatCts?.Dispose(); + _heartbeatCts = null; + } + + private async Task HeartbeatLoopAsync(CancellationToken token) + { + try + { + while (!token.IsCancellationRequested) + { + await Task.Delay(HeartbeatInterval, token); + + // Only probe an established connection; during Reconnecting/Error the socket is gone. + if (State != ConnectionState.Connected || _interpreter is null) + { + continue; + } + + try + { + await _interpreter.WriteToNetworkAsync(s_iacNop, CancellationToken.None); + } + catch + { + // The write failed — the socket is dead. Tear it down so the read loop completes + // and the monitor drives an auto-reconnect. + _client?.Dispose(); + } + } + } + catch (OperationCanceledException) + { + } + } + // Performs the actual TCP connect + telnet negotiation and starts the read-loop // monitor. Used both for the initial connect and for each reconnect attempt. private async Task EstablishConnectionAsync(string host, int port, CancellationToken cancellationToken) @@ -140,6 +234,9 @@ private async Task EstablishConnectionAsync(string host, int port, CancellationT // SO_KEEPALIVE lets the OS probe a silently-dead peer so a half-open socket // eventually surfaces as a read-loop completion instead of hanging forever. client.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true); + // Tune the keepalive so a dead peer surfaces in ~1 minute instead of the OS default + // (often ~2h idle) — important on mobile networks that change while moving. + TrySetTcpKeepAlive(client.Client); await client.ConnectAsync(host, port, cancellationToken); var (interpreter, readTask) = await _factory.CreateBuilder() diff --git a/src/SharpClient.Core/Presentation/SessionsViewModel.cs b/src/SharpClient.Core/Presentation/SessionsViewModel.cs index d88be74..f74bb44 100644 --- a/src/SharpClient.Core/Presentation/SessionsViewModel.cs +++ b/src/SharpClient.Core/Presentation/SessionsViewModel.cs @@ -31,6 +31,10 @@ public SessionsViewModel(ISessionManager manager) public bool CanSend => Active?.State == ConnectionState.Connected && !string.IsNullOrWhiteSpace(Input); + /// True while the active session has a live-ish connection the user could drop. + public bool CanDisconnect => + Active is { State: ConnectionState.Connected or ConnectionState.Connecting or ConnectionState.Reconnecting }; + public IReadOnlyList History => Active is not null && _histories.TryGetValue(Active, out var h) ? h : []; @@ -40,6 +44,13 @@ public SessionsViewModel(ISessionManager manager) public Task CloseAsync(ISession session) => _manager.CloseAsync(session); + /// + /// Intentionally drop the active session's connection. Stops auto-reconnect (the user can + /// reconnect from the world list), which is the escape hatch when the client thinks it is + /// connected but the socket is actually dead. + /// + public Task DisconnectAsync() => Active?.DisconnectAsync() ?? Task.CompletedTask; + public async Task SendAsync() { if (!CanSend || Active is null) diff --git a/src/SharpClient.Core/Sessions/ISession.cs b/src/SharpClient.Core/Sessions/ISession.cs index ef1b2ec..2c2333f 100644 --- a/src/SharpClient.Core/Sessions/ISession.cs +++ b/src/SharpClient.Core/Sessions/ISession.cs @@ -28,6 +28,12 @@ public event Action? ProtocolChanged { add { } remove { } } public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default); + /// Intentionally drop the connection (stops auto-reconnect) while keeping the session. + public Task DisconnectAsync() => Task.CompletedTask; + + /// Force an immediate reconnect to the same endpoint (e.g. after a network change). + public Task ForceReconnectAsync() => Task.CompletedTask; + public Task SendAsync(string line); public Task SendWindowSizeAsync(int cols, int rows) => Task.CompletedTask; diff --git a/src/SharpClient.Core/Sessions/Session.cs b/src/SharpClient.Core/Sessions/Session.cs index fa43fe6..ff3b755 100644 --- a/src/SharpClient.Core/Sessions/Session.cs +++ b/src/SharpClient.Core/Sessions/Session.cs @@ -107,6 +107,10 @@ public event Action? ProtocolChanged public Task ConnectAsync(string host, int port, CancellationToken cancellationToken = default) => _connection.ConnectAsync(host, port, cancellationToken); + public Task DisconnectAsync() => _connection.DisconnectAsync(); + + public Task ForceReconnectAsync() => _connection.ForceReconnectAsync(); + private static readonly TextStyle EchoStyle = TextStyle.Default with { Foreground = AnsiColor.Indexed(8) }; public async Task SendAsync(string line) diff --git a/src/SharpClient.UI/Components/SessionScreen.razor b/src/SharpClient.UI/Components/SessionScreen.razor index 2425b30..2a287b5 100644 --- a/src/SharpClient.UI/Components/SessionScreen.razor +++ b/src/SharpClient.UI/Components/SessionScreen.razor @@ -16,6 +16,17 @@
@* Connection state is already shown as a coloured dot on each tab (SessionTabs), so the redundant "Connected" state pill has been removed from the header. *@ + @if (Vm.CanDisconnect) + { + + } @if (ProtocolVm is not null) {