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

@@ -1220,6 +1220,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </remarks>
internal event EventHandler? TerminalSessionOpened;
/// <summary>
/// Raised when any tab's forward starts, stops or fails, so a head can re-count what is listening.
/// </summary>
/// <remarks>
/// The phone's keep-alive notification is the one listener: it says how many forwards are listening
/// alongside how many shells are connected. On the UI thread, since every state change is.
/// </remarks>
internal event EventHandler? ForwardsChanged;
/// <summary>How many forwards are listening, across every tab.</summary>
internal int ListeningForwardCount => Tabs.Sum(tab => tab.ListeningForwardCount);
/// <summary>
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
/// view can put the keyboard back there.
@@ -1466,6 +1478,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
internal bool IsTerminalShowing =>
IsTerminalSurface
&& !IsConnectSheetOpen
&& !IsForwardsSheetOpen
&& !IsHostKeyDecisionShowing
&& SelectedTab is { HasSession: true };
@@ -2211,6 +2224,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void CloseConnectSheet() => IsConnectSheetOpen = false;
// ---- The phone's forwards sheet ----
/// <summary>
/// Whether the phone's list of the selected tab's forwards is open over the terminal.
/// </summary>
/// <remarks>
/// <para>
/// The phone's answer to the desktop sidebar's FORWARDS section, over the same rows —
/// <see cref="TerminalTabViewModel.Forwards"/> — with the same state word, reason and switch. A sheet
/// rather than something always drawn because a phone gives a shell the whole screen, so it is raised
/// from a button in the terminal's bar, which draws only for a tab that has forwards and turns the danger
/// colour when one of them failed.
/// </para>
/// <para>
/// Shell state for the reason <see cref="IsConnectSheetOpen"/> is: it collapses the renderer, which is a
/// native view nothing can be drawn over. Lowered with the connect sheet whenever the terminal surface is
/// left — see <see cref="OnSurfaceChanged"/>.
/// </para>
/// </remarks>
[ObservableProperty]
private bool isForwardsSheetOpen;
/// <summary>Raises the forwards sheet over the terminal.</summary>
/// <remarks>Gated as <see cref="OpenConnectSheet"/> is, and on there being a tab to list.</remarks>
[RelayCommand]
private void OpenForwardsSheet()
{
if (!IsTerminalSurface || SelectedTab is null)
{
return;
}
IsForwardsSheetOpen = true;
}
/// <summary>Lowers the forwards sheet, leaving the terminal where it was.</summary>
[RelayCommand]
private void CloseForwardsSheet() => IsForwardsSheetOpen = false;
// ---- Open terminals ----
/// <summary>
@@ -3875,19 +3927,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// for the tab's current id would start its listener on a connection it was never part of.
/// </para>
/// <para>
/// ◆ <b>Not on the phone.</b> This view model is shared, and the forwarding would work there unchanged —
/// but that head has no surface listing a tab's forwards, and listeners opened on a phone that never shows
/// them are exactly the invisible state this codebase refuses elsewhere. Its More screen says forwarding
/// is desktop-only for now. A runtime check for the reason <c>RendererNeverStarted</c> gives.
/// ◆ <b>On the phone too.</b> This used to return early on Android, because that head had nowhere to show
/// what was listening. Its terminal bar now carries a FORWARDS button over the same rows the desktop's
/// sidebar lists — see <see cref="IsForwardsSheetOpen"/> — so both heads start the same forwards.
/// </para>
/// </remarks>
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
{
if (OperatingSystem.IsAndroid())
{
return;
}
var sessionId = e.SessionId;
tab.ReplaceForwards(e.Forwards.Select(rule => new PortForwardViewModel(
@@ -3897,8 +3943,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
foreach (var forward in tab.Forwards)
{
// Never unsubscribed, and it need not be: the row holds the handler, not the other way round, so a
// row replaced by a reconnect is collected with it — after its last change, to Stopped, is heard.
forward.PropertyChanged += OnForwardChanged;
_ = forward.StartAsync();
}
ForwardsChanged?.Invoke(this, EventArgs.Empty);
}
private void OnForwardChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(PortForwardViewModel.State))
{
ForwardsChanged?.Invoke(this, EventArgs.Empty);
}
}
/// <summary>
@@ -5187,6 +5246,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
if (value is not ShellSurface.Terminal)
{
IsConnectSheetOpen = false;
IsForwardsSheetOpen = false;
}
RaiseSurfaceState();
@@ -5371,6 +5431,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
/// <inheritdoc cref="OnIsSearchingChanged" />
partial void OnIsForwardsSheetOpenChanged(bool value) => RaiseTerminalState();
/// <remarks>
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
/// <see cref="IsAskingForThePassphrase"/>.