Merge remote-tracking branch 'origin/master' into api14-rollup

This commit is contained in:
github-actions[bot] 2025-11-26 20:10:02 +00:00
commit 947518b3d6
20 changed files with 812 additions and 104 deletions

View file

@ -503,13 +503,13 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
/// <param name="path">Path to read from.</param>
/// <param name="fs">File storage.</param>
/// <returns>The deserialized configuration file.</returns>
public static DalamudConfiguration Load(string path, ReliableFileStorage fs)
public static async Task<DalamudConfiguration> Load(string path, ReliableFileStorage fs)
{
DalamudConfiguration deserialized = null;
try
{
fs.ReadAllText(path, text =>
await fs.ReadAllTextAsync(path, text =>
{
deserialized =
JsonConvert.DeserializeObject<DalamudConfiguration>(text, SerializerSettings);
@ -580,8 +580,6 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
{
this.Save();
this.isSaveQueued = false;
Log.Verbose("Config saved");
}
}
@ -630,16 +628,20 @@ internal sealed class DalamudConfiguration : IInternalDisposableService
// Wait for previous write to finish
this.writeTask?.Wait();
this.writeTask = Task.Run(() =>
this.writeTask = Task.Run(async () =>
{
Service<ReliableFileStorage>.Get().WriteAllText(
this.configPath,
JsonConvert.SerializeObject(this, SerializerSettings));
await Service<ReliableFileStorage>.Get().WriteAllTextAsync(
this.configPath,
JsonConvert.SerializeObject(this, SerializerSettings));
Log.Verbose("DalamudConfiguration saved");
}).ContinueWith(t =>
{
if (t.IsFaulted)
{
Log.Error(t.Exception, "Failed to save DalamudConfiguration to {Path}", this.configPath);
Log.Error(
t.Exception,
"Failed to save DalamudConfiguration to {Path}",
this.configPath);
}
});

View file

@ -2,6 +2,8 @@ using System.IO;
using System.Reflection;
using Dalamud.Storage;
using Dalamud.Utility;
using Newtonsoft.Json;
namespace Dalamud.Configuration;
@ -9,6 +11,7 @@ namespace Dalamud.Configuration;
/// <summary>
/// Configuration to store settings for a dalamud plugin.
/// </summary>
[Api13ToDo("Make this a service. We need to be able to dispose it reliably to write configs asynchronously. Maybe also let people write files with vfs.")]
public sealed class PluginConfigurations
{
private readonly DirectoryInfo configDirectory;
@ -36,7 +39,7 @@ public sealed class PluginConfigurations
public void Save(IPluginConfiguration config, string pluginName, Guid workingPluginId)
{
Service<ReliableFileStorage>.Get()
.WriteAllText(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId);
.WriteAllTextAsync(this.GetConfigFile(pluginName).FullName, SerializeConfig(config), workingPluginId).GetAwaiter().GetResult();
}
/// <summary>
@ -52,12 +55,12 @@ public sealed class PluginConfigurations
IPluginConfiguration? config = null;
try
{
Service<ReliableFileStorage>.Get().ReadAllText(path.FullName, text =>
Service<ReliableFileStorage>.Get().ReadAllTextAsync(path.FullName, text =>
{
config = DeserializeConfig(text);
if (config == null)
throw new Exception("Read config was null.");
}, workingPluginId);
}, workingPluginId).GetAwaiter().GetResult();
}
catch (FileNotFoundException)
{

View file

@ -6,7 +6,7 @@
<PropertyGroup Label="Feature">
<Description>XIV Launcher addon framework</Description>
<DalamudVersion>13.0.0.9</DalamudVersion>
<DalamudVersion>13.0.0.10</DalamudVersion>
<AssemblyVersion>$(DalamudVersion)</AssemblyVersion>
<Version>$(DalamudVersion)</Version>
<FileVersion>$(DalamudVersion)</FileVersion>

View file

@ -144,7 +144,8 @@ public sealed class EntryPoint
// Load configuration first to get some early persistent state, like log level
var fs = new ReliableFileStorage(Path.GetDirectoryName(info.ConfigurationPath)!);
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs);
var configuration = DalamudConfiguration.Load(info.ConfigurationPath!, fs)
.GetAwaiter().GetResult();
// Set the appropriate logging level from the configuration
if (!configuration.LogSynchronously)

View file

@ -22,7 +22,7 @@ using PublicContentSheet = Lumina.Excel.Sheets.PublicContent;
namespace Dalamud.Game.UnlockState;
#pragma warning disable UnlockState
#pragma warning disable Dalamud001
/// <summary>
/// This class provides unlock state of various content in the game.

View file

@ -1060,7 +1060,7 @@ internal class DalamudInterface : IInternalDisposableService
{
ImGui.PushFont(InterfaceManager.MonoFont);
ImGui.BeginMenu(Util.GetBranch() ?? "???", false);
ImGui.BeginMenu($"{Util.GetActiveTrack() ?? "???"} on {Util.GetGitBranch() ?? "???"}", false);
ImGui.BeginMenu($"{Util.GetScmVersion()}", false);
ImGui.BeginMenu(this.FrameCount.ToString("000000"), false);
ImGui.BeginMenu(ImGui.GetIO().Framerate.ToString("000"), false);

View file

@ -46,10 +46,12 @@ public class BranchSwitcherWindow : Window
this.branches = await client.GetFromJsonAsync<Dictionary<string, VersionEntry>>(BranchInfoUrl);
Debug.Assert(this.branches != null, "this.branches != null");
var branch = Util.GetBranch();
this.selectedBranchIndex = this.branches!.Any(x => x.Value.Track == branch) ?
this.branches.TakeWhile(x => x.Value.Track != branch).Count()
: 0;
var trackName = Util.GetActiveTrack();
this.selectedBranchIndex = this.branches.IndexOf(x => x.Value.Track != trackName);
if (this.selectedBranchIndex == -1)
{
this.selectedBranchIndex = 0;
}
});
base.OnOpen();

View file

@ -52,7 +52,7 @@ internal class VfsWidget : IDataWindowWidget
for (var i = 0; i < this.reps; i++)
{
stopwatch.Restart();
service.WriteAllBytes(path, data);
service.WriteAllBytesAsync(path, data).GetAwaiter().GetResult();
stopwatch.Stop();
acc += stopwatch.ElapsedMilliseconds;
Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds);
@ -70,7 +70,7 @@ internal class VfsWidget : IDataWindowWidget
for (var i = 0; i < this.reps; i++)
{
stopwatch.Restart();
service.ReadAllBytes(path);
service.ReadAllBytesAsync(path).GetAwaiter().GetResult();
stopwatch.Stop();
acc += stopwatch.ElapsedMilliseconds;
Log.Information("Turn {Turn} took {Ms}ms", i, stopwatch.ElapsedMilliseconds);

View file

@ -55,6 +55,9 @@ public abstract class Window
private Vector2 fadeOutSize = Vector2.Zero;
private Vector2 fadeOutOrigin = Vector2.Zero;
private bool hasError = false;
private Exception? lastError;
/// <summary>
/// Initializes a new instance of the <see cref="Window"/> class.
/// </summary>
@ -458,14 +461,24 @@ public abstract class Window
this.presetDirty = true;
}
// Draw the actual window contents
try
if (this.hasError)
{
this.Draw();
this.DrawErrorMessage();
}
catch (Exception ex)
else
{
Log.Error(ex, "Error during Draw(): {WindowName}", this.WindowName);
// Draw the actual window contents
try
{
this.Draw();
}
catch (Exception ex)
{
Log.Error(ex, "Error during Draw(): {WindowName}", this.WindowName);
this.hasError = true;
this.lastError = ex;
}
}
}
@ -793,7 +806,7 @@ public abstract class Window
hovered = true;
// We can't use ImGui native functions here, because they don't work with clickthrough
if ((global::Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0)
if ((Windows.Win32.PInvoke.GetKeyState((int)VirtualKey.LBUTTON) & 0x8000) != 0)
{
held = true;
pressed = true;
@ -871,6 +884,52 @@ public abstract class Window
ImGui.End();
}
private void DrawErrorMessage()
{
// TODO: Once window systems are services, offer to reload the plugin
ImGui.TextColoredWrapped(ImGuiColors.DalamudRed,Loc.Localize("WindowSystemErrorOccurred", "An error occurred while rendering this window. Please contact the developer for details."));
ImGuiHelpers.ScaledDummy(5);
if (ImGui.Button(Loc.Localize("WindowSystemErrorRecoverButton", "Attempt to retry")))
{
this.hasError = false;
this.lastError = null;
}
ImGui.SameLine();
if (ImGui.Button(Loc.Localize("WindowSystemErrorClose", "Close Window")))
{
this.IsOpen = false;
this.hasError = false;
this.lastError = null;
}
ImGuiHelpers.ScaledDummy(10);
if (this.lastError != null)
{
using var child = ImRaii.Child("##ErrorDetails", new Vector2(0, 200 * ImGuiHelpers.GlobalScale), true);
using (ImRaii.PushColor(ImGuiCol.Text, ImGuiColors.DalamudGrey))
{
ImGui.TextWrapped(Loc.Localize("WindowSystemErrorDetails", "Error Details:"));
ImGui.Separator();
ImGui.TextWrapped(this.lastError.ToString());
}
var childWindowSize = ImGui.GetWindowSize();
var copyText = Loc.Localize("WindowSystemErrorCopy", "Copy");
var buttonWidth = ImGuiComponents.GetIconButtonWithTextWidth(FontAwesomeIcon.Copy, copyText);
ImGui.SetCursorPos(new Vector2(childWindowSize.X - buttonWidth - ImGui.GetStyle().FramePadding.X,
ImGui.GetStyle().FramePadding.Y));
if (ImGuiComponents.IconButtonWithText(FontAwesomeIcon.Copy, copyText))
{
ImGui.SetClipboardText(this.lastError.ToString());
}
}
}
/// <summary>
/// Structure detailing the size constraints of a window.
/// </summary>

View file

@ -362,6 +362,9 @@ internal class PluginManager : IInternalDisposableService
if (!this.configuration.DoPluginTest)
return false;
if (!manifest.TestingDalamudApiLevel.HasValue)
return false;
return manifest.IsTestingExclusive || manifest.IsAvailableForTesting;
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
namespace Dalamud.Plugin.SelfTest.Internal;
@ -12,7 +13,7 @@ namespace Dalamud.Plugin.SelfTest.Internal;
[PluginInterface]
[ServiceManager.ScopedService]
[ResolveVia<ISelfTestRegistry>]
internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService
internal class SelfTestRegistryPluginScoped : ISelfTestRegistry, IInternalDisposableService, IDalamudService
{
[ServiceManager.ServiceDependency]
private readonly SelfTestRegistry selfTestRegistry = Service<SelfTestRegistry>.Get();

View file

@ -0,0 +1,168 @@
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Configuration;
using Dalamud.Storage;
namespace Dalamud.Plugin.Services;
/// <summary>
/// Service to interact with the file system, as a replacement for standard C# file I/O.
/// Writes and reads using this service are, to the best of our ability, atomic and reliable.
///
/// All data is synced to disk immediately and written to a database, additionally to files on disk. This means
/// that in case of file corruption, data can likely be recovered from the database.
///
/// However, this also means that operations using this service duplicate data on disk, so we don't recommend
/// performing large file operations. The service will not permit files larger than <see cref="MaxFileSizeBytes"/>
/// (64MB) to be written.
///
/// Saved configuration data using the <see cref="PluginConfigurations"/> class uses this functionality implicitly.
/// </summary>
[Experimental("Dalamud001")]
public interface IReliableFileStorage : IDalamudService
{
/// <summary>
/// Gets the maximum file size, in bytes, that can be written using this service.
/// </summary>
/// <remarks>
/// The service enforces this limit when writing files and fails with an appropriate exception
/// (for example <see cref="ArgumentException"/> or a custom exception) when a caller attempts to write
/// more than this number of bytes.
/// </remarks>
long MaxFileSizeBytes { get; }
/// <summary>
/// Check whether a file exists either on the local filesystem or in the transparent backup database.
/// </summary>
/// <param name="path">The file system path to check. Must not be null or empty.</param>
/// <returns>
/// True if the file exists on disk or a backup copy exists in the storage's internal journal/backup database;
/// otherwise false.
/// </returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
bool Exists(string path);
/// <summary>
/// Write the given text into a file using UTF-8 encoding. The write is performed atomically and is persisted to
/// both the filesystem and the internal backup database used by this service.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="contents">The string contents to write. May be null, in which case an empty file is written.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
Task WriteAllTextAsync(string path, string? contents);
/// <summary>
/// Write the given text into a file using the provided <paramref name="encoding"/>. The write is performed
/// atomically (to the extent possible) and is persisted to both the filesystem and the internal backup database
/// used by this service.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="contents">The string contents to write. May be null, in which case an empty file is written.</param>
/// <param name="encoding">The text encoding to use when serializing the string to bytes. Must not be null.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> is null.</exception>
Task WriteAllTextAsync(string path, string? contents, Encoding encoding);
/// <summary>
/// Write the given bytes to a file. The write is persisted to both the filesystem and the service's internal
/// backup database. Avoid writing extremely large byte arrays because this service duplicates data on disk.
/// </summary>
/// <param name="path">The file path to write to. Must not be null or empty.</param>
/// <param name="bytes">The raw bytes to write. Must not be null.</param>
/// <returns>A <see cref="Task"/> that completes when the write has finished and been flushed to disk and the backup.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="bytes"/> is null.</exception>
Task WriteAllBytesAsync(string path, byte[] bytes);
/// <summary>
/// Read all text from a file using UTF-8 encoding. If the file is unreadable or missing on disk, the service
/// attempts to return a backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns backed-up contents if available. When
/// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing.
/// </param>
/// <returns>The textual contents of the file, decoded using UTF-8.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<string> ReadAllTextAsync(string path, bool forceBackup = false);
/// <summary>
/// Read all text from a file using the specified <paramref name="encoding"/>. If the file is unreadable or
/// missing on disk, the service attempts to return a backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="encoding">The encoding to use when decoding the stored bytes into text. Must not be null.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns backed-up contents if available. When
/// false the service tries the filesystem first and falls back to the backup only on error or when the file is missing.
/// </param>
/// <returns>The textual contents of the file decoded using the provided <paramref name="encoding"/>.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false);
/// <summary>
/// Read all text from a file and invoke the provided <paramref name="reader"/> callback with the string
/// contents. If the reader throws or the initial read fails, the service attempts a backup read and invokes the
/// reader again with the backup contents. If both reads fail the service surfaces an exception to the caller.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="reader">
/// A callback invoked with the file's textual contents. Must not be null.
/// If the callback throws an exception the service treats that as a signal to retry the read using the
/// internal backup database and will invoke the callback again with the backup contents when available.
/// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of
/// silently accepting corrupt data.
/// </param>
/// <returns>A <see cref="Task"/> that completes when the read (and any attempted fallback) and callback invocation have finished.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="reader"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
/// <exception cref="FileReadException">Thrown when both the filesystem read and the backup read fail for other reasons.</exception>
Task ReadAllTextAsync(string path, Action<string> reader);
/// <summary>
/// Read all text from a file using the specified <paramref name="encoding"/> and invoke the provided
/// <paramref name="reader"/> callback with the decoded string contents. If the reader throws or the initial
/// read fails, the service attempts a backup read and invokes the reader again with the backup contents. If
/// both reads fail the service surfaces an exception to the caller.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="encoding">The encoding to use when decoding the stored bytes into text. Must not be null.</param>
/// <param name="reader">
/// A callback invoked with the file's textual contents. Must not be null.
/// If the callback throws an exception the service treats that as a signal to retry the read using the
/// internal backup database and will invoke the callback again with the backup contents when available.
/// For example, the callback can throw when JSON deserialization fails to request the backup copy instead of
/// silently accepting corrupt data.
/// </param>
/// <returns>A <see cref="Task"/> that completes when the read (and any attempted fallback) and callback invocation have finished.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="encoding"/> or <paramref name="reader"/> is null.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
/// <exception cref="FileReadException">Thrown when both the filesystem read and the backup read fail for other reasons.</exception>
Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader);
/// <summary>
/// Read all bytes from a file. If the file is unreadable or missing on disk, the service may try to return a
/// backed-up copy from its internal journal/backup database.
/// </summary>
/// <param name="path">The file path to read. Must not be null or empty.</param>
/// <param name="forceBackup">
/// When true the service prefers the internal backup database and returns the backed-up contents
/// if available. When false the service tries the filesystem first and falls back to the backup only
/// on error or when the file is missing.
/// </param>
/// <returns>The raw bytes stored in the file.</returns>
/// <exception cref="ArgumentException">Thrown when <paramref name="path"/> is null or empty.</exception>
/// <exception cref="FileNotFoundException">Thrown when the file does not exist on disk and no backup copy is available.</exception>
Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false);
}

View file

@ -10,7 +10,7 @@ namespace Dalamud.Plugin.Services;
/// <summary>
/// Interface for determining unlock state of various content in the game.
/// </summary>
[Experimental("UnlockState")]
[Experimental("Dalamud001")]
public interface IUnlockState : IDalamudService
{
/// <summary>

View file

@ -1,5 +1,6 @@
using System.IO;
using System.Text;
using System.Threading.Tasks;
using Dalamud.Logging.Internal;
using Dalamud.Utility;
@ -92,8 +93,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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);
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task WriteAllTextAsync(string path, string? contents, Guid containerId = default)
=> await this.WriteAllTextAsync(path, contents, Encoding.UTF8, containerId);
/// <summary>
/// Write all text to a file.
@ -102,10 +104,11 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task WriteAllTextAsync(string path, string? contents, Encoding encoding, Guid containerId = default)
{
var bytes = encoding.GetBytes(contents ?? string.Empty);
this.WriteAllBytes(path, bytes, containerId);
await this.WriteAllBytesAsync(path, bytes, containerId);
}
/// <summary>
@ -114,7 +117,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public Task WriteAllBytesAsync(string path, byte[] bytes, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -123,7 +127,7 @@ internal class ReliableFileStorage : IInternalDisposableService
if (this.db == null)
{
FilesystemUtil.WriteAllBytesSafe(path, bytes);
return;
return Task.CompletedTask;
}
this.db.RunInTransaction(() =>
@ -149,6 +153,8 @@ internal class ReliableFileStorage : IInternalDisposableService
FilesystemUtil.WriteAllBytesSafe(path, bytes);
});
}
return Task.CompletedTask;
}
/// <summary>
@ -161,8 +167,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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);
public Task<string> ReadAllTextAsync(string path, bool forceBackup = false, Guid containerId = default)
=> this.ReadAllTextAsync(path, Encoding.UTF8, forceBackup, containerId);
/// <summary>
/// Read all text from a file.
@ -175,9 +181,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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)
public async Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false, Guid containerId = default)
{
var bytes = this.ReadAllBytes(path, forceBackup, containerId);
var bytes = await this.ReadAllBytesAsync(path, forceBackup, containerId);
return encoding.GetString(bytes);
}
@ -191,8 +197,9 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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);
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task ReadAllTextAsync(string path, Action<string> reader, Guid containerId = default)
=> await this.ReadAllTextAsync(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
@ -205,7 +212,8 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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)
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
public async Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -216,7 +224,7 @@ internal class ReliableFileStorage : IInternalDisposableService
// 1.) Try without using the backup
try
{
var text = this.ReadAllText(path, encoding, false, containerId);
var text = await this.ReadAllTextAsync(path, encoding, false, containerId);
reader(text);
return;
}
@ -233,7 +241,7 @@ internal class ReliableFileStorage : IInternalDisposableService
// 2.) Try using the backup
try
{
var text = this.ReadAllText(path, encoding, true, containerId);
var text = await this.ReadAllTextAsync(path, encoding, true, containerId);
reader(text);
}
catch (Exception ex)
@ -253,7 +261,7 @@ internal class ReliableFileStorage : IInternalDisposableService
/// <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)
public async Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false, Guid containerId = default)
{
ArgumentException.ThrowIfNullOrEmpty(path);
@ -265,15 +273,12 @@ internal class ReliableFileStorage : IInternalDisposableService
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;
return file == null ? throw new FileNotFoundException() : file.Data;
}
// If the file doesn't exist, immediately check the backup db
if (!File.Exists(path))
return this.ReadAllBytes(path, true, containerId);
return await this.ReadAllBytesAsync(path, true, containerId);
try
{
@ -282,7 +287,7 @@ internal class ReliableFileStorage : IInternalDisposableService
catch (Exception e)
{
Log.Error(e, "Failed to read file from disk, falling back to database");
return this.ReadAllBytes(path, true, containerId);
return await this.ReadAllBytesAsync(path, true, containerId);
}
}

