Files
DodoSSH/src/DodoSSH.Client.Session/ClientSettings.cs
T
jaap-jan ccaf7a8e72
ci / build and test (pull_request) Failing after 2m33s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Skipped
ci / android head (pull_request) Successful in 3m34s
Give the terminal back the width and the keyboard the session shell took
Seven things reported from a day's use of v5b's session shell, and they are one
commit because five of them are the same complaint from different angles: the
window spends too much of itself on chrome describing the session, and the parts
that are not chrome do not behave.

◆ THE HOST HEADER IS GONE, and that is a deliberate departure from the design.

Terminal.dc.html and SFTP.dc.html both draw a 60-pixel row above the pane: the
address on the left, a cross-surface button on the right. Both facts are worth
having and the strip they sat in was not — the tab already names the host, and a
full-width bar repeating it was the cheapest 60 pixels in the layout to give
back. The address is now the first line of the sidebar and the button is
stretched across the column under it, so nothing is lost and the pane is taller.

Which word that button carries and which command it runs used to be handed in
from the two usage sites in MainWindow.axaml, because the control was drawn
twice. One sidebar cannot do that, so SessionCrossSurfaceLabel and
OpenOtherSurfaceCommand resolve it in the shell — the same place SessionAddress
already decides which surface's fact to read. The two directions underneath are
untouched: SelectFilesHostAsync for a tab's host, OpenTerminalForFilesHostAsync
for a fresh terminal at whatever SFTP has open.

SessionHeader.axaml is deleted rather than left unused, and LayoutHarness stops
subtracting its 60 pixels from every session screen's budget — the same
treatment the retired window-wide tab strip got, and for the same reason: a
constant for chrome nobody draws is a suite quietly measuring the wrong
rectangle.

◆ AND THE SIDEBAR CLOSES, which the design has no state for at all.

300 pixels of an 1081-pixel minimum is a great deal to spend on a list that is
often two rows long. The column now folds to a 34-pixel rail carrying the
chevron that brings it back — a rail rather than nothing, because a panel that
vanishes leaving no trace is one people report as lost rather than as closed.
Both states live in the one control and swap on IsSessionSidebarOpen, so
MainWindow's own "Auto" column takes whichever width is showing without knowing
the state exists.

Written through to ClientSettings.SessionSidebarOpen rather than held for the
session. It is a decision about how much of the window a terminal gets, and one
that had to be made again on every launch would not really be on offer.

---- THE FOUR SMALLER ONES ----

A SNIP LANDED IN A TERMINAL NOBODY COULD TYPE AT, and looked selected when it
got there. Two causes with nothing in common. The click moved Win32 focus onto
the sidebar row, and term.focus() in the page cannot take it back — only the
host can, so the shell raises TerminalFocusRequested and the window answers with
the same posted focus every other path here uses. The highlight was bash: xterm
wraps a paste in bracketed-paste markers, readline marks what arrives inside
them as an active region, and it stays in reverse video until the next
keystroke. Right for a clipboard paste, wrong for a snippet picked off a
sidebar. Single-line snips are typed rather than pasted now, which needs no
markers; multi-line still pastes, because "runs three commands unasked" is the
worse of the two failures and the markers are the whole of what prevents it.

A BLACK BAR UNDER THE TERMINAL, on Windows. xterm.css paints its scrolling
viewport #000 — its own comment explains why, and it is a macOS scrollbar
concern. Everywhere else that black is covered by the rows, except along the
bottom: the fit addon floors the row count, so the remainder below the last
whole row is bare viewport, up to a line tall, against this page's #171a26. The
light square at its right-hand end is where WebView2's classic scrollbar corner
lands. The viewport is repainted in the page's own background, and the scrollbar
with it — thin and in these colours rather than a grey Windows channel down the
side of a near-black terminal, and kept rather than hidden, because a surface
that scrolls with no sign that it does is worse than a quiet bar.

THE PINS ROW DREW A TOFU BOX. U+E946 is not in the embedded Material Icons face
at all — that file is the 2019 build and its cmap skips E944 and E946 — so the
rail's Pins row and the hosts screen's own pin badge have both been drawing a
missing-glyph rectangle since v5b picked the codepoint. push_pin in that vintage
is U+F10D, verified against the file rather than against a codepoints table for
a later release of the font. Every other icon codepoint in the repository was
audited the same way; this was the only miss.

THE KBD CHIP CUT THE CHORD IN HALF. 34 pixels is the design's width for a chip
reading ⌘K, and this build substitutes CTRL K — six characters and a space,
wider than 34 at 10.5 mono. MinWidth and padding instead, so the design's
footprint survives for the day this face has a ⌘ to draw.

