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
@@ -125,7 +125,21 @@
<DataTemplate DataType="vm:SidebarGroupHeader">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" Margin="8,12,8,5">
<TextBlock Grid.Column="0" Classes="detail" Text="{Binding Chevron}" VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Classes="section" Text="{Binding Label}" Margin="7,0,0,0" />
<!--
The name, and the vault it is in where this session holds more than one. The badge is what
makes one heading per group survive a shared vault: two vaults may each hold a "production",
and this list has no nesting to tell the two apart with. Drawn as the same outline tag a host
row wears, so "which vault" looks the same wherever it is answered.
-->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="7,0,0,0">
<TextBlock Classes="section" Text="{Binding Label}" TextTrimming="CharacterEllipsis" />
<Border Classes="tag outline" VerticalAlignment="Center"
IsVisible="{Binding HasVaultBadge}">
<TextBlock Text="{Binding VaultBadge}" />
</Border>
</StackPanel>
<TextBlock Grid.Column="2" Classes="detail" Text="{Binding Count}" FontSize="9"
VerticalAlignment="Center" />
@@ -262,10 +276,12 @@
<!--
◆ WHICH VAULT THIS HOST WILL LIVE IN. Drawn only while adding and only where there is more than
one vault that can be written to, exactly as on the desktop — an existing host's vault cannot
change, because the two are encrypted under different keys and moving an item is a delete and a
retype. Above GROUP rather than below it because it decides what GROUP can offer: a group is an
item in one vault, so choosing a vault refills that list with that vault's groups.
one vault that can be written to, exactly as on the desktop — an existing host's vault is not a
field of this form, because the two are encrypted under different keys and changing it is a
re-seal into one vault and a tombstone in the other. That is MOVE, beside EDIT under the host,
and it is separate so that it cannot happen as a side effect of saving something else. Above
GROUP rather than below it because it decides what GROUP can offer: a group is an item in one
vault, so choosing a vault refills that list with that vault's groups.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
@@ -279,7 +295,7 @@
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
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, with MOVE under the host — what it cannot do is become unreadable to somebody who has already synced it." />
</StackPanel>
<TextBlock Classes="label" Text="GROUP" Margin="0,4,0,0" />
@@ -367,6 +383,27 @@
<TextBox Classes="field" Text="{Binding GroupEditorLabel}" PlaceholderText="group name" />
<!--
◆ WHICH VAULT THIS GROUP WILL LIVE IN, on the same terms as the host editor's picker above and
for the same reason: it is what makes the group shared, and it cannot be changed afterwards.
Above INSIDE because it decides what INSIDE can offer — a parent belongs to one vault, and one
from another is a level half the readers cannot resolve.
-->
<StackPanel Spacing="6" IsVisible="{Binding ShowsGroupEditorVaultChoice}">
<TextBlock Classes="label" Text="VAULT" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding GroupEditorVaultChoices}"
SelectedItem="{Binding GroupEditorSelectedVault}"
HorizontalAlignment="Stretch" MinHeight="44">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
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>
<TextBlock Classes="label" Text="INSIDE" Margin="0,4,0,0" />
<ComboBox ItemsSource="{Binding GroupEditorParentChoices}"
SelectedItem="{Binding GroupEditorSelectedParent}"
@@ -432,45 +469,97 @@
BorderThickness="0,1,0,0" Padding="14,12">
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="CONNECT TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" />
</StackPanel>
<!--
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
The tick below it is the phone's whole answer to storing one, and on this head it is the only one:
the keychain lists credentials here but has no editor to create one in, so before this a password
typed on a phone could only ever be typed again. The host editor's picker could then bind it.
Everything about connecting, in one group so that the move panel below can take the bar rather
than appear underneath it. A password box and a CONNECT button under a form asking which vault to
move the host into would be two unrelated questions in one bar, and the taller of the two would
push the other off a phone screen.
-->
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
</TextBox.KeyBindings>
</TextBox>
<StackPanel Spacing="10" IsVisible="{Binding !IsMovingHost}">
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"
IsVisible="{Binding SelectedHostAsksForAPassword}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Remember this password for this host" />
</CheckBox>
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Classes="label" Text="CONNECT TO" />
<TextBlock Classes="mono" FontSize="11" Text="{Binding SelectedHost.Label}" />
</StackPanel>
<TextBlock Classes="detail" TextWrapping="Wrap" IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" />
<!--
Shown only for a host that actually asks for one. A password box beside a key-authenticated host
is an invitation to type a secret nothing will use.
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
The tick below it is the phone's whole answer to storing one, and on this head it is the only
one: the keychain lists credentials here but has no editor to create one in, so before this a
password typed on a phone could only ever be typed again. The host editor's picker could then
bind it.
-->
<TextBox Classes="field secret" IsVisible="{Binding SelectedHostAsksForAPassword}"
Text="{Binding ConnectPassword}" PlaceholderText="password">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding ConnectCommand}" />
</TextBox.KeyBindings>
</TextBox>
<CheckBox IsChecked="{Binding RemembersConnectPassword}" MinHeight="44"
IsVisible="{Binding SelectedHostAsksForAPassword}">
<TextBlock Classes="mono" FontSize="11.5" TextWrapping="Wrap"
Text="Remember this password for this host" />
</CheckBox>
<TextBlock Classes="detail" TextWrapping="Wrap"
IsVisible="{Binding !SelectedHostAsksForAPassword}"
Text="{Binding SelectedHostAuthenticationNote}" />
<Button Classes="primary" Content="CONNECT" Command="{Binding ConnectCommand}"
IsEnabled="{Binding !IsBusy}" />
</StackPanel>
<!--
Not asked for by the design, and here because a + that adds hosts with no way to correct one is a
strange thing to ship. It costs nothing: the editor above serves both, so this is the same panel
opened on an existing row.
MOVE is beside it rather than inside it, and that is the same line the desktop's menu draws: which
vault a host is in 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. It shows only
where there is somewhere to move to; see VaultViewModel.CanMoveSelectedHost.
-->
<Button Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditSelectedHostCommand}" />
<Grid ColumnDefinitions="*,8,*" IsVisible="{Binding !IsMovingHost}">
<Button Grid.Column="0" Classes="secondary" Height="44" Content="EDIT"
Command="{Binding EditSelectedHostCommand}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="MOVE"
IsVisible="{Binding CanMoveSelectedHost}"
Command="{Binding MoveHostCommand}" />
</Grid>
<!--
◆ MOVING THE HOST TO ANOTHER VAULT, in the place the connect controls were. 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.
The sentence is not decoration. A group and a tag are items of the vault the host is leaving, so
neither can come with it — and on a phone, where the status line afterwards is one line at the
bottom of a screen somebody has already navigated away from, saying it before the tap is the only
place it reliably gets read.
-->
<StackPanel Spacing="10" IsVisible="{Binding IsMovingHost}">
<TextBlock Classes="label" Text="MOVE TO VAULT" />
<ComboBox HorizontalAlignment="Stretch" MinHeight="44"
ItemsSource="{Binding MoveVaultChoices}"
SelectedItem="{Binding SelectedMoveVault}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:VaultChoiceViewModel">
<TextBlock Classes="mono" FontSize="12" Text="{Binding Display}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Classes="body"
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." />
<Grid ColumnDefinitions="*,8,*">
<Button Grid.Column="0" Classes="primary" Height="44" Content="MOVE"
Command="{Binding ConfirmMoveHostCommand}" IsEnabled="{Binding !IsBusy}" />
<Button Grid.Column="2" Classes="secondary" Height="44" Content="CANCEL"
Command="{Binding CancelMoveHostCommand}" />
</Grid>
</StackPanel>
</StackPanel>
</Border>