Merge main into the phone connections branch
ci / build and test (push) Canceled after 46s
ci / android head (push) Canceled after 0s
ci / api image (push) Canceled after 0s

Main had already taken this branch's first two commits, so what merged is the
Connections work against three things that landed beside it. Four of the six
conflicts were prose about arrangements both sides changed; two were real.

**The phone hub gained a Teams row while this branch was moving the keychain
onto it.** Both are additions to `IsMoreSurface` and both belong: teams because
the desktop reaches them from its rail and the phone through the hub, the
keychain because a bottom bar is for the places a session moves between. The
membership test, the back gesture's first case and the hub's own arithmetic all
take the union. The distinction is now written down rather than implied — teams
is the design's count plus one, and the keychain is the only rearrangement of
it: the bar lost a slot to gain that row.

**`ConnectAndAnnounceAsync` was the real one.** Main gave it
`RememberTypedPasswordAsync`, which binds the password that just worked to the
host it worked on; this branch had replaced the `HostRowViewModel` that method
needs with a four-field `ConnectionTarget`. Keeping both meant deciding what a
manual connection does with a password that succeeded, and the answer was
already written on the screen it is typed into: nothing. There is no item to
bind a credential to and none to bind it on, and that path saves nothing by
design.

So `ConnectionTarget` carries the row again — as a nullable, in place of the
host id it had, with `HostId` derived from it. Two things read it and both are
things that can only be done to a keychain item rather than to an address:
naming the log entry, and keeping the password. Null is not missing data there;
it is the whole of what makes the manual path different, and having one field
rather than two keeps "was this a keychain host" a question with one answer.

The desktop's rail lost SFTP and S3 to the tab strip on main, so the README's
"a rail with nine slots has room" was true when it was written this afternoon
and is not now. It says the room rather than the number.

Phase 11's four new device checks and main's Phase 12 on teams were the same
conflict twice — two appends to the end of one file — and both are kept.

