Merge branch 'main' into the desktop updater, and give way on two numbers

Main landed a realtime push feature while this branch was building the updater,
and the two collided in three places. Every one of them resolves the same way:
main got there first, so this branch moves.

**Two ADRs were both numbered 0012.** Main's is realtime push; this one is now
[ADR 0013](docs/adr/0013-desktop-distribution-and-updates.md). Git did not call
this a conflict — the filenames differ — so it would have merged quietly and left
the directory with two 0012s and every cross-reference ambiguous. Renumbered here
along with the nine places that point at it.

**Two manual-check phases were both numbered 15**, and that one git did catch.
Main's "Changes that arrive without a timer" keeps 15; installing and updating
the desktop client becomes Phase 16, with its checks and every reference to them
renumbered. The file's own rule is that a number is for life, which is exactly
why the one that had not been pushed is the one that gives way.

**The merge rewrote several files with CRLF**, and `.editorconfig` asks for LF on
everything except `*.ps1`. That is not cosmetic here: IDE0055 is an error and
`EnforceCodeStyleInBuild` is on, so it failed the build on three lines of
App.axaml.cs whose only change in this branch was an ADR number in a comment.
Forty-six files normalised back to LF; the release script keeps CRLF, which is
what `.gitattributes` and `.editorconfig` both already say for a PowerShell file.

Nothing else conflicted. The updater does not touch the sync loop or the event
stream, and the one file both sides edited heavily — MainWindowViewModel — merged
without a hunk in common.

