Start port forwards on the phone, listed in a sheet off its terminal
ci / build and test (pull_request) Successful in 3m32s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 5m17s
ci / api image (pull_request) Failing after 13s

The Android head skipped a host's forwards because it had nowhere to show
what was listening. Its terminal bar now draws a ⇄ for a tab that has
forwards, carrying the listening count in green and turning the danger
colour when one failed, and it raises a sheet over the same rows the
desktop sidebar lists: notation, route, state, failure reason and a tap
to switch each off or on. MainWindowViewModel.StartForwards no longer
returns early on Android.

The sheet is shell state like the connect sheet: it collapses the
renderer while up, is lowered by leaving the terminal, and the back
gesture closes it first.

Forwards ride the shell's keep-alive rather than holding the foreground
service themselves, since a forward cannot outlive its shell. They only
change the notification, which now counts ports forwarded beside shells
connected, fed by a new ForwardsChanged event and ListeningForwardCount.

The phone still has no editor for forwards; the More screen card, README
and design-import-gaps now say they run here and are edited on the
desktop.
This commit is contained in:
jaap-jan committed 2026-09-28 21:06:05 +02:00
1 parent f05deb4fbc
commit c3dfa42108
13 files changed
+415 -56

No files matched your search

@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
/// <param name="liveSessions">Shells with a live channel behind them.</param>
/// <param name="activeTransfers">Transfers still moving bytes.</param>
/// <param name="holdsFileSession">Whether the Files screen holds a live, idle SFTP connection.</param>
/// <param name="listeningForwards">
/// Port forwards listening over those shells. Named in the summary and nothing else: a forward rides a
/// shell's connection, so it cannot be the reason the process has to stay up — see
/// <see cref="SessionKeepAlive"/>.
/// </param>
/// <remarks>
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
/// always. Something live and the service not yet running starts it. Something live and the service
@@ -152,7 +157,7 @@ internal sealed class SessionForegroundService : Service
/// which needs no foreground-start permission at all. A foregrounded refresh still prefers a real
/// start even over a service that looks like it is running; the inline remark below is why.
/// </remarks>
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession)
public static void Reconcile(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{
var context = PhoneEnvironment.Require();
@@ -165,7 +170,7 @@ internal sealed class SessionForegroundService : Service
// The summary says what is actually held, counted rather than generic — the same principle the
// delete confirmations follow. "DodoSSH is running" would tell the user nothing they could act on.
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession);
var summary = Summarise(liveSessions, activeTransfers, holdsFileSession, listeningForwards);
// In-place only while backgrounded, where it is the only legal move. Foregrounded, a real start is
// always allowed and is preferred even when `running` says the service is up: a disconnect followed
@@ -255,15 +260,22 @@ internal sealed class SessionForegroundService : Service
activity.RequestPermissions([postNotifications], NotificationPermissionRequestCode));
}
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession)
private static string Summarise(int liveSessions, int activeTransfers, bool holdsFileSession, int listeningForwards)
{
var parts = new List<string>(3);
var parts = new List<string>(4);
if (liveSessions > 0)
{
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
}
// Beside the shells they run over, and only while one is: a count left over from a session that has
// just ended would be a receipt for ports nothing holds open any more.
if (liveSessions > 0 && listeningForwards > 0)
{
parts.Add(listeningForwards == 1 ? "1 port forwarded" : $"{listeningForwards} ports forwarded");
}
if (holdsFileSession)
{
parts.Add("Files connected");
@@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform;
/// looked, to this class, exactly like nothing being open at all. <c>holdsFileSession</c> below is that
/// gap closed, read the same way the other two facts are: asked, not cached.
/// </para>
/// <para>
/// Listening port forwards are a fourth fact, and a different kind: they only change what the notification
/// says. A forward runs over a shell's connection and stops with it, so there is never a listening forward
/// without a live shell already holding the service up — and a count that could start the service on its own
/// would only ever be wrong, in the moment a forward's Stopped had not been heard yet. What they add is the
/// receipt: the user is entitled to know the phone is holding ports open, as well as shells.
/// </para>
/// </remarks>
internal sealed class SessionKeepAlive : IDisposable
{
private readonly TerminalWorkspace workspace;
private readonly Func<int> activeTransfers;
private readonly Func<bool> holdsFileSession;
private readonly Func<int> listeningForwards;
/// <param name="workspace">The live shells.</param>
/// <param name="activeTransfers">
@@ -40,11 +48,20 @@ internal sealed class SessionKeepAlive : IDisposable
/// Whether the Files screen holds a live SFTP connection with nothing moving on it — the idle-but-
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
/// </param>
public SessionKeepAlive(TerminalWorkspace workspace, Func<int> activeTransfers, Func<bool> holdsFileSession)
/// <param name="listeningForwards">
/// How many port forwards are listening, across every tab. For the notification's wording only; see the
/// class remarks. See <c>MainWindowViewModel.ListeningForwardCount</c>.
/// </param>
public SessionKeepAlive(
TerminalWorkspace workspace,
Func<int> activeTransfers,
Func<bool> holdsFileSession,
Func<int> listeningForwards)
{
this.workspace = workspace;
this.activeTransfers = activeTransfers;
this.holdsFileSession = holdsFileSession;
this.listeningForwards = listeningForwards;
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
// closer's own — which is fine: starting and stopping a service is a binder call and needs no
@@ -56,14 +73,15 @@ internal sealed class SessionKeepAlive : IDisposable
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
/// <remarks>
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
/// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
/// finishing, a Files connection opening or closing, a forward starting or stopping. Calling it when nothing changed is free:
/// reconciling to the state it is already in is either a redundant <c>startForegroundService</c> — or,
/// now, a redundant notification post — on a running service, or a <c>stopService</c> on a stopped one,
/// and Android treats all of those as no-ops.
/// </remarks>
public void Refresh() =>
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
SessionForegroundService.Reconcile(
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
/// <inheritdoc />
public void Dispose()
@@ -72,7 +90,7 @@ internal sealed class SessionKeepAlive : IDisposable
// The notification goes with the composition root. Leaving it up over a process that is shutting
// down is how an SSH client acquires a reputation for a notification you cannot get rid of.
SessionForegroundService.Reconcile(0, 0, false);
SessionForegroundService.Reconcile(0, 0, false, 0);
}
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();