fix: Add app data dir migration step for OSX

spectre-console-remove-di-hacks
Robert Dailey 6 months ago
parent 57c4e23dba
commit f769c9669d

@ -114,5 +114,6 @@
<s:Boolean x:Key="/Default/UserDictionary/Words/=Persister/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=radarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Recyclarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Recyclarr_0027s/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Servarr/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/UserDictionary/Words/=Sonarr/@EntryIndexedValue">True</s:Boolean></wpf:ResourceDictionary>

@ -1,4 +1,5 @@
using Autofac;
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Migration.Steps;
namespace Recyclarr.Cli.Migration;
@ -11,8 +12,10 @@ public class MigrationAutofacModule : Module
builder.RegisterType<MigrationExecutor>().As<IMigrationExecutor>();
// Migration Steps
builder.RegisterAssemblyTypes(ThisAssembly)
.AssignableTo<IMigrationStep>()
.As<IMigrationStep>();
builder.RegisterTypes(
typeof(MoveOsxAppDataDotnet8),
typeof(DeleteRepoDirMigrationStep))
.As<IMigrationStep>()
.OrderByRegistration();
}
}

@ -3,20 +3,11 @@ using Spectre.Console;
namespace Recyclarr.Cli.Migration;
public class MigrationExecutor : IMigrationExecutor
public class MigrationExecutor(IOrderedEnumerable<IMigrationStep> migrationSteps, IAnsiConsole console)
: IMigrationExecutor
{
private readonly IAnsiConsole _console;
private readonly List<IMigrationStep> _migrationSteps;
public MigrationExecutor(IEnumerable<IMigrationStep> migrationSteps, IAnsiConsole console)
{
_console = console;
_migrationSteps = migrationSteps.OrderBy(x => x.Order).ToList();
}
private void PerformMigrationStepsImpl(bool withDiagnostics, IEnumerable<IMigrationStep> migrationSteps)
public void PerformAllMigrationSteps(bool withDiagnostics)
{
// ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator
foreach (var step in migrationSteps)
{
// Do not use LINQ to filter using CheckIfNeeded(). If it returns true, then 'Execute()' must be invoked to
@ -29,25 +20,20 @@ public class MigrationExecutor : IMigrationExecutor
try
{
step.Execute(withDiagnostics ? _console : null);
step.Execute(withDiagnostics ? console : null);
}
catch (Exception e) when (e is not MigrationException)
{
throw new MigrationException(e, step.Description, step.Remediation);
}
_console.WriteLine($"Migrate: {step.Description}");
console.WriteLine($"Migrate: {step.Description}");
}
}
public void PerformAllMigrationSteps(bool withDiagnostics)
{
PerformMigrationStepsImpl(withDiagnostics, _migrationSteps);
}
public void CheckNeededMigrations()
{
var neededMigrationSteps = _migrationSteps.Where(x => x.CheckIfNeeded()).ToList();
var neededMigrationSteps = migrationSteps.Where(x => x.CheckIfNeeded()).ToList();
if (neededMigrationSteps.Count == 0)
{
return;
@ -58,11 +44,11 @@ public class MigrationExecutor : IMigrationExecutor
foreach (var step in neededMigrationSteps)
{
var requiredText = step.Required ? "Required" : "Not Required";
_console.WriteLine($"Migration Needed ({requiredText}): {step.Description}");
console.WriteLine($"Migration Needed ({requiredText}): {step.Description}");
wereAnyRequired |= step.Required;
}
_console.WriteLine(
console.WriteLine(
"\nRun the `migrate` subcommand to perform the above migration steps automatically\n");
if (wereAnyRequired)

@ -9,7 +9,6 @@ namespace Recyclarr.Cli.Migration.Steps;
[UsedImplicitly]
public class DeleteRepoDirMigrationStep(IAppPaths paths) : IMigrationStep
{
public int Order => 1;
public string Description => "Delete old repo directory";
public IReadOnlyCollection<string> Remediation => new[]
{

@ -4,11 +4,6 @@ namespace Recyclarr.Cli.Migration.Steps;
public interface IMigrationStep
{
/// <summary>
/// Determines the order in which this migration step will run.
/// </summary>
int Order { get; }
/// <summary>
/// A description printed to the user so that they understand the purpose of this migration step, and
/// what it does.

@ -0,0 +1,43 @@
using System.IO.Abstractions;
using JetBrains.Annotations;
using Recyclarr.Common.Extensions;
using Recyclarr.Platform;
using Spectre.Console;
namespace Recyclarr.Cli.Migration.Steps;
[UsedImplicitly]
public class MoveOsxAppDataDotnet8(
IAppPaths paths,
IEnvironment env,
IRuntimeInformation runtimeInfo,
IFileSystem fs)
: IMigrationStep
{
public string Description => "Migrate OSX app data to 'Library/Application Support'";
public IReadOnlyCollection<string> Remediation => new[]
{
$"Ensure Recyclarr has permission to move {OldAppDataDir} to {NewAppDataDir} and try again",
$"Move {OldAppDataDir} to {NewAppDataDir} manually if Recyclarr can't do it"
};
public bool Required => true;
private IDirectoryInfo OldAppDataDir => fs.DirectoryInfo
.New(env.GetFolderPath(Environment.SpecialFolder.UserProfile))
.SubDir(".config", AppPaths.DefaultAppDataDirectoryName);
private IDirectoryInfo NewAppDataDir => paths.AppDataDirectory;
public bool CheckIfNeeded()
{
return runtimeInfo.IsPlatformOsx() && OldAppDataDir.Exists;
}
public void Execute(IAnsiConsole? console)
{
NewAppDataDir.Create();
OldAppDataDir.MoveTo(NewAppDataDir.FullName);
console?.WriteLine($"Moved app settings dir from '{OldAppDataDir}' to '{NewAppDataDir}'");
}
}

@ -8,7 +8,6 @@ public class CommonAutofacModule : Module
protected override void Load(ContainerBuilder builder)
{
base.Load(builder);
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<RuntimeValidationService>().As<IRuntimeValidationService>();
}
}

@ -1,5 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using Recyclarr.Common;
using Recyclarr.Platform;
using YamlDotNet.Core;
using YamlDotNet.Core.Events;
using YamlDotNet.Serialization;

@ -1,5 +1,5 @@
using JetBrains.Annotations;
using Recyclarr.Common;
using Recyclarr.Platform;
using Recyclarr.Yaml;
using YamlDotNet.Serialization;

@ -1,17 +1,19 @@
using System.IO.Abstractions;
using Recyclarr.Common;
namespace Recyclarr.Platform;
public class DefaultAppDataSetup(IEnvironment env, IFileSystem fs)
public class DefaultAppDataSetup(
IEnvironment env,
IFileSystem fs,
IRuntimeInformation runtimeInfo)
{
public IAppPaths CreateAppPaths(string? appDataDirectoryOverride = null, bool forceCreate = true)
public IAppPaths CreateAppPaths(string? appDataDirectoryOverride = null)
{
var appDir = GetAppDataDirectory(appDataDirectoryOverride, forceCreate);
var appDir = GetAppDataDirectory(appDataDirectoryOverride);
return new AppPaths(fs.DirectoryInfo.New(appDir));
}
private string GetAppDataDirectory(string? appDataDirectoryOverride, bool forceCreate)
private string GetAppDataDirectory(string? appDataDirectoryOverride)
{
// If a specific app data directory is not provided, use the following environment variable to find the path.
appDataDirectoryOverride ??= env.GetEnvironmentVariable("RECYCLARR_APP_DATA");
@ -22,26 +24,40 @@ public class DefaultAppDataSetup(IEnvironment env, IFileSystem fs)
return fs.Directory.CreateDirectory(appDataDirectoryOverride).FullName;
}
// If we can't even get the $HOME directory value, throw an exception. User must explicitly specify it with
// --app-data.
var home = env.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(home))
// Set app data path to application directory value (e.g. `$HOME/.config` on Linux) and ensure it is
// created.
var appData = env.GetFolderPath(
Environment.SpecialFolder.ApplicationData,
Environment.SpecialFolderOption.Create);
if (string.IsNullOrEmpty(appData))
{
throw new NoHomeDirectoryException(
"The system does not have a HOME directory, so the application cannot determine where to place " +
"data files. Please use the --app-data option to explicitly set a location for these files");
"Unable to find or create the default app data directory. The application cannot determine where " +
"to place data files. Please use the --app-data option to explicitly set a location for these files.");
}
// Set app data path to application directory value (e.g. `$HOME/.config` on Linux) and ensure it is
// created.
var appData = env.GetFolderPath(Environment.SpecialFolder.ApplicationData,
forceCreate ? Environment.SpecialFolderOption.Create : Environment.SpecialFolderOption.None);
appData = fs.Path.Combine(appData, AppPaths.DefaultAppDataDirectoryName);
if (string.IsNullOrEmpty(appData))
try
{
if (runtimeInfo.IsPlatformOsx())
{
var oldAppData = fs.Path.Combine(env.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config",
AppPaths.DefaultAppDataDirectoryName);
if (fs.DirectoryInfo.New(oldAppData).Exists)
{
// Attempt to move the directory for the user. If this cannot be done, then the MoveOsxAppDataDotnet8
// migration step (which is required) will force the issue to the user and provide remediation steps.
fs.Directory.Move(oldAppData, appData);
}
}
}
catch (IOException)
{
throw new DirectoryNotFoundException("Unable to find the default app data directory");
// Ignore failures here because we'll let the migration step take care of it.
}
return fs.Path.Combine(appData, AppPaths.DefaultAppDataDirectoryName);
return appData;
}
}

@ -1,4 +1,4 @@
namespace Recyclarr.Common;
namespace Recyclarr.Platform;
public class DefaultEnvironment : IEnvironment
{

@ -0,0 +1,11 @@
using System.Runtime.InteropServices;
namespace Recyclarr.Platform;
public class DefaultRuntimeInformation : IRuntimeInformation
{
public bool IsPlatformOsx()
{
return RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
}
}

@ -1,4 +1,4 @@
namespace Recyclarr.Common;
namespace Recyclarr.Platform;
public interface IEnvironment
{

@ -0,0 +1,6 @@
namespace Recyclarr.Platform;
public interface IRuntimeInformation
{
bool IsPlatformOsx();
}

@ -13,6 +13,8 @@ public class PlatformAutofacModule : Module
private static void RegisterAppPaths(ContainerBuilder builder)
{
builder.RegisterType<DefaultAppDataSetup>();
builder.RegisterType<DefaultEnvironment>().As<IEnvironment>();
builder.RegisterType<DefaultRuntimeInformation>().As<IRuntimeInformation>();
builder.Register(c =>
{

@ -1,3 +1,4 @@
using Autofac.Extras.Ordering;
using Recyclarr.Cli.Migration;
using Recyclarr.Cli.Migration.Steps;
using Spectre.Console.Testing;
@ -13,7 +14,7 @@ public class MigrationExecutorTest
{
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
var executor = new MigrationExecutor(new[] {step}.AsOrdered(), console);
step.CheckIfNeeded().Returns(false);
@ -28,7 +29,7 @@ public class MigrationExecutorTest
{
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
var executor = new MigrationExecutor(new[] {step}.AsOrdered(), console);
step.CheckIfNeeded().Returns(true);
@ -50,18 +51,14 @@ public class MigrationExecutorTest
Substitute.For<IMigrationStep>()
};
steps[0].Order.Returns(20);
steps[1].Order.Returns(10);
steps[2].Order.Returns(30);
var executor = new MigrationExecutor(steps, console);
var executor = new MigrationExecutor(steps.AsOrdered(), console);
executor.PerformAllMigrationSteps(false);
Received.InOrder(() =>
{
steps[1].CheckIfNeeded();
steps[0].CheckIfNeeded();
steps[1].CheckIfNeeded();
steps[2].CheckIfNeeded();
});
}
@ -71,7 +68,7 @@ public class MigrationExecutorTest
{
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
var executor = new MigrationExecutor(new[] {step}.AsOrdered(), console);
step.CheckIfNeeded().Returns(true);
step.When(x => x.Execute(null)).Throw(new ArgumentException("test message"));
@ -86,7 +83,7 @@ public class MigrationExecutorTest
{
using var console = new TestConsole();
var step = Substitute.For<IMigrationStep>();
var executor = new MigrationExecutor(new[] {step}, console);
var executor = new MigrationExecutor(new[] {step}.AsOrdered(), console);
var exception = new MigrationException(new ArgumentException(), "a", new[] {"b"});
step.CheckIfNeeded().Returns(true);

@ -1,5 +1,5 @@
using Recyclarr.Common;
using Recyclarr.Config.Parsing;
using Recyclarr.Platform;
namespace Recyclarr.IntegrationTests;

@ -3,4 +3,4 @@ using Recyclarr.Platform;
namespace Recyclarr.Tests.TestLibrary;
public sealed class TestAppPaths(IFileSystem fs) : AppPaths(fs.CurrentDirectory().SubDirectory("recyclarr"));
public sealed class TestAppPaths(IFileSystem fs) : AppPaths(fs.CurrentDirectory().SubDirectory("app"));

@ -1,5 +1,4 @@
using System.IO.Abstractions;
using Recyclarr.Common;
using Recyclarr.Platform;
namespace Recyclarr.Tests.Platform;
@ -42,26 +41,7 @@ public class DefaultAppDataSetupTest
}
[Test, AutoMockData]
public void Force_creation_uses_correct_behavior_when_disabled(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IEnvironment env,
DefaultAppDataSetup sut)
{
var overridePath = fs.CurrentDirectory()
.SubDirectory("override")
.SubDirectory("path");
env.GetEnvironmentVariable(default!).ReturnsForAnyArgs((string?) null);
env.GetFolderPath(default).ReturnsForAnyArgs(overridePath.FullName);
sut.CreateAppPaths(null, false);
env.Received().GetFolderPath(Environment.SpecialFolder.ApplicationData, Environment.SpecialFolderOption.None);
fs.AllDirectories.Should().NotContain(overridePath.FullName);
}
[Test, AutoMockData]
public void Force_creation_uses_correct_behavior_when_enabled(
public void Creation_uses_correct_behavior(
[Frozen(Matching.ImplementedInterfaces)] MockFileSystem fs,
[Frozen] IEnvironment env,
DefaultAppDataSetup sut)

Loading…
Cancel
Save