using System.Text.Json;
using System.Text.Json.Serialization;
namespace DodoSSH.Client.Session;
///
/// Preferences that belong to this machine rather than to the vault.
///
///
///
/// Deliberately not in the cache, and deliberately not synced. 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.
///
///
/// 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.
///
///
public sealed record ClientSettings
{
/// The size a terminal draws at with nothing stored.
///
/// 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.
///
public const int DefaultTerminalFontSize = 13;
/// Smallest terminal font size the shell will set.
///
/// 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.
///
public const int MinimumTerminalFontSize = 8;
/// Largest terminal font size the shell will set.
///
/// 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.
///
public const int MaximumTerminalFontSize = 32;
/// The terminal font size, in CSS pixels.
public int TerminalFontSize { get; init; } = DefaultTerminalFontSize;
/// Brings a value inside the range this type will store.
public static int ClampTerminalFontSize(int pixels) =>
Math.Clamp(pixels, MinimumTerminalFontSize, MaximumTerminalFontSize);
}
///
/// Reads and writes as a small JSON file.
///
///
///
/// 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.
///
///
/// Written through a temporary file and moved into place, so an interrupted write leaves the previous
/// settings rather than half of the new ones.
///
///
public sealed class ClientSettingsStore(ClientPaths paths)
{
/// Reads the stored settings, or the defaults.
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();
}
}
/// Stores the settings, best effort.
///
/// 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.
///
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;