Public Access
Merge branch 'main' into the Android head
Main grew the screens the host-management plan called for — hosts, pins, snippets, logs, import, teams — plus the ObjectStore and Import projects behind two of them, and moved WindowsDeviceKeyStore into the desktop head's Platform folder. Five of those view models landed in a directory this branch had already moved, so they join the rest in DodoSSH.Client.Shell: git spotted the rename and put them there, and the namespaces followed. Shell picks up ObjectStore and Import as a result, which the Android head then gets transitively and will use neither of at first — scoped storage means there is no ~/.ssh/config to import, and file transfer is out of its first scope. Desktop suites green at 155 and 64.
This commit is contained in:
@@ -15,7 +15,8 @@
|
||||
Focusable="True">
|
||||
|
||||
<!--
|
||||
The shell window: a titlebar it draws itself, a nav rail, one screen at a time, and a status bar.
|
||||
The shell window: a titlebar it draws itself, a nav rail, a tab strip, one surface at a time, and a
|
||||
status bar.
|
||||
|
||||
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
|
||||
@@ -28,6 +29,13 @@
|
||||
removes the caption and keeps the resize border and the drop shadow, which is the half of the system
|
||||
chrome worth having.
|
||||
|
||||
TWO SURFACES, ONE RECTANGLE.
|
||||
|
||||
The tab strip is above everything the nav rail leads to, so a terminal opened from any screen stays
|
||||
visible and reachable from every other one. What that costs is that the terminal and the pages now share
|
||||
the area beneath the strip, and exactly one of them may occupy it. That is the whole of ShellSurface: an
|
||||
enum rather than two flags, so there is no way to write the state where both are showing.
|
||||
|
||||
THE OCCLUSION RULE, which every arrangement in this file obeys.
|
||||
|
||||
NativeWebView hosts a real Win32 child window through NativeControlHost, and a child window composites
|
||||
@@ -36,15 +44,16 @@
|
||||
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
|
||||
IsTerminalShowing is the one place that decision is made: a locked vault, the page area, 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.
|
||||
Note where IsShowingPages is bound: on the one Panel that holds every screen, not on each screen. That
|
||||
is what makes the rule hard to break rather than merely documented — a sixth screen added inside that
|
||||
Panel cannot forget to collapse, because it is not the thing doing the collapsing. Its own IsVisible
|
||||
only chooses between the pages.
|
||||
|
||||
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
|
||||
@@ -64,167 +73,111 @@
|
||||
|
||||
<views:NavRail Grid.Column="0" />
|
||||
|
||||
<Panel Grid.Column="1">
|
||||
<!--
|
||||
The rail is full height and the strip is not, so the strip spans exactly the area it navigates.
|
||||
The other arrangement — strip above rail — would put a row of tabs over a column of destinations
|
||||
they have nothing to do with.
|
||||
-->
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,*">
|
||||
|
||||
<!-- ============ HOSTS + TERMINAL ============ -->
|
||||
<Grid ColumnDefinitions="268,*" IsVisible="{Binding IsHostsScreen}">
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
|
||||
<views:HostSidebar Grid.Column="0" x:Name="Hosts" DataContext="{Binding Vault}" />
|
||||
<Panel Grid.Row="1">
|
||||
|
||||
|
||||
<Grid Grid.Column="1" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<views:TerminalTabs Grid.Row="0" />
|
||||
<!-- ============ THE PAGES ============ -->
|
||||
<Panel IsVisible="{Binding IsShowingPages}">
|
||||
|
||||
<!--
|
||||
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.
|
||||
Bound directly rather than wrapped, unlike the two below it: this screen's data context is
|
||||
the shell's, so IsHostsScreen resolves. It hands the vault to the sidebar from inside its
|
||||
own markup.
|
||||
-->
|
||||
<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>
|
||||
<views:HostsScreen x:Name="HostsPane" IsVisible="{Binding IsHostsScreen}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
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.
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and
|
||||
putting both on one element resolves IsVisible against that view model, where
|
||||
IsTransfersScreen does not exist.
|
||||
-->
|
||||
<NativeWebView Grid.Row="3" x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
</Grid>
|
||||
<!-- ============ KEYCHAIN ============ -->
|
||||
<!--
|
||||
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>
|
||||
|
||||
<!-- ============ HOST KEYS ============ -->
|
||||
<!--
|
||||
Wrapped, like the two above and for the same reason: its data context is the screen's own
|
||||
view model, where IsKnownHostsScreen does not exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsKnownHostsScreen}">
|
||||
<views:KnownHostsScreen x:Name="PinsPane" DataContext="{Binding KnownHostsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ SNIPPETS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsSnippetsScreen}">
|
||||
<views:SnippetsScreen x:Name="SnippetsPane" DataContext="{Binding SnippetsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ LOGS ============ -->
|
||||
<!-- Wrapped, like the others whose data context is their own view model. -->
|
||||
<Panel IsVisible="{Binding IsLogsScreen}">
|
||||
<views:LogsScreen x:Name="LogsPane" DataContext="{Binding LogsScreen}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ TEAM ============ -->
|
||||
<!--
|
||||
Wrapped, for the reason the vault and transfers screens are: the visibility is the shell's
|
||||
business and the data context is the teams view model, and both on one element would resolve
|
||||
IsTeamScreen against a type that does not have it.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTeamScreen}">
|
||||
<views:TeamsScreen DataContext="{Binding Teams}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ PREFERENCES ============ -->
|
||||
<views:PreferencesScreen IsVisible="{Binding IsPreferencesScreen}" />
|
||||
|
||||
<!-- ============ IMPORT ============ -->
|
||||
<!--
|
||||
Reached from preferences rather than from the rail; see ShellScreen.Import. Wrapped, like
|
||||
the others whose data context is their own view model.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsImportScreen}">
|
||||
<views:ImportScreen x:Name="ImportPane" DataContext="{Binding ImportScreen}" />
|
||||
</Panel>
|
||||
|
||||
</Panel>
|
||||
|
||||
<!--
|
||||
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.
|
||||
|
||||
A sibling of the page area rather than a child of any screen, which is the structural half of
|
||||
the tab rework: the terminal belongs to the window now, not to the hosts screen.
|
||||
|
||||
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 x:Name="Terminal"
|
||||
IsVisible="{Binding IsTerminalShowing, FallbackValue=False}" />
|
||||
|
||||
<!-- ============ FILES ============ -->
|
||||
<!--
|
||||
Wrapped rather than bound directly, for the same reason the vault screen is: this element's
|
||||
visibility is the shell's business and its data context is the transfers view model, and putting
|
||||
both on one element resolves IsVisible against that view model, where IsTransfersScreen does not
|
||||
exist.
|
||||
-->
|
||||
<Panel IsVisible="{Binding IsTransfersScreen}">
|
||||
<views:TransfersScreen DataContext="{Binding Transfers}" />
|
||||
</Panel>
|
||||
|
||||
<!-- ============ 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>
|
||||
</Grid>
|
||||
|
||||
<!--
|
||||
@@ -262,12 +215,12 @@
|
||||
|
||||
<Border Classes="card" IsVisible="{Binding IsNeedingEnrollment}">
|
||||
<StackPanel Spacing="12">
|
||||
<TextBlock Classes="heading" Text="Choose a vault passphrase" />
|
||||
<TextBlock Classes="heading" Text="Choose a keychain 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." />
|
||||
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 keychain." />
|
||||
<TextBox Text="{Binding Passphrase}" PlaceholderText="passphrase" PasswordChar="•" />
|
||||
<TextBox Text="{Binding ConfirmPassphrase}" PlaceholderText="again" PasswordChar="•" />
|
||||
<Button Classes="accent" Content="CREATE MY VAULT" Command="{Binding EnrollCommand}"
|
||||
<Button Classes="accent" Content="CREATE MY KEYCHAIN" Command="{Binding EnrollCommand}"
|
||||
IsEnabled="{Binding !IsBusy}" HorizontalAlignment="Left" />
|
||||
<TextBlock Classes="hint" Text="{Binding StatusMessage}" />
|
||||
</StackPanel>
|
||||
@@ -275,14 +228,14 @@
|
||||
|
||||
<!--
|
||||
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.
|
||||
together with the passphrase means the keychain 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." />
|
||||
Text="It is shown once and is not stored anywhere. Without it, forgetting your passphrase means losing the keychain: 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}"
|
||||
|
||||
Reference in New Issue
Block a user