Files
DodoSSH/src/DodoSSH.Client.Session/ClientSettings.cs
T
jaap-jan 367cebae9b
ci / build and test (push) Successful in 4m52s
ci / desktop nightly (push) Failing after 58s
ci / android head (push) Successful in 5m59s
ci / api image (push) Successful in 8s
Android: a customisable key bar, a quick-access keyboard, a long-press cursor gesture, and a side panel on tablets
The key bar above the software keyboard is now the user's own: which keys, in what order, up to sixteen
in two rows that never scroll. Ctrl and Alt latch across the bar and the phone's keyboard, and Ctrl or Alt
with a cursor key sends the modified CSI form. Seven predefined groups (essentials, cursor, control,
symbols, tmux, vim, function) and keys of one's own, written in \e / ^C notation, are arranged on a
customise sheet that saves as it goes.

A grid button swaps the software keyboard for a quick-access keyboard of the same height, with KEYS,
SNIPPETS, HISTORY and APPEARANCE tabs; it reopens to the tab last used. Snippets go through the shell's
existing InsertSnippet. History keeps a typed command only once the remote has echoed it, so nothing
typed at a password prompt is ever recorded; it is memory-only and LOCK clears it. Each session pump now
keeps its last kilobyte of output and the alternate-screen state for that test (OutputTail).

Long-press and drag on the terminal sends arrow keys, as a joystick with three speeds by distance;
scrolling is unchanged and there is no mode switch. It takes the touch before Chromium's own long press,
so a held finger no longer pastes; paste is on the bar by default instead. A switch in APPEARANCE turns
the gesture off. The text-size buttons moved from the bar to APPEARANCE.

On wide screens the same tabs open as a side panel beside the terminal, toggled from the top bar, with
the bar's own keys repeated at the top of KEYS while the bar stands down.

The model, gesture arithmetic, echo reading and history are in DodoSSH.Client.Shell and covered by
TerminalKeyboardTests and OutputTailTests. Builds clean; not yet exercised on a device, because the
emulator's install is locked behind a passphrase that was not recorded.
2026-10-03 14:29:22 +02:00

223 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>
/// <param name="TerminalKeyboard">
/// How the phone's keys above the keyboard are arranged, or null for the arrangement it ships with. See
/// <see cref="TerminalKeyboardSettings"/>.
/// </param>
public sealed record ClientSettings(
int TerminalFontSize = ClientSettings.DefaultTerminalFontSize,
bool AutomaticUpdateChecks = true,
bool SessionSidebarOpen = true,
TerminalKeyboardSettings? TerminalKeyboard = null)
{
/*
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))]
[JsonSerializable(typeof(TerminalKeyboardSettings))]
internal sealed partial class ClientSettingsJsonContext : JsonSerializerContext;