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 /// /// Plugin-scoped VFS wrapper. /// [PluginInterface] [ServiceManager.ScopedService] #pragma warning disable SA1015 [ResolveVia] #pragma warning restore SA1015 public class ReliableFileStoragePluginScoped : IReliableFileStorage, IInternalDisposableService { private readonly Lock pendingLock = new(); private readonly HashSet pendingWrites = []; private readonly LocalPlugin plugin; [ServiceManager.ServiceDependency] private readonly ReliableFileStorage storage = Service.Get(); // When true, the scope is disposing and new write requests are rejected. private volatile bool isDisposing = false; /// /// Initializes a new instance of the class. /// /// The owner plugin. [ServiceManager.ServiceConstructor] internal ReliableFileStoragePluginScoped(LocalPlugin plugin) { this.plugin = plugin; } /// public long MaxFileSizeBytes => 64 * 1024 * 1024; /// public bool Exists(string path) { if (this.isDisposing) throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); return this.storage.Exists(path, this.plugin.EffectiveWorkingPluginId); } /// 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); } /// 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); } /// 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; } /// public Task 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); } /// public Task 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); } /// public Task ReadAllTextAsync(string path, Action reader) { if (this.isDisposing) throw new ObjectDisposedException(nameof(ReliableFileStoragePluginScoped)); ArgumentException.ThrowIfNullOrEmpty(path); ArgumentNullException.ThrowIfNull(reader); return this.storage.ReadAllTextAsync(path, reader, this.plugin.EffectiveWorkingPluginId); } /// public Task ReadAllTextAsync(string path, Encoding encoding, Action 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); } /// public Task 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); } /// 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. } } }