---- AND THE POPOVER UNDER THE USER CHIP ----

Reported as not matching the design, and it was not: Button.poprow set a corner
radius and a padding and never touched the Background, so every row wore the
Fluent theme's own #33FFFFFF button fill. Six raised pills stacked in a menu the
design draws as six lines of text — and the hover rule underneath was already
correct and simply invisible against a fill that never went away. Set on the
ContentPresenter as well as on the Button, the same as Button.flat, because the
theme binds its brush there and a Background set only on the control loses to
it. The panel itself gets this window's own radius-12 card treatment through a
FlyoutPresenter class rather than by widening the shared context-menu rule, and
Vaults and Preferences stop being drawn one step dimmer than Settings and
Logout, which read as two disabled entries in a menu of five live ones.

---- WHAT PROVES IT ----

Three tests in the layout suite, two of them checked against the defect they
describe: the popover row's resting fill (fails with #33ffffff without the
style), and the kbd chip against the natural width of its own text, measured on
a detached copy because a TextBlock's DesiredSize is already clipped to what it
was given and reports 34 inside a 34-pixel chip either way. SessionSidebarTests
is new — the sidebar has never been laid out by a test, and it now holds a
string of unbounded length beside a button that has to stay clickable. In the
shell suite: the cross-surface row in both directions, the closed state
surviving to disk, and the focus request being made when a snip lands and not
made when it does not.
2026-08-10 16:49:44 +02:00

