diff --git a/Fronter.NET.Tests/Extensions/TranslationSourceTests.cs b/Fronter.NET.Tests/Extensions/TranslationSourceTests.cs index 84cf6bd8..d22aaa6b 100644 --- a/Fronter.NET.Tests/Extensions/TranslationSourceTests.cs +++ b/Fronter.NET.Tests/Extensions/TranslationSourceTests.cs @@ -68,6 +68,37 @@ public void InvalidSavedLanguageFallsBackToEnglish() { } } + [Fact] + public void TranslationsAreLoadedFromBaseDirectoryNotWorkingDirectory() { + // Reproduce the macOS launch scenario: CWD is a different directory that + // happens to contain its own localization files with different content. + var realBaseDir = CreateLocalizationRoot(); + var decoyWorkingDirectory = CreateLocalizationRoot(); + var previousWorkingDirectory = Directory.GetCurrentDirectory(); + try { + WriteLocalizationFile(realBaseDir, "english", "EXIT", "Exit"); + WriteLocalizationFile(decoyWorkingDirectory, "english", "EXIT", "Decoy"); + + Directory.SetCurrentDirectory(decoyWorkingDirectory); + + var source = new TranslationSource(realBaseDir); + + // Translations must come from the base directory, not the working directory. + Assert.Equal("Exit", source.Translate("EXIT")); + + source.SaveLanguage("english"); + + // The saved language file must be written next to the executable's files. + Assert.True(File.Exists(Path.Combine(realBaseDir, "Configuration", "fronter-language.txt"))); + Assert.False(File.Exists(Path.Combine(decoyWorkingDirectory, "Configuration", "fronter-language.txt"))); + } + finally { + Directory.SetCurrentDirectory(previousWorkingDirectory); + Cleanup(realBaseDir); + Cleanup(decoyWorkingDirectory); + } + } + private static string CreateLocalizationRoot() { var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); Directory.CreateDirectory(Path.Combine(tempDir, "Configuration")); diff --git a/Fronter.NET.Tests/FronterPathsTests.cs b/Fronter.NET.Tests/FronterPathsTests.cs new file mode 100644 index 00000000..b7a93b9d --- /dev/null +++ b/Fronter.NET.Tests/FronterPathsTests.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using Xunit; + +namespace Fronter.Tests; + +// This collection disables parallelization, so changing the process working +// directory in these tests cannot affect other tests. +[Collection("Sequential")] +public sealed class FronterPathsTests { + [Fact] + public void AllPathsAreRootedInBaseDirectoryIndependentlyOfWorkingDirectory() { + // Reproduce the macOS launch scenario: the process working directory is + // NOT the directory containing the executable (Finder/Dock set CWD to "/"). + var decoyDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var previousWorkingDirectory = Directory.GetCurrentDirectory(); + try { + Directory.CreateDirectory(decoyDirectory); + Directory.SetCurrentDirectory(decoyDirectory); + + var baseDirectory = FronterPaths.BaseDirectory; + Assert.Equal(AppContext.BaseDirectory, baseDirectory); + Assert.Equal(Path.Combine(baseDirectory, "log.txt"), FronterPaths.LogFilePath); + Assert.Equal(Path.Combine(baseDirectory, "Configuration", "fronter-theme.txt"), FronterPaths.ThemeFilePath); + Assert.Equal(Path.Combine(baseDirectory, "temp"), FronterPaths.TempDirectoryPath); + Assert.Equal(Path.Combine(baseDirectory, "Updater"), FronterPaths.UpdaterDirectoryPath); + Assert.Equal(Path.Combine(baseDirectory, "Updater-running"), FronterPaths.UpdaterRunningDirectoryPath); + + // Every path must be absolute and point inside the base directory. + string[] paths = [ + FronterPaths.LogFilePath, + FronterPaths.ThemeFilePath, + FronterPaths.TempDirectoryPath, + FronterPaths.UpdaterDirectoryPath, + FronterPaths.UpdaterRunningDirectoryPath, + ]; + foreach (var path in paths) { + Assert.True(Path.IsPathFullyQualified(path), $"Path is not fully qualified: {path}"); + Assert.StartsWith(baseDirectory, path); + } + } + finally { + Directory.SetCurrentDirectory(previousWorkingDirectory); + if (Directory.Exists(decoyDirectory)) { + Directory.Delete(decoyDirectory, recursive: true); + } + } + } +} \ No newline at end of file diff --git a/Fronter.NET.Tests/Services/LoggingConfiguratorTests.cs b/Fronter.NET.Tests/Services/LoggingConfiguratorTests.cs index 792e7375..52c7e0d1 100644 --- a/Fronter.NET.Tests/Services/LoggingConfiguratorTests.cs +++ b/Fronter.NET.Tests/Services/LoggingConfiguratorTests.cs @@ -1,4 +1,5 @@ using log4net; +using System; using System.IO; using System.Text; using Xunit; @@ -26,4 +27,36 @@ public void MessagesAreLoggedToLogTxtFile() { Assert.Contains("Test warning", logFileContent); Assert.Contains("Test error", logFileContent); } + + [Fact] + public void LogsAreWrittenNextToExecutableNotCurrentWorkingDirectory() { + // Reproduce the macOS launch scenario: CWD is not the app's directory. + var decoyDirectory = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); + var previousWorkingDirectory = Directory.GetCurrentDirectory(); + try { + Directory.CreateDirectory(decoyDirectory); + Directory.SetCurrentDirectory(decoyDirectory); + + LoggingConfigurator.ConfigureLogging(useConsole: false); + + var logger = LogManager.GetLogger(typeof(LoggingConfiguratorTests)); + logger.Info("Path resolution test message"); + + Assert.True(File.Exists(FronterPaths.LogFilePath), $"Expected log file at {FronterPaths.LogFilePath}"); + + using var fs = new FileStream(FronterPaths.LogFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var sr = new StreamReader(fs, Encoding.Default); + var logFileContent = sr.ReadToEnd(); + Assert.Contains("Path resolution test message", logFileContent); + + // Nothing may be written into the working directory. + Assert.False(File.Exists(Path.Combine(decoyDirectory, "log.txt"))); + } + finally { + Directory.SetCurrentDirectory(previousWorkingDirectory); + if (Directory.Exists(decoyDirectory)) { + Directory.Delete(decoyDirectory, recursive: true); + } + } + } } \ No newline at end of file diff --git a/Fronter.NET/App.axaml.cs b/Fronter.NET/App.axaml.cs index df977773..6c8058a8 100644 --- a/Fronter.NET/App.axaml.cs +++ b/Fronter.NET/App.axaml.cs @@ -18,7 +18,7 @@ namespace Fronter; internal sealed class App : Application { private static readonly ILog logger = LogManager.GetLogger("Frontend"); - private const string FronterThemePath = "Configuration/fronter-theme.txt"; + private static readonly string FronterThemePath = FronterPaths.ThemeFilePath; private const string DefaultTheme = "Default"; public override void Initialize() { diff --git a/Fronter.NET/Extensions/TranslationSource.cs b/Fronter.NET/Extensions/TranslationSource.cs index e4a2fc2f..2ea9d20d 100644 --- a/Fronter.NET/Extensions/TranslationSource.cs +++ b/Fronter.NET/Extensions/TranslationSource.cs @@ -20,7 +20,7 @@ internal sealed partial class TranslationSource : ReactiveObject { private readonly Lock translationsLock = new(); private readonly string baseDirectory; private readonly Dictionary> localizationFilePathsByLanguage = new(StringComparer.Ordinal); - private readonly HashSet loadedTranslationLanguages = new(StringComparer.Ordinal); + private readonly HashSet loadedTranslationLanguages = [with(StringComparer.Ordinal)]; private int deferredTranslationsLoadStarted; private Task? deferredTranslationsLoadTask; @@ -32,7 +32,7 @@ internal TranslationSource(string baseDirectory) { string languagesPath = Path.Combine(baseDirectory, "languages.txt"); if (!File.Exists(languagesPath)) { - logger.Error("No languages dictionary found!"); + logger.Error($"No languages dictionary found at {languagesPath}!"); return; } diff --git a/Fronter.NET/FronterPaths.cs b/Fronter.NET/FronterPaths.cs new file mode 100644 index 00000000..a3b14126 --- /dev/null +++ b/Fronter.NET/FronterPaths.cs @@ -0,0 +1,25 @@ +using System; +using System.IO; + +namespace Fronter; + +// Central place for resolving app-relative file paths. +// All paths must be rooted at AppContext.BaseDirectory (the directory containing +// the executable) rather than the current working directory: on macOS, launching +// the app from Finder/Dock sets the working directory to "/", which used to make +// the app unable to find its files (see ImperatorToCK3 issue #2471). +internal static class FronterPaths { + public static string BaseDirectory => AppContext.BaseDirectory; + + public static string ConfigurationDirectoryPath => Path.Combine(BaseDirectory, "Configuration"); + + public static string ThemeFilePath => Path.Combine(ConfigurationDirectoryPath, "fronter-theme.txt"); + + public static string LogFilePath => Path.Combine(BaseDirectory, "log.txt"); + + public static string TempDirectoryPath => Path.Combine(BaseDirectory, "temp"); + + public static string UpdaterDirectoryPath => Path.Combine(BaseDirectory, "Updater"); + + public static string UpdaterRunningDirectoryPath => Path.Combine(BaseDirectory, "Updater-running"); +} \ No newline at end of file diff --git a/Fronter.NET/LoggingConfigurator.cs b/Fronter.NET/LoggingConfigurator.cs index 1a66bd01..edf65291 100644 --- a/Fronter.NET/LoggingConfigurator.cs +++ b/Fronter.NET/LoggingConfigurator.cs @@ -26,7 +26,7 @@ public static void ConfigureLogging(bool useConsole = false) { layout.ActivateOptions(); var fileAppender = new FileAppender { Name = "file", - File = "log.txt", + File = FronterPaths.LogFilePath, AppendToFile = false, Threshold = Level.All, Layout = layout, diff --git a/Fronter.NET/Services/ConverterLauncher.cs b/Fronter.NET/Services/ConverterLauncher.cs index 2a0f59ee..47c60c95 100644 --- a/Fronter.NET/Services/ConverterLauncher.cs +++ b/Fronter.NET/Services/ConverterLauncher.cs @@ -237,19 +237,19 @@ private static async Task GetSaveUploadConsent() { } private static async Task AttachLogAndSaveToSentry(Config config, SentryHelper sentryHelper) { - sentryHelper.AddAttachment("log.txt"); + sentryHelper.AddAttachment(FronterPaths.LogFilePath); var saveLocation = config.RequiredFiles.FirstOrDefault(f => f.Name.Equals("SaveGame"))?.Value; if (saveLocation is null) { return; } - Directory.CreateDirectory("temp"); + Directory.CreateDirectory(FronterPaths.TempDirectoryPath); // Create zip with save file. var dateTimeString = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss", CultureInfo.InvariantCulture); var asciiSaveName = CommonFunctions.TrimExtension(Path.GetFileName(saveLocation)).FoldToASCII(); - var archivePath = $"temp/SaveGame_{dateTimeString}_{asciiSaveName}.zip"; + var archivePath = Path.Combine(FronterPaths.TempDirectoryPath, $"SaveGame_{dateTimeString}_{asciiSaveName}.zip"); using (var zip = ZipFile.Open(archivePath, ZipArchiveMode.Create)) { zip.CreateEntryFromFile(saveLocation, new FileInfo(saveLocation).Name); } @@ -257,7 +257,7 @@ private static async Task AttachLogAndSaveToSentry(Config config, SentryHelper s // Sentry allows up to 20 MB per compressed request. // So we need to calculate whether we can fit the save archive. // Otherwise we upload it to Backblaze. - var logSize = new FileInfo("log.txt").Length; // Size in bytes. + var logSize = new FileInfo(FronterPaths.LogFilePath).Length; // Size in bytes. const int spaceForBaseRequest = 1024 * 1024 / 2; // 0.5 MB, arbitrary. var saveSizeLimitForSentry = (20 * 1024 * 1024) - (logSize + spaceForBaseRequest); var saveArchiveSize = new FileInfo(archivePath).Length; diff --git a/Fronter.NET/Services/UpdateChecker.cs b/Fronter.NET/Services/UpdateChecker.cs index 284fa525..829303c5 100644 --- a/Fronter.NET/Services/UpdateChecker.cs +++ b/Fronter.NET/Services/UpdateChecker.cs @@ -434,8 +434,8 @@ public static async Task RunInstallerAndDie(string installerUrl, Config config, } public static void StartUpdaterAndDie(string archiveUrl, string converterBackendDirName) { - var updaterDirPath = Path.Combine(".", "Updater"); - var updaterRunningDirPath = Path.Combine(".", "Updater-running"); + var updaterDirPath = FronterPaths.UpdaterDirectoryPath; + var updaterRunningDirPath = FronterPaths.UpdaterRunningDirectoryPath; const string manualUpdateHint = "Try updating the converter manually."; if (Directory.Exists(updaterRunningDirPath) && !FileSystemHelper.TryDeleteFolder(updaterRunningDirPath)) {