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:
2026-07-31 21:03:22 +02:00
199 changed files with 31294 additions and 775 deletions
+150 -36
View File
@@ -2,25 +2,30 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
xmlns:ssh="using:DodoSSH.Client.Ssh"
x:Class="DodoSSH.Client.App.Views.VaultScreen"
x:DataType="vm:VaultViewModel">
<!--
Everything in the vault that is not a host: the keys, the stored passwords, and the host keys this user
has approved.
The keychain: the SSH keys and the stored passwords. Things a person creates and edits.
Three columns, as the design has them — a category rail, one table, and a detail pane. The table has one
shape for every kind, which is what makes the ALL category possible and is why the row projection
exists; see VaultItemRowViewModel.
Two of the design's five categories are not here. IDENTITIES and CERTIFICATES have no item type behind
them — the vault holds exactly four kinds and two of those are hosts and pins — so listing them would be
two headings that could never have anything under them. HOST KEYS is the other way round: a real,
fully-backed category the design has no slot for. Both are recorded in docs/design-import-gaps.md.
HOST KEYS was a fourth category here and is now a screen of its own; see KnownHostsScreen. It never fit:
the two categories left are things somebody made on purpose, and a pin is a decision recorded at the
moment of connecting — nobody goes looking for one in a list of credentials. It also has a workflow the
shared table could not serve, which is comparing an untruncated fingerprint against a published one.
The SCOPES rail below the categories is the vault list, which is real and today has one entry in it. The
design shows three, two of them teams; team vaults exist as tables on the server and are refused by its
access service, so a rail with three entries would be showing two vaults nothing can open.
Two of the design's five categories are still not here. IDENTITIES and CERTIFICATES have no item type
behind them, so listing them would be two headings that could never have anything under them. Recorded
in docs/design-import-gaps.md.
The SCOPES rail below the categories is the keychain list. Since M3 it genuinely has more than one entry
when somebody is in a team — but it is still not a selector, because every table on this screen already
spans every keychain this session holds a key for and each row names its own. What it carries instead is
the one keychain question with an answer: where a new item is filed.
-->
<Grid ColumnDefinitions="176,*,244">
@@ -31,7 +36,7 @@
<ScrollViewer>
<StackPanel Margin="0,12">
<TextBlock Classes="label" Text="VAULT" Margin="14,0,14,8" />
<TextBlock Classes="label" Text="KEYCHAIN" Margin="14,0,14,8" />
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.All}"
@@ -66,13 +71,18 @@
</Grid>
</Button>
<!--
Buckets. A category here rather than a screen of its own, unlike the approved host keys: a bucket
is something somebody creates, edits and keeps a secret for, which is what the other two
categories are. A pin is a decision recorded at connect time and is not.
-->
<Button Classes="flat cat" Command="{Binding ShowSectionCommand}"
CommandParameter="{x:Static vm:VaultSection.KnownHosts}"
Classes.active="{Binding ShowsKnownHosts}">
CommandParameter="{x:Static vm:VaultSection.Buckets}"
Classes.active="{Binding ShowsBuckets}">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="rowmark catmark" />
<TextBlock Grid.Column="1" Text="HOST KEYS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding KnownHostPins.Count}"
<TextBlock Grid.Column="1" Text="BUCKETS" Margin="12,0,0,0" />
<TextBlock Grid.Column="2" Text="{Binding ObjectStores.Count}"
Foreground="{StaticResource TextFaint}" />
</Grid>
</Button>
@@ -82,18 +92,35 @@
<TextBlock Classes="label" Text="SCOPES" Margin="14,0,14,8" />
<!--
One entry per vault this session opened. Not a selector: every list on this screen reads the
active vault, and a rail that let you click a vault you cannot switch to would be a control that
does nothing. It is here because knowing which vault you are looking at is worth a line, and
because this is where a second one appears when shared vaults arrive.
Still not a selector. Every list on this screen now spans every vault this session holds a key
for, and each row names its own vault — so there is nothing to switch to. What the picker below
chooses is where a *new* item is filed, which is a different question and the only one that has
an answer worth asking for.
-->
<StackPanel Orientation="Horizontal" Margin="14,2" Spacing="7">
<Ellipse Width="6" Height="6" Fill="{StaticResource Accent}" VerticalAlignment="Center" />
<TextBlock Classes="mono" Text="{Binding HostsHeading}" FontSize="10"
Foreground="{StaticResource Text}" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Classes="hint" FontSize="9.5" Margin="14,6,14,0"
Text="One vault, because the server grants access to your own and refuses the rest. Sharing is a later milestone." />
<!--
Hidden at one vault, which is where most people stay. A control offering a single option is a
question with no answer.
-->
<StackPanel Margin="14,10,14,0" Spacing="4" IsVisible="{Binding HasVaultChoice}">
<TextBlock Classes="label" Text="NEW ITEMS GO TO" />
<ComboBox ItemsSource="{Binding TargetVaults}"
SelectedItem="{Binding SelectedTargetVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="11" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="An item filed into a team's vault is readable by everyone holding that vault's key. It defaults to your own and never moves on its own." />
</StackPanel>
<!--
Items that would not decrypt. Shown here rather than only in the status line because this is the
@@ -121,10 +148,18 @@
<TextBlock Grid.Column="1" Classes="mono" Text="{Binding SectionSummary}" FontSize="9.5"
Foreground="{StaticResource TextFaint}" Margin="10,0,0,0" VerticalAlignment="Center" />
<StackPanel Grid.Column="3" Orientation="Horizontal" Spacing="6">
<!--
Always offered. Every category left on this screen is one things can be added to — the one
that was not, HOST KEYS, is now its own screen, and a pin still cannot be typed in there
either. See KnownHostsScreen.
-->
<Button Classes="ghost" Content="GENERATE KEY" Command="{Binding NewGeneratedKeyCommand}"
ToolTip.Tip="Makes a new key pair here, so the private half never becomes a file on this disk." />
<Button Classes="ghost" Content="+ SSH KEY" Command="{Binding NewKeyCommand}"
IsVisible="{Binding CanAddToSection}" />
<Button Classes="accent" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}"
IsVisible="{Binding CanAddToSection}" />
ToolTip.Tip="Pastes in a key you already have." />
<Button Classes="ghost" Content="+ PASSWORD" Command="{Binding NewCredentialCommand}" />
<Button Classes="accent" Content="+ BUCKET" Command="{Binding NewObjectStoreCommand}"
ToolTip.Tip="An S3-compatible bucket, to browse beside a host on the Files screen." />
</StackPanel>
</Grid>
</Border>
@@ -218,7 +253,7 @@
empty rows, this says what is missing in one line.
-->
<TextBlock Classes="hint" FontSize="9.5" Margin="0,12,0,0"
Text="Vault items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
Text="Keychain items record no author, no timestamps and no sharing yet, so there is nothing more to show here." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,14,0,0"
IsVisible="{Binding ShowsItemActions}">
@@ -226,6 +261,17 @@
<Button Classes="danger" Content="DELETE" Command="{Binding DeleteSelectedItemCommand}" />
</StackPanel>
<!--
The public half only, and there is no button for the other one. Installing a key means pasting
this line into a host's authorized_keys; a private key on the clipboard is a private key in
every application on the machine.
-->
<Button Classes="ghost" Content="COPY PUBLIC KEY" Margin="0,6,0,0"
HorizontalAlignment="Left"
IsVisible="{Binding SelectedItemIsKey}"
Command="{Binding CopyPublicKeyCommand}"
ToolTip.Tip="Copies the authorized_keys line for this key, which is what a host needs to let it in." />
<!--
The question DELETE asks, in the place those two buttons were. Here rather than over the
screen, because this pane is where the item being deleted is described: the name, the kind and
@@ -238,19 +284,50 @@
<views:ConfirmDeleteCard />
</Border>
</StackPanel>
<!--
Making a key, as opposed to pasting one in. A step of its own and a short one: an algorithm, a
comment, and a button. What it produces lands in the editor below, unsaved — so there is still
exactly one thing on this screen that writes a key, and it is still SAVE.
-->
<StackPanel Spacing="6" IsVisible="{Binding IsGeneratingKey}">
<TextBlock Classes="label" Text="NEW SSH KEY" Margin="0,0,0,4" />
<StackPanel Orientation="Horizontal" Spacing="6">
<!--
Buttons and a command rather than a selector bound to the algorithm, which is the same
choice the category rail makes and for the same reason: a selector moves its own highlight
before anything can refuse, so it can end up showing a choice nobody made.
-->
<Button Classes="flat choice" Content="ED25519"
Classes.active="{Binding GeneratesEd25519}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Ed25519}"
ToolTip.Tip="What every current OpenSSH prefers. Small, fast, and generated instantly." />
<Button Classes="flat choice" Content="RSA 4096"
Classes.active="{Binding GeneratesRsa}"
Command="{Binding ChooseKeyAlgorithmCommand}"
CommandParameter="{x:Static ssh:SshKeyAlgorithm.Rsa4096}"
ToolTip.Tip="For servers too old to accept Ed25519. Larger, and a few seconds to generate." />
</StackPanel>
<TextBox Text="{Binding GenerateComment}" PlaceholderText="name — also the key's comment" />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap"
Text="This is what the key is called here and what is written into it, so the line on a host says where it came from." />
<!--
A pin has no editor and no Add, which is the one asymmetry on this screen and is deliberate:
a pin appears because somebody approved a fingerprint at the moment of connecting, which is
the one place it can be checked against what the operator published. What it does have is a
way out, because a changed host key is refused outright and a rebuilt server would otherwise
be unreachable for ever.
Said plainly rather than left to be discovered. Writing an encrypted openssh-key-v1 file needs
bcrypt_pbkdf, which .NET has no primitive for — and the defence it buys is one this product
already makes: a passphrase protects a key file on a disk, and this key is never on one.
-->
<StackPanel Spacing="6" Margin="0,14,0,0" IsVisible="{Binding SelectedItemIsPin}">
<TextBlock Classes="hint" FontSize="9.5"
Text="Approved when you first connected. A pin outlives the host it was approved for, so one that says no host uses it is a leftover rather than a warning." />
<Button Classes="danger" Content="FORGET THIS HOST KEY" HorizontalAlignment="Left"
Command="{Binding ForgetPinCommand}"
ToolTip.Tip="Withdraws every pinned key for this address, so the next connection asks you to check the fingerprint again. Takes effect immediately." />
<TextBlock Classes="hint" FontSize="9.5" TextWrapping="Wrap" Margin="0,4,0,0"
Text="The key file itself has no passphrase. Your keychain passphrase is what protects it, and it never reaches the server in a form it can read." />
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,8,0,0">
<Button Classes="accent" Content="GENERATE" Command="{Binding GenerateKeyCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelGenerateKeyCommand}" />
</StackPanel>
</StackPanel>
@@ -273,7 +350,7 @@
<TextBox Text="{Binding KeyEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a vault: on a disk the passphrase protects the key, and in here your vault passphrase protects both." />
Text="The key and its passphrase are encrypted here and never reach the server in a form it can read. Storing both together is the point of a keychain: on a disk the passphrase protects the key, and in here your keychain passphrase protects both." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveKeyCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelKeyEditCommand}" />
@@ -307,6 +384,43 @@
</StackPanel>
</StackPanel>
<!-- The bucket editor. -->
<StackPanel Spacing="6" IsVisible="{Binding IsEditingObjectStore}">
<TextBlock Classes="label" Text="BUCKET" Margin="0,0,0,4" />
<TextBox Text="{Binding BucketEditorLabel}" PlaceholderText="name" />
<TextBox Text="{Binding BucketEditorBucket}" PlaceholderText="bucket" />
<TextBox Text="{Binding BucketEditorAccessKeyId}" PlaceholderText="access key id" />
<!--
Masked, like a password and for the same reason: a secret access key is one. The access key id
beside it is an identifier and is shown, which is also why the two are separate boxes.
-->
<TextBox Text="{Binding BucketEditorSecretAccessKey}" PlaceholderText="secret access key"
PasswordChar="•" />
<TextBox Text="{Binding BucketEditorRegion}" PlaceholderText="region (e.g. eu-west-1)" />
<!--
Blank means Amazon, and then the region resolves the host. Anything else is a full URL, which
is what makes this work against a self-hosted service.
-->
<TextBox Text="{Binding BucketEditorEndpoint}"
PlaceholderText="endpoint (blank: Amazon S3)" />
<CheckBox IsChecked="{Binding BucketEditorUsePathStyle}"
Content="Address the bucket as a path" />
<!--
Said where the decision is made. Getting this wrong produces a DNS failure whose message
mentions neither buckets nor this setting, which is the worst kind of thing to leave to a guess.
-->
<TextBlock Classes="hint" FontSize="9.5"
Text="Off for Amazon S3. On for most self-hosted services — MinIO and Ceph have no wildcard DNS, so the bucket cannot be a subdomain." />
<TextBox Text="{Binding BucketEditorNotes}" PlaceholderText="notes" AcceptsReturn="True"
Height="44" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="9.5"
Text="Encrypted here, keys and endpoint alike, and never sent to the server in a form it can read. Pick this bucket on the Files screen to browse it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveObjectStoreCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelObjectStoreEditCommand}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
</Border>