Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s

Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
This commit is contained in:
2026-07-31 12:26:59 +02:00
42 changed files with 3708 additions and 155 deletions
+22 -48
View File
@@ -240,7 +240,13 @@
<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" />
<!--
Named because signing out lands here with the keyboard needing somewhere to go: the vault's
controls have just been collapsed, and Focus() on a collapsed control is a no-op that is
not replayed. See MainWindow.axaml.cs.
-->
<TextBox x:Name="ServerUrlBox" 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}" />
@@ -284,54 +290,22 @@
</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." />
<!--
The unlock card, and the sign-out confirmation that replaces it. Both live in their own files:
nothing inside this window can be laid out by a test — WebView2's adapter refuses the headless
session's thread — so markup that stays here is markup nobody can measure. See UnlockCard.axaml.
-->
<Border Classes="card" IsVisible="{Binding IsAskingForThePassphrase}">
<views:UnlockCard x:Name="UnlockPane" />
</Border>
<!--
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>
<!--
The confirmation, in place of the unlock card rather than under it: the card is already close
to the height the window guarantees at its minimum, and a screen a user cannot read all of is
worse than one that shows one question at a time.
-->
<Border Classes="card" IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
</Panel>