Verified after resolving: the solution builds, the Android head builds clean,
and 837 tests pass across the seven client suites, including main's own additions
(233 shell, 79 layout, 240 domain, 118 sync, 54 session, 74 terminal, 39
storage).
This commit is contained in:
2026-08-03 15:35:49 +02:00
88 changed files with 9866 additions and 1707 deletions
@@ -73,7 +73,7 @@ internal enum ShellScreen
/// <summary>Everything in the vault that is not a host.</summary>
Vault = 2,
/// <summary>Shared vaults and the people in them. Nothing implements it yet.</summary>
/// <summary>Shared vaults and the people in them. Both heads draw it.</summary>
Team = 3,
/// <summary>Preferences.</summary>
@@ -418,25 +418,21 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary>
/// <remarks>
/// <para>
/// Two defaults, because the two audiences never overlap. A release build is installed by somebody
/// signing in to the hosted deployment, and typing its address is the only thing standing between
/// them and a working application. A debug build is run from a clone, next to
/// <c>dotnet run --project src/DodoSSH.Api</c>, and shipping the hosted address there would point
/// every development launch at production — which is worse than an inconvenience, since sign-in is
/// the step that enrolls a device.
/// </para>
/// <para>
/// Note the schemes. <c>http</c> locally is not an oversight: the API's first launch profile — the
/// one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing an
/// HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// One default for every build. The hosted deployment is what all but a handful of launches are
/// aiming at, and typing its address is the only thing standing between an installed application and
/// a working one. Running against a clone means replacing this with
/// <c>http://localhost:5233</c> by hand — note the scheme, because the API's first launch profile —
/// the one a plain <c>dotnet run</c> and the README both select — is plaintext on 5233, and pointing
/// an HTTPS client at a plaintext port fails as "The SSL connection could not be established", which
/// sends people looking for a certificate problem. See <see cref="ExplainSignInFailure" />.
/// </para>
/// <para>
/// This was once split on <c>DEBUG</c> so a development launch could not enroll a device against
/// production by accident. That protection is gone: a debug build now offers the hosted address like
/// any other, and the first sign-in accepted unread lands there.
/// </para>
/// </remarks>
#if DEBUG
internal const string DefaultServerUrl = "http://localhost:5233";
#else
internal const string DefaultServerUrl = "https://ssh.dodotech.cloud";
#endif
[ObservableProperty]
private string serverUrl = DefaultServerUrl;
@@ -784,18 +780,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// tabs are each exactly one thing, and this one is seven.
///
/// Preferences is in the list because the phone reaches it through the hub. The desktop reaches it from
/// the rail and never asks this.
/// the rail and never asks this. <see cref="ShellScreen.Team"/> is in it for the same reason and no
/// other: the desktop has a rail entry for teams and the phone reaches them through the hub, so a
/// screen missing here is one whose arrival darkens the tab that led to it and brings the shell's own
/// header back over a screen that already has one.
///
/// <b>The keychain joined it, and that is why the bar went from four entries to three.</b> A phone's
/// bottom bar is for the places a session moves between, and the keychain is not one of those: hosts
/// and connections are what somebody opens the application to do, and keys, credentials and tags are
/// what they go and manage occasionally. The desktop keeps its rail entry — it has room for nine — so
/// this is the second thing the two heads deliberately arrange differently, after the hub itself.
/// <b>The keychain joined it too, and that is why the bar went from four entries to three.</b> Unlike
/// the two above, that one is a move rather than an addition: a phone's bottom bar is for the places a
/// session moves between, and the keychain is not one of those — hosts and connections are what
/// somebody opens the application to do, and keys, credentials and tags are what they go and manage
/// occasionally. The desktop keeps its rail entry, having room for nine, so this is the second thing
/// the two heads deliberately arrange differently, after the hub itself.
/// </remarks>
internal bool IsMoreSurface =>
IsShowingPages && Screen is ShellScreen.More or ShellScreen.Snippets or ShellScreen.Logs
or ShellScreen.Transfers or ShellScreen.Buckets or ShellScreen.Preferences
or ShellScreen.Vault;
or ShellScreen.Team or ShellScreen.Vault;
/// <summary>
/// Whether the terminal's WebView may be on screen at this instant.
@@ -1028,6 +1028,60 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
vault.ManualStatus = string.Empty;
}
// ---- The desktop's fixed tabs ----
/// <summary>
/// Whether the tab strip's <c>Vaults</c> tab is the one showing.
/// </summary>
/// <remarks>
/// <para>
/// The desktop strip holds three tabs that are always there — Vaults, SFTP, S3 — and then a tab per open
/// terminal. This is the first of the three, and it is the only one with anything under it: the nav rail
/// and whichever of its screens the rail points at. So the rail is drawn on this and nothing else, which
/// is what the strip buys — a rail beside a file transfer would be offering nine destinations none of
/// which is the screen you are looking at.
/// </para>
/// <para>
/// Expressed as "a page, and not one of the two the strip took" rather than as a fourth
/// <see cref="ShellSurface"/>. SFTP and S3 were already <see cref="ShellScreen"/> members before they
/// were tabs, and they still are on the phone, where they are two rows in the hub rather than two tabs —
/// so a surface for each would have been a second way to say a thing <see cref="Screen"/> already says,
/// and the two would have had to be kept in step. <see cref="IsTransfersShowing"/> and
/// <see cref="IsBucketsShowing"/> are the other two tabs, unchanged and already used by both heads.
/// </para>
/// </remarks>
internal bool IsVaultsTab => IsShowingPages && IsVaultsPage(Screen);
/// <summary>The pages that live under the Vaults tab, as opposed to under SFTP or S3.</summary>
private static bool IsVaultsPage(ShellScreen screen) =>
screen is not (ShellScreen.Transfers or ShellScreen.Buckets);
/// <summary>
/// Which page the Vaults tab returns to.
/// </summary>
/// <remarks>
/// <para>
/// The Vaults tab has sub-navigation and the other tabs do not, so it is the one tab with somewhere to
/// come back to: leaving the keychain for SFTP and pressing Vaults again should land on the keychain,
/// not on the hosts screen. Without this it would land on whatever <see cref="Screen"/> happened to hold,
/// which after a visit to SFTP is <see cref="ShellScreen.Transfers"/> — a Vaults tab showing the file
/// screen.
/// </para>
/// <para>
/// <b>This is not the hidden field <see cref="ShellSurface"/> argues against</b>, and the difference is
/// worth stating because the two look alike. That one would have been a second copy of "which page",
/// kept because the enum could not hold two facts at once. This is the Vaults tab's own state — a tab
/// remembering its page, the way any tab does — and nothing else reads it.
/// </para>
/// </remarks>
private ShellScreen vaultsScreen = ShellScreen.Hosts;
/// <summary>Selects the Vaults tab, on the page it was last left on.</summary>
[RelayCommand]
private void ShowVaults() => ShowScreen(vaultsScreen);
// ---- The phone's connect menu ----
/// <summary>
/// Whether the phone's connect menu is open over the terminal.
/// </summary>
@@ -2663,6 +2717,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
{
RaiseSurfaceState();
// What the Vaults tab comes back to; see the field.
if (IsVaultsPage(value))
{
vaultsScreen = value;
}
// Read when the screen is opened rather than kept in step with every sync pass. Two full logs is
// thousands of decryptions, and nobody is waiting for their own connection from an hour ago to
// appear on a screen they are not looking at. Not awaited: navigating must not block on a read.
@@ -2762,6 +2822,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
OnPropertyChanged(nameof(IsBucketsScreen));
OnPropertyChanged(nameof(IsShowingPages));
OnPropertyChanged(nameof(IsVaultsTab));
OnPropertyChanged(nameof(IsHostsShowing));
OnPropertyChanged(nameof(IsTransfersShowing));
OnPropertyChanged(nameof(IsVaultShowing));