View file

@ -0,0 +1,199 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Dalamud.IoC;
using Dalamud.IoC.Internal;
using Dalamud.Plugin.Internal.Types;
using Dalamud.Plugin.Services;
namespace Dalamud.Storage;
#pragma warning disable Dalamud001
/// <summary>
/// Plugin-scoped VFS wrapper.
/// </summary>
[PluginInterface]
[ServiceManager.ScopedService]
#pragma warning disable SA1015
[ResolveVia<IReliableFileStorage>]
#pragma warning restore SA1015
public class ReliableFileStoragePluginScoped : IReliableFileStorage, IInternalDisposableService
{
private readonly Lock pendingLock = new();
private readonly HashSet<Task> pendingWrites = [];
private readonly LocalPlugin plugin;
[ServiceManager.ServiceDependency]
private readonly ReliableFileStorage storage = Service<ReliableFileStorage>.Get();
// When true, the scope is disposing and new write requests are rejected.
private volatile bool isDisposing = false;
/// <summary>
/// Initializes a new instance of the <see cref="ReliableFileStoragePluginScoped"/> class.
/// </summary>
/// <param name="plugin">The owner plugin.</param>
[ServiceManager.ServiceConstructor]
internal ReliableFileStoragePluginScoped(LocalPlugin plugin)
{
this.plugin = plugin;
}
/// <inheritdoc/>
public long MaxFileSizeBytes => 64 * 1024 * 1024;
/// <inheritdoc/>
public bool Exists(string path)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
return this.storage.Exists(path, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task WriteAllTextAsync(string path, string? contents)
{
// Route through WriteAllBytesAsync so all write tracking and size checks are centralized.
ArgumentException.ThrowIfNullOrEmpty(path);
var bytes = Encoding.UTF8.GetBytes(contents ?? string.Empty);
return this.WriteAllBytesAsync(path, bytes);
}
/// <inheritdoc/>
public Task WriteAllTextAsync(string path, string? contents, Encoding encoding)
{
// Route through WriteAllBytesAsync so all write tracking and size checks are centralized.
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
var bytes = encoding.GetBytes(contents ?? string.Empty);
return this.WriteAllBytesAsync(path, bytes);
}
/// <inheritdoc/>
public Task WriteAllBytesAsync(string path, byte[] bytes)
{
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(bytes);
if (bytes.LongLength > this.MaxFileSizeBytes)
throw new ArgumentException($"The provided data exceeds the maximum allowed size of {this.MaxFileSizeBytes} bytes.", nameof(bytes));
// Start the underlying write task
var task = Task.Run(() => this.storage.WriteAllBytesAsync(path, bytes, this.plugin.EffectiveWorkingPluginId));
// Track the task so we can wait for it on dispose
lock (this.pendingLock)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
this.pendingWrites.Add(task);
}
// Remove when done, if the task is already done this runs synchronously here and removes immediately
_ = task.ContinueWith(t =>
{
lock (this.pendingLock)
{
this.pendingWrites.Remove(t);
}
}, TaskContinuationOptions.ExecuteSynchronously);
return task;
}
/// <inheritdoc/>
public Task<string> ReadAllTextAsync(string path, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
return this.storage.ReadAllTextAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task<string> ReadAllTextAsync(string path, Encoding encoding, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
return this.storage.ReadAllTextAsync(path, encoding, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task ReadAllTextAsync(string path, Action<string> reader)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(reader);
return this.storage.ReadAllTextAsync(path, reader, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task ReadAllTextAsync(string path, Encoding encoding, Action<string> reader)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
ArgumentNullException.ThrowIfNull(encoding);
ArgumentNullException.ThrowIfNull(reader);
return this.storage.ReadAllTextAsync(path, encoding, reader, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public Task<byte[]> ReadAllBytesAsync(string path, bool forceBackup = false)
{
if (this.isDisposing)
throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped));
ArgumentException.ThrowIfNullOrEmpty(path);
return this.storage.ReadAllBytesAsync(path, forceBackup, this.plugin.EffectiveWorkingPluginId);
}
/// <inheritdoc/>
public void DisposeService()
{
Task[] tasksSnapshot;
lock (this.pendingLock)
{
// Mark disposing to reject new writes.
this.isDisposing = true;
if (this.pendingWrites.Count == 0)
return;
tasksSnapshot = this.pendingWrites.ToArray();
}
try
{
// Wait for all pending writes to complete. If some complete while we're waiting they will be in tasksSnapshot
// and are observed here; newly started writes are rejected due to isDisposing.
Task.WaitAll(tasksSnapshot);
}
catch (AggregateException)
{
// Swallow exceptions here: the underlying write failures will have been surfaced earlier to callers.
// We don't want dispose to throw and crash unload sequences.
}
}
}

View file

@ -140,10 +140,10 @@ public static partial class Util
}
/// <summary>
/// Gets the Dalamud branch name this version of Dalamud was built from, or null, if this is a Debug build.
/// Gets the Git branch name this version of Dalamud was built from, or null, if this is a Debug build.
/// </summary>
/// <returns>The branch name.</returns>
public static string? GetBranch()
public static string? GetGitBranch()
{
if (branchInternal != null)
return branchInternal;
@ -155,7 +155,17 @@ public static partial class Util
if (gitBranch == null)
return null;
return branchInternal = gitBranch == "master" ? "release" : gitBranch;
return branchInternal = gitBranch;
}
/// <summary>
/// Gets the active Dalamud track, if this instance was launched through XIVLauncher and used a version
/// downloaded from webservices.
/// </summary>
/// <returns>The name of the track, or null.</returns>
internal static string? GetActiveTrack()
{
return Environment.GetEnvironmentVariable("DALAMUD_BRANCH");
}
/// <inheritdoc cref="DescribeAddress(nint)"/>