diff --git a/Directory.Packages.props b/Directory.Packages.props index 0e96538581..9b40e29678 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -2,7 +2,7 @@ - + diff --git a/docs/decisions/backend/0013-default-datastore-locations.md b/docs/decisions/backend/0013-default-datastore-locations.md new file mode 100644 index 0000000000..ad7a3000c6 --- /dev/null +++ b/docs/decisions/backend/0013-default-datastore-locations.md @@ -0,0 +1,241 @@ +``` +# These are optional elements. Feel free to remove any of them. +status: {proposed} +date: {2023-11-21 when the decision was last updated} +deciders: {App Team} +``` + +# Default Storage Locations for NexusMods.App Files + +## Context and Problem Statement + +The App currently stores its files in the same folder as the application (`{EntryFolder}`). + +This approach has issues with certain packaging formats. For example, when the application is run from +an AppImage, the `EntryFolder` becomes read-only, and our application fails to run. + +We need to identify alternative storage locations that are both accessible and writable, regardless of how the +application is deployed or executed. + +## The Current Situation + +| Packaging System | Can Write Entry Directory? | Notes | +|-------------------------|----------------------------|-----------------------------------------------------------------------------| +| Windows (User Folder) | ✅ | No issues. | +| Windows (Program Files) | ⚠️ | Requires elevated permissions. | +| macOS (User Folder) | ✅ | No issues. | +| macOS (/Applications) | ⚠️ | Requires elevated permissions. Reportedly affects code signing workflow. | +| Linux (AppImage) | ⚠️ | Read only. Can write to `.AppImage` folder via `$OWD` environment variable. | +| Linux (Flatpak) | ❌ | Read only. | + +## Per Distribution Method Notes + +| Packaging System | Notes | +|------------------|-------------------------------------------------------------------------------------------------------------------| +| Windows | Requires elevated permissions for non-user folders. | +| macOS | Requires elevated permissions for non-user folders. | +| Linux (AppImage) | Single file app. Mounted on launch. | +| Linux (Flatpak) | Can only access certain directories by default. Full FileSystem permissions may be requested via manifest change. | + +## Decision Drivers + +* On uninstall, the application should be able to remove all of its files from the system. +* Default storage locations should be configurable. + * This is already the case today, but stating this here marks the functionality as a requirement. +* The application should be 'portable'. + * In other words, it should be able to be run from a USB stick, etc. +* Application data should be user accessible. + * In other words, easy to find/navigate to. + * Because application data, also contains logs and user accessible files. + * e.g. If the App crashes on boot, finding logs should be easy. +* Cross-platform consistency. + * The paths should be consistent across different operating systems. + * For example, if on one platform it is in entry directory, it should do the same on other platforms too. +* The user must have write access to the directory. + +## Considered Options + +* **Option 0: Keep using Entry Directory** +* **Option 1: User's Home Directory** +* **Option 2: Operating System's AppData Directory** + +## Decision Outcome + +We went with `Option 2: Operating System's AppData Directory`. + +With the following caveats/notes: + +- We will assume the machine is single user for now. (no `Roaming` directory) +- We will use idiomatic paths for each operating system. + +| Directory | Windows | Linux | +|-----------------|----------------|-------------------| +| DataModel | %localappdata% | XDG_DATA_HOME | +| Temporary Files | %temp% | XDG_STATE_HOME ⚠️ | +| Logs | %localappdata% | XDG_STATE_HOME | + +⚠️ Non-standard location. This is because we risk RAM starvation on large downloads as `tmpfs` is often in RAM+swap. + +Inside `NexusMods.App` subfolder, of course. +macOS is not yet implemented, so is currently omitted. + +## Pros and Cons of the Options + +### Option 0: Keep using Entry Directory + +* Good, because it easily makes the application portable. +* Good, because it is consistent across deployment methods. +* Good, because it makes user data very easy to find. +* Bad, because it is incompatible with many packaging systems. + +### Option 1: User's Home Directory + +In a subfolder of: + +- `~` on Linux/macOS +- `C:\Users\{User}` on Windows + +* Good, because it is easy to find. +* Good, because it remains consistent across different deployment methods. +* Bad, because it can clutter the user's personal space with application data. ('yet another folder in my home + directory') + +### Option 2: Operating System's AppData Directory + +In a subfolder of: + +- `AppData/Roaming` on Windows +- `~/Library/Application Support` on macOS +- `~/.config` on Linux a.k.a. `XDG_CONFIG_HOME` + +* Good, because it's the idiomatic (intended) location for application data. +* Good, because it separates application data from user files, reducing clutter. +* Bad, because user might struggle to find these locations. + +--------- + +# Additional Considerations + +## Issue: Separate Per User and Per Machine Data + +--------- + +Decision: Discussed +in [Meeting Notes: Default Storage Locations](../meeting-notes/0000-datastore-locations.md#default-storage-location). We +will pretend multi-user systems don't exist for now. + +--------- + +!!! note "We will be using Windows as an example here, but this also applies to other operating systems" + +The current issue with idiomatically using [AppData](#option-2-operating-systems-appdata-directory) is +the presence of multi user systems. For example, offices, LAN centres, etc. + +There are two specific issues: + +### 1. Network Synchronization + +!!! Addressed + +Files in `AppData/Roaming` are usually downloaded upon login in these configurations, and this download typically +happens on every login. + +This is bad because it means having to potentially wait a very long time (even at Gigabit speed) to download a lot of +data on login. In the case of something like Starfield, game backup + mods could exceed 100GB, meaning a 10+ minute +download if no data is locally cached. + +In the case of alternative [Home Directory](#option-1-users-home-directory) approach however, the home directory can +just be accessed over the network, avoiding the need for a long synchronization wait time. (At expense of slow +deployment times as mod archives would be accessed over the network) + +### 2. Disk Space (Local) + +In the other case of multiple users on same local machine, all mod + game backup data is duplicated on storage, wasting +potentially a huge amount of space. + +### Additional Context + +The idiomatic approach for this kind of problem is storing mods + backup game files +in a machine wide location such as `C:\ProgramData`. Per user data (loadouts, mod configs etc.) in `AppData/Roaming`. + +When using in a multi-machine setup, like an office, the App would load the loadout from `AppData/Roaming` and start +downloading any mod assets it may be missing. If they are modding a game which has been modded by another user on the +same local system, the shared backup game files in `C:\ProgramData` would be used to first restore the game to its +original state. + +Such as system may mean a slight rework of the DataStore however, as it means having to know where each file was +originally sourced from and (possibly) having a separate data store for user and machine wide data. + +### The Alternative + +Pretend the problem doesn't exist. Not uncommon in software development (sadly). +More convenient for developers, but the App however would work very poorly in multi user environments. + +## Action Plan: Moving Default Configuration File + +--------- + +Decision: [Portable mode to be implemented](../meeting-notes/0000-datastore-locations.md#portable-mode) with +`AppConfig.json` override method. Lack of `AppConfig.json` indicates 'use default paths'. Remaining settings +to be moved to DataStore. + +For now, as a compromise, as not to not cause a regression in functionality, we will blank out the default +paths in the `AppConfig.json` file. If they are blank, we will apply the default paths decided upon in this ADR. + +--------- + +Currently the App stores its configuration file (`AppConfig.json`) for custom paths in the same folder as the +application (`{EntryFolder}`). This is of course problematic, as many packaging systems make this folder read-only. + +### Moving the Configuration File + +Once changes tied to this ADR are applied, this config file should be moved to whatever is the new default decided +as a result of this ADR. If no file exists, the App should create one in the default location with the default values. + +### Required: Support for 'Portable Mode' + +For users who wish to run the App in 'portable mode' (e.g. in a non-static location), an override will need to exist to +read paths from the local folder (which is current behaviour). + +This can be done in one of following ways: + +- `AppConfig.json` in App folder takes precedence over default location `AppConfig.json`. +- `portable.txt` file forces a default set of 'portable' paths to be used. + +The first option is more powerful, but the second option may be more familiar to users. In any case, implementing either +and switching between them code wise should be rather trivial. + +!! danger + +'Portable Mode' can only be supported for distribution methods where the App folder is writeable, for a list of these, +[see 'The Current Situation'](#the-current-situation). How we communicate this to the user is TBD. + +## Future Questions: User Path Configuration UX + +!! note "This is a pending discussion topic. To be moved to future ADRs." + +Discussion on the User Experience of configuring paths is still pending. + +Here are some (current) proposed discussion topics: + +- How do we advertise 'portable' mode. + - Either we copy the App files from a read-only directory to a new location, then tell user to uninstall their ' + installed' version. + - Or users will need to download a separate build (e.g. AppImage instead of Flatpak, i.e. `ZIP` instead of `MSI` on + Windows) to use it. + - We can't have custom installer logic, as not all packaging formats support it. Do we make a custom installer? + +- Support installing archives to multiple locations. + - Do we do automated per game rules? + - Size based rules? + - Delegating to secondary locations if primary is full? + +- Support dynamically moving the Archives between folders. + - Do we use Steam inspired UI for this? + - Reboot required? + +--------- + +Decision: To be further discussed later. Location per game to be supported in the future. + +--------- diff --git a/docs/decisions/meeting-notes/0000-datastore-locations.md b/docs/decisions/meeting-notes/0000-datastore-locations.md new file mode 100644 index 0000000000..4462ded016 --- /dev/null +++ b/docs/decisions/meeting-notes/0000-datastore-locations.md @@ -0,0 +1,54 @@ +# Meeting Summary on Default Storage Locations + +**Date of Meeting**: 26th of November, 2023 + +**Attendees**: App Team Only (No Vortex/Design) + +**Agenda**: + +1. Final decision on default storage location for app files. +2. Discussion on Portable Mode implementation. +3. Decision on handling separate per user and per machine data. +4. Additional context and future considerations. + +## Decisions Made + +### Default Storage Location + +**Agreed upon LocalAppData**. The team unanimously agreed to use the `LocalAppData` on Windows +and `XDG_DATA_HOME` on Linux. This is Option 2 from the ADR, however with only the use of LocalAppData. + +We chose to pretend (for now) that Multi-User systems aren't within our scope as they are very +infrequently used. + +This keeps our DataModel simpler, without the need to track file source locations (where to +download them from) or splitting the DataModel itself into two. + +### Portable Mode + +**Support for Portable Mode Confirmed**. Team chose to vote in favour of supporting a portable +mode based on past experience with end users and and their mod managers. + +@erri120 made an note that one of the fact that part of the main appeal of a 'portable mode' is +being able to transfer the data between one machine to another. So technically, that can already be +fulfilled using built-in export/import functionality. + +In any case, as not much work is currently required to deliver this (it already is portable, in fact!), +the team decided to support it. + +There are some concerns, however, such as the user potentially managing game with multiple +managers, leading to an inconsistent state. For now, we expect the user to be responsible for +not making this mistake. + +### Future Plans + +**Auto Installs to Multiple Locations**: This feature will be addressed in the future. The goal +is to allow users to specify storage locations on a per-game basis. + +## Next Steps and Action Items + +- **Implementing LocalAppData as the Default Storage**: Transition the app's default storage location + to `LocalAppData` / `XDG_DATA_HOME` as per the decision. + +- **Developing Portable Mode**: Basically make the current config file we have 'optional', if it + exists, use it, otherwise default to a 'default' storage location. \ No newline at end of file diff --git a/src/ArchiveManagement/NexusMods.FileExtractor/IFileExtractorSettings.cs b/src/ArchiveManagement/NexusMods.FileExtractor/IFileExtractorSettings.cs index 3f2a4ae895..4a2aec67b3 100644 --- a/src/ArchiveManagement/NexusMods.FileExtractor/IFileExtractorSettings.cs +++ b/src/ArchiveManagement/NexusMods.FileExtractor/IFileExtractorSettings.cs @@ -21,14 +21,13 @@ public interface IFileExtractorSettings [PublicAPI] public class FileExtractorSettings : IFileExtractorSettings { - // Note: We can't serialize AbsolutePath because it contains more fields than expected. Just hope user sets correct paths and pray for the best. - private readonly IFileSystem _fileSystem; - - + /// + public ConfigurationPath TempFolderLocation { get; set; } + /// /// Default constructor for serialization. /// - public FileExtractorSettings() : this(FileSystem.Shared) {} + public FileExtractorSettings() : this(FileSystem.Shared) { } /// /// Creates a default new instance of . @@ -36,11 +35,30 @@ public FileExtractorSettings() : this(FileSystem.Shared) {} /// public FileExtractorSettings(IFileSystem fileSystem) { - _fileSystem = fileSystem; - TempFolderLocation = new ConfigurationPath(_fileSystem - .GetKnownPath(KnownPath.EntryDirectory).Combine("Temp")); + TempFolderLocation = new ConfigurationPath(GetDefaultBaseDirectory(fileSystem)); + } + + private static AbsolutePath GetDefaultBaseDirectory(IFileSystem fs) + { + // Note: The idiomatic place for this is Temporary Directory (/tmp on Linux, %TEMP% on Windows) + // however this can be dangerous to do on Linux, as /tmp is often a RAM disk, and can be + // too small to handle large files. + return fs.OS.MatchPlatform( + () => fs.GetKnownPath(KnownPath.TempDirectory).Combine("NexusMods.App/Temp"), + () => fs.GetKnownPath(KnownPath.XDG_STATE_HOME).Combine("NexusMods.App/Temp"), + () => throw new PlatformNotSupportedException( + "(Note: Sewer) Paths needs PR for macOS. I don't have a non-painful way to access a Mac.")); + } + + /// + /// Ensures default settings in case of placeholders of undefined/invalid settings. + /// + public void Sanitize(IFileSystem fs) + { + // Set default locations if none are provided. + if (string.IsNullOrEmpty(TempFolderLocation.RawPath)) + TempFolderLocation = new ConfigurationPath(GetDefaultBaseDirectory(fs)); + + TempFolderLocation.ToAbsolutePath().CreateDirectory(); } - - /// - public ConfigurationPath TempFolderLocation { get; set; } } diff --git a/src/NexusMods.App/AppConfig.cs b/src/NexusMods.App/AppConfig.cs index 920c1f34b0..ea1b21a441 100644 --- a/src/NexusMods.App/AppConfig.cs +++ b/src/NexusMods.App/AppConfig.cs @@ -53,11 +53,12 @@ Individual settings objects must implement a `Sanitize` function to ensure these /// /// Sanitizes the config; e.g. /// - public void Sanitize() + public void Sanitize(IFileSystem fs) { - DataModelSettings.Sanitize(); + DataModelSettings.Sanitize(fs); + FileExtractorSettings.Sanitize(fs); HttpDownloaderSettings.Sanitize(); - LoggingSettings.Sanitize(); + LoggingSettings.Sanitize(fs); } } @@ -96,7 +97,7 @@ public class LoggingSettings : ILoggingSettings /// /// Default constructor for serialization. /// - public LoggingSettings() : this(FileSystem.Shared) {} + public LoggingSettings() : this(FileSystem.Shared) { } /// /// Creates the default logger with logs stored in the entry directory. @@ -104,20 +105,44 @@ public LoggingSettings() : this(FileSystem.Shared) {} /// The FileSystem implementation to use. public LoggingSettings(IFileSystem fileSystem) { - var baseFolder = fileSystem.GetKnownPath(KnownPath.EntryDirectory); - baseFolder.CreateDirectory(); - FilePath = new ConfigurationPath(baseFolder.Combine(LogFileName)); - ArchiveFilePath = new ConfigurationPath(baseFolder.Combine(LogFileNameTemplate)); + var baseFolder = GetDefaultBaseDirectory(fileSystem); + FilePath = GetFilePath(baseFolder); + ArchiveFilePath = GetArchiveFilePath(baseFolder); MaxArchivedFiles = 10; } /// /// Expands any user provided paths; and ensures default settings in case of placeholders. /// - public void Sanitize() + public void Sanitize(IFileSystem fs) { MaxArchivedFiles = MaxArchivedFiles < 0 ? 10 : MaxArchivedFiles; + + // Set default locations if none are provided. + var baseFolder = GetDefaultBaseDirectory(fs); + if (string.IsNullOrEmpty(FilePath.RawPath)) + FilePath = GetFilePath(baseFolder); + + if (string.IsNullOrEmpty(ArchiveFilePath.RawPath)) + ArchiveFilePath = GetArchiveFilePath(baseFolder); + + FilePath.ToAbsolutePath().Parent.CreateDirectory(); + ArchiveFilePath.ToAbsolutePath().Parent.CreateDirectory(); + } + + private static AbsolutePath GetDefaultBaseDirectory(IFileSystem fs) + { + return fs.OS.MatchPlatform( + () => fs.GetKnownPath(KnownPath.LocalApplicationDataDirectory).Combine("NexusMods.App/Logs"), + () => fs.GetKnownPath(KnownPath.XDG_STATE_HOME).Combine("NexusMods.App/Logs"), + () => throw new PlatformNotSupportedException( + "(Note: Sewer) Paths needs PR for macOS. I don't have a non-painful way to access a Mac.")); } + + private static ConfigurationPath GetFilePath(AbsolutePath baseFolder) => new(baseFolder.Combine(LogFileName)); + + private static ConfigurationPath GetArchiveFilePath(AbsolutePath baseFolder) => + new(baseFolder.Combine(LogFileNameTemplate)); } internal class AppConfigManager : IAppConfigManager diff --git a/src/NexusMods.App/AppConfig.json b/src/NexusMods.App/AppConfig.json index 70ee3c940e..39911ca6e4 100644 --- a/src/NexusMods.App/AppConfig.json +++ b/src/NexusMods.App/AppConfig.json @@ -1,16 +1,16 @@ { "DataModelSettings": { - "DataStoreFilePath": "{EntryFolder}/DataModel/DataModel.sqlite", - "IpcDataStoreFilePath": "{EntryFolder}/DataModel/DataModel_IPC.sqlite", + "DataStoreFilePath": "", + "IpcDataStoreFilePath": "", "ArchiveLocations": [ - "{EntryFolder}/Archives" + "" ], "MaxHashingJobs": -1, "LoadoutDeploymentJobs": -1, "MaxHashingThroughputBytesPerSecond": -1 }, "FileExtractorSettings": { - "TempFolderLocation": "{EntryFolder}/Temp" + "TempFolderLocation": "" }, "HttpDownloaderSettings": { "WriteQueueLength": 16, @@ -18,8 +18,8 @@ "CancelSpeedFraction": 0.66 }, "LoggingSettings": { - "FilePath": "{EntryFolder}/Logs/nexusmods.app.current.log", - "ArchiveFilePath": "{EntryFolder}/Logs/nexusmods.app.{##}.log", + "FilePath": "", + "ArchiveFilePath": "", "MaxArchivedFiles": 10 }, "LauncherSettings": { diff --git a/src/NexusMods.App/Program.cs b/src/NexusMods.App/Program.cs index 50881c9969..1ec9b1159c 100644 --- a/src/NexusMods.App/Program.cs +++ b/src/NexusMods.App/Program.cs @@ -44,11 +44,11 @@ public static async Task Main(string[] args) }); // Run in debug mode if we are in debug mode and the debugger is attached. - #if DEBUG +#if DEBUG var isDebug = Debugger.IsAttached; - #else +#else var isDebug = false; - #endif +#endif _logger.LogDebug("Application starting in {Mode} mode", isDebug ? "debug" : "release"); var startup = host.Services.GetRequiredService(); @@ -64,18 +64,7 @@ public static IHost BuildHost() // to ConfigureLogging; since the DI container isn't built until the host is. var config = new AppConfig(); var host = new HostBuilder() - .ConfigureServices(services => - { - // Bind the AppSettings class to the configuration and register it as a singleton service - // Question to Reviewers: Should this be moved to AddApp? - var appFolder = FileSystem.Shared.GetKnownPath(KnownPath.EntryDirectory); - var configJson = File.ReadAllText(appFolder.Combine("AppConfig.json").GetFullPath()); - - // Note: suppressed because invalid config will throw. - config = JsonSerializer.Deserialize(configJson)!; - config.Sanitize(); - services.AddApp(config).Validate(); - }) + .ConfigureServices(services => services.AddApp(ReadAppConfig(config)).Validate()) .ConfigureLogging((_, builder) => AddLogging(builder, config.LoggingSettings)) .Build(); @@ -83,11 +72,81 @@ public static IHost BuildHost() // do additional initialization inside their constructors. // We need to make sure their constructors are called to // finalize our OpenTelemetry configuration. - //host.Services.GetService(); + //host.Services.GetService(); //host.Services.GetService(); return host; } + private static AppConfig ReadAppConfig(AppConfig existingConfig) + { + // Read an App Config from the entry directory and sanitize if it exists. + var configJson = TryReadConfig(); + + if (configJson != null) + { + // If we can't deserialize, use default. + try + { + existingConfig = JsonSerializer.Deserialize(configJson)!; + } + catch (Exception) + { + /* Ignored */ + } + + existingConfig.Sanitize(FileSystem.Shared); + } + else + { + // No custom config so use default. + existingConfig.Sanitize(FileSystem.Shared); + } + + return existingConfig; + } + + private static string? TryReadConfig() + { + // Try to read an `AppConfig.json` from the entry directory + const string configFileName = "AppConfig.json"; + + // TODO: NexusMods.Paths needs ReadAllText API. For now we delegate to standard library because source is `FileSystem.Shared`. + + var fs = FileSystem.Shared; + if (fs.OS.IsLinux) + { + // On AppImage (Linux), 'OWD' should take precedence over the entry directory if it exists. + // https://docs.appimage.org/packaging-guide/environment-variables.html + var owd = Environment.GetEnvironmentVariable("OWD"); + if (!string.IsNullOrEmpty(owd)) + { + try + { + return File.ReadAllText(fs.FromUnsanitizedFullPath(owd).Combine(configFileName) + .GetFullPath()); + } + catch (Exception) + { + /* Ignored */ + } + } + } + + // Try App Folder + var appFolder = fs.GetKnownPath(KnownPath.EntryDirectory); + try + { + return File.ReadAllText(appFolder.Combine(configFileName).GetFullPath()); + } + catch (Exception) + { + /* Ignored */ + } + + // Config doesn't exist. + return null; + } + static void AddLogging(ILoggingBuilder loggingBuilder, ILoggingSettings settings) { var config = new NLog.Config.LoggingConfiguration(); diff --git a/src/NexusMods.DataModel/IDataModelSettings.cs b/src/NexusMods.DataModel/IDataModelSettings.cs index cfbab300e4..52356cea08 100644 --- a/src/NexusMods.DataModel/IDataModelSettings.cs +++ b/src/NexusMods.DataModel/IDataModelSettings.cs @@ -13,7 +13,7 @@ public interface IDataModelSettings /// If true, data model will be stored in memory only and the paths will be ignored. /// public bool UseInMemoryDataModel { get; } - + /// /// Path of the file which contains the backing data store or database. /// @@ -60,10 +60,10 @@ public class DataModelSettings : IDataModelSettings private const string DataModelFileName = "DataModel.sqlite"; private const string DataModelIpcFileName = "DataModel_IPC.sqlite"; private const string ArchivesFileName = "Archives"; - + /// public bool UseInMemoryDataModel { get; set; } - + /// public ConfigurationPath DataStoreFilePath { get; set; } @@ -81,7 +81,7 @@ public class DataModelSettings : IDataModelSettings /// public long MaxHashingThroughputBytesPerSecond { get; set; } = 0; - + /// /// Default constructor for serialization. /// @@ -90,7 +90,7 @@ public DataModelSettings() : this(FileSystem.Shared) { } /// /// Creates the default datamodel settings with a given base directory. /// - public DataModelSettings(IFileSystem s) : this(s.GetKnownPath(KnownPath.EntryDirectory).Combine("DataModel")) { } + public DataModelSettings(IFileSystem s) : this(GetDefaultBaseDirectory(s)) { } /// /// Creates the default datamodel settings with a given base directory. @@ -98,26 +98,62 @@ public DataModelSettings(IFileSystem s) : this(s.GetKnownPath(KnownPath.EntryDir /// The base directory to use. public DataModelSettings(AbsolutePath baseDirectory) { - baseDirectory.CreateDirectory(); - DataStoreFilePath = new ConfigurationPath(baseDirectory.Combine(DataModelFileName)); - IpcDataStoreFilePath = new ConfigurationPath(baseDirectory.Combine(DataModelIpcFileName)); - ArchiveLocations = new[] - { - new ConfigurationPath(baseDirectory.Combine(ArchivesFileName)) - }; + DataStoreFilePath = GetDefaultDataStoreFilePath(baseDirectory); + IpcDataStoreFilePath = GetDefaultIpcFilePath(baseDirectory); + ArchiveLocations = GetDefaultArchiveLocations(baseDirectory); } /// - /// Expands any user provided paths; and ensures default settings in case of placeholders. + /// Ensures default settings in case of placeholders of undefined/invalid settings. /// - public void Sanitize() + public void Sanitize(IFileSystem fs) { MaxHashingJobs = MaxHashingJobs < 0 ? Environment.ProcessorCount : MaxHashingJobs; LoadoutDeploymentJobs = LoadoutDeploymentJobs < 0 ? Environment.ProcessorCount : LoadoutDeploymentJobs; - MaxHashingThroughputBytesPerSecond = MaxHashingThroughputBytesPerSecond <= 0 ? 0 : MaxHashingThroughputBytesPerSecond; + MaxHashingThroughputBytesPerSecond = + MaxHashingThroughputBytesPerSecond <= 0 ? 0 : MaxHashingThroughputBytesPerSecond; + + // Deduplicate: This is necessary in case user has duplicates, or (in the past) MSFT configuration + // binder would insert a duplicate. + ArchiveLocations = ArchiveLocations.Distinct().Where(x => !string.IsNullOrEmpty(x.GetFullPath())).ToArray(); + + // Set default locations if none are provided. + var baseDir = GetDefaultBaseDirectory(fs); + if (ArchiveLocations.Length == 0) + ArchiveLocations = GetDefaultArchiveLocations(baseDir); + + if (string.IsNullOrEmpty(DataStoreFilePath.RawPath)) + DataStoreFilePath = GetDefaultDataStoreFilePath(baseDir); + + if (string.IsNullOrEmpty(IpcDataStoreFilePath.RawPath)) + IpcDataStoreFilePath = GetDefaultIpcFilePath(baseDir); - // Deduplicate: This is necessary in case user has duplicates, or the MSFT configuration - // binder inserts a duplicate. - ArchiveLocations = ArchiveLocations.Distinct().ToArray(); + // Ensure all locations exist + foreach (var location in ArchiveLocations) + location.ToAbsolutePath().CreateDirectory(); + + DataStoreFilePath.ToAbsolutePath().Parent.CreateDirectory(); + IpcDataStoreFilePath.ToAbsolutePath().Parent.CreateDirectory(); } + + private static AbsolutePath GetDefaultBaseDirectory(IFileSystem fs) + { + return fs.OS.MatchPlatform( + () => fs.GetKnownPath(KnownPath.LocalApplicationDataDirectory).Combine("NexusMods.App/DataModel"), + () => fs.GetKnownPath(KnownPath.XDG_DATA_HOME).Combine("NexusMods.App/DataModel"), + () => throw new PlatformNotSupportedException( + "(Note: Sewer) Paths needs PR for macOS. I don't have a non-painful way to access a Mac.")); + } + + private static ConfigurationPath GetDefaultDataStoreFilePath(AbsolutePath baseDirectory) => + new(baseDirectory.Combine(DataModelFileName)); + + private static ConfigurationPath GetDefaultIpcFilePath(AbsolutePath baseDirectory) => + new(baseDirectory.Combine(DataModelIpcFileName)); + + private static ConfigurationPath[] GetDefaultArchiveLocations(AbsolutePath baseDirectory) => + new[] + { + new ConfigurationPath(baseDirectory.Combine(ArchivesFileName)) + }; }