diff --git a/README.md b/README.md index 40dc9b3..114b15b 100644 --- a/README.md +++ b/README.md @@ -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 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 -host, and they start with every terminal opened to it. The session sidebar lists them under FORWARDS with the -state of each, and the status bar counts the ones listening. +host, and they start with every terminal opened to it, on either head. The session sidebar lists them under +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. @@ -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. **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 -so. +phone runs forwards too, listed behind a `⇄` in its terminal's bar, but cannot edit them yet; see +[The Android head](#the-android-head). ### 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 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 -shell, a host's files over SFTP, or a bucket. The system back gesture does what the arrow does, and lowers -that menu first if it is open. +shell, a host's files over SFTP, or a bucket. Beside the `+`, for a tab whose host has port forwards, a `⇄` +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 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 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 -rather than leaving a gap. The v2 design draws a whole screen for it. The forwarding itself is shared code and -would run here unchanged, but this head has no surface that lists what is listening, and a phone opening -listeners it never shows you is worse than one that opens none. See +**Port forwarding runs here too, and is edited on the desktop.** A host's forwards start with every +terminal the phone opens to it, as they do on the desktop. The terminal's bar grows a `⇄` for a tab that has +any, carrying the number listening in green, or turning red when one could not start; it opens a sheet +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). It is deliberately **not** in `DodoSSH.slnx`. Putting it there would make the `android` workload and a full diff --git a/docs/android-port.md b/docs/android-port.md index 01897d5..8524354 100644 --- a/docs/android-port.md +++ b/docs/android-port.md @@ -631,8 +631,8 @@ go at 360dp: 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 [`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 - because it has no surface yet that shows what is listening. + named rather than drawn as a screen: it has since been built, and this head runs a host's forwards with + 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 SETTINGS rather than MORE and wears a gear: a bottom bar is for the places a session moves between, and diff --git a/docs/design-import-gaps.md b/docs/design-import-gaps.md index 0a36556..03f34a8 100644 --- a/docs/design-import-gaps.md +++ b/docs/design-import-gaps.md @@ -39,8 +39,8 @@ the chrome, hosts and terminals, file transfer, the vault, teams, and preference > > | 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. | -> | `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. | +> | 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 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. | > | 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. | diff --git a/src/DodoSSH.Client.Android/App.axaml.cs b/src/DodoSSH.Client.Android/App.axaml.cs index ad53d6b..8d0fdb8 100644 --- a/src/DodoSSH.Client.Android/App.axaml.cs +++ b/src/DodoSSH.Client.Android/App.axaml.cs @@ -132,7 +132,8 @@ public sealed partial class DodoSshApp : Avalonia.Application var keepAlive = new SessionKeepAlive( workspace, 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 // 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. 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(); } diff --git a/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs index 216efb1..5413f2b 100644 --- a/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs +++ b/src/DodoSSH.Client.Android/Platform/SessionForegroundService.cs @@ -141,6 +141,11 @@ internal sealed class SessionForegroundService : Service /// Shells with a live channel behind them. /// Transfers still moving bytes. /// Whether the Files screen holds a live, idle SFTP connection. + /// + /// 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 + /// . + /// /// /// 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. /// - 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(3); + var parts = new List(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"); diff --git a/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs index 374d6bc..07e2c10 100644 --- a/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs +++ b/src/DodoSSH.Client.Android/Platform/SessionKeepAlive.cs @@ -24,12 +24,20 @@ namespace DodoSSH.Client.Android.Platform; /// looked, to this class, exactly like nothing being open at all. holdsFileSession below is that /// gap closed, read the same way the other two facts are: asked, not cached. /// +/// +/// 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. +/// /// internal sealed class SessionKeepAlive : IDisposable { private readonly TerminalWorkspace workspace; private readonly Func activeTransfers; private readonly Func holdsFileSession; + private readonly Func listeningForwards; /// The live shells. /// @@ -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 TransfersViewModel.HasLiveFileSession. /// - public SessionKeepAlive(TerminalWorkspace workspace, Func activeTransfers, Func holdsFileSession) + /// + /// How many port forwards are listening, across every tab. For the notification's wording only; see the + /// class remarks. See MainWindowViewModel.ListeningForwardCount. + /// + public SessionKeepAlive( + TerminalWorkspace workspace, + Func activeTransfers, + Func holdsFileSession, + Func 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 /// Re-reads the counts and starts or stops the service to match. /// - /// 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 startForegroundService — or, /// now, a redundant notification post — on a running service, or a stopService on a stopped one, /// and Android treats all of those as no-ops. /// public void Refresh() => - SessionForegroundService.Reconcile(workspace.LiveSessionCount, activeTransfers(), holdsFileSession()); + SessionForegroundService.Reconcile( + workspace.LiveSessionCount, activeTransfers(), holdsFileSession(), listeningForwards()); /// 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(); diff --git a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml index 9172201..ee7d73b 100644 --- a/src/DodoSSH.Client.Android/Views/MoreScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/MoreScreen.axaml @@ -189,15 +189,18 @@ ◆ 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 - rules with toggles and byte counters. Forwarding is built — a host's forwards start with every - desktop terminal to it, listed in the session sidebar — but not started here: - MainWindowViewModel.StartForwards skips them on Android, because this head has no surface that - lists what is listening, and a phone opening listeners it never shows is worse than one opening - none. The byte counters are not built anywhere; SSH.NET keeps no count to draw. + rules with toggles and byte counters. Forwarding is built and runs here as on the desktop — a host's + forwards start with every terminal opened to it — but it is not a destination, so it has no row. A + forward belongs to a host and runs with a session, and on this head the session's forwards are a + sheet off the terminal's bar: the ⇄ button, drawn for a tab that has some, lists them with a switch + 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: - a destination that is quietly absent reads as a product that never intended it. See - docs/design-import-gaps.md. + a place somebody looks for a feature should say where it went. See docs/design-import-gaps.md. --> @@ -207,11 +210,11 @@ - + + 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." /> diff --git a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs index 45e6be9..466b052 100644 --- a/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs +++ b/src/DodoSSH.Client.Android/Views/PhoneShell.axaml.cs @@ -579,7 +579,7 @@ internal sealed partial class PhoneShell : UserControl /// /// The connect menu is a second such guard, and it matters more than the first. A terminal now /// 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 /// menu would take two, neither of them the one being looked at. /// @@ -598,13 +598,12 @@ internal sealed partial class PhoneShell : UserControl return; } - // The connect menu, which is raised from the terminal's own bar and is the topmost thing the phone - // draws while it is up. Ahead of the editors below because it is nearer, and ahead of leaving the - // terminal because a gesture that dismissed the surface underneath a menu would close two things at - // once — and the one the user was looking at would not be either of them. - if (current.IsConnectSheetOpen) + // The connect menu or the forwards sheet, which are raised from the terminal's own bar and are the + // topmost thing the phone draws while one is up. Ahead of the editors below because they are nearer, + // and ahead of leaving the terminal because a gesture that dismissed the surface underneath a sheet + // would close two things at once — and the one the user was looking at would not be either of them. + if (TryCloseATerminalSheet(current)) { - current.CloseConnectSheetCommand.Execute(null); e.Handled = true; return; } @@ -647,6 +646,31 @@ internal sealed partial class PhoneShell : UserControl } } + /// + /// Lowers the connect menu or the forwards sheet, whichever is up over the terminal. + /// + /// Whether one was closed, and so whether back has been spent. + /// + /// 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. + /// + 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; + } + /// /// Lowers whatever the hosts screen has raised over its list, topmost first. /// diff --git a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml index a511cae..1827425 100644 --- a/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml +++ b/src/DodoSSH.Client.Android/Views/TerminalScreen.axaml @@ -110,6 +110,34 @@ + + + + + + + @@ -139,7 +167,7 @@ job — see the remark on the header in PhoneShell.axaml. --> - + - + + + + + + + + + +