Public Access
Give hosts and terminals their own screen, and the rest of the vault another
Rebuilds the client's shell from an imported design: a titlebar and nav rail it draws itself, real multi-session tabs over the one WebView, a Ctrl+K host search, and a vault screen that merges keys, passwords and pinned host keys into one table. Hosts left the vault column for their own screen beside the terminal, which is what the design asks for and turned out to be the better split anyway. Two screens the design shows have nothing behind them yet — file transfer and teams — and say so plainly rather than rendering invented data; every other gap between the design and this build is recorded in docs/design-import-gaps.md.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:sys="using:System.Collections.Generic"
|
||||
xmlns:vm="using:DodoSSH.Client.App.ViewModels"
|
||||
xmlns:views="using:DodoSSH.Client.App.Views"
|
||||
x:Class="DodoSSH.Client.App.Views.MainWindow"
|
||||
@@ -7,331 +8,357 @@
|
||||
Title="DodoSSH"
|
||||
Width="1180"
|
||||
Height="760"
|
||||
MinWidth="820"
|
||||
MinHeight="520"
|
||||
Background="#10131a">
|
||||
|
||||
<!-- The shared classes these views use are in App.axaml, so a control laid out on its own keeps them. -->
|
||||
MinWidth="880"
|
||||
MinHeight="560"
|
||||
Background="{StaticResource Canvas}"
|
||||
SystemDecorations="BorderOnly"
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
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.
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
|
||||
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.
|
||||
Windows is asked for a resize border and nothing else, so TitleBar does the dragging, the maximising and
|
||||
the closing. That is a real cost, and the reason it is paid is that a stock grey system bar above a
|
||||
near-black application is the one part of the window that would look borrowed.
|
||||
|
||||
SystemDecorations="BorderOnly" rather than extending the client area under the system chrome, which is
|
||||
what this tried first and is measurably wrong on Avalonia 12: ExtendClientAreaChromeHints is gone from
|
||||
that version, so extending leaves the caption buttons and the window title drawn on top of this
|
||||
window's own — two sets of minimise/maximise/close, and "DodoSSH" printed twice, overlapping. BorderOnly
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
chrome worth having.
|
||||
|
||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||
|
||||
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 a screen over it leaves that screen sliced at the WebView's left edge with its
|
||||
buttons unreachable, which this window has shipped once already.
|
||||
|
||||
So anything that would occupy the terminal's rectangle collapses the terminal instead, and
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, a screen other than Hosts, or
|
||||
the quick-connect palette. Collapsing is safe, and cheaply so — NativeControlHost creates the native
|
||||
control when the control is attached to the visual tree, not when it is laid out or shown, so WebView2
|
||||
still starts, still loads the page and still lets the renderer attach its socket while it is false. It
|
||||
only swaps ShowInBounds for HideWithSize, and 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.
|
||||
VaultViewModel.ConnectAsync — the data plane drops frames when no renderer is attached, so the gate is
|
||||
that await, never this control's visibility.
|
||||
|
||||
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.
|
||||
-->
|
||||
<Panel>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" ColumnDefinitions="340,*">
|
||||
<Grid RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<!-- 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}" />
|
||||
<!--
|
||||
Offered only on a machine that can keep a device key and has not already registered one, so it
|
||||
disappears once used and never appears where it could not work. Its own button rather than a
|
||||
setting, because it is a one-time decision with a consent dialog attached — and because the
|
||||
honest place to ask "may this machine unlock itself?" is right after somebody proved they can.
|
||||
-->
|
||||
<Button Content="Use Windows Hello here" Command="{Binding RegisterDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanRegisterDevice}"
|
||||
ToolTip.Tip="Registers this machine so a later launch can open the vault with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
|
||||
<!--
|
||||
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one:
|
||||
a machine with no TPM and a machine that is already registered are both "cannot register", and
|
||||
only the second has anything to take back.
|
||||
-->
|
||||
<Button Content="Stop unlocking here" Command="{Binding ForgetDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanForgetDevice}"
|
||||
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
|
||||
<Button Content="Sync" Command="{Binding Vault.SyncCommand}" />
|
||||
<!--
|
||||
The tooltip carries the policy to the point of action, because the button's name implies
|
||||
the opposite of what it does to a running shell.
|
||||
-->
|
||||
<Button Content="Lock" Command="{Binding LockCommand}"
|
||||
ToolTip.Tip="Closes the vault and forgets its keys. Open shells keep running and reappear when you unlock." />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
<views:TitleBar Grid.Row="0" />
|
||||
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
<!-- The unlocked application. -->
|
||||
<Grid ColumnDefinitions="Auto,*" IsVisible="{Binding IsUnlocked}">
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<!--
|
||||
Connecting. A password box only for a host that asks to be — a host bound to a stored
|
||||
credential or a key wants nothing typed here — and a sentence in its place when it does not,
|
||||
because "nothing needs typing" and "something needs typing and the box has not appeared yet"
|
||||
look identical and only one of them is fine.
|
||||
-->
|
||||
<Border Grid.Row="1" Padding="12,8" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource BorderSubtle}" BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="200" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Vault and bind this host to it in the host's own editor." />
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint"
|
||||
FontSize="11" VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Classes="accent" Content="CONNECT" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<StackPanel Grid.Row="2">
|
||||
|
||||
<!--
|
||||
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="12,10" Background="{StaticResource WarnWash}"
|
||||
BorderBrush="{StaticResource WarnSoft}" BorderThickness="0,0,0,1"
|
||||
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="{StaticResource WarnText}" TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Vault.PendingHostKey.Fingerprint}"
|
||||
Foreground="{StaticResource Warn}" TextWrapping="Wrap" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="TRUST AND CONNECT"
|
||||
Command="{Binding Vault.TrustHostKeyCommand}" />
|
||||
<Button Classes="ghost" Content="CANCEL"
|
||||
Command="{Binding Vault.RejectHostKeyCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<Border Padding="12,10" Background="{StaticResource DangerWash}"
|
||||
BorderBrush="{StaticResource DangerSoft}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasHostKeyMismatch}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="The host key changed and the connection was refused."
|
||||
Foreground="{StaticResource Danger}" FontWeight="SemiBold" />
|
||||
<SelectableTextBlock Text="{Binding Vault.HostKeyMismatch}"
|
||||
Foreground="{StaticResource Danger}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="If the server was legitimately rebuilt, edit the host and choose "Forget host key" first. There is deliberately no way to continue from here."
|
||||
Foreground="{StaticResource WarnText}" 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="12,10" Background="{StaticResource Panel}"
|
||||
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1"
|
||||
IsVisible="{Binding Vault.HasConflicts}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Text="Some changes could not be merged automatically."
|
||||
Foreground="{StaticResource Info}" FontWeight="SemiBold" />
|
||||
<ItemsControl ItemsSource="{Binding Vault.Conflicts}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictRowViewModel">
|
||||
<Border Margin="0,4" Padding="8" Background="{StaticResource Raised}"
|
||||
CornerRadius="4">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding Summary}" Foreground="{StaticResource Text}"
|
||||
TextWrapping="Wrap" />
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding Detail}" FontSize="11"
|
||||
Foreground="{StaticResource TextDim}"
|
||||
IsVisible="{Binding HasDetail}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Button Classes="ghost" Content="DISMISS ALL" HorizontalAlignment="Left"
|
||||
Command="{Binding Vault.AcknowledgeAllConflictsCommand}" />
|
||||
</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.
|
||||
|
||||
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="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTransfersScreen}"
|
||||
Title="FILE TRANSFER"
|
||||
Milestone="MILESTONE M2"
|
||||
Summary="The design shows a two-pane file browser over SFTP with a transfer queue. None of it is here: an SSH connection in this build opens exactly one interactive shell channel and nothing else, so there is no file transfer to show the state of."
|
||||
Instead="Until this lands, move files the way you would from any terminal — scp or rsync from a shell on this machine, or a shell open on the host itself.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>An SFTP subsystem channel on ISshConnection, which today offers OpenShellAsync and nothing more (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>Remote directory listing — names, sizes, modification times and permission bits (DodoSSH.Client.Ssh).</x:String>
|
||||
<x:String>A transfer queue with progress, throughput and resume, and somewhere for it to live across a lock (DodoSSH.Client.Ssh, DodoSSH.Client.Session).</x:String>
|
||||
<x:String>Routing a transfer through a bastion, which needs jump-host support the connection layer does not have — the host model already records the chain.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ VAULT ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the reason the vault column always was: this element's
|
||||
visibility is the shell's business and its data context is the vault, and put both on one element
|
||||
and IsVisible resolves against the vault as well, where IsVaultScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsVaultScreen}">
|
||||
<views:VaultScreen x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<views:NotBuiltScreen IsVisible="{Binding IsTeamScreen}"
|
||||
Title="TEAM"
|
||||
Milestone="MILESTONE M3"
|
||||
Summary="The design shows members, roles, shared vaults and pending invitations. The server has team tables from its first migration and not one endpoint that reads them, and its access service refuses every vault that is not your own — so there is nobody to list and no shared vault to open."
|
||||
Instead="Everything you have is yours alone today: your hosts are in the sidebar on the Hosts screen, and your keys, passwords and approved host keys are on the Vault screen. Sharing a credential means handing it over out of band, and rotating it afterwards.">
|
||||
<views:NotBuiltScreen.Missing>
|
||||
<sys:List x:TypeArguments="x:String">
|
||||
<x:String>Endpoints for teams, membership, roles and invitations — the server exposes eight routes and none of them is about people (DodoSSH.Api).</x:String>
|
||||
<x:String>Access to a vault somebody else owns: VaultAccessService resolves personal ownership and denies everything else (DodoSSH.Api).</x:String>
|
||||
<x:String>Roles on the wire. VaultSummary carries a nullable TeamId and an opaque permissions flag, and no DTO gives either a meaning (DodoSSH.Contracts).</x:String>
|
||||
<x:String>Per-member facts the design shows — two-factor state, last-active time, avatars — none of which the server records.</x:String>
|
||||
<x:String>Sharing an item, which is the point of the screen: today a vault key is sealed to one account, and sharing means re-wrapping it for another.</x:String>
|
||||
</sys:List>
|
||||
</views:NotBuiltScreen.Missing>
|
||||
</views:NotBuiltScreen>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
Setup and unlock. Later in the Panel, so it is above the application content in Avalonia's z-order —
|
||||
which covers Avalonia-drawn content and nothing else. The terminal is collapsed rather than covered;
|
||||
see the occlusion rule above.
|
||||
-->
|
||||
<Border Background="{StaticResource Canvas}" 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 Classes="accent" 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 Classes="accent" 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="{StaticResource Raised}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="14">
|
||||
<SelectableTextBlock Classes="mono" Text="{Binding RecoveryCode}"
|
||||
FontSize="16" Foreground="{StaticResource Accent}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
<CheckBox IsChecked="{Binding RecoveryCodeWrittenDown}"
|
||||
Content="I have written it down somewhere safe" />
|
||||
<Button Classes="accent" 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="{StaticResource Info}" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="accent" Content="UNLOCK" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Classes="ghost" Content="USE WINDOWS HELLO"
|
||||
Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<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." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
|
||||
BorderThickness="1" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="{StaticResource Info}"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Border>
|
||||
|
||||
<!--
|
||||
The vault column, in its own control. VaultColumn.axaml carries the reasoning about its shape; what
|
||||
matters here is that it is the part of this window a test can lay out, because this window as a
|
||||
whole cannot be shown off the UI thread that owns WebView2.
|
||||
|
||||
Wrapped in a Panel rather than bound directly. The column's data context is the vault, and this
|
||||
element's visibility is the shell's business — put both on one element and IsVisible would resolve
|
||||
against the vault as well, where IsUnlocked does not exist.
|
||||
Last, so it is over everything Avalonia draws. It is over the terminal too, because opening it
|
||||
collapses the terminal — see IsTerminalShowing.
|
||||
-->
|
||||
<Panel Grid.Row="1" Grid.Column="0" IsVisible="{Binding IsUnlocked}">
|
||||
<views:VaultColumn x:Name="VaultPane" DataContext="{Binding Vault}" />
|
||||
</Panel>
|
||||
<views:QuickConnect x:Name="Palette" IsVisible="{Binding IsSearching}" />
|
||||
|
||||
<!-- Terminal column -->
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
</Panel>
|
||||
|
||||
<Border Grid.Row="0" Padding="10,8" Background="#171b24" IsVisible="{Binding IsUnlocked}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<!--
|
||||
Typed per connection, and now only for a host that asks to be. A host bound to a stored
|
||||
credential wants nothing typed here; so does one bound to a key. "not stored" rather than "not
|
||||
stored yet", because the vault can hold this now — a host on a typed password is a host nobody
|
||||
has bound a credential to, which is a choice rather than a missing feature.
|
||||
<views:StatusBar Grid.Row="2" />
|
||||
|
||||
The rest of that explanation is a tooltip and not a sentence in this row, which was measured the
|
||||
hard way: at the window's 820px minimum this column gets 480, and a 220px box plus Connect plus
|
||||
any sentence does not fit. The row shipped clipped for as long as it has had a hint in it. This
|
||||
is also the one strip of the window no test can see — MainWindow cannot be laid out headlessly at
|
||||
all, because WebView2 refuses the headless dispatcher's MTA thread. Extracting this header into
|
||||
its own control, as the vault column was extracted for exactly this reason, is what would make
|
||||
it measurable.
|
||||
-->
|
||||
<TextBox Text="{Binding Vault.ConnectPassword}" PlaceholderText="password (not stored)"
|
||||
PasswordChar="•" Width="220" VerticalAlignment="Center"
|
||||
IsVisible="{Binding Vault.SelectedHostAsksForAPassword}"
|
||||
ToolTip.Tip="Typed each time and never stored. To stop typing it, add a password under Passwords and bind this host to it in the host's own editor." />
|
||||
<!--
|
||||
Hidden rather than disabled, unlike most of this window. A disabled password box invites the
|
||||
reading that a password is wanted and unavailable; the honest statement for a host that carries
|
||||
its own secret is that nothing needs typing, and an absent box says that better than a
|
||||
greyed-out one. Which secret it carries is in the sentence, because a stored password can be
|
||||
wrong and re-typed here and a key cannot.
|
||||
-->
|
||||
<TextBlock Text="{Binding Vault.SelectedHostAuthenticationNote}" Classes="hint" FontSize="11"
|
||||
VerticalAlignment="Center"
|
||||
IsVisible="{Binding !Vault.SelectedHostAsksForAPassword}" />
|
||||
<Button Content="Connect" Command="{Binding Vault.ConnectCommand}"
|
||||
IsEnabled="{Binding !Vault.IsBusy}" VerticalAlignment="Center" />
|
||||
</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, edit the host and choose "Forget host key" 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" />
|
||||
<!--
|
||||
Named because locking has to put the keyboard here explicitly. The terminal's native
|
||||
child window keeps Win32 focus when it is collapsed, so without that this box would
|
||||
show a caret and silently swallow the passphrase — see NativeKeyboardFocus.
|
||||
-->
|
||||
<TextBox x:Name="UnlockPassphrase" Text="{Binding Passphrase}"
|
||||
PlaceholderText="vault passphrase" PasswordChar="•" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Content="Unlock" Command="{Binding UnlockCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" />
|
||||
<!--
|
||||
Shown only when this machine has both a registered wrap and a keystore still willing to
|
||||
release the key. Absent rather than disabled, because a greyed-out "Use Windows Hello" on a
|
||||
machine that never had it invites the reading that something is broken — and the passphrase
|
||||
box beside it is not a fallback, it is the ordinary way in.
|
||||
-->
|
||||
<Button Content="Use Windows Hello" Command="{Binding UnlockWithDeviceCommand}"
|
||||
IsEnabled="{Binding !IsBusy}"
|
||||
IsVisible="{Binding CanUnlockWithDevice}"
|
||||
ToolTip.Tip="Opens the vault with this machine's device key. Windows will ask you to confirm." />
|
||||
</StackPanel>
|
||||
<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." />
|
||||
|
||||
<!--
|
||||
Stated here because the lock screen is what hides it. The terminal's WebView is collapsed
|
||||
while locked, so a shell left running is invisible as well as unstopped — and a screen
|
||||
saying "Unlock your vault" over a machine that still holds authenticated SSH channels is
|
||||
exactly the kind of half-truth this project writes down instead of implying. Visible only
|
||||
when there is something to disclose, so an ordinary launch stays quiet.
|
||||
-->
|
||||
<Border Background="#1b2432" CornerRadius="4" Padding="10,8"
|
||||
IsVisible="{Binding HasLiveSessions, FallbackValue=False}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding LiveSessionSummary}" Foreground="#bcd2ea"
|
||||
FontWeight="SemiBold" TextWrapping="Wrap" />
|
||||
<TextBlock Classes="hint" FontSize="11"
|
||||
Text="Locking closes the vault, not your terminals: a job you started keeps running, and its output is waiting behind this screen. It also means this machine still holds an open, authenticated channel to those hosts — locked describes the vault, not the connections. Quit DodoSSH to end them." />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
|
||||
</Border>
|
||||
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
</Window>
|
||||
|
||||
Reference in New Issue
Block a user