diff --git a/README.md b/README.md index db71eae..e670a49 100644 --- a/README.md +++ b/README.md @@ -423,7 +423,13 @@ the migrations for N1MM's own admin databases and the program it speaks with, and neither has anything to do with a log. On a system that does not say where Documents is — most Linux systems, until -someone sets `XDG_DOCUMENTS_DIR` — it is `~/Documents/N1MM Logger+`. +someone sets `XDG_DOCUMENTS_DIR` — it is `~/Documents/N1MM Logger+`. Setting +`NONEMM_HOME` puts the whole lot somewhere else, which is what a station keeping +its files off the Documents folder, or running two copies side by side, wants. + +If a folder cannot be made — something else is already sitting at that path, or +the disk will not have it — the program says so in a window and stops, rather +than dying with a stack trace before it has a window to say anything in. ### Operators diff --git a/src/Nonemm.App/App.axaml.cs b/src/Nonemm.App/App.axaml.cs index d1c5a40..88d35d3 100644 --- a/src/Nonemm.App/App.axaml.cs +++ b/src/Nonemm.App/App.axaml.cs @@ -17,7 +17,12 @@ public partial class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { UserPaths paths = UserPaths.Default(); - paths.CreateFolders(); + if (paths.CreateFolders() is { } problem) + { + desktop.MainWindow = new StartupProblemWindow(problem, paths.Root); + base.OnFrameworkInitializationCompleted(); + return; + } Settings settings = Settings.Load(paths.SettingsFile); Themes.Use(settings.Theme); Themes.Changed += (_, _) => ApplyTheme(); diff --git a/src/Nonemm.App/Configuration/UserPaths.cs b/src/Nonemm.App/Configuration/UserPaths.cs index 11c85d3..c9b1a70 100644 --- a/src/Nonemm.App/Configuration/UserPaths.cs +++ b/src/Nonemm.App/Configuration/UserPaths.cs @@ -33,7 +33,13 @@ public sealed class UserPaths Own = Path.Combine(root, "Nonemm"); } - public static UserPaths Default() => new(DefaultRoot()); + /// `NONEMM_HOME` moves the whole lot somewhere else, for a station that + /// keeps its files off the Documents folder or runs two copies side by + /// side. + public static UserPaths Default() => + new(Environment.GetEnvironmentVariable("NONEMM_HOME") is { Length: > 0 } wanted + ? wanted + : DefaultRoot()); public string Root { get; } @@ -97,12 +103,34 @@ public sealed class UserPaths ? Wav : Path.Combine(Wav, operatorCallsign.Trim().ToUpperInvariant()); - public void CreateFolders() + /// Makes every folder, and answers with what stopped it rather than + /// throwing: this runs before there is a window to report anything in, and + /// a program that dies with a stack trace tells the operator nothing. + public string? CreateFolders() { foreach (string folder in All()) { - Directory.CreateDirectory(folder); + if (Directory.Exists(folder)) + { + continue; + } + if (File.Exists(folder)) + { + return $"{folder} is a file, not a folder. Move it out of the way and start again."; + } + try + { + Directory.CreateDirectory(folder); + } + catch (Exception failure) when (failure is IOException or UnauthorizedAccessException) + { + // something is already there that is neither a file nor a + // folder — a link to somewhere that is not there is the usual + // one — or the disk will not have it + return $"{folder} could not be made: {failure.Message}"; + } } + return null; } /// Every folder that is made at startup, the root first. diff --git a/src/Nonemm.App/Windows/StartupProblemWindow.cs b/src/Nonemm.App/Windows/StartupProblemWindow.cs new file mode 100644 index 0000000..55d9368 --- /dev/null +++ b/src/Nonemm.App/Windows/StartupProblemWindow.cs @@ -0,0 +1,52 @@ +using Avalonia.Controls; +using Avalonia.Layout; +using Avalonia.Media; + +namespace Nonemm.App.Windows; + +/// What the operator sees when the program cannot make the folders it keeps +/// everything in. There is no log, no settings and no entry window at that +/// point, so this says what is wrong and what to do about it, and closing it +/// ends the program. +public sealed class StartupProblemWindow : Window +{ + public StartupProblemWindow(string problem, string root) + { + Title = "Nonemm cannot start"; + Width = 560; + SizeToContent = SizeToContent.Height; + WindowStartupLocation = WindowStartupLocation.CenterScreen; + Content = new StackPanel + { + Margin = new Avalonia.Thickness(18), + Spacing = 10, + Children = + { + new TextBlock + { + Text = "The program keeps its files with N1MM's, and could not make them:", + TextWrapping = TextWrapping.Wrap, + }, + new TextBlock { Text = problem, TextWrapping = TextWrapping.Wrap, FontWeight = FontWeight.Bold }, + new TextBlock + { + Text = $"The folder it works in is {root}. Setting NONEMM_HOME to another " + + "path moves the whole lot somewhere else.", + TextWrapping = TextWrapping.Wrap, + FontSize = 11, + Opacity = 0.75, + }, + new Button + { + Content = "Quit", + HorizontalAlignment = HorizontalAlignment.Right, + IsDefault = true, + }, + }, + }; + if (((StackPanel)Content).Children[^1] is Button quit) + { + quit.Click += (_, _) => Close(); + } + } +} diff --git a/tests/Nonemm.App.Tests/UserPathsTests.cs b/tests/Nonemm.App.Tests/UserPathsTests.cs index 5b6a800..f623bf5 100644 --- a/tests/Nonemm.App.Tests/UserPathsTests.cs +++ b/tests/Nonemm.App.Tests/UserPathsTests.cs @@ -20,7 +20,7 @@ public class UserPathsTests : IDisposable { UserPaths paths = new(root); - paths.CreateFolders(); + Assert.Null(paths.CreateFolders()); string[] made = [.. Directory.GetDirectories(root).Select(Path.GetFileName).OfType().Order()]; Assert.Equal( @@ -62,4 +62,32 @@ public class UserPathsTests : IDisposable Assert.Equal(Path.Combine(root, "Nonemm", "settings.json"), paths.SettingsFile); } + + /// A file where the folder should be stops the program starting, so it says + /// so rather than throwing at an operator who has no window to read it in. + [Fact] + public void AFileWhereTheFolderShouldBeIsReported() + { + Directory.CreateDirectory(root); + string inTheWay = Path.Combine(root, "N1MM Logger+"); + File.WriteAllText(inTheWay, "not a folder"); + UserPaths paths = new(inTheWay); + + string? problem = paths.CreateFolders(); + + Assert.NotNull(problem); + Assert.Contains(inTheWay, problem); + Assert.Contains("is a file", problem); + } + + [Fact] + public void FoldersThatAreAlreadyThereAreLeftAlone() + { + UserPaths paths = new(root); + paths.CreateFolders(); + File.WriteAllText(Path.Combine(paths.Databases, "om5m.s3db"), "log"); + + Assert.Null(paths.CreateFolders()); + Assert.Equal("log", File.ReadAllText(Path.Combine(paths.Databases, "om5m.s3db"))); + } }