217 lines
10 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Session;
/// <summary>
/// Preferences that belong to this machine rather than to the vault.
/// </summary>
/// <remarks>
/// <para>
/// <b>Deliberately not in the cache, and deliberately not synced.</b> The cache is encrypted and cannot be
/// read until a vault is unlocked, and a font size is needed by the first terminal a locked launch draws.
/// Syncing it would be worse than useless: the size that suits a phone is not the size that suits a
/// 27-inch monitor, and one following the other around is a preference nobody asked for.
/// </para>
/// <para>
/// Nothing secret goes in here, and that is a rule rather than a description of what happens to be here
/// today. The file is plaintext in the profile directory; anything that would matter if it were read
/// belongs in the cache, behind the passphrase.
/// </para>
/// </remarks>
/// <param name="TerminalFontSize">The terminal font size, in CSS pixels.</param>
/// <param name="AutomaticUpdateChecks">
/// Whether this machine looks for a newer build on its own. See the remarks on the property.
/// </param>
/// <param name="SessionSidebarOpen">
/// Whether the session shell's QUICK ACCESS sidebar is drawn. See the remarks on the property.
/// </param>
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true,
bool SessionSidebarOpen = true)
{
/*
A positional record, and the defaults live on the parameters rather than on property initializers.
That is not a style choice — it is the only place System.Text.Json will read them from, and getting
it wrong fails silently.
What the source generator emits for a record is an ObjectWithParameterizedConstructorCreator: it
treats the init-only properties as constructor arguments and builds the object as
new ClientSettings() { TerminalFontSize = (int)args[0], AutomaticUpdateChecks = (bool)args[1] }
so a property initializer does run — and is then immediately overwritten by args, which for a member
absent from the JSON is the CLR default. Measured before this was fixed: a settings file of {} read
back TerminalFontSize 0, clamped to the 8px floor rather than the 13px the renderer draws at, and
AutomaticUpdateChecks false. Reflection-based deserialisation of the same JSON answers 13 and true,
which is what makes it so easy to miss. Setting GenerationMode to Metadata does not help either; it
was tried.
Constructor parameter defaults are carried into the generated metadata, so absence resolves to the
value declared here. ASettingAbsentFromTheFile_ComesBackAsItsDeclaredDefault fails without this.
The type is still used exactly as before — new ClientSettings(), and `with` expressions — because
every parameter is optional.
*/
/// <summary>The size a terminal draws at with nothing stored.</summary>
/// <remarks>
/// Matches the renderer's own default, and has to: the page creates panes at its own constant until
/// the host tells it otherwise, so a different value here would show as a resize in front of the user
/// on every launch.
/// </remarks>
public const int DefaultTerminalFontSize = 13;
/// <summary>Smallest terminal font size the shell will set.</summary>
/// <remarks>
/// Not zero, and not one. Below about eight pixels a monospace grid stops being legible and starts
/// being a texture — and every column of it is still a column the remote is being told exists.
/// </remarks>
public const int MinimumTerminalFontSize = 8;
/// <summary>Largest terminal font size the shell will set.</summary>
/// <remarks>
/// A phone in portrait at 32px is about twenty columns, which is already narrower than most command
/// output survives. The cap is what stops "bigger" arriving at a terminal that cannot show a prompt.
/// </remarks>
public const int MaximumTerminalFontSize = 32;
/*
AutomaticUpdateChecks: why it is on by default, and the three neighbours it deliberately does not
have. Prose rather than XML doc because the property is declared in the parameter list above, and a
param tag is the wrong shape for several paragraphs.
On by default. A client that quietly runs a year behind is the failure ADR 0011 names as the real
cost of distributing outside a store, and the desktop has the mechanism to avoid it — so the
default should use it. It costs little to leave on, because a check downloads and never installs:
nothing happens to a running application except a line offering a restart.
Three neighbours this deliberately does not have, because each would be a mistake worth
naming rather than an omission.
No feed address. Where builds come from is a constant in the code, and ADR 0011 rule 2 is
why: an operator who could point this at themselves could pin a chosen user to a build with a known
hole. This file is plaintext and hand-editable, which is exactly what makes it the wrong home for
that value.
No last-checked timestamp. The rule above about nothing secret is not only about secrets —
a record of when this machine last contacted the project's forge is a small fact about a person
that this file does not otherwise carry. It also buys little: the check runs on a timer measured
from launch, so any machine that runs at all is current within hours. What the screen shows is
remembered for the session and no longer.
No channel switch. There is one release channel. A toggle offering a second would be a
preference with nothing behind it, which is the thing the preferences screen's own opening comment
warns against.
*/
/*
SessionSidebarOpen: why closing the sidebar is remembered, and why it is remembered here.
On by default, because the sidebar is where a session's pins, its snips and the way across to the
other surface live — a first launch that hid all three would be hiding the feature rather than
offering to.
Remembered at all because closing it is a choice about how much of a 1180-pixel window a terminal
gets, and a choice that has to be made again on every launch is one the application is not really
offering. It belongs in this file rather than in the vault for the same reason the font size does:
it is a fact about this screen, not about this keychain, and following somebody from a 27-inch
monitor onto a laptop would be a preference nobody asked for.
Not per-surface and not per-tab. The sidebar is one control drawn on two screens — see
SessionSidebar.axaml — and a window where it is open on SFTP and closed on the terminal is a
window that appears to lose it at random.
*/
/// <summary>Brings a value inside the range this type will store.</summary>
public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
}
/// <summary>
/// Reads and writes <see cref="ClientSettings"/> as a small JSON file.
/// </summary>
/// <remarks>
/// <para>
/// Every failure resolves to the defaults, and none of them throw. A missing file is the first launch, a
/// truncated one is a machine that lost power mid-write, and an unreadable one is a profile directory
/// somebody has been editing by hand. None of those is a reason to refuse to start an SSH client, and a
/// preference that cannot be read is a preference that was never set.
/// </para>
/// <para>
/// Written through a temporary file and moved into place, so an interrupted write leaves the previous
/// settings rather than half of the new ones.
/// </para>
/// </remarks>
public sealed class ClientSettingsStore(ClientPaths paths)
{
/// <summary>Reads the stored settings, or the defaults.</summary>
public ClientSettings Read()
{
try
{
if (!File.Exists(paths.SettingsFile))
{
return new ClientSettings();
}
var json = File.ReadAllText(paths.SettingsFile);
var stored = JsonSerializer.Deserialize(json, ClientSettingsJsonContext.Default.ClientSettings);
return stored is null
? new ClientSettings()
: stored with
{
// Clamped on the way in as well as on the way out. The file is editable by hand, and a
// 400-pixel terminal is a pane with no columns in it rather than an amusing mistake.
TerminalFontSize = ClientSettings.ClampTerminalFontSize(stored.TerminalFontSize),
};
}
catch (Exception exception) when (exception is IOException
or UnauthorizedAccessException
or JsonException)
{
return new ClientSettings();
}
}
/// <summary>Stores the settings, best effort.</summary>
/// <returns>
/// Whether they reached the disk. False is worth having rather than silence: it is the difference
/// between a preference that will be there next launch and one that will not, and the caller is the
/// only thing that could say so.
/// </returns>
public bool Write(ClientSettings settings)
{
ArgumentNullException.ThrowIfNull(settings);
try
{
paths.EnsureCreated();
var temporary = paths.SettingsFile + ".tmp";
File.WriteAllText(
temporary,
JsonSerializer.Serialize(settings, ClientSettingsJsonContext.Default.ClientSettings));
File.Move(temporary, paths.SettingsFile, overwrite: true);
return true;
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
return false;
}
}
}
[JsonSourceGenerationOptions(
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
WriteIndented = true,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip)]
[JsonSerializable(typeof(ClientSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;