Public Access
An adversarial review of 0500e43 did not refute the fix but closed the gap I
had left open and found three hazards around it. A standalone spike — a 60-line
Avalonia app with no DodoSSH code — reproduces the airspace bug on a 340,* grid,
and a second harness mirroring the data plane's handshake measures what I had
only reasoned about: with IsVisible=false set before the window is ever shown,
the adapter is created, the page is fetched and the WebSocket 101 is sent, with
frames arriving over the socket while hidden. A cold WebView2 profile behaves
the same. Revealing recomputes bounds in about 7 ms.
So the docs no longer cite "35 msedgewebview2 processes" as the confirmation
that the renderer attaches. A process count cannot show that a socket was
accepted — the same shape of mistake, one level down, as the one that entry was
already correcting. It now cites the handshake, quotes Avalonia's maintainer on
airspace being by design, and links the still-open upstream issue.
Three changes to the fix itself:
- terminal.js skips the fit below 40px in either axis. The vendored fit addon
floors its proposal at 2 columns by 1 row rather than refusing, so a
degenerate viewport reflows the *remote* pty through window-change and
mangles wrapped scrollback unrecoverably. Reachable today by minimising, and
by dragging a splitter to the edge once splits land — a guard where the sizes
arrive, not a special case for one caller.
- FallbackValue=False on the binding. A compiled binding with no DataContext
yields UnsetValue, IsVisible falls back to true, and the occlusion returns
silently. Not reachable at runtime; it is what the previewer does.
- The comment now says why it must be IsVisible on this control: detaching
destroys the native control and the whole WebView2 process tree, so
conditional content would pay a cold start per unlock, and hoisting the
binding to an ancestor is unverified because NativeWebView's own
bounds-and-scaling re-push fires only for its own IsVisible.
Also recorded, not fixed: hiding does not suspend the page (visibilityState
stays "visible" and rAF keeps firing at ~115/s, which is *why* the handshake
completes while hidden); the conflict log can squeeze the terminal row toward
nothing; and nothing hands the terminal Win32 focus after Connect, so the first
keystrokes go to the shell's UI rather than the remote shell.
325 lines
18 KiB
XML
325 lines
18 KiB
XML
<Window xmlns="https://github.com/avaloniaui"
|
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
|
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
|
x:DataType="vm:MainWindowViewModel"
|
|
Title="DodoSSH"
|
|
Width="1180"
|
|
Height="760"
|
|
MinWidth="820"
|
|
MinHeight="520"
|
|
Background="#10131a">
|
|
|
|
<Window.Styles>
|
|
<Style Selector="TextBlock.hint">
|
|
<Setter Property="Foreground" Value="#7b8394" />
|
|
<Setter Property="TextWrapping" Value="Wrap" />
|
|
</Style>
|
|
<Style Selector="TextBlock.heading">
|
|
<Setter Property="Foreground" Value="#e6e9f0" />
|
|
<Setter Property="FontSize" Value="18" />
|
|
<Setter Property="FontWeight" Value="SemiBold" />
|
|
</Style>
|
|
<Style Selector="Border.card">
|
|
<Setter Property="Background" Value="#171b24" />
|
|
<Setter Property="CornerRadius" Value="8" />
|
|
<Setter Property="Padding" Value="24" />
|
|
<Setter Property="MaxWidth" Value="520" />
|
|
<Setter Property="VerticalAlignment" Value="Center" />
|
|
<Setter Property="HorizontalAlignment" Value="Center" />
|
|
</Style>
|
|
</Window.Styles>
|
|
|
|
<!--
|
|
The terminal's WebView is collapsed whenever the vault is not unlocked, and that is not a style
|
|
choice. NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window
|
|
composites above everything the parent paints — so no sibling in this visual tree can cover it,
|
|
whatever the z-order says. Layering the setup screens over it left them sliced at the WebView's left
|
|
edge, with their buttons unreachable at the window's default width.
|
|
|
|
Collapsing is safe, which the earlier version of this comment denied: NativeControlHost creates the
|
|
native control when the control is attached to the visual tree, not when it is laid out or shown, and
|
|
an assigned Source is replayed once the adapter exists. IsVisible=false therefore still starts
|
|
WebView2, still loads the page and still lets the renderer attach its socket; it only swaps
|
|
ShowInBounds for HideWithSize. Flipping it back re-pushes the bounds.
|
|
|
|
What the first connection after unlocking actually depends on is the await in
|
|
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate
|
|
is that await, never this control's visibility.
|
|
-->
|
|
<Panel>
|
|
|
|
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
|
|
|
|
<!-- Account bar -->
|
|
<Border Grid.Row="0" Grid.ColumnSpan="2" Padding="12,8" Background="#171b24"
|
|
IsVisible="{Binding IsUnlocked}">
|
|
<Grid ColumnDefinitions="*,Auto">
|
|
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center">
|
|
<TextBlock Text="{Binding Vault.VaultName}" Foreground="#e6e9f0" FontWeight="SemiBold"
|
|
VerticalAlignment="Center" />
|
|
<TextBlock Text="{Binding AccountName}" Classes="hint" VerticalAlignment="Center" />
|
|
<TextBlock Text="{Binding Vault.Status}" Classes="hint" VerticalAlignment="Center"
|
|
TextTrimming="CharacterEllipsis" MaxWidth="520" />
|
|
</StackPanel>
|
|
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
|
<TextBlock Text="offline" Foreground="#c8a55a" VerticalAlignment="Center"
|
|
IsVisible="{Binding !IsOnline}" />
|
|
<Button Content="Sign in" Command="{Binding SignInCommand}"
|
|
IsVisible="{Binding !IsOnline}" />
|
|
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
|
<Button Content="Lock" Command="{Binding LockCommand}" />
|
|
</StackPanel>
|
|
</Grid>
|
|
</Border>
|
|
|
|
<!-- Host list -->
|
|
<Grid Grid.Row="1" Grid.Column="0" RowDefinitions="*,Auto,Auto"
|
|
Background="#131722" IsVisible="{Binding IsUnlocked}">
|
|
|
|
<ListBox Grid.Row="0" Margin="6"
|
|
ItemsSource="{Binding Vault.Hosts}"
|
|
SelectedItem="{Binding Vault.SelectedHost}"
|
|
Background="Transparent">
|
|
<ListBox.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:HostRowViewModel">
|
|
<StackPanel Spacing="2" Margin="2,4">
|
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
<TextBlock Text="{Binding Label}" Foreground="#e6e9f0" FontWeight="SemiBold" />
|
|
<Border Background="#2b2410" CornerRadius="3" Padding="4,0"
|
|
IsVisible="{Binding Badge, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
|
|
<TextBlock Text="{Binding Badge}" Foreground="#e8dcb0" FontSize="10"
|
|
VerticalAlignment="Center" />
|
|
</Border>
|
|
</StackPanel>
|
|
<TextBlock Text="{Binding Address}" Classes="hint" FontSize="11"
|
|
FontFamily="ui-monospace,Consolas,monospace" />
|
|
</StackPanel>
|
|
</DataTemplate>
|
|
</ListBox.ItemTemplate>
|
|
</ListBox>
|
|
|
|
<!-- The editor doubles as the "add" form; there is no separate dialog. -->
|
|
<Border Grid.Row="1" Padding="10" Background="#171b24" IsVisible="{Binding Vault.IsEditing}">
|
|
<StackPanel Spacing="6">
|
|
<TextBox Text="{Binding Vault.EditorLabel}" PlaceholderText="name" />
|
|
<TextBox Text="{Binding Vault.EditorHostname}" PlaceholderText="hostname or address" />
|
|
<NumericUpDown Value="{Binding Vault.EditorPort}" Minimum="1" Maximum="65535"
|
|
FormatString="0" />
|
|
<TextBox Text="{Binding Vault.EditorUsername}" PlaceholderText="username" />
|
|
<TextBox Text="{Binding Vault.EditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
|
|
Height="60" TextWrapping="Wrap" />
|
|
<CheckBox IsChecked="{Binding Vault.EditorRelayEnabled}"
|
|
Content="Allow connecting through the server relay" />
|
|
<!--
|
|
Stated at the moment the decision is made, which is the only place it means anything. With
|
|
relay off the server stores no address at all; with it on the server must be able to resolve
|
|
the target, or it becomes an authenticated open proxy into the operator's network.
|
|
-->
|
|
<TextBlock Classes="hint" FontSize="11"
|
|
Text="Enabling the relay stores this host's address on the server in plain text. Everything else about the host stays encrypted." />
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<Button Content="Save" Command="{Binding Vault.SaveHostCommand}" />
|
|
<Button Content="Cancel" Command="{Binding Vault.CancelEditCommand}" />
|
|
</StackPanel>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="6" Margin="8"
|
|
IsVisible="{Binding !Vault.IsEditing}">
|
|
<Button Content="Add" Command="{Binding Vault.NewHostCommand}" />
|
|
<Button Content="Edit" Command="{Binding Vault.EditSelectedHostCommand}" />
|
|
<Button Content="Delete" Command="{Binding Vault.DeleteHostCommand}" />
|
|
</StackPanel>
|
|
|
|
</Grid>
|
|
|
|
<!-- Terminal column -->
|
|
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
|
|
|
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<!--
|
|
Typed per connection. SyncEntityType.Credential exists in the contract but is not synced
|
|
yet, so the vault genuinely does not hold this — saying so beats a password box that looks
|
|
like it should have been remembered.
|
|
-->
|
|
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored yet)"
|
|
PasswordChar="•" Width="220" VerticalAlignment="Center" />
|
|
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
|
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
|
<TextBlock Classes="hint" FontSize="11" VerticalAlignment="Center"
|
|
Text="Credentials are not in the vault yet." />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<StackPanel Grid.Row="1" IsVisible="{Binding IsUnlocked}">
|
|
|
|
<!--
|
|
Host key prompts. Unknown and changed look deliberately different: one is a decision, the
|
|
other is a refusal. Presenting a changed key with a "continue" button is how users are taught
|
|
to click through the one warning that matters.
|
|
-->
|
|
<Border Padding="10,8" Background="#2b2410" IsVisible="{Binding Vault.HasPendingHostKey}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="This host has not been seen before. Check the fingerprint against what the server's operator published."
|
|
Foreground="#e8dcb0" TextWrapping="Wrap" />
|
|
<SelectableTextBlock Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
|
FontFamily="ui-monospace,Consolas,monospace"
|
|
Foreground="#f4ecd0" />
|
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
<Button Content="Trust and connect" Command="{Binding Vault.TrustHostKeyCommand}" />
|
|
<Button Content="Cancel" Command="{Binding Vault.RejectHostKeyCommand}" />
|
|
</StackPanel>
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Padding="10,8" Background="#3a1418" IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="The host key changed and the connection was refused."
|
|
Foreground="#f3c9cd" FontWeight="SemiBold" />
|
|
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
|
Foreground="#f3c9cd" TextWrapping="Wrap" />
|
|
<TextBlock Text="If the server was legitimately rebuilt, remove its pinned key in the host's settings first. There is deliberately no way to continue from here."
|
|
Foreground="#d59aa1" TextWrapping="Wrap" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
The conflict log. The merge is only allowed to pick a winner because the value it overrode is
|
|
kept and shown; without this panel it would be last-writer-wins with a longer explanation.
|
|
-->
|
|
<Border Padding="10,8" Background="#1b2432" IsVisible="{Binding Vault.HasConflicts}">
|
|
<StackPanel Spacing="6">
|
|
<TextBlock Text="Some changes could not be merged automatically."
|
|
Foreground="#bcd2ea" FontWeight="SemiBold" />
|
|
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
|
<ItemsControl.ItemTemplate>
|
|
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
|
<Border Margin="0,4" Padding="8" Background="#141b26" CornerRadius="4">
|
|
<StackPanel Spacing="4">
|
|
<TextBlock Text="{Binding Summary}" Foreground="#dfe6f0" TextWrapping="Wrap" />
|
|
<SelectableTextBlock Text="{Binding Detail}" Classes="hint" FontSize="11"
|
|
FontFamily="ui-monospace,Consolas,monospace"
|
|
IsVisible="{Binding HasDetail}" />
|
|
</StackPanel>
|
|
</Border>
|
|
</DataTemplate>
|
|
</ItemsControl.ItemTemplate>
|
|
</ItemsControl>
|
|
<Button Content="Dismiss all" Command="{Binding Vault.AcknowledgeAllConflictsCommand}"
|
|
HorizontalAlignment="Left" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
</StackPanel>
|
|
|
|
<!--
|
|
One WebView hosting every terminal. Not one per tab: each WebView2 is a separate browser process
|
|
tree, so twenty tabs would cost twenty of them.
|
|
|
|
IsVisible is load-bearing rather than cosmetic — see the note on the root Panel. Without it the
|
|
native child window paints over the setup and unlock screens and swallows their input.
|
|
|
|
It must stay IsVisible on this control specifically, and two nearby alternatives are wrong.
|
|
Removing the control from the tree instead — conditional content, a template swap — detaches it,
|
|
and detaching destroys the native control and the whole WebView2 process tree, so every unlock
|
|
would pay a cold start. Hoisting the binding to an ancestor looks tidier and is unverified:
|
|
NativeControlHost does watch ancestors, but NativeWebView's own bounds-and-scaling re-push fires
|
|
only for its own IsVisible.
|
|
|
|
FallbackValue, because a compiled binding with no DataContext yields UnsetValue, IsVisible then
|
|
falls back to its default of true, and the occlusion comes back silently. Not reachable at
|
|
runtime — the DataContext is set before the window is shown — but it is what the previewer does.
|
|
-->
|
|
<NativeWebView Grid.Row="2" x:Name="Terminal"
|
|
IsVisible="{Binding IsUnlocked, FallbackValue=False}" />
|
|
|
|
</Grid>
|
|
|
|
</Grid>
|
|
|
|
<!--
|
|
Setup and unlock. Last in the Panel, so it is above the app content in Avalonia's z-order — which
|
|
covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered.
|
|
-->
|
|
<Border Background="#10131a" IsVisible="{Binding !IsUnlocked}">
|
|
|
|
<Panel>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsStarting}">
|
|
<StackPanel Spacing="10">
|
|
<TextBlock Classes="heading" Text="DodoSSH" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingServer}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Connect to your server" />
|
|
<TextBlock Classes="hint"
|
|
Text="One address is all this needs. The identity provider, the client id and the scopes all come from the server itself." />
|
|
<TextBox Text="{Binding ServerUrl}" PlaceholderText="https://dodossh.example" />
|
|
<Button Content="Sign in with your browser" Command="{Binding SignInCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
|
<TextBlock Classes="hint"
|
|
Text="This passphrase never leaves this machine, and the server cannot reset it. It is the only thing standing between a stolen copy of the database and every credential in your vault." />
|
|
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
|
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
|
<Button Content="Create my vault" Command="{Binding EnrollCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<!--
|
|
Shown once and impossible to skip. This is the only moment the code exists, and losing it
|
|
together with the passphrase means the vault is unrecoverable — there is no server-side reset by
|
|
design.
|
|
-->
|
|
<Border Classes="card" IsVisible="{Binding IsShowingRecoveryCode}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Write this recovery code down" />
|
|
<TextBlock Classes="hint"
|
|
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the vault: nobody — including whoever runs the server — can recover it for you." />
|
|
<Border Background="#0c0f15" CornerRadius="6" Padding="14">
|
|
<SelectableTextBlock Text="{Binding RecoveryCode}"
|
|
FontFamily="ui-monospace,Consolas,monospace"
|
|
FontSize="16" Foreground="#9ee6b4" TextWrapping="Wrap" />
|
|
</Border>
|
|
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
|
Content="I have written it down somewhere safe" />
|
|
<Button Content="Continue" Command="{Binding ConfirmRecoveryCodeCommand}"
|
|
HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
<Border Classes="card" IsVisible="{Binding IsLocked}">
|
|
<StackPanel Spacing="12">
|
|
<TextBlock Classes="heading" Text="Unlock your vault" />
|
|
<TextBlock Text="{Binding AccountName}" Foreground="#bcd2ea" />
|
|
<TextBox Text="{Binding Passphrase}" PlaceholderText="vault passphrase" PasswordChar="•" />
|
|
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
|
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
|
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
|
<TextBlock Classes="hint" FontSize="11"
|
|
Text="This works with no network: the salt and the wrapped key are already on this machine." />
|
|
</StackPanel>
|
|
</Border>
|
|
|
|
</Panel>
|
|
|
|
</Border>
|
|
|
|
</Panel>
|
|
|
|
</Window>
|