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;