Files
DodoSSH/src/DodoSSH.Client.App/Views/PreferencesScreen.axaml
T
jaap-jan 6728a0a597 Let the desktop client replace itself, and give the repository one version
Packaging for Windows, and the updater that only exists once something is
packaged. Velopack, win-x64, fed from the project's own forge — never from the
deployment a client signs in to, which is ADR 0011 rule 2 carried over
unchanged and is why the feed address is a constant in the code rather than a
setting. See docs/adr/0012-desktop-distribution-and-updates.md.

**Nothing is ever installed while somebody is using it.** A newer build is found
on a six-hourly pass, downloaded in the background, and then waits — for a
restart the user presses, or for the next launch they were going to do anyway.
That is a policy rather than caution: this application argues at length that
locking keeps shells running, because a lock that destroyed work would stop
being used, and a restart does not keep them. Having taught that, it owes the
user the choice at the one moment it stops being true, and the sentence saying
so counts the shells it would close.

**The version is now derived from the v* tag**, by MinVer, for everything. There
was no version before this — no property anywhere, so every assembly reported
the SDK's 1.0.0 and the API served that string as its serverVersion to every
client that asked. The tag was already the version of record for the container
image; this makes it the version of record full stop. MinVer's failure mode is
answering plausibly rather than failing, and here a wrong version is a client
that never updates, so it is guarded twice: fetch-depth 0 on every checkout, and
a step that fails a tag build when the tag and the computed version disagree.

**The pack id is DodoSSH.Desktop and not DodoSSH**, which is the one decision
here that would have destroyed data. Velopack installs to %LOCALAPPDATA%\<packId>
and removes that whole directory on uninstall, and %LOCALAPPDATA%\DodoSSH is
where ClientPaths keeps the encrypted cache, the outbox of changes not yet
pushed, and the device key. The obvious id would have had the uninstaller
silently delete work the server has never seen — the thing the application
refuses to do without a counted confirmation. Velopack's own advice to move user
data to roaming %APPDATA% is declined for the reason ClientPaths already gives.

**Releases are cut by a person, and CI gains no job that could.** The tempting
argument is that a forge write token is not a signing key. It does not survive
contact with what the token does: Velopack clients trust their feed and do not
verify a package signature when they apply one, so whoever can write a release
can ship an update every install runs. That is the capability ADR 0011 rule 1
puts on a machine which is not a runner, reached through a different door. The
mechanical objection — vpk needs Windows and the runners are Linux — is the
smaller of the two and is recorded beside it, because somebody will fix one and
believe they are done.

Unsigned for now, deliberately and with the cost stated where a user reads it:
SmartScreen warns once per person, on Setup.exe, because Mark-of-the-Web is
applied by the browser that downloaded it. In-app updates are fetched by the
application and applied from a local file, and never trip it.

The banner is a fourth row of the window rather than an overlay. Anything drawn
in the terminal's rectangle is sliced by the native child window that composites
above it — the defect this window has shipped once — and a sibling row is the
arrangement TitleBar and StatusBar already prove works.

----

Three defects surfaced on the way, none of them in the feature being built.

**A settings key absent from the file came back as the CLR default, not the
declared one.** The JSON source generator builds a record through a synthesised
parameterised constructor and assigns every property from its argument array, so
a property initializer runs and is then overwritten by a default for anything the
file did not contain. A settings.json of {} read back a font size of 0, clamped
up to the 8px floor rather than the 13px the renderer draws at. It could not bite
while there was one setting, because that setting was written on every save and
so was never absent; adding a second would have turned automatic update checks
off for every existing profile, silently, the opposite of the documented default.
Reflection-based deserialisation of the same JSON answers correctly, which is why
every way of checking it by hand agrees except the one that ships. The defaults
now live on the constructor parameters, which is the only place the generator
reads them from.

**Declaring a RuntimeIdentifier on the desktop head broke the server's image
build.** It is the obvious way to let a self-contained publish restore under
locked mode, and it writes a net10.0/win-x64 target into the lock file of every
project the head references transitively — including DodoSSH.Contracts and
DodoSSH.Crypto, which the API builds too. The Dockerfile restores those with no
RID and fails NU1004. Found by running docker build rather than by reading. The
RID stays out of the committed state; the two commands that need one ask for it
unlocked, and the release script puts the lock files back.

