Public Access
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05c56f20a4 |
@@ -720,24 +720,3 @@ grid of cards with a drawer — see above. The split it describes did not change
|
|||||||
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
running, so a tab list rebuilt per unlock would lose track of sessions that are still connected — the very
|
||||||
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
sessions the unlock screen already counts. `TerminalWorkspace` gained `SessionActivated` on the wire,
|
||||||
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
`IsSessionLive`, and a `SessionEnded` event so a tab can stop claiming to be connected.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v5c-4 — two more, asked for after living with v5b
|
|
||||||
|
|
||||||
**The session shell's host header is gone, and it is a deliberate departure from the design.**
|
|
||||||
`Terminal.dc.html` and `SFTP.dc.html` both draw a 60-pixel row above the pane carrying the address on the
|
|
||||||
left and a cross-surface button on the right, and v5b shipped it as `SessionHeader.axaml`. Both of the two
|
|
||||||
facts it held now live at the head of the sidebar beside the pane — the address as its own line, and the
|
|
||||||
button stretched across the column under it — and the pane is 60 pixels taller for it. The reasoning is the
|
|
||||||
one the design cannot see from a mock: this is a window somebody keeps a terminal open in all day, and a
|
|
||||||
full-width strip repeating an address the tab already names was the cheapest 60 pixels in the layout to give
|
|
||||||
back. `LayoutHarness.SessionScreenHeight` no longer subtracts a header, for the same reason it stopped
|
|
||||||
subtracting the retired window-wide tab strip.
|
|
||||||
|
|
||||||
**The sidebar closes, which the design has no state for.** 300 pixels of a 1081-pixel minimum is a lot to
|
|
||||||
spend on a list that is often two rows long, so `MainWindowViewModel.IsSessionSidebarOpen` folds the column
|
|
||||||
to a 34-pixel rail carrying the chevron that brings it back — a rail rather than nothing, because a panel
|
|
||||||
that vanishes without trace is one people report as lost. The choice is written through to
|
|
||||||
`ClientSettings.SessionSidebarOpen` rather than held for the session: it is a decision about how much of the
|
|
||||||
window a terminal gets, and one that had to be made again on every launch would not really be on offer.
|
|
||||||
|
|||||||
@@ -388,19 +388,6 @@ agent of our own plus ProxyJump covers the real use cases.
|
|||||||
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
**The SSH suite pulls `linuxserver/openssh-server` from Docker Hub**, which is rate-limited for
|
||||||
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
unauthenticated pulls. If CI starts failing on image pulls rather than on tests, that is why.
|
||||||
|
|
||||||
**That suite has an intermittent `The connection was closed by the remote host`**, on whichever test
|
|
||||||
connects first, within tens of milliseconds. Seen in CI and reproducible locally. *Mitigated, not
|
|
||||||
solved:* `SshServerFixture` now raises sshd's `MaxStartups` from its compiled-in `10:30:100`, which
|
|
||||||
refuses connections at random past ten unauthenticated ones in flight — reachable because xUnit runs
|
|
||||||
test classes in parallel and most of them connect. The fixture comment carries the full argument and
|
|
||||||
is explicit that the cure is unproven.
|
|
||||||
|
|
||||||
**And the reason it is unproven is a measurement trap worth not falling into twice.** Docker
|
|
||||||
throughput on the Windows development machine swings enough to swamp the effect: the identical
|
|
||||||
unmodified suite ran 85/85 clean and, an hour later, failed 13 runs out of 15. Any before/after flake
|
|
||||||
comparison taken there is noise. Measure this class of thing in CI, or make the server say why —
|
|
||||||
raise sshd's `LogLevel`, disable Ryuk so the container outlives the run, and read `docker logs`.
|
|
||||||
|
|
||||||
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
**MSIX packaging is ruled out, not merely deprioritised.** A packaged app runs WebView2 in an
|
||||||
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
AppContainer where loopback connections are blocked without a `CheckNetIsolation` exemption. The
|
||||||
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
terminal data plane *is* a loopback WebSocket, so MSIX would break the product outright. Velopack
|
||||||
|
|||||||
@@ -436,20 +436,6 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
|
||||||
Amber, and it does not contradict the remark above. That one says green is a fact about a host rather
|
|
||||||
than an accent, and this is the colour for a fact that is not settled yet: green is what is true, purple
|
|
||||||
is what you can press, and a connection still being made is neither. The palette's own rule gives amber
|
|
||||||
to the caveat worth reading, which is exactly what this is.
|
|
||||||
|
|
||||||
Only the tab strips use it, and only for a tab with no shell behind it yet — the same amber, from the
|
|
||||||
same brush, as the track and the running step on the connecting screen, so that a tab and the screen it
|
|
||||||
opens agree about what is happening. See TerminalScreen.axaml.
|
|
||||||
-->
|
|
||||||
<Style Selector="Ellipse.dot.connecting">
|
|
||||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
<!-- Every label, count, address and fingerprint in this design is monospace. See Palette.axaml. -->
|
||||||
<Style Selector="TextBlock.mono">
|
<Style Selector="TextBlock.mono">
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||||
|
|||||||
@@ -1029,48 +1029,6 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
<!--
|
|
||||||
Making a credential without leaving the host, as on the desktop and on the same reasoning: the
|
|
||||||
moment one is wanted is while deciding how a host authenticates, and this head has no keychain
|
|
||||||
editor for credentials at all — so without this a phone could bind a host to a credential but
|
|
||||||
never make one. Writes to the keychain the instant ADD is pressed, exactly as the new-tag box
|
|
||||||
below does and for the same reason: a host can only name an id that exists.
|
|
||||||
-->
|
|
||||||
<Button Classes="secondary" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
|
||||||
MinHeight="40" Padding="14,0"
|
|
||||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
|
||||||
Command="{Binding BeginEditorCredentialCommand}" />
|
|
||||||
|
|
||||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
|
||||||
IsVisible="{Binding IsAddingEditorCredential}">
|
|
||||||
<StackPanel Spacing="8">
|
|
||||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" />
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialLabel}"
|
|
||||||
PlaceholderText="name" />
|
|
||||||
<!--
|
|
||||||
Optional, and what makes a credential its own item: one account on twenty machines is
|
|
||||||
rotated in one place. Left blank, this host's own username is used.
|
|
||||||
-->
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialUsername}"
|
|
||||||
PlaceholderText="username (blank: this host's own)" />
|
|
||||||
<TextBox Classes="field secret" Text="{Binding EditorNewCredentialPassword}"
|
|
||||||
PlaceholderText="password" />
|
|
||||||
<TextBox Classes="field" Text="{Binding EditorNewCredentialNotes}"
|
|
||||||
PlaceholderText="notes" />
|
|
||||||
<TextBlock Classes="detail" TextWrapping="Wrap"
|
|
||||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you leave this host without saving." />
|
|
||||||
<Grid ColumnDefinitions="*,8,*">
|
|
||||||
<Button Grid.Column="0" Classes="primary" Content="ADD" MinHeight="44"
|
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
|
||||||
Command="{Binding AddEditorCredentialCommand}" />
|
|
||||||
<Button Grid.Column="2" Classes="secondary" Content="CANCEL" MinHeight="44"
|
|
||||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Center"
|
|
||||||
Command="{Binding CancelEditorCredentialCommand}" />
|
|
||||||
</Grid>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
|
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
|
||||||
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
|
||||||
|
|||||||
@@ -320,14 +320,8 @@
|
|||||||
which was true when a tab could not exist without a session; one can now —
|
which was true when a tab could not exist without a session; one can now —
|
||||||
connecting opens the tab first — and a dot that was green before anything had
|
connecting opens the tab first — and a dot that was green before anything had
|
||||||
answered would be the one thing on this strip claiming something untrue.
|
answered would be the one thing on this strip claiming something untrue.
|
||||||
|
|
||||||
Amber while it is being made, which is the other half of that correction. Not being
|
|
||||||
green stopped the dot lying, but it left a tab still dialling drawn exactly like a
|
|
||||||
tab whose shell has exited — the two states on this strip with the least in common,
|
|
||||||
one worth waiting for and one over. See Phone.axaml.
|
|
||||||
-->
|
-->
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
<TextBlock Classes="mono" FontSize="11" Text="{Binding Label}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -33,85 +33,6 @@
|
|||||||
gesture does the same thing the arrow does.
|
gesture does the same thing the arrow does.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
── the connecting step list ─────────────────────────────────────────────────────────────────────
|
|
||||||
The same five rows the desktop's ConnectingCard draws, from the same reported phases, in this head's
|
|
||||||
own sizes. Kept here rather than in Phone.axaml because nothing else on this head has a step list —
|
|
||||||
the theme file is for what more than one screen shares, and a rule that exists for one control is
|
|
||||||
easier to read beside it.
|
|
||||||
|
|
||||||
Amber for the step in flight, green behind it, red where it stopped. That is the palette's rule
|
|
||||||
rather than an exception to it: green is what is true and purple is what you can press, and a step
|
|
||||||
still happening is neither. See ConnectingCard.axaml for the longer version of this argument, and
|
|
||||||
Palette.axaml for the rule itself.
|
|
||||||
|
|
||||||
A phone needs this more than a desktop does, which is the same thing the connecting block below
|
|
||||||
already says about itself: mobile links are slower and drop more often, so the stretch this describes
|
|
||||||
is longer here and more likely to end badly.
|
|
||||||
-->
|
|
||||||
<!--
|
|
||||||
Its own FontFamily rather than the row also carrying the mono class, which is this head's convention
|
|
||||||
and not a stylistic preference: Phone.axaml's mono sets a colour and a size along with the family, so
|
|
||||||
a caption wearing both classes would be asking two rules for one Foreground and settling it on style
|
|
||||||
ordering. Every other text class here — body, label, title, detail — names its own family for exactly
|
|
||||||
that reason. The desktop's mono sets the family alone, which is why ConnectingCard composes the two
|
|
||||||
and this does not.
|
|
||||||
-->
|
|
||||||
<Style Selector="TextBlock.stepcaption">
|
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.done">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.running">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
|
||||||
<Setter Property="FontWeight" Value="SemiBold" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Fixed width and centred: four different characters on a ragged edge is a list that looks broken. -->
|
|
||||||
<Style Selector="TextBlock.stepmark">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
|
||||||
<Setter Property="FontSize" Value="11" />
|
|
||||||
<Setter Property="Width" Value="13" />
|
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.done">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.running">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
4 rather than the desktop's 5, which is the only deliberate difference between the two heads here:
|
|
||||||
this bar sits in a column 24 from each edge of a 360dp screen rather than under a 460-wide card, so
|
|
||||||
the same height reads as a heavier rule across a narrower span.
|
|
||||||
-->
|
|
||||||
<Style Selector="ProgressBar.steptrack">
|
|
||||||
<Setter Property="Height" Value="4" />
|
|
||||||
<Setter Property="MinHeight" Value="4" />
|
|
||||||
<Setter Property="CornerRadius" Value="2" />
|
|
||||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ProgressBar.steptrack.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
</UserControl.Styles>
|
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*,Auto">
|
<Grid RowDefinitions="Auto,*,Auto">
|
||||||
@@ -176,12 +97,8 @@
|
|||||||
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
Command="{Binding $parent[views:TerminalScreen].((vm:MainWindowViewModel)DataContext).SelectTabCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="7" VerticalAlignment="Center">
|
||||||
<!--
|
<!-- Green only while there is a shell behind it; see the same dot in PhoneShell. -->
|
||||||
Green only while there is a shell behind it, amber while one is being made; see
|
<Ellipse Classes="dot" Classes.live="{Binding IsLive}" Width="6" Height="6"
|
||||||
the same dot in PhoneShell, and Phone.axaml for why amber is not a rule broken.
|
|
||||||
-->
|
|
||||||
<Ellipse Classes="dot" Classes.live="{Binding IsLive}"
|
|
||||||
Classes.connecting="{Binding IsConnecting}" Width="6" Height="6"
|
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
<TextBlock Classes="mono" FontSize="12" FontWeight="SemiBold"
|
||||||
Text="{Binding Label}" />
|
Text="{Binding Label}" />
|
||||||
@@ -367,70 +284,11 @@
|
|||||||
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
<TextBlock Classes="title" FontSize="13" Text="{Binding SelectedTab.Label}" />
|
||||||
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
<TextBlock Classes="detail" FontSize="11" Foreground="{StaticResource TextDim}"
|
||||||
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
TextWrapping="Wrap" Text="{Binding SelectedTab.Address}" />
|
||||||
|
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}" />
|
||||||
<!--
|
<Button Classes="row" MinHeight="44" Padding="14,0" HorizontalAlignment="Left"
|
||||||
Where a single unchanging "connecting…" used to be. The track counts steps that really finished
|
|
||||||
against the five there are — StepsDone over StepCount, never a percentage, because the arithmetic
|
|
||||||
that makes a percentage is the arithmetic that starts inventing one. See TerminalTabViewModel.
|
|
||||||
|
|
||||||
Drawn for both states rather than once per state: a refused connection has the same five rows and
|
|
||||||
the same track, and the only differences are that one row is red and the track stops where it got
|
|
||||||
to. Two templates kept identical for the sake of a colour is how the two drift apart.
|
|
||||||
-->
|
|
||||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
|
||||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
|
||||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<StackPanel Spacing="6" />
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="9">
|
|
||||||
<TextBlock Classes="stepmark"
|
|
||||||
Classes.done="{Binding IsDone}"
|
|
||||||
Classes.running="{Binding IsRunning}"
|
|
||||||
Classes.stopped="{Binding IsStopped}"
|
|
||||||
Text="{Binding Mark}" />
|
|
||||||
<TextBlock Classes="stepcaption"
|
|
||||||
Classes.done="{Binding IsDone}"
|
|
||||||
Classes.running="{Binding IsRunning}"
|
|
||||||
Classes.stopped="{Binding IsStopped}"
|
|
||||||
Text="{Binding Caption}" />
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Only for a refusal now. While a connection is being made this used to be the whole of what this
|
|
||||||
screen said, and it is now the step list's running row said twice — so it is shown for the one
|
|
||||||
state the list cannot put into words: why it stopped.
|
|
||||||
-->
|
|
||||||
<TextBlock Classes="body" Text="{Binding SelectedTab.Status}"
|
|
||||||
Foreground="{StaticResource Danger}"
|
|
||||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Two 44-high targets side by side rather than one, and the second is the logs: the step list is
|
|
||||||
this attempt and the log is every other one, which is the question a connection that is taking too
|
|
||||||
long on a mobile link actually raises — has this machine ever worked from here. Reached the
|
|
||||||
ordinary way, through ShowScreenCommand, exactly as the rail and MORE reach it.
|
|
||||||
-->
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Left">
|
|
||||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
|
||||||
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}">
|
||||||
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
<TextBlock Classes="label" FontSize="9" Text="CLOSE THIS TAB" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="row" MinHeight="44" Padding="14,0"
|
|
||||||
Command="{Binding ShowScreenCommand}"
|
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Logs}">
|
|
||||||
<TextBlock Classes="label" FontSize="9" Text="SHOW LOGS" />
|
|
||||||
</Button>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
|
|||||||
@@ -475,34 +475,11 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
|
||||||
The user popover itself: the panel the rail's chip opens, in this window's own idiom rather than the
|
|
||||||
theme's. The shared MenuFlyoutPresenter rule further down this file gives every popup Chrome and a
|
|
||||||
4-pixel radius, which is right for a context menu and wrong for this one — the design draws the account
|
|
||||||
menu as a rounded card, the same radius-12 shape as every other floating surface here. Reached with
|
|
||||||
FlyoutPresenterClasses from NavRail.axaml rather than by widening that rule, so a right-click menu two
|
|
||||||
screens away does not quietly become a card as well.
|
|
||||||
-->
|
|
||||||
<Style Selector="FlyoutPresenter.poppanel">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Raised}" />
|
|
||||||
<Setter Property="BorderBrush" Value="{StaticResource BorderMid}" />
|
|
||||||
<Setter Property="BorderThickness" Value="1" />
|
|
||||||
<Setter Property="CornerRadius" Value="12" />
|
|
||||||
<Setter Property="Padding" Value="8" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
|
A row inside the user popover: a vault switch, "New vault", Settings, Preferences, Vaults, Logout. All
|
||||||
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
|
six share one shape — flat, a track fill under the pointer, 8 pixels of rounding — because the popover
|
||||||
draws them as one list and a row that looked different from its neighbours would read as a separator
|
draws them as one list and a row that looked different from its neighbours would read as a separator
|
||||||
that is not one.
|
that is not one.
|
||||||
|
|
||||||
◆ FLAT MEANS SAYING SO, which this rule did not. It set a corner radius and a padding and left the
|
|
||||||
Background alone, so every row wore the Fluent theme's own button fill and its border: six raised pills
|
|
||||||
stacked in a menu, where the design draws six lines of text that light up under the pointer. The hover
|
|
||||||
rule below was already right and was simply invisible against a fill that was there all along. Set on
|
|
||||||
the ContentPresenter as well as on the Button, the same as Button.flat does and for the same reason —
|
|
||||||
the theme's template binds its own brush there, and a Background set only on the control loses to it.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.poprow">
|
<Style Selector="Button.poprow">
|
||||||
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
<Setter Property="HorizontalAlignment" Value="Stretch" />
|
||||||
@@ -511,20 +488,14 @@
|
|||||||
<Setter Property="Padding" Value="11,4" />
|
<Setter Property="Padding" Value="11,4" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
<Setter Property="MinHeight" Value="20" />
|
<Setter Property="MinHeight" Value="20" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.poprow /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
<Setter Property="CornerRadius" Value="8" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
<Style Selector="Button.poprow:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.poprow:pressed /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The check square beside a shown vault in the popover — magenta rather than the accent, because the
|
The check square beside a shown vault in the popover — magenta rather than the accent, because the
|
||||||
@@ -623,13 +594,10 @@
|
|||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
── v5b: THE "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ─────────────────────────────────────────────
|
── v5b: THE HOST HEADER'S "OPEN SFTP" / "OPEN TERMINAL" GHOST BUTTON ───────────────────────────────
|
||||||
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
A ghost button distinct from <c>Button.ghost</c> above: this one's resting border is <c>BorderHover</c>
|
||||||
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
rather than <c>BorderMid</c> — the mock's own inset ring for this one control — and the design gives it
|
||||||
no filled hover, only the border turning to the accent.
|
no filled hover, only the border turning to the accent. See <c>SessionHeader.axaml</c>.
|
||||||
|
|
||||||
Named for the host header it was drawn for, which v5c-4 retired; the button itself moved intact to the
|
|
||||||
head of the session sidebar and is stretched across that column there. See <c>SessionSidebar.axaml</c>.
|
|
||||||
-->
|
-->
|
||||||
<Style Selector="Button.headerghost">
|
<Style Selector="Button.headerghost">
|
||||||
<Setter Property="Height" Value="32" />
|
<Setter Property="Height" Value="32" />
|
||||||
@@ -675,30 +643,6 @@
|
|||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
<Setter Property="Background" Value="{StaticResource Track}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
|
||||||
── v5c-4: THE SIDEBAR'S OWN CLOSE AND REOPEN ────────────────────────────────────────────────────────
|
|
||||||
One class for both, because they are one control in two states — a 26-pixel square carrying a chevron,
|
|
||||||
at the head of the column when it is open and at the head of the rail when it is not. Square rather
|
|
||||||
than the 33-tall rows below it: it is chrome belonging to the panel, not an entry in the list the panel
|
|
||||||
is holding, and matching the rows' shape would have offered it as one.
|
|
||||||
-->
|
|
||||||
<Style Selector="Button.sidebargrip">
|
|
||||||
<Setter Property="Width" Value="26" />
|
|
||||||
<Setter Property="Height" Value="26" />
|
|
||||||
<Setter Property="MinWidth" Value="0" />
|
|
||||||
<Setter Property="MinHeight" Value="0" />
|
|
||||||
<Setter Property="HorizontalAlignment" Value="Center" />
|
|
||||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
|
||||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.sidebargrip /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="CornerRadius" Value="8" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.sidebargrip:pointerover /template/ ContentPresenter#PART_ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Track}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
|
The "+ Pin folder" / "+ Add Snip" row at the foot of each section: 30 tall rather than 33, and its own
|
||||||
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
quieter foreground — the mock draws these as the same greyed-out "add" idiom in both sections.
|
||||||
@@ -1225,16 +1169,6 @@
|
|||||||
<Setter Property="Fill" Value="{StaticResource Live}" />
|
<Setter Property="Fill" Value="{StaticResource Live}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
<!--
|
|
||||||
Amber, and it does not contradict the rule above it. Green is what is true and this is not yet true;
|
|
||||||
purple is what you can press and a dot is not pressable. What is left is the caveat colour, which is
|
|
||||||
exactly what a connection still being made is. The same amber the connecting card's track uses, from
|
|
||||||
the same brush, so that the tab and the card the tab opens agree.
|
|
||||||
-->
|
|
||||||
<Style Selector="Ellipse.dot.connecting">
|
|
||||||
<Setter Property="Fill" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
The accent strip a selected row carries, drawn by the row template rather than by the item, because the
|
||||||
item's presenter is the thing the theme keeps repainting.
|
item's presenter is the thing the theme keeps repainting.
|
||||||
|
|||||||
@@ -15,28 +15,6 @@
|
|||||||
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
Showing the last terminal's pane would be a lie, and showing nothing reads as the application having
|
||||||
broken, so this says which machine, as whom, and how far along it is.
|
broken, so this says which machine, as whom, and how far along it is.
|
||||||
|
|
||||||
── The step list, and why it is amber ───────────────────────────────────────────────────────────────
|
|
||||||
"How far along it is" used to be one line of prose that never changed after the tab was created, which
|
|
||||||
made every slow connection look exactly like every hung one. It is now the five steps of actually
|
|
||||||
getting there, each lit at the moment the handshake reports it — see SshConnectionPhase, which names
|
|
||||||
only the boundaries a client can genuinely observe. A connection that stops therefore stops on a named
|
|
||||||
row, and "the host key is being checked" stops being the same screen as "the host is not answering".
|
|
||||||
|
|
||||||
Amber for the step in flight, and that is the palette's rule rather than an exception to it. Green is
|
|
||||||
what is true and purple is what you can press; a step still happening is neither, and it is precisely
|
|
||||||
the caveat-worth-reading amber exists for — see the remark above Warn in Palette.axaml. Steps behind it
|
|
||||||
go green as they become true, and the one a refusal landed on goes red. Nothing on the list is drawn in
|
|
||||||
the accent, because there is nothing on it to press.
|
|
||||||
|
|
||||||
Nothing here animates, which is the argument the transfer strip makes for its own track in
|
|
||||||
TransfersScreen.axaml, arriving at a screen with more reason to want a spinner. A spinner is furniture
|
|
||||||
invented to fill a state nobody measured; these steps are measured, so the track fills to what has
|
|
||||||
actually finished and then waits there. Waiting is what waiting looks like.
|
|
||||||
|
|
||||||
The list is drawn for both states rather than once per state. A refused connection has the same five
|
|
||||||
rows and the same track — the difference is only that one row is red and the track stops — and drawing
|
|
||||||
it twice would be two templates to keep identical for the sake of a colour.
|
|
||||||
|
|
||||||
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
It obeys the occlusion rule the whole window obeys: this is Avalonia-drawn content in the WebView's own
|
||||||
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
rectangle, so the shell collapses the terminal while it is up. IsTerminalShowing and IsConnectingShowing
|
||||||
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
are exclusive by construction — a selected tab either has a session or it does not — which is what makes
|
||||||
@@ -46,69 +24,8 @@
|
|||||||
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
can be laid out by a test: WebView2's adapter refuses the headless session's thread.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<UserControl.Styles>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A rule per lit state over one quiet default, so that the pending weight is stated once and each state
|
|
||||||
that differs from it is the one line that says how.
|
|
||||||
-->
|
|
||||||
<Style Selector="TextBlock.stepcaption">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
|
|
||||||
<Setter Property="FontSize" Value="12" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.done">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextDim}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.running">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource WarnText}" />
|
|
||||||
<Setter Property="FontWeight" Value="Medium" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepcaption.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource DangerText}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The marker beside each caption. Fixed width and centred, because four different characters on a
|
|
||||||
ragged left edge is a list that looks broken; see ConnectionStepViewModel.Mark for which they are.
|
|
||||||
-->
|
|
||||||
<Style Selector="TextBlock.stepmark">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource BorderMid}" />
|
|
||||||
<Setter Property="FontSize" Value="12" />
|
|
||||||
<Setter Property="Width" Value="14" />
|
|
||||||
<Setter Property="TextAlignment" Value="Center" />
|
|
||||||
<Setter Property="VerticalAlignment" Value="Center" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.done">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Live}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.running">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="TextBlock.stepmark.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The track over the list. Amber while the attempt is alive and red once it is not, so that the bar says
|
|
||||||
the same thing as the row it stopped on rather than staying the colour of something still being waited
|
|
||||||
for. Chip underneath, matching the transfer strip's track.
|
|
||||||
-->
|
|
||||||
<Style Selector="ProgressBar.steptrack">
|
|
||||||
<Setter Property="Height" Value="5" />
|
|
||||||
<Setter Property="MinHeight" Value="5" />
|
|
||||||
<Setter Property="CornerRadius" Value="3" />
|
|
||||||
<Setter Property="Background" Value="{StaticResource Chip}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Warn}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="ProgressBar.steptrack.stopped">
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource Danger}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
</UserControl.Styles>
|
|
||||||
|
|
||||||
<Panel>
|
<Panel>
|
||||||
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="18" MaxWidth="460"
|
<StackPanel VerticalAlignment="Center" HorizontalAlignment="Center" Spacing="14" MaxWidth="460"
|
||||||
Margin="24">
|
Margin="24">
|
||||||
|
|
||||||
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
<StackPanel Spacing="6" HorizontalAlignment="Center">
|
||||||
@@ -121,43 +38,15 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
Bound to StepsDone against StepCount rather than to a percentage: five steps and a maximum of five
|
Two states, deliberately different. Waiting is an accent line under the host's name; a refusal is
|
||||||
means the bar is a count of things that really finished, and the arithmetic that would turn it into
|
the reason, in the palette's red, because it is the only place the reason will be after the user
|
||||||
a percentage is exactly the arithmetic that would start inventing one.
|
navigates away from the screen that started the connection.
|
||||||
-->
|
-->
|
||||||
<ProgressBar Classes="steptrack" Classes.stopped="{Binding SelectedTab.IsFailed}"
|
<TextBlock Classes="mono" Text="{Binding SelectedTab.Status}" FontSize="12"
|
||||||
Minimum="0" Maximum="{Binding SelectedTab.StepCount}"
|
Foreground="{StaticResource Accent}" HorizontalAlignment="Center"
|
||||||
Value="{Binding SelectedTab.StepsDone, Mode=OneWay}" />
|
TextWrapping="Wrap" TextAlignment="Center"
|
||||||
|
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding SelectedTab.Steps}" HorizontalAlignment="Center">
|
|
||||||
<ItemsControl.ItemsPanel>
|
|
||||||
<ItemsPanelTemplate>
|
|
||||||
<StackPanel Spacing="7" />
|
|
||||||
</ItemsPanelTemplate>
|
|
||||||
</ItemsControl.ItemsPanel>
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConnectionStepViewModel">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
|
||||||
<TextBlock Classes="stepmark"
|
|
||||||
Classes.done="{Binding IsDone}"
|
|
||||||
Classes.running="{Binding IsRunning}"
|
|
||||||
Classes.stopped="{Binding IsStopped}"
|
|
||||||
Text="{Binding Mark}" />
|
|
||||||
<TextBlock Classes="stepcaption mono"
|
|
||||||
Classes.done="{Binding IsDone}"
|
|
||||||
Classes.running="{Binding IsRunning}"
|
|
||||||
Classes.stopped="{Binding IsStopped}"
|
|
||||||
Text="{Binding Caption}" />
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
A refusal is the reason, in the palette's red, because it is the only place the reason will be after
|
|
||||||
the user navigates away from the screen that started the connection. It sits under the list rather
|
|
||||||
than replacing it: which row it stopped on is half the answer and the sentence is the other half.
|
|
||||||
-->
|
|
||||||
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
<SelectableTextBlock Text="{Binding SelectedTab.Status}" FontSize="13"
|
||||||
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
Foreground="{StaticResource Danger}" HorizontalAlignment="Center"
|
||||||
TextWrapping="Wrap" TextAlignment="Center"
|
TextWrapping="Wrap" TextAlignment="Center"
|
||||||
@@ -173,23 +62,14 @@
|
|||||||
handshake that finishes afterwards is adopted rather than dropped — see
|
handshake that finishes afterwards is adopted rather than dropped — see
|
||||||
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
MainWindowViewModel.CloseTabAsync. Two buttons rather than one with a converted label, because the
|
||||||
two are different decisions and only one of them abandons something still running.
|
two are different decisions and only one of them abandons something still running.
|
||||||
|
|
||||||
Beside each, the logs. The step list is this attempt and the log is every other one, which is the
|
|
||||||
question both a connection taking too long and a connection just refused actually raise — has this
|
|
||||||
machine ever worked. It is the ordinary rail destination reached the ordinary way rather than a
|
|
||||||
second log grown inside this card, and leaving by it does not abandon the handshake: the tab stays
|
|
||||||
in the strip and the card is still here on the way back.
|
|
||||||
-->
|
-->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Center">
|
<Button Classes="ghost" HorizontalAlignment="Center" Content="GIVE UP"
|
||||||
<Button Classes="ghost" Content="SHOW LOGS" Command="{Binding ShowScreenCommand}"
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.Logs}" />
|
|
||||||
<Button Classes="ghost" Content="GIVE UP" Command="{Binding CloseTabCommand}"
|
|
||||||
CommandParameter="{Binding SelectedTab}"
|
|
||||||
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
IsVisible="{Binding SelectedTab.IsConnecting}" />
|
||||||
<Button Classes="ghost" Content="CLOSE TAB" Command="{Binding CloseTabCommand}"
|
|
||||||
CommandParameter="{Binding SelectedTab}"
|
<Button Classes="ghost" HorizontalAlignment="Center" Content="CLOSE TAB"
|
||||||
|
Command="{Binding CloseTabCommand}" CommandParameter="{Binding SelectedTab}"
|
||||||
IsVisible="{Binding SelectedTab.IsFailed}" />
|
IsVisible="{Binding SelectedTab.IsFailed}" />
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|||||||
@@ -584,56 +584,6 @@
|
|||||||
</ComboBox.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ComboBox>
|
</ComboBox>
|
||||||
|
|
||||||
<!--
|
|
||||||
Making a credential without leaving the host. The moment one is wanted is this one: somebody
|
|
||||||
is deciding how a host authenticates and finds the password is not in the keychain yet, and
|
|
||||||
sending them to the other screen to add it would lose the half-typed host they are standing
|
|
||||||
in. Same argument as the new-tag box further down, same immediate write, same honest
|
|
||||||
consequence — the credential stays if this editor is cancelled, because a host can only name
|
|
||||||
an id that exists.
|
|
||||||
|
|
||||||
A button beside the picker rather than an entry inside it. Every row of that list is a
|
|
||||||
binding the host can have; "make a new one" is an action, and as an entry it would sit in the
|
|
||||||
box afterwards describing a state no host can be in.
|
|
||||||
-->
|
|
||||||
<Button Classes="ghost" Content="+ NEW CREDENTIAL" HorizontalAlignment="Left"
|
|
||||||
FontSize="10.5" Height="28" Padding="10,0"
|
|
||||||
IsVisible="{Binding !IsAddingEditorCredential}"
|
|
||||||
Command="{Binding BeginEditorCredentialCommand}"
|
|
||||||
ToolTip.Tip="Adds a credential to the keychain and binds this host to it" />
|
|
||||||
|
|
||||||
<Border CornerRadius="12" Background="{StaticResource Field}"
|
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1" Padding="12"
|
|
||||||
IsVisible="{Binding IsAddingEditorCredential}">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="label" Text="NEW CREDENTIAL" FontSize="10" />
|
|
||||||
<TextBox Text="{Binding EditorNewCredentialLabel}" PlaceholderText="name" Height="36" />
|
|
||||||
<!--
|
|
||||||
Optional, and what makes a credential worth being its own item: one account on twenty
|
|
||||||
machines is rotated in one place. Left blank, this host's own username is used.
|
|
||||||
-->
|
|
||||||
<TextBox Text="{Binding EditorNewCredentialUsername}" Height="36"
|
|
||||||
PlaceholderText="username (blank: use this host's own)" />
|
|
||||||
<!-- Masked, on the reasoning the keychain's own password box carries. -->
|
|
||||||
<TextBox Text="{Binding EditorNewCredentialPassword}" PlaceholderText="password"
|
|
||||||
PasswordChar="•" Height="36">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddEditorCredentialCommand}" />
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
<TextBox Text="{Binding EditorNewCredentialNotes}" PlaceholderText="notes"
|
|
||||||
AcceptsReturn="True" Height="44" TextWrapping="Wrap" />
|
|
||||||
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
|
|
||||||
Text="Added to the keychain as soon as you press ADD, so it stays even if you cancel this host. Renaming and deleting are on the keychain screen." />
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Classes="accent" Content="ADD"
|
|
||||||
Command="{Binding AddEditorCredentialCommand}" />
|
|
||||||
<Button Classes="ghost" Content="CANCEL"
|
|
||||||
Command="{Binding CancelEditorCredentialCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
◆ THE RELAY CARD, restyled to the mock's nested-card shape — radius 12, a checkbox with the
|
||||||
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
title beside it rather than under it — but NOT to the mock's copy. The sentence stays
|
||||||
|
|||||||
@@ -690,7 +690,7 @@
|
|||||||
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
<Border Classes="chip" Height="19" CornerRadius="5" Padding="6,2"
|
||||||
IsVisible="{Binding HasPins}">
|
IsVisible="{Binding HasPins}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="3">
|
<StackPanel Orientation="Horizontal" Spacing="3">
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
<TextBlock Text="" FontFamily="{StaticResource IconFont}"
|
||||||
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
FontSize="11" Foreground="{StaticResource TextFaint}" />
|
||||||
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
<TextBlock Classes="mono" Text="{Binding PinCount}" FontSize="10.5"
|
||||||
Foreground="{StaticResource TextFaint}" />
|
Foreground="{StaticResource TextFaint}" />
|
||||||
|
|||||||
@@ -148,17 +148,13 @@
|
|||||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<!--
|
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||||
v5c-4: two rows rather than three. The 60-pixel host header that used to sit above this
|
<views:SessionHeader Grid.Row="0"
|
||||||
screen is gone; the address and the "Open terminal" button it carried are in the
|
OpenLabel="Open terminal"
|
||||||
sidebar now — see SessionSidebar.axaml — and the pane keeps the height. Its third
|
OpenCommand="{Binding OpenTerminalForFilesHostCommand}"
|
||||||
binding, the Transfers.Status line it printed while no host was open, is not moved
|
EmptyText="{Binding Transfers.Status}" />
|
||||||
either: TransfersScreen draws that same string itself, both in its own empty state and
|
<views:TransfersScreen Grid.Row="1" DataContext="{Binding Transfers}" />
|
||||||
beside the remote pane's DISCONNECT once something is open.
|
<views:SessionStatusBar Grid.Row="2" />
|
||||||
-->
|
|
||||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
|
||||||
<views:TransfersScreen Grid.Row="0" DataContext="{Binding Transfers}" />
|
|
||||||
<views:SessionStatusBar Grid.Row="1" />
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always
|
||||||
@@ -272,16 +268,14 @@
|
|||||||
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
<Border Grid.Row="1" BorderBrush="{StaticResource Border}" BorderThickness="1"
|
||||||
CornerRadius="0,0,12,12" ClipToBounds="True">
|
CornerRadius="0,0,12,12" ClipToBounds="True">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
<!--
|
<Grid Grid.Column="0" RowDefinitions="Auto,*,Auto">
|
||||||
v5c-4: two rows rather than three, the same as the SFTP wrapper above and for the same
|
<views:SessionHeader Grid.Row="0"
|
||||||
reason — the host header is gone and the terminal has its 60 pixels. The empty state it
|
OpenLabel="Open SFTP"
|
||||||
used to print ("no terminals open · press + or Ctrl+K…") went with it rather than moving:
|
OpenCommand="{Binding SelectFilesHostCommand}"
|
||||||
this Grid is only drawn on the terminal surface, and the surface with no tab open already
|
OpenCommandParameter="{Binding SelectedTab}"
|
||||||
answers for itself in the tab row's own "+" and in the connecting card below.
|
EmptyText="no terminals open · press + or Ctrl+K, or choose a host and press Connect" />
|
||||||
-->
|
|
||||||
<Grid Grid.Column="0" RowDefinitions="*,Auto">
|
|
||||||
|
|
||||||
<Panel Grid.Row="0" Background="{StaticResource Pane}">
|
<Panel Grid.Row="1" Background="{StaticResource Pane}">
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
The other thing that can be in the terminal's rectangle: a tab whose session does not
|
||||||
@@ -306,7 +300,7 @@
|
|||||||
|
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<views:SessionStatusBar Grid.Row="1" ShowsEncoding="True" />
|
<views:SessionStatusBar Grid.Row="2" ShowsEncoding="True" />
|
||||||
</Grid>
|
</Grid>
|
||||||
<!--
|
<!--
|
||||||
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
Hides when no session is active — see ShowsQuickAccessSidebar — rather than always drawn:
|
||||||
|
|||||||
@@ -268,7 +268,6 @@ internal sealed partial class MainWindow : Window
|
|||||||
if (shell is { } previous)
|
if (shell is { } previous)
|
||||||
{
|
{
|
||||||
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
previous.TerminalSessionOpened -= OnTerminalSessionOpened;
|
||||||
previous.TerminalFocusRequested -= OnTerminalFocusRequested;
|
|
||||||
previous.PropertyChanged -= OnShellPropertyChanged;
|
previous.PropertyChanged -= OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -287,7 +286,6 @@ internal sealed partial class MainWindow : Window
|
|||||||
wasUnlocked = viewModel.IsUnlocked;
|
wasUnlocked = viewModel.IsUnlocked;
|
||||||
|
|
||||||
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
viewModel.TerminalSessionOpened += OnTerminalSessionOpened;
|
||||||
viewModel.TerminalFocusRequested += OnTerminalFocusRequested;
|
|
||||||
viewModel.PropertyChanged += OnShellPropertyChanged;
|
viewModel.PropertyChanged += OnShellPropertyChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,15 +298,6 @@ internal sealed partial class MainWindow : Window
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
private void OnTerminalSessionOpened(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The same call for a session that was already open and has just been typed into from the sidebar —
|
|
||||||
/// see <see cref="MainWindowViewModel.TerminalFocusRequested"/>. Posted like every other path here,
|
|
||||||
/// although nothing was revealed this turn: the post also re-checks that a terminal is still showing,
|
|
||||||
/// which is what keeps this from stealing the keyboard if the insert landed the user on the snippets
|
|
||||||
/// screen instead.
|
|
||||||
/// </remarks>
|
|
||||||
private void OnTerminalFocusRequested(object? sender, EventArgs e) => FocusTerminalWhenLaidOut();
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
/// A dispatch and nothing else. Every arm below is a separate decision about where the keyboard goes,
|
||||||
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
/// and they were one method until the four of them stopped fitting in a screenful — which is roughly the
|
||||||
|
|||||||
@@ -131,19 +131,13 @@
|
|||||||
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
|
KEPT — the mock has no screen for approved host keys at all; see the file-level remark. push_pin
|
||||||
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
|
is the same codepoint HostsScreen.axaml already draws for a host's own pin badge, reused rather
|
||||||
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
than picked afresh so the one concept reads as one glyph everywhere it appears.
|
||||||
|
|
||||||
◆ U+F10D, not U+E946, which both sites drew until this pass and which no glyph in the embedded
|
|
||||||
face answers to: the cmap of Assets/Fonts/MaterialIcons (Material Icons 1.017, 2019) skips E944
|
|
||||||
and E946, so this row and the hosts screen's own pin badge were both drawing a tofu box. F10D is
|
|
||||||
where push_pin lives in that vintage, verified against the file rather than against a codepoints
|
|
||||||
table for a later release of the font.
|
|
||||||
-->
|
-->
|
||||||
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
<Button Classes="flat nav" Classes.active="{Binding IsKnownHostsShowing}"
|
||||||
Command="{Binding ShowScreenCommand}"
|
Command="{Binding ShowScreenCommand}"
|
||||||
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
CommandParameter="{x:Static vm:ShellScreen.KnownHosts}"
|
||||||
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
ToolTip.Tip="Host keys you have approved, and how to withdraw one">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock Classes="navicon" Text="" />
|
<TextBlock Classes="navicon" Text="" />
|
||||||
<TextBlock Classes="navlabel" Text="Pins" />
|
<TextBlock Classes="navlabel" Text="Pins" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,22 +191,17 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<FlyoutBase.AttachedFlyout>
|
<FlyoutBase.AttachedFlyout>
|
||||||
<!--
|
<Flyout Placement="TopEdgeAlignedLeft">
|
||||||
FlyoutPresenterClasses, because a Flyout's own panel is not in this markup's visual tree to be
|
<StackPanel Width="227" Spacing="8">
|
||||||
styled from here — see FlyoutPresenter.poppanel in App.axaml for what the class carries and why
|
|
||||||
the shared popup rule was not simply widened to cover it.
|
|
||||||
-->
|
|
||||||
<Flyout Placement="TopEdgeAlignedLeft" FlyoutPresenterClasses="poppanel">
|
|
||||||
<StackPanel Width="227" Spacing="4">
|
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
|
The real email, when the server sent one — verified against MainWindowViewModel.Email rather
|
||||||
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
|
than assumed, and simply absent from the popover when it has not. No " · Org" suffix: there
|
||||||
is no organisation concept behind a vault, only the vault itself, which the rows below name.
|
is no organisation concept behind a vault, only the vault itself, which the rows below name.
|
||||||
-->
|
-->
|
||||||
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1" Margin="11,4,11,6"
|
<TextBlock FontSize="10.5" FontWeight="Medium" LetterSpacing="0.1"
|
||||||
Foreground="{StaticResource TextGhost}"
|
Foreground="{StaticResource TextGhost}"
|
||||||
Text="{Binding Email}" TextTrimming="CharacterEllipsis"
|
Text="{Binding Email}"
|
||||||
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
IsVisible="{Binding Email, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
@@ -257,7 +246,7 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
v5c: Settings, Vaults and Preferences now each land on their own page of the settings mode —
|
||||||
@@ -271,23 +260,16 @@
|
|||||||
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
<Button Classes="poprow" Click="OnPopoverSettingsPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource Text}" />
|
||||||
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Settings" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<!--
|
|
||||||
◆ THE SAME TREATMENT AS SETTINGS ABOVE AND LOGOUT BELOW, which these two did not have: their
|
|
||||||
label was TextGhost where the other two rows' was Text, so a menu of five equally live
|
|
||||||
destinations drew two of them in the colour this window uses for something switched off. The
|
|
||||||
icons stay one step quieter than the words — the idiom the nav rail's own rows already follow
|
|
||||||
— but "quieter than the word beside it" and "dimmed" are not the same statement.
|
|
||||||
-->
|
|
||||||
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
<Button Classes="poprow" Click="OnPopoverVaultsPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Vaults" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
@@ -295,11 +277,11 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource TextGhost}" />
|
||||||
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Preferences" FontSize="10" Foreground="{StaticResource TextGhost}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Border Height="1" Margin="11,4" Background="{StaticResource BorderMid}" />
|
<Border Height="1" Background="{StaticResource BorderMid}" />
|
||||||
|
|
||||||
<!--
|
<!--
|
||||||
The existing sign-out flow, with its own confirm card — see
|
The existing sign-out flow, with its own confirm card — see
|
||||||
@@ -309,7 +291,7 @@
|
|||||||
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
<Button Classes="poprow" Click="OnPopoverLogoutPressed">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="10">
|
<StackPanel Orientation="Horizontal" Spacing="10">
|
||||||
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
<TextBlock FontFamily="{StaticResource IconFont}" Text="" FontSize="12"
|
||||||
Foreground="{StaticResource TextGhost}" />
|
Foreground="{StaticResource Text}" />
|
||||||
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
<TextBlock Text="Logout" FontSize="10" Foreground="{StaticResource Text}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<UserControl xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
|
||||||
|
x:Class="DodoSSH.Client.App.Views.SessionHeader"
|
||||||
|
x:Name="Root"
|
||||||
|
x:DataType="vm:MainWindowViewModel">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
── v5b's session shell host header ──────────────────────────────────────────────────────────────────────
|
||||||
|
60px, DeepChrome, atop the pane both the terminal and the SFTP surface hold. Per the design, minus the
|
||||||
|
three deviations design-notes/v5b-fidelity-notes.md records: no OS label, no latency reading, no "Port
|
||||||
|
forward" button — none of those are facts this application has.
|
||||||
|
|
||||||
|
◆ THE ONE FACT LEFT is the address, read off MainWindowViewModel.SessionAddress — which is already the
|
||||||
|
surface-aware property, so this control asks no question about which screen it is on. What differs
|
||||||
|
between the two usages is only the cross-surface button: <see cref="OpenLabel"/>, <see cref="OpenCommand"/>
|
||||||
|
and the empty-state copy, all handed in from MainWindow.axaml rather than branched on here.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<Border Height="60" Background="{StaticResource DeepChrome}"
|
||||||
|
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="24,0,20,0">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The address, only while a session/host context is active — see SessionAddress's own remark for what
|
||||||
|
"active" means on each surface. The empty state takes its place otherwise, in the idiom every other
|
||||||
|
screen's own "nothing yet" sentence already uses: TextFaint, sentence case, no punctuation implying a
|
||||||
|
form to fill in.
|
||||||
|
-->
|
||||||
|
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold" FontSize="14"
|
||||||
|
Foreground="{StaticResource AccentText}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}"
|
||||||
|
TextTrimming="CharacterEllipsis"
|
||||||
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<TextBlock Grid.Column="0" Classes="mono" FontSize="12.5"
|
||||||
|
Foreground="{StaticResource TextFaint}" VerticalAlignment="Center"
|
||||||
|
Text="{Binding #Root.EmptyText}" TextTrimming="CharacterEllipsis"
|
||||||
|
IsVisible="{Binding SessionAddress, Converter={x:Static StringConverters.IsNullOrEmpty}}" />
|
||||||
|
|
||||||
|
<Button Grid.Column="1" Classes="headerghost"
|
||||||
|
Content="{Binding #Root.OpenLabel}"
|
||||||
|
Command="{Binding #Root.OpenCommand}"
|
||||||
|
CommandParameter="{Binding #Root.OpenCommandParameter}" />
|
||||||
|
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
</UserControl>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using System.Windows.Input;
|
||||||
|
using Avalonia;
|
||||||
|
using Avalonia.Controls;
|
||||||
|
|
||||||
|
namespace DodoSSH.Client.App.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The v5b session shell's host header: the address, and a ghost button that crosses to the other surface.
|
||||||
|
/// See the remark at the top of SessionHeader.axaml.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed partial class SessionHeader : UserControl
|
||||||
|
{
|
||||||
|
/// <summary>What the cross-surface ghost button says — "Open SFTP" or "Open terminal".</summary>
|
||||||
|
internal static readonly StyledProperty<string?> OpenLabelProperty =
|
||||||
|
AvaloniaProperty.Register<SessionHeader, string?>(nameof(OpenLabel));
|
||||||
|
|
||||||
|
/// <summary>What the cross-surface ghost button runs.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// The terminal usage binds <c>SelectFilesHostCommand</c> with the selected tab as its parameter; the
|
||||||
|
/// SFTP usage binds <c>OpenTerminalForFilesHostCommand</c>, which needs none — see the remark on both in
|
||||||
|
/// <c>MainWindowViewModel</c> for why the two directions are not symmetrical.
|
||||||
|
/// </remarks>
|
||||||
|
internal static readonly StyledProperty<ICommand?> OpenCommandProperty =
|
||||||
|
AvaloniaProperty.Register<SessionHeader, ICommand?>(nameof(OpenCommand));
|
||||||
|
|
||||||
|
internal static readonly StyledProperty<object?> OpenCommandParameterProperty =
|
||||||
|
AvaloniaProperty.Register<SessionHeader, object?>(nameof(OpenCommandParameter));
|
||||||
|
|
||||||
|
/// <summary>What the header says instead of an address, while no session/host context is active.</summary>
|
||||||
|
internal static readonly StyledProperty<string?> EmptyTextProperty =
|
||||||
|
AvaloniaProperty.Register<SessionHeader, string?>(nameof(EmptyText));
|
||||||
|
|
||||||
|
public SessionHeader() => InitializeComponent();
|
||||||
|
|
||||||
|
internal string? OpenLabel
|
||||||
|
{
|
||||||
|
get => GetValue(OpenLabelProperty);
|
||||||
|
set => SetValue(OpenLabelProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal ICommand? OpenCommand
|
||||||
|
{
|
||||||
|
get => GetValue(OpenCommandProperty);
|
||||||
|
set => SetValue(OpenCommandProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal object? OpenCommandParameter
|
||||||
|
{
|
||||||
|
get => GetValue(OpenCommandParameterProperty);
|
||||||
|
set => SetValue(OpenCommandParameterProperty, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal string? EmptyText
|
||||||
|
{
|
||||||
|
get => GetValue(EmptyTextProperty);
|
||||||
|
set => SetValue(EmptyTextProperty, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,83 +19,20 @@
|
|||||||
Every row here is a command the shell already exposes for exactly this purpose — see
|
Every row here is a command the shell already exposes for exactly this purpose — see
|
||||||
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
MainWindowViewModel.PinFolderFromSidebarCommand, AddSnippetFromSidebarCommand and InsertSnippetCommand —
|
||||||
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
so this control carries no logic of its own beyond the list it draws and the click it forwards.
|
||||||
|
|
||||||
── v5c-4: THE SESSION BLOCK AT THE HEAD, AND THE HEADER ROW THAT IS GONE ─────────────────────────────────
|
|
||||||
The 60-pixel host header that used to sit above the pane on both surfaces has been retired, and its two
|
|
||||||
contents moved up here: the address it printed, and the cross-surface button — "Open SFTP" from a
|
|
||||||
terminal, "Open terminal" from SFTP. Which of the two words it is and which command it runs are resolved
|
|
||||||
by the shell now rather than handed in from the two usage sites; see
|
|
||||||
MainWindowViewModel.SessionCrossSurfaceLabel and OpenOtherSurfaceCommand. The pane keeps that height.
|
|
||||||
|
|
||||||
The address is the fact the header row existed for, so it moves rather than disappears. It sits where the
|
|
||||||
QUICK ACCESS heading used to print the selected tab's short label — that label said less than the address
|
|
||||||
does and would be the same word twice beside it.
|
|
||||||
|
|
||||||
── AND THE COLUMN CLOSES ────────────────────────────────────────────────────────────────────────────────
|
|
||||||
300 pixels is a lot of a 1180-pixel window to give a list that is often two rows long, so the column
|
|
||||||
folds to a 34-pixel rail carrying the way back. A rail rather than nothing: a panel that vanishes without
|
|
||||||
trace is one people report as lost. Both halves live in this control and swap on
|
|
||||||
MainWindowViewModel.IsSessionSidebarOpen, so MainWindow.axaml's own "Auto" column takes whichever width
|
|
||||||
is showing without knowing anything about the state — and the pane beside it grows into what is freed.
|
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Panel>
|
|
||||||
|
|
||||||
<!-- ============ THE RAIL, WHEN THE COLUMN IS CLOSED ============ -->
|
|
||||||
<!--
|
|
||||||
Painted and bordered like the open column so the closing reads as the same surface narrowing rather
|
|
||||||
than as one piece of furniture being swapped for another.
|
|
||||||
-->
|
|
||||||
<Border Width="34" Background="{StaticResource Sidebar}"
|
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
|
||||||
IsVisible="{Binding !IsSessionSidebarOpen}">
|
|
||||||
<Button Classes="flat sidebargrip" VerticalAlignment="Top" Margin="0,20,0,0"
|
|
||||||
Command="{Binding ToggleSessionSidebarCommand}"
|
|
||||||
ToolTip.Tip="Show quick access, snips and the way across to the other surface">
|
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
|
||||||
Foreground="{StaticResource TextFaint}"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
</Button>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ============ THE COLUMN ============ -->
|
|
||||||
<Border Width="300" Background="{StaticResource Sidebar}"
|
<Border Width="300" Background="{StaticResource Sidebar}"
|
||||||
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0"
|
BorderBrush="{StaticResource Border}" BorderThickness="1,0,0,0">
|
||||||
IsVisible="{Binding IsSessionSidebarOpen}">
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Spacing="6" Margin="16,20">
|
<StackPanel Spacing="6" Margin="16,20">
|
||||||
|
|
||||||
<!-- ============ THE SESSION ============ -->
|
|
||||||
<!--
|
|
||||||
The address, and the button that closes the column. Both on one row, and the address is the
|
|
||||||
trimming one: a long account@host:port is exactly the string that would otherwise push the
|
|
||||||
close button off the edge of a panel whose whole point is that it can be got rid of.
|
|
||||||
-->
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="8,0,0,0">
|
|
||||||
<TextBlock Grid.Column="0" FontFamily="{StaticResource MonoFont}" FontWeight="Bold"
|
|
||||||
FontSize="12.5" Foreground="{StaticResource AccentText}"
|
|
||||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"
|
|
||||||
Text="{Binding SessionAddress}" ToolTip.Tip="{Binding SessionAddress}" />
|
|
||||||
<Button Grid.Column="1" Classes="flat sidebargrip"
|
|
||||||
Command="{Binding ToggleSessionSidebarCommand}"
|
|
||||||
ToolTip.Tip="Close this column. The terminal takes the width, and the rail it leaves behind brings it back.">
|
|
||||||
<TextBlock Text="" FontFamily="{StaticResource IconFont}" FontSize="18"
|
|
||||||
Foreground="{StaticResource TextFaint}"
|
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
|
||||||
</Button>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The cross-surface button, stretched across the column rather than sized to its own caption: it
|
|
||||||
is the one action in this panel that is not a list row, and a 90-pixel button floating at the
|
|
||||||
left of a 300-pixel column would read as unfinished.
|
|
||||||
-->
|
|
||||||
<Button Classes="headerghost" HorizontalAlignment="Stretch" Margin="0,4,0,10"
|
|
||||||
Content="{Binding SessionCrossSurfaceLabel}"
|
|
||||||
Command="{Binding OpenOtherSurfaceCommand}" />
|
|
||||||
|
|
||||||
<!-- ============ QUICK ACCESS ============ -->
|
<!-- ============ QUICK ACCESS ============ -->
|
||||||
<TextBlock Classes="label" Text="QUICK ACCESS" FontSize="10" Margin="8,0" />
|
<Grid ColumnDefinitions="*,Auto" Margin="8,0">
|
||||||
|
<TextBlock Grid.Column="0" Classes="label" Text="QUICK ACCESS" FontSize="10" />
|
||||||
|
<TextBlock Grid.Column="1" Classes="mono" FontSize="10"
|
||||||
|
Foreground="{StaticResource TextGhost}"
|
||||||
|
Text="{Binding SelectedTab.Label}" TextTrimming="CharacterEllipsis" MaxWidth="130" />
|
||||||
|
</Grid>
|
||||||
|
|
||||||
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
<ItemsControl ItemsSource="{Binding ActiveTabPinnedPaths}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
@@ -168,6 +105,4 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
</UserControl>
|
</UserControl>
|
||||||
|
|||||||
@@ -60,23 +60,12 @@
|
|||||||
ToolTip.Tip="{Binding Address}">
|
ToolTip.Tip="{Binding Address}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="9" VerticalAlignment="Center">
|
||||||
<!--
|
<!--
|
||||||
Three states now, where there were two. Green while the shell behind this tab is running
|
Two states, as the strip's own dots always were: green while the shell behind this tab is
|
||||||
and grey once it has ended, as the strip's dots always were — and amber while it is
|
running, grey while it is connecting and once it has ended. The design's third, amber,
|
||||||
connecting, which used to be grey as well.
|
state has no meaning here — nothing in this application checks whether a host is merely
|
||||||
|
reachable — so it is not drawn; see design-notes/v5b-fidelity-notes.md.
|
||||||
The design's amber had no meaning here while nothing in this application knew how far a
|
|
||||||
connection had got; that changed with the step list, and the note this comment used to
|
|
||||||
carry — that amber is for a host merely reachable, so it is not drawn — is answered
|
|
||||||
rather than ignored. It is not being reachable that is amber, it is being underway. See
|
|
||||||
ConnectingCard.axaml, whose track and running step are the same colour for the same
|
|
||||||
reason, and design-notes/v5b-fidelity-notes.md for the state this is not.
|
|
||||||
|
|
||||||
Worth the third colour because the two it replaces were the same one: a tab still
|
|
||||||
dialling and a tab whose shell has exited both drew grey, which are the two states in
|
|
||||||
this strip with the least in common — one is worth waiting for and the other is over.
|
|
||||||
-->
|
-->
|
||||||
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
<Ellipse Classes="dot" Width="8" Height="8" Classes.live="{Binding IsLive}"
|
||||||
Classes.connecting="{Binding IsConnecting}"
|
|
||||||
VerticalAlignment="Center" />
|
VerticalAlignment="Center" />
|
||||||
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
<TextBlock Text="{Binding Label}" VerticalAlignment="Center" />
|
||||||
|
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
|
the same 53px bar: "Back to application" on the left, in place of the wordmark and the search box, and
|
||||||
the same three window-control glyphs on the right TitleBar.axaml already draws.
|
the same three window-control glyphs on the right TitleBar.axaml already draws.
|
||||||
|
|
||||||
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionStatusBar and
|
A separate control rather than a variant of TitleBar itself, on the same reasoning SessionHeader and
|
||||||
SessionSidebar are their own files: nothing here can be measured by a test that hosts the real window,
|
SessionStatusBar are their own files: nothing here can be measured by a test that hosts the real window,
|
||||||
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
and a control that is either "the wordmark bar" or "the settings bar" depending on a bound flag would be
|
||||||
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
two controls wearing one name. The dragging, maximising and closing logic is duplicated from TitleBar's
|
||||||
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
own code-behind rather than shared through a base class — four short handlers, and the day one of the two
|
||||||
|
|||||||
@@ -25,6 +25,14 @@
|
|||||||
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
channel this application has for saying that a save failed, that a sync was refused, that a merge picked
|
||||||
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
a winner. The design is a mock-up of a working afternoon and has nowhere for a sentence like that to go;
|
||||||
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
dropping the bar would have meant dropping the sentence, or repeating it on six screens.
|
||||||
|
|
||||||
|
── One line, several terminals ────────────────────────────────────────────────────────────────────────
|
||||||
|
One bar for a window that now holds any number of tabs, and connecting writes to it — so "Connected to
|
||||||
|
prod-db." outlived the tab it was about, and closing prod-db left that sentence sitting under whichever
|
||||||
|
terminal the user looked at next. A connection's own lines are now owned by the attempt that wrote them
|
||||||
|
and are cleared when its tab is closed; everything else the vault says stays, because closing a terminal
|
||||||
|
is not an answer to a failed save. See VaultViewModel.ForgetConnectionStatus and
|
||||||
|
MainWindowViewModel.CloseTabAsync.
|
||||||
-->
|
-->
|
||||||
|
|
||||||
<Border Height="24" Background="{StaticResource Chrome}"
|
<Border Height="24" Background="{StaticResource Chrome}"
|
||||||
|
|||||||
@@ -79,16 +79,8 @@
|
|||||||
-->
|
-->
|
||||||
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
<TextBlock Grid.Column="1" Text="Search or connect…" FontSize="13.5" Margin="10,0"
|
||||||
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
Foreground="{StaticResource TextGhost}" VerticalAlignment="Center" />
|
||||||
<!--
|
<!-- CTRL K, not the design's ⌘K — see the remark at the top of this file. -->
|
||||||
CTRL K, not the design's ⌘K — see the remark at the top of this file.
|
<Border Grid.Column="2" Width="34" Height="18" CornerRadius="5"
|
||||||
|
|
||||||
◆ PADDED RATHER THAN 34 WIDE, which is the design's own width for a chip reading ⌘K: two
|
|
||||||
glyphs, where the substitution this bar makes is six characters and a space. At 10.5 mono
|
|
||||||
that run is wider than 34, so the chip clipped it — "CTRL" with the K cut in half. MinWidth
|
|
||||||
keeps the design's footprint for the day this face has a ⌘ to draw, and the padding is what
|
|
||||||
the longer label actually needs.
|
|
||||||
-->
|
|
||||||
<Border Grid.Column="2" MinWidth="34" Height="18" CornerRadius="5" Padding="7,0"
|
|
||||||
Background="{StaticResource KbdChip}"
|
Background="{StaticResource KbdChip}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center">
|
HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||||
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
<TextBlock Classes="mono" Text="CTRL K" FontSize="10.5" FontWeight="Medium"
|
||||||
|
|||||||
@@ -382,9 +382,8 @@
|
|||||||
<!--
|
<!--
|
||||||
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
◆ WHAT IS OPEN, AND WHAT CLOSES IT. Only while something is.
|
||||||
|
|
||||||
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: the session shell
|
v5b drops the account-at-host chip this row used to carry beside DISCONNECT: SessionHeader now
|
||||||
prints the very same address beside this screen — in the sidebar's own session block since v5c-4
|
prints the very same address above this whole screen — see MainWindowViewModel.SessionAddress,
|
||||||
retired the header row that printed it above — see MainWindowViewModel.SessionAddress,
|
|
||||||
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
which already reads Transfers.ConnectedTo on the SFTP surface — and repeating it here stopped being
|
||||||
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
information and started being the thing squeezing DISCONNECT off the edge. At the session shell's
|
||||||
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
own narrower budget this pane is 204 pixels wide once QUICK ACCESS is showing beside it, where the
|
||||||
|
|||||||
@@ -23,13 +23,9 @@ namespace DodoSSH.Client.Session;
|
|||||||
/// <param name="AutomaticUpdateChecks">
|
/// <param name="AutomaticUpdateChecks">
|
||||||
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="SessionSidebarOpen">
|
|
||||||
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
|
|
||||||
/// </param>
|
|
||||||
public sealed record ClientSettings(
|
public sealed record ClientSettings(
|
||||||
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
|
||||||
bool AutomaticUpdateChecks = true,
|
bool AutomaticUpdateChecks = true)
|
||||||
bool SessionSidebarOpen = true)
|
|
||||||
{
|
{
|
||||||
/*
|
/*
|
||||||
A positional record, and the defaults live on the parameters rather than on property initializers.
|
A positional record, and the defaults live on the parameters rather than on property initializers.
|
||||||
@@ -106,24 +102,6 @@ public sealed record ClientSettings(
|
|||||||
warns against.
|
warns against.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/*
|
|
||||||
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
|
|
||||||
|
|
||||||
On by default, because the sidebar is where a session's pins, its snips and the way across to the
|
|
||||||
other surface live — a first launch that hid all three would be hiding the feature rather than
|
|
||||||
offering to.
|
|
||||||
|
|
||||||
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
|
|
||||||
gets, and a choice that has to be made again on every launch is one the application is not really
|
|
||||||
offering. It belongs in this file rather than in the vault for the same reason the font size does:
|
|
||||||
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
|
|
||||||
monitor onto a laptop would be a preference nobody asked for.
|
|
||||||
|
|
||||||
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
|
|
||||||
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
|
|
||||||
window that appears to lose it at random.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// <summary>Brings a value inside the range this type will store.</summary>
|
/// <summary>Brings a value inside the range this type will store.</summary>
|
||||||
public static int ClampTerminalFontSize(int pixels) =>
|
public static int ClampTerminalFontSize(int pixels) =>
|
||||||
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
|
||||||
|
|||||||
@@ -328,9 +328,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private readonly Func<string, Task>? copyToClipboard;
|
private readonly Func<string, Task>? copyToClipboard;
|
||||||
|
|
||||||
/// <inheritdoc cref="MainWindowViewModel(ClientPaths, ClientCacheFactory, TerminalWorkspace, VaultKnownHostStore, IDeviceKeyStore, SignInHandler, TimeProvider, ISftpSessionFactory, Argon2Profile?, ResumeHandler?, Func{string, Task}?, string?, IUpdateChannel?, Action{Action}?)" path="/param[@name='post']" />
|
|
||||||
private readonly Action<Action> post;
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
/// Created once and kept for the life of the process, like <see cref="workspace"/> and for the same
|
||||||
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
/// reason: file transfer opens its own authenticated connection, and locking the vault must not destroy
|
||||||
@@ -473,19 +470,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
/// absence of a line rather than by a line somebody has to remember to keep a no-op; and ADR 0011 settles
|
||||||
/// the Android head's distribution separately, so it must never acquire one by accident.
|
/// the Android head's distribution separately, so it must never acquire one by accident.
|
||||||
/// </param>
|
/// </param>
|
||||||
/// <param name="post">
|
|
||||||
/// Runs an action on the thread this shell's view models are read from. Defaults to the UI thread's
|
|
||||||
/// dispatcher, which is the answer in every real head.
|
|
||||||
/// <para>
|
|
||||||
/// A delegate rather than <c>Dispatcher.UIThread</c> reached directly, for exactly the reason
|
|
||||||
/// <c>TransfersViewModel</c>'s is one — see the remark there. It is process-wide and belongs to whichever
|
|
||||||
/// thread touched it first, so a suite that runs with no window has no way to drain it and no way to
|
|
||||||
/// know whose it is. This one exists because connection phases are reported from the handshake's own
|
|
||||||
/// thread, which is the first thing in this class that has to cross onto the UI thread and also has to
|
|
||||||
/// be assertable: the three <c>Dispatcher.UIThread.Post</c> calls that predate it are the ones this
|
|
||||||
/// suite's own comments record as out of reach, and they are left alone rather than swept in here.
|
|
||||||
/// </para>
|
|
||||||
/// </param>
|
|
||||||
internal MainWindowViewModel(
|
internal MainWindowViewModel(
|
||||||
ClientPaths paths,
|
ClientPaths paths,
|
||||||
ClientCacheFactory caches,
|
ClientCacheFactory caches,
|
||||||
@@ -499,11 +483,8 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
ResumeHandler? resume = null,
|
ResumeHandler? resume = null,
|
||||||
Func<string, Task>? copyToClipboard = null,
|
Func<string, Task>? copyToClipboard = null,
|
||||||
string? deviceName = null,
|
string? deviceName = null,
|
||||||
IUpdateChannel? updates = null,
|
IUpdateChannel? updates = null)
|
||||||
Action<Action>? post = null)
|
|
||||||
{
|
{
|
||||||
this.post = post ?? (action => Dispatcher.UIThread.Post(action));
|
|
||||||
|
|
||||||
this.paths = paths;
|
this.paths = paths;
|
||||||
this.caches = caches;
|
this.caches = caches;
|
||||||
this.workspace = workspace;
|
this.workspace = workspace;
|
||||||
@@ -550,30 +531,16 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
updateScreen = CreateUpdateScreen(updates);
|
updateScreen = CreateUpdateScreen(updates);
|
||||||
|
|
||||||
ApplyStoredPreferences();
|
// Read straight away rather than at first use, so the value is right before anything can read it —
|
||||||
|
// a phone draws its terminal buttons from this, and a size that arrived a moment later would show
|
||||||
|
// as the interface correcting itself.
|
||||||
|
TerminalFontSize = ClientSettings.ClampTerminalFontSize(settings.Read().TerminalFontSize);
|
||||||
|
|
||||||
_ = TellRendererTheFontSizeAsync();
|
_ = TellRendererTheFontSizeAsync();
|
||||||
|
|
||||||
StartSessionShellTracking();
|
StartSessionShellTracking();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Takes this machine's own preferences off disk, before anything can read them.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Read straight away rather than at first use, and both of them for the same reason: whatever is stored
|
|
||||||
/// is what the first window draws. A phone builds its terminal's font buttons from the size, and the
|
|
||||||
/// session shell decides whether to give a sidebar 300 pixels — either arriving a moment later shows as
|
|
||||||
/// the interface correcting itself in front of the user.
|
|
||||||
/// </remarks>
|
|
||||||
private void ApplyStoredPreferences()
|
|
||||||
{
|
|
||||||
var stored = settings.Read();
|
|
||||||
|
|
||||||
TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize);
|
|
||||||
IsSessionSidebarOpen = stored.SessionSidebarOpen;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
/// Wires up the two pieces of v5b's session shell that this constructor had no room left to inline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1017,20 +984,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler? TerminalSessionOpened;
|
internal event EventHandler? TerminalSessionOpened;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Raised when something this shell did belongs in the terminal the user is already looking at, so the
|
|
||||||
/// view can put the keyboard back there.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Separate from <see cref="TerminalSessionOpened"/> because no session opened: the sidebar's SNIPS row
|
|
||||||
/// typed into one that was already running, and the click that did it moved Win32 focus onto an Avalonia
|
|
||||||
/// button. The page cannot fix that from its side — see the <c>term.focus()</c> at the end of
|
|
||||||
/// <c>terminal.js</c>'s paste handler, which only ever reaches <c>document.activeElement</c> — so the
|
|
||||||
/// half that can only be done by the host is asked for here. The view re-checks that a terminal is
|
|
||||||
/// actually showing before it acts; see <c>MainWindow.FocusTerminalWhenLaidOut</c>.
|
|
||||||
/// </remarks>
|
|
||||||
internal event EventHandler? TerminalFocusRequested;
|
|
||||||
|
|
||||||
internal bool IsStarting => State == ShellState.Starting;
|
internal bool IsStarting => State == ShellState.Starting;
|
||||||
|
|
||||||
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
internal bool IsNeedingServer => State == ShellState.NeedsServer;
|
||||||
@@ -1971,9 +1924,18 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// Closes one terminal, ending its shell.
|
/// Closes one terminal, ending its shell.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
/// This is the one thing in the application that deliberately ends a session, which is why it is a tab's
|
||||||
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
/// close button and not a menu item: closing the window somebody's job is running in should take exactly
|
||||||
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
/// as much intent as it looks like it does. Locking does not do this, and neither does anything else.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// <b>It takes the status line with it, when that line is this tab's.</b> The strip along the bottom is
|
||||||
|
/// one line for the whole window — see <c>StatusBar.axaml</c> — and connecting writes to it, so closing
|
||||||
|
/// prod-db used to leave "Connected to prod-db." reporting on a session that is gone, over whichever
|
||||||
|
/// terminal the user looked at next. Only the sentence this attempt wrote goes; anything the vault has
|
||||||
|
/// said since stays, because a failed save is not something closing a terminal answers.
|
||||||
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
private async Task CloseTabAsync(TerminalTabViewModel tab)
|
||||||
@@ -2007,6 +1969,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
|
|
||||||
RaiseTabState();
|
RaiseTabState();
|
||||||
|
|
||||||
|
// Only this tab's own sentence, and only if that is what the bar currently holds — see the remark.
|
||||||
|
Vault?.ForgetConnectionStatus(tab.AttemptId);
|
||||||
|
|
||||||
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
// Explicitly, and not left to the selection having moved. Closing a tab that was not the selected one
|
||||||
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
// changes nothing about the selection, so OnSelectedTabChanged does not run — and the host whose
|
||||||
// terminal just went would keep a lit dot until something else happened to move the selection.
|
// terminal just went would keep a lit dot until something else happened to move the selection.
|
||||||
@@ -3290,7 +3255,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
oldValue.PropertyChanged -= OnVaultPropertyChanged;
|
||||||
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
oldValue.Hosts.CollectionChanged -= OnVaultHostsChanged;
|
||||||
|
|
||||||
// The four connection events are kept while an attempt is still in flight, and that is not an
|
// The three connection events are kept while an attempt is still in flight, and that is not an
|
||||||
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
|
// oversight. Locking does not end a handshake any more than it ends a shell — the workspace is
|
||||||
// what holds both, and it outlives every vault — so a connection started just before a lock still
|
// what holds both, and it outlives every vault — so a connection started just before a lock still
|
||||||
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
// has an answer coming, and the tab standing in for it is still in the strip afterwards, because
|
||||||
@@ -3304,7 +3269,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (attempts.Count == 0)
|
if (attempts.Count == 0)
|
||||||
{
|
{
|
||||||
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
oldValue.ConnectionStarting -= OnVaultConnectionStarting;
|
||||||
oldValue.ConnectionProgress -= OnVaultConnectionProgress;
|
|
||||||
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
oldValue.ConnectionFailed -= OnVaultConnectionFailed;
|
||||||
oldValue.SessionOpened -= OnVaultSessionOpened;
|
oldValue.SessionOpened -= OnVaultSessionOpened;
|
||||||
}
|
}
|
||||||
@@ -3313,7 +3277,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (newValue is not null)
|
if (newValue is not null)
|
||||||
{
|
{
|
||||||
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
newValue.ConnectionStarting += OnVaultConnectionStarting;
|
||||||
newValue.ConnectionProgress += OnVaultConnectionProgress;
|
|
||||||
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
newValue.ConnectionFailed += OnVaultConnectionFailed;
|
||||||
newValue.SessionOpened += OnVaultSessionOpened;
|
newValue.SessionOpened += OnVaultSessionOpened;
|
||||||
newValue.PropertyChanged += OnVaultPropertyChanged;
|
newValue.PropertyChanged += OnVaultPropertyChanged;
|
||||||
@@ -3449,47 +3412,12 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
private void OnVaultConnectionStarting(object? sender, ConnectionAttemptEventArgs e)
|
||||||
{
|
{
|
||||||
var tab = new TerminalTabViewModel(e.Label, e.Address);
|
var tab = new TerminalTabViewModel(e.Label, e.Address) { AttemptId = e.AttemptId };
|
||||||
attempts[e.AttemptId] = tab;
|
attempts[e.AttemptId] = tab;
|
||||||
|
|
||||||
AdoptTab(tab);
|
AdoptTab(tab);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Moves a connecting tab's step list on, from the handshake's own report.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The one place the phases raised by <c>VaultViewModel.ConnectionProgress</c> are marshalled, and the
|
|
||||||
/// reason that event does not marshal for itself: doing it here means it happens once, visibly, at the
|
|
||||||
/// only boundary that cares — everything this touches is a view model an Avalonia binding is attached to.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Posted unconditionally rather than applied inline when it looks safe. Some phases really do arrive
|
|
||||||
/// on this thread — the first is reported before the handshake has yielded at all — and a
|
|
||||||
/// <c>CheckAccess</c> fast path for them would buy one dispatcher turn on a card that is up for seconds,
|
|
||||||
/// at the price of the two orderings existing at once and only one of them being the one a test runs.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A step that arrives after the attempt has settled is harmless and needs no guard here:
|
|
||||||
/// <see cref="TerminalTabViewModel.Advance"/> ignores anything reported to a tab that is no longer
|
|
||||||
/// connecting, which is what a posted phase landing behind its own <see cref="OnVaultSessionOpened"/>
|
|
||||||
/// looks like.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A report for an attempt with no tab is dropped, exactly as the other two handlers drop one: the user
|
|
||||||
/// closed the connecting tab and there is nothing left to draw a step on. The handshake is not affected
|
|
||||||
/// and its session is still adopted if it opens.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private void OnVaultConnectionProgress(object? sender, ConnectionProgressEventArgs e) => post(() =>
|
|
||||||
{
|
|
||||||
if (attempts.TryGetValue(e.AttemptId, out var tab))
|
|
||||||
{
|
|
||||||
tab.Advance(e.Step);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
|
/// Redraws the vault menu after a synchronisation pass found a vault this account had not seen.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3513,6 +3441,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
// dropping it would leave a shell running with nothing in the window naming it.
|
// dropping it would leave a shell running with nothing in the window naming it.
|
||||||
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
|
||||||
{
|
{
|
||||||
|
AttemptId = e.AttemptId,
|
||||||
StartedAt = clock.GetUtcNow(),
|
StartedAt = clock.GetUtcNow(),
|
||||||
Cipher = NullIfEmpty(e.Cipher),
|
Cipher = NullIfEmpty(e.Cipher),
|
||||||
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
|
||||||
@@ -3852,72 +3781,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
(IsTerminalSurface && SelectedTab is not null)
|
(IsTerminalSurface && SelectedTab is not null)
|
||||||
|| (IsTransfersShowing && Transfers.IsConnected);
|
|| (IsTransfersShowing && Transfers.IsConnected);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Whether the sidebar is drawn in full, as opposed to collapsed to the rail that brings it back.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A separate question from <see cref="ShowsQuickAccessSidebar"/>, and the two are not interchangeable:
|
|
||||||
/// that one is "is there a session for this to be about", which the shell answers, and this one is "does
|
|
||||||
/// the person want to see it", which only they can. Closed still draws something — a 34-pixel rail with
|
|
||||||
/// the way back on it; see <c>SessionSidebar.axaml</c> — because a panel that vanishes with no trace of
|
|
||||||
/// how to get it back is one people report as lost rather than as closed. Remembered between launches;
|
|
||||||
/// see <see cref="ToggleSessionSidebar"/> and <c>ClientSettings.SessionSidebarOpen</c>.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isSessionSidebarOpen = true;
|
|
||||||
|
|
||||||
/// <summary>Opens the session sidebar, or closes it to its rail.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Written through on every toggle rather than on shutdown: this shell is disposed on paths that do not
|
|
||||||
/// all run to completion — a killed process, a phone's activity going away — and a preference that
|
|
||||||
/// survives only a clean exit is one that will sometimes be forgotten for no reason the user can see.
|
|
||||||
/// The store swallows its own failures and says whether it wrote; nothing here can do anything useful
|
|
||||||
/// with the answer, so the toggle stands whether or not the disk took it.
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void ToggleSessionSidebar()
|
|
||||||
{
|
|
||||||
IsSessionSidebarOpen = !IsSessionSidebarOpen;
|
|
||||||
|
|
||||||
_ = settings.Write(settings.Read() with { SessionSidebarOpen = IsSessionSidebarOpen });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The label on the sidebar's cross-surface row: where the other half of this host is.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// v5c-4 moved this button off the session shell's own 60-pixel header row and into the sidebar, and the
|
|
||||||
/// header went with it — see <c>SessionSidebar.axaml</c>. What the two surfaces hand in separately used
|
|
||||||
/// to be a pair of properties on the header control; it is resolved here now, for the same reason
|
|
||||||
/// <see cref="SessionAddress"/> is: the sidebar is one control drawn on both surfaces, and a view that
|
|
||||||
/// branched on which one it was would be asking a question the shell has already answered.
|
|
||||||
/// </remarks>
|
|
||||||
internal string SessionCrossSurfaceLabel => IsTerminalSurface ? "Open SFTP" : "Open terminal";
|
|
||||||
|
|
||||||
/// <summary>Goes to the other half of the session the sidebar is about.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The two directions were two commands bound from two usages of the header control, and they still are
|
|
||||||
/// two methods — <see cref="SelectFilesHostAsync"/> takes a tab and opens an SFTP connection to its host;
|
|
||||||
/// <see cref="OpenTerminalForFilesHostAsync"/> dials a fresh terminal at whatever SFTP has open, because
|
|
||||||
/// there is no terminal session to reuse. What is new is only that one control now asks for both, so the
|
|
||||||
/// branch lives here beside <see cref="SessionCrossSurfaceLabel"/>, which has to agree with it.
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task OpenOtherSurfaceAsync()
|
|
||||||
{
|
|
||||||
if (IsTerminalSurface)
|
|
||||||
{
|
|
||||||
if (SelectedTab is { } tab)
|
|
||||||
{
|
|
||||||
await SelectFilesHostAsync(tab).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await OpenTerminalForFilesHostAsync().ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
/// Opens the files screen on the active tab's host and navigates its remote pane to one of its pins.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -3990,12 +3853,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
if (screen.CanInsert)
|
if (screen.CanInsert)
|
||||||
{
|
{
|
||||||
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
await screen.InsertCommand.ExecuteAsync(null).ConfigureAwait(true);
|
||||||
|
|
||||||
// The click that got here took the keyboard off the terminal and gave it to the sidebar row, so
|
|
||||||
// the command lands at a prompt that cannot be typed at until somebody clicks the pane. Asked
|
|
||||||
// for after the insert rather than before it, so the caret arrives to find the text already
|
|
||||||
// there. See TerminalFocusRequested.
|
|
||||||
TerminalFocusRequested?.Invoke(this, EventArgs.Empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4089,15 +3946,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The account and endpoint the session shell's sidebar and status bar are about right now, or null when
|
/// The account and endpoint the session shell's header and status bar are about right now, or null when
|
||||||
/// neither surface has one.
|
/// neither surface has one.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
|
/// One property reading whichever surface is showing, rather than one binding per surface reading its own
|
||||||
/// source directly — <c>SessionSidebar.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
/// source directly — <c>SessionHeader.axaml</c> and <c>SessionStatusBar.axaml</c> are the same markup on
|
||||||
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
|
/// both surfaces precisely because the shell resolves "which fact source" here instead of asking the view
|
||||||
/// to. It was the retired header row that printed this first; v5c-4 moved the line into the sidebar's own
|
/// to. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
||||||
/// session block and left this property exactly as it was. The terminal's is <see cref="SelectedTab"/>'s own address; SFTP's is <see cref="TransfersViewModel.ConnectedTo"/>,
|
|
||||||
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
/// which is already the account and endpoint actually dialled — nothing here re-derives it.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? SessionAddress => Surface switch
|
internal string? SessionAddress => Surface switch
|
||||||
@@ -4242,10 +4098,6 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
|
|||||||
OnPropertyChanged(nameof(SessionIdentityLabel));
|
OnPropertyChanged(nameof(SessionIdentityLabel));
|
||||||
OnPropertyChanged(nameof(SessionIdentityText));
|
OnPropertyChanged(nameof(SessionIdentityText));
|
||||||
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
OnPropertyChanged(nameof(ShowsQuickAccessSidebar));
|
||||||
|
|
||||||
// v5c-4: the sidebar's cross-surface row says where the other half of this session is, so it turns
|
|
||||||
// over with the surface exactly as the facts above do.
|
|
||||||
OnPropertyChanged(nameof(SessionCrossSurfaceLabel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
|
|||||||
@@ -1,129 +1,7 @@
|
|||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using DodoSSH.Client.Ssh;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.Shell.ViewModels;
|
namespace DodoSSH.Client.Shell.ViewModels;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One named part of making a connection, in the order they happen.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="SshConnectionPhase"/> with one more at the front. The SSH assembly reports four phases and
|
|
||||||
/// knows about no others, which is correct for it — it has never heard of a renderer. But the first thing a
|
|
||||||
/// connection here waits on is the terminal page attaching its socket, and on the first connection after a
|
|
||||||
/// cold start that is a real wait with a real failure mode of its own: a missing WebView2 runtime. A step
|
|
||||||
/// list that began at "reaching the host" would leave the one wait most likely to hang unnamed.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Declared here rather than shared with the SSH layer for that reason, and the mapping between the two is
|
|
||||||
/// one <c>switch</c> in <c>VaultViewModel</c>. The numbering is the order and the order is load-bearing:
|
|
||||||
/// <see cref="TerminalTabViewModel.Advance"/> compares these values to decide what is already behind it.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal enum ConnectionStep
|
|
||||||
{
|
|
||||||
/// <summary>Waiting for the renderer to attach, before anything is dialled.</summary>
|
|
||||||
PreparingTerminal = 0,
|
|
||||||
|
|
||||||
/// <inheritdoc cref="SshConnectionPhase.Reaching" />
|
|
||||||
Reaching = 1,
|
|
||||||
|
|
||||||
/// <inheritdoc cref="SshConnectionPhase.CheckingHostKey" />
|
|
||||||
CheckingHostKey = 2,
|
|
||||||
|
|
||||||
/// <inheritdoc cref="SshConnectionPhase.Authenticating" />
|
|
||||||
Authenticating = 3,
|
|
||||||
|
|
||||||
/// <inheritdoc cref="SshConnectionPhase.OpeningShell" />
|
|
||||||
OpeningShell = 4,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>How one step of a connection is getting on.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Four states rather than a bool per row, because a step list is read as a sequence and the reader's
|
|
||||||
/// question at each row is which of the four this is: behind us, happening, not yet, or where it stopped.
|
|
||||||
/// <see cref="Stopped"/> exists only for the row a failure landed on — see
|
|
||||||
/// <see cref="TerminalTabViewModel.Failed"/> — and is what turns the list from a progress bar into an
|
|
||||||
/// account of how far the attempt got.
|
|
||||||
/// </remarks>
|
|
||||||
internal enum ConnectionStepState
|
|
||||||
{
|
|
||||||
/// <summary>Not started. Nothing is known about it yet.</summary>
|
|
||||||
Pending = 0,
|
|
||||||
|
|
||||||
/// <summary>Happening now.</summary>
|
|
||||||
Running = 1,
|
|
||||||
|
|
||||||
/// <summary>Finished, because something after it started.</summary>
|
|
||||||
Done = 2,
|
|
||||||
|
|
||||||
/// <summary>Where the attempt stopped. There is no step after this one.</summary>
|
|
||||||
Stopped = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>One row of the connecting card's step list.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A view model per step rather than an index the view compares against, because each row draws its own
|
|
||||||
/// state and an <c>ItemsControl</c> has no way to ask "am I before the current one?" — the alternative was a
|
|
||||||
/// converter taking two bindings, which is the same comparison written somewhere it cannot be tested.
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed partial class ConnectionStepViewModel : ObservableObject
|
|
||||||
{
|
|
||||||
internal ConnectionStepViewModel(ConnectionStep step, string caption)
|
|
||||||
{
|
|
||||||
Step = step;
|
|
||||||
Caption = caption;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Which step this is.</summary>
|
|
||||||
internal ConnectionStep Step { get; }
|
|
||||||
|
|
||||||
/// <summary>What the row says, in the present tense of the thing being waited on.</summary>
|
|
||||||
internal string Caption { get; }
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectionStepState" />
|
|
||||||
[ObservableProperty]
|
|
||||||
private ConnectionStepState state;
|
|
||||||
|
|
||||||
/// <summary>Whether this step is the one happening now.</summary>
|
|
||||||
internal bool IsRunning => State is ConnectionStepState.Running;
|
|
||||||
|
|
||||||
/// <summary>Whether this step finished.</summary>
|
|
||||||
internal bool IsDone => State is ConnectionStepState.Done;
|
|
||||||
|
|
||||||
/// <summary>Whether the attempt stopped on this step.</summary>
|
|
||||||
internal bool IsStopped => State is ConnectionStepState.Stopped;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The character drawn beside the caption for whichever state this is in.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Here rather than in a converter for the reason <c>TransferRowViewModel.StatusWord</c> is: the mapping
|
|
||||||
/// is four cases with no arithmetic, and a converter would put it in a file the shell's tests cannot
|
|
||||||
/// reach. The colours stay in the view, where the palette is.
|
|
||||||
/// <para>
|
|
||||||
/// Four distinguishable shapes rather than one recoloured, because the difference between a step that
|
|
||||||
/// finished and a step still running has to survive somebody who cannot tell this design's green from
|
|
||||||
/// its amber.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal string Mark => State switch
|
|
||||||
{
|
|
||||||
ConnectionStepState.Done => "✓",
|
|
||||||
ConnectionStepState.Running => "●",
|
|
||||||
ConnectionStepState.Stopped => "✕",
|
|
||||||
_ => "○",
|
|
||||||
};
|
|
||||||
|
|
||||||
partial void OnStateChanged(ConnectionStepState value)
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
|
||||||
OnPropertyChanged(nameof(IsDone));
|
|
||||||
OnPropertyChanged(nameof(IsStopped));
|
|
||||||
OnPropertyChanged(nameof(Mark));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// How far along a tab's connection is.
|
/// How far along a tab's connection is.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -179,23 +57,8 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Label = label;
|
Label = label;
|
||||||
Address = address;
|
Address = address;
|
||||||
|
status = "connecting…";
|
||||||
isLive = false;
|
isLive = false;
|
||||||
|
|
||||||
Steps =
|
|
||||||
[
|
|
||||||
new ConnectionStepViewModel(ConnectionStep.PreparingTerminal, "Starting the terminal"),
|
|
||||||
new ConnectionStepViewModel(ConnectionStep.Reaching, "Reaching the host"),
|
|
||||||
new ConnectionStepViewModel(ConnectionStep.CheckingHostKey, "Checking the host key"),
|
|
||||||
new ConnectionStepViewModel(ConnectionStep.Authenticating, "Signing in"),
|
|
||||||
new ConnectionStepViewModel(ConnectionStep.OpeningShell, "Opening the shell"),
|
|
||||||
];
|
|
||||||
|
|
||||||
// The first step is running before anything is awaited, because it is: the tab is created in the
|
|
||||||
// same turn as the click and the renderer wait starts immediately after. A list that opened with
|
|
||||||
// every row pending would show a connection that had not begun, which is one turn of the dispatcher
|
|
||||||
// away from being untrue and is the turn the card is first drawn in.
|
|
||||||
status = Steps[0].Caption;
|
|
||||||
Steps[0].State = ConnectionStepState.Running;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A tab for a session that is already open.</summary>
|
/// <summary>A tab for a session that is already open.</summary>
|
||||||
@@ -209,12 +72,6 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
state = TerminalTabState.Open;
|
state = TerminalTabState.Open;
|
||||||
status = string.Empty;
|
status = string.Empty;
|
||||||
isLive = true;
|
isLive = true;
|
||||||
|
|
||||||
// A session that already exists got through every step by definition, even though this tab watched
|
|
||||||
// none of them happen — an adopted session is one whose connecting tab the user closed. The list is
|
|
||||||
// never drawn for a tab in this state; it is filled in so that nothing downstream has to treat "open"
|
|
||||||
// as a fourth answer to "how far did it get".
|
|
||||||
CompleteSteps();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -227,6 +84,18 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal uint SessionId { get; private set; }
|
internal uint SessionId { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The connection attempt this tab was opened for, or <see cref="Guid.Empty"/> for a tab nothing dialled.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Carried only so that closing this tab can take the vault's status line with it when the line is about
|
||||||
|
/// this connection — see <c>VaultViewModel.ForgetConnectionStatus</c>. It is the attempt id and not the
|
||||||
|
/// session id because the message can be written before a session exists ("Connecting to prod-db…") and
|
||||||
|
/// after one has stopped existing, and because the two failure sentences a tab can end on never have a
|
||||||
|
/// session id at all.
|
||||||
|
/// </remarks>
|
||||||
|
internal Guid AttemptId { get; init; }
|
||||||
|
|
||||||
internal string Label { get; }
|
internal string Label { get; }
|
||||||
|
|
||||||
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
/// <summary>The account and endpoint, for the pane header and the status bar.</summary>
|
||||||
@@ -271,36 +140,6 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal string? IdentityLabel { get; set; }
|
internal string? IdentityLabel { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far this connection got, step by step, for the card that stands in for the pane.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Fixed at construction and never added to or removed from — the steps of a connection are known before
|
|
||||||
/// it starts, and only their state changes — so a plain array is enough and the view needs no collection
|
|
||||||
/// change notification for it.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Every row here is reported, not guessed.</b> The states come from
|
|
||||||
/// <see cref="SshConnectionPhase"/>, raised by the handshake itself at the moment each part of it begins.
|
|
||||||
/// Nothing on this list is a timer, a fraction, or a step this view model decided had probably finished
|
|
||||||
/// by now. That is the whole reason it is worth showing: a card that invented plausible progress would be
|
|
||||||
/// indistinguishable from one that had stopped receiving any.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal IReadOnlyList<ConnectionStepViewModel> Steps { get; }
|
|
||||||
|
|
||||||
/// <summary>How many steps are behind the attempt, for the card's track.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Counted rather than stored, and it counts <see cref="ConnectionStepState.Done"/> alone: the running
|
|
||||||
/// step is deliberately not half a step. The track fills to where the attempt has actually got to and
|
|
||||||
/// stops there, which is the same promise the list itself makes.
|
|
||||||
/// </remarks>
|
|
||||||
internal int StepsDone => Steps.Count(step => step.IsDone);
|
|
||||||
|
|
||||||
/// <summary>How many steps there are, for the card's track.</summary>
|
|
||||||
internal int StepCount => Steps.Count;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="TerminalTabState" />
|
/// <inheritdoc cref="TerminalTabState" />
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TerminalTabState state;
|
private TerminalTabState state;
|
||||||
@@ -360,54 +199,6 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
/// <summary>Whether this tab is a connection that never happened.</summary>
|
/// <summary>Whether this tab is a connection that never happened.</summary>
|
||||||
internal bool IsFailed => State is TerminalTabState.Failed;
|
internal bool IsFailed => State is TerminalTabState.Failed;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records that the connection has reached a named step.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Everything before <paramref name="step"/> is marked done, because a phase that has begun is proof the
|
|
||||||
/// ones before it ended — the handshake is a sequence and there is no way to be at one point in it
|
|
||||||
/// without having passed the earlier ones. That is also what covers a step too fast to observe: it is
|
|
||||||
/// closed by its successor rather than needing a report of its own.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Monotonic, and silently so. A report that has already been passed is ignored rather than rewinding
|
|
||||||
/// the list, because the one thing that can produce one is a retry after the host-key question, and a
|
|
||||||
/// card that jumped backwards would read as the connection having come undone.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal void Advance(ConnectionStep step)
|
|
||||||
{
|
|
||||||
if (State is not TerminalTabState.Connecting)
|
|
||||||
{
|
|
||||||
// Nothing to draw and nothing to correct. A late report from a handshake that has since
|
|
||||||
// finished or been given up on is not worth reopening a settled tab for.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var reached = Steps.FirstOrDefault(row => row.Step == step);
|
|
||||||
|
|
||||||
if (reached is null || reached.IsDone)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var row in Steps)
|
|
||||||
{
|
|
||||||
if (row.Step < step)
|
|
||||||
{
|
|
||||||
row.State = ConnectionStepState.Done;
|
|
||||||
}
|
|
||||||
else if (row.Step == step)
|
|
||||||
{
|
|
||||||
row.State = ConnectionStepState.Running;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Status = reached.Caption;
|
|
||||||
OnPropertyChanged(nameof(StepsDone));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
/// <summary>Takes ownership of the session that has just opened for this tab.</summary>
|
||||||
internal void Opened(uint sessionId)
|
internal void Opened(uint sessionId)
|
||||||
{
|
{
|
||||||
@@ -415,8 +206,6 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
IsLive = true;
|
IsLive = true;
|
||||||
State = TerminalTabState.Open;
|
State = TerminalTabState.Open;
|
||||||
|
|
||||||
CompleteSteps();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -431,33 +220,9 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
Status = reason;
|
Status = reason;
|
||||||
IsLive = false;
|
IsLive = false;
|
||||||
|
|
||||||
// Before the state change, so the list is already correct the first time a view asks. The step that
|
|
||||||
// was running is where it stopped, and the ones behind it stay done: how far a refused connection
|
|
||||||
// got is the most useful thing the card still knows, and it is the difference between "that host is
|
|
||||||
// not there" and "that host is there and would not have me".
|
|
||||||
foreach (var row in Steps)
|
|
||||||
{
|
|
||||||
if (row.IsRunning)
|
|
||||||
{
|
|
||||||
row.State = ConnectionStepState.Stopped;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
State = TerminalTabState.Failed;
|
State = TerminalTabState.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Marks every step done, for a connection that is no longer being waited on.</summary>
|
|
||||||
private void CompleteSteps()
|
|
||||||
{
|
|
||||||
foreach (var row in Steps)
|
|
||||||
{
|
|
||||||
row.State = ConnectionStepState.Done;
|
|
||||||
}
|
|
||||||
|
|
||||||
OnPropertyChanged(nameof(StepsDone));
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnStateChanged(TerminalTabState value)
|
partial void OnStateChanged(TerminalTabState value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(HasSession));
|
OnPropertyChanged(nameof(HasSession));
|
||||||
|
|||||||
@@ -943,31 +943,6 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
|
|||||||
internal string Address { get; } = address;
|
internal string Address { get; } = address;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A connection that has got as far as a named step.</summary>
|
|
||||||
/// <param name="attemptId">The attempt this is about.</param>
|
|
||||||
/// <param name="step">The step that has just begun.</param>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The fourth of the attempt events, and the only one that can be raised more than once for an attempt. It
|
|
||||||
/// exists because the other three say a connection started and then, seconds later, whether it worked — and
|
|
||||||
/// the seconds in between are the whole of what a user staring at a connecting card is trying to find out.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Raised on whichever thread the handshake is on.</b> SSH.NET reports the interior of a connection from
|
|
||||||
/// its own thread, and this event is that report forwarded rather than a copy made on a timer, so a
|
|
||||||
/// subscriber that touches a view model must marshal for itself. <c>MainWindowViewModel</c> does; see the
|
|
||||||
/// handler.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class ConnectionProgressEventArgs(Guid attemptId, ConnectionStep step) : EventArgs
|
|
||||||
{
|
|
||||||
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" />
|
|
||||||
internal Guid AttemptId { get; } = attemptId;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="ConnectionProgressEventArgs" path="/param[@name='step']" />
|
|
||||||
internal ConnectionStep Step { get; } = step;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>A connection that was asked for and did not happen.</summary>
|
/// <summary>A connection that was asked for and did not happen.</summary>
|
||||||
/// <param name="attemptId">The attempt that has just ended.</param>
|
/// <param name="attemptId">The attempt that has just ended.</param>
|
||||||
/// <param name="reason">What to say about it, in the tab.</param>
|
/// <param name="reason">What to say about it, in the tab.</param>
|
||||||
@@ -2155,6 +2130,60 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string status = string.Empty;
|
private string status = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The connection <see cref="Status"/> is currently about, or <see cref="Guid.Empty"/> when it is about
|
||||||
|
/// something else — a save, a sync, a refusal that has nothing to do with a terminal.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// The status line is one line for the whole window, and most of what goes through it is about the vault,
|
||||||
|
/// which is one thing however many terminals are open. A connection is not: "Connected to prod-db." is
|
||||||
|
/// about one tab, and it used to outlive that tab — close prod-db, look at the terminal beside it, and the
|
||||||
|
/// strip along the bottom was still reporting on a session that is no longer there.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Ownership rather than a blanket clear on every close, because the alternative loses the sentences this
|
||||||
|
/// bar exists for: a failed save or a refused sync is what the design deleted the bar's other four fields
|
||||||
|
/// to make room for, and closing a terminal is no reason to take one away.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Reset by <see cref="OnStatusChanged"/> on every write, so the only messages that carry an owner are the
|
||||||
|
/// ones <see cref="SayAbout"/> writes. Anything assigning <see cref="Status"/> in the ordinary way says,
|
||||||
|
/// by doing so, that its message belongs to no tab.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
private Guid statusAttemptId;
|
||||||
|
|
||||||
|
partial void OnStatusChanged(string value) => statusAttemptId = Guid.Empty;
|
||||||
|
|
||||||
|
/// <summary>Writes a status line that belongs to one connection attempt.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// After the assignment, not before: <see cref="OnStatusChanged"/> clears the owner on every write, which
|
||||||
|
/// is what makes "belongs to nobody" the default rather than something each of the other status writes
|
||||||
|
/// would have to remember to say.
|
||||||
|
/// </remarks>
|
||||||
|
private void SayAbout(ConnectionAttemptEventArgs attempt, string message)
|
||||||
|
{
|
||||||
|
Status = message;
|
||||||
|
statusAttemptId = attempt.AttemptId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Clears the status line if what it is saying is about this connection, and leaves it alone otherwise.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Called by the shell when a tab is closed — see <c>MainWindowViewModel.CloseTabAsync</c>. Cleared to
|
||||||
|
/// empty rather than replaced with "Closed prod-db.": the tab going is the report, it is the report the
|
||||||
|
/// user just made happen, and a bar that answers every close with a sentence is a bar people stop reading.
|
||||||
|
/// </remarks>
|
||||||
|
internal void ForgetConnectionStatus(Guid attemptId)
|
||||||
|
{
|
||||||
|
if (attemptId != Guid.Empty && statusAttemptId == attemptId)
|
||||||
|
{
|
||||||
|
Status = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private int pendingChanges;
|
private int pendingChanges;
|
||||||
|
|
||||||
@@ -3111,37 +3140,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private AuthenticationChoice? editorSelectedAuthentication;
|
private AuthenticationChoice? editorSelectedAuthentication;
|
||||||
|
|
||||||
// ---- Making a credential from inside the host editor ----
|
|
||||||
// A fifth set of editor fields, and deliberately not the keychain screen's four. Sharing them would put
|
|
||||||
// IsEditingCredential — which AVaultEditorIsInTheWay asks about — true while the user is on the Hosts
|
|
||||||
// screen, and the whole Vault screen would refuse to open an editor with a sentence naming a form on
|
|
||||||
// another screen. That is the exact failure AHostEditorIsInTheWay was split out to end; see its remarks.
|
|
||||||
|
|
||||||
/// <summary>Whether the host editor is showing its own new-credential form.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private bool isAddingEditorCredential;
|
|
||||||
|
|
||||||
/// <summary>The name in the host editor's new-credential form.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string editorNewCredentialLabel = string.Empty;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="CredentialEditorUsername" path="/remarks" />
|
|
||||||
[ObservableProperty]
|
|
||||||
private string editorNewCredentialUsername = string.Empty;
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Holds a password for as long as the form is open, on the same terms the keychain's box does — see
|
|
||||||
/// <see cref="CredentialEditorPassword"/>. Cleared by every path that closes this form, including the
|
|
||||||
/// ones that close the host editor around it, so a password typed here cannot outlive the form and
|
|
||||||
/// reappear behind the next host somebody edits.
|
|
||||||
/// </remarks>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string editorNewCredentialPassword = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>Free text, as the keychain's own editor takes.</summary>
|
|
||||||
[ObservableProperty]
|
|
||||||
private string editorNewCredentialNotes = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
|
/// <summary>What the group picker offers: "no group", then every group of the chosen vault.</summary>
|
||||||
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
/// <inheritdoc cref="EditorAuthenticationChoices" path="/remarks" />
|
||||||
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
internal ObservableCollection<GroupChoice> EditorGroupChoices { get; } = [];
|
||||||
@@ -3413,121 +3411,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
OnPropertyChanged(nameof(HasTagChoices));
|
OnPropertyChanged(nameof(HasTagChoices));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Opens the host editor's own new-credential form.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// A button beside the picker rather than an entry inside it. Every row of that list is a binding the
|
|
||||||
/// host can have — see <see cref="AuthenticationChoice"/> — and "make a new one" is an action, not a
|
|
||||||
/// binding: as an entry it would sit in the box afterwards describing a state no host can be in, and
|
|
||||||
/// cancelling the form would leave the picker showing it.
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private void BeginEditorCredential()
|
|
||||||
{
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
IsAddingEditorCredential = true;
|
|
||||||
Status = "Adding a credential for this host.";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Abandons the form, clearing the password out of it.</summary>
|
|
||||||
[RelayCommand]
|
|
||||||
private void CancelEditorCredential()
|
|
||||||
{
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
Status = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Closes the form and drops what was typed into it, the password included.</summary>
|
|
||||||
private void ClearEditorCredentialForm()
|
|
||||||
{
|
|
||||||
IsAddingEditorCredential = false;
|
|
||||||
EditorNewCredentialLabel = string.Empty;
|
|
||||||
EditorNewCredentialUsername = string.Empty;
|
|
||||||
EditorNewCredentialPassword = string.Empty;
|
|
||||||
EditorNewCredentialNotes = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Creates a credential from the host editor's form and binds the host being edited to it.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The same reasoning <see cref="AddEditorTagAsync"/> gives, and for the same moment: somebody is
|
|
||||||
/// choosing how a host authenticates and finds the password they want is not in the keychain yet.
|
|
||||||
/// Sending them to the other screen to make one would lose the half-typed host they were standing in.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>It writes to the keychain immediately, unlike every other field in this editor.</b> A credential
|
|
||||||
/// is a shared item with an id and a host can only name an id that exists, so there is nothing to defer.
|
|
||||||
/// Cancelling the host edit therefore leaves the credential behind — honest rather than hidden, and the
|
|
||||||
/// bargain a tag already makes here.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>A name that already exists is duplicated rather than reused, which is where this deliberately
|
|
||||||
/// parts from the tag path.</b> Two tags called "staging" are the same intention spelled twice; two
|
|
||||||
/// credentials called "root" are two different passwords, and quietly binding the host to the one that
|
|
||||||
/// happened to be there already would authenticate it as an account the user never chose. A duplicate
|
|
||||||
/// label in the picker is a smaller problem than a silent wrong password.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Into <see cref="editingHostVaultId"/>, not the standing target: the credential belongs wherever the
|
|
||||||
/// host is being sealed, so everybody who can read the host can read what it authenticates with. That is
|
|
||||||
/// stricter than the tag path — which files into the active vault and is recorded as a gap — and it can
|
|
||||||
/// be, because the picker here lists credentials from every readable vault rather than one.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AddEditorCredentialAsync(CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var credential = new CredentialSecret
|
|
||||||
{
|
|
||||||
Label = EditorNewCredentialLabel.Trim(),
|
|
||||||
|
|
||||||
// Not trimmed. A password of spaces is a password — CredentialSecret.TryValidate says so — and
|
|
||||||
// trimming one here would lock somebody out of a host over a tidiness opinion.
|
|
||||||
Password = EditorNewCredentialPassword,
|
|
||||||
Username = string.IsNullOrWhiteSpace(EditorNewCredentialUsername)
|
|
||||||
? null
|
|
||||||
: EditorNewCredentialUsername.Trim(),
|
|
||||||
|
|
||||||
// Untrimmed and unnormalised past blank-is-absent, as the keychain's editor writes it: free text
|
|
||||||
// is the user's to lay out, and its leading indent is theirs rather than this form's to correct.
|
|
||||||
Notes = string.IsNullOrWhiteSpace(EditorNewCredentialNotes) ? null : EditorNewCredentialNotes,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!credential.TryValidate(out var reason))
|
|
||||||
{
|
|
||||||
Status = reason;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await RunAsync(
|
|
||||||
"Saving…",
|
|
||||||
async () =>
|
|
||||||
{
|
|
||||||
var entityId = await session.Credentials
|
|
||||||
.CreateAsync(editingHostVaultId, credential, cancellationToken)
|
|
||||||
.ConfigureAwait(true);
|
|
||||||
|
|
||||||
// Before the reload, not after it. RefreshOpenEditors rebuilds this picker and then restores
|
|
||||||
// it from whatever this property says, so writing the binding here is what survives the pass
|
|
||||||
// — and by the time it is read, ReloadCredentialsAsync has put the matching entry in the
|
|
||||||
// list for it to land on.
|
|
||||||
EditorSelectedAuthentication =
|
|
||||||
AuthenticationChoice.ForCredential(entityId, credential.Label);
|
|
||||||
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
|
||||||
|
|
||||||
Status = $"Added '{credential.Label}' and bound this host to it. "
|
|
||||||
+ "Save the host to keep the binding.";
|
|
||||||
}).ConfigureAwait(true);
|
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
/// The paths pinned on the host being edited, in the order QUICK ACCESS draws them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -4152,15 +4035,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
internal event EventHandler<ConnectionAttemptEventArgs>? ConnectionStarting;
|
||||||
|
|
||||||
/// <summary>Raised as a connection this vault announced gets from one step to the next.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Between <see cref="ConnectionStarting"/> and whichever of the other two ends the attempt, any number
|
|
||||||
/// of times including none — a handshake fast enough to finish inside one turn reports nothing, which is
|
|
||||||
/// the honest account of it. See <see cref="ConnectionProgressEventArgs"/> for the threading, which is
|
|
||||||
/// the one way this event differs from its three neighbours.
|
|
||||||
/// </remarks>
|
|
||||||
internal event EventHandler<ConnectionProgressEventArgs>? ConnectionProgress;
|
|
||||||
|
|
||||||
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
/// <summary>Raised when a connection this vault announced does not become a session.</summary>
|
||||||
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
/// <inheritdoc cref="ConnectionStarting" path="/remarks" />
|
||||||
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
internal event EventHandler<ConnectionFailedEventArgs>? ConnectionFailed;
|
||||||
@@ -7272,9 +7146,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorPinnedPaths.Clear();
|
EditorPinnedPaths.Clear();
|
||||||
EditorNewPin = string.Empty;
|
EditorNewPin = string.Empty;
|
||||||
|
|
||||||
// Closed rather than carried over, and it holds a password — see EditorNewCredentialPassword.
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
|
|
||||||
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
// Before the group picker, because a group belongs to one vault and the picker is that vault's.
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
|
|
||||||
@@ -7360,9 +7231,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
EditorNewTag = string.Empty;
|
EditorNewTag = string.Empty;
|
||||||
BuildTagChoices();
|
BuildTagChoices();
|
||||||
|
|
||||||
// As in NewHost, and for the password it can be holding.
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
|
|
||||||
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
LoadEditorPinnedPaths(row.Host.PinnedPaths);
|
||||||
|
|
||||||
BuildEditorVaultChoices(editingHostVaultId);
|
BuildEditorVaultChoices(editingHostVaultId);
|
||||||
@@ -8223,10 +8091,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
{
|
{
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
editingEntityId = null;
|
editingEntityId = null;
|
||||||
|
|
||||||
// The form goes with the editor it lives in, password and all. A credential already added through it
|
|
||||||
// stays in the keychain — see AddEditorCredentialAsync — but what was still being typed does not.
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
Status = string.Empty;
|
Status = string.Empty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -8260,10 +8124,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
IsEditing = false;
|
IsEditing = false;
|
||||||
|
|
||||||
// As CancelEdit does, for the same password.
|
|
||||||
ClearEditorCredentialForm();
|
|
||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
SelectedHost = Hosts.FirstOrDefault(row => row.EntityId == editingEntityId);
|
||||||
@@ -10923,53 +10783,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Turns the handshake's phases into this attempt's progress events.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// Forwarded rather than accumulated, because the tab is the thing that knows what has already happened
|
|
||||||
/// and this object deliberately does not: a connection here is one straight line from renderer to
|
|
||||||
/// session, and a running total of where it had got to would be a second copy of the state the card
|
|
||||||
/// already draws from the first.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Deliberately not <c>System.Progress<T></c></b>, which captures whatever synchronisation
|
|
||||||
/// context it happens to be constructed on and posts to it. That reads like a convenience and is really
|
|
||||||
/// a second place the marshalling decision gets made: silently, differently under a test with no
|
|
||||||
/// context, and — because a post is a later turn — out of order with respect to the failure or the
|
|
||||||
/// session that follows the phase. Raised inline instead, and the shell marshals once where it can be
|
|
||||||
/// seen. See <c>MainWindowViewModel.OnVaultConnectionProgress</c>.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private PhaseReporter ReporterFor(ConnectionAttemptEventArgs attempt) => new(phase =>
|
|
||||||
ConnectionProgress?.Invoke(this, new ConnectionProgressEventArgs(attempt.AttemptId, StepFor(phase))));
|
|
||||||
|
|
||||||
/// <summary>The step a handshake phase is reported to the shell as.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The whole of the mapping between the SSH assembly's four phases and the card's five steps, in one
|
|
||||||
/// place. <see cref="ConnectionStep.PreparingTerminal"/> is not here because nothing reports it: the tab
|
|
||||||
/// starts on it, and the first phase to arrive is what closes it.
|
|
||||||
/// </remarks>
|
|
||||||
private static ConnectionStep StepFor(SshConnectionPhase phase) => phase switch
|
|
||||||
{
|
|
||||||
SshConnectionPhase.Reaching => ConnectionStep.Reaching,
|
|
||||||
SshConnectionPhase.CheckingHostKey => ConnectionStep.CheckingHostKey,
|
|
||||||
SshConnectionPhase.Authenticating => ConnectionStep.Authenticating,
|
|
||||||
SshConnectionPhase.OpeningShell => ConnectionStep.OpeningShell,
|
|
||||||
_ => ConnectionStep.Reaching,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Hands each phase straight to a delegate, on the thread that reported it.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The whole type, and it exists to be the thing <c>System.Progress<T></c> is not — see the remark
|
|
||||||
/// at its one use. A lambda cannot implement an interface, and the alternative was widening the
|
|
||||||
/// workspace's parameter to <c>Action<T></c>, which would have put a non-standard progress
|
|
||||||
/// contract into three assemblies to save one class here.
|
|
||||||
/// </remarks>
|
|
||||||
private sealed class PhaseReporter(Action<SshConnectionPhase> report) : IProgress<SshConnectionPhase>
|
|
||||||
{
|
|
||||||
public void Report(SshConnectionPhase value) => report(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
/// <summary>What a manual target reads as, once it has been taken apart.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
/// Separate from <see cref="ConnectionTarget"/> because a keychain host has no username of its own at
|
||||||
@@ -11008,7 +10821,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
Dialled(target, authentication));
|
Dialled(target, authentication));
|
||||||
|
|
||||||
ConnectionStarting?.Invoke(this, attempt);
|
ConnectionStarting?.Invoke(this, attempt);
|
||||||
Status = $"Connecting to {target.Label}…";
|
SayAbout(attempt, $"Connecting to {target.Label}…");
|
||||||
|
|
||||||
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
await OpenSessionAsync(attempt, target, authentication, cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
@@ -11247,7 +11060,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
catch (TimeoutException)
|
catch (TimeoutException)
|
||||||
{
|
{
|
||||||
Abandon(attempt, RendererNeverStarted);
|
Abandon(
|
||||||
|
attempt,
|
||||||
|
"The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
||||||
|
+ "runtime is probably missing or blocked; install it and try again.");
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
catch (SshHostKeyUnknownException exception)
|
||||||
{
|
{
|
||||||
@@ -11272,29 +11088,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>What a renderer that never attached is reported as.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The wait is translated rather than reported for the reason <see cref="OpenSessionAsync"/> gives —
|
|
||||||
/// <see cref="TimeoutException"/> says only "The operation has timed out" — and the whole value of the
|
|
||||||
/// translation is naming where to look. Which is why it cannot be one sentence: the desktop's answer is
|
|
||||||
/// a runtime this application does not install, and the phone has no such runtime and no such answer.
|
|
||||||
/// Telling somebody on a handset to install Microsoft Edge WebView2 is worse than saying nothing, at the
|
|
||||||
/// one moment they are trying to work out what went wrong.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// A runtime check rather than a constructor parameter, for the reason
|
|
||||||
/// <c>MainWindowViewModel.GestureWait</c> records at length: which renderer is behind the terminal is a
|
|
||||||
/// fact about the platform this assembly is running on, not about one installation of it.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private static string RendererNeverStarted =>
|
|
||||||
OperatingSystem.IsAndroid()
|
|
||||||
? "The terminal did not start, so nothing was connected. Android's WebView is probably "
|
|
||||||
+ "disabled or updating; check it in Settings and try again."
|
|
||||||
: "The terminal did not start, so nothing was connected. The Microsoft Edge WebView2 "
|
|
||||||
+ "runtime is probably missing or blocked; install it and try again.";
|
|
||||||
|
|
||||||
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
/// <summary>Says, in one place, that an attempt ended without a session and why.</summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
/// The reason goes to two places on purpose. The status line is where somebody watching this screen is
|
||||||
@@ -11303,7 +11096,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
private void Abandon(ConnectionAttemptEventArgs attempt, string reason)
|
||||||
{
|
{
|
||||||
Status = reason;
|
// Owned by the attempt, unlike Answer's own line below: this is the case where the tab stays, carrying
|
||||||
|
// the same reason, so closing that tab is exactly the moment the line stops being about anything.
|
||||||
|
SayAbout(attempt, reason);
|
||||||
|
|
||||||
ConnectionFailed?.Invoke(
|
ConnectionFailed?.Invoke(
|
||||||
this,
|
this,
|
||||||
@@ -11339,8 +11134,6 @@ internal sealed partial class VaultViewModel(
|
|||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Not reported before the await: the tab is constructed with this step already running — see
|
|
||||||
// TerminalTabViewModel — because there is no moment between the two worth telling anybody about.
|
|
||||||
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
await workspace.WaitForRendererAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
var request = new SshConnectionRequest(
|
var request = new SshConnectionRequest(
|
||||||
@@ -11350,7 +11143,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
authentication.Credential);
|
authentication.Credential);
|
||||||
|
|
||||||
var sessionId = await workspace
|
var sessionId = await workspace
|
||||||
.OpenSessionAsync(request, TerminalSize.Default, ReporterFor(attempt), cancellationToken)
|
.OpenSessionAsync(request, TerminalSize.Default, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
// The workspace has already opened a ticket for this session, with the address and the moment it
|
// The workspace has already opened a ticket for this session, with the address and the moment it
|
||||||
@@ -11360,7 +11153,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
// only record that machine was reached at all.
|
// only record that machine was reached at all.
|
||||||
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
connectionLog?.Identify(sessionId, target.Label, target.HostId);
|
||||||
|
|
||||||
Status = $"Connected to {target.Label}.";
|
SayAbout(attempt, $"Connected to {target.Label}.");
|
||||||
|
|
||||||
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
// The session the workspace just opened is the only place the negotiated cipher and host-key
|
||||||
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
// algorithm live — an SshConnectionRequest asks for neither and gets no say in either — so they are
|
||||||
@@ -11391,7 +11184,8 @@ internal sealed partial class VaultViewModel(
|
|||||||
// screen it is typed on says so.
|
// screen it is typed on says so.
|
||||||
if (target.Row is { } row)
|
if (target.Row is { } row)
|
||||||
{
|
{
|
||||||
await RememberTypedPasswordAsync(row, authentication, cancellationToken).ConfigureAwait(true);
|
await RememberTypedPasswordAsync(attempt, row, authentication, cancellationToken)
|
||||||
|
.ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11423,6 +11217,7 @@ internal sealed partial class VaultViewModel(
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task RememberTypedPasswordAsync(
|
private async Task RememberTypedPasswordAsync(
|
||||||
|
ConnectionAttemptEventArgs attempt,
|
||||||
HostRowViewModel row,
|
HostRowViewModel row,
|
||||||
HostAuthentication authentication,
|
HostAuthentication authentication,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -11438,8 +11233,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
if (row.IsReadOnly)
|
if (row.IsReadOnly)
|
||||||
{
|
{
|
||||||
Status = $"Connected to {row.Label}. Its password was not saved: this host was written by a "
|
SayAbout(
|
||||||
+ "newer version of DodoSSH, and binding a credential would re-encode it.";
|
attempt,
|
||||||
|
$"Connected to {row.Label}. Its password was not saved: this host was written by a newer "
|
||||||
|
+ "version of DodoSSH, and binding a credential would re-encode it.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11463,7 +11260,9 @@ internal sealed partial class VaultViewModel(
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
Status = $"Connected to {row.Label}, but its password could not be saved: {exception.Message}";
|
SayAbout(
|
||||||
|
attempt,
|
||||||
|
$"Connected to {row.Label}, but its password could not be saved: {exception.Message}");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11474,8 +11273,10 @@ internal sealed partial class VaultViewModel(
|
|||||||
|
|
||||||
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
await ReloadAsync(cancellationToken).ConfigureAwait(true);
|
||||||
|
|
||||||
Status = $"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it "
|
SayAbout(
|
||||||
+ "will not be asked for again.";
|
attempt,
|
||||||
|
$"Connected to {row.Label}. Its password is saved in your keychain as '{row.Label}', so it will "
|
||||||
|
+ "not be asked for again.");
|
||||||
|
|
||||||
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
await AutoSyncAsync(cancellationToken).ConfigureAwait(true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,50 +78,6 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
── THE BLACK STRIP UNDER THE TERMINAL ───────────────────────────────────────────────────────────────
|
|
||||||
xterm.css paints its scrolling viewport #000 — literally black, and its own comment says why: on macOS
|
|
||||||
the overlay scrollbar is only fully opaque over an opaque backdrop. Everywhere else that black is a
|
|
||||||
surface nobody sees, because the rows cover it — except along the bottom, where they do not. The fit
|
|
||||||
addon floors the row count, so whatever is left of the pane below the last whole row is viewport with
|
|
||||||
nothing drawn on it: a full-width black bar under the terminal, up to one line tall, against this
|
|
||||||
page's own #171a26. On Windows it is also where the classic scrollbar's bottom corner lands, which is
|
|
||||||
the light square at its right-hand end.
|
|
||||||
|
|
||||||
Repainting it in the page's own background is the whole fix. The remainder is still there — it is the
|
|
||||||
cost of a grid that has to divide evenly — but it now reads as the terminal's own margin rather than
|
|
||||||
as a strip of chrome that belongs to something else.
|
|
||||||
*/
|
|
||||||
.xterm .xterm-viewport {
|
|
||||||
background-color: var(--dodo-background);
|
|
||||||
|
|
||||||
/*
|
|
||||||
And the scrollbar itself, which WebView2 draws in the classic Windows style: a 15-pixel light-grey
|
|
||||||
channel with arrow buttons, down the right of a near-black terminal. Thin and in this page's own
|
|
||||||
colours instead — kept rather than hidden, because the scrollback is real and a surface that scrolls
|
|
||||||
with no sign that it does is worse than a quiet bar saying where you are.
|
|
||||||
|
|
||||||
Both spellings. scrollbar-width/-color is the standard one and is what current WebView2 and WebKitGTK
|
|
||||||
honour; ::-webkit-scrollbar is what older Chromium builds and WKWebView answer to. Neither is
|
|
||||||
load-bearing on its own and the two do not conflict — whichever the host understands wins.
|
|
||||||
*/
|
|
||||||
scrollbar-width: thin;
|
|
||||||
scrollbar-color: color-mix(in srgb, var(--dodo-muted) 45%, transparent) transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
|
||||||
width: 9px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-viewport::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
|
||||||
background: color-mix(in srgb, var(--dodo-muted) 45%, transparent);
|
|
||||||
border-radius: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#status {
|
#status {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|||||||
@@ -84,57 +84,14 @@ const RELEASE_FOCUS_MESSAGE = 'dodossh.release-focus';
|
|||||||
const root = document.getElementById('root');
|
const root = document.getElementById('root');
|
||||||
const statusBanner = document.getElementById('status');
|
const statusBanner = document.getElementById('status');
|
||||||
|
|
||||||
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement, notice: string}>} */
|
/** @type {Map<number, {term: object, fit: object, pane: HTMLElement}>} */
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
|
||||||
/** @type {WebSocket | null} */
|
/** @type {WebSocket | null} */
|
||||||
let socket = null;
|
let socket = null;
|
||||||
|
|
||||||
/** Whose pane is showing, or null before there is one — see activate(). */
|
function setStatus(text) {
|
||||||
let activeSessionId = null;
|
statusBanner.textContent = text ?? '';
|
||||||
|
|
||||||
/*
|
|
||||||
── THE BANNER BELONGS TO ONE PANE AT A TIME ─────────────────────────────────────────────────────────
|
|
||||||
There is one #status element for the whole page, because there is one page for every terminal: the
|
|
||||||
panes are stacked in the same box and all but the active one are hidden. What goes in it comes from
|
|
||||||
two sources that are not the same size, and the difference is the whole of this.
|
|
||||||
|
|
||||||
The socket's troubles are the page's. There is a single socket behind every pane, so "the view is
|
|
||||||
reconnecting" is true of whatever is on screen and true of the panes behind it.
|
|
||||||
|
|
||||||
A session's last words are not. "The remote closed the session." is a fact about one terminal and says
|
|
||||||
nothing whatever about the others — so it is held on the session and drawn only while that session's
|
|
||||||
pane is the one showing. Written straight into the shared element, which is what this used to do, it
|
|
||||||
outlived the tab it described: switching to a live terminal left the dead one's epitaph sitting under
|
|
||||||
it, and opening or closing any other tab wiped the message whether or not it belonged to that tab.
|
|
||||||
|
|
||||||
The socket's half wins when both have something to say: a page whose socket is down is not showing
|
|
||||||
live output on any pane, which makes what became of one session the less urgent of the two.
|
|
||||||
*/
|
|
||||||
let transportStatus = statusBanner.textContent ?? '';
|
|
||||||
|
|
||||||
function renderStatus() {
|
|
||||||
const notice = activeSessionId === null ? '' : sessions.get(activeSessionId)?.notice ?? '';
|
|
||||||
|
|
||||||
statusBanner.textContent = transportStatus || notice;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Says something about the socket, which every pane shares. */
|
|
||||||
function setTransportStatus(text) {
|
|
||||||
transportStatus = text ?? '';
|
|
||||||
renderStatus();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Records what became of one session, to be drawn only while that session's pane is showing. */
|
|
||||||
function setSessionNotice(sessionId, text) {
|
|
||||||
const session = sessions.get(sessionId);
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
session.notice = text ?? '';
|
|
||||||
renderStatus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Builds a frame: opcode, big-endian session id, then payload. */
|
/** Builds a frame: opcode, big-endian session id, then payload. */
|
||||||
@@ -298,31 +255,8 @@ function createSession(sessionId) {
|
|||||||
// WebGL where it is available. Falling back rather than failing matters because a software
|
// WebGL where it is available. Falling back rather than failing matters because a software
|
||||||
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
// renderer is slow but usable, whereas a blank pane is not — and remote desktops and VMs
|
||||||
// routinely have no usable GPU context.
|
// routinely have no usable GPU context.
|
||||||
//
|
|
||||||
// ◆ THE CONTEXT-LOSS HANDLER IS THE HALF THAT WAS MISSING, AND ON A PHONE IT IS THE WHOLE THING.
|
|
||||||
//
|
|
||||||
// The addon does not recover from a lost GPU context by itself, and it does not fail loudly either:
|
|
||||||
// it stays loaded over a dead context and draws nothing at all. What that looks like from outside is
|
|
||||||
// a terminal that is connected, still accepting keystrokes, still acknowledging output — and blank.
|
|
||||||
// xterm's own guidance is to dispose the addon and let the DOM renderer take over, which is what this
|
|
||||||
// does; the addon is not reloaded afterwards, because a pane that lost the context once is on a
|
|
||||||
// surface that will do it again and thrashing between renderers is worse than being slow.
|
|
||||||
//
|
|
||||||
// Losing it is ordinary on Android and nearly unheard of on Windows, which is why this went unnoticed
|
|
||||||
// for so long. Collapsing the renderer sets the native view to GONE — see
|
|
||||||
// AndroidNativeControlHostImpl.HideWithSize — and a WebView with no surface has no GL context. The
|
|
||||||
// shell collapses it every time a tab starts connecting, every time the connect sheet opens and every
|
|
||||||
// time the app is backgrounded, so on a phone the first loss arrives within seconds of the first
|
|
||||||
// session. WebView2 hides a child HWND instead and keeps rendering throughout; see
|
|
||||||
// docs/platform-flags.md.
|
|
||||||
try {
|
try {
|
||||||
const webgl = new WebglAddon.WebglAddon();
|
term.loadAddon(new WebglAddon.WebglAddon());
|
||||||
|
|
||||||
// Subscribed before loadAddon, because loadAddon is what activates the addon and a context that is
|
|
||||||
// already gone can be reported from inside that call.
|
|
||||||
webgl.onContextLoss(() => webgl.dispose());
|
|
||||||
|
|
||||||
term.loadAddon(webgl);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
console.warn('WebGL renderer unavailable; falling back to canvas.', error);
|
||||||
}
|
}
|
||||||
@@ -335,7 +269,7 @@ function createSession(sessionId) {
|
|||||||
|
|
||||||
term.onResize(() => sendResize(sessionId, term, pane));
|
term.onResize(() => sendResize(sessionId, term, pane));
|
||||||
|
|
||||||
const session = { term, fit, pane, notice: '' };
|
const session = { term, fit, pane };
|
||||||
sessions.set(sessionId, session);
|
sessions.set(sessionId, session);
|
||||||
|
|
||||||
activate(sessionId);
|
activate(sessionId);
|
||||||
@@ -349,11 +283,6 @@ function activate(sessionId) {
|
|||||||
session.pane.dataset.active = String(id === sessionId);
|
session.pane.dataset.active = String(id === sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// The banner follows the pane. Whatever this session has to say for itself replaces whatever the
|
|
||||||
// session that was showing had to say for its own, which is the point of holding it per session.
|
|
||||||
activeSessionId = sessionId;
|
|
||||||
renderStatus();
|
|
||||||
|
|
||||||
const active = sessions.get(sessionId);
|
const active = sessions.get(sessionId);
|
||||||
if (active) {
|
if (active) {
|
||||||
active.term.focus();
|
active.term.focus();
|
||||||
@@ -367,17 +296,10 @@ function activate(sessionId) {
|
|||||||
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
// caller, because more than one path reaches here: a minimised window, and a splitter dragged to the edge
|
||||||
// once splits land.
|
// once splits land.
|
||||||
//
|
//
|
||||||
// It is *not* what protects the vault's lock screen on the desktop, which an earlier version of this
|
// It is *not* what protects the vault's lock screen, which an earlier version of this comment claimed.
|
||||||
// comment claimed. Collapsing WebView2 hides a native child window without resizing it, so this page's
|
// Collapsing the host's WebView hides a native child window without resizing it, so this page's viewport
|
||||||
// viewport does not change, no observer fires and this function is never called — measured with a live
|
// does not change, no observer fires and this function is never called — measured with a live shell, and
|
||||||
// shell, and confirmed by removing the guard and finding the lock cycle equally clean. See
|
// confirmed by removing the guard and finding the lock cycle equally clean. See docs/platform-flags.md.
|
||||||
// docs/platform-flags.md.
|
|
||||||
//
|
|
||||||
// On the phone it *is* load-bearing, and that is the one place the two heads differ here. Android hides a
|
|
||||||
// native child by setting it GONE, and a GONE view is skipped by its parent's layout — so collapsing the
|
|
||||||
// renderer really does take this page's viewport to nothing, the observer really does fire, and without
|
|
||||||
// the guard every lock, every connect sheet and every trip to the background would reflow the remote pty
|
|
||||||
// to 2x1 and mangle the scrollback it wrapped.
|
|
||||||
const MINIMUM_FITTABLE_PIXELS = 40;
|
const MINIMUM_FITTABLE_PIXELS = 40;
|
||||||
|
|
||||||
function resize(session, sessionId) {
|
function resize(session, sessionId) {
|
||||||
@@ -421,10 +343,7 @@ function handleFrame(buffer) {
|
|||||||
session.term.write(REPLAY_BANNER);
|
session.term.write(REPLAY_BANNER);
|
||||||
}
|
}
|
||||||
|
|
||||||
// This session's own line, and only this one's: a session that is open has nothing to say about
|
setStatus('');
|
||||||
// how it ended. The page's own "Connecting…" is cleared by the socket opening, which happens
|
|
||||||
// before any frame can arrive.
|
|
||||||
setSessionNotice(sessionId, '');
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,14 +396,7 @@ function handleFrame(buffer) {
|
|||||||
session.pane.remove();
|
session.pane.remove();
|
||||||
sessions.delete(sessionId);
|
sessions.delete(sessionId);
|
||||||
|
|
||||||
// The notice went with the session record it was held on, but the page can still be pointing at
|
setStatus('');
|
||||||
// the pane that is now gone. Cleared rather than left dangling, so the banner stops describing a
|
|
||||||
// closed tab while the host decides which pane to show next.
|
|
||||||
if (activeSessionId === sessionId) {
|
|
||||||
activeSessionId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderStatus();
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -505,23 +417,8 @@ function handleFrame(buffer) {
|
|||||||
is something only this page sees — and a shell that receives a multi-line command inside those
|
is something only this page sees — and a shell that receives a multi-line command inside those
|
||||||
markers treats every newline as text. Without them it treats each one as "run this", so a
|
markers treats every newline as text. Without them it treats each one as "run this", so a
|
||||||
three-line snippet runs three commands the moment it is inserted.
|
three-line snippet runs three commands the moment it is inserted.
|
||||||
|
|
||||||
◆ ONE LINE IS TYPED INSTEAD, and this is not an optimisation. Bracketed paste is what readline
|
|
||||||
uses to decide it has been pasted into, and bash marks the result as an active region: the
|
|
||||||
inserted command sits at the prompt in reverse video, looking selected, until the next
|
|
||||||
keystroke clears it. That is right for a paste somebody made with the clipboard and wrong for a
|
|
||||||
snippet they picked off the sidebar, which should read as though they had typed it.
|
|
||||||
|
|
||||||
The markers are only load-bearing for text carrying a newline — that is the whole of what the
|
|
||||||
paragraph above protects against — so a single-line snippet does not need them and is written
|
|
||||||
as keystrokes. Multi-line still pastes, highlight and all, because "runs three commands
|
|
||||||
unasked" is the worse of the two.
|
|
||||||
*/
|
*/
|
||||||
if (text.includes('\n') || text.includes('\r')) {
|
|
||||||
session.term.paste(text);
|
session.term.paste(text);
|
||||||
} else {
|
|
||||||
session.term.input(text);
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
And the Enter goes through input(), deliberately outside that wrapper. A '\r' appended to the
|
||||||
@@ -533,15 +430,6 @@ function handleFrame(buffer) {
|
|||||||
session.term.input('\r');
|
session.term.input('\r');
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
The caret goes back where the text landed. Half of it, anyway: this reaches
|
|
||||||
document.activeElement and nothing further, so it is what makes the pane the page's own focused
|
|
||||||
element and what stops a hidden textarea from keeping the caret. The other half is Win32
|
|
||||||
focus — the sidebar row that sent this frame took it — and only the host can give that back;
|
|
||||||
see MainWindowViewModel.TerminalFocusRequested and MainWindow's own FocusTerminalWhenLaidOut.
|
|
||||||
*/
|
|
||||||
session.term.focus();
|
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,19 +449,14 @@ function handleFrame(buffer) {
|
|||||||
const session = sessions.get(sessionId);
|
const session = sessions.get(sessionId);
|
||||||
const reason = new TextDecoder().decode(payload);
|
const reason = new TextDecoder().decode(payload);
|
||||||
|
|
||||||
if (!session) {
|
if (session) {
|
||||||
// No pane, so there is nothing this page can honestly hang the reason on. It used to go into
|
|
||||||
// the banner anyway, which printed one session's ending underneath whichever pane happened to
|
|
||||||
// be showing at the time.
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The pane and its scrollback stay. The user was probably reading the last thing the
|
// The pane and its scrollback stay. The user was probably reading the last thing the
|
||||||
// remote said, and that is usually why the session ended.
|
// remote said, and that is usually why the session ended.
|
||||||
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
session.term.write(`\r\n\x1b[38;5;244m── ${reason} ──\x1b[0m\r\n`);
|
||||||
session.term.options.cursorBlink = false;
|
session.term.options.cursorBlink = false;
|
||||||
|
}
|
||||||
|
|
||||||
setSessionNotice(sessionId, reason);
|
setStatus(reason);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,7 +481,7 @@ function scheduleReconnect() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setTransportStatus('Reconnecting the terminal view…');
|
setStatus('Reconnecting the terminal view…');
|
||||||
|
|
||||||
reconnectTimer = setTimeout(() => {
|
reconnectTimer = setTimeout(() => {
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
@@ -618,7 +501,7 @@ function connect() {
|
|||||||
socket.binaryType = 'arraybuffer';
|
socket.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
socket.addEventListener('open', () => {
|
socket.addEventListener('open', () => {
|
||||||
setTransportStatus('');
|
setStatus('');
|
||||||
|
|
||||||
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
// Back to the quick attempt for whatever the next failure turns out to be. Kept slow between
|
||||||
// attempts within one outage, reset once the outage is actually over.
|
// attempts within one outage, reset once the outage is actually over.
|
||||||
|
|||||||
@@ -121,53 +121,12 @@ public interface ISshConnection : IAsyncDisposable
|
|||||||
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
Task<ISshShellSession> OpenShellAsync(TerminalSize size, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How far a connection being made has got.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// These are the boundaries a client can actually observe, and there are deliberately no others. SSH.NET
|
|
||||||
/// runs the whole handshake inside one <c>ConnectAsync</c> and raises exactly one event from the middle of
|
|
||||||
/// it — <c>HostKeyReceived</c>, once the key exchange has produced a key to show. That event is the only
|
|
||||||
/// interior moment there is, so it is the only interior phase named here: everything before it is
|
|
||||||
/// <see cref="Reaching"/> and everything after it is <see cref="Authenticating"/>.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>Nothing here is a guess about elapsed time or a fraction of the way through.</b> Each value is
|
|
||||||
/// reported at the instant the thing it names actually starts, which is what makes it safe for a screen to
|
|
||||||
/// draw as fact. A phase that took no measurable time is reported anyway and simply passes at once — that
|
|
||||||
/// is a true account of a fast handshake, not a step that was skipped. See the transfer strip's own remark
|
|
||||||
/// in TransfersScreen.axaml for why this design does not invent furniture for states it cannot measure.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
public enum SshConnectionPhase
|
|
||||||
{
|
|
||||||
/// <summary>Resolving the name, opening the socket, and exchanging keys. Before any key is known.</summary>
|
|
||||||
Reaching = 0,
|
|
||||||
|
|
||||||
/// <summary>The server has offered a host key, and its trust is being decided.</summary>
|
|
||||||
CheckingHostKey = 1,
|
|
||||||
|
|
||||||
/// <summary>The key was accepted. The credential is being offered.</summary>
|
|
||||||
Authenticating = 2,
|
|
||||||
|
|
||||||
/// <summary>Authenticated. A pseudo-terminal and a shell channel are being opened.</summary>
|
|
||||||
OpeningShell = 3,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Opens connections, enforcing host key trust before authenticating.</summary>
|
/// <summary>Opens connections, enforcing host key trust before authenticating.</summary>
|
||||||
public interface ISshConnectionFactory
|
public interface ISshConnectionFactory
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Connects and authenticates.
|
/// Connects and authenticates.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">What to connect to, as whom, and with what.</param>
|
|
||||||
/// <param name="progress">
|
|
||||||
/// Told each phase as it begins, or null to report nothing. Called from whichever thread the handshake
|
|
||||||
/// is on — SSH.NET raises host key verification on its own — so an implementation that touches a UI must
|
|
||||||
/// marshal for itself.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="cancellationToken">Abandons the attempt.</param>
|
|
||||||
/// <exception cref="SshHostKeyUnknownException">
|
/// <exception cref="SshHostKeyUnknownException">
|
||||||
/// The host has no pinned key. The caller must show the fingerprint, and only on explicit
|
/// The host has no pinned key. The caller must show the fingerprint, and only on explicit
|
||||||
/// confirmation record it via <see cref="IKnownHostStore.TrustAsync"/> and retry.
|
/// confirmation record it via <see cref="IKnownHostStore.TrustAsync"/> and retry.
|
||||||
@@ -175,8 +134,5 @@ public interface ISshConnectionFactory
|
|||||||
/// <exception cref="SshHostKeyMismatchException">
|
/// <exception cref="SshHostKeyMismatchException">
|
||||||
/// The presented key differs from the pin. There is no retry path: this is a hard block.
|
/// The presented key differs from the pin. There is no retry path: this is a hard block.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
Task<ISshConnection> ConnectAsync(
|
Task<ISshConnection> ConnectAsync(SshConnectionRequest request, CancellationToken cancellationToken);
|
||||||
SshConnectionRequest request,
|
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,14 +42,13 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
var client = new SshClient(BuildConnectionInfo(request));
|
var client = new SshClient(BuildConnectionInfo(request));
|
||||||
|
|
||||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress, cancellationToken)
|
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
return new SshNetConnection(client, gate.Presented!);
|
return new SshNetConnection(client, gate.Presented!);
|
||||||
@@ -69,10 +68,7 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
|
|
||||||
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
var client = new SftpClient(BuildConnectionInfo(request)) { BufferSize = SftpBufferSize };
|
||||||
|
|
||||||
// No progress for the file-transfer path. The screen that waits on one is the file browser, which
|
var gate = await ConnectThroughHostKeyGateAsync(client, request, cancellationToken)
|
||||||
// reports itself, and a second connection opened behind an already-open shell has nothing the user
|
|
||||||
// is watching a step list for.
|
|
||||||
var gate = await ConnectThroughHostKeyGateAsync(client, request, progress: null, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
// Read once, here, rather than per call. SftpClient.WorkingDirectory canonicalises against the server
|
||||||
@@ -105,18 +101,12 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
private async Task<HostKeyGate> ConnectThroughHostKeyGateAsync(
|
||||||
BaseClient client,
|
BaseClient client,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var gate = new HostKeyGate(knownHosts, request, progress, cancellationToken);
|
var gate = new HostKeyGate(knownHosts, request, cancellationToken);
|
||||||
|
|
||||||
client.HostKeyReceived += gate.OnHostKeyReceived;
|
client.HostKeyReceived += gate.OnHostKeyReceived;
|
||||||
|
|
||||||
// Before the await rather than inside the gate, because this phase is the part of the handshake
|
|
||||||
// that happens before there is anything to raise an event about: the lookup, the socket and the key
|
|
||||||
// exchange. Nothing else can report the start of it.
|
|
||||||
progress?.Report(SshConnectionPhase.Reaching);
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -149,7 +139,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
private sealed class HostKeyGate(
|
private sealed class HostKeyGate(
|
||||||
IKnownHostStore knownHosts,
|
IKnownHostStore knownHosts,
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
/// <summary>What the server offered, once the handshake has reached that point.</summary>
|
/// <summary>What the server offered, once the handshake has reached that point.</summary>
|
||||||
@@ -168,11 +157,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
|
|
||||||
Presented = presentation;
|
Presented = presentation;
|
||||||
|
|
||||||
// Reported before the lookup rather than after it, because the lookup is the wait: this is a
|
|
||||||
// vault-backed store on the handshake thread, and on a locked or cold vault it is the part of
|
|
||||||
// "checking the host key" long enough to be worth naming.
|
|
||||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
|
||||||
|
|
||||||
// Looked up here rather than before connecting, because the negotiated algorithm is only
|
// Looked up here rather than before connecting, because the negotiated algorithm is only
|
||||||
// known now and a server may choose a different one than it did last time.
|
// known now and a server may choose a different one than it did last time.
|
||||||
//
|
//
|
||||||
@@ -195,15 +179,6 @@ public sealed class SshNetConnectionFactory(IKnownHostStore knownHosts)
|
|||||||
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
var matches = SshHostKeyFingerprint.Equal(pinned, presentation.Fingerprint);
|
||||||
mismatch = !matches;
|
mismatch = !matches;
|
||||||
e.CanTrust = matches;
|
e.CanTrust = matches;
|
||||||
|
|
||||||
// Only on acceptance, and here rather than after the await above, because this is the last
|
|
||||||
// moment SSH.NET gives anyone: returning true from this handler is what lets the handshake go on
|
|
||||||
// to offer the credential, and it does not come back until it has an answer either way. A
|
|
||||||
// refusal reports nothing — there is no authentication about to happen for it to be true of.
|
|
||||||
if (matches)
|
|
||||||
{
|
|
||||||
progress?.Report(SshConnectionPhase.Authenticating);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
|
/// <summary>The specific exception for a refusal this gate caused, or null if it did not.</summary>
|
||||||
|
|||||||
@@ -369,31 +369,13 @@ public sealed class TerminalWorkspace : IAsyncDisposable
|
|||||||
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
dataPlane.RendererAttached.WaitAsync(options.RendererTimeout, cancellationToken);
|
||||||
|
|
||||||
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
/// <summary>Connects to a host and starts a terminal for it.</summary>
|
||||||
/// <param name="request">What to connect to, as whom, and with what.</param>
|
|
||||||
/// <param name="size">The pseudo-terminal's initial size.</param>
|
|
||||||
/// <param name="progress">
|
|
||||||
/// Told each phase as it begins, or null to report nothing. Reported from the handshake's own thread;
|
|
||||||
/// see <see cref="SshConnectionPhase"/>. Optional because a session opened by anything other than the
|
|
||||||
/// connecting card has nobody watching a step list for it, which is every caller but one.
|
|
||||||
/// </param>
|
|
||||||
/// <param name="cancellationToken">Abandons the attempt.</param>
|
|
||||||
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
|
/// <returns>The session id, which identifies this terminal in the renderer.</returns>
|
||||||
/// <remarks>
|
|
||||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is reported here rather than by the factory because
|
|
||||||
/// this is where it happens: the factory's work ends with an authenticated connection, and asking for a
|
|
||||||
/// pseudo-terminal on it is a separate round trip this method makes.
|
|
||||||
/// </remarks>
|
|
||||||
public async Task<uint> OpenSessionAsync(
|
public async Task<uint> OpenSessionAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
TerminalSize size,
|
TerminalSize size,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var connection = await connections
|
var connection = await connections.ConnectAsync(request, cancellationToken).ConfigureAwait(false);
|
||||||
.ConnectAsync(request, progress, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
progress?.Report(SshConnectionPhase.OpeningShell);
|
|
||||||
|
|
||||||
ISshShellSession shell;
|
ISshShellSession shell;
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -122,14 +122,8 @@ internal static class LayoutHarness
|
|||||||
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
/// <summary>The v5b session shell's own right-hand sidebar, from <c>SessionSidebar.axaml</c>.</summary>
|
||||||
internal const double SessionSidebarWidth = 300;
|
internal const double SessionSidebarWidth = 300;
|
||||||
|
|
||||||
/*
|
/// <summary>The v5b session shell's own host header, from <c>SessionHeader.axaml</c>.</summary>
|
||||||
A third session-shell constant stood here through wave B and C: SessionHeaderHeight, 60 pixels, for
|
internal const double SessionHeaderHeight = 60;
|
||||||
the host header that sat above the pane on both surfaces. v5c-4 retires that row — its address and
|
|
||||||
its cross-surface button both live in the sidebar now; see SessionSidebar.axaml — so the pane between
|
|
||||||
the tab row and the status bar is 60 pixels taller and this budget no longer subtracts anything for
|
|
||||||
it. The same treatment the retired window-wide tab strip got above, and for the same reason: a
|
|
||||||
constant for chrome that is not drawn is a budget that quietly under-measures every screen.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
/// <summary>The v5b session shell's own status bar, from <c>SessionStatusBar.axaml</c>.</summary>
|
||||||
internal const double SessionStatusBarHeight = 37;
|
internal const double SessionStatusBarHeight = 37;
|
||||||
@@ -152,12 +146,12 @@ internal static class LayoutHarness
|
|||||||
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
|
/// The arithmetic, top to bottom: <see cref="ScreenHeight"/> less <see cref="SessionShellPadding"/> on
|
||||||
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
|
/// both the top and the bottom of the outer padded column, less <see cref="SessionTabRowHeight"/> for the
|
||||||
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
|
/// tab row that sits above the bordered container, less <see cref="SessionShellBorderThickness"/> on both
|
||||||
/// the top and the bottom of that border, less <see cref="SessionStatusBarHeight"/> for the one fixed
|
/// the top and the bottom of that border, less <see cref="SessionHeaderHeight"/> and
|
||||||
/// strip left below the pane — v5c-4 retired the header above it; see the note where its constant was.
|
/// <see cref="SessionStatusBarHeight"/> for the two fixed strips the pane sits between.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
internal static double SessionScreenHeight =>
|
internal static double SessionScreenHeight =>
|
||||||
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
ScreenHeight - (2 * SessionShellPadding) - SessionTabRowHeight - (2 * SessionShellBorderThickness)
|
||||||
- SessionStatusBarHeight;
|
- SessionHeaderHeight - SessionStatusBarHeight;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
/// The width a session-shell screen gets, with or without <c>SessionSidebar</c>'s own QUICK ACCESS
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Controls.Presenters;
|
|
||||||
using Avalonia.Controls.Primitives;
|
using Avalonia.Controls.Primitives;
|
||||||
using Avalonia.Headless;
|
using Avalonia.Headless;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
using Avalonia.Media;
|
|
||||||
using Avalonia.VisualTree;
|
using Avalonia.VisualTree;
|
||||||
using DodoSSH.Client.App.Views;
|
using DodoSSH.Client.App.Views;
|
||||||
using DodoSSH.Client.Session;
|
using DodoSSH.Client.Session;
|
||||||
@@ -274,49 +272,6 @@ public sealed class NavRailTests : IAsyncLifetime
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Every row in the popover rests flat, and the pointer is what fills one.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>Button.poprow</c> set a radius and a padding and left the Background alone, so each row wore the
|
|
||||||
/// Fluent theme's own button fill: the account menu drew as six raised pills where the design draws six
|
|
||||||
/// lines of text. Read as a colour off the templated presenter rather than off the Button, because that
|
|
||||||
/// is where the theme puts its brush and therefore the only place the absence of one can be proven.
|
|
||||||
///
|
|
||||||
/// The hover half is asserted too, and it is what stops "flat" being fixed by making the rows
|
|
||||||
/// permanently invisible to the pointer: a menu row that does not answer a pointer at all is a worse
|
|
||||||
/// answer than one that answers wrongly.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task PopoverRowsAreFlatUntilThePointerFindsThem()
|
|
||||||
{
|
|
||||||
await OnTheRailAsync((rail, window) =>
|
|
||||||
{
|
|
||||||
Click(UserChip(rail), window);
|
|
||||||
|
|
||||||
var row = PopoverRow(window, "Settings");
|
|
||||||
var presenter = row.GetVisualDescendants()
|
|
||||||
.OfType<ContentPresenter>()
|
|
||||||
.First(candidate => candidate.Name is "PART_ContentPresenter");
|
|
||||||
|
|
||||||
var resting = presenter.Background as ISolidColorBrush;
|
|
||||||
|
|
||||||
(resting is null || resting.Color.A == 0).ShouldBeTrue(
|
|
||||||
$"a popover row rests flat, and this one is filled with {resting?.Color}");
|
|
||||||
|
|
||||||
var centre = row.TranslatePoint(new Point(row.Bounds.Width / 2, row.Bounds.Height / 2), window)
|
|
||||||
?? throw new InvalidOperationException("the row is not in this window's tree");
|
|
||||||
|
|
||||||
window.MouseMove(centre);
|
|
||||||
LayoutHarness.Settle(window, LayoutHarness.NavRailWidth, LayoutHarness.ScreenHeight);
|
|
||||||
|
|
||||||
row.IsPointerOver.ShouldBeTrue("the pointer was moved onto it");
|
|
||||||
(presenter.Background as ISolidColorBrush).ShouldNotBeNull().Color.A.ShouldNotBe(
|
|
||||||
(byte)0,
|
|
||||||
"a row that does not change under the pointer is one nobody can tell is clickable");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Helpers ----
|
// ---- Helpers ----
|
||||||
|
|
||||||
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
private Task OnTheRailAsync(Action<NavRail, Window> body) =>
|
||||||
|
|||||||
@@ -189,25 +189,6 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The editor with its new-credential card showing, which the test above never draws: the card is
|
|
||||||
/// collapsed until somebody presses + NEW CREDENTIAL, so nothing else in this suite measures the three
|
|
||||||
/// boxes, the paragraph of hint text and the two buttons it adds inside the section that already holds
|
|
||||||
/// the authentication picker. A card that only appears on a click is exactly the shape that escapes a
|
|
||||||
/// harness driven by the default state.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheHostDrawerFitsWithTheNewCredentialFormOpen()
|
|
||||||
{
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.IsAddingEditorCredential.ShouldBeTrue("there is nothing to measure otherwise");
|
|
||||||
|
|
||||||
await MeasureDrawerAsync(faults => faults.ShouldBeEmpty());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
|
/// The other editor, and it is in this control for the first time: the desktop's group editor used to be
|
||||||
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
|
/// a bar across the foot of the hosts screen, where it competed with the grid for the same column. Its
|
||||||
@@ -1274,7 +1255,7 @@ public sealed class ScreenLayoutTests : IAsyncLifetime
|
|||||||
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
|
/// this screen — see <c>ShowsQuickAccessSidebar</c> — so this is the test that actually reaches the
|
||||||
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
|
/// 472-pixel budget <see cref="LayoutHarness.SessionScreenWidth"/> computes, 204 pixels a side. DISCONNECT
|
||||||
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
|
/// is what is left in the remote pane's own connected strip now; the account-at-host chip that used to
|
||||||
/// share the row with it moved out, because the session shell already prints the same address beside
|
/// share the row with it moved out, because <c>SessionHeader</c> already prints the same address above
|
||||||
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
|
/// this screen — see <c>TransfersScreen.axaml</c>'s own remark on the strip for why keeping both was the
|
||||||
/// thing squeezing DISCONNECT off the edge at this width.
|
/// thing squeezing DISCONNECT off the edge at this width.
|
||||||
/// </para>
|
/// </para>
|
||||||
|
|||||||
@@ -1,184 +0,0 @@
|
|||||||
using Avalonia;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Headless;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.VisualTree;
|
|
||||||
using DodoSSH.Client.App.Views;
|
|
||||||
using DodoSSH.Client.Session;
|
|
||||||
using DodoSSH.Client.Shell.ViewModels;
|
|
||||||
using DodoSSH.Client.Ssh;
|
|
||||||
using DodoSSH.Client.Storage;
|
|
||||||
using DodoSSH.Client.Terminal;
|
|
||||||
using NSubstitute;
|
|
||||||
|
|
||||||
namespace DodoSSH.Client.App.Layout.Tests;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The session shell's right-hand column: its two widths, and what a long address does to the row it shares.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Worth a suite of its own since v5c-4, which gave this control two things it did not have: a session block
|
|
||||||
/// at its head — the address, and the cross-surface button, both inherited from the 60-pixel header row that
|
|
||||||
/// pass retired — and a closed state. The first is exactly the shape this harness exists for, a fixed-width
|
|
||||||
/// column holding a string of unbounded length beside a button that must stay clickable; the second is a
|
|
||||||
/// width the rest of the window has to cope with, and <c>MainWindow.axaml</c> copes with it by asking this
|
|
||||||
/// control how wide it is rather than by knowing.
|
|
||||||
/// </remarks>
|
|
||||||
public sealed class SessionSidebarTests : IAsyncLifetime
|
|
||||||
{
|
|
||||||
/// <summary>The widths <c>SessionSidebar.axaml</c> declares for its two states.</summary>
|
|
||||||
private const double OpenWidth = 300;
|
|
||||||
|
|
||||||
/// <inheritdoc cref="OpenWidth" />
|
|
||||||
private const double RailWidth = 34;
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Long on purpose, and longer than the column is wide at this font: the address is the one string here
|
|
||||||
/// whose length nobody controls, and it shares its row with the button that closes the column.
|
|
||||||
/// </remarks>
|
|
||||||
private const string LongAddress = "a-very-long-deploy-account@db-primary.eu-west-1.internal.example:22022";
|
|
||||||
|
|
||||||
private string directory = null!;
|
|
||||||
private ClientCacheFactory caches = null!;
|
|
||||||
private TerminalWorkspace workspace = null!;
|
|
||||||
private MainWindowViewModel shell = null!;
|
|
||||||
|
|
||||||
private static CancellationToken Token => TestContext.Current.CancellationToken;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public ValueTask InitializeAsync()
|
|
||||||
{
|
|
||||||
// A profile of this test's own rather than ClientPaths.Default: closing the sidebar is written
|
|
||||||
// through to disk — see ClientSettings.SessionSidebarOpen — and a suite that used the default paths
|
|
||||||
// would be editing the preferences of whoever ran it.
|
|
||||||
directory = Path.Combine(Path.GetTempPath(), $"dodossh-sidebar-{Guid.CreateVersion7():N}");
|
|
||||||
caches = ClientCacheFactory.ForMemory($"session-sidebar-{Guid.CreateVersion7():N}");
|
|
||||||
|
|
||||||
workspace = new TerminalWorkspace(
|
|
||||||
new InMemoryTerminalAssetProvider(new Dictionary<string, TerminalAsset>(StringComparer.Ordinal)),
|
|
||||||
Substitute.For<ISshConnectionFactory>(),
|
|
||||||
TimeProvider.System);
|
|
||||||
|
|
||||||
shell = new MainWindowViewModel(
|
|
||||||
new ClientPaths(directory),
|
|
||||||
caches,
|
|
||||||
workspace,
|
|
||||||
new VaultKnownHostStore(),
|
|
||||||
Substitute.For<IDeviceKeyStore>(),
|
|
||||||
(_, _) => throw new NotSupportedException("nothing here signs in"),
|
|
||||||
TimeProvider.System,
|
|
||||||
Substitute.For<ISftpSessionFactory>())
|
|
||||||
{
|
|
||||||
State = ShellState.Unlocked,
|
|
||||||
};
|
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await shell.DisposeAsync();
|
|
||||||
await workspace.DisposeAsync();
|
|
||||||
caches.Dispose();
|
|
||||||
|
|
||||||
if (Directory.Exists(directory))
|
|
||||||
{
|
|
||||||
Directory.Delete(directory, recursive: true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The address trims and the close button stays where it is; that is the whole claim. Asserted through
|
|
||||||
/// <see cref="LayoutHarness.Unreachable"/> rather than by reading the address's own width, because what
|
|
||||||
/// matters is not how much of the string is shown — an ellipsis is an honest answer — but that nothing
|
|
||||||
/// beside it was pushed out of the column to make room.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheColumnIsThreeHundredWide_AndALongAddressPushesNothingOutOfIt()
|
|
||||||
{
|
|
||||||
await OnTheSidebarAsync((sidebar, window) =>
|
|
||||||
{
|
|
||||||
// DesiredSize rather than Bounds, and that distinction is the control's own: the width lives on
|
|
||||||
// the Border inside it, so what the surrounding "Auto" column is given — and what this asks for
|
|
||||||
// — is what the control asks for, not how wide a host window happened to stretch it.
|
|
||||||
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
||||||
|
|
||||||
shell.SessionAddress.ShouldBe(LongAddress, "the fixture selected a tab with one");
|
|
||||||
|
|
||||||
LayoutHarness.Unreachable(window).ShouldBeEmpty();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The cross-surface button that used to live in the header row. Read off the control rather than off the
|
|
||||||
/// view model, so a row bound to the wrong property — or to nothing, which a compiled binding would still
|
|
||||||
/// draw as an empty button — fails this.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheCrossSurfaceButtonNamesTheOtherSurface()
|
|
||||||
{
|
|
||||||
await OnTheSidebarAsync((sidebar, _) =>
|
|
||||||
{
|
|
||||||
var button = sidebar.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.First(candidate => candidate.Classes.Contains("headerghost"));
|
|
||||||
|
|
||||||
button.Content.ShouldBe("Open SFTP");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Closed, the control is still drawn and is still the thing the window asks for a width — see
|
|
||||||
/// <c>MainWindow.axaml</c>'s own "Auto" column. What it must not be is nothing: a rail with the way back
|
|
||||||
/// on it is the difference between a panel somebody closed and a panel somebody lost.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ClosingTheColumnLeavesTheRailThatBringsItBack()
|
|
||||||
{
|
|
||||||
await OnTheSidebarAsync((sidebar, window) =>
|
|
||||||
{
|
|
||||||
shell.IsSessionSidebarOpen = false;
|
|
||||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
||||||
|
|
||||||
sidebar.DesiredSize.Width.ShouldBe(RailWidth);
|
|
||||||
|
|
||||||
var grip = sidebar.GetVisualDescendants()
|
|
||||||
.OfType<Button>()
|
|
||||||
.Single(candidate => candidate.Classes.Contains("sidebargrip") && candidate.IsEffectivelyVisible);
|
|
||||||
|
|
||||||
var centre = grip.TranslatePoint(new Point(grip.Bounds.Width / 2, grip.Bounds.Height / 2), window)
|
|
||||||
?? throw new InvalidOperationException("the grip is not in this window's tree");
|
|
||||||
|
|
||||||
window.MouseDown(centre, MouseButton.Left);
|
|
||||||
window.MouseUp(centre, MouseButton.Left);
|
|
||||||
|
|
||||||
shell.IsSessionSidebarOpen.ShouldBeTrue("the rail's own button is what reopens the column");
|
|
||||||
|
|
||||||
LayoutHarness.Settle(window, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
||||||
sidebar.DesiredSize.Width.ShouldBe(OpenWidth);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Task OnTheSidebarAsync(Action<SessionSidebar, Window> body) =>
|
|
||||||
LayoutHarness.OnTheUiThreadAsync(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
shell.Tabs.Add(new TerminalTabViewModel(1, "db-primary", LongAddress));
|
|
||||||
shell.SelectTabCommand.Execute(shell.Tabs[0]);
|
|
||||||
|
|
||||||
var sidebar = new SessionSidebar { DataContext = shell, ShowsSnips = true };
|
|
||||||
var window = LayoutHarness.HostAtMinimumSize(
|
|
||||||
sidebar, LayoutHarness.MinimumWidth, LayoutHarness.MinimumHeight);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
body(sidebar, window);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Token);
|
|
||||||
}
|
|
||||||
@@ -101,59 +101,4 @@ public sealed class TitleBarTests : IAsyncLifetime
|
|||||||
},
|
},
|
||||||
Token);
|
Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The chip that says which chord opens the pill beside it. The design's own is 34 pixels wide because
|
|
||||||
/// the design's own label is ⌘K — two glyphs — and this build substitutes "CTRL K", which at 10.5 mono is
|
|
||||||
/// wider than that. It shipped clipped: the chip drew "CTRL" and half of the K, which reads as a rendering
|
|
||||||
/// glitch rather than as a keyboard shortcut.
|
|
||||||
///
|
|
||||||
/// Asserted as "the chip is at least as wide as its own text", not against a number. A pixel count would
|
|
||||||
/// have to be re-derived by hand every time the font, the size or the wording moved, and the thing that
|
|
||||||
/// actually matters is the relationship between the two.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheKeyboardChipIsWideEnoughForTheChordItNames()
|
|
||||||
{
|
|
||||||
await LayoutHarness.OnTheUiThreadAsync(
|
|
||||||
() =>
|
|
||||||
{
|
|
||||||
var bar = new TitleBar { DataContext = shell };
|
|
||||||
var window = LayoutHarness.HostAtMinimumSize(
|
|
||||||
bar, LayoutHarness.MinimumWidth, LayoutHarness.TitleBarHeight);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var label = bar.GetVisualDescendants()
|
|
||||||
.OfType<TextBlock>()
|
|
||||||
.Single(text => string.Equals(text.Text, "CTRL K", StringComparison.Ordinal));
|
|
||||||
|
|
||||||
var chip = label.GetVisualAncestors().OfType<Border>().First();
|
|
||||||
|
|
||||||
// Measured on a copy under an unbounded constraint, not read off the label in the tree.
|
|
||||||
// A TextBlock's own DesiredSize is already clipped to what it was given, so the laid-out
|
|
||||||
// one reports 34 inside a 34-pixel chip whether or not the text fits — which is exactly
|
|
||||||
// the state this test exists to fail on.
|
|
||||||
var natural = new TextBlock
|
|
||||||
{
|
|
||||||
Text = label.Text,
|
|
||||||
FontFamily = label.FontFamily,
|
|
||||||
FontSize = label.FontSize,
|
|
||||||
FontWeight = label.FontWeight,
|
|
||||||
};
|
|
||||||
|
|
||||||
natural.Measure(Size.Infinity);
|
|
||||||
|
|
||||||
natural.DesiredSize.Width.ShouldBeGreaterThan(0, "the chord is a real run of text");
|
|
||||||
chip.Bounds.Width.ShouldBeGreaterThanOrEqualTo(
|
|
||||||
natural.DesiredSize.Width,
|
|
||||||
"a chip narrower than its own label draws part of the chord and cuts the rest");
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
window.Close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
Token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,32 +40,18 @@ internal sealed class FakeSshConnectionFactory : ISshConnectionFactory, ISftpSes
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public async Task<ISshConnection> ConnectAsync(
|
public async Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
Requests.Add(request);
|
Requests.Add(request);
|
||||||
|
|
||||||
// Before the gate rather than after it, which is what makes this fake useful for the connecting
|
|
||||||
// card: a test that holds Gate open is a connection stuck partway through, and the step list has to
|
|
||||||
// show it stuck on a named step rather than on none.
|
|
||||||
progress?.Report(SshConnectionPhase.Reaching);
|
|
||||||
|
|
||||||
if (Gate is { } gate)
|
if (Gate is { } gate)
|
||||||
{
|
{
|
||||||
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Failure is { } failure)
|
return Failure is { } failure
|
||||||
{
|
? throw failure
|
||||||
throw failure;
|
: new FakeSshConnection(request);
|
||||||
}
|
|
||||||
|
|
||||||
// Only on the way to succeeding. A failure reported as having authenticated would let a test pass
|
|
||||||
// while the card showed a refused connection getting one step further than it did.
|
|
||||||
progress?.Report(SshConnectionPhase.CheckingHostKey);
|
|
||||||
progress?.Report(SshConnectionPhase.Authenticating);
|
|
||||||
|
|
||||||
return new FakeSshConnection(request);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -143,14 +143,7 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
{
|
{
|
||||||
clipboard.Add(text);
|
clipboard.Add(text);
|
||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
},
|
});
|
||||||
|
|
||||||
// Inline, because this suite has no window and therefore no dispatcher to drain — the same
|
|
||||||
// answer TransferQueueingTests reached, and for the reason its own remark gives: reaching
|
|
||||||
// Dispatcher.UIThread from a test means asserting on a queue owned by whichever class touched
|
|
||||||
// it first. Running the action where it was raised takes the thread out of the question, and
|
|
||||||
// every phase this suite reports is raised on the thread doing the asserting anyway.
|
|
||||||
post: action => action());
|
|
||||||
|
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
}
|
}
|
||||||
@@ -1261,6 +1254,68 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The status line is one line for the whole window, so a sentence about a session that has been closed is
|
||||||
|
/// a sentence the user reads over some other terminal.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_TakesItsOwnStatusLineWithIt()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
vault.Status.ShouldContain("Connected", Case.Insensitive);
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||||
|
|
||||||
|
vault.Status.ShouldBeEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// The other half, and the reason closing does not simply blank the bar: everything this application has
|
||||||
|
/// to say about a save, a sync or a refusal goes through the same line — see <c>StatusBar.axaml</c> — and
|
||||||
|
/// closing a terminal answers none of it.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_LeavesAStatusLineThatIsAboutSomethingElse()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
vault.Status = "The keychain could not be saved.";
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(shell.Tabs[0]);
|
||||||
|
|
||||||
|
vault.Status.ShouldBe("The keychain could not be saved.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// Two sessions to the same host, which is the case a label could not tell apart: the line belongs to the
|
||||||
|
/// attempt that wrote it, so closing the other one leaves it alone.
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task ClosingATab_LeavesTheLineAnotherTabWrote()
|
||||||
|
{
|
||||||
|
var vault = await ReadyToConnectAsync();
|
||||||
|
|
||||||
|
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
||||||
|
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
var first = shell.Tabs[0];
|
||||||
|
|
||||||
|
// The second connection's own line is what the bar holds now.
|
||||||
|
await vault.ConnectCommand.ExecuteAsync(null);
|
||||||
|
var reported = vault.Status;
|
||||||
|
|
||||||
|
await shell.CloseTabCommand.ExecuteAsync(first);
|
||||||
|
|
||||||
|
vault.Status.ShouldBe(reported);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Connecting, while it is still happening ----
|
// ---- Connecting, while it is still happening ----
|
||||||
//
|
//
|
||||||
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
// A handshake is a network round trip and no longer holds the vault while it runs, so there is a stretch
|
||||||
@@ -1310,119 +1365,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsConnectingShowing.ShouldBeFalse();
|
shell.IsConnectingShowing.ShouldBeFalse();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// What the card draws while the stretch above is going on. The tab used to carry one line of prose
|
|
||||||
/// fixed at the moment it was created, which made a handshake stuck on a key exchange look exactly like
|
|
||||||
/// one stuck on a dead socket — and made a connection that was progressing look exactly like one that
|
|
||||||
/// was not.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The gate is held open on the step the fake reports before it, so this asserts the state the card is
|
|
||||||
/// actually drawn in rather than one it passes through: one step behind, one step lit, three not
|
|
||||||
/// reached. Nothing here waits or polls, which is the other half of the claim — the report arrives on
|
|
||||||
/// the thread that raised it and the tab is up to date in the same turn.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task Connecting_LightsTheStepTheHandshakeHasActuallyReached()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
||||||
|
|
||||||
ssh.Gate = new TaskCompletionSource();
|
|
||||||
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
tab.Steps.Select(step => step.State).ShouldBe(
|
|
||||||
[
|
|
||||||
ConnectionStepState.Done,
|
|
||||||
ConnectionStepState.Running,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
],
|
|
||||||
"the renderer attached, the host is being reached, and nothing after that has happened");
|
|
||||||
|
|
||||||
tab.StepsDone.ShouldBe(1, "the track fills to what finished, and the running step is not half a step");
|
|
||||||
tab.Status.ShouldBe("Reaching the host");
|
|
||||||
|
|
||||||
ssh.Gate.SetResult();
|
|
||||||
await connecting;
|
|
||||||
|
|
||||||
tab.Steps.ShouldAllBe(step => step.IsDone, "a session that opened got through all of them");
|
|
||||||
tab.StepsDone.ShouldBe(tab.StepCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The half of the step list a progress bar could not do: where it stopped is kept, and the steps behind
|
|
||||||
/// it stay done. That is the difference between "that host is not there" and "that host is there and
|
|
||||||
/// would not have me", and it is the question the reason sentence alone often does not settle.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ARefusedConnection_KeepsTheStepItStoppedOn()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
||||||
|
|
||||||
ssh.Failure = new InvalidOperationException("No route to host.");
|
|
||||||
|
|
||||||
await vault.ConnectCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
tab.Steps.Select(step => step.State).ShouldBe(
|
|
||||||
[
|
|
||||||
ConnectionStepState.Done,
|
|
||||||
ConnectionStepState.Stopped,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
ConnectionStepState.Pending,
|
|
||||||
],
|
|
||||||
"it got as far as reaching the host and no further");
|
|
||||||
|
|
||||||
tab.Steps[1].Mark.ShouldBe("✕", "and says so without relying on the colour");
|
|
||||||
|
|
||||||
// The reason still goes where it always went. The list says how far, and this says what happened.
|
|
||||||
tab.Status.ShouldBe("No route to host.");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// A report that arrives for an attempt the shell has forgotten. Giving up on a connecting tab removes
|
|
||||||
/// it while the handshake is still running — see <c>CloseTabAsync</c> — so every phase reported after
|
|
||||||
/// that has no tab to land on. Dropped rather than resurrecting the tab, and above all not thrown: the
|
|
||||||
/// handshake is still going, and its session is still adopted if it opens.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task GivingUpOnATab_LeavesLaterPhasesWithNothingToDo()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
|
|
||||||
|
|
||||||
ssh.Gate = new TaskCompletionSource();
|
|
||||||
var connecting = vault.ConnectCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var tab = shell.Tabs.ShouldHaveSingleItem();
|
|
||||||
await shell.CloseTabCommand.ExecuteAsync(tab);
|
|
||||||
|
|
||||||
// Everything after the gate — the host key, the credential, the shell — is reported to a shell that
|
|
||||||
// no longer has a tab for this attempt.
|
|
||||||
ssh.Gate.SetResult();
|
|
||||||
await connecting;
|
|
||||||
|
|
||||||
// The session opened anyway and was adopted, which is the behaviour giving up already promised.
|
|
||||||
var adopted = shell.Tabs.ShouldHaveSingleItem();
|
|
||||||
adopted.HasSession.ShouldBeTrue();
|
|
||||||
adopted.ShouldNotBe(tab);
|
|
||||||
|
|
||||||
// And the forgotten tab was left where it was rather than being advanced from the sidelines.
|
|
||||||
tab.Steps[1].IsRunning.ShouldBeTrue("nothing moved it on after the shell let go of it");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
/// A refusal has to end up somewhere the user will see it, and by the time one arrives they are quite
|
||||||
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
/// likely looking at another screen — which is exactly what not blocking bought. The tab is that place,
|
||||||
@@ -2461,7 +2403,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||||
TerminalSize.Default,
|
TerminalSize.Default,
|
||||||
progress: null,
|
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(1);
|
workspace.LiveSessionCount.ShouldBe(1);
|
||||||
@@ -3101,161 +3042,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
|
vault.Hosts[0].Host.CredentialId.ShouldBeNull();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The moment a credential is wanted is the moment somebody is choosing how a host authenticates and
|
|
||||||
/// finds it is not in the keychain yet, so the host editor makes one. Selecting it has to survive the
|
|
||||||
/// reload the write triggers, which is the part that needs a test: the refill rebuilds the picker from
|
|
||||||
/// the vault and restores it from the editor's own selection, so the binding is written before the
|
|
||||||
/// reload rather than after it.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ACredentialMadeInTheHostEditor_BindsTheHostToIt()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.IsAddingEditorCredential.ShouldBeTrue();
|
|
||||||
|
|
||||||
vault.EditorNewCredentialLabel = "pg-primary";
|
|
||||||
vault.EditorNewCredentialUsername = "postgres";
|
|
||||||
vault.EditorNewCredentialPassword = "s3cret";
|
|
||||||
vault.EditorNewCredentialNotes = "rotated quarterly";
|
|
||||||
|
|
||||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
var credential = vault.Credentials.ShouldHaveSingleItem();
|
|
||||||
credential.Credential.Username.ShouldBe("postgres");
|
|
||||||
credential.Credential.Notes.ShouldBe("rotated quarterly");
|
|
||||||
|
|
||||||
vault.IsAddingEditorCredential.ShouldBeFalse("the form closes once the credential is in the keychain");
|
|
||||||
vault.EditorNewCredentialPassword.ShouldBeEmpty("the form must not go on holding the password");
|
|
||||||
|
|
||||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldBe(
|
|
||||||
credential.EntityId,
|
|
||||||
"the picker has to land on the credential that was just made, through the reload");
|
|
||||||
|
|
||||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBe(credential.EntityId);
|
|
||||||
vault.Hosts[0].Host.SshKeyId.ShouldBeNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The honest consequence of writing immediately, and the same one the new-tag box already carries: a
|
|
||||||
/// credential is a shared item with an id, the host can only name an id that exists, so the credential
|
|
||||||
/// was never part of the host to begin with. What was still being typed is a different matter — that
|
|
||||||
/// includes a password, and it goes with the editor it was typed into.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task CancellingTheHostEditor_KeepsTheCredentialItMade_AndDropsWhatWasStillBeingTyped()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.EditorNewCredentialLabel = "pg-primary";
|
|
||||||
vault.EditorNewCredentialPassword = "s3cret";
|
|
||||||
|
|
||||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
// A second one, opened and left half-typed.
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.EditorNewCredentialLabel = "half";
|
|
||||||
vault.EditorNewCredentialPassword = "typed-but-never-added";
|
|
||||||
vault.EditorNewCredentialNotes = "half a thought";
|
|
||||||
|
|
||||||
vault.CancelEditCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.Credentials.ShouldHaveSingleItem().Label.ShouldBe("pg-primary");
|
|
||||||
|
|
||||||
vault.IsAddingEditorCredential.ShouldBeFalse();
|
|
||||||
vault.EditorNewCredentialLabel.ShouldBeEmpty();
|
|
||||||
vault.EditorNewCredentialNotes.ShouldBeEmpty();
|
|
||||||
vault.EditorNewCredentialPassword.ShouldBeEmpty(
|
|
||||||
"a password typed into an abandoned form must not survive behind the next host");
|
|
||||||
|
|
||||||
vault.Hosts.ShouldHaveSingleItem().Host.CredentialId.ShouldBeNull(
|
|
||||||
"the binding itself was never saved");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Why this form has fields of its own rather than reusing the keychain screen's four.
|
|
||||||
/// <c>IsEditingCredential</c> is what <c>AVaultEditorIsInTheWay</c> asks about, so sharing it would make
|
|
||||||
/// the whole Vault screen refuse to open an editor, with a sentence naming a form the user cannot see
|
|
||||||
/// on a screen they are not looking at. That is the exact failure the guard was split in two to end.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheHostEditorsCredentialForm_DoesNotBlockTheKeychainsOwnEditors()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.NewCredentialCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.IsEditingCredential.ShouldBeTrue(
|
|
||||||
"the keychain's editor lives on another screen and opens regardless");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Where this deliberately parts from the new-tag box beside it, which offers an existing tag rather
|
|
||||||
/// than repeating it. Two tags called "staging" are one intention spelled twice; two credentials called
|
|
||||||
/// "root" are two different passwords, and quietly binding the host to whichever was there already
|
|
||||||
/// would authenticate it as an account nobody chose.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ACredentialMadeInTheHostEditor_UnderANameAlreadyTaken_IsASecondCredential()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
await AddCredentialAsync(vault, "root", password: "first");
|
|
||||||
|
|
||||||
var first = vault.Credentials.ShouldHaveSingleItem().EntityId;
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.EditorNewCredentialLabel = "root";
|
|
||||||
vault.EditorNewCredentialPassword = "second";
|
|
||||||
|
|
||||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.Credentials.Count.ShouldBe(2);
|
|
||||||
|
|
||||||
vault.EditorSelectedAuthentication.ShouldNotBeNull().EntityId.ShouldNotBe(
|
|
||||||
first,
|
|
||||||
"binding to the credential that happened to share the name would be the wrong password");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The same refusal <c>CredentialSecret.TryValidate</c> gives the keychain's editor, reaching the user
|
|
||||||
/// here rather than producing an item that looks usable and fails at the handshake.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ACredentialMadeInTheHostEditor_WithNoPassword_IsRefused()
|
|
||||||
{
|
|
||||||
var vault = await ReadyToConnectAsync();
|
|
||||||
|
|
||||||
vault.SelectedHost = vault.Hosts[0];
|
|
||||||
vault.EditSelectedHostCommand.Execute(null);
|
|
||||||
|
|
||||||
vault.BeginEditorCredentialCommand.Execute(null);
|
|
||||||
vault.EditorNewCredentialLabel = "pg-primary";
|
|
||||||
|
|
||||||
await vault.AddEditorCredentialCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
vault.Credentials.ShouldBeEmpty();
|
|
||||||
vault.IsAddingEditorCredential.ShouldBeTrue("the form stays open on what it refused");
|
|
||||||
vault.Status.ShouldContain("password");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
|
/// Tags reach the same editor by a different route — the keychain screen rather than the box under the
|
||||||
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
|
/// chips — and a chip that only appeared on the next open would send the user round the same detour.
|
||||||
@@ -6498,68 +6284,12 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
shell.IsTerminalShowing.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// v5c-4 retired the session shell's own header row and moved its cross-surface button into the sidebar,
|
|
||||||
/// where one control is drawn on both surfaces — so the two directions above are reached through one
|
|
||||||
/// command and one label, resolved by the shell. This is that resolution: the same two outcomes the two
|
|
||||||
/// tests above assert, from the row a user actually clicks now.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task TheSidebarsCrossSurfaceRow_NamesAndOpensWhicheverSurfaceIsNotShowing()
|
|
||||||
{
|
|
||||||
await ConnectedHostWithAPinAsync();
|
|
||||||
|
|
||||||
shell.IsTerminalSurface.ShouldBeTrue();
|
|
||||||
shell.SessionCrossSurfaceLabel.ShouldBe("Open SFTP");
|
|
||||||
|
|
||||||
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.IsTransfersShowing.ShouldBeTrue();
|
|
||||||
shell.Transfers.SelectedHost.ShouldNotBeNull().Label.ShouldBe("prod-db");
|
|
||||||
shell.SessionCrossSurfaceLabel.ShouldBe("Open terminal", "the row turns over with the surface");
|
|
||||||
|
|
||||||
var tabsBefore = shell.Tabs.Count;
|
|
||||||
|
|
||||||
await shell.OpenOtherSurfaceCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
shell.Tabs.Count.ShouldBe(tabsBefore + 1, "the other direction dials a terminal at the browsed host");
|
|
||||||
shell.IsTerminalShowing.ShouldBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Closing the sidebar is a preference about this machine, so it outlives the window — see
|
|
||||||
/// <c>ClientSettings.SessionSidebarOpen</c>. Asserted against the store rather than against a second
|
|
||||||
/// shell built over the same profile: what a fresh launch reads is exactly what is on disk, and building
|
|
||||||
/// another shell here would prove the constructor twice and the storage once.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task ClosingTheSidebar_IsRememberedForTheNextLaunch()
|
|
||||||
{
|
|
||||||
await ConnectedHostWithAPinAsync();
|
|
||||||
|
|
||||||
shell.IsSessionSidebarOpen.ShouldBeTrue("open is the default, and nothing has closed it");
|
|
||||||
|
|
||||||
shell.ToggleSessionSidebarCommand.Execute(null);
|
|
||||||
|
|
||||||
shell.IsSessionSidebarOpen.ShouldBeFalse();
|
|
||||||
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeFalse();
|
|
||||||
|
|
||||||
shell.ToggleSessionSidebarCommand.Execute(null);
|
|
||||||
|
|
||||||
shell.IsSessionSidebarOpen.ShouldBeTrue();
|
|
||||||
new ClientSettingsStore(paths).Read().SessionSidebarOpen.ShouldBeTrue("and reopening is remembered too");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
|
/// The sidebar's SNIPS row, wired through <c>SnippetsViewModel.InsertCommand</c> rather than a second
|
||||||
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
|
/// insert path — see the deviation recorded on <c>MainWindowViewModel.InsertSnippetCommand</c>. Proven
|
||||||
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
|
/// through a real connected tab and a real renderer, the same fixture <c>InsertingASnippet_...</c> above
|
||||||
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
|
/// uses for the standalone screen, because what is worth proving here is that the shell's command reaches
|
||||||
/// that same mechanism rather than reimplementing it.
|
/// that same mechanism rather than reimplementing it.
|
||||||
///
|
|
||||||
/// The focus request is asserted here rather than in a test of its own because it is part of what this
|
|
||||||
/// click does: the row that typed the command took the keyboard with it, and only the window can give it
|
|
||||||
/// back. See <c>MainWindowViewModel.TerminalFocusRequested</c>.
|
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
public async Task InsertingASnippetFromTheSidebarTypesItIntoTheSelectedTab()
|
||||||
@@ -6571,38 +6301,9 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
|
|
||||||
var row = snippets.Visible.ShouldHaveSingleItem();
|
var row = snippets.Visible.ShouldHaveSingleItem();
|
||||||
|
|
||||||
var focusRequests = 0;
|
|
||||||
shell.TerminalFocusRequested += (_, _) => focusRequests++;
|
|
||||||
|
|
||||||
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
||||||
|
|
||||||
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
snippets.Selected.ShouldBe(row, "the sidebar row picks the same selection INSERT reads");
|
||||||
focusRequests.ShouldBe(1, "the keyboard goes back to the terminal the command landed in");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The other half of the rule above: nothing was typed, so nothing asks for the keyboard. A snip clicked
|
|
||||||
/// with no terminal to put it in lands on the snippets screen instead — see
|
|
||||||
/// <c>MainWindowViewModel.InsertSnippetCommand</c> — and stealing focus into a collapsed WebView on the
|
|
||||||
/// way would leave that screen unable to be typed on.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task InsertingASnippetWithNothingToInsertInto_DoesNotAskForTheTerminal()
|
|
||||||
{
|
|
||||||
await UnlockedAsync();
|
|
||||||
|
|
||||||
var snippets = shell.SnippetsScreen.ShouldNotBeNull();
|
|
||||||
await AddSnippetAsync(snippets, "uptime", "uptime", runs: false);
|
|
||||||
|
|
||||||
var row = snippets.Visible.ShouldHaveSingleItem();
|
|
||||||
|
|
||||||
var focusRequests = 0;
|
|
||||||
shell.TerminalFocusRequested += (_, _) => focusRequests++;
|
|
||||||
|
|
||||||
await shell.InsertSnippetCommand.ExecuteAsync(row);
|
|
||||||
|
|
||||||
shell.Screen.ShouldBe(ShellScreen.Snippets);
|
|
||||||
focusRequests.ShouldBe(0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -8318,7 +8019,6 @@ public sealed class ShellFlowTests : IAsyncLifetime
|
|||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
new SshConnectionRequest("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")),
|
||||||
TerminalSize.Default,
|
TerminalSize.Default,
|
||||||
progress: null,
|
|
||||||
Token);
|
Token);
|
||||||
|
|
||||||
shell.SignOutCommand.Execute(null);
|
shell.SignOutCommand.Execute(null);
|
||||||
|
|||||||
@@ -73,8 +73,7 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
var factory = new SshNetConnectionFactory(knownHosts);
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
await Should.ThrowAsync<SshAuthenticationException>(async () =>
|
await Should.ThrowAsync<SshAuthenticationException>(async () =>
|
||||||
await factory.ConnectAsync(
|
await factory.ConnectAsync(Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), Token));
|
||||||
Request(new SshPrivateKeyCredential(Pkcs1(stranger), null)), progress: null, Token));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -126,86 +125,6 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
shell.IsOpen.ShouldBeTrue();
|
shell.IsOpen.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// The claim the connecting card is built on, checked where it can actually be checked: against a real
|
|
||||||
/// handshake rather than a fake that reports whatever it was written to report. Every other test of the
|
|
||||||
/// step list asserts that the shell draws what it is told; this one asserts that what it is told is
|
|
||||||
/// true.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The order is the assertion. A step list is only readable if the reports arrive in the order it draws
|
|
||||||
/// them, and the middle one is the load-bearing part — <see cref="SshConnectionPhase.CheckingHostKey"/>
|
|
||||||
/// comes out of SSH.NET's <c>HostKeyReceived</c>, which is the single interior moment the library gives
|
|
||||||
/// anybody, and it has to land between the other two rather than beside them.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is deliberately absent: this factory's work ends with
|
|
||||||
/// an authenticated connection, and the phase for opening a channel on one belongs to the layer that
|
|
||||||
/// opens it. <c>TerminalWorkspaceTests</c> covers that half.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AHandshake_ReportsItsPhasesInTheOrderTheyHappen()
|
|
||||||
{
|
|
||||||
var knownHosts = await TrustedStoreAsync();
|
|
||||||
var reported = new List<SshConnectionPhase>();
|
|
||||||
|
|
||||||
await using var connection = await new SshNetConnectionFactory(knownHosts).ConnectAsync(
|
|
||||||
Request(new SshPrivateKeyCredential(Pkcs1(fixture.ClientKey), Passphrase: null)),
|
|
||||||
new DelegateProgress<SshConnectionPhase>(phase =>
|
|
||||||
{
|
|
||||||
lock (reported)
|
|
||||||
{
|
|
||||||
// Locked because the last two are reported from SSH.NET's own handshake thread rather
|
|
||||||
// than from the awaiting one, which is the whole reason the shell marshals them.
|
|
||||||
reported.Add(phase);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Token);
|
|
||||||
|
|
||||||
connection.IsConnected.ShouldBeTrue();
|
|
||||||
|
|
||||||
lock (reported)
|
|
||||||
{
|
|
||||||
reported.ShouldBe(
|
|
||||||
[
|
|
||||||
SshConnectionPhase.Reaching,
|
|
||||||
SshConnectionPhase.CheckingHostKey,
|
|
||||||
SshConnectionPhase.Authenticating,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// The other half of the phase contract, and the one that would be easy to get wrong by reporting
|
|
||||||
/// optimistically: a refused key stops at the check. Nothing may claim the credential was offered, and
|
|
||||||
/// against an unknown host nothing ever is — the gate returns false and SSH.NET abandons the handshake
|
|
||||||
/// before authentication.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task AHostKeyRefusal_NeverClaimsToHaveAuthenticated()
|
|
||||||
{
|
|
||||||
var reported = new List<SshConnectionPhase>();
|
|
||||||
|
|
||||||
await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
|
||||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(
|
|
||||||
Request(new SshPasswordCredential(SshServerFixture.Password)),
|
|
||||||
new DelegateProgress<SshConnectionPhase>(phase =>
|
|
||||||
{
|
|
||||||
lock (reported)
|
|
||||||
{
|
|
||||||
reported.Add(phase);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Token));
|
|
||||||
|
|
||||||
lock (reported)
|
|
||||||
{
|
|
||||||
reported.ShouldBe([SshConnectionPhase.Reaching, SshConnectionPhase.CheckingHostKey]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
|
private static byte[] Pkcs1(RSA key) => Encoding.UTF8.GetBytes(key.ExportRSAPrivateKeyPem());
|
||||||
|
|
||||||
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
|
private static byte[] Pkcs8(RSA key) => Encoding.UTF8.GetBytes(key.ExportPkcs8PrivateKeyPem());
|
||||||
@@ -222,7 +141,7 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
// Learned by being refused, which is the only way this client learns a host key.
|
// Learned by being refused, which is the only way this client learns a host key.
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await factory.ConnectAsync(
|
await factory.ConnectAsync(
|
||||||
Request(new SshPasswordCredential(SshServerFixture.Password)), progress: null, Token));
|
Request(new SshPasswordCredential(SshServerFixture.Password)), Token));
|
||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
@@ -234,6 +153,6 @@ public sealed class KeyAuthenticationTests(SshServerFixture fixture)
|
|||||||
var knownHosts = await TrustedStoreAsync();
|
var knownHosts = await TrustedStoreAsync();
|
||||||
|
|
||||||
return await new SshNetConnectionFactory(knownHosts)
|
return await new SshNetConnectionFactory(knownHosts)
|
||||||
.ConnectAsync(Request(credential), progress: null, Token);
|
.ConnectAsync(Request(credential), Token);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,14 +137,3 @@ public sealed class KnownHostStoreTests
|
|||||||
string fingerprint = "SHA256:approved") =>
|
string fingerprint = "SHA256:approved") =>
|
||||||
new(host, port, algorithm, fingerprint);
|
new(host, port, algorithm, fingerprint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>System.Progress<T></c> posts to a captured synchronisation context, or to the thread pool when
|
|
||||||
/// there is none — which is what a test has here — so a list it appended to would be asserted on before it
|
|
||||||
/// had been written. The same reason the shell does not use it either; see <c>VaultViewModel.ReporterFor</c>.
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
|
|
||||||
{
|
|
||||||
public void Report(T value) => report(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
var factory = new SshNetConnectionFactory(knownHosts);
|
var factory = new SshNetConnectionFactory(knownHosts);
|
||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await factory.ConnectAsync(request, progress: null, Token));
|
await factory.ConnectAsync(request, Token));
|
||||||
|
|
||||||
unknown.Presentation.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
|
unknown.Presentation.Host.ShouldBe(InternalHost, "the target's name, not the proxy's");
|
||||||
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
|
unknown.Presentation.Port.ShouldBe(SshServerFixture.InternalPort);
|
||||||
@@ -75,7 +75,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
await using var connection = await factory.ConnectAsync(request, progress: null, Token);
|
await using var connection = await factory.ConnectAsync(request, Token);
|
||||||
|
|
||||||
connection.IsConnected.ShouldBeTrue();
|
connection.IsConnected.ShouldBeTrue();
|
||||||
connection.HostKey.Host.ShouldBe(InternalHost);
|
connection.HostKey.Host.ShouldBe(InternalHost);
|
||||||
@@ -121,8 +121,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
|
var request = Request(Credential(), new SshLoopbackProxy(DeadPort()));
|
||||||
|
|
||||||
var failure = await Should.ThrowAsync<Exception>(async () =>
|
var failure = await Should.ThrowAsync<Exception>(async () =>
|
||||||
await new SshNetConnectionFactory(new InMemoryKnownHostStore())
|
await new SshNetConnectionFactory(new InMemoryKnownHostStore()).ConnectAsync(request, Token));
|
||||||
.ConnectAsync(request, progress: null, Token));
|
|
||||||
|
|
||||||
failure.ShouldNotBeOfType<SshHostKeyUnknownException>();
|
failure.ShouldNotBeOfType<SshHostKeyUnknownException>();
|
||||||
failure.ShouldNotBeOfType<SshHostKeyMismatchException>();
|
failure.ShouldNotBeOfType<SshHostKeyMismatchException>();
|
||||||
@@ -138,7 +137,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
var knownHosts = await TrustedStoreAsync();
|
var knownHosts = await TrustedStoreAsync();
|
||||||
|
|
||||||
await using var connection = await new SshNetConnectionFactory(knownHosts)
|
await using var connection = await new SshNetConnectionFactory(knownHosts)
|
||||||
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token);
|
.ConnectAsync(Request(Credential(), proxy: null), Token);
|
||||||
|
|
||||||
connection.IsConnected.ShouldBeTrue();
|
connection.IsConnected.ShouldBeTrue();
|
||||||
}
|
}
|
||||||
@@ -217,7 +216,7 @@ public sealed class LoopbackProxyTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await new SshNetConnectionFactory(knownHosts)
|
await new SshNetConnectionFactory(knownHosts)
|
||||||
.ConnectAsync(Request(Credential(), proxy: null), progress: null, Token));
|
.ConnectAsync(Request(Credential(), proxy: null), Token));
|
||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
await knownHosts.TrustAsync(unknown.Presentation, Token);
|
||||||
|
|
||||||
|
|||||||
@@ -27,11 +27,11 @@ public sealed class PumpOverRealSshTests(SshServerFixture fixture)
|
|||||||
new SshPasswordCredential(SshServerFixture.Password));
|
new SshPasswordCredential(SshServerFixture.Password));
|
||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken));
|
await factory.ConnectAsync(request, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
await knownHosts.TrustAsync(unknown.Presentation, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
return await factory.ConnectAsync(request, progress: null, TestContext.Current.CancellationToken);
|
return await factory.ConnectAsync(request, TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Net.Sockets;
|
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
using System.Text;
|
|
||||||
using DotNet.Testcontainers.Builders;
|
using DotNet.Testcontainers.Builders;
|
||||||
using DotNet.Testcontainers.Containers;
|
using DotNet.Testcontainers.Containers;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -42,21 +40,6 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
|
|
||||||
private const int SshPort = 2222;
|
private const int SshPort = 2222;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How many connections in a row the server has to answer before this fixture calls it ready.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// Twenty-five, and the number is measured rather than picked. Probing a fresh container 200 times with
|
|
||||||
/// penalties left at the image's default, the first <c>Not allowed at this time</c> came back at probe
|
|
||||||
/// 18 and 183 of the 200 were refused; with <c>PerSourcePenalties no</c> applied, none of 200 were. Ten
|
|
||||||
/// was tried first and is useless — it sits below the threshold, so the guard passed happily against a
|
|
||||||
/// server that was still penalising. See <see cref="WaitUntilServingAsync"/>.
|
|
||||||
/// </remarks>
|
|
||||||
private const int RequiredStreak = 25;
|
|
||||||
|
|
||||||
/// <summary>How long to keep trying before giving up on the server entirely.</summary>
|
|
||||||
private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(60);
|
|
||||||
|
|
||||||
private readonly SemaphoreSlim sftpGate = new(1, 1);
|
private readonly SemaphoreSlim sftpGate = new(1, 1);
|
||||||
|
|
||||||
private IContainer? container;
|
private IContainer? container;
|
||||||
@@ -97,202 +80,52 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
await container.StartAsync();
|
await container.StartAsync();
|
||||||
await ReconfigureAsync();
|
await AllowTcpForwardingAsync();
|
||||||
await WaitUntilServingAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Turns off the hardening this suite trips over, and makes the running server re-read its config.
|
/// Lets this server open the direct-tcpip channels a forward is made of.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>PerSourcePenalties no</c> is the fix for the flake this suite had for months, and the other
|
/// ◆ <b>The image ships <c>AllowTcpForwarding no</c>, and nothing says so at the point it bites.</b> A
|
||||||
/// two settings here are not.</b> OpenSSH 9.8 added per-source penalties and 10.x has them on by
|
/// dynamic forward starts perfectly happily — it is a local listener, and opening it asks the server
|
||||||
/// default; this image runs 10.3. A source address that keeps disconnecting without authenticating is
|
/// nothing — and then every connection through it is refused when the channel is opened. SSH.NET
|
||||||
/// penalised, and while the penalty holds every connection from it is answered with the clear-text line
|
/// reports that as <c>SOCKS5: General failure</c> from the proxy, which names neither the server nor
|
||||||
/// <c>Not allowed at this time</c> and then closed.
|
/// the setting, and is what the first run of <c>LoopbackProxyTests</c> collected.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// <b>This suite generates exactly that traffic, by design.</b> This client's first contact with an
|
/// Patched after start rather than baked in, because the image's entrypoint writes its configuration
|
||||||
/// unknown host is a connection deliberately refused at the host key — which is a disconnect with no
|
/// itself on every boot — a mounted file would be overwritten before sshd read it. sshd re-reads on
|
||||||
/// authentication attempt — and several tests do nothing else:
|
/// <c>SIGHUP</c> and applies the result to connections made after that, and the readiness wait has
|
||||||
/// <c>RefusingTheHostKey_AbortsTheConnection</c>, <c>AnUntrustedHost_IsRefusedExactlyAsAShellWouldBe</c>,
|
/// already run, so nothing here races the boot.
|
||||||
/// and every helper that learns a host key by being turned away first. Enough of them close together and
|
|
||||||
/// sshd stops talking to the test host altogether, for a while, and then starts again.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// From the client that is <c>SshConnectionException: The connection was closed by the remote host</c>
|
|
||||||
/// within milliseconds — no banner, nothing to say which of the many reasons it was. It hits whichever
|
|
||||||
/// class is running when the penalty lands and spares the rest, which is why it read as random and why
|
|
||||||
/// the class it hit lost <em>every</em> connection it made rather than a random few. The one test in that
|
|
||||||
/// class that expects a refusal passed throughout, for the wrong reason.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>Two earlier diagnoses were wrong, and are recorded here so they are not tried again.</b>
|
|
||||||
/// <c>MaxStartups</c> was blamed on the reasoning that xUnit runs test classes in parallel, so ten
|
|
||||||
/// unauthenticated connections would be in flight at once — but every class that touches this server
|
|
||||||
/// shares <see cref="SshCollection"/>, and xUnit's unit of parallelism is the collection, so they run one
|
|
||||||
/// after another and never have more than a connection or two open. The reload window was blamed next,
|
|
||||||
/// and a wait for the banner to answer was written and removed as unproven; it was unproven because the
|
|
||||||
/// banner does answer, right up until the penalty lands.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// The line is appended rather than replaced in place, unlike the two below it, because the image's
|
|
||||||
/// config does not mention the keyword at all — there is no line to replace, and sshd takes the first
|
|
||||||
/// value it finds for a keyword that appears more than once.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b><c>AllowTcpForwarding</c> is what a dynamic forward needs</b>, and the image ships it off as
|
|
||||||
/// hardening. Without it a forward opens perfectly happily — a local listener asks the server nothing —
|
|
||||||
/// and then every connection through it is refused when the channel is opened. SSH.NET reports that as
|
|
||||||
/// <c>SOCKS5: General failure</c>, which names neither the server nor the setting, and is what the first
|
|
||||||
/// run of <c>LoopbackProxyTests</c> collected. That suite is also the alarm if this method ever silently
|
|
||||||
/// stops working.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <c>MaxStartups</c> is raised for the reason it should have been in the first place rather than as a
|
|
||||||
/// fix for anything: the compiled-in default refuses connections at random past ten unauthenticated ones
|
|
||||||
/// in flight, and a throttle is hardening a test server has no business reproducing. It is kept, not
|
|
||||||
/// because it was ever shown to matter here, but because removing it would be a second change riding
|
|
||||||
/// along with this one.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Both are replaced in place rather than appended, because sshd_config takes the <em>first</em> value
|
|
||||||
/// it finds for a keyword: an appended line would be dead the day the image ships an uncommented one of
|
|
||||||
/// its own.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b><c>/config/sshd/sshd_config</c>, and there are two.</b> The image also carries
|
/// ◆ <b><c>/config/sshd/sshd_config</c>, and there are two.</b> The image also carries
|
||||||
/// <c>/etc/ssh/sshd_config</c>, which looks like the file to patch, reads identically, and is not the one
|
/// <c>/etc/ssh/sshd_config</c>, which looks like the file to patch, reads identically, and is not the
|
||||||
/// the running server was started with — patching it changes the text and nothing else, which is a fix
|
/// one the running server was started with — patching it changes the text and nothing else, which is a
|
||||||
/// that appears to work and leaves the failure exactly where it was.
|
/// fix that appears to work and leaves the failure exactly where it was. Measured with <c>find</c>
|
||||||
|
/// rather than assumed, after the first version of this method did precisely that.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// ◆ <b>Patched after boot and reloaded, rather than injected before it — which was tried and does not
|
/// It is on for the whole assembly rather than for the one test that needs it. Forwarding is off in
|
||||||
/// work.</b> This image family runs <c>/custom-cont-init.d</c> scripts, which look like the right hook
|
/// this image as hardening, not as a behaviour worth reproducing: nothing else here opens a channel of
|
||||||
/// and are not: the container's own log puts <c>sshd is listening on port 2222</c> <em>before</em>
|
/// any kind, so allowing it changes what exactly one suite can do and what none of the others see.
|
||||||
/// <c>[custom-init] Files found, executing</c>, so a script there edits a file the running server has
|
|
||||||
/// already read. It leaves a config that greps correctly and a server behaving as though it had never
|
|
||||||
/// been touched — the same trap as the wrong file, one layer up. Measured from the log, after a version
|
|
||||||
/// of this fixture did exactly that and failed twenty-eight tests.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
private async Task ReconfigureAsync()
|
private async Task AllowTcpForwardingAsync()
|
||||||
{
|
{
|
||||||
var result = await container!.ExecAsync([
|
var result = await container!.ExecAsync([
|
||||||
"sh",
|
"sh",
|
||||||
"-c",
|
"-c",
|
||||||
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
"sed -i 's/^AllowTcpForwarding no/AllowTcpForwarding yes/' /config/sshd/sshd_config"
|
||||||
+ " && sed -i 's/^#*MaxStartups .*/MaxStartups 200/' /config/sshd/sshd_config"
|
|
||||||
+ " && printf '\\nPerSourcePenalties no\\n' >> /config/sshd/sshd_config"
|
|
||||||
+ " && pkill -HUP sshd",
|
+ " && pkill -HUP sshd",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (result.ExitCode != 0)
|
if (result.ExitCode != 0)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Could not reconfigure the test server: {result.Stderr}");
|
$"Could not enable TCP forwarding on the test server: {result.Stderr}");
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Blocks until the server answers <see cref="RequiredStreak"/> connections in a row with its banner.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// ◆ <b>This is a guard rather than a wait, and what it guards against is
|
|
||||||
/// <c>PerSourcePenalties</c> coming back.</b> Reconfiguring above turns it off; this proves it is off,
|
|
||||||
/// immediately and by name, instead of letting the suite discover it later as an unrelated-looking
|
|
||||||
/// failure in whichever class happened to be running.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// <b>Consecutive, and deliberately with no pause between them.</b> Each probe opens a connection, reads
|
|
||||||
/// the identification string and disconnects without authenticating — which is exactly the shape of
|
|
||||||
/// connection <c>PerSourcePenalties</c> punishes, and exactly what this suite does all day: a first
|
|
||||||
/// contact with an unknown host is a connection this client deliberately refuses at the host key.
|
|
||||||
/// <see cref="RequiredStreak"/> back to back is therefore not a soak test, it is the specific
|
|
||||||
/// provocation, sized above the measured threshold on purpose, and it costs well under a second when the
|
|
||||||
/// setting is off.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It is also the one check that can tell a listening socket from a running server. The container's own
|
|
||||||
/// readiness — a log line and <c>netstat</c> showing <c>:2222</c> — passes on a container whose sshd has
|
|
||||||
/// gone: the socket is published by a host-side proxy that accepts before it has anything to forward to,
|
|
||||||
/// so a dead server presents as a connection accepted and closed rather than as one refused.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Probed from the host rather than with <c>docker exec</c>, deliberately: that is the path the tests
|
|
||||||
/// take, proxy included, and penalties are counted per source address — from inside the container the
|
|
||||||
/// source would be the loopback rather than the address every test connects from.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
private async Task WaitUntilServingAsync()
|
|
||||||
{
|
|
||||||
// TimeProvider.System rather than DateTimeOffset.UtcNow, which this repository bans so that time can
|
|
||||||
// be faked — and rather than a fake, because what is being waited on is a real container starting.
|
|
||||||
var deadline = TimeProvider.System.GetUtcNow() + ReadyTimeout;
|
|
||||||
var streak = 0;
|
|
||||||
var last = "no probe ran";
|
|
||||||
|
|
||||||
while (streak < RequiredStreak)
|
|
||||||
{
|
|
||||||
if (TimeProvider.System.GetUtcNow() >= deadline)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException(
|
|
||||||
$"The test server did not answer {RequiredStreak} connections in a row within "
|
|
||||||
+ $"{ReadyTimeout}. The last probe said: {last}. If it says \"Not allowed at this "
|
|
||||||
+ "time\", sshd is penalising this source address and PerSourcePenalties is no longer "
|
|
||||||
+ "being turned off — see ReconfigureAsync.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var (answered, what) = await ProbeAsync();
|
|
||||||
last = what;
|
|
||||||
|
|
||||||
if (answered)
|
|
||||||
{
|
|
||||||
streak++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only pause when it is not working. Back-to-back probes are the point while they succeed;
|
|
||||||
// hammering a server that has not finished starting is just noise.
|
|
||||||
streak = 0;
|
|
||||||
await Task.Delay(TimeSpan.FromMilliseconds(200));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Opens a socket and reads far enough to see OpenSSH's identification string.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The description comes back with the answer because the interesting failures are not exceptions. A
|
|
||||||
/// penalised source is told <c>Not allowed at this time</c> in clear text before the socket closes, and
|
|
||||||
/// a suite that only knew "no banner" would have to go and find that out again — which is what happened
|
|
||||||
/// the first time, at some length.
|
|
||||||
/// </remarks>
|
|
||||||
private async Task<(bool Answered, string What)> ProbeAsync()
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
using var probe = new TcpClient();
|
|
||||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
||||||
|
|
||||||
await probe.ConnectAsync(Host, Port, timeout.Token);
|
|
||||||
|
|
||||||
var buffer = new byte[64];
|
|
||||||
var read = await probe.GetStream().ReadAtLeastAsync(
|
|
||||||
buffer, 4, throwOnEndOfStream: false, timeout.Token);
|
|
||||||
|
|
||||||
var answered = read >= 4 && "SSH-"u8.SequenceEqual(buffer.AsSpan(0, 4));
|
|
||||||
|
|
||||||
return (
|
|
||||||
answered,
|
|
||||||
answered
|
|
||||||
? "SSH-"
|
|
||||||
: $"{read} bytes: "
|
|
||||||
+ Encoding.ASCII.GetString(buffer, 0, Math.Max(read, 0)).ReplaceLineEndings(" "));
|
|
||||||
}
|
|
||||||
catch (Exception exception) when (exception is SocketException or OperationCanceledException or IOException)
|
|
||||||
{
|
|
||||||
return (false, $"{exception.GetType().Name}: {exception.Message}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,17 +168,12 @@ public sealed class SshServerFixture : IAsyncLifetime
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Shared rather than opened per test. This was once explained as a way of staying under the server's
|
/// Shared rather than opened per test, and that is a limit of the server rather than an optimisation.
|
||||||
/// <c>MaxStartups</c> throttle, on the belief that the suite ran its classes in parallel and made two
|
/// sshd's <c>MaxStartups</c> drops connections at random once enough are part-way through a handshake,
|
||||||
/// handshakes per test — this client's first contact with an unknown host is a connection deliberately
|
/// and this client's first contact with an unknown host is a connection deliberately <em>refused</em> at
|
||||||
/// <em>refused</em> at the host key, so every session costs two. The parallelism was not real: every
|
/// the host key — so a suite that opened its own session per test made two handshakes per test and
|
||||||
/// class here shares one collection and xUnit runs collections, not classes, in parallel. See
|
/// pushed the whole assembly over the threshold. What that looks like is unrelated tests failing with
|
||||||
/// <see cref="WaitUntilServingAsync"/>, which is where that mistake was found and what the failure it
|
/// "the connection was closed by the remote host", a different few each run.
|
||||||
/// was blamed for turned out to be.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// It stays shared regardless, on the plainer argument: one session is enough, and a handshake per test
|
|
||||||
/// would be seconds of the suite's runtime spent proving nothing this file has not already proved.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <para>
|
/// <para>
|
||||||
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
/// Safe to share because an SFTP session holds no per-test state: every test here works in a directory
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
|||||||
|
|
||||||
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
var unknown = await Should.ThrowAsync<SshHostKeyUnknownException>(async () =>
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
request, TerminalSize.Default, progress: null, TestContext.Current.CancellationToken));
|
request, TerminalSize.Default, TestContext.Current.CancellationToken));
|
||||||
|
|
||||||
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
unknown.Presentation.Fingerprint.ShouldStartWith(SshHostKeyFingerprint.Prefix);
|
||||||
|
|
||||||
@@ -119,7 +119,6 @@ public sealed class TerminalEndToEndTests(SshServerFixture fixture)
|
|||||||
return await workspace.OpenSessionAsync(
|
return await workspace.OpenSessionAsync(
|
||||||
request,
|
request,
|
||||||
new TerminalSize(100, 30, 1000, 750),
|
new TerminalSize(100, 30, 1000, 750),
|
||||||
progress: null,
|
|
||||||
TestContext.Current.CancellationToken);
|
TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -139,15 +139,8 @@ internal sealed class FakeConnectionFactory(long bytesPerShell = long.MaxValue,
|
|||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<ISshConnection> ConnectAsync(
|
public Task<ISshConnection> ConnectAsync(
|
||||||
SshConnectionRequest request,
|
SshConnectionRequest request,
|
||||||
IProgress<SshConnectionPhase>? progress,
|
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// The real factory's own order, so that a test watching this fake is watching the same sequence a
|
|
||||||
// real handshake produces. It cannot report CheckingHostKey — there is no key exchange here to
|
|
||||||
// produce a key — and inventing one would make this the only place that phase came from.
|
|
||||||
progress?.Report(SshConnectionPhase.Reaching);
|
|
||||||
progress?.Report(SshConnectionPhase.Authenticating);
|
|
||||||
|
|
||||||
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
|
var connection = new FakeConnection(request, bytesPerShell, blockShellReads);
|
||||||
Connections.Add(connection);
|
Connections.Add(connection);
|
||||||
|
|
||||||
@@ -257,15 +250,3 @@ internal sealed class RecordingTransport : ITerminalTransport
|
|||||||
TerminalFrame.TryRead(frame, out var actual, out _, out _)
|
TerminalFrame.TryRead(frame, out var actual, out _, out _)
|
||||||
&& actual == (byte)opcode);
|
&& actual == (byte)opcode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>An <see cref="IProgress{T}"/> that runs its callback on the thread that reported.</summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// <c>System.Progress<T></c> would post to a captured synchronisation context, or to the thread pool
|
|
||||||
/// when there is none — which is what a test has here — so a list it appended to would be asserted on before
|
|
||||||
/// it had been written. This is the same reason the shell does not use it either; see
|
|
||||||
/// <c>VaultViewModel.ReporterFor</c>.
|
|
||||||
/// </remarks>
|
|
||||||
internal sealed class DelegateProgress<T>(Action<T> report) : IProgress<T>
|
|
||||||
{
|
|
||||||
public void Report(T value) => report(value);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -72,12 +72,12 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.LiveSessionCount.ShouldBe(0);
|
workspace.LiveSessionCount.ShouldBe(0);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(1);
|
workspace.LiveSessionCount.ShouldBe(1);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
workspace.LiveSessionCount.ShouldBe(2);
|
workspace.LiveSessionCount.ShouldBe(2);
|
||||||
}
|
}
|
||||||
@@ -98,63 +98,11 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
|
await WaitUntilAsync(() => workspace.LiveSessionCount == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// <para>
|
|
||||||
/// <see cref="SshConnectionPhase.OpeningShell"/> is the one phase no connection factory can report,
|
|
||||||
/// because by the time it happens the factory has handed back a connection and gone. If this layer did
|
|
||||||
/// not report it the card's last step would light only when the whole session opened, which is the one
|
|
||||||
/// moment the card is already being taken down — a step nobody would ever see lit.
|
|
||||||
/// </para>
|
|
||||||
/// <para>
|
|
||||||
/// Asserted as the whole sequence rather than as "contains OpeningShell", because the order is the part
|
|
||||||
/// that matters: a step list is only readable if what it is told arrives in the order it draws.
|
|
||||||
/// </para>
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task OpeningASession_ReportsTheShellPhaseTheFactoryCannot()
|
|
||||||
{
|
|
||||||
var connections = new FakeConnectionFactory();
|
|
||||||
var reported = new List<SshConnectionPhase>();
|
|
||||||
|
|
||||||
await using var workspace = CreateWorkspace(connections);
|
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
|
||||||
Request(),
|
|
||||||
TerminalSize.Default,
|
|
||||||
new DelegateProgress<SshConnectionPhase>(reported.Add),
|
|
||||||
TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
reported.ShouldBe(
|
|
||||||
[
|
|
||||||
SshConnectionPhase.Reaching,
|
|
||||||
SshConnectionPhase.Authenticating,
|
|
||||||
SshConnectionPhase.OpeningShell,
|
|
||||||
],
|
|
||||||
"the factory's own phases, then the one this layer performs itself");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <remarks>
|
|
||||||
/// Nobody watching is the ordinary case — every caller but the connecting card passes null — so it is
|
|
||||||
/// worth one test that the null is a null and not a null reference.
|
|
||||||
/// </remarks>
|
|
||||||
[Fact]
|
|
||||||
public async Task OpeningASession_WorksWithNobodyWatchingItsPhases()
|
|
||||||
{
|
|
||||||
var connections = new FakeConnectionFactory();
|
|
||||||
|
|
||||||
await using var workspace = CreateWorkspace(connections);
|
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
workspace.IsSessionLive(sessionId).ShouldBeTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
public async Task ClosingASessionEndsItAndDisposesItsConnection()
|
||||||
{
|
{
|
||||||
@@ -163,7 +111,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
@@ -190,9 +138,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var first = await workspace.OpenSessionAsync(
|
var first = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
var second = await workspace.OpenSessionAsync(
|
var second = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
workspace.IsSessionLive(first).ShouldBeTrue();
|
workspace.IsSessionLive(first).ShouldBeTrue();
|
||||||
workspace.IsSessionLive(second).ShouldBeTrue();
|
workspace.IsSessionLive(second).ShouldBeTrue();
|
||||||
@@ -218,7 +166,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull();
|
||||||
var connection = connections.Connections.ShouldHaveSingleItem();
|
var connection = connections.Connections.ShouldHaveSingleItem();
|
||||||
@@ -251,7 +199,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
(await workspace.PasteAsync(
|
(await workspace.PasteAsync(
|
||||||
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
|
sessionId, "uptime", execute: false, TestContext.Current.CancellationToken))
|
||||||
@@ -297,7 +245,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await WaitUntilAsync(() =>
|
await WaitUntilAsync(() =>
|
||||||
{
|
{
|
||||||
@@ -335,7 +283,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
@@ -361,9 +309,9 @@ public sealed class TerminalWorkspaceTests
|
|||||||
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
|
||||||
|
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
await workspace.OpenSessionAsync(
|
await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.DisposeAsync();
|
await workspace.DisposeAsync();
|
||||||
|
|
||||||
@@ -407,7 +355,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
using var first = await ConnectRendererAsync(workspace);
|
using var first = await ConnectRendererAsync(workspace);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
|
// The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not
|
||||||
// what this test is about — read and discarded so it cannot be confused for one below.
|
// what this test is about — read and discarded so it cannot be confused for one below.
|
||||||
@@ -474,7 +422,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(connections);
|
await using var workspace = CreateWorkspace(connections);
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
|
// The up-arrow, as a PTY expects it. Three bytes, and all three matter.
|
||||||
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
|
byte[] upArrow = [0x1B, (byte)'[', (byte)'A'];
|
||||||
@@ -496,7 +444,7 @@ public sealed class TerminalWorkspaceTests
|
|||||||
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
|
await using var workspace = CreateWorkspace(new FakeConnectionFactory());
|
||||||
|
|
||||||
var sessionId = await workspace.OpenSessionAsync(
|
var sessionId = await workspace.OpenSessionAsync(
|
||||||
Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken);
|
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await workspace.CloseSessionAsync(sessionId);
|
await workspace.CloseSessionAsync(sessionId);
|
||||||
|
|
||||||
|
|||||||
@@ -391,7 +391,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var first = await factory.ConnectAsync(request, progress: null, Token);
|
await using var first = await factory.ConnectAsync(request, Token);
|
||||||
Assert.Fail("An unseen host key must not be trusted silently.");
|
Assert.Fail("An unseen host key must not be trusted silently.");
|
||||||
}
|
}
|
||||||
catch (SshHostKeyUnknownException exception)
|
catch (SshHostKeyUnknownException exception)
|
||||||
@@ -402,7 +402,7 @@ public sealed class M1VerticalSliceTests(DevStack stack) : IClassFixture<DevStac
|
|||||||
await knownHosts.TrustAsync(pin, Token);
|
await knownHosts.TrustAsync(pin, Token);
|
||||||
}
|
}
|
||||||
|
|
||||||
await using var connection = await factory.ConnectAsync(request, progress: null, Token);
|
await using var connection = await factory.ConnectAsync(request, Token);
|
||||||
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
await using var shell = await connection.OpenShellAsync(TerminalSize.Default, Token);
|
||||||
|
|
||||||
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
await shell.WriteTextAsync("echo dodossh-e2e-ok\n", Token);
|
||||||
|
|||||||
Reference in New Issue
Block a user