namespace DodoSSH.Client.Session; /// /// Where this machine keeps its profile. /// /// /// /// A record with an explicit directory rather than a static lookup, so a test — or a portable install — /// can point it somewhere else without an environment variable. /// /// /// The choice of directory matters more than it looks. The cache is a SQLite file written by one /// process, and the whole design assumes each machine has its own: the outbox holds changes this machine /// has made and not yet pushed, and two machines sharing one file through a cloud sync client corrupts /// it. So this deliberately picks a local, non-roaming location on every platform. On Windows /// that means %LOCALAPPDATA% and never %APPDATA%, which roams in a domain environment and /// would do exactly the wrong thing. /// /// /// The profile directory. Created on demand. public sealed record ClientPaths(string DataDirectory) { private const string WindowsFolderName = "DodoSSH"; private const string UnixFolderName = "dodossh"; /// /// The channel whose profile is kept apart, named here because this type is the one that acts on it. /// /// /// The string itself is the desktop head's DesktopChannel.Nightly. It is repeated rather than /// referenced because the dependency runs the wrong way — this project is shared with the Android /// head, which must never acquire a desktop updater — and because a value that reaches an assembly as /// build metadata is a string by the time anybody here sees it. /// private const string NightlyChannelName = "nightly"; /// /// What the nightly's directory is called: the release one, with this on the end. /// /// /// A sibling rather than a subdirectory of the release profile, so that neither install's uninstaller /// or reset can reach the other's, and so a person looking in %LOCALAPPDATA% sees two things /// with two names rather than one thing with a surprise inside it. /// private const string NightlySuffix = ".Nightly"; /// The conventional location for this platform. public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null)); /// /// Where a build on the given channel keeps its profile. /// /// /// /// A nightly may not share a profile with the release build, and the reason is the cache rather /// than the secrets. The schema is migrated on every launch, before unlock; a nightly carrying a /// migration the release build has not shipped yet would upgrade a database the release build then /// opens. Both are installed at once by design — that is the whole point of a channel that installs /// beside rather than over — so this is an ordinary Tuesday rather than a corner case. Two of them /// running at the same time on one SQLite file and one outbox is the second reason and would be /// enough on its own. /// /// /// It costs a nightly its sign-in and its known hosts, which is the honest trade: a nightly is a /// second installation of the application, and treating it as one is what stops it damaging the first. /// The device key is per install too, so the deployment sees a new device — which is exactly what /// happened, and what the trust model expects to be told about. /// /// /// Only the nightly channel is answered specially. Anything else, including the release channel and /// anything unrecognised, gets — the directory every existing install already /// uses, which must not move for any reason. /// /// /// The build channel, as the head's own metadata reports it. public static ClientPaths ForChannel(string? channel) => string.Equals(channel, NightlyChannelName, StringComparison.Ordinal) ? new ClientPaths(ResolveDataDirectory(NightlySuffix)) : Default; /// The encrypted local cache. public string CacheFile => Path.Combine(DataDirectory, "cache.db"); /// /// This machine's preferences, in plaintext. /// /// /// Beside the cache rather than inside it, because everything in it is needed before a vault /// is unlocked — a terminal draws at a chosen size on a launch that never reaches a passphrase. That /// is also the reason nothing secret may go in here; see . /// public string SettingsFile => Path.Combine(DataDirectory, "settings.json"); /// /// This machine's device key, encrypted to a key it cannot export. /// /// /// Local and non-roaming for a stronger reason than the cache is: the file is decryptable only by a /// key held in this machine's TPM, so a copy of it on another machine is bytes nothing can open. It /// following a user to a second computer would be useless rather than dangerous — but a roaming /// profile that overwrote one machine's blob with another's would break both. /// public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key"); /// Creates the profile directory if it is not there yet. /// /// Separate from resolving the path, because resolving must never have a side effect: it is read /// during startup diagnostics and by tests that have no business creating directories. /// public void EnsureCreated() => Directory.CreateDirectory(DataDirectory); /// /// The platform branches are explicit rather than delegating to /// everywhere. That enumeration does /// the right thing on Windows, but on macOS the runtime maps it to ~/.local/share rather than /// to ~/Library/Application Support, and relying on framework behaviour that differs per /// platform for a path users will look at is how a file ends up somewhere nobody expects. /// /// XDG_DATA_HOME is honoured explicitly for the same reason: it is the spec, and reading it /// here is one line versus depending on whether the runtime happens to. /// /// private static string ResolveDataDirectory(string? suffix) { // Appended to the folder name rather than added as a path segment, on every platform, so the two // profiles are siblings everywhere. The Unix name is lower-cased with the rest of its folder. var windows = WindowsFolderName + suffix; var unix = UnixFolderName + suffix?.ToLowerInvariant(); if (OperatingSystem.IsWindows()) { return Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), windows); } var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); if (OperatingSystem.IsMacOS()) { return Path.Combine(home, "Library", "Application Support", windows); } var xdgDataHome = Environment.GetEnvironmentVariable("XDG_DATA_HOME"); var root = string.IsNullOrWhiteSpace(xdgDataHome) ? Path.Combine(home, ".local", "share") : xdgDataHome; return Path.Combine(root, unix); } }