**A Docker ARG named VERSION silently sets MSBuild's Version.** An ARG is an
environment variable for the rest of the stage, MSBuild reads environment
variables as properties, and property names are case-insensitive. With the
workflow passing main-<short sha> on a main build the publish died with
NETSDK1018 pointing at DodoSSH.Contracts, a project nobody had touched. The build
stage's argument is ASSEMBLY_VERSION now, empty except on a tag build.

All three are in docs/platform-flags.md, which is where the next person will look.

----

Verified: the whole solution builds and restores locked; 289 shell, 93 layout and
54 session tests pass, including the regression test for the settings defect and
a measurement of the banner at the window's minimum width. vpk pack runs end to
end and reports "Verified VelopackApp.Run()" against Program.Main. The API image
builds correctly both as a main build and as a tag build, carrying 1.0.0 and
0.1.0 respectively.

Not verified, and it needs a published release to be: installing, updating and
uninstalling on a real machine. That is Phase 15 of docs/manual-checks.md, and
the pack id and the WebView2 profile fix are reasoned and commented but only
proved by walking it. Two things to watch at the first upload — the reverse
proxy's body-size limit for a 64 MB asset, and whether vpk upload gitea is happy
with Gitea 1.27.1.
2026-08-04 17:04:41 +02:00

276 lines
18 KiB
XML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:DodoSSH.Client.Shell.ViewModels"
xmlns:views="using:DodoSSH.Client.App.Views"
x:Class="DodoSSH.Client.App.Views.PreferencesScreen"
x:DataType="vm:MainWindowViewModel">
<!--
Preferences.
The design's rail has six sections and its TERMINAL panel has six settings. One of those six is now
real: text size. It needed all three of the things this comment used to record as missing — somewhere
to keep a preference (settings.json beside the cache, outside it deliberately, so it can be read on a
launch that never unlocks anything), a frame carrying a terminal option (TerminalServerOpcode.FontSize),
and a way for the page's own chords to reach the host that owns the value
(TerminalClientOpcode.FontSizeStep). The rule that kept it off this screen until then still stands and
is why the storage came first: a stepper that reset on every launch is worse than no stepper.
So this screen ships what is real, which is not nothing: this machine's device key is a genuine
preference with a genuine effect, and it is the one thing on the design's SECURITY panel that exists.
The two commands behind it were already in the shell; they were merely homeless, wedged into the old
account bar because there was nowhere else to put them.
Everything else is listed as absent rather than omitted, because a preferences screen that is silent
about the settings it has not got reads as a product with six preferences.
-->
<ScrollViewer>
<StackPanel MaxWidth="620" Margin="28,26" HorizontalAlignment="Left">
<TextBlock Classes="mono" Text="THIS MACHINE" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Unlock with Windows Hello" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Registers this machine so a later launch can open the keychain with a Windows confirmation instead of your passphrase. Your passphrase keeps working." />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="REGISTER"
Command="{Binding RegisterDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanRegisterDevice}" />
<!--
The withdrawal, in the place the offer was. Its own flag rather than the negation of that one: a
machine with no TPM and a machine that is already registered are both "cannot register", and only
the second has anything to take back.
-->
<Button Grid.Column="1" Classes="danger" Content="STOP UNLOCKING HERE"
Command="{Binding ForgetDeviceCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding CanForgetDevice}"
ToolTip.Tip="Withdraws this machine's device key, here and from your account, so it goes back to asking for your passphrase. Do this to a machine you have lost." />
</Grid>
<!-- Neither flag is set on a machine that cannot keep a key at all, and that is worth saying. -->
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This machine has nowhere to keep a device key, so the keychain will keep asking for your passphrase. That needs a TPM and a Windows keystore willing to release the key."
IsVisible="{Binding HasNoDeviceKeyOption}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="UPDATES" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Not on the design at all, unlike everything else here. It arrived with packaging: an installed
client can replace itself, and the moment that is true the question of where a replacement comes
from stops being theoretical. The answer is the security content of this section rather than a
footnote to it, which is why it is printed under the version instead of hidden in a tooltip.
-->
<TextBlock Classes="mono" Text="{Binding Updates.CurrentVersion}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<TextBlock Classes="hint" FontSize="11" Margin="0,4,0,0"
Text="Builds come from the project's own release page, and never from the server you sign in to. That is deliberate: whoever hands you the client can hand you a client that copies your passphrase, and the operator of a DodoSSH deployment is the party the trust model is about. A deployment may tell you where to get it. It is not where it comes from." />
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check for updates" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Asks the release page whether there is a newer build, and downloads it if there is. Nothing is ever installed while you are using it — a downloaded update waits for a restart you ask for, or for the next time you start DodoSSH." />
</StackPanel>
<Button x:Name="CheckNowButton" Grid.Column="1" Classes="ghost" Content="CHECK NOW"
Command="{Binding Updates.CheckNowCommand}"
IsEnabled="{Binding Updates.CanCheckNow}" />
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Check on its own" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Every six hours while DodoSSH is running, starting a couple of minutes after launch. It keeps checking while the keychain is locked, because where builds come from has nothing to do with your vault." />
</StackPanel>
<CheckBox x:Name="AutomaticUpdatesToggle" Grid.Column="1" VerticalAlignment="Top"
IsChecked="{Binding Updates.IsAutomatic}"
IsEnabled="{Binding Updates.IsSupported}" />
</Grid>
<!-- The only other ProgressBar in the application is the transfers one; same height, same brushes. -->
<ProgressBar Height="4" Minimum="0" Maximum="100" Margin="0,12,0,0"
Value="{Binding Updates.DownloadPercent}"
Foreground="{StaticResource Accent}" Background="{StaticResource Raised}"
IsVisible="{Binding Updates.IsDownloading}" />
<!--
The restart, with the sentence the banner only has room for in a tooltip. This screen scrolls, so
this is where the warning can be as long as it needs to be — and it needs to be, because this
application has spent a lot of words teaching that locking keeps shells running.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0" IsVisible="{Binding Updates.IsReady}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="{Binding Updates.ReadyHeadline}" Foreground="{StaticResource Text}"
FontSize="13" FontWeight="Medium" TextWrapping="Wrap" />
<TextBlock Classes="hint" FontSize="11" Text="{Binding Updates.RestartWarning}" />
</StackPanel>
<Button Grid.Column="1" Classes="accent" Content="RESTART NOW"
Command="{Binding Updates.RestartNowCommand}" />
</Grid>
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="{Binding Updates.Status}"
IsVisible="{Binding Updates.Status, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" />
<!--
The HasNoDeviceKeyOption precedent, one section up: a machine that gets none of the above is told
why rather than shown three controls that cannot do anything.
-->
<TextBlock Classes="hint" FontSize="11" Margin="0,8,0,0"
Text="This copy of DodoSSH cannot replace itself, so none of the above does anything. That is what a build run from a source checkout looks like, and also what a copy somebody unzipped by hand looks like — it is the installer that registers the update path."
IsVisible="{Binding Updates.IsUnsupported}" />
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="TERMINAL" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<!--
Here as well as on the chord, and not because the chord is in doubt. Ctrl+plus can only be heard
while a terminal has focus, since that is where the keyboard is being read — so somebody who has
not opened one yet, or who has just made the text too small to find anything in, has nowhere else
to look. This is that place, and it names the chord so the screen teaches it rather than replacing
it.
-->
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Text size" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="How large a terminal draws, in pixels. Ctrl+plus and Ctrl+minus do the same while a terminal has focus, and Ctrl+0 puts it back. It resizes the grid rather than magnifying it, so the remote is told how many columns it now has — which is also why it stops before the columns run out." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" VerticalAlignment="Top">
<Button Classes="ghost" Content="A" Command="{Binding ShrinkTerminalFontCommand}"
IsEnabled="{Binding CanShrinkTerminalFont}"
ToolTip.Tip="Smaller · Ctrl+minus" />
<TextBlock Classes="mono" FontSize="13" MinWidth="26" VerticalAlignment="Center"
TextAlignment="Center" Foreground="{StaticResource Text}"
Text="{Binding TerminalFontSize}" />
<Button Classes="ghost" Content="A+" Command="{Binding EnlargeTerminalFontCommand}"
IsEnabled="{Binding CanEnlargeTerminalFont}"
ToolTip.Tip="Larger · Ctrl+plus" />
<Button Classes="ghost" Content="RESET" Command="{Binding ResetTerminalFontCommand}"
ToolTip.Tip="Back to the size it ships at · Ctrl+0" />
</StackPanel>
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="KEYCHAIN" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Lock the keychain" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Closes the keychain and forgets every key it held. Shells you have open keep running and reappear when you unlock — locked describes the keychain, not this machine's access to your hosts." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="LOCK NOW" Command="{Binding LockCommand}" />
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Synchronise" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Runs a pass now. One runs on its own when the keychain opens, straight after any change, and every minute while it stays open — and a pass that finds this machine offline signs it back in from the session it remembered, so nothing here depends on being pressed." />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6">
<Button Classes="ghost" Content="SIGN IN" Command="{Binding SignInCommand}"
IsVisible="{Binding !IsOnline}"
ToolTip.Tip="Opens your browser. Only needed when there is no remembered session to resume — after signing out, or once your identity provider stops accepting the one this machine held." />
<Button Classes="ghost" Content="SYNC NOW" Command="{Binding Vault.SyncCommand}" />
</StackPanel>
</Grid>
<Grid ColumnDefinitions="*,Auto" Margin="0,14,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Import from ~/.ssh/config" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Reads this machine's OpenSSH configuration and offers what it finds. It shows you the list first and stores nothing until you say so, and it does not read any private key — where a key file is named, the path is recorded as a note." />
</StackPanel>
<Button Grid.Column="1" Classes="ghost" Content="IMPORT HOSTS"
Command="{Binding ShowScreenCommand}"
CommandParameter="{x:Static vm:ShellScreen.Import}" />
</Grid>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="ACCOUNT" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource Text}" />
<TextBlock Classes="mono" Text="{Binding AccountName}" FontSize="12" Margin="0,8,0,0"
Foreground="{StaticResource Info}" TextTrimming="CharacterEllipsis" />
<Grid ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,0,16,0">
<TextBlock Text="Sign out of this machine" Foreground="{StaticResource Text}" FontSize="13"
FontWeight="Medium" />
<TextBlock Classes="hint" FontSize="11"
Text="Deletes this machine's copy of the keychain and withdraws its device key, so it goes back to knowing nothing. The keychain stays on the server; signing in again brings it back. Use this to hand a machine on, or to enrol a different account." />
</StackPanel>
<!--
Hidden rather than disabled while the confirmation is up, because the card below carries the
button that actually does it and two sign-out buttons on one screen is one too many.
-->
<Button Grid.Column="1" Classes="danger" Content="SIGN OUT"
Command="{Binding SignOutCommand}"
IsEnabled="{Binding !IsBusy}"
IsVisible="{Binding !IsConfirmingSignOut}" />
</Grid>
<Border Background="{StaticResource Panel}" BorderBrush="{StaticResource Border}"
BorderThickness="1" CornerRadius="6" Padding="14" Margin="0,14,0,0"
IsVisible="{Binding IsConfirmingSignOut}">
<views:SignOutCard />
</Border>
<Border Height="1" Background="{StaticResource BorderSubtle}" Margin="0,20" />
<TextBlock Classes="mono" Text="NOT BUILT YET" FontSize="14" FontWeight="SemiBold"
LetterSpacing="1" Foreground="{StaticResource TextDim}" />
<TextBlock Classes="hint" FontSize="12" Margin="0,8,0,0"
Text="These are on the design and have nothing behind them. They are listed rather than left out, so that what this screen does not do is as legible as what it does. The full list, and what each would take, is in docs/design-import-gaps.md." />
<ItemsControl Margin="0,12,0,0">
<ItemsControl.Styles>
<Style Selector="TextBlock.gap">
<Setter Property="Foreground" Value="{StaticResource TextFaint}" />
<Setter Property="FontSize" Value="11" />
<Setter Property="TextWrapping" Value="Wrap" />
<Setter Property="Margin" Value="0,0,0,7" />
</Style>
</ItemsControl.Styles>
<TextBlock Classes="gap"
Text="Terminal font, size, cursor and scrollback — the renderer hard-codes them, and nothing carries a change to it." />
<TextBlock Classes="gap"
Text="A beta channel — there is one release channel, and a switch offering a second would be a preference with nothing behind it." />
<TextBlock Classes="gap"
Text="Auto-lock after idle — nothing tracks idleness, and the lock policy would have to decide what to do about a shell mid-job." />
<TextBlock Classes="gap"
Text="Per-use approval before a key signs — keys are handed to the SSH stack whole at connect time, so there is no per-signature moment to interrupt." />
<TextBlock Classes="gap"
Text="SSO and organisation policy — the server has endpoints for membership and none for policy, so there is nothing for this screen to show." />
<TextBlock Classes="gap"
Text="Keyboard shortcuts — the window binds one chord, and the terminal keeps the rest for the remote." />
</ItemsControl>
</StackPanel>
</ScrollViewer>
</UserControl>