mirror of
https://github.com/goatcorp/Dalamud.git
synced 2025-12-12 18:27:23 +01:00
feat: first pass at ReliableFileStorage service
This commit is contained in:
parent
f96ab7aa90
commit
125034155b
6 changed files with 379 additions and 13 deletions
|
|
@ -7,6 +7,7 @@ using System.Linq;
|
||||||
using Dalamud.Game.Text;
|
using Dalamud.Game.Text;
|
||||||
using Dalamud.Interface.Style;
|
using Dalamud.Interface.Style;
|
||||||
using Dalamud.Plugin.Internal.Profiles;
|
using Dalamud.Plugin.Internal.Profiles;
|
||||||
|
using Dalamud.Storage;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
@ -18,7 +19,7 @@ namespace Dalamud.Configuration.Internal;
|
||||||
/// Class containing Dalamud settings.
|
/// Class containing Dalamud settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[Serializable]
|
[Serializable]
|
||||||
internal sealed class DalamudConfiguration : IServiceType
|
internal sealed class DalamudConfiguration : IServiceType, IDisposable
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerSettings SerializerSettings = new()
|
private static readonly JsonSerializerSettings SerializerSettings = new()
|
||||||
{
|
{
|
||||||
|
|
@ -422,18 +423,28 @@ internal sealed class DalamudConfiguration : IServiceType
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Load a configuration from the provided path.
|
/// Load a configuration from the provided path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="path">The path to load the configuration file from.</param>
|
/// <param name="path">Path to read from.</param>
|
||||||
|
/// <param name="fs">File storage.</param>
|
||||||
/// <returns>The deserialized configuration file.</returns>
|
/// <returns>The deserialized configuration file.</returns>
|
||||||
public static DalamudConfiguration Load(string path)
|
public static DalamudConfiguration Load(string path, ReliableFileStorage fs)
|
||||||
{
|
{
|
||||||
DalamudConfiguration deserialized = null;
|
DalamudConfiguration deserialized = null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
deserialized = JsonConvert.DeserializeObject<DalamudConfiguration>(File.ReadAllText(path), SerializerSettings);
|
fs.ReadAllText(path, text =>
|
||||||
|
{
|
||||||
|
deserialized =
|
||||||
|
JsonConvert.DeserializeObject<DalamudConfiguration>(text, SerializerSettings);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (FileNotFoundException)
|
||||||
{
|
{
|
||||||
Log.Warning(ex, "Failed to load DalamudConfiguration at {0}", path);
|
// ignored
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Log.Error(e, "Could not load DalamudConfiguration at {Path}, creating new", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
deserialized ??= new DalamudConfiguration();
|
deserialized ??= new DalamudConfiguration();
|
||||||
|
|
@ -458,6 +469,13 @@ internal sealed class DalamudConfiguration : IServiceType
|
||||||
this.Save();
|
this.Save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
// Make sure that we save, if a save is queued while we are shutting down
|
||||||
|
this.Update();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Save the file, if needed. Only needs to be done once a frame.
|
/// Save the file, if needed. Only needs to be done once a frame.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
@ -476,7 +494,8 @@ internal sealed class DalamudConfiguration : IServiceType
|
||||||
{
|
{
|
||||||
ThreadSafety.AssertMainThread();
|
ThreadSafety.AssertMainThread();
|
||||||
|
|
||||||
Util.WriteAllTextSafe(this.configPath, JsonConvert.SerializeObject(this, SerializerSettings));
|
Service<ReliableFileStorage>.Get().WriteAllText(
|
||||||
|
this.configPath, JsonConvert.SerializeObject(this, SerializerSettings));
|
||||||
this.DalamudConfigurationSaved?.Invoke(this);
|
this.DalamudConfigurationSaved?.Invoke(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ using Dalamud.Game;
|
||||||
using Dalamud.Game.Gui.Internal;
|
using Dalamud.Game.Gui.Internal;
|
||||||
using Dalamud.Interface.Internal;
|
using Dalamud.Interface.Internal;
|
||||||
using Dalamud.Plugin.Internal;
|
using Dalamud.Plugin.Internal;
|
||||||
|
using Dalamud.Storage;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using PInvoke;
|
using PInvoke;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
@ -40,14 +41,15 @@ internal sealed class Dalamud : IServiceType
|
||||||
/// Initializes a new instance of the <see cref="Dalamud"/> class.
|
/// Initializes a new instance of the <see cref="Dalamud"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="info">DalamudStartInfo instance.</param>
|
/// <param name="info">DalamudStartInfo instance.</param>
|
||||||
|
/// <param name="fs">ReliableFileStorage instance.</param>
|
||||||
/// <param name="configuration">The Dalamud configuration.</param>
|
/// <param name="configuration">The Dalamud configuration.</param>
|
||||||
/// <param name="mainThreadContinueEvent">Event used to signal the main thread to continue.</param>
|
/// <param name="mainThreadContinueEvent">Event used to signal the main thread to continue.</param>
|
||||||
public Dalamud(DalamudStartInfo info, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent)
|
public Dalamud(DalamudStartInfo info, ReliableFileStorage fs, DalamudConfiguration configuration, IntPtr mainThreadContinueEvent)
|
||||||
{
|
{
|
||||||
this.unloadSignal = new ManualResetEvent(false);
|
this.unloadSignal = new ManualResetEvent(false);
|
||||||
this.unloadSignal.Reset();
|
this.unloadSignal.Reset();
|
||||||
|
|
||||||
ServiceManager.InitializeProvidedServicesAndClientStructs(this, info, configuration);
|
ServiceManager.InitializeProvidedServicesAndClientStructs(this, info, fs, configuration);
|
||||||
|
|
||||||
if (!configuration.IsResumeGameAfterPluginLoad)
|
if (!configuration.IsResumeGameAfterPluginLoad)
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,7 @@
|
||||||
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
|
<PackageReference Include="Serilog.Sinks.Async" Version="1.5.0" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0" />
|
||||||
|
<PackageReference Include="sqlite-net-pcl" Version="1.8.116" />
|
||||||
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.333">
|
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.333">
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ using Dalamud.Configuration.Internal;
|
||||||
using Dalamud.Logging.Internal;
|
using Dalamud.Logging.Internal;
|
||||||
using Dalamud.Logging.Retention;
|
using Dalamud.Logging.Retention;
|
||||||
using Dalamud.Plugin.Internal;
|
using Dalamud.Plugin.Internal;
|
||||||
|
using Dalamud.Storage;
|
||||||
using Dalamud.Support;
|
using Dalamud.Support;
|
||||||
using Dalamud.Utility;
|
using Dalamud.Utility;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
@ -137,7 +138,8 @@ public sealed class EntryPoint
|
||||||
SerilogEventSink.Instance.LogLine += SerilogOnLogLine;
|
SerilogEventSink.Instance.LogLine += SerilogOnLogLine;
|
||||||
|
|
||||||
// Load configuration first to get some early persistent state, like log level
|
// Load configuration first to get some early persistent state, like log level
|
||||||
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!);
|
var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!);
|
||||||
|
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs);
|
||||||
|
|
||||||
// Set the appropriate logging level from the configuration
|
// Set the appropriate logging level from the configuration
|
||||||
if (!configuration.LogSynchronously)
|
if (!configuration.LogSynchronously)
|
||||||
|
|
@ -169,7 +171,7 @@ public sealed class EntryPoint
|
||||||
if (!Util.IsWine())
|
if (!Util.IsWine())
|
||||||
InitSymbolHandler(info);
|
InitSymbolHandler(info);
|
||||||
|
|
||||||
var dalamud = new Dalamud(info, configuration, mainThreadContinueEvent);
|
var dalamud = new Dalamud(info, fs, configuration, mainThreadContinueEvent);
|
||||||
Log.Information("This is Dalamud - Core: {GitHash}, CS: {CsGitHash} [{CsVersion}]", Util.GetGitHash(), Util.GetGitHashClientStructs(), FFXIVClientStructs.Interop.Resolver.Version);
|
Log.Information("This is Dalamud - Core: {GitHash}, CS: {CsGitHash} [{CsVersion}]", Util.GetGitHash(), Util.GetGitHashClientStructs(), FFXIVClientStructs.Interop.Resolver.Version);
|
||||||
|
|
||||||
dalamud.WaitForUnload();
|
dalamud.WaitForUnload();
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ using Dalamud.Configuration.Internal;
|
||||||
using Dalamud.Game;
|
using Dalamud.Game;
|
||||||
using Dalamud.IoC.Internal;
|
using Dalamud.IoC.Internal;
|
||||||
using Dalamud.Logging.Internal;
|
using Dalamud.Logging.Internal;
|
||||||
|
using Dalamud.Storage;
|
||||||
using Dalamud.Utility.Timing;
|
using Dalamud.Utility.Timing;
|
||||||
using JetBrains.Annotations;
|
using JetBrains.Annotations;
|
||||||
|
|
||||||
|
|
@ -83,8 +84,9 @@ internal static class ServiceManager
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="dalamud">Instance of <see cref="Dalamud"/>.</param>
|
/// <param name="dalamud">Instance of <see cref="Dalamud"/>.</param>
|
||||||
/// <param name="startInfo">Instance of <see cref="DalamudStartInfo"/>.</param>
|
/// <param name="startInfo">Instance of <see cref="DalamudStartInfo"/>.</param>
|
||||||
|
/// <param name="fs">Instance of <see cref="ReliableFileStorage"/></param>
|
||||||
/// <param name="configuration">Instance of <see cref="DalamudConfiguration"/>.</param>
|
/// <param name="configuration">Instance of <see cref="DalamudConfiguration"/>.</param>
|
||||||
public static void InitializeProvidedServicesAndClientStructs(Dalamud dalamud, DalamudStartInfo startInfo, DalamudConfiguration configuration)
|
public static void InitializeProvidedServicesAndClientStructs(Dalamud dalamud, DalamudStartInfo startInfo, ReliableFileStorage fs, DalamudConfiguration configuration)
|
||||||
{
|
{
|
||||||
// Initialize the process information.
|
// Initialize the process information.
|
||||||
var cacheDir = new DirectoryInfo(Path.Combine(startInfo.WorkingDirectory!, "cachedSigs"));
|
var cacheDir = new DirectoryInfo(Path.Combine(startInfo.WorkingDirectory!, "cachedSigs"));
|
||||||
|
|
@ -99,6 +101,9 @@ internal static class ServiceManager
|
||||||
Service<DalamudStartInfo>.Provide(startInfo);
|
Service<DalamudStartInfo>.Provide(startInfo);
|
||||||
LoadedServices.Add(typeof(DalamudStartInfo));
|
LoadedServices.Add(typeof(DalamudStartInfo));
|
||||||
|
|
||||||
|
Service<ReliableFileStorage>.Provide(fs);
|
||||||
|
LoadedServices.Add(typeof(ReliableFileStorage));
|
||||||
|
|
||||||
Service<DalamudConfiguration>.Provide(configuration);
|
Service<DalamudConfiguration>.Provide(configuration);
|
||||||
LoadedServices.Add(typeof(DalamudConfiguration));
|
LoadedServices.Add(typeof(DalamudConfiguration));
|
||||||
|
|
||||||
|
|
|
||||||
337
Dalamud/Storage/ReliableFileStorage.cs
Normal file
337
Dalamud/Storage/ReliableFileStorage.cs
Normal file
|
|
@ -0,0 +1,337 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using Dalamud.Logging.Internal;
|
||||||
|
using PInvoke;
|
||||||
|
using SQLite;
|
||||||
|
|
||||||
|
namespace Dalamud.Storage;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* TODO: A file that is read frequently, but written very rarely, might not have offline changes by users persisted
|
||||||
|
* into the backup database, since it is only written to the backup database when it is written to the filesystem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A service that provides a reliable file storage.
|
||||||
|
/// Implements a VFS that writes files to the disk, and additionally keeps files in a SQLite database
|
||||||
|
/// for journaling/backup purposes.
|
||||||
|
/// Consumers can choose to receive a backup if they think that the file is corrupt.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This is not an early-loaded service, as it is needed before they are initialized.
|
||||||
|
/// </remarks>
|
||||||
|
public class ReliableFileStorage : IServiceType, IDisposable
|
||||||
|
{
|
||||||
|
private static readonly ModuleLog Log = new("VFS");
|
||||||
|
|
||||||
|
private SQLiteConnection db;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="ReliableFileStorage"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="vfsDbPath">Path to the VFS.</param>
|
||||||
|
[ServiceManager.ServiceConstructor]
|
||||||
|
public ReliableFileStorage(string vfsDbPath)
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(vfsDbPath, "dalamudVfs.db");
|
||||||
|
|
||||||
|
Log.Verbose("Initializing VFS database at {Path}", databasePath);
|
||||||
|
this.db = new SQLiteConnection(databasePath, SQLiteOpenFlags.ReadWrite | SQLiteOpenFlags.Create | SQLiteOpenFlags.FullMutex);
|
||||||
|
this.db.CreateTable<DbFile>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Check if a file exists.
|
||||||
|
/// This will return true if the file does not exist on the filesystem, but in the transparent backup.
|
||||||
|
/// You must then use this instance to read the file to ensure consistency.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to check.</param>
|
||||||
|
/// <param name="containerId">The container to check in.</param>
|
||||||
|
/// <returns>True if the file exists.</returns>
|
||||||
|
public bool Exists(string path, Guid containerId = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||||
|
|
||||||
|
if (File.Exists(path))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
// If the file doesn't actually exist on the FS, but it does in the DB, we can say YES and read operations will read from the DB instead
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
|
||||||
|
return file != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write all text to a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to write to.</param>
|
||||||
|
/// <param name="contents">The contents of the file.</param>
|
||||||
|
/// <param name="containerId">Container to write to.</param>
|
||||||
|
public void WriteAllText(string path, string? contents, Guid containerId = default)
|
||||||
|
=> this.WriteAllText(path, contents, Encoding.UTF8, containerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write all text to a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to write to.</param>
|
||||||
|
/// <param name="contents">The contents of the file.</param>
|
||||||
|
/// <param name="encoding">The encoding to write with.</param>
|
||||||
|
/// <param name="containerId">Container to write to.</param>
|
||||||
|
public void WriteAllText(string path, string? contents, Encoding encoding, Guid containerId = default)
|
||||||
|
{
|
||||||
|
var bytes = encoding.GetBytes(contents ?? string.Empty);
|
||||||
|
this.WriteAllBytes(path, bytes, containerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Write all bytes to a file.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">Path to write to.</param>
|
||||||
|
/// <param name="bytes">The contents of the file.</param>
|
||||||
|
/// <param name="containerId">Container to write to.</param>
|
||||||
|
public void WriteAllBytes(string path, byte[] bytes, Guid containerId = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||||
|
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
|
||||||
|
if (file == null)
|
||||||
|
{
|
||||||
|
file = new DbFile
|
||||||
|
{
|
||||||
|
ContainerId = containerId,
|
||||||
|
Path = normalizedPath,
|
||||||
|
Data = bytes,
|
||||||
|
};
|
||||||
|
this.db.Insert(file);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
file.Data = bytes;
|
||||||
|
this.db.Update(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteFileReliably(path, bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all text from a file.
|
||||||
|
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
|
||||||
|
/// automatically written back to disk, however.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to read from.</param>
|
||||||
|
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
|
||||||
|
/// <param name="containerId">The container to read from.</param>
|
||||||
|
/// <returns>All text stored in this file.</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
|
||||||
|
public string ReadAllText(string path, bool forceBackup = false, Guid containerId = default)
|
||||||
|
=> this.ReadAllText(path, Encoding.UTF8, forceBackup, containerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all text from a file.
|
||||||
|
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
|
||||||
|
/// automatically written back to disk, however.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to read from.</param>
|
||||||
|
/// <param name="encoding">The encoding to read with.</param>
|
||||||
|
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
|
||||||
|
/// <param name="containerId">The container to read from.</param>
|
||||||
|
/// <returns>All text stored in this file.</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
|
||||||
|
public string ReadAllText(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default)
|
||||||
|
{
|
||||||
|
var bytes = this.ReadAllBytes(path, forceBackup, containerId);
|
||||||
|
return encoding.GetString(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all text from a file, and automatically try again with the backup if the file does not exist or
|
||||||
|
/// the <paramref name="reader"/> function throws an exception. If the backup read also throws an exception,
|
||||||
|
/// or the file does not exist in the backup, a <see cref="FileReadException"/> is thrown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to read from.</param>
|
||||||
|
/// <param name="reader">Lambda that reads the file. Throw here to automatically attempt a read from the backup.</param>
|
||||||
|
/// <param name="containerId">The container to read from.</param>
|
||||||
|
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
|
||||||
|
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
|
||||||
|
public void ReadAllText(string path, Action<string> reader, Guid containerId = default)
|
||||||
|
=> this.ReadAllText(path, Encoding.UTF8, reader, containerId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all text from a file, and automatically try again with the backup if the file does not exist or
|
||||||
|
/// the <paramref name="reader"/> function throws an exception. If the backup read also throws an exception,
|
||||||
|
/// or the file does not exist in the backup, a <see cref="FileReadException"/> is thrown.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to read from.</param>
|
||||||
|
/// <param name="encoding">The encoding to read with.</param>
|
||||||
|
/// <param name="reader">Lambda that reads the file. Throw here to automatically attempt a read from the backup.</param>
|
||||||
|
/// <param name="containerId">The container to read from.</param>
|
||||||
|
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
|
||||||
|
/// <exception cref="FileReadException">Thrown here if the file and the backup fail their read.</exception>
|
||||||
|
public void ReadAllText(string path, Encoding encoding, Action<string> reader, Guid containerId = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||||
|
|
||||||
|
// TODO: We are technically reading one time too many here, if the file does not exist on the FS, ReadAllText
|
||||||
|
// fails over to the backup, and then the backup fails to read in the lambda. We should do something about that,
|
||||||
|
// but it's not a big deal. Would be nice if ReadAllText could indicate if it did fail over.
|
||||||
|
|
||||||
|
// 1.) Try without using the backup
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = this.ReadAllText(path, encoding, false, containerId);
|
||||||
|
reader(text);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (FileNotFoundException)
|
||||||
|
{
|
||||||
|
// We can't do anything about this.
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Verbose(ex, "First chance read from {Path} failed, trying backup", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.) Try using the backup
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var text = this.ReadAllText(path, encoding, true, containerId);
|
||||||
|
reader(text);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Error(ex, "Second chance read from {Path} failed, giving up", path);
|
||||||
|
throw new FileReadException(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Read all bytes from a file.
|
||||||
|
/// If the file does not exist on the filesystem, a read is attempted from the backup. The backup is not
|
||||||
|
/// automatically written back to disk, however.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to read from.</param>
|
||||||
|
/// <param name="forceBackup">Whether or not the backup of the file should take priority.</param>
|
||||||
|
/// <param name="containerId">The container to read from.</param>
|
||||||
|
/// <returns>All bytes stored in this file.</returns>
|
||||||
|
/// <exception cref="FileNotFoundException">Thrown if the file does not exist on the filesystem or in the backup.</exception>
|
||||||
|
public byte[] ReadAllBytes(string path, bool forceBackup = false, Guid containerId = default)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||||
|
|
||||||
|
if (forceBackup)
|
||||||
|
{
|
||||||
|
var normalizedPath = NormalizePath(path);
|
||||||
|
var file = this.db.Table<DbFile>().FirstOrDefault(f => f.Path == normalizedPath && f.ContainerId == containerId);
|
||||||
|
if (file == null)
|
||||||
|
throw new FileNotFoundException();
|
||||||
|
|
||||||
|
return file.Data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file doesn't exist, immediately check the backup db
|
||||||
|
if (!File.Exists(path))
|
||||||
|
return this.ReadAllBytes(path, true, containerId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Log.Error(e, "Failed to read file from disk, falling back to database");
|
||||||
|
return this.ReadAllBytes(path, true, containerId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc/>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
this.db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void WriteFileReliably(string path, byte[] bytes)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(path);
|
||||||
|
|
||||||
|
// Open the temp file
|
||||||
|
var tempPath = path + ".tmp";
|
||||||
|
|
||||||
|
using var tempFile = Kernel32
|
||||||
|
.CreateFile(tempPath.AsSpan(),
|
||||||
|
new Kernel32.ACCESS_MASK(Kernel32.FileAccess.FILE_GENERIC_READ | Kernel32.FileAccess.FILE_GENERIC_WRITE),
|
||||||
|
Kernel32.FileShare.None,
|
||||||
|
null,
|
||||||
|
Kernel32.CreationDisposition.CREATE_ALWAYS,
|
||||||
|
Kernel32.CreateFileFlags.FILE_ATTRIBUTE_NORMAL,
|
||||||
|
Kernel32.SafeObjectHandle.Null);
|
||||||
|
|
||||||
|
if (tempFile.IsInvalid)
|
||||||
|
throw new Win32Exception();
|
||||||
|
|
||||||
|
// Write the data
|
||||||
|
var bytesWritten = Kernel32.WriteFile(tempFile, new ArraySegment<byte>(bytes));
|
||||||
|
if (bytesWritten != bytes.Length)
|
||||||
|
throw new Exception($"Could not write all bytes to temp file ({bytesWritten} of {bytes.Length})");
|
||||||
|
|
||||||
|
if (!Kernel32.FlushFileBuffers(tempFile))
|
||||||
|
throw new Win32Exception();
|
||||||
|
|
||||||
|
tempFile.Close();
|
||||||
|
|
||||||
|
if (!MoveFileEx(tempPath, path, MoveFileFlags.MovefileReplaceExisting | MoveFileFlags.MovefileWriteThrough))
|
||||||
|
throw new Win32Exception();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Replace possible non-portable parts of a path with portable versions.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="path">The path to normalize.</param>
|
||||||
|
/// <returns>The normalized path.</returns>
|
||||||
|
private static string NormalizePath(string path)
|
||||||
|
{
|
||||||
|
// Replace users folder
|
||||||
|
var usersFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
path = path.Replace(usersFolder, "%USERPROFILE%");
|
||||||
|
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Flags]
|
||||||
|
#pragma warning disable SA1201
|
||||||
|
private enum MoveFileFlags
|
||||||
|
#pragma warning restore SA1201
|
||||||
|
{
|
||||||
|
MovefileReplaceExisting = 0x00000001,
|
||||||
|
MovefileWriteThrough = 0x00000008,
|
||||||
|
}
|
||||||
|
|
||||||
|
[return: MarshalAs(UnmanagedType.Bool)]
|
||||||
|
[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode)]
|
||||||
|
private static extern bool MoveFileEx(string lpExistingFileName, string lpNewFileName,
|
||||||
|
MoveFileFlags dwFlags);
|
||||||
|
|
||||||
|
private class DbFile
|
||||||
|
{
|
||||||
|
[PrimaryKey]
|
||||||
|
[AutoIncrement]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
public Guid ContainerId { get; set; }
|
||||||
|
|
||||||
|
public string Path { get; set; } = null!;
|
||||||
|
|
||||||
|
public byte[] Data { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FileReadException : Exception
|
||||||
|
{
|
||||||
|
public FileReadException(Exception inner)
|
||||||
|
: base("Failed to read file", inner)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue