Public Access
Start port forwards on the phone, listed in a sheet off its terminal
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:
1 parent
f05deb4fbc
commit
c3dfa42108
13 files changed
+415
-56
No files matched your search
@@ -432,8 +432,9 @@ A host can carry **port forwards**, added under PORT FORWARDS in its editor, in
|
|||||||
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
|
**local** (`-L`, a port here reaching a machine the remote can see), **remote** (`-R`, a port on the remote
|
||||||
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
|
reaching back to one this machine can see) and **dynamic** (`-D`, a SOCKS proxy here whose connections leave
|
||||||
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
|
from the remote). They are saved in the host's encrypted payload, so they sync, merge and are shared with the
|
||||||
host, and they start with every terminal opened to it. The session sidebar lists them under FORWARDS with the
|
host, and they start with every terminal opened to it, on either head. The session sidebar lists them under
|
||||||
state of each, and the status bar counts the ones listening.
|
FORWARDS with the state of each, and the status bar counts the ones listening; on the phone the same list is a
|
||||||
|
sheet off the terminal's bar.
|
||||||
|
|
||||||
Three things about them are worth expecting rather than discovering.
|
Three things about them are worth expecting rather than discovering.
|
||||||
|
|
||||||
@@ -448,8 +449,8 @@ second tab to the same host runs the same forwards, and they fail there because
|
|||||||
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
|
says so, and the terminal is unaffected. Any row can be switched off and on while its session is up.
|
||||||
|
|
||||||
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
|
**No byte counts**, which the design draws: SSH.NET keeps none, so a number there would be invented. The
|
||||||
phone does not start forwards yet, because it has nowhere to show you they are running; its More screen says
|
phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see
|
||||||
so.
|
[The Android head](#the-android-head).
|
||||||
|
|
||||||
### Sharing a vault
|
### Sharing a vault
|
||||||
|
|
||||||
@@ -666,8 +667,9 @@ its host if it has one and back into the box if it does not.
|
|||||||
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
|
**A connected phone shows one bar and then the terminal.** The header, the session strip and the bottom
|
||||||
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
|
bar are collapsed while a shell is up, and a single 35-pixel row replaces them: back on the left, the
|
||||||
sessions as pills, and a `+` on the right offering the three connections this application can make — a
|
sessions as pills, and a `+` on the right offering the three connections this application can make — a
|
||||||
shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers
|
shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄`
|
||||||
that menu first if it is open.
|
opens them; see below. The system back gesture does what the arrow does, and lowers either sheet first if
|
||||||
|
one is open.
|
||||||
|
|
||||||
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
Widening the rail moved the desktop window's minimum from `880x560` to `1016x574`, which leaves every
|
||||||
screen exactly the width it was designed against.
|
screen exactly the width it was designed against.
|
||||||
@@ -735,10 +737,24 @@ What it does *not* yet do is use the extra width inside a screen: the host list
|
|||||||
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
|
size. Two columns needs the row model to change — the list is headings and hosts in one sequence, and a
|
||||||
heading has to span — which is shared with the desktop and is its own piece of work.
|
heading has to span — which is shared with the desktop and is its own piece of work.
|
||||||
|
|
||||||
**Port forwarding runs on the desktop and not here yet**, and the phone's More screen says so in a paragraph
|
**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every
|
||||||
rather than leaving a gap. The v2 design draws a whole screen for it. The forwarding itself is shared code and
|
terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has
|
||||||
would run here unchanged, but this head has no surface that lists what is listening, and a phone opening
|
any, carrying the number listening in green, or turning red when one could not start; it opens a sheet
|
||||||
listeners it never shows you is worse than one that opens none. See
|
listing the tab's forwards with their route, their state, the reason a failed one gave, and a tap on each to
|
||||||
|
switch it off or on — the desktop sidebar's FORWARDS section, over the same rows. The v2 design's whole
|
||||||
|
FORWARDING screen is not drawn, for the reason the desktop has none: a forward belongs to a host and runs
|
||||||
|
with a session, and both already have a place. What the phone has not got is an editor for them — its host
|
||||||
|
form has no PORT FORWARDS section — so they are added on the desktop and arrive with the host; the More
|
||||||
|
screen says so in a paragraph.
|
||||||
|
|
||||||
|
Forwards ride the **keep-alive** a shell already has. A forward runs over its tab's connection, so the
|
||||||
|
foreground service that holds a shell open in the background is holding its forwards open too, and nothing
|
||||||
|
else needs to: the service is never started for a forward alone, because there is never a listening forward
|
||||||
|
without a live shell. What forwards change is the notification, which counts them beside the shells —
|
||||||
|
`1 shell connected · 2 ports forwarded` — because a phone in a pocket holding ports open is something its
|
||||||
|
owner should be able to see. Local and SOCKS forwards listen on the phone's own loopback, which every app on
|
||||||
|
the phone can reach; that is what makes one useful here — a database client on the phone reaching a server
|
||||||
|
behind the remote — and it is the same exposure the desktop's localhost has to its own processes. See
|
||||||
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
[Forwarding ports](#forwarding-ports) and [`docs/design-import-gaps.md`](docs/design-import-gaps.md).
|
||||||
|
|
||||||
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
|
It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full
|
||||||
|
|||||||
@@ -631,8 +631,8 @@ go at 360dp:
|
|||||||
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
|
for. `ShellScreen` gained `More` and `Buckets`. The palette went blue and the two heads moved together,
|
||||||
because it is shared. What v2 asked for and did not get is in
|
because it is shared. What v2 asked for and did not get is in
|
||||||
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
|
[`docs/design-import-gaps.md`](design-import-gaps.md) — the short version being that port forwarding is
|
||||||
named rather than drawn: it has since been built for the desktop, and this head does not start forwards
|
named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with
|
||||||
because it has no surface yet that shows what is listening.
|
every terminal and lists them in a sheet off the terminal's bar. It cannot edit them yet.
|
||||||
|
|
||||||
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
|
**The bar has since gone from four entries to three.** The keychain joined the hub, which is now called
|
||||||
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
|
SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference
|
|||||||
>
|
>
|
||||||
> | v2 element | What ships instead |
|
> | v2 element | What ships instead |
|
||||||
> | --- | --- |
|
> | --- | --- |
|
||||||
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built on the desktop, not started on the phone, and said out loud.** Forwarding exists — `ISshConnection.StartForwardAsync`, rules saved in the host's payload — and the view model that starts them is shared, but this head has no surface listing what is listening, so `MainWindowViewModel.StartForwards` skips them on Android rather than open listeners the phone never shows. The MORE screen's paragraph says DESKTOP ONLY. Byte counts are not built on either head: SSH.NET keeps none. |
|
> | The **FORWARDING** screen: local/remote/dynamic rules, toggles, bytes transferred | **Built, as a sheet off the terminal rather than a screen, and running on the phone as on the desktop.** A host's forwards start with every terminal opened to it on either head — `MainWindowViewModel.StartForwards` no longer skips Android. The terminal's bar draws a `⇄` for a tab that has forwards, with the listening count in green and the glyph in the danger colour when one failed, and it raises a sheet (`IsForwardsSheetOpen`) listing `SelectedTab.Forwards` with notation, route, state word, failure reason and a tap-to-toggle per row — the desktop sidebar's FORWARDS section over the same rows. Not a screen, for the desktop's reason: a forward belongs to a host and runs with a session. **Two gaps remain.** The phone's host form has no PORT FORWARDS section, so forwards are edited on the desktop and arrive with the host; the MORE screen's paragraph, now tagged IN THE TERMINAL, says so. And byte counts are built on neither head: SSH.NET keeps none. The keep-alive notification counts listening forwards beside the shells they run over, and is never started for a forward alone, since a forward cannot outlive its shell. |
|
||||||
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwards anything, so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
> | `23 ms · fwd 5432` on the terminal's connection line | ◆ **The line is gone, and what was real on it moved.** There was never an RTT to draw — SSH.NET measures none — and nothing forwarded anything at the time (forwards now have the `⇄` in the bar above; see the row before this one), so what shipped was the account and endpoint actually dialled. In v3 a connected phone draws one 35-pixel bar and then the terminal, so a second 36-pixel row naming the machine is exactly the chrome that surface exists to give back: the address is on the connecting card, where it is read before anything has answered, and the shell's own prompt says it afterwards. The two text-size buttons that shared the line are pinned at the end of the accessory row, outside its scroller, which is what the line was protecting them from. |
|
||||||
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
|
> | `ED25519` badge and `SHA256:kQ9f…Zw2M` on every keychain card | `Detail`, which is what is genuinely known *about* an item. Unchanged from the first import: no algorithm field, no fingerprint, and computing either means parsing armour the type stores verbatim. |
|
||||||
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
|
> | An `agent` chip on a key | Omitted. There is no agent of any kind — see the first import's Vault section. |
|
||||||
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
|
> | Snippet cards footed with `edge-eu-1 · today 10:58 · exit 0` | The command and its badge. Nothing records where a snippet ran, when, or what it returned; the shell it is typed into never reports back. |
|
||||||
|
|||||||
@@ -132,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
var keepAlive = new SessionKeepAlive(
|
var keepAlive = new SessionKeepAlive(
|
||||||
workspace,
|
workspace,
|
||||||
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
activeTransfers: () => viewModel.Transfers.ActiveTransfers,
|
||||||
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession);
|
holdsFileSession: () => viewModel.Transfers.HasLiveFileSession,
|
||||||
|
listeningForwards: () => viewModel.ListeningForwardCount);
|
||||||
|
|
||||||
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
// The other end of the same wire: the workspace announces its own sessions ending, and the queue
|
||||||
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
// announces transfers appearing and finishing, and a Files session connecting or disconnecting.
|
||||||
@@ -148,6 +149,11 @@ public sealed partial class DodoSshApp : Avalonia.Application
|
|||||||
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
// in the first place, which was precisely the promise this whole arrangement exists to keep.
|
||||||
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
viewModel.TerminalSessionOpened += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
|
// A forward starting, stopping or failing changes the notification's wording and nothing else — it
|
||||||
|
// rides a shell's connection, so the shell is already what holds the service up. The user is still
|
||||||
|
// entitled to know a phone in their pocket is holding ports open.
|
||||||
|
viewModel.ForwardsChanged += (_, _) => keepAlive.Refresh();
|
||||||
|
|
||||||
keepAlive.Refresh();
|
keepAlive.Refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service
|
|||||||
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
/// <param name="liveSessions">Shells with a live channel behind them.</param>
|
||||||
/// <param name="activeTransfers">Transfers still moving bytes.</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="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>
|
/// <remarks>
|
||||||
/// Three outcomes, not the two a plain start-or-stop would have. Nothing live stops the service, as
|
/// 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
|
/// 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
|
/// 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.
|
/// start even over a service that looks like it is running; the inline remark below is why.
|
||||||
/// </remarks>
|
/// </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();
|
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
|
// 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.
|
// 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
|
// 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
|
// 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));
|
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)
|
if (liveSessions > 0)
|
||||||
{
|
{
|
||||||
parts.Add(liveSessions == 1 ? "1 shell connected" : $"{liveSessions} shells connected");
|
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)
|
if (holdsFileSession)
|
||||||
{
|
{
|
||||||
parts.Add("Files connected");
|
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
|
/// 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.
|
/// gap closed, read the same way the other two facts are: asked, not cached.
|
||||||
/// </para>
|
/// </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>
|
/// </remarks>
|
||||||
internal sealed class SessionKeepAlive : IDisposable
|
internal sealed class SessionKeepAlive : IDisposable
|
||||||
{
|
{
|
||||||
private readonly TerminalWorkspace workspace;
|
private readonly TerminalWorkspace workspace;
|
||||||
private readonly Func<int> activeTransfers;
|
private readonly Func<int> activeTransfers;
|
||||||
private readonly Func<bool> holdsFileSession;
|
private readonly Func<bool> holdsFileSession;
|
||||||
|
private readonly Func<int> listeningForwards;
|
||||||
|
|
||||||
/// <param name="workspace">The live shells.</param>
|
/// <param name="workspace">The live shells.</param>
|
||||||
/// <param name="activeTransfers">
|
/// <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-
|
/// 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>.
|
/// connected case a transfer count alone would miss. See <c>TransfersViewModel.HasLiveFileSession</c>.
|
||||||
/// </param>
|
/// </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.workspace = workspace;
|
||||||
this.activeTransfers = activeTransfers;
|
this.activeTransfers = activeTransfers;
|
||||||
this.holdsFileSession = holdsFileSession;
|
this.holdsFileSession = holdsFileSession;
|
||||||
|
this.listeningForwards = listeningForwards;
|
||||||
|
|
||||||
// Raised on whatever thread the workspace announced from — a continuation of the ended run, or the
|
// 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
|
// 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>
|
/// <summary>Re-reads the counts and starts or stops the service to match.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Called after anything that could change any of the three facts — opening a shell, closing a tab, a
|
/// Called after anything that could change any of the facts — opening a shell, closing a tab, a transfer
|
||||||
/// transfer finishing, a Files connection opening or closing. Calling it when nothing changed is free:
|
/// 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,
|
/// 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,
|
/// 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.
|
/// and Android treats all of those as no-ops.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public void Refresh() =>
|
public void Refresh() =>
|
||||||
SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession());
|
SessionForegroundService.Reconcile(
|
||||||
|
workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards());
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public void Dispose()
|
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
|
// 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.
|
// 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();
|
private void OnSessionEnded(object? sender, TerminalSessionEndedEventArgs e) => Refresh();
|
||||||
|
|||||||
@@ -189,15 +189,18 @@
|
|||||||
◆ Port forwarding, stated rather than drawn.
|
◆ Port forwarding, stated rather than drawn.
|
||||||
|
|
||||||
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
|
The v2 design gives this hub a sixth entry and a full screen behind it: local, remote and dynamic
|
||||||
rules with toggles and byte counters. Forwarding is built — a host's forwards start with every
|
rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's
|
||||||
desktop terminal to it, listed in the session sidebar — but not started here:
|
forwards start with every terminal opened to it — but it is not a destination, so it has no row. A
|
||||||
MainWindowViewModel.StartForwards skips them on Android, because this head has no surface that
|
forward belongs to a host and runs with a session, and on this head the session's forwards are a
|
||||||
lists what is listening, and a phone opening listeners it never shows is worse than one opening
|
sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch
|
||||||
none. The byte counters are not built anywhere; SSH.NET keeps no count to draw.
|
each. See MainWindowViewModel.IsForwardsSheetOpen.
|
||||||
|
|
||||||
|
What the phone lacks is the editor: its host form has no PORT FORWARDS section yet, so forwards are
|
||||||
|
added on the desktop and arrive here with the host. The byte counters are not built anywhere;
|
||||||
|
SSH.NET keeps no count to draw.
|
||||||
|
|
||||||
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
|
It is a paragraph here rather than a missing row for the reason the desktop keeps TEAMS in its rail:
|
||||||
a destination that is quietly absent reads as a product that never intended it. See
|
a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md.
|
||||||
docs/design-import-gaps.md.
|
|
||||||
-->
|
-->
|
||||||
<Border Classes="card" Margin="6,16,6,0">
|
<Border Classes="card" Margin="6,16,6,0">
|
||||||
<StackPanel Spacing="7">
|
<StackPanel Spacing="7">
|
||||||
@@ -207,11 +210,11 @@
|
|||||||
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||||
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
Foreground="{StaticResource TextDim}" Text="Port forwarding" />
|
||||||
<Border Classes="tag outline" VerticalAlignment="Center">
|
<Border Classes="tag outline" VerticalAlignment="Center">
|
||||||
<TextBlock Text="DESKTOP ONLY" />
|
<TextBlock Text="IN THE TERMINAL" />
|
||||||
</Border>
|
</Border>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Forwards saved on a host start with every terminal the desktop app opens to it. This phone does not start them, because it has nowhere yet to show you what is listening. They are kept on the host all the same, and nothing here changes them." />
|
Text="Forwards saved on a host start with every terminal opened to it, here as on the desktop. The ⇄ button in the terminal's bar lists them, with a switch each and the reason one could not start. They are added and edited in the desktop app's host editor; this phone runs them but has no editor for them yet." />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -579,7 +579,7 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
|
/// <b>The connect menu is a second such guard, and it matters more than the first.</b> A terminal now
|
||||||
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
|
/// fills the screen — no header, no bottom bar — so while that menu is up this gesture is the only way
|
||||||
/// off it other than the scrim and CANCEL. It is checked before the terminal is dismissed for the
|
/// off it other than the scrim and CANCEL. The forwards sheet, raised from the same bar, shares the guard. It is checked before the terminal is dismissed for the
|
||||||
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
|
/// reason it is drawn over it: back takes the topmost thing, and dismissing the surface underneath a
|
||||||
/// menu would take two, neither of them the one being looked at.
|
/// menu would take two, neither of them the one being looked at.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -598,13 +598,12 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone
|
// The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the
|
||||||
// draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the
|
// topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer,
|
||||||
// terminal because a gesture that dismissed the surface underneath a menu would close two things at
|
// and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet
|
||||||
// once — and the one the user was looking at would not be either of them.
|
// would close two things at once — and the one the user was looking at would not be either of them.
|
||||||
if (current.IsConnectSheetOpen)
|
if (TryCloseATerminalSheet(current))
|
||||||
{
|
{
|
||||||
current.CloseConnectSheetCommand.Execute(null);
|
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -647,6 +646,31 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lowers the connect menu or the forwards sheet, whichever is up over the terminal.
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>Whether one was closed, and so whether back has been spent.</returns>
|
||||||
|
/// <remarks>
|
||||||
|
/// Both are raised from the terminal's bar and the scrim under each covers that bar, so at most one is
|
||||||
|
/// ever open; the order below decides nothing.
|
||||||
|
/// </remarks>
|
||||||
|
private static bool TryCloseATerminalSheet(MainWindowViewModel current)
|
||||||
|
{
|
||||||
|
if (current.IsConnectSheetOpen)
|
||||||
|
{
|
||||||
|
current.CloseConnectSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current.IsForwardsSheetOpen)
|
||||||
|
{
|
||||||
|
current.CloseForwardsSheetCommand.Execute(null);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
/// Lowers whatever the hosts screen has raised over its list, topmost first.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -110,6 +110,34 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── the forwards ──────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
The desktop sidebar's state word, in the same three colours: green only while listening, the danger
|
||||||
|
text colour for a forward that could not start, faint for OFF and STARTING. The bar's ⇄ follows the
|
||||||
|
same rule for the failed case and is otherwise the colour of the icons beside it. Mono and 9.5, which
|
||||||
|
is this head's label lettering rather than the desktop's Montserrat, and its own family for the reason
|
||||||
|
the step captions above give.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBlock.fwdstate">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontWeight" Value="SemiBold" />
|
||||||
|
<Setter Property="FontSize" Value="9.5" />
|
||||||
|
<Setter Property="VerticalAlignment" Value="Center" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.fwdstate.live">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.fwdstate.failed">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.fwdglyph">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBlock.fwdglyph.failed">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
</UserControl.Styles>
|
</UserControl.Styles>
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
@@ -139,7 +167,7 @@
|
|||||||
job — see the remark on the header in PhoneShell.axaml. -->
|
job — see the remark on the header in PhoneShell.axaml. -->
|
||||||
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
<Border Grid.Row="0" Height="35" Background="{StaticResource DeepChrome}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
Back, and it goes to the page this terminal was opened over rather than to Hosts by name. The
|
||||||
@@ -244,10 +272,37 @@
|
|||||||
HOSTS, which is the only action on its screen; this one is not.
|
HOSTS, which is the only action on its screen; this one is not.
|
||||||
|
|
||||||
Click as well as Command, and the handler does one thing the command cannot: put the software
|
Click as well as Command, and the handler does one thing the command cannot: put the software
|
||||||
keyboard away. See OnConnectMenuRequested.
|
keyboard away. See OnSheetRequested.
|
||||||
-->
|
-->
|
||||||
<Button Grid.Column="2" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
<!--
|
||||||
Click="OnConnectMenuRequested"
|
The selected tab's forwards, which is what lets this head start them at all: a phone opening
|
||||||
|
listeners it never shows you is worse than one opening none. Drawn only for a tab that has some, so
|
||||||
|
a host without forwards pays nothing for the feature in a 35-pixel bar.
|
||||||
|
|
||||||
|
The count beside the arrows is the listening ones, in the green that means "running" everywhere
|
||||||
|
else; the arrows themselves turn the danger colour when one could not start. The desktop can leave a
|
||||||
|
refusal in its sidebar for the eye to find, because the sidebar is always drawn. The sheet this opens
|
||||||
|
is not, so the button has to say it first.
|
||||||
|
|
||||||
|
Outside the ScrollViewer with +, for the reason both ends are: a tenth tab must not push it away.
|
||||||
|
Width is not pinned to Button.icon's 44, because "⇄ 12" is wider than that; 44 is its minimum.
|
||||||
|
-->
|
||||||
|
<Button Grid.Column="2" Classes="icon" Height="34" Width="NaN" MinWidth="44" Padding="8,0"
|
||||||
|
Click="OnSheetRequested"
|
||||||
|
Command="{Binding OpenForwardsSheetCommand}"
|
||||||
|
IsVisible="{Binding SelectedTab.HasForwards, FallbackValue=False}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="fwdglyph" Classes.failed="{Binding SelectedTab.HasFailedForward, FallbackValue=False}"
|
||||||
|
Text="⇄" FontSize="16" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold" Foreground="{StaticResource Live}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Text="{Binding SelectedTab.ListeningForwardCount, FallbackValue=''}"
|
||||||
|
IsVisible="{Binding SelectedTab.HasListeningForward, FallbackValue=False}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" Classes="icon" Content="+" Height="34" Margin="0,0,4,0"
|
||||||
|
Click="OnSheetRequested"
|
||||||
Command="{Binding OpenConnectSheetCommand}" />
|
Command="{Binding OpenConnectSheetCommand}" />
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -742,6 +797,75 @@
|
|||||||
</Border>
|
</Border>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
|
<!-- ============ the forwards sheet ============ -->
|
||||||
|
<!--
|
||||||
|
The desktop sidebar's FORWARDS section, as a sheet: the selected tab's forwards as they run over its
|
||||||
|
session, bound to the same rows. Each row is its own switch, as there — tap a listening one to stop it,
|
||||||
|
an off or failed one to start it, a failed one being a retry because the port may have been freed.
|
||||||
|
|
||||||
|
The route is written under the notation rather than left to a tooltip, which is where the desktop
|
||||||
|
keeps it: a phone has no hover, and which end each port is on is exactly what people get backwards
|
||||||
|
about -L and -R. The reason a row failed sits under that again, as the desktop draws it.
|
||||||
|
|
||||||
|
The same shape as the connect sheet above, raised from the bar the same way, collapsing the renderer
|
||||||
|
the same way — see MainWindowViewModel.IsForwardsSheetOpen. It scrolls, capped at most of the display,
|
||||||
|
because a host may carry more forwards than fit, and a sheet that grew past the top of the screen would
|
||||||
|
put its own title out of reach.
|
||||||
|
|
||||||
|
Nothing here edits a forward, and there is no add row: this head's host editor has no PORT FORWARDS
|
||||||
|
section yet, and the desktop's Add forward row leads to one. The footer says where they are edited.
|
||||||
|
-->
|
||||||
|
<Panel IsVisible="{Binding IsForwardsSheetOpen}">
|
||||||
|
|
||||||
|
<Button Classes="scrim" Command="{Binding CloseForwardsSheetCommand}" />
|
||||||
|
|
||||||
|
<Border VerticalAlignment="Bottom" Background="{StaticResource Panel}"
|
||||||
|
BorderBrush="{StaticResource BorderMid}" BorderThickness="0,1,0,0"
|
||||||
|
CornerRadius="22,22,0,0" Padding="20,18,20,16">
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
|
<Border Width="38" Height="4" CornerRadius="2" Background="{StaticResource BorderMid}"
|
||||||
|
HorizontalAlignment="Center" Margin="0,0,0,16" />
|
||||||
|
|
||||||
|
<TextBlock Classes="title" Text="FORWARDS" FontSize="13" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" Margin="0,3,0,0"
|
||||||
|
Text="{Binding SelectedTab.Label, FallbackValue=''}" />
|
||||||
|
|
||||||
|
<ScrollViewer Margin="0,10,0,0" MaxHeight="420" HorizontalScrollBarVisibility="Disabled">
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedTab.Forwards}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:PortForwardViewModel">
|
||||||
|
<Button Classes="row" Padding="12,8" Command="{Binding ToggleCommand}">
|
||||||
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="3" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="mono" FontSize="13.5" FontWeight="SemiBold"
|
||||||
|
Text="{Binding Notation}" TextTrimming="CharacterEllipsis" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Text="{Binding Route}" />
|
||||||
|
<TextBlock Classes="detail" FontSize="10.5" TextWrapping="Wrap"
|
||||||
|
Foreground="{StaticResource TextDim}" Text="{Binding Detail}"
|
||||||
|
IsVisible="{Binding Detail, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="1" Classes="fwdstate" Margin="12,0,0,0"
|
||||||
|
Text="{Binding StateText}"
|
||||||
|
Classes.live="{Binding IsListening}" Classes.failed="{Binding IsFailed}" />
|
||||||
|
</Grid>
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<TextBlock Classes="body" Margin="0,10,0,0"
|
||||||
|
Text="Tap a forward to switch it off or on. Local and SOCKS forwards listen on this phone's localhost, where any app on it can connect, and every forward stops with this tab's connection. Forwards are saved on the host and edited in the desktop app; a change starts with the next connection." />
|
||||||
|
|
||||||
|
<Button Classes="secondary" Content="DONE" Margin="0,12,0,0"
|
||||||
|
Command="{Binding CloseForwardsSheetCommand}" />
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</Panel>
|
||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
@@ -210,10 +210,14 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
private void AdoptRenderer() => RendererFocus.Suppress(input.Acquire);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the keyboard away on the way to the connect menu.
|
/// Puts the keyboard away on the way to the connect menu or the forwards sheet.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
|
/// One handler for both buttons in the terminal's bar that raise a sheet, since what follows is true of
|
||||||
|
/// either: each is a panel at the bottom of the screen, which is where the keyboard is.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
/// A handler beside the button's command rather than something the command does, because the command is
|
/// A handler beside the button's command rather than something the command does, because the command is
|
||||||
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
|
/// on the shared view model and this is a platform call: the desktop head has no software keyboard to
|
||||||
/// put away, and a shell view model that knew about Android's input-method manager would be the first
|
/// put away, and a shell view model that knew about Android's input-method manager would be the first
|
||||||
@@ -222,7 +226,7 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
|
/// <b>The keyboard is up in the ordinary case.</b> This button sits above a terminal somebody is typing
|
||||||
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
|
/// into, so the sheet it raises would otherwise arrive underneath a keyboard covering half the display —
|
||||||
/// and the three rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
|
/// and the rows on it are the bottom half. Worse, the keyboard's inset shortens everything the
|
||||||
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
|
/// phone draws (see <c>PhoneShell.ApplyKeyboardInset</c>), so the sheet would be laid out into the
|
||||||
/// remaining strip rather than merely hidden behind it.
|
/// remaining strip rather than merely hidden behind it.
|
||||||
/// </para>
|
/// </para>
|
||||||
@@ -231,7 +235,7 @@ internal sealed partial class TerminalScreen : UserControl
|
|||||||
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
|
/// what the other writes: this one talks to the platform and the command sets a flag, so they commute.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnConnectMenuRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
|
private void OnSheetRequested(object? sender, RoutedEventArgs e) => SoftKeyboard.Hide();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Takes the browser gestures back off a surface that is not a web page.
|
/// Takes the browser gestures back off a surface that is not a web page.
|
||||||
|
|||||||
@@ -1220,6 +1220,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler? TerminalSessionOpened;
|
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>
|
/// <summary>
|
||||||
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
|
/// 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.
|
/// view can put the keyboard back there.
|
||||||
@@ -1466,6 +1478,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
internal bool IsTerminalShowing =>
|
internal bool IsTerminalShowing =>
|
||||||
IsTerminalSurface
|
IsTerminalSurface
|
||||||
&& !IsConnectSheetOpen
|
&& !IsConnectSheetOpen
|
||||||
|
&& !IsForwardsSheetOpen
|
||||||
&& !IsHostKeyDecisionShowing
|
&& !IsHostKeyDecisionShowing
|
||||||
&& SelectedTab is { HasSession: true };
|
&& SelectedTab is { HasSession: true };
|
||||||
|
|
||||||
@@ -2211,6 +2224,45 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseConnectSheet() => IsConnectSheetOpen = false;
|
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 ----
|
// ---- Open terminals ----
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// for the tab's current id would start its listener on a connection it was never part of.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Not on the phone.</b> This view model is shared, and the forwarding would work there unchanged —
|
/// ◆ <b>On the phone too.</b> This used to return early on Android, because that head had nowhere to show
|
||||||
/// but that head has no surface listing a tab's forwards, and listeners opened on a phone that never shows
|
/// what was listening. Its terminal bar now carries a FORWARDS button over the same rows the desktop's
|
||||||
/// them are exactly the invisible state this codebase refuses elsewhere. Its More screen says forwarding
|
/// sidebar lists — see <see cref="IsForwardsSheetOpen"/> — so both heads start the same forwards.
|
||||||
/// is desktop-only for now. A runtime check for the reason <c>RendererNeverStarted</c> gives.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
|
private void StartForwards(TerminalTabViewModel tab, TerminalSessionEventArgs e)
|
||||||
{
|
{
|
||||||
if (OperatingSystem.IsAndroid())
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var sessionId = e.SessionId;
|
var sessionId = e.SessionId;
|
||||||
|
|
||||||
tab.ReplaceForwards(e.Forwards.Select(rule => new PortForwardViewModel(
|
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)
|
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();
|
_ = 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>
|
/// <summary>
|
||||||
@@ -5187,6 +5246,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (value is not ShellSurface.Terminal)
|
if (value is not ShellSurface.Terminal)
|
||||||
{
|
{
|
||||||
IsConnectSheetOpen = false;
|
IsConnectSheetOpen = false;
|
||||||
|
IsForwardsSheetOpen = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
RaiseSurfaceState();
|
RaiseSurfaceState();
|
||||||
@@ -5371,6 +5431,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <inheritdoc cref="OnIsSearchingChanged" />
|
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||||
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
partial void OnIsConnectSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||||
|
|
||||||
|
/// <inheritdoc cref="OnIsSearchingChanged" />
|
||||||
|
partial void OnIsForwardsSheetOpenChanged(bool value) => RaiseTerminalState();
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
/// The unlock card and the confirmation swap, so arming one has to hide the other — see
|
||||||
/// <see cref="IsAskingForThePassphrase"/>.
|
/// <see cref="IsAskingForThePassphrase"/>.
|
||||||
|
|||||||
@@ -342,6 +342,20 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// <summary>How many of <see cref="Forwards"/> are listening now.</summary>
|
/// <summary>How many of <see cref="Forwards"/> are listening now.</summary>
|
||||||
internal int ListeningForwardCount => Forwards.Count(forward => forward.IsListening);
|
internal int ListeningForwardCount => Forwards.Count(forward => forward.IsListening);
|
||||||
|
|
||||||
|
/// <summary>Whether any of <see cref="Forwards"/> is listening now.</summary>
|
||||||
|
internal bool HasListeningForward => ListeningForwardCount > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether this tab carries any forwards at all, running or not.</summary>
|
||||||
|
/// <remarks>The phone's terminal bar draws its FORWARDS button only for a tab that has some.</remarks>
|
||||||
|
internal bool HasForwards => Forwards.Count > 0;
|
||||||
|
|
||||||
|
/// <summary>Whether any of <see cref="Forwards"/> could not be started.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone's list is a sheet, not a sidebar that is always drawn, so a refusal there would be invisible
|
||||||
|
/// until somebody happened to open it. This is what lets the button that opens it say so first.
|
||||||
|
/// </remarks>
|
||||||
|
internal bool HasFailedForward => Forwards.Any(forward => forward.IsFailed);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The status bar's forward count, or null when nothing is listening and there is nothing to say.
|
/// The status bar's forward count, or null when nothing is listening and there is nothing to say.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -394,6 +408,9 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(ListeningForwardCount));
|
OnPropertyChanged(nameof(ListeningForwardCount));
|
||||||
OnPropertyChanged(nameof(ForwardsSummary));
|
OnPropertyChanged(nameof(ForwardsSummary));
|
||||||
|
OnPropertyChanged(nameof(HasListeningForward));
|
||||||
|
OnPropertyChanged(nameof(HasForwards));
|
||||||
|
OnPropertyChanged(nameof(HasFailedForward));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc cref="TerminalTabState" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
|
|||||||
@@ -157,6 +157,78 @@ public sealed partial class ShellFlowTests
|
|||||||
ssh.Forwards.ShouldBeEmpty();
|
ssh.Forwards.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- The phone's sheet ----
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The phone lists a tab's forwards in a sheet over the terminal, so it obeys the connect sheet's rules:
|
||||||
|
/// it collapses the renderer while it is up, and leaving the terminal lowers it however it is left.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheForwardsSheet_HidesTheRenderer_AndLeavingTheTerminalLowersIt()
|
||||||
|
{
|
||||||
|
await ConnectedHostWithForwardsAsync();
|
||||||
|
|
||||||
|
shell.SelectedTab.ShouldNotBeNull().HasForwards.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.OpenForwardsSheetCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsForwardsSheetOpen.ShouldBeTrue();
|
||||||
|
shell.IsTerminalShowing.ShouldBeFalse("the sheet draws over the renderer's rectangle");
|
||||||
|
shell.IsTerminalSurface.ShouldBeTrue("the bar the sheet was raised from is on that surface");
|
||||||
|
|
||||||
|
shell.CloseForwardsSheetCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
|
|
||||||
|
shell.OpenForwardsSheetCommand.Execute(null);
|
||||||
|
shell.ShowScreenCommand.Execute(ShellScreen.Keychain);
|
||||||
|
|
||||||
|
shell.IsForwardsSheetOpen.ShouldBeFalse("a sheet left up over a page would hold the renderer blank");
|
||||||
|
|
||||||
|
shell.OpenForwardsSheetCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.IsForwardsSheetOpen.ShouldBeFalse("it must not open over a page either");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// What the phone's bar button and its keep-alive notification read: whether a tab has forwards, whether
|
||||||
|
/// one failed, and how many listen across every tab — with an event each time any of that moves.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task TheCountsAPhoneShows_FollowEveryForwardsState()
|
||||||
|
{
|
||||||
|
ssh.ForwardFailure = spec => spec.BindPort == 5432 ? "Port 5432 on this machine is already in use." : null;
|
||||||
|
|
||||||
|
var changes = 0;
|
||||||
|
shell.ForwardsChanged += (_, _) => changes++;
|
||||||
|
|
||||||
|
await ConnectedHostWithForwardsAsync();
|
||||||
|
|
||||||
|
var tab = shell.Tabs.ShouldHaveSingleItem();
|
||||||
|
await EventuallyAsync(() => tab.Forwards.All(row => row.State is not PortForwardState.Starting), "both should settle");
|
||||||
|
|
||||||
|
tab.HasForwards.ShouldBeTrue();
|
||||||
|
tab.HasFailedForward.ShouldBeTrue();
|
||||||
|
tab.HasListeningForward.ShouldBeTrue();
|
||||||
|
shell.ListeningForwardCount.ShouldBe(1);
|
||||||
|
changes.ShouldBeGreaterThan(0);
|
||||||
|
|
||||||
|
var before = changes;
|
||||||
|
ssh.ForwardFailure = null;
|
||||||
|
await tab.Forwards[0].ToggleCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
tab.HasFailedForward.ShouldBeFalse();
|
||||||
|
shell.ListeningForwardCount.ShouldBe(2);
|
||||||
|
changes.ShouldBeGreaterThan(before, "a retry that listens is a change the notification has to hear");
|
||||||
|
|
||||||
|
ssh.Shells.ShouldHaveSingleItem().Drop("An established connection was aborted by the server.");
|
||||||
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
shell.ListeningForwardCount.ShouldBe(0);
|
||||||
|
tab.HasListeningForward.ShouldBeFalse();
|
||||||
|
tab.HasForwards.ShouldBeTrue("the rows stay, saying they went with the session");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- The editor ----
|
// ---- The editor ----
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
Reference in new issue
Block a user