Public Access
176 lines
9.0 KiB
C#
176 lines
9.0 KiB
C#
namespace DodoSSH.Client.Session;
|
|
|
|
/// <summary>
|
|
/// Where this machine keeps its profile.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>The choice of directory matters more than it looks.</b> 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 <em>local</em>, non-roaming location on every platform. On Windows
|
|
/// that means <c>%LOCALAPPDATA%</c> and never <c>%APPDATA%</c>, which roams in a domain environment and
|
|
/// would do exactly the wrong thing.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="DataDirectory">The profile directory. Created on demand.</param>
|
|
public sealed record ClientPaths(string DataDirectory)
|
|
{
|
|
private const string WindowsFolderName = "DodoSSH";
|
|
private const string UnixFolderName = "dodossh";
|
|
|
|
/// <summary>
|
|
/// The channel whose profile is kept apart, named here because this type is the one that acts on it.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// The string itself is the desktop head's <c>DesktopChannel.Nightly</c>. 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.
|
|
/// </remarks>
|
|
private const string NightlyChannelName = "nightly";
|
|
|
|
/// <summary>
|
|
/// What the nightly's directory is called: the release one, with this on the end.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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 <c>%LOCALAPPDATA%</c> sees two things
|
|
/// with two names rather than one thing with a surprise inside it.
|
|
/// </remarks>
|
|
private const string NightlySuffix = ".Nightly";
|
|
|
|
/// <summary>The conventional location for this platform.</summary>
|
|
public static ClientPaths Default { get; } = new(ResolveDataDirectory(suffix: null));
|
|
|
|
/// <summary>
|
|
/// Where a build on the given channel keeps its profile.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>A nightly may not share a profile with the release build, and the reason is the cache rather
|
|
/// than the secrets.</b> 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// 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.
|
|
/// </para>
|
|
/// <para>
|
|
/// Only the nightly channel is answered specially. Anything else, including the release channel and
|
|
/// anything unrecognised, gets <see cref="Default"/> — the directory every existing install already
|
|
/// uses, which must not move for any reason.
|
|
/// </para>
|
|
/// </remarks>
|
|
/// <param name="channel">The build channel, as the head's own metadata reports it.</param>
|
|
public static ClientPaths ForChannel(string? channel) =>
|
|
string.Equals(channel, NightlyChannelName, StringComparison.Ordinal)
|
|
? new ClientPaths(ResolveDataDirectory(NightlySuffix))
|
|
: Default;
|
|
|
|
/// <summary>The encrypted local cache.</summary>
|
|
public string CacheFile => Path.Combine(DataDirectory, "cache.db");
|
|
|
|
/// <summary>
|
|
/// This machine's preferences, in plaintext.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Beside the cache rather than inside it, because everything in it is needed <em>before</em> 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 <see cref="ClientSettings"/>.
|
|
/// </remarks>
|
|
public string SettingsFile => Path.Combine(DataDirectory, "settings.json");
|
|
|
|
/// <summary>
|
|
/// This machine's device key, encrypted to a key it cannot export.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public string DeviceKeyFile => Path.Combine(DataDirectory, "device.key");
|
|
|
|
/// <summary>
|
|
/// The file one desktop instance holds open for as long as it is running.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para>
|
|
/// Named here rather than in the head that uses it, because every other name in this directory is named
|
|
/// here and a filename invented somewhere else is one nobody looking at the profile can account for. The
|
|
/// Android head has no use for it: the platform allows one process per package and needs no help.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Being in the profile directory is what scopes it correctly, and that is the whole reason it is a
|
|
/// file rather than a named mutex.</b> This path is already per user and per channel, so a nightly and a
|
|
/// release build do not exclude each other and two people signed into one machine each get their own
|
|
/// instance — neither of which a global name would give without repeating the same two decisions in
|
|
/// another form. Contents are irrelevant and it stays empty; what carries the meaning is the exclusive
|
|
/// handle, which the operating system releases when the process ends however it ends.
|
|
/// </para>
|
|
/// </remarks>
|
|
public string InstanceLockFile => Path.Combine(DataDirectory, "instance.lock");
|
|
|
|
/// <summary>Where the application writes down why it died, when something nothing caught ends it.</summary>
|
|
public string CrashLogFile => Path.Combine(DataDirectory, "crash.log");
|
|
|
|
/// <summary>Creates the profile directory if it is not there yet.</summary>
|
|
/// <remarks>
|
|
/// 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.
|
|
/// </remarks>
|
|
public void EnsureCreated() => Directory.CreateDirectory(DataDirectory);
|
|
|
|
/// <remarks>
|
|
/// The platform branches are explicit rather than delegating to
|
|
/// <see cref="Environment.SpecialFolder.LocalApplicationData"/> everywhere. That enumeration does
|
|
/// the right thing on Windows, but on macOS the runtime maps it to <c>~/.local/share</c> rather than
|
|
/// to <c>~/Library/Application Support</c>, 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.
|
|
/// <para>
|
|
/// <c>XDG_DATA_HOME</c> 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.
|
|
/// </para>
|
|
/// </remarks>
|
|
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);
|
|
}
|
|
}
|