Public Access
Let the terminal's text be made bigger, and remember how big
Taking pinch-zoom off the phone left nothing in its place, and there was nothing on the desktop either. This is the replacement, and it is deliberately not the thing that was removed: zoom scales what has already been drawn, so the remote goes on wrapping to a width that is no longer on screen. Changing the font size refits the grid and reports the new column count, so the far end is told it has fewer columns. That round trip is the feature. The size is one number, owned by the shell. It has to be, for two reasons that pull the same way: it must survive a relaunch, and it must be reachable from a phone that has no Ctrl key to press. So the page asks and the host decides — a signed step over a new client opcode, answered with a size over a new server opcode. The phone's buttons and the desktop's chords arrive at the same place, and a size set by either is the size both remember. Stored in settings.json beside the cache rather than in it, and that is not laziness about a migration. The cache is encrypted and unreadable until a vault is unlocked, and the first terminal of a locked launch needs the size already. Nothing secret may go in that file; ClientSettings says so out loud, because the next person to add a preference is the one who needs to read it. Where it is reachable from differs per head, and only here. The phone gets A− and A+ on the connection line — not in the accessory row, which scrolls, and a control that fixes unreadable text must never be the thing that is off-screen. The desktop gets the three chords every terminal emulator has, answered by the page while a terminal has focus and by the window when it does not, plus a row in preferences that shows the current value and names the chords rather than replacing them. Someone whose terminal is too small to read is not in a position to go looking. Clamped 8 to 32. Below eight a monospace grid stops being legible and becomes a texture, and every column of it is still a column the remote is being told exists; above thirty-two a phone in portrait has too few columns to hold a prompt. The buttons disable at the ends rather than accepting presses that do nothing, which on a terminal reads as the application having stopped responding. The preferences screen's header comment claimed none of the design's terminal settings could be saved, and listed the three things that were missing to make one work. All three now exist, so it says which one is real and why the other five still are not. Verified with the protocol suite — including that the step byte round-trips signed, since read unsigned a step down arrives as 255 and clamps to the largest font, making "smaller" do the most dramatic available version of "larger" — a data-plane test that the chord is heard with no session registered, and five shell tests: the default matches the renderer's, both clamps hold, reset works, and a size chosen in one shell is there in a second one over the same profile directory. Layout suite and both heads build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
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>
|
||||
public sealed record ClientSettings
|
||||
{
|
||||
/// <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;
|
||||
|
||||
/// <summary>The terminal font size, in CSS pixels.</summary>
|
||||
public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
|
||||
|
||||
/// <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;
|
||||
Reference in New Issue
Block a user