using System; using System.Collections.Concurrent; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Cysharp.Threading.Tasks; namespace ShrinkDataSaver { public class LocalStorageProvider : IStorageProvider { private const string BackupPrimarySuffix = ".bak1"; private const string BackupSecondarySuffix = ".bak2"; private const string TempWriteSuffix = ".tmp"; private static readonly ConcurrentDictionary PathLocks = new(StringComparer.OrdinalIgnoreCase); private readonly string _rootPath; public LocalStorageProvider(string rootPath = null) { _rootPath = rootPath ?? ShrinkDataSaverPlatform.Paths?.PersistentDataPath #if UNITY_5_3_OR_NEWER ?? UnityEngine.Application.persistentDataPath #endif ?? throw new InvalidOperationException("ShrinkDataSaver persistent path is not configured."); } private string Resolve(string path) => Path.IsPathRooted(path) ? path : Path.Combine(_rootPath, path); public async UniTask WriteAsync(string path, byte[] data, CancellationToken ct = default) { var fullPath = Resolve(path); var dir = Path.GetDirectoryName(fullPath); if (!string.IsNullOrEmpty(dir)) { await Task.Run(() => Directory.CreateDirectory(dir), ct); } var tempPath = fullPath + TempWriteSuffix; var backupPath = fullPath + BackupPrimarySuffix; var secondaryBackupPath = fullPath + BackupSecondarySuffix; var pathLock = GetPathLock(fullPath); await pathLock.WaitAsync(ct); try { await WriteFileBytesAsync(tempPath, data, ct); await Task.Run(() => { if (File.Exists(fullPath)) { if (File.Exists(secondaryBackupPath)) { File.Delete(secondaryBackupPath); } if (File.Exists(backupPath)) { File.Move(backupPath, secondaryBackupPath); } File.Replace(tempPath, fullPath, backupPath, true); } else { File.Move(tempPath, fullPath); } }, ct); } catch { await Task.Run(() => { if (File.Exists(tempPath)) { File.Delete(tempPath); } }, CancellationToken.None); throw; } finally { pathLock.Release(); } } public async UniTask ReadAsync(string path, CancellationToken ct = default) { var fullPath = Resolve(path); var pathLock = GetPathLock(fullPath); await pathLock.WaitAsync(ct); try { if (!File.Exists(fullPath)) { throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}"); } return await ReadFileBytesAsync(fullPath, ct); } finally { pathLock.Release(); } } public UniTask ExistsAsync(string path, CancellationToken ct = default) => Task.Run(() => File.Exists(Resolve(path)), ct).AsUniTask(); public async UniTask DeleteAsync(string path, CancellationToken ct = default) { var fullPath = Resolve(path); var pathLock = GetPathLock(fullPath); await pathLock.WaitAsync(ct); try { await Task.Run(() => { if (File.Exists(fullPath)) { File.Delete(fullPath); } }, ct); } finally { pathLock.Release(); } } public async UniTask ListAsync(string prefix = "", CancellationToken ct = default) { return await Task.Run(() => { var dir = string.IsNullOrEmpty(prefix) ? _rootPath : Path.Combine(_rootPath, prefix); if (!Directory.Exists(dir)) { return Array.Empty(); } return Directory.GetFiles(dir) .Select(f => Path.GetRelativePath(_rootPath, f)) .ToArray(); }, ct); } private static SemaphoreSlim GetPathLock(string fullPath) { return PathLocks.GetOrAdd(fullPath, _ => new SemaphoreSlim(1, 1)); } private static async UniTask WriteFileBytesAsync(string fullPath, byte[] data, CancellationToken ct) { await using var stream = new FileStream( fullPath, FileMode.Create, FileAccess.Write, FileShare.None, 4096, FileOptions.Asynchronous); await stream.WriteAsync(data, 0, data.Length, ct); stream.Flush(true); } private static async UniTask ReadFileBytesAsync(string fullPath, CancellationToken ct) { await using var stream = new FileStream( fullPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous | FileOptions.SequentialScan); var length = stream.Length; if (length > int.MaxValue) { throw new IOException($"ShrinkDataSaver: file too large to read into memory: {fullPath}"); } var buffer = new byte[length]; var offset = 0; while (offset < buffer.Length) { var read = await stream.ReadAsync(buffer, offset, buffer.Length - offset, ct); if (read <= 0) { throw new EndOfStreamException($"ShrinkDataSaver: unexpected EOF while reading {fullPath}"); } offset += read; } return buffer; } } }