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 enum LocalStorageCommitStrategy { FileReplace = 0, RenameOnly = 1 } 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; private readonly LocalStorageCommitStrategy _commitStrategy; private readonly Action _overwriteMove; public LocalStorageProvider(string rootPath = null) : this(rootPath, LocalStorageCommitStrategy.FileReplace) { } public LocalStorageProvider(string rootPath, LocalStorageCommitStrategy commitStrategy) : this(rootPath, commitStrategy, null) { } public LocalStorageProvider(string rootPath, LocalStorageCommitStrategy commitStrategy, Action overwriteMove) { if (!Enum.IsDefined(typeof(LocalStorageCommitStrategy), commitStrategy)) throw new ArgumentOutOfRangeException(nameof(commitStrategy)); if (commitStrategy == LocalStorageCommitStrategy.RenameOnly && overwriteMove == null) throw new ArgumentException("RenameOnly requires an overwrite-rename implementation.", nameof(overwriteMove)); _overwriteMove = overwriteMove; _rootPath = rootPath ?? ShrinkDataSaverPlatform.Paths?.PersistentDataPath #if UNITY_5_3_OR_NEWER ?? UnityEngine.Application.persistentDataPath #endif ?? throw new InvalidOperationException("ShrinkDataSaver persistent path is not configured."); _commitStrategy = commitStrategy; } public LocalStorageCommitStrategy CommitStrategy => _commitStrategy; 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 pathLock = GetPathLock(fullPath); await pathLock.WaitAsync(ct); try { await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); ct.ThrowIfCancellationRequested(); var tempPath = fullPath + TempWriteSuffix; try { await WriteFileBytesAsync(tempPath, data, ct); ct.ThrowIfCancellationRequested(); await Task.Run(() => Commit(tempPath, fullPath), CancellationToken.None); } catch { await Task.Run(() => TryDelete(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 { await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); if (!File.Exists(fullPath)) { throw new FileNotFoundException($"ShrinkDataSaver: file not found: {fullPath}"); } return await ReadFileBytesAsync(fullPath, ct); } finally { pathLock.Release(); } } public async UniTask ExistsAsync(string path, CancellationToken ct = default) => await ExistsCoreAsync(Resolve(path), ct); private async UniTask ExistsCoreAsync(string fullPath, CancellationToken ct) { var pathLock = GetPathLock(fullPath); await pathLock.WaitAsync(ct); try { await Task.Run(() => RecoverInterruptedWrite(fullPath), CancellationToken.None); return File.Exists(fullPath); } finally { pathLock.Release(); } } 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(() => { File.Delete(fullPath + TempWriteSuffix); File.Delete(fullPath + BackupPrimarySuffix); File.Delete(fullPath + BackupSecondarySuffix); 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 void Commit(string tempPath, string fullPath) { if (_commitStrategy == LocalStorageCommitStrategy.RenameOnly) CommitRenameOnly(tempPath, fullPath); else CommitFileReplace(tempPath, fullPath); } private static void CommitFileReplace(string tempPath, string fullPath) { var backupPath = fullPath + BackupPrimarySuffix; var secondaryBackupPath = fullPath + BackupSecondarySuffix; if (!File.Exists(fullPath)) { File.Move(tempPath, fullPath); return; } if (File.Exists(secondaryBackupPath)) { File.Delete(secondaryBackupPath); } if (File.Exists(backupPath)) { File.Move(backupPath, secondaryBackupPath); } File.Replace(tempPath, fullPath, backupPath, true); } private void CommitRenameOnly(string tempPath, string fullPath) { var backupPath = fullPath + BackupPrimarySuffix; var secondaryBackupPath = fullPath + BackupSecondarySuffix; if (File.Exists(fullPath)) { if (File.Exists(backupPath)) { if (File.Exists(secondaryBackupPath)) { File.Delete(secondaryBackupPath); } MoveFileReplacing(backupPath, secondaryBackupPath); } MoveFileReplacing(fullPath, backupPath); } MoveFileReplacing(tempPath, fullPath); } private void RecoverInterruptedWrite(string fullPath) { if (_commitStrategy != LocalStorageCommitStrategy.RenameOnly) return; var tempPath = fullPath + TempWriteSuffix; var primaryBackupPath = fullPath + BackupPrimarySuffix; var secondaryBackupPath = fullPath + BackupSecondarySuffix; if (!File.Exists(fullPath)) { if (File.Exists(primaryBackupPath)) { MoveFileReplacing(primaryBackupPath, fullPath); } else if (File.Exists(secondaryBackupPath)) { MoveFileReplacing(secondaryBackupPath, fullPath); } } if (File.Exists(tempPath)) { TryDelete(tempPath); } } private void MoveFileReplacing(string source, string destination) => _overwriteMove(source, destination); private static void TryDelete(string path) { try { if (File.Exists(path)) { File.Delete(path); } } catch (IOException) { } catch (UnauthorizedAccessException) { } } 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; } } }