Verified after merging: the solution restores locked and builds clean, and 304
shell, 100 layout, 54 session, 28 client-api and 25 contracts tests pass. The
first two counts are higher than before the merge because main's own tests came
with it and pass alongside these.
This commit is contained in:
2026-08-04 17:52:57 +02:00
52 changed files with 6205 additions and 341 deletions
@@ -41,6 +41,21 @@
Text="{Binding PendingDeletion.Usage}" />
</Border>
<!--
The second question, and only a group's deletion has one: whether the machines filed under it go with
it. See VaultViewModel.DeleteGroup for why it is asked rather than stated, and DeletionTakesTheHostsToo
for why it is a tick rather than a pair of options — the two answers are not equally weighted, and the
recoverable one is the one that needs no decision.
Below the usage box on purpose: that box is the count this is about, and a tick offered above the
number it applies to is a tick somebody answers before reading how many.
-->
<CheckBox IsVisible="{Binding PendingDeletion.HasChoice, FallbackValue=False}"
IsChecked="{Binding DeletionTakesTheHostsToo}">
<TextBlock Foreground="{StaticResource WarnText}" FontSize="12" TextWrapping="Wrap"
Text="{Binding PendingDeletion.Choice}" />
</CheckBox>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="danger" Content="DELETE" Command="{Binding ConfirmDeleteCommand}"
IsEnabled="{Binding !IsBusy}" />
+95 -23
View File
@@ -55,10 +55,11 @@
renderer. They are listed in docs/design-import-gaps.md with what ships instead, and none of them is
drawn disabled.
The design's fifth missing control was the vault picker's chevron, and half of it now exists: a host
being *created* is asked which vault it goes into, in the editor below. What still does not exist is
the other half — moving an existing host — because the two vaults are encrypted under different keys,
so that is a delete and a retype rather than an edit.
The design's fifth missing control was the vault picker's chevron, and both halves of what it stood for
now exist without it: a host being *created* is asked which vault it goes into, in the editor below, and
an existing one is moved from the pane's ⋯ menu. The chevron itself stays undrawn, because a chevron on
a subtitle implies an edit and this is not one — the two vaults are encrypted under different keys, so a
move is a re-seal into one and a tombstone in the other, and the host takes a new id.
-->
<Border Width="304" Background="{StaticResource Sidebar}"
@@ -71,11 +72,12 @@
One row for all three panels, which is why what it says is on the view model rather than repeated
three times here. See VaultViewModel.DrawerTitle.
The subtitle is the vault this host is filed in, and the design's chevron beside it is not drawn:
an item cannot be moved between vaults — the two are encrypted under different keys, so moving one
is a delete and a retype — and a picker offering the move would be offering something no layer below
this can do. Choosing the vault at the moment a host is created is a different question and does
have an answer; it is in the editor, beside the name.
The subtitle is the vault this host is filed in, and the design's chevron beside it is still not
drawn although a host can now be moved. The two are encrypted under different keys, so a move is a
re-seal into one vault and a tombstone in the other — it leaves the host's group and tags behind and
gives it a new id, none of which a chevron on a subtitle would lead anybody to expect. It is in the
menu instead, next to the two other things that happen to a whole host. Choosing the vault at the
moment a host is created is a different question, and it is in the editor beside the name.
-->
<Border Grid.Row="0" Padding="14,10" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,0,0,1">
@@ -91,10 +93,15 @@
</StackPanel>
<!--
The host's own two actions, behind a menu rather than as a row of buttons under the pane. They
are what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and
the other two are things you go looking for. It hides with the deletion question for the reason
the buttons did — see VaultViewModel.ShowsHostPaneActions.
The host's own actions, behind a menu rather than as a row of buttons under the pane. They are
what EDIT and DELETE were; a pane whose footer is CONNECT has one action worth a button, and the
rest are things you go looking for. It hides with the deletion question and with the move panel
for the reason the buttons did — see VaultViewModel.ShowsHostPaneActions.
Moving is here rather than in the editor, and the separator says which side of the line it is
on: it is not a field of the host. The two vaults are encrypted under different keys, so it is a
re-seal into one and a tombstone in the other — nothing a SAVE could do — and a picker inside
the form would let somebody correcting a port move a machine by leaving it where they found it.
-->
<Button Grid.Column="1" Classes="flat paneicon" Content="⋯"
IsVisible="{Binding ShowsHostPaneActions}"
@@ -102,6 +109,7 @@
<Button.Flyout>
<MenuFlyout>
<MenuItem Header="Edit…" Command="{Binding EditSelectedHostCommand}" />
<MenuItem Header="Move to another vault…" Command="{Binding MoveHostCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteHostCommand}" />
</MenuFlyout>
@@ -365,12 +373,16 @@
<TextBox Text="{Binding EditorLabel}" PlaceholderText="name" />
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it is the one decision on this
form that cannot be changed afterwards: the vaults are encrypted under different keys, so
moving an item between them is a delete and a retype. It is a field of the host rather
than the keychain screen's standing "new items go to" preference, and it is a separate
selection from it — moving this one does not move that one, and a click over there cannot
move a host half-typed here.
◆ WHICH VAULT THIS HOST WILL LIVE IN, asked here because it decides who can read it and
because it is the one thing on this form that no later SAVE can change: the vaults are
encrypted under different keys, so changing it is a re-seal into one and a tombstone in
the other. That is offered — "Move to another vault…" in the pane's own menu — and it is
deliberately not this control, because a picker inside the form would move a machine as a
side effect of correcting a port.
It is a field of the host rather than the keychain screen's standing "new items go to"
preference, and it is a separate selection from it — moving this one does not move that
one, and a click over there cannot move a host half-typed here.
Shown only while adding, and only where there is more than one vault that can be written
to. An existing host's row is not drawn at all rather than drawn disabled; the drawer's
@@ -390,7 +402,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A host in a shared vault is readable by everybody holding that vault's key, and it cannot be moved out afterwards." />
Text="A host in a shared vault is readable by everybody holding that vault's key. It can be moved out later, from this pane's own menu — what it cannot do is become unreadable to somebody who has already synced it." />
</StackPanel>
<!--
@@ -554,6 +566,36 @@
<TextBox Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<!--
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker
above: asked while adding, hidden where there is only one vault to write to, and never
offered for an existing group. Not because the group is stuck — the card's own menu takes
it to another vault, with everything on the shelf — but because moving it is a
re-seal of every item involved into new ids, which is nothing a SAVE on this form could
do, and a picker here would do it as a side effect of correcting a default port.
A group in a shared vault is what gives a team an arrangement rather than a heap: the
people holding that vault's key see the folder, and the hosts inside it inherit its port,
its username and its key.
The parent picker below follows it, for the reason the host's group picker follows the
host's vault — a parent in another vault would be a level half the readers cannot resolve.
See VaultViewModel.ShowsGroupEditorVaultChoice.
-->
<StackPanel Spacing="4" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
SelectedItem="{Binding GroupEditorSelectedVault}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="11" TextWrapping="Wrap"
Text="A group in a shared vault is visible to everybody holding that vault's key, and only hosts in the same vault can be filed under it." />
</StackPanel>
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
HorizontalAlignment="Stretch">
@@ -602,13 +644,13 @@
<!-- ============ THE FOOTER ============ -->
<!--
One row, and exactly one of its four contents is showing — the same by-construction exclusivity the
One row, and exactly one of its five contents is showing — the same by-construction exclusivity the
panels above have, from the same flags. It is what each panel is for: connecting, saving a host,
saving a group, or answering the question about deleting one.
saving a group, answering the question about deleting one, or choosing where to move one.
◆ THE QUESTION TAKES CONNECT'S PLACE rather than stacking under it, as it always did with the row of
buttons this footer replaced, so that DELETE cannot be pressed again while its own question is on
screen. See VaultViewModel.ShowsHostPaneActions.
screen. See VaultViewModel.ShowsHostPaneActions. The move panel takes it for the same reason.
-->
<Border Grid.Row="2" Padding="12" Background="{StaticResource Panel}"
BorderBrush="{StaticResource Border}" BorderThickness="0,1,0,0">
@@ -624,6 +666,36 @@
<views:ConfirmDeleteCard />
</Border>
<!--
◆ MOVING THE HOST TO ANOTHER VAULT. A picker and two buttons, not a question with a yes: what
is being asked is which vault, and a move is undone by moving it back rather than by being
careful — so this is not drawn in the danger colours the deletion question uses.
The sentence under it is the part worth keeping. A group and a tag are items of the vault the
host is leaving, so neither can come; saying so here rather than only in the status line
afterwards is the difference between a warning and a surprise. What it deliberately does not
promise is anything about the key or password the host authenticates with — those resolve
across vaults, they are kept, and the status line names one that is left outside.
-->
<StackPanel Spacing="8" IsVisible="{Binding IsMovingHost}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The host is re-encrypted with the other vault's key, so everybody who holds that key can read it and nobody else can. Its group and tags stay behind — both belong to the vault it is leaving." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveHostCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveHostCommand}" />
</StackPanel>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="6" IsVisible="{Binding IsEditing}">
<Button Classes="accent" Content="SAVE" Command="{Binding SaveHostCommand}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelEditCommand}" />
+99 -21
View File
@@ -226,31 +226,59 @@
</ItemsControl.ItemTemplate>
</ItemsControl>
<Grid ColumnDefinitions="Auto,*,Auto">
<TextBlock Grid.Column="0" Classes="label" Text="GROUPS"
Foreground="{StaticResource TextDim}" VerticalAlignment="Center" />
<!--
The group's own two actions, beside the group cards rather than in the toolbar, because they
act on the card that is selected — and this is where the selection is made. Hidden rather
than disabled while DELETE's question is up, as every other pair in this application is, so
it cannot be pressed twice, and hidden again when there is nothing for them to act on. What
that is, with no card selected, is the group the trail above ends with; see
VaultViewModel.GroupTarget.
-->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="EDIT" Command="{Binding EditGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
<Button Classes="ghost" Content="DELETE" Command="{Binding DeleteGroupCommand}"
IsVisible="{Binding ShowsGroupActions}" />
</StackPanel>
</Grid>
<!--
The heading and nothing else. This used to be a row with EDIT and DELETE at the far end of it,
and the card's own menu is where those live now — see the note on it below. They were a second
control for the job that menu already does, and the harder of the two to read: a button beside
a heading has to say which card it means, and with none selected it meant the group the trail
ends with rather than anything on screen. Moving a group is on that menu and never had a button
here, for the same reason and without the intervening mistake.
-->
<TextBlock Classes="label" Text="GROUPS" Foreground="{StaticResource TextDim}" />
<Border Padding="10" Background="{StaticResource DangerWash}" CornerRadius="6"
IsVisible="{Binding IsConfirmingGroupDeletion}">
<views:ConfirmDeleteCard />
</Border>
<!--
◆ MOVING THE GROUP TO ANOTHER VAULT. The host drawer's move panel, one level up, and under
the heading rather than in the drawer because a group has no drawer of its own. A picker and
two buttons rather than a question with a yes: what is being asked is which vault, and a move
is undone by moving it back — so it is not drawn in the danger colours the deletion question
above it uses. It sits beside that question and never with it: MoveGroup disarms a pending
deletion and DeleteGroup folds this away, so the section draws at most one of the two.
The sentence is what the panel is for. A group cannot go anywhere alone: the machines under it
are items of the vault it is leaving, and so are the groups nested inside it, so all of them
are re-sealed under the destination's key and all of them take new ids. What cannot come is
the group it is nested under, which is why it arrives at the top level. Saying that here
rather than only in the status line afterwards is the difference between a warning and a
surprise.
-->
<Border Padding="10" Background="{StaticResource Panel}" CornerRadius="6"
BorderBrush="{StaticResource Border}" BorderThickness="1"
IsVisible="{Binding IsMovingGroup}">
<StackPanel Spacing="8">
<TextBlock Classes="label" Text="MOVE GROUP TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" ItemsSource="{Binding MoveGroupVaultChoices}"
SelectedItem="{Binding SelectedMoveGroupVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Text="{Binding Display}" FontSize="12" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="hint" FontSize="10.5" TextWrapping="Wrap"
Text="The group goes with the groups inside it and every host filed under them, all re-encrypted with the other vault's key. Any group it is nested under stays behind, so it arrives at the top level, and the hosts' tags stay behind with it." />
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="accent" Content="MOVE" Command="{Binding ConfirmMoveGroupCommand}"
IsEnabled="{Binding !IsBusy}" />
<Button Classes="ghost" Content="CANCEL" Command="{Binding CancelMoveGroupCommand}" />
</StackPanel>
</StackPanel>
</Border>
<!--
A ListBox rather than an ItemsControl of buttons, and that is what marks the chosen group for
free: selection is a state the control already has, and the tile style draws it the same way
@@ -262,10 +290,15 @@
opens a shell on a host. One click used to mean both, and a card was then the only place a
group could be named while also being the control that threw the rest of the grid away.
◆ ONE SELECTION, TWO LISTS. Selecting a card here takes the mark off the host grid below, and
selecting a host takes it off this one. Two controls each keep their own SelectedItem, so the
vault is what joins them — see VaultViewModel.OnSelectedHostChanged. Without it both grids
could be lit at once, which is two chosen things under two pairs of buttons.
◆ THIS IS ONE LEVEL, NOT EVERY GROUP. It binds VisibleGroups: what is inside the group the
trail above ends with, or the outermost groups when it ends at ALL HOSTS. Folded away entirely
at a group with nothing inside it, which is an ordinary thing to open — the trail and the two
buttons stay, because leaving it is a gesture and editing it is a button.
at a group with nothing inside it, which is an ordinary thing to open — the trail stays, and
it is also the way back to the level where that group has a card of its own to be edited from.
◆ THESE CARDS ARE THE DROP TARGET. A host card dragged onto one is filed under that group, and
that is the whole of what a drag does on this screen. It used to be a heading inside the host
@@ -282,6 +315,40 @@
<ListBox.ItemsPanel>
<ItemsPanelTemplate><WrapPanel /></ItemsPanelTemplate>
</ListBox.ItemsPanel>
<!--
The three things you can do to a group, on the group itself — the same menu the host grid
below has, for the same reasons. On the list rather than in the item template, because the
commands are the vault's and a menu inside a DataTemplate would have the row for its data
context; the code-behind selects whatever was right-clicked before the menu opens, and
cancels it outright over the space around the cards.
Open is here as well as on the double-click, which is not a duplicate for its own sake: a
gesture is unreachable without a pointer. Edit and Delete are here and nowhere else — they
were also a pair of buttons beside the heading above, which acted on GroupTarget and so with
no card selected meant the group the trail ends with rather than any card on screen. All
three act on the card that was right-clicked, which is the whole of what this menu is for.
Only Open takes a parameter, because OpenGroupCommand's null means ALL HOSTS rather than
nothing; the other three read GroupTarget, which the selection the code-behind has just made
is the first half of.
Moving is above the separator that Delete sits below, exactly as it is in the host pane's
menu, and for the same reason: it is not a field of the group and it is not a deletion
either. The two vaults are encrypted under different keys, so it is a re-seal of the whole
shelf into one and a tombstone per item in the other.
-->
<ListBox.ContextMenu>
<ContextMenu>
<MenuItem Header="Open" Command="{Binding OpenGroupCommand}"
CommandParameter="{Binding SelectedGroup}" />
<MenuItem Header="Edit…" Command="{Binding EditGroupCommand}" />
<MenuItem Header="Move to another vault…" Command="{Binding MoveGroupCommand}" />
<Separator />
<MenuItem Header="Delete…" Command="{Binding DeleteGroupCommand}" />
</ContextMenu>
</ListBox.ContextMenu>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:HostGroupRowViewModel">
<Border Classes="tile">
@@ -304,6 +371,17 @@
</Grid>
<TextBlock Text="{Binding Description}" FontSize="11"
Foreground="{StaticResource TextFaint}" />
<!--
Which vault this group is in, on the same rule and in the same place as the host
card's: only where there is more than one vault to be in. It matters more on a
folder than on a machine — two vaults may each hold a "production", and without
this the cards are two identical folders side by side, one of which a colleague
can read. It is also what says a group is shared at all.
-->
<TextBlock Classes="mono" Text="{Binding VaultBadge}" FontSize="10"
Foreground="{StaticResource TextFaint}"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding HasVaultBadge}" />
</StackPanel>
</Grid>
</Border>
@@ -96,6 +96,11 @@ internal sealed partial class HostsScreen : UserControl
HostGrid.AddHandler(PointerPressedEvent, OnPointerPressed, RoutingStrategies.Tunnel);
HostGrid.AddHandler(ContextRequestedEvent, OnContextRequested, RoutingStrategies.Tunnel);
// And the group cards answer a right click the same way, for the same reason: their menu's commands
// read the vault's group selection, and without this they would act on whichever card was selected
// before — or, with none, on the group the trail ends with, which is not on screen at all.
GroupGrid.AddHandler(ContextRequestedEvent, OnGroupContextRequested, RoutingStrategies.Tunnel);
HostGrid.PointerMoved += OnPointerMoved;
HostGrid.PointerReleased += OnPointerReleased;
HostGrid.PointerCaptureLost += OnPointerCaptureLost;
@@ -204,6 +209,33 @@ internal sealed partial class HostsScreen : UserControl
vault.SelectedSidebarRow = row;
}
/// <summary>
/// Points the group menu at whatever was right-clicked.
/// </summary>
/// <remarks>
/// <para>
/// The host grid's rule, applied to the cards above it — see <see cref="OnContextRequested"/>. What is
/// different is what an unaimed menu would have done: <c>GroupTarget</c> falls back to the open group
/// when no card is selected, so Edit and Delete over a card would have been offered about the group whose
/// contents are showing rather than the one the pointer is on. This menu is the only way to either of
/// them now, so aiming it is the whole of aiming them.
/// </para>
/// <para>
/// Cancelled outright over the space around the cards, as the host grid's is. That is not a group, and
/// the fallback is exactly what would make the menu look like it worked there.
/// </para>
/// </remarks>
private void OnGroupContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (Vault is not { } vault || RowUnder(e.Source) is not HostGroupRowViewModel row)
{
e.Handled = true;
return;
}
vault.SelectedGroup = row;
}
/// <remarks>
/// Remembered rather than acted on. Whether this press is a click or the start of a drag is not known
/// until the pointer moves, so this is the point at which both are still possible.