diff --git a/CHANGELIST.md b/CHANGELIST.md index 07732457f..3a66022b4 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -56,6 +56,7 @@ - Add man page output to the command-line programs (gmipf) - Add a man page for the graphical frontend (gmipf) - Normalize CLI feature handling +- Root the default output path when the current folder is unwritable (gmipf) ### 3.8.3 (2026-07-06) diff --git a/MPF.Frontend.Test/OptionsTests.cs b/MPF.Frontend.Test/OptionsTests.cs new file mode 100644 index 000000000..4ae5d769e --- /dev/null +++ b/MPF.Frontend.Test/OptionsTests.cs @@ -0,0 +1,79 @@ +using System.IO; +using SabreTools.IO; +using Xunit; + +namespace MPF.Frontend.Test +{ + public class OptionsTests + { + /// + /// A writable current directory keeps the relative default + /// + [Fact] + public void GetDefaultOutputPath_WritableDirectory_ReturnsRelativePath() + { + string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(currentDirectory); + + try + { + string actual = DumpSettings.GetDefaultOutputPath(currentDirectory); + Assert.Equal("ISO", actual); + } + finally + { + Directory.Delete(currentDirectory); + } + } + + /// + /// A current directory that can not hold a file falls back to the home directory + /// + /// + /// A file stands in for the directory because that is the only way of making the creation + /// fail on every platform. Denied permissions end up in the same branch. + /// + [Fact] + public void GetDefaultOutputPath_UnwritableDirectory_ReturnsHomePath() + { + string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + File.Create(currentDirectory).Dispose(); + + try + { + string actual = DumpSettings.GetDefaultOutputPath(currentDirectory); + Assert.Equal(Path.Combine(PathTool.GetHomeDirectory(), "ISO"), actual); + } + finally + { + File.Delete(currentDirectory); + } + } + + /// + /// A missing current directory falls back to the home directory + /// + [Fact] + public void GetDefaultOutputPath_MissingDirectory_ReturnsHomePath() + { + string currentDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + string actual = DumpSettings.GetDefaultOutputPath(currentDirectory); + + Assert.Equal(Path.Combine(PathTool.GetHomeDirectory(), "ISO"), actual); + } + + /// + /// Clearing the default output path restores the default instead of leaving it empty + /// + [Fact] + public void DefaultOutputPath_SetToNull_RestoresDefault() + { + var options = new Options(); + + options.Dumping.DefaultOutputPath = null; + + Assert.Equal(DumpSettings.DefaultOutputPathValue, options.Dumping.DefaultOutputPath); + } + } +} diff --git a/MPF.Frontend/Options.cs b/MPF.Frontend/Options.cs index 884005c8c..d1707f1f6 100644 --- a/MPF.Frontend/Options.cs +++ b/MPF.Frontend/Options.cs @@ -1,6 +1,7 @@ using System; using System.IO; using Newtonsoft.Json; +using SabreTools.IO; using SabreTools.RedumpLib.Data; using AaruDumpSettings = MPF.ExecutionContexts.Aaru.DumpSettings; using DiscImageCreatorDumpSettings = MPF.ExecutionContexts.DiscImageCreator.DumpSettings; @@ -179,6 +180,11 @@ public Options(Options? source) /// public class DumpSettings { + /// + /// Default output directory name + /// + private const string DefaultOutputDirectoryName = "ISO"; + #region Default Paths /// @@ -281,6 +287,57 @@ internal static string DefaultRedumperPath } } + /// + /// Default output path for dumps + /// + /// + /// The relative path is kept wherever the current directory can be written to, which is the + /// portable layout. It only resolves to the home directory when the current directory can + /// not be written to, such as an installed copy launched from a read-only location. + /// + [JsonIgnore] + internal static string DefaultOutputPathValue + { + get + { + if (field is not null) + return field; + + field = GetDefaultOutputPath(Environment.CurrentDirectory); + return field; + } + } + + /// + /// Attempt to determine the default output path + /// + /// Directory that a relative path would resolve against + internal static string GetDefaultOutputPath(string currentDirectory) + { + // Portable output -- keep the relative path wherever the current directory can hold a + // file. Creating one is the only portable way of asking; the probe is removed again, and + // only the creation decides so that a failed cleanup does not read as unwritable. + if (!string.IsNullOrEmpty(currentDirectory)) + { + try + { + string probePath = Path.Combine(currentDirectory, Path.GetRandomFileName()); + File.Create(probePath).Dispose(); + try { File.Delete(probePath); } catch { } + return DefaultOutputDirectoryName; + } + catch { } + } + + // User output + string homeDir = PathTool.GetHomeDirectory(); + if (!string.IsNullOrEmpty(homeDir)) + return Path.Combine(homeDir, DefaultOutputDirectoryName); + + // This should not happen + return DefaultOutputDirectoryName; + } + #endregion #region Internal Program @@ -350,9 +407,9 @@ public string? DefaultOutputPath get; set { - field = value ?? "ISO"; + field = value ?? DefaultOutputPathValue; } - } = "ISO"; + } = DefaultOutputPathValue; /// /// Default system if none can be detected diff --git a/MPF.Frontend/Tools/OptionsLoader.cs b/MPF.Frontend/Tools/OptionsLoader.cs index 05362a26a..64cfd8d23 100644 --- a/MPF.Frontend/Tools/OptionsLoader.cs +++ b/MPF.Frontend/Tools/OptionsLoader.cs @@ -322,7 +322,7 @@ private static Options ConvertFromDictionary(Dictionary? source options.Dumping.DreamdumpPath = GetStringSetting(source, "DreamdumpPath", DumpSettings.DefaultDreamdumpPath) ?? DumpSettings.DefaultDreamdumpPath; options.Dumping.RedumperPath = GetStringSetting(source, "RedumperPath", DumpSettings.DefaultRedumperPath) ?? DumpSettings.DefaultRedumperPath; - options.Dumping.DefaultOutputPath = GetStringSetting(source, "DefaultOutputPath", "ISO"); + options.Dumping.DefaultOutputPath = GetStringSetting(source, "DefaultOutputPath", DumpSettings.DefaultOutputPathValue); valueString = GetStringSetting(source, "DefaultSystem", PhysicalSystem.IBMPCcompatible.ToString()); options.Dumping.DefaultSystem = (valueString ?? string.Empty).ToPhysicalSystem(); options.Dumping.DumpSpeeds.CD = GetInt32Setting(source, "PreferredDumpSpeedCD", 24);