Public Access
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0509c6679 | ||
|
|
f7354aa7e8 | ||
|
|
36b6ded63e | ||
|
|
fa85ad1eff | ||
|
|
0cecf1afc4 | ||
|
|
089de72834 | ||
|
|
f43436218a |
No files matched your search
+39
-7
@@ -2099,14 +2099,16 @@ that dialled it taken away. See `MainWindowViewModel.OnVaultConnectionFailed`.
|
|||||||
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
With at least one keychain host and one machine connected to by hand in the log, open Connections with
|
||||||
nothing running.
|
nothing running.
|
||||||
|
|
||||||
**Pass:** tapping the keychain one lands on HOSTS with that host **ticked and the action bar up**, ready
|
**Pass:** tapping the keychain one puts **its name** on the `connect to:` line and carries on as typing that
|
||||||
for CONNECT out of the ⋯. Tapping the hand-typed one puts its address back in the box on this screen and
|
name would — its password line, or straight into a terminal for a host with a key or a saved password.
|
||||||
leaves the password box **empty**. Neither one dials anything on its own.
|
Tapping the hand-typed one puts its `user@host:port` on the line and asks for its password with the password
|
||||||
|
line **empty**. Neither one leaves this screen, and the hand-typed one dials nothing until a password is
|
||||||
|
given.
|
||||||
|
|
||||||
**Failure means:** if the hand-typed one navigates to HOSTS, the log entry is carrying a host id it should
|
**Failure means:** if the hand-typed one shows a host's name rather than its address, the log entry is
|
||||||
not have. If the password came back, something is storing it — it never was. And if the keychain one arrives
|
carrying a host id it should not have. If the password came back, something is storing it — it never was.
|
||||||
at a list with nothing marked, `ConnectToRecent` is setting the selection rather than going through
|
And if the keychain one shows the logged address rather than the host's name, the host id is not being
|
||||||
`AskAboutHost`: nothing on that list means "selected" any more, so that is a screen with nothing to press.
|
matched against the keychain — see `MainWindowViewModel.ChoosePromptRecentAsync`.
|
||||||
|
|
||||||
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
### 11.9 The keychain and snippets are the vault's, and back walks out of them
|
||||||
|
|
||||||
@@ -2335,6 +2337,23 @@ Android is not delivering the click as a `contextmenu` event, and the gesture ne
|
|||||||
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
`WebView` instead of a page one. Neither costs this head anything it has today: a phone without a pointer
|
||||||
has no right button to miss.
|
has no right button to miss.
|
||||||
|
|
||||||
|
### 11.14 Connections with nothing open asks what to connect to
|
||||||
|
|
||||||
|
Close every shell, then press **Connections**. Type part of a saved host's name into the `connect to:` line.
|
||||||
|
|
||||||
|
**Pass:** the matching hosts appear under the line as buttons a thumb can hit; tapping one asks for that
|
||||||
|
host's password on a line of its own under ssh's wording — or connects at once, for a host with a key or a
|
||||||
|
saved password. Then type `user@host` for a machine that is not saved and press CONNECT: the password line
|
||||||
|
appears with the keyboard already on it, and BACK — or the system back gesture — steps off it onto the
|
||||||
|
address rather than leaving the screen. A tap on a RECENT row lands on the prompt and asks whatever it needs
|
||||||
|
to before anything is dialled. Nothing new appears on HOSTS or in the keychain afterwards.
|
||||||
|
|
||||||
|
**Failure means:** a password line that appears with the keyboard still on the first line is the posted focus
|
||||||
|
in `ConnectPrompt.axaml.cs` arriving before the box has bounds. Back that leaves Connections from the password
|
||||||
|
line is `PhoneShell.OnBackRequested`'s prompt step having been reordered below the surface check. The
|
||||||
|
decisions themselves — which name matches, what is refused, what is saved — are `ShellFlowTests`' prompt
|
||||||
|
cases, shared with the desktop; this is the rectangles and the keyboard.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase 12 — Shared vaults: the operations that span two accounts
|
## Phase 12 — Shared vaults: the operations that span two accounts
|
||||||
@@ -3519,3 +3538,16 @@ Register a device, sync something, then `sudo dnf remove dodossh`.
|
|||||||
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
make an uninstall delete a user's un-synced work — the Linux spelling of the mistake ADR 0013 decision 2
|
||||||
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
exists to prevent. Files left behind under `/opt/DodoSSH` are files the package did not own, which means
|
||||||
something wrote into the install directory at runtime and would survive a reinstall.
|
something wrote into the install directory at runtime and would survive a reinstall.
|
||||||
|
|
||||||
|
### 19.13 A copy at the same path that rpm does not own is offered nothing
|
||||||
|
|
||||||
|
On a distribution without rpm — Arch, say, with a locally built package that puts the nightly tree at
|
||||||
|
`/opt/DodoSSH.Nightly` — open PREFERENCES → UPDATES.
|
||||||
|
|
||||||
|
**Pass:** **CHECK NOW** is disabled and the row says this copy was installed outside the `dodossh-nightly`
|
||||||
|
RPM and should be updated with the package manager that installed it. `~/.local/share/dodossh.nightly/updates/`
|
||||||
|
stays empty however long it runs.
|
||||||
|
|
||||||
|
**Failure means:** a banner offering a nightly is the path being taken as proof the RPM is installed, or an
|
||||||
|
rpm that cannot answer being read as an old version — the fault this check was written for, which offered
|
||||||
|
every nightly as newer and fetched a 56 MB package the machine could never install, every six hours.
|
||||||
@@ -843,6 +843,30 @@
|
|||||||
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
<Setter Property="TextInputOptions.ContentType" Value="Password" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The Connections prompt's two lines, typed into the way a shell is: no fill and no border in any state, so
|
||||||
|
the text sits on the pane where its label leaves off and the caret is the only sign the line takes input.
|
||||||
|
44 tall all the same, which is this head's touch floor — the line has no box to aim at, so the whole
|
||||||
|
height of it has to be the target. See ConnectPrompt.axaml. Declared after .secret, which it combines with
|
||||||
|
on the password line and sets nothing in common with.
|
||||||
|
-->
|
||||||
|
<Style Selector="TextBox.prompt">
|
||||||
|
<Setter Property="MinHeight" Value="44" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource Text}" />
|
||||||
|
<Setter Property="CaretBrush" Value="{StaticResource AccentText}" />
|
||||||
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
<Setter Property="FontSize" Value="14" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="TextBox.prompt /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:pointerover /template/ Border#PART_BorderElement,
|
||||||
|
TextBox.prompt:focus /template/ Border#PART_BorderElement">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderBrush" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
The live dot, and the class name is the same one the desktop sidebar uses so the two heads cannot
|
||||||
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
drift on what green means: a terminal is open on this host right now. Deliberately not reachability —
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
xmlns:views="using:DodoSSH.Client.Android.Views"
|
||||||
|
x:Class="DodoSSH.Client.Android.Views.ConnectPrompt"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The phone's half of the desktop's empty-terminal prompt, drawn on Connections while nothing is open: the
|
||||||
|
same two lines, over the same view-model state — MainWindowViewModel.PromptText and its siblings — so a
|
||||||
|
name or an address typed here is read, refused and dialled exactly as it is on the desktop. See the
|
||||||
|
desktop's ConnectPrompt.axaml for the reasoning; what is here is only what a phone changes.
|
||||||
|
|
||||||
|
THREE THINGS A PHONE CHANGES.
|
||||||
|
|
||||||
|
No Tab and no Escape. The matches under the first line are buttons sized for a thumb instead of a
|
||||||
|
completion list for a key, and a tap on one takes it outright — completing to a name and then pressing
|
||||||
|
CONNECT is two taps for what one already says. The way off the password line is BACK beside CONNECT,
|
||||||
|
and the system back gesture, which PhoneShell routes to the same command.
|
||||||
|
|
||||||
|
A CONNECT button, because the software keyboard's own return key may commit or may only close the
|
||||||
|
keyboard — that is the IME's choice, as the files screen's GO button already says. Enter is still bound,
|
||||||
|
for every keyboard that does send it.
|
||||||
|
|
||||||
|
The password's label on its own line. ssh's wording — "deploy@build.internal:2222's password:" — is a
|
||||||
|
third of a phone's width before anything is typed, so the box goes under it rather than beside it.
|
||||||
|
-->
|
||||||
|
<Border Background="{StaticResource Pane}" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
|
CornerRadius="12" Padding="14,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="12" Foreground="{StaticResource TextFaint}" TextWrapping="Wrap"
|
||||||
|
Text="Type a saved host's name, or user@host[:port] for a machine that is not saved." />
|
||||||
|
|
||||||
|
<!-- ============ what to connect to ============ -->
|
||||||
|
<Grid ColumnDefinitions="Auto,*" Margin="0,4,0,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="14" FontWeight="Bold"
|
||||||
|
Foreground="{StaticResource AccentText}" Text="connect to:" VerticalAlignment="Center" />
|
||||||
|
<TextBox Grid.Column="1" x:Name="Target" Classes="prompt" Margin="8,0,0,0"
|
||||||
|
AutomationProperties.Name="Connect to"
|
||||||
|
Text="{Binding PromptText}" IsReadOnly="{Binding IsPromptAskingForPassword}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<!-- The saved hosts that match so far, best first. A tap takes one: it connects, or asks its password. -->
|
||||||
|
<ItemsControl ItemsSource="{Binding PromptMatches}" IsVisible="{Binding ShowsPromptMatches}">
|
||||||
|
<ItemsControl.ItemsPanel>
|
||||||
|
<ItemsPanelTemplate>
|
||||||
|
<WrapPanel />
|
||||||
|
</ItemsPanelTemplate>
|
||||||
|
</ItemsControl.ItemsPanel>
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
||||||
|
<Button Classes="chiptoggle" MinHeight="44" Margin="0,0,6,6"
|
||||||
|
Command="{Binding $parent[views:ConnectPrompt].((vm:MainWindowViewModel)DataContext).ChoosePromptMatchCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Text="{Binding Label}" />
|
||||||
|
</Button>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
|
||||||
|
<!-- ============ the password, when one is needed ============ -->
|
||||||
|
<StackPanel x:Name="PasswordLine" Spacing="0" IsVisible="{Binding IsPromptAskingForPassword}">
|
||||||
|
<TextBlock Classes="mono" FontSize="13" Foreground="{StaticResource Text}" TextWrapping="Wrap"
|
||||||
|
Text="{Binding PromptPasswordLabel}" />
|
||||||
|
<!--
|
||||||
|
Classes="secret" for what it tells the software keyboard, not only for the dots: see Phone.axaml.
|
||||||
|
-->
|
||||||
|
<TextBox x:Name="Password" Classes="prompt secret" AutomationProperties.Name="Password"
|
||||||
|
Text="{Binding PromptPassword}">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter" Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="mono" FontSize="12" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
||||||
|
Text="{Binding PromptRefusal}" IsVisible="{Binding HasPromptRefusal}" />
|
||||||
|
|
||||||
|
<!--
|
||||||
|
CONNECT always; BACK only on the password line, where there is a line to go back from. Side by side,
|
||||||
|
CONNECT on the right where the thumb ends up, the order the hosts screen's own panels use.
|
||||||
|
-->
|
||||||
|
<Grid ColumnDefinitions="*,8,*" Margin="0,6,0,0">
|
||||||
|
<Button Grid.Column="0" Classes="secondary" Height="44" Content="BACK"
|
||||||
|
Command="{Binding CancelPromptCommand}" IsVisible="{Binding IsPromptAskingForPassword}" />
|
||||||
|
<Button Grid.Column="2" Classes="primary" Height="44" Content="CONNECT"
|
||||||
|
Command="{Binding SubmitPromptCommand}" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Markup.Xaml;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.Android.Views;
|
||||||
|
|
||||||
|
/// <summary>The phone's empty-terminal prompt: what to connect to, and its password when one is needed.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The one thing the code-behind does is move the keyboard onto the password line when it appears. Enter on a
|
||||||
|
/// name is answered with a second question, and a keyboard left on the first line — read-only by then — would
|
||||||
|
/// type nothing and look as though it should.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// It does not take the keyboard when the screen opens, unlike the desktop's prompt. On a phone that would
|
||||||
|
/// raise the software keyboard over the recent connections underneath, which are the likelier tap.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <c>FindControl</c> rather than the generated <c>x:Name</c> fields, for the reason
|
||||||
|
/// <c>HostsScreen</c>'s constructor gives: no view on this head calls <c>InitializeComponent</c>, so those
|
||||||
|
/// fields are null at run time.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
internal sealed partial class ConnectPrompt : UserControl
|
||||||
|
{
|
||||||
|
public ConnectPrompt()
|
||||||
|
{
|
||||||
|
AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
var line = this.FindControl<StackPanel>("PasswordLine")!;
|
||||||
|
var box = this.FindControl<TextBox>("Password")!;
|
||||||
|
|
||||||
|
line.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.Property == IsVisibleProperty && line.IsVisible)
|
||||||
|
{
|
||||||
|
// Posted at Loaded, as HostsScreen's own password sheet is: the box has no bounds on the turn
|
||||||
|
// its line becomes visible, and focusing a control with none raises no keyboard.
|
||||||
|
Dispatcher.UIThread.Post(() => box.Focus(NavigationMethod.Tab), DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -674,6 +674,16 @@ internal sealed partial class PhoneShell : UserControl
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The Connections prompt's password line, which back steps off onto the name it asked about — the
|
||||||
|
// same thing its BACK button does, and the nearer of the two things back could mean here. Leaving the
|
||||||
|
// surface would throw the name away along with the question.
|
||||||
|
if (current is { IsConnectPromptShowing: true, IsPromptAskingForPassword: true })
|
||||||
|
{
|
||||||
|
current.CancelPromptCommand.Execute(null);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!current.IsShowingPages)
|
if (!current.IsShowingPages)
|
||||||
{
|
{
|
||||||
current.ShowScreenCommand.Execute(current.Screen);
|
current.ShowScreenCommand.Execute(current.Screen);
|
||||||
|
|||||||
@@ -345,9 +345,11 @@
|
|||||||
running this surface is where a connection is made rather than a page apologising for having
|
running this surface is where a connection is made rather than a page apologising for having
|
||||||
nothing on it. Two things are offered, in the order somebody reaches for them.
|
nothing on it. Two things are offered, in the order somebody reaches for them.
|
||||||
|
|
||||||
A box, because the case this application had no answer for is the address somebody has just been
|
A prompt, because a terminal with nothing in it should do what a terminal does, which is ask — the
|
||||||
given. Everything else here starts from a keychain item, which is right for a machine used twice and
|
same one the desktop draws in its empty terminal, over the same view-model state. It takes a saved
|
||||||
is several screens of work for one used once.
|
host's name or user@host for a machine that is not saved, and asks for a password only when one is
|
||||||
|
needed. See ConnectPrompt.axaml. It replaced a box that took addresses alone, which left a saved
|
||||||
|
machine several screens away from here.
|
||||||
|
|
||||||
And the machines already connected to, under it, because that is the commoner half by far: the
|
And the machines already connected to, under it, because that is the commoner half by far: the
|
||||||
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
thing most likely to be wanted is the thing that was wanted yesterday. They come out of the vault's
|
||||||
@@ -359,44 +361,7 @@
|
|||||||
|
|
||||||
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
<TextBlock Classes="title" FontSize="13" Text="CONNECT TO A MACHINE" />
|
||||||
|
|
||||||
<!--
|
<views:ConnectPrompt />
|
||||||
The manual target. One box in the grammar people already have — user@host, or user@host:port —
|
|
||||||
rather than three, because three boxes on a phone is three taps between three keyboards for
|
|
||||||
something that arrives as one string.
|
|
||||||
-->
|
|
||||||
<TextBox Classes="field" Text="{Binding Vault.ManualTarget}"
|
|
||||||
PlaceholderText="user@host or user@host:port">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A typed password and nothing else. Offering the keychain's keys here would be a second binding
|
|
||||||
resolution beside the one the connect path already has, and a key is the reason to save the
|
|
||||||
machine as a host rather than dial it from a box.
|
|
||||||
|
|
||||||
Classes="secret" and not merely PasswordChar: the class is what also tells the software keyboard
|
|
||||||
this is not prose, which is the difference between dots on screen and dots on screen with the
|
|
||||||
password in the IME's dictionary. See Theme/Phone.axaml.
|
|
||||||
-->
|
|
||||||
<TextBox Classes="field secret" Text="{Binding Vault.ManualPassword}"
|
|
||||||
PlaceholderText="password">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<Button Classes="primary" Content="CONNECT" Command="{Binding Vault.ConnectManuallyCommand}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Beside the box rather than on the shell's status line. What fails here is about what was typed —
|
|
||||||
a missing account, a port that is not a number — and a sentence about a text box belongs under
|
|
||||||
the text box.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="detail" TextWrapping="Wrap" Foreground="{StaticResource WarnText}"
|
|
||||||
Text="{Binding Vault.ManualStatus}"
|
|
||||||
IsVisible="{Binding Vault.ManualStatus, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
|
||||||
|
|
||||||
<TextBlock Classes="body"
|
<TextBlock Classes="body"
|
||||||
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
Text="Nothing typed here is saved. A machine worth keeping belongs on HOSTS, where it can carry a key, a group's defaults and a name." />
|
||||||
@@ -410,14 +375,13 @@
|
|||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
<DataTemplate x:DataType="vm:ConnectionLogRowViewModel">
|
||||||
<!--
|
<!--
|
||||||
Tapping one does not connect, and that is deliberate rather than a missing step. A row
|
A tap goes onto the prompt above and carries on as if it had been typed: a keychain host
|
||||||
that names a keychain host goes to that host with its connect bar — which is where its
|
connects, or asks for its password there; an address asks for its password, which was
|
||||||
key, or its password box, or its refusal lives — and one that names only an address goes
|
never stored. Nothing is dialled before the prompt has asked whatever it needs to. See
|
||||||
back into the box above, without the password, which was never stored. Either way what
|
MainWindowViewModel.ChoosePromptRecentCommand.
|
||||||
happens next is visible before anything is dialled. See ConnectToRecentCommand.
|
|
||||||
-->
|
-->
|
||||||
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
<Button Classes="row" MinHeight="52" Padding="12,8" CornerRadius="10"
|
||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ConnectToRecentCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).ChoosePromptRecentCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
<StackPanel Grid.Column="0" Spacing="2" VerticalAlignment="Center">
|
||||||
|
|||||||
@@ -127,17 +127,61 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
this.installedVersion = installedVersion;
|
this.installedVersion = installedVersion;
|
||||||
this.handToInstaller = handToInstaller;
|
this.handToInstaller = handToInstaller;
|
||||||
|
|
||||||
packageName = string.Equals(channel, "nightly", StringComparison.Ordinal)
|
packageName = PackageFor(channel);
|
||||||
? "dodossh-nightly"
|
}
|
||||||
: "dodossh";
|
|
||||||
|
/// <summary>The channel for a copy under the RPM's install root, if rpm says the package is there.</summary>
|
||||||
|
internal static IUpdateChannel ForInstall(string channel) =>
|
||||||
|
ForInstall(channel, InstalledRpmVersion, () => new LinuxUpdateChannel(channel));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The channel for a copy under the RPM's install root, with rpm supplied, which is how the tests
|
||||||
|
/// reach it.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// <b>The path says where this copy is and only rpm says who put it there.</b>
|
||||||
|
/// <c>UpdateChannels.LinuxChannel</c> has already established the first, and on an RPM distribution
|
||||||
|
/// that is the whole story. It is not on any other: a locally built pacman or dpkg package that mirrors
|
||||||
|
/// the spec puts the same tree at the same path, and this channel then offered every nightly as newer
|
||||||
|
/// than a version nobody could read, fetched a package that machine has no way to install, and did it
|
||||||
|
/// again every six hours.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Asked once, at composition, for the reason the rest of this decision is: who installed this copy
|
||||||
|
/// does not change while it runs. It costs one <c>rpm</c> launch at start on a machine whose path
|
||||||
|
/// already matched, and nothing at all anywhere else.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="channel">The build's channel, which names the package.</param>
|
||||||
|
/// <param name="installedVersion">What rpm says is installed under a package name, or null.</param>
|
||||||
|
/// <param name="whenOwned">The channel to use when rpm does own this copy.</param>
|
||||||
|
internal static IUpdateChannel ForInstall(
|
||||||
|
string channel,
|
||||||
|
Func<string, string?> installedVersion,
|
||||||
|
Func<IUpdateChannel> whenOwned)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(channel);
|
||||||
|
ArgumentNullException.ThrowIfNull(installedVersion);
|
||||||
|
ArgumentNullException.ThrowIfNull(whenOwned);
|
||||||
|
|
||||||
|
var package = PackageFor(channel);
|
||||||
|
|
||||||
|
return installedVersion(package) is null
|
||||||
|
? new UnavailableUpdateChannel(
|
||||||
|
$"This copy of DodoSSH was installed outside the {package} RPM — rpm has no record of that "
|
||||||
|
+ "package here — and an RPM is the only kind of update it can fetch. Update it with the "
|
||||||
|
+ "package manager that installed it.")
|
||||||
|
: whenOwned();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
/// True unconditionally, because whether this copy is one that can be updated was already decided by
|
||||||
/// <see cref="UpdateChannels.ForThisMachine"/> — a build run from a checkout never reaches this class
|
/// <see cref="UpdateChannels.ForThisMachine"/> and <see cref="ForInstall(string)"/> — a build run from
|
||||||
/// at all. The same split Velopack's side uses, where <c>IsInstalled</c> is checked once at
|
/// a checkout, or installed by something other than rpm, never reaches this class at all. The same
|
||||||
/// composition rather than at every call site.
|
/// split Velopack's side uses, where <c>IsInstalled</c> is checked once at composition rather than at
|
||||||
|
/// every call site.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public bool IsSupported => true;
|
public bool IsSupported => true;
|
||||||
|
|
||||||
@@ -184,9 +228,20 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
$"The {channel} release's {manifestName} could not be read as a manifest.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// â—† No answer from rpm is a failure, never a version. ForInstall only built this class because
|
||||||
|
// rpm answered, so silence now is a package removed under a running process or an rpm that
|
||||||
|
// stopped working — and any stand-in number would make every published build look newer and
|
||||||
|
// fetch a package for a machine that cannot install it, which is what this once did.
|
||||||
|
if (installedVersion(packageName) is not { } installed)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"rpm has no record of {packageName}, so there is no installed version to compare the "
|
||||||
|
+ $"{channel} release against.");
|
||||||
|
}
|
||||||
|
|
||||||
// The one place null is returned, and it means what null is documented to mean: this build is
|
// The one place null is returned, and it means what null is documented to mean: this build is
|
||||||
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
// current. Compared the way rpm compares, because rpm is what accepts or refuses the install.
|
||||||
if (RpmVersion.Compare(manifest.RpmVersion, installedVersion(packageName) ?? "0") <= 0)
|
if (RpmVersion.Compare(manifest.RpmVersion, installed) <= 0)
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -324,6 +379,12 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
? $"{RepositoryApi}/releases/tags/nightly-desktop"
|
||||||
: $"{RepositoryApi}/releases/latest";
|
: $"{RepositoryApi}/releases/latest";
|
||||||
|
|
||||||
|
/// <summary>The package a channel's builds are published as.</summary>
|
||||||
|
private static string PackageFor(string channel) =>
|
||||||
|
string.Equals(channel, "nightly", StringComparison.Ordinal)
|
||||||
|
? "dodossh-nightly"
|
||||||
|
: "dodossh";
|
||||||
|
|
||||||
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
private static string? Asset(LinuxForgeRelease? release, string name) =>
|
||||||
release?.Assets?.FirstOrDefault(
|
release?.Assets?.FirstOrDefault(
|
||||||
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
asset => string.Equals(asset.Name, name, StringComparison.Ordinal))?.DownloadUrl;
|
||||||
@@ -365,9 +426,11 @@ internal sealed class LinuxUpdateChannel : IUpdateChannel
|
|||||||
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
/// <c>scripts/ci-linux-rpm.sh</c> performs, and two copies of one rule is a pair that can disagree.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// A machine that cannot answer gets null, which the caller reads as <c>0</c> and makes every
|
/// A machine that cannot answer gets null — no <c>rpm</c> at all, or one with no such package — and
|
||||||
/// published build look newer. That is the wrong way to fail, chosen deliberately: the failure is then
|
/// null means "rpm does not own this copy", never a version. It used to be read as <c>0</c>, on the
|
||||||
/// an install PackageKit declines rather than a security fix that never arrives.
|
/// argument that the worst case was an install PackageKit declined. That holds only where PackageKit
|
||||||
|
/// installs RPMs: on any other distribution it made every published build look newer, forever, and
|
||||||
|
/// fetched a package that machine could not install. See <see cref="ForInstall(string)"/>.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private static string? InstalledRpmVersion(string packageName)
|
private static string? InstalledRpmVersion(string packageName)
|
||||||
|
|||||||
@@ -66,21 +66,23 @@ internal static class UpdateChannels
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The Linux channel, if this copy is one a package manager put here.</summary>
|
/// <summary>The Linux channel, if this copy is one the RPM put here.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
/// <b>The question Velopack answers with <c>IsInstalled</c>, answered by looking at where this process
|
||||||
/// lives.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the spec puts it there
|
/// lives and then asking rpm.</b> The RPM owns <c>/opt/DodoSSH</c> or <c>/opt/DodoSSH.Nightly</c> — the
|
||||||
/// and nothing else writes to <c>/opt</c> under those names — so a build running from anywhere else is
|
/// spec puts it there — so a build running from anywhere else is a checkout somebody started, an
|
||||||
/// a checkout somebody started, an extracted archive, or a tree copied by hand. None of those can be
|
/// extracted archive, or a tree copied by hand. None of those can be updated by installing a package
|
||||||
/// updated by installing a package over them, and offering it would be an offer that cannot be carried
|
/// over them, and offering it would be an offer that cannot be carried out.
|
||||||
/// out.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Checked by path rather than by asking rpm which package owns the executable, which would be the
|
/// <b>The path is necessary and not sufficient.</b> It used to be the whole check, on the argument
|
||||||
/// more thorough answer and costs a process launch on every start for a distinction nothing acts on
|
/// that asking rpm cost a process launch for a distinction nothing acted on differently. Something
|
||||||
/// differently. The install root is a constant in two places — here and the packaging script — and
|
/// does: a locally built pacman package that mirrors the spec puts the same tree at the same path,
|
||||||
/// they are named in each other's comments.
|
/// and that copy was offered every nightly as an RPM it could never install. So a matching path is
|
||||||
|
/// then put to rpm — see <see cref="LinuxUpdateChannel.ForInstall(string)"/> — and the launch is paid
|
||||||
|
/// only where the path already matched. The install root is a constant in two places — here and the
|
||||||
|
/// packaging script — and they are named in each other's comments.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[SupportedOSPlatform("linux")]
|
[SupportedOSPlatform("linux")]
|
||||||
@@ -91,7 +93,7 @@ internal static class UpdateChannels
|
|||||||
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
var here = AppContext.BaseDirectory.TrimEnd(Path.DirectorySeparatorChar);
|
||||||
|
|
||||||
return string.Equals(here, root, StringComparison.Ordinal)
|
return string.Equals(here, root, StringComparison.Ordinal)
|
||||||
? new LinuxUpdateChannel(DesktopChannel.Name)
|
? LinuxUpdateChannel.ForInstall(DesktopChannel.Name)
|
||||||
: new UnavailableUpdateChannel();
|
: new UnavailableUpdateChannel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -170,7 +170,7 @@
|
|||||||
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
The second step, in the same card, so the keyboard never leaves the palette: Enter on the address
|
||||||
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
comes here, Enter here dials, Escape goes back to the address rather than closing. The box carries
|
||||||
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
PasswordChar and nothing is written anywhere — see MainWindowViewModel.IsAskingForAdHocPassword,
|
||||||
and VaultViewModel.ConnectManuallyAsync for why a typed password is the only way in.
|
and VaultViewModel.ConnectToEndpointAsync for why a typed password is the only way in.
|
||||||
-->
|
-->
|
||||||
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
<StackPanel x:Name="PasswordStep" IsVisible="{Binding IsAskingForAdHocPassword}">
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,10 @@
|
|||||||
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
<TextBlock Classes="settingsrowcaption"
|
<TextBlock Classes="settingsrowcaption"
|
||||||
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
Text="This copy of DodoSSH cannot replace itself, so nothing above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
|
||||||
IsVisible="{Binding Updates.IsUnsupported}" />
|
IsVisible="{Binding Updates.IsUnsupportedForTheUsualReason}" />
|
||||||
|
<TextBlock Classes="settingsrowcaption"
|
||||||
|
Text="{Binding Updates.UnsupportedReason}"
|
||||||
|
IsVisible="{Binding Updates.UnsupportedReason, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -141,9 +141,32 @@ public interface IUpdateChannel
|
|||||||
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
/// <see cref="UnavailableDeviceKeyStore"/>: the caller then has one shape to write against, and "no
|
||||||
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
/// updater here" is a thing the interface can express rather than a case every call site has to remember.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>One situation outside those four carries a <see cref="Reason"/>.</b> A Linux copy sitting exactly
|
||||||
|
/// where the RPM puts it that no RPM owns — built locally and installed by another distribution's package
|
||||||
|
/// manager. It looks installed, so the sentence about checkouts and unzipped copies would describe the
|
||||||
|
/// wrong machine, and the person reading it needs telling which updater is theirs.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
public sealed class UnavailableUpdateChannel : IUpdateChannel
|
||||||
{
|
{
|
||||||
|
/// <summary>Creates one for the situations that need no telling apart.</summary>
|
||||||
|
public UnavailableUpdateChannel()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Creates one whose situation is worth a sentence of its own.</summary>
|
||||||
|
/// <param name="reason">What to tell the user, as a sentence they can act on.</param>
|
||||||
|
public UnavailableUpdateChannel(string reason)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||||
|
|
||||||
|
Reason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, where that is not the usual why.</summary>
|
||||||
|
public string? Reason { get; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public bool IsSupported => false;
|
public bool IsSupported => false;
|
||||||
|
|
||||||
|
|||||||
@@ -68,9 +68,9 @@ internal sealed class ConnectionLogRowViewModel(VaultItem<ConnectionLogSecret> e
|
|||||||
/// <summary>The keychain host this was, if it was one.</summary>
|
/// <summary>The keychain host this was, if it was one.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
/// Null for a connection made to a typed address, and that is a real distinction rather than missing
|
||||||
/// data — see <c>VaultViewModel.ConnectManuallyAsync</c>. It is what lets the Connections screen offer
|
/// data — see <c>VaultViewModel.ConnectToEndpointAsync</c>. It is what lets the connect prompt offer the
|
||||||
/// the right thing when one of these rows is tapped: a keychain host has a connect bar with its own
|
/// right thing when one of these rows is tapped: a keychain host has its own authentication behind it, and
|
||||||
/// authentication behind it, and an address has only the box it was typed into.
|
/// an address has only the password typed for it — see <c>MainWindowViewModel.ChoosePromptRecentAsync</c>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal Guid? HostId => entry.Secret.HostId;
|
internal Guid? HostId => entry.Secret.HostId;
|
||||||
|
|
||||||
|
|||||||
@@ -1857,9 +1857,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// button needs a command.
|
/// button needs a command.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — the
|
/// Not gated on there being a tab. With nothing open the surface is where a connection is made — both
|
||||||
/// phone's terminal screen draws its manual box there, and the desktop its prompt; see
|
/// heads draw the connect prompt there; see <see cref="IsConnectPromptShowing"/> — which is worth being
|
||||||
/// <see cref="IsConnectPromptShowing"/> — which is worth being able to reach deliberately.
|
/// able to reach deliberately.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1946,57 +1946,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(HasRecentConnections));
|
OnPropertyChanged(nameof(HasRecentConnections));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Goes back to a machine that has been connected to before.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Two destinations, because a recent row is one of two different things.</b> One that names a
|
|
||||||
/// keychain host goes to that host on the hosts screen, with whatever that head uses to ask about one
|
|
||||||
/// machine raised over it — the desktop's drawer, the phone's action bar — carrying whatever
|
|
||||||
/// authentication the keychain resolves for it. Connecting from here instead would be a third connect
|
|
||||||
/// path that had to answer all of that again.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// â—† <b>It raises that rather than merely selecting the row, and on the phone it has to.</b> Nothing on
|
|
||||||
/// that list means "selected" any more — a tap connects and a long press ticks — so arriving with the
|
|
||||||
/// host selected and nothing else would be arriving at a screen with nothing to press. Asking to go back
|
|
||||||
/// to a machine is exactly the deliberate act those flags exist to distinguish from browsing. See
|
|
||||||
/// <c>VaultViewModel.AskAboutHost</c>, which is where the two heads' answers are raised together.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// One that names no item was typed into the manual box, and the log stored exactly what was dialled —
|
|
||||||
/// <c>user@host:port</c>, which is the grammar that box takes. So it goes back into the box, and what
|
|
||||||
/// is deliberately not restored is the password: it was never stored, which is the whole point of the
|
|
||||||
/// manual path, and a field that filled itself in would be claiming otherwise.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
|
||||||
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
|
||||||
/// user has switched off, and for the same reason rather than by accident: selecting it would point the
|
|
||||||
/// hosts screen at a row that screen is not drawing, and the grid would null the selection straight back
|
|
||||||
/// out — arriving at the hosts screen with nothing selected and no explanation.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void ConnectToRecent(ConnectionLogRowViewModel row)
|
|
||||||
{
|
|
||||||
if (row is null || Vault is not { } vault)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (row.HostId is { } hostId
|
|
||||||
&& vault.Hosts.FirstOrDefault(host => host.EntityId == hostId) is { } known
|
|
||||||
&& vault.IsVaultShown(known.VaultId))
|
|
||||||
{
|
|
||||||
vault.AskAboutHostCommand.Execute(known);
|
|
||||||
ShowScreen(ShellScreen.Hosts);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
vault.ManualTarget = row.Address;
|
|
||||||
vault.ManualStatus = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- The rail's own page grouping ----
|
// ---- The rail's own page grouping ----
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -2602,10 +2551,20 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// A click on one of the recent connections printed under the prompt.
|
/// A click on one of the recent connections printed under the prompt.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A row naming a host still in the keychain goes to that host, with whatever the keychain knows about
|
/// <para>
|
||||||
/// it; one that names none — or a host deleted since — is the address that was dialled, and asks for its
|
/// <b>Two answers, because a recent row is one of two different things.</b> A row naming a host still in
|
||||||
/// password the way a typed one does. The same split <see cref="ConnectToRecent"/> makes, and for its
|
/// the keychain goes to that host, with whatever the keychain knows about it — its key, its saved
|
||||||
/// reason, except that here the answer is a line of the prompt rather than another screen.
|
/// password, its refusals — exactly as typing its name would. One that names none was typed in as an
|
||||||
|
/// address, and the log stored exactly what was dialled, <c>user@host:port</c>, which is the grammar the
|
||||||
|
/// prompt takes. So it goes back onto the prompt and asks for its password the way a typed one does; the
|
||||||
|
/// password was never stored, which is the whole point of that path.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// A host deleted since it was connected to falls through to the address, which is the honest answer:
|
||||||
|
/// the machine is still there and the keychain no longer knows about it. So does a host in a vault the
|
||||||
|
/// user has switched off, and for the same reason rather than by accident: taking it would reach a host
|
||||||
|
/// the rest of the application is deliberately not showing.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||||
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
private async Task ChoosePromptRecentAsync(ConnectionLogRowViewModel? row)
|
||||||
@@ -2807,13 +2766,13 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
/// <b>The palette's answer to a machine that is not in the keychain.</b> Every other row it offers is a
|
||||||
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
/// saved host, and a box somebody has just been given the address of had no way in on this head short of
|
||||||
/// saving it first — the phone has had a manual box on its terminal screen all along. The query already
|
/// saving it first. The query already is a box, and <c>user@host</c> is the grammar people type into
|
||||||
/// is a box, and <c>user@host</c> is the grammar people type into one, so the palette reads it rather
|
/// one, so the palette reads it rather than growing a second field.
|
||||||
/// than growing a second field.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the phone's box's own parser, so
|
/// Read with <see cref="VaultViewModel.TryParseManualTarget"/>, which is the connect prompt's parser and
|
||||||
/// the two heads accept exactly the same addresses and refuse the rest with the same sentences.
|
/// the phone's hosts search's too, so every box accepts exactly the same addresses and refuses the rest
|
||||||
|
/// with the same sentences.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
/// Only a query with an <c>@</c> in it is read at all. Without one, everything typed is a search, and a
|
||||||
@@ -2884,7 +2843,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
/// <summary>The password typed for <see cref="AdHocTarget"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Cleared whenever the palette closes or goes back to the query, on the same terms the phone's manual box
|
/// Cleared whenever the palette closes or goes back to the query, on the same terms the connect prompt
|
||||||
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
/// clears its own: it is a secret nobody asked to keep, and the next address typed must not arrive with the
|
||||||
/// last one's password already in the box.
|
/// last one's password already in the box.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
@@ -3001,8 +2960,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the phone's manual box's own
|
/// Through <see cref="VaultViewModel.ConnectToEndpointAsync"/>, which is the connect prompt's own path,
|
||||||
/// path, so a machine reached from here gets the same tab, the same host-key question and the same
|
/// so a machine reached from here gets the same tab, the same host-key question and the same
|
||||||
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
/// connection-log entry. What is written, if the user answers the host-key question, is a pin — the trust
|
||||||
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
/// decision belongs to the endpoint rather than to a keychain item, and a machine reached this way is
|
||||||
/// exactly the one whose key nobody has seen before.
|
/// exactly the one whose key nobody has seen before.
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
this.restart = restart;
|
this.restart = restart;
|
||||||
|
|
||||||
CurrentVersion = updates.CurrentVersion;
|
CurrentVersion = updates.CurrentVersion;
|
||||||
|
UnsupportedReason = (updates as UnavailableUpdateChannel)?.Reason;
|
||||||
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
isAutomatic = settings.Read().AutomaticUpdateChecks;
|
||||||
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
state = updates.IsSupported ? UpdateState.Idle : UpdateState.Unsupported;
|
||||||
}
|
}
|
||||||
@@ -133,6 +134,16 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
/// <summary>What this build calls itself.</summary>
|
/// <summary>What this build calls itself.</summary>
|
||||||
internal string CurrentVersion { get; }
|
internal string CurrentVersion { get; }
|
||||||
|
|
||||||
|
/// <summary>Why this copy cannot replace itself, when the channel had something particular to say.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Null in the ordinary cases, where the preferences screen's own sentence about checkouts and unzipped
|
||||||
|
/// copies is the right one. See <see cref="UnavailableUpdateChannel"/> for the case where it is not.
|
||||||
|
/// </remarks>
|
||||||
|
internal string? UnsupportedReason { get; }
|
||||||
|
|
||||||
|
/// <summary>Unsupported, with nothing more particular to say than the screen's own sentence.</summary>
|
||||||
|
internal bool IsUnsupportedForTheUsualReason => IsUnsupported && UnsupportedReason is null;
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private UpdateState state;
|
private UpdateState state;
|
||||||
|
|
||||||
@@ -248,6 +259,7 @@ internal sealed partial class UpdateViewModel : ObservableObject, IAsyncDisposab
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsSupported));
|
OnPropertyChanged(nameof(IsSupported));
|
||||||
OnPropertyChanged(nameof(IsUnsupported));
|
OnPropertyChanged(nameof(IsUnsupported));
|
||||||
|
OnPropertyChanged(nameof(IsUnsupportedForTheUsualReason));
|
||||||
OnPropertyChanged(nameof(IsChecking));
|
OnPropertyChanged(nameof(IsChecking));
|
||||||
OnPropertyChanged(nameof(IsDownloading));
|
OnPropertyChanged(nameof(IsDownloading));
|
||||||
OnPropertyChanged(nameof(IsReady));
|
OnPropertyChanged(nameof(IsReady));
|
||||||
|
|||||||
@@ -4251,48 +4251,21 @@ internal sealed partial class VaultViewModel(
|
|||||||
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
[NotifyPropertyChangedFor(nameof(ShowsAddButton))]
|
||||||
private bool isAskingForConnectPassword;
|
private bool isAskingForConnectPassword;
|
||||||
|
|
||||||
/// <summary>What was typed into the manual connect box, as <c>user@host</c> or <c>user@host:port</c>.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// One box rather than four, because this is the form of an address people already have: it is what a
|
|
||||||
/// colleague pastes into a chat window and what `ssh` itself takes. Splitting it into user, host and port
|
|
||||||
/// would make the ordinary case three taps between three keyboards on a phone.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualTarget = string.Empty;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectPassword" />
|
|
||||||
/// <remarks>
|
|
||||||
/// Its own box rather than <see cref="ConnectPassword"/>, for the reason <c>TryBuildConnectionRequest</c>
|
|
||||||
/// takes the typed password as a parameter: these are different screens, and a password typed on one is
|
|
||||||
/// not a password offered on the other.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualPassword = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Why the manual box refused, if it did.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Beside that box rather than only on <see cref="Status"/>. The refusals here are about what was typed
|
|
||||||
/// — a missing account, a port that is not a number — and a sentence about a text box belongs next to
|
|
||||||
/// the text box, not on a status line that also carries what the sync engine is doing.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string manualStatus = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
/// The machine the hosts search box names, when what was typed into it reads as <c>user@host[:port]</c>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>The phone's version of the desktop palette's typed-address row.</b> The manual box on the terminal
|
/// <b>The phone's version of the desktop palette's typed-address row.</b> The connect prompt on the
|
||||||
/// screen is the other way in, and it is only drawn while nothing is open — a second machine reached while
|
/// terminal screen is the other way in, and it is only drawn while nothing is open — a second machine
|
||||||
/// a first one's session is up had no box at all. The search well at the top of HOSTS is always there, the
|
/// reached while a first one's session is up had no box at all. The search well at the top of HOSTS is
|
||||||
/// design already calls it "Search or connect…", and <c>user@host</c> is what people type into a box, so
|
/// always there, the design already calls it "Search or connect…", and <c>user@host</c> is what people
|
||||||
/// it reads the address rather than the screen growing a second field.
|
/// type into a box, so it reads the address rather than the screen growing a second field.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Read with <see cref="TryParseManualTarget"/>, the manual box's own parser, and only for a filter with an
|
/// Read with <see cref="TryParseManualTarget"/>, the prompt's and the palette's parser too, and only for a
|
||||||
/// <c>@</c> in it: without one, everything typed is a search, and a card offering to dial three letters
|
/// filter with an <c>@</c> in it: without one, everything typed is a search, and a card offering to dial
|
||||||
/// would arrive on every keystroke.
|
/// three letters would arrive on every keystroke.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
@@ -4318,10 +4291,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
/// <summary>The password typed for <see cref="FilterAddress"/>.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Its own box rather than <see cref="ManualPassword"/>, for the reason that one is not
|
/// Its own box rather than <see cref="ConnectPassword"/>: these are different screens, and a password
|
||||||
/// <see cref="ConnectPassword"/>: these are different screens, and a password typed on one is not a
|
/// typed on one is not a password offered on the other. Cleared when the address changes, so the next
|
||||||
/// password offered on the other. Cleared when the address changes, so the next machine typed never
|
/// machine typed never arrives with the last one's password already in the box.
|
||||||
/// arrives with the last one's password already in the box.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string filterAddressPassword = string.Empty;
|
private string filterAddressPassword = string.Empty;
|
||||||
@@ -7820,40 +7792,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
IsHostPaneOpen = true;
|
IsHostPaneOpen = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raises whatever the head in front of the user uses to ask about one host.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="row">The machine, which the caller is holding.</param>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// â—† <b>One command, two pieces of furniture, and it exists because the phone's has changed.</b> The
|
|
||||||
/// desktop's answer is the drawer beside the grid and the phone's used to be the connect bar — one flag
|
|
||||||
/// served both. The bar is gone: the phone's answer is now a tick and the action bar across the top, and
|
|
||||||
/// that is a different piece of state, so a caller that only opened the pane would leave this head
|
|
||||||
/// arriving at a list with nothing on it to press.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Both are set rather than branching on which head is running, because neither view model knows and
|
|
||||||
/// neither should have to. The one the head does not draw is inert: the desktop draws no ticks, and the
|
|
||||||
/// phone has no drawer.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The one caller is <c>MainWindowViewModel.ConnectToRecent</c>, and what it is doing is the deliberate
|
|
||||||
/// act both flags exist to tell apart from browsing.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void AskAboutHost(HostRowViewModel? row)
|
|
||||||
{
|
|
||||||
if (row is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
OpenHostPane(row);
|
|
||||||
ChooseHost(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Puts the drawer away, whichever of the three panels is in it.
|
/// Puts the drawer away, whichever of the three panels is in it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -11529,62 +11467,13 @@ internal sealed partial class VaultViewModel(
|
|||||||
cancellationToken).ConfigureAwait(true);
|
cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens a terminal on somewhere that is not in the keychain.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
|
||||||
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
|
||||||
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
|
||||||
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
|
||||||
/// been given the address of.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
|
||||||
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
|
||||||
/// written there at length. A key is a reason to save the host.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
|
||||||
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
|
||||||
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
|
||||||
/// </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 Task ConnectManuallyAsync() => ConnectManuallyAsync(CancellationToken.None);
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectManuallyAsync()" />
|
|
||||||
private async Task ConnectManuallyAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!TryParseManualTarget(ManualTarget, out var endpoint, out var refusal))
|
|
||||||
{
|
|
||||||
ManualStatus = refusal;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ManualPassword.Length == 0)
|
|
||||||
{
|
|
||||||
ManualStatus = "A password is needed. Save this machine as a host to reach it with a key.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ManualStatus = string.Empty;
|
|
||||||
|
|
||||||
await ConnectToEndpointAsync(endpoint, ManualPassword, cancellationToken).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
/// Connects to the address typed into the hosts search box, with the password typed under it.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Through <see cref="ConnectToEndpointAsync"/>, so the tab, the host-key question and the connection-log
|
/// 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
|
/// entry are the ones the connect prompt and the desktop palette produce, and nothing is written to the
|
||||||
/// keychain.
|
/// keychain.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
@@ -11654,14 +11543,27 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The second half of <see cref="ConnectManuallyAsync(CancellationToken)"/>, split out for the desktop's
|
/// <b>The one connection this application makes to a machine it has never been told about.</b> Everything
|
||||||
/// quick-connect palette. That head has no manual box for <see cref="ManualTarget"/> to be typed into —
|
/// else starts from a keychain item, and that is still the way a host anybody uses twice should be
|
||||||
/// the palette's own query is the box — so it parses with <see cref="TryParseManualTarget"/> and hands
|
/// reached — it is the only way to get a key, a group's defaults, a saved username or a password that is
|
||||||
/// over what it read rather than writing into the phone's fields to have them read back here.
|
/// not typed again. This is for the other case, which is real and had no answer: a box somebody has just
|
||||||
|
/// been given the address of.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// The caller has already refused an unreadable address and an empty password, which is why neither is
|
/// <b>A typed password and nothing else.</b> Offering the keychain's keys here would be a second binding
|
||||||
/// checked again: both refusals are sentences about a box, and only the caller knows which box.
|
/// resolution beside <see cref="TryBuildAuthentication"/>, and the argument against a second one is
|
||||||
|
/// written there at length. A key is a reason to save the host.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Nothing is written to the keychain, deliberately. What <em>is</em> written, if the user answers the
|
||||||
|
/// question, is a host-key pin — the trust decision belongs to the endpoint rather than to the item, and
|
||||||
|
/// a machine reached this way is exactly the one whose key nobody has seen before.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Every box an address is typed into — the connect prompt, the desktop's palette, the phone's hosts
|
||||||
|
/// search — parses it with <see cref="TryParseManualTarget"/> and hands over what it read. The caller has
|
||||||
|
/// already refused an unreadable address and an empty password, which is why neither is checked again:
|
||||||
|
/// both refusals are sentences about a box, and only the caller knows which box.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal Task ConnectToEndpointAsync(
|
internal Task ConnectToEndpointAsync(
|
||||||
@@ -12375,7 +12277,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked
|
||||||
// for must not wait on a vault write to appear.
|
// for must not wait on a vault write to appear.
|
||||||
//
|
//
|
||||||
// Only for a target that came from a keychain host. A machine typed into the manual box has nothing
|
// Only for a target that came from a keychain host. A machine typed in as an address has nothing
|
||||||
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
// to bind a credential to and nothing to bind it *on* — that path saves nothing by design, and the
|
||||||
// screen it is typed on says so.
|
// screen it is typed on says so.
|
||||||
if (target is { Row: { } row, MayRememberPassword: true })
|
if (target is { Row: { } row, MayRememberPassword: true })
|
||||||
@@ -12702,7 +12604,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
/// Only a keychain host can be: the relay dials the address the server holds for an item, Spelunk is
|
||||||
/// reached with a key the item names, and a machine typed into the manual box has neither.
|
/// reached with a key the item names, and a machine typed in as an address has neither.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
internal bool Routed => Row?.Host is { } host && (host.RelayEnabled || host.Spelunk is not null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,19 +86,86 @@ public sealed class LinuxUpdateChannelTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A machine that cannot say what it has treats everything as newer.
|
/// A machine that cannot say what it has is told so, and is offered nothing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The wrong way to fail, chosen deliberately and for the reason the android channel gives: the cost is
|
/// This used to read the silence as <c>0</c> and offer everything, on the argument that the cost was
|
||||||
/// an install the package manager declines, and the cost of the other direction is a security fix that
|
/// an install the package manager declined. On a machine with no rpm at all — a pacman package of the
|
||||||
/// never arrives.
|
/// same tree at the same path — that was every nightly, every six hours, as a 56 MB download nothing
|
||||||
|
/// could install. Unknown is not older.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnUnknownInstalledVersionTreatsEverythingAsNewer()
|
public async Task AnUnknownInstalledVersionThrowsRatherThanOfferingAnything()
|
||||||
{
|
{
|
||||||
var channel = Channel(RandomNumberGenerator.GetBytes(16), published: "0.0.1~alpha.0.1-1", installed: null);
|
var handed = new List<string>();
|
||||||
|
var channel = Channel(
|
||||||
|
RandomNumberGenerator.GetBytes(16),
|
||||||
|
published: "0.0.1~alpha.0.200-1",
|
||||||
|
installed: null,
|
||||||
|
handToInstaller: handed.Add);
|
||||||
|
|
||||||
(await channel.CheckAsync(Token)).ShouldNotBeNull();
|
await Should.ThrowAsync<InvalidOperationException>(async () => await channel.CheckAsync(Token));
|
||||||
|
|
||||||
|
await Should.ThrowAsync<InvalidOperationException>(
|
||||||
|
async () => await channel.DownloadAsync(
|
||||||
|
new DodoSSH.Client.Session.AvailableUpdate("0.0.1-alpha.0.200"),
|
||||||
|
new Progress<int>(),
|
||||||
|
Token));
|
||||||
|
|
||||||
|
handed.ShouldBeEmpty();
|
||||||
|
Directory.Exists(Path.Combine(directory, "updates")).ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A copy rpm does not own gets no channel at all, and a sentence saying whose it is to update.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The case that matters: the right path and no rpm — a locally built pacman package that mirrors the
|
||||||
|
/// spec. No binary and no such package both arrive here as null.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmDoesNotOwn_IsUnavailableAndSaysWhy()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"nightly",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
() => throw new InvalidOperationException("Built a channel for a copy rpm does not own."));
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh-nightly"]);
|
||||||
|
updates.IsSupported.ShouldBeFalse();
|
||||||
|
|
||||||
|
var reason = updates.ShouldBeOfType<DodoSSH.Client.Session.UnavailableUpdateChannel>().Reason;
|
||||||
|
|
||||||
|
reason.ShouldNotBeNull();
|
||||||
|
reason.ShouldContain("outside the dodossh-nightly RPM");
|
||||||
|
reason.ShouldContain("package manager that installed it");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ACopyRpmOwns_GetsTheChannel()
|
||||||
|
{
|
||||||
|
var asked = new List<string>();
|
||||||
|
var owned = new FakeUpdateChannel();
|
||||||
|
|
||||||
|
var updates = LinuxUpdateChannel.ForInstall(
|
||||||
|
"release",
|
||||||
|
package =>
|
||||||
|
{
|
||||||
|
asked.Add(package);
|
||||||
|
|
||||||
|
return "0.0.1-1";
|
||||||
|
},
|
||||||
|
() => owned);
|
||||||
|
|
||||||
|
asked.ShouldBe(["dodossh"]);
|
||||||
|
updates.ShouldBeSameAs(owned);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -114,6 +114,13 @@ public sealed partial class ShellFlowTests
|
|||||||
|
|
||||||
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
|
||||||
|
|
||||||
|
// Waited for in its own right rather than taken as read from IsLive. Ended clears IsLive and then
|
||||||
|
// ends the forwards in the same dispatcher turn, which the app's UI thread never splits — but this
|
||||||
|
// harness's post runs the ending on the fake shell's thread, so a poll from here can land between.
|
||||||
|
await EventuallyAsync(
|
||||||
|
() => tab.Forwards.All(row => row.Detail == "Stopped with the session."),
|
||||||
|
"the session's end should have reached its forwards");
|
||||||
|
|
||||||
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
tab.Forwards.ShouldAllBe(row => row.State == PortForwardState.Stopped);
|
||||||
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
tab.Forwards.ShouldAllBe(row => row.Detail == "Stopped with the session.");
|
||||||
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
tab.Forwards.ShouldAllBe(row => !row.CanToggle);
|
||||||
@@ -145,13 +152,11 @@ public sealed partial class ShellFlowTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ATypedInAddress_StartsNoForwards()
|
public async Task ATypedInAddress_StartsNoForwards()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@10.0.0.9";
|
await ConnectAtThePromptAsync("deploy@10.0.0.9", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
shell.Tabs.ShouldHaveSingleItem().Forwards.ShouldBeEmpty();
|
||||||
ssh.Forwards.ShouldBeEmpty();
|
ssh.Forwards.ShouldBeEmpty();
|
||||||
|
|||||||
@@ -1040,19 +1040,17 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
/// The one connection this application makes to a machine that is not in the keychain. What is worth
|
||||||
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
/// pinning is that it is dialled exactly as typed and nothing is inferred — the account, the address and
|
||||||
/// the port all come out of the one box.
|
/// the port all come out of the one line, and the spaces a paste brings with it are not part of any of
|
||||||
|
/// them.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AManualTarget_IsDialledExactlyAsItWasTyped()
|
public async Task ThePrompt_DialsATypedAddressExactlyAsItWasTyped()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = " deploy@build.internal:2222 ";
|
await ConnectAtThePromptAsync(" deploy@build.internal:2222 ", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var request = ssh.Requests.ShouldHaveSingleItem();
|
var request = ssh.Requests.ShouldHaveSingleItem();
|
||||||
request.Host.ShouldBe("build.internal");
|
request.Host.ShouldBe("build.internal");
|
||||||
@@ -1065,71 +1063,55 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AManualTargetWithNoPort_TakesTwentyTwo()
|
public async Task ThePrompt_DialsAnAddressWithNoPortOnTwentyTwo()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "root@box";
|
await ConnectAtThePromptAsync("root@box", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
ssh.Requests.ShouldHaveSingleItem().Port.ShouldBe(22);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
/// <c>ssh</c> would fall back to this machine's own account name. A phone's is the Android user, which
|
||||||
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
/// is never a login on anything, so the guess would fail at the remote as "authentication failed"
|
||||||
/// rather than here as a sentence about the box that was typed into.
|
/// rather than here as a sentence about the line that was typed into.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The first two have no <c>@</c>, so the prompt reads them as the name of a saved host rather than as an
|
||||||
|
/// address — which is why their sentences are the prompt's own and not the address parser's.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("", "Type a machine")]
|
[InlineData("", "Type the name of a saved host")]
|
||||||
[InlineData("build.internal", "Say who to log in as")]
|
[InlineData("build.internal", "No saved host is called")]
|
||||||
[InlineData("deploy@", "Say who to log in as")]
|
[InlineData("deploy@", "Say who to log in as")]
|
||||||
[InlineData("@build.internal", "Say who to log in as")]
|
[InlineData("@build.internal", "Say who to log in as")]
|
||||||
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
[InlineData("deploy@build.internal:70000", "between 1 and 65535")]
|
||||||
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
[InlineData("deploy@build.internal:ssh", "between 1 and 65535")]
|
||||||
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
[InlineData("deploy@[fe80::1]", "bracketed IPv6")]
|
||||||
public async Task AManualTargetThatCannotBeRead_IsRefusedBeforeAnythingIsDialled(
|
public async Task ThePrompt_RefusesWhatCannotBeRead_BeforeAnythingIsDialled(
|
||||||
string typed,
|
string typed,
|
||||||
string because)
|
string because)
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = typed;
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
vault.ManualPassword = "hunter2";
|
shell.PromptText = typed;
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
vault.ManualStatus.ShouldContain(because);
|
shell.PromptRefusal.ShouldContain(because);
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeFalse("a line that cannot be read has no password to ask for");
|
||||||
ssh.Requests.ShouldBeEmpty();
|
ssh.Requests.ShouldBeEmpty();
|
||||||
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
shell.Tabs.ShouldBeEmpty("a refusal is not an attempt, so there is no tab to explain it");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// A password is the only thing this path can authenticate with, so an empty one is refused here rather
|
|
||||||
/// than sent. Offering the keychain's keys would be a second binding resolution beside the connect
|
|
||||||
/// path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only copy of.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AManualTargetWithNoPassword_SaysSoRatherThanDiallingWithoutOne()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
||||||
|
|
||||||
vault.ManualTarget = "root@box";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.ManualStatus.ShouldContain("password");
|
|
||||||
ssh.Requests.ShouldBeEmpty();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The phone's hosts search box, read as an address: the card that dials it appears for <c>user@host</c>
|
/// 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.
|
/// and for nothing else. A plain search must not grow one on every keystroke.
|
||||||
@@ -1188,8 +1170,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An empty password is refused under the box rather than sent, as the manual box refuses one — and the
|
/// An empty password is refused under the box rather than sent, as the connect prompt refuses one — and
|
||||||
/// address stays where it was typed, so filling the password in is all that is left to do.
|
/// the address stays where it was typed, so filling the password in is all that is left to do.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
public async Task TheHostsSearchRefusesAnEmptyPassword_AndKeepsTheAddress()
|
||||||
@@ -1363,6 +1345,27 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
|
shell.HasPromptRefusal.ShouldBeFalse("typing again clears a refusal about what was typed");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// A tap on a match takes that host outright — the phone's way of completing a name, where there is no
|
||||||
|
/// Tab — and carries on as Enter would: here, to the host's password, with nothing dialled yet.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ATappedMatchOnThePrompt_TakesThatHost()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
shell.PromptText = "prod";
|
||||||
|
|
||||||
|
await shell.ChoosePromptMatchCommand.ExecuteAsync(shell.PromptMatches.ShouldHaveSingleItem());
|
||||||
|
|
||||||
|
shell.PromptText.ShouldBe("prod-db");
|
||||||
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
|
shell.ShowsPromptMatches.ShouldBeFalse("the matches are for the name, not drawn under its password");
|
||||||
|
shell.PromptPasswordLabel.ShouldBe($"{vault.Hosts[0].Address}'s password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
|
/// Two hosts in two vaults can share a name, and the prompt has no room to say which vault each is in, so
|
||||||
/// it refuses rather than guessing and points at the palette, which does say.
|
/// it refuses rather than guessing and points at the palette, which does say.
|
||||||
@@ -1408,8 +1411,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// An empty password is refused on the prompt rather than sent, for the reason the manual box gives: the
|
/// An empty password is refused on the prompt rather than sent: the server's "authentication failed" would
|
||||||
/// server's "authentication failed" would be a sentence about the machine when the problem is the box.
|
/// be a sentence about the machine when the problem is the box. A password is the only thing a typed
|
||||||
|
/// address can authenticate with — offering the keychain's keys would be a second binding resolution
|
||||||
|
/// beside the connect path's own, which is the thing <c>TryBuildAuthentication</c> exists to be the only
|
||||||
|
/// copy of.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ThePrompt_RefusesAnEmptyPassword()
|
public async Task ThePrompt_RefusesAnEmptyPassword()
|
||||||
@@ -1443,6 +1449,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.PromptText.ShouldBe("deploy@build.internal:2222");
|
shell.PromptText.ShouldBe("deploy@build.internal:2222");
|
||||||
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
shell.PromptPasswordLabel.ShouldBe("deploy@build.internal:2222's password:");
|
||||||
|
shell.PromptPassword.ShouldBeEmpty("the log never held one");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1464,7 +1472,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
/// <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 typed address it would answer "do you trust this key" by dialling a
|
||||||
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
/// different machine — or by refusing with "choose a host first" over a key the user has just agreed to
|
||||||
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
/// trust. A host is deliberately selected here, so a retry that ignored the attempt would connect and
|
||||||
/// the assertion would still catch it.
|
/// the assertion would still catch it.
|
||||||
@@ -1481,10 +1489,7 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
ssh.Failure = new SshHostKeyUnknownException(
|
ssh.Failure = new SshHostKeyUnknownException(
|
||||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.HasPendingHostKey.ShouldBeTrue();
|
vault.HasPendingHostKey.ShouldBeTrue();
|
||||||
|
|
||||||
@@ -1499,46 +1504,25 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A recent row is one of two different things, and tapping it has to lead to whichever one it is. The
|
/// A recent row is one of two different things, and choosing it has to lead to whichever one it is. The
|
||||||
/// keychain half goes to the host's own connect bar rather than connecting from here, because that bar
|
/// keychain half takes the host as typing its name would, so its key, its saved password and its
|
||||||
/// is where its key, its password box and its refusals already live.
|
/// refusals are the keychain's — here, a host with nothing saved, so the prompt asks for its password
|
||||||
|
/// with nothing dialled yet.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARecentConnectionNamingAKeychainHost_OpensThatHostOnTheHostsScreen()
|
public async Task ARecentHostOnThePrompt_TakesThatHost()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
var host = vault.Hosts[0];
|
var host = vault.Hosts[0];
|
||||||
vault.SelectedHost = null;
|
|
||||||
shell.ShowScreenCommand.Execute(ShellScreen.Preferences);
|
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
|
||||||
|
|
||||||
shell.IsHostsShowing.ShouldBeTrue();
|
|
||||||
vault.SelectedHost.ShouldBe(host);
|
|
||||||
vault.ManualTarget.ShouldBeEmpty("a keychain host is not dialled out of the manual box");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The other half. The log stored what was actually dialled, which is the grammar the manual box takes,
|
|
||||||
/// so it goes straight back in — without the password, which was never stored and whose absence is the
|
|
||||||
/// point of that path rather than a gap in it.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ARecentConnectionWithNoKeychainItem_GoesBackIntoTheManualBox()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
// Deliberately somewhere else first, so "it did not navigate" is an assertion rather than the
|
|
||||||
// screen the application happens to open on.
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(
|
await shell.ChoosePromptRecentCommand.ExecuteAsync(Recent("prod-db", "root@prod-db:22", host.EntityId));
|
||||||
Recent("deploy@build.internal", "deploy@build.internal:2222", hostId: null));
|
|
||||||
|
|
||||||
vault.ManualTarget.ShouldBe("deploy@build.internal:2222");
|
shell.PromptText.ShouldBe("prod-db", "a keychain host is taken by name, not as the address the log kept");
|
||||||
vault.ManualPassword.ShouldBeEmpty();
|
shell.IsPromptAskingForPassword.ShouldBeTrue();
|
||||||
shell.IsTerminalSurface.ShouldBeTrue("the box being filled in is on this surface");
|
shell.PromptPasswordLabel.ShouldBe($"{host.Address}'s password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
@@ -1546,17 +1530,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
/// about it, so the address is the honest answer rather than a tap that does nothing.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ARecentConnectionNamingAHostThatHasGone_FallsBackToTheAddress()
|
public async Task ARecentHostThatHasGone_FallsBackToItsAddressOnThePrompt()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
shell.ConnectToRecentCommand.Execute(
|
await shell.ChoosePromptRecentCommand.ExecuteAsync(
|
||||||
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
Recent("prod-db", "root@prod-db:22", Guid.CreateVersion7()));
|
||||||
|
|
||||||
vault.ManualTarget.ShouldBe("root@prod-db:22");
|
shell.PromptText.ShouldBe("root@prod-db:22");
|
||||||
shell.IsTerminalSurface.ShouldBeTrue();
|
shell.PromptPasswordLabel.ShouldBe("root@prod-db:22's password:");
|
||||||
|
ssh.Requests.ShouldBeEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One row of the connection log, built by hand.</summary>
|
/// <summary>One row of the connection log, built by hand.</summary>
|
||||||
@@ -3578,8 +3563,8 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
/// The connection with no host, which is the case the old navigation was worst for: it took the box that
|
||||||
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
/// was typed into away and asked about the machine on a list the machine is deliberately not on. The
|
||||||
/// phone's connect box is on the terminal surface, so staying there is what keeps it behind the sheet —
|
/// connect prompt is on the terminal surface, so staying there is what keeps it behind the sheet — and
|
||||||
/// and what the user comes back to whichever way they answer.
|
/// what the user comes back to whichever way they answer.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
public async Task ConnectingByHand_AsksAboutTheHostKeyOverTheBoxItWasTypedInto()
|
||||||
@@ -3588,19 +3573,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
shell.ShowTerminalCommand.Execute(null);
|
|
||||||
|
|
||||||
ssh.Failure = new SshHostKeyUnknownException(
|
ssh.Failure = new SshHostKeyUnknownException(
|
||||||
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
new HostKeyPresentation("build.internal", 2222, "ssh-ed25519", "SHA256:unknown"));
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.HasPendingHostKey.ShouldBeTrue();
|
vault.HasPendingHostKey.ShouldBeTrue();
|
||||||
|
|
||||||
shell.IsTerminalSurface.ShouldBeTrue("the connect box is on this surface and has not been taken away");
|
shell.IsTerminalSurface.ShouldBeTrue("the connect prompt is on this surface and has not been taken away");
|
||||||
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
shell.IsHostKeyDecisionShowing.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8571,14 +8551,11 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
public async Task SessionFacts_ATypedPasswordSession_ShowsTheHostKeyAlgorithmAloneWithNoIdentity()
|
||||||
{
|
{
|
||||||
var vault = await ReadyToConnectAsync();
|
await ReadyToConnectAsync();
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
vault.ManualTarget = "deploy@build.internal:2222";
|
await ConnectAtThePromptAsync("deploy@build.internal:2222", "hunter2");
|
||||||
vault.ManualPassword = "hunter2";
|
|
||||||
|
|
||||||
await vault.ConnectManuallyCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.IsSessionConnected.ShouldBeTrue();
|
shell.IsSessionConnected.ShouldBeTrue();
|
||||||
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
shell.SessionHostKeyAlgorithm.ShouldBe("ssh-ed25519");
|
||||||
@@ -11378,6 +11355,18 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
|||||||
return vault;
|
return vault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Types an address at the connect prompt, then its password, pressing Enter after each.</summary>
|
||||||
|
private async Task ConnectAtThePromptAsync(string typed, string password)
|
||||||
|
{
|
||||||
|
shell.ShowTerminalCommand.Execute(null);
|
||||||
|
|
||||||
|
shell.PromptText = typed;
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
shell.PromptPassword = password;
|
||||||
|
await shell.SubmitPromptCommand.ExecuteAsync(null);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
/// <summary>A transfers screen connected to the fixture's host, and the session it opened.</summary>
|
||||||
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
private async Task<FakeSftpSession> ConnectedTransfersAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -80,6 +80,32 @@ public sealed class UpdateFlowTests : IDisposable
|
|||||||
Build().CurrentVersion.ShouldBe("0.4.2");
|
Build().CurrentVersion.ShouldBe("0.4.2");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_ForAReasonOfItsOwn_SaysThatInstead()
|
||||||
|
{
|
||||||
|
var updates = new UpdateViewModel(
|
||||||
|
new UnavailableUpdateChannel("Update it with the package manager that installed it."),
|
||||||
|
new ClientSettingsStore(Paths),
|
||||||
|
TimeProvider.System,
|
||||||
|
() => 0,
|
||||||
|
restart: _ => Task.CompletedTask);
|
||||||
|
|
||||||
|
updates.IsUnsupported.ShouldBeTrue();
|
||||||
|
updates.UnsupportedReason.ShouldBe("Update it with the package manager that installed it.");
|
||||||
|
updates.IsUnsupportedForTheUsualReason.ShouldBeFalse();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ABuildThatCannotReplaceItself_ForTheUsualReasons_LeavesTheScreenItsOwnSentence()
|
||||||
|
{
|
||||||
|
channel.IsSupported = false;
|
||||||
|
|
||||||
|
var updates = Build();
|
||||||
|
|
||||||
|
updates.UnsupportedReason.ShouldBeNull();
|
||||||
|
updates.IsUnsupportedForTheUsualReason.ShouldBeTrue();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
public async Task ABackgroundPassThatFindsNothing_SaysNothing()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user