using System.Globalization;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using DodoSSH.Client.Domain;
using DodoSSH.Client.Ssh;
namespace DodoSSH.Client.Shell.ViewModels;
/// Where one of a tab's forwards has got to.
internal enum PortForwardState
{
/// Not listening: switched off, or its session is over.
Stopped = 0,
/// Asked for and not answered yet. A remote forward waits on the server.
Starting = 1,
/// Listening.
Listening = 2,
/// Could not be started; says why.
Failed = 3,
}
///
/// One of a host's saved forwards, as it runs on one terminal tab.
///
///
///
/// Built fresh for each session the tab holds, and started with it: the rule is the host's, the listener is
/// this session's. A reconnect is a new session, so it gets new rows — see
/// — rather than trying to carry a listener across a
/// connection that no longer exists.
///
///
/// One failing does not stop the others, or the terminal. The commonest failure is a port already in
/// use — very often by a second tab to the same host, which is running the same forwards — and a shell that
/// would not open because of it would be the forward taking the session hostage. The row says why instead.
///
///
internal sealed partial class PortForwardViewModel : ObservableObject
{
private readonly Func> start;
private readonly Action post;
private ISshPortForward? forward;
private bool sessionOver;
/// The detail line: why it failed, or what last went wrong through it.
[ObservableProperty]
private string? detail;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsListening))]
[NotifyPropertyChangedFor(nameof(IsFailed))]
[NotifyPropertyChangedFor(nameof(CanToggle))]
[NotifyPropertyChangedFor(nameof(StateText))]
[NotifyCanExecuteChangedFor(nameof(ToggleCommand))]
private PortForwardState state;
/// The host's saved forward.
/// Starts a forward on this tab's session; see TerminalWorkspace.StartForwardAsync.
/// Runs an action on the UI thread, for reports SSH.NET raises on its own.
internal PortForwardViewModel(
PortForwardRule rule,
Func> start,
Action post)
{
Rule = rule;
this.start = start;
this.post = post;
}
/// The saved forward this row runs.
internal PortForwardRule Rule { get; }
/// The forward in OpenSSH's notation, e.g. -L 5432:db.internal:5432.
internal string Notation => Rule.Notation;
/// What the forward connects to what, in words rather than flags.
///
/// Which end each port is on is the thing people get backwards about -L and -R, so it is
/// written out: "this machine" and "the remote" rather than an arrow whose direction has to be learned.
///
internal string Route => Rule.Kind switch
{
PortForwardKind.Local =>
$"localhost:{Port(Rule.BindPort)} here → {Rule.TargetHost}:{Port(Rule.TargetPort)} from the remote",
PortForwardKind.Remote =>
$"localhost:{Port(Rule.BindPort)} on the remote → {Rule.TargetHost}:{Port(Rule.TargetPort)} from here",
_ => $"SOCKS proxy on localhost:{Port(Rule.BindPort)}, out through the remote",
};
/// Whether it is listening now.
internal bool IsListening => State is PortForwardState.Listening;
/// Whether it could not be started.
internal bool IsFailed => State is PortForwardState.Failed;
///
/// Whether the switch does anything: not while a start is in flight, and never once the session is over.
///
internal bool CanToggle => !sessionOver && State is not PortForwardState.Starting;
/// The state in the status bar's own lettering.
internal string StateText => State switch
{
PortForwardState.Starting => "STARTING",
PortForwardState.Listening => "LISTENING",
PortForwardState.Failed => "FAILED",
_ => "OFF",
};
/// Starts the forward, unless it is already running or its session has ended.
///
/// Never throws. It is started without anyone waiting on it — every forward on a tab at once, as the
/// tab opens — so a failure has nowhere to go but this row.
///
internal async Task StartAsync()
{
if (sessionOver || State is PortForwardState.Starting or PortForwardState.Listening)
{
return;
}
State = PortForwardState.Starting;
Detail = null;
ISshPortForward started;
try
{
started = await start(ToSpec(Rule), CancellationToken.None).ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Unless the session ended while this was in flight, in which case that is the true story and
// SessionEnded has already told it.
if (!sessionOver)
{
Detail = exception.Message;
State = PortForwardState.Failed;
}
return;
}
if (sessionOver)
{
// The session ended while the server was answering. Its connection has gone and taken the
// listener with it; this only lets go of the handle.
await started.DisposeAsync().ConfigureAwait(true);
return;
}
forward = started;
started.ErrorOccurred += OnForwardError;
State = PortForwardState.Listening;
}
/// Stops the forward and leaves the row switched off.
internal async Task StopAsync()
{
await ReleaseAsync().ConfigureAwait(true);
if (!sessionOver)
{
State = PortForwardState.Stopped;
Detail = null;
}
}
///
/// Records that the session this forward ran over has ended, which stopped the forward with it.
///
///
/// A failed row keeps its reason: that the session later ended does not make it any less true that the
/// port was taken. Anything else is off, and says it went with the session rather than being switched
/// off, which is the difference between "I did that" and "that happened".
///
internal void SessionEnded()
{
if (sessionOver)
{
return;
}
sessionOver = true;
_ = ReleaseAsync();
if (State is not PortForwardState.Failed)
{
State = PortForwardState.Stopped;
Detail = "Stopped with the session.";
}
OnPropertyChanged(nameof(CanToggle));
ToggleCommand.NotifyCanExecuteChanged();
}
/// The SSH layer's spec for a saved rule; the two enums share their numbering on purpose.
internal static SshPortForwardSpec ToSpec(PortForwardRule rule) => rule.Kind switch
{
PortForwardKind.Local => new SshPortForwardSpec(
SshPortForwardKind.Local, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
PortForwardKind.Remote => new SshPortForwardSpec(
SshPortForwardKind.Remote, rule.BindPort, rule.TargetHost, rule.TargetPort ?? 0),
_ => new SshPortForwardSpec(SshPortForwardKind.Dynamic, rule.BindPort),
};
/// A failed row's switch is a retry: the port may have been freed since.
[RelayCommand(CanExecute = nameof(CanToggle))]
private Task ToggleAsync() => IsListening ? StopAsync() : StartAsync();
private async Task ReleaseAsync()
{
if (forward is not { } running)
{
return;
}
forward = null;
running.ErrorOccurred -= OnForwardError;
try
{
await running.DisposeAsync().ConfigureAwait(true);
}
catch (Exception exception) when (exception is not OutOfMemoryException)
{
// Stopping a listener has no failure anybody could act on; the connection closing would have
// stopped it regardless.
}
}
///
/// A connection through the forward failed — usually the target refusing it. The listener is still up,
/// so the row stays LISTENING and says what happened underneath.
///
private void OnForwardError(object? sender, SshPortForwardErrorEventArgs e) => post(() =>
{
if (State is PortForwardState.Listening)
{
Detail = e.Message;
}
});
private static string Port(int? port) => port?.ToString(CultureInfo.InvariantCulture) ?? "?";
}