Android: type user@host into the hosts search to open a terminal on a machine without saving it
ci / android head (push) Successful in 4m22s
ci / build and test (push) Successful in 4m39s
ci / api image (push) Successful in 7s
ci / deploy ssh.dodotech.cloud (push) Failing after 1s
ci / desktop nightly (push) Successful in 3m20s

The phone's only unsaved way in was the box on the terminal screen, which is drawn only while nothing
is open, so a second machine could not be reached that way while a first one's session was up. The
search well at the top of HOSTS is always there and the design already calls it "Search or
connect...", so it now reads an address the way the desktop palette does: with an @ in it, a NOT
SAVED card above the list offers a password box and CONNECT.

It connects through VaultViewModel.ConnectToEndpointAsync, the same path as the manual box and the
desktop palette, so nothing is written to the keychain. The search box and the password are cleared
once it dials, the password goes whenever the address changes, and an address the manual parser
refuses gets its sentence under the box.

Covered by six new ShellFlowTests cases; manual check 8.20 covers the card on a device.
This commit is contained in:
jaap-jan committed 2026-10-14 17:27:57 +02:00
1 parent 988248fa57
commit 5b2e3f3651
4 files changed
+285 -2

No files matched your search

+18 -1
View File
@@ -1437,7 +1437,7 @@ time)" — is the worse failure of the two: it rebinds a host as a side effect o
## Phase 8 — Adding and removing on the phone's host list ## Phase 8 — Adding and removing on the phone's host list
Twenty-one checks, and the reason there are twenty-one rather than none is worth stating: **the layout suite Twenty-two checks, and the reason there are twenty-two rather than none is worth stating: **the layout suite
cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and cannot see any of this and structurally never will.** `DodoSSH.Client.App.Layout.Tests` targets `net10.0` and
`DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's `DodoSSH.Client.Android` targets `net10.0-android`, so a project reference is impossible; Avalonia's
application, dispatcher and platform are one-shot process globals, so a second head cannot share the application, dispatcher and platform are one-shot process globals, so a second head cannot share the
@@ -1810,6 +1810,23 @@ follow, and try disconnecting and reconnecting instead.
--- ---
### 8.20 Connecting from the search box to a machine that is not saved
With a terminal already open on some other machine, go back to HOSTS and type `user@host` for a reachable
machine that is **not** in the list into the search box at the top. Type its password into the card that
appears, and press CONNECT.
**Pass:** the card appears only once there is an `@` in the box, names the address with `:22` on the end, and
says NOT SAVED. CONNECT opens a second tab for `user@host` — the first is still there — and the fingerprint
card asks about an unknown key over the terminal. Back on HOSTS the search box is empty, the list is the whole
list again, and there is no new host in it. A port the parser refuses (`root@box:70000`) gives a sentence in
amber under the box rather than a card.
**Failure means:** the card sitting *over* the list rather than pushing it down is the panels' arrangement
having been lost — see the remark above `IsSendingChosenHostsToAVault` in `HostsScreen.axaml`. A password that
shows in the keyboard's suggestion strip is `Classes="secret"` missing from the box. Everything behind the
card is `ShellFlowTests`' hosts-search cases; this is the rectangles, and the software keyboard.
## Phase 9 — Tag chips and the picker ## Phase 9 — Tag chips and the picker
The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one The desktop half is measured by the layout suite, which now seeds three tags and puts all of them on one
@@ -72,7 +72,7 @@
<Grid ColumnDefinitions="Auto,*"> <Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="icon" Text="&#xE8B6;" Foreground="{StaticResource TextFaint}" <TextBlock Grid.Column="0" Classes="icon" Text="&#xE8B6;" Foreground="{StaticResource TextFaint}"
FontSize="20" Margin="12,0,0,0" /> FontSize="20" Margin="12,0,0,0" />
<TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts" <TextBox Grid.Column="1" Text="{Binding HostFilter}" PlaceholderText="Search hosts, or type user@host"
Padding="9,0" VerticalContentAlignment="Center" Padding="9,0" VerticalContentAlignment="Center"
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
Foreground="{StaticResource Text}" FontSize="14" /> Foreground="{StaticResource Text}" FontSize="14" />
@@ -82,6 +82,42 @@
<!-- ============ sync + conflicts ============ --> <!-- ============ sync + conflicts ============ -->
<StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7"> <StackPanel Grid.Row="1" Margin="16,4,16,0" Spacing="7">
<!--
◆ A MACHINE THAT IS NOT SAVED. When the search box holds user@host, this card offers to connect to it
with a password, and nothing is written to the keychain — the desktop palette's typed-address row, in
this screen's shape. The terminal screen's own box does the same but is drawn only while nothing is
open; this one is here whatever is running. See VaultViewModel.FilterAddress.
Above the list rather than over it, like the panels below: the hosts that also match what was typed
stay in view, and one of them may be the same machine with a key already bound to it.
Classes="field secret" on the password for the reason the terminal screen's box gives: the class is
what keeps the password out of the software keyboard's dictionary, not only off the screen.
-->
<Border IsVisible="{Binding HasFilterAddress}" Background="{StaticResource Panel}"
BorderBrush="{StaticResource BorderMid}" BorderThickness="1" CornerRadius="12" Padding="13,11">
<StackPanel Spacing="9">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="mono" Text="{Binding FilterAddressText}" FontSize="13"
Foreground="{StaticResource Text}" TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="label" Text="NOT SAVED" Margin="10,0,0,0"
VerticalAlignment="Center" />
</Grid>
<TextBox Classes="field secret" Text="{Binding FilterAddressPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectToFilterAddressCommand}" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="primary" Height="44" Content="CONNECT"
Command="{Binding ConnectToFilterAddressCommand}" />
</StackPanel>
</Border>
<!-- Why what was typed is not an address yet, or why CONNECT refused — a sentence about the box. -->
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
Text="{Binding FilterAddressRefusal}" IsVisible="{Binding HasFilterAddressRefusal}" />
<TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" /> <TextBlock Classes="detail" Text="{Binding Status}" TextWrapping="Wrap" />
<!-- <!--
@@ -4278,6 +4278,54 @@ internal sealed partial class VaultViewModel(
[ObservableProperty] [ObservableProperty]
private string manualStatus = string.Empty; private string manualStatus = string.Empty;
/// <summary>
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
/// it reads the address rather than the screen growing a second field.
/// </para>
/// <para>
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
/// would arrive on every keystroke.
/// </para>
/// </remarks>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddress))]
[NotifyPropertyChangedFor(nameof(FilterAddressText))]
private ManualEndpoint? filterAddress;
/// <summary>Whether the search box holds an address that can be connected to.</summary>
internal bool HasFilterAddress => FilterAddress is not null;
/// <summary>The typed address as it will be dialled, port and all.</summary>
internal string FilterAddressText => FilterAddress?.Address ?? string.Empty;
/// <summary>
/// Why a search with an <c>@</c> in it is not an address, or why its CONNECT was refused; empty otherwise.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasFilterAddressRefusal))]
private string filterAddressRefusal = string.Empty;
/// <summary>Whether there is a refusal to show under the search box.</summary>
internal bool HasFilterAddressRefusal => FilterAddressRefusal.Length > 0;
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
/// <remarks>
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
/// password offered on the other. Cleared when the address changes, so the next machine typed never
/// arrives with the last one's password already in the box.
/// </remarks>
[ObservableProperty]
private string filterAddressPassword = string.Empty;
/// <summary> /// <summary>
/// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it. /// The attempt an unanswered host-key question belongs to, so that trusting the key can replay it.
/// </summary> /// </summary>
@@ -11483,6 +11531,77 @@ internal sealed partial class VaultViewModel(
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true); await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
} }
/// <summary>
/// Connects to the address typed into the hosts search box, with the password typed under it.
/// </summary>
/// <remarks>
/// <para>
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
/// entry are the ones the manual box and the desktop palette produce, and nothing is written to the
/// keychain.
/// </para>
/// <para>
/// The search box and the password are cleared before the dial, from copies taken first. The list left
/// filtered to an address would be a list of nothing to come back to, and the password is a secret nobody
/// asked to keep.
/// </para>
/// <para>
/// It takes no cancellation token and allows concurrent executions, for the two reasons
/// <see cref="ConnectAsync"/> carries.
/// </para>
/// </remarks>
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ConnectToFilterAddressAsync()
{
if (FilterAddress is not { } endpoint)
{
return;
}
if (FilterAddressPassword.Length == 0)
{
FilterAddressRefusal = $"Type the password for {endpoint.Address}.";
return;
}
var password = FilterAddressPassword;
// In this order: clearing the filter clears the address and the password with it — see
// OnHostFilterChanged — and the endpoint and password above are what is dialled.
HostFilter = string.Empty;
await ConnectToEndpointAsync(endpoint, password, CancellationToken.None).ConfigureAwait(true);
}
/// <summary>Reads the hosts search box as an address, if it has an <c>@</c> in it.</summary>
private void RefreshFilterAddress(string filter)
{
var previous = FilterAddress;
var trimmed = filter.Trim();
if (!trimmed.Contains('@', StringComparison.Ordinal))
{
FilterAddress = null;
FilterAddressRefusal = string.Empty;
}
else if (TryParseManualTarget(trimmed, out var endpoint, out var reason))
{
FilterAddress = endpoint;
FilterAddressRefusal = string.Empty;
}
else
{
FilterAddress = null;
FilterAddressRefusal = reason;
}
// A different machine, or none, is a different question: the password typed for the last one goes.
if (FilterAddress != previous)
{
FilterAddressPassword = string.Empty;
}
}
/// <summary> /// <summary>
/// Opens a terminal on a typed address with a typed password, saving neither. /// Opens a terminal on a typed address with a typed password, saving neither.
/// </summary> /// </summary>
@@ -13504,6 +13623,7 @@ internal sealed partial class VaultViewModel(
/// </remarks> /// </remarks>
partial void OnHostFilterChanged(string value) partial void OnHostFilterChanged(string value)
{ {
RefreshFilterAddress(value);
RebuildVisibleHosts(); RebuildVisibleHosts();
RebuildHostSections(); RebuildHostSections();
OnPropertyChanged(nameof(NoHostBoardMessage)); OnPropertyChanged(nameof(NoHostBoardMessage));
@@ -1130,6 +1130,116 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
ssh.Requests.ShouldBeEmpty(); ssh.Requests.ShouldBeEmpty();
} }
/// <remarks>
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
/// and for nothing else. A plain search must not grow one on every keystroke.
/// </remarks>
[Theory]
[InlineData("root@box", "root@box:22")]
[InlineData(" deploy@build.internal:2222 ", "deploy@build.internal:2222")]
public async Task AnAddressTypedIntoTheHostsSearch_IsOfferedAsAMachineToConnectTo(
string typed,
string offered)
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "prod";
vault.HasFilterAddress.ShouldBeFalse("a search without an @ is a search");
vault.HasFilterAddressRefusal.ShouldBeFalse();
vault.HostFilter = typed;
vault.HasFilterAddress.ShouldBeTrue();
vault.FilterAddressText.ShouldBe(offered);
}
/// <remarks>
/// The whole path from the search box: the address and the password typed there are what is dialled, a tab
/// opens for it, and the keychain is exactly as it was — that is the point of connecting this way. The box
/// and the password are both empty afterwards, so the list is not left filtered to a machine it does not
/// hold and the password does not wait in the box for the next one.
/// </remarks>
[Fact]
public async Task TheHostsSearchConnectsToATypedAddress_AndSavesNothing()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
var hostsBefore = vault.Hosts.Count;
vault.HostFilter = "deploy@build.internal:2222";
vault.FilterAddressPassword = "hunter2";
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
var request = ssh.Requests.ShouldHaveSingleItem();
request.Host.ShouldBe("build.internal");
request.Port.ShouldBe(2222);
request.Username.ShouldBe("deploy");
request.Credential.ShouldBeOfType<SshPasswordCredential>().Password.ShouldBe("hunter2");
shell.Tabs.ShouldHaveSingleItem().Label.ShouldBe("deploy@build.internal");
vault.Hosts.Count.ShouldBe(hostsBefore, "connecting without saving saves nothing");
vault.HostFilter.ShouldBeEmpty();
vault.FilterAddressPassword.ShouldBeEmpty();
vault.HasFilterAddress.ShouldBeFalse();
}
/// <remarks>
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
/// address stays where it was typed, so filling the password in is all that is left to do.
/// </remarks>
[Fact]
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
{
var vault = await ReadyToConnectAsync();
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.HostFilter = "root@box";
await vault.ConnectToFilterAddressCommand.ExecuteAsync(null);
vault.FilterAddressRefusal.ShouldContain("password");
vault.HostFilter.ShouldBe("root@box");
ssh.Requests.ShouldBeEmpty();
shell.Tabs.ShouldBeEmpty();
}
/// <remarks>
/// A search that looks like an address and cannot be one says why, in the manual parser's own words,
/// rather than offering nothing and leaving the box looking ignored.
/// </remarks>
[Fact]
public async Task AnAddressInTheHostsSearchThatCannotBeRead_SaysWhy()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box:70000";
vault.HasFilterAddress.ShouldBeFalse();
vault.FilterAddressRefusal.ShouldContain("between 1 and 65535");
}
/// <remarks>
/// A password belongs to the machine it was typed for. Changing the address in the box has to take it
/// away, or the next machine typed would be dialled with somebody else's password.
/// </remarks>
[Fact]
public async Task ChangingTheAddressInTheHostsSearch_DropsThePasswordTypedForTheLastOne()
{
var vault = await ReadyToConnectAsync();
vault.HostFilter = "root@box";
vault.FilterAddressPassword = "hunter2";
vault.HostFilter = "root@other-box";
vault.FilterAddressPassword.ShouldBeEmpty();
}
/// <remarks> /// <remarks>
/// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was /// <b>The retry used to re-run whichever host was selected.</b> That was right while a selected host was
/// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a /// the only way to connect; with a manual target it would answer "do you trust this key" by dialling a