using System.Collections.Concurrent; using System.Text.Json; namespace XFEToolBox.Core.Tools; /// /// 工具宿主保存的窗口恢复信息。最小化状态会被记录,但下次启动时应恢复到 /// ,避免工具启动后不可见。 /// public sealed record ToolWindowPlacement( double Left, double Top, double Width, double Height, string State, string LastVisibleState, bool WasMinimized, DateTimeOffset SavedAt); /// /// 当前工具进程的数据入口。工具宿主必须先以 manifest 中的工具 ID 调用 /// ;工具代码随后只能通过当前上下文读写自己的数据。 /// public static class ToolDataStore { private const string WindowPlacementKey = ".host/window-placement"; private static readonly object InitializationLock = new(); private static string? _currentToolId; public static bool IsInitialized => _currentToolId is not null; public static string CurrentToolId => _currentToolId ?? throw new InvalidOperationException("工具数据存储尚未初始化。请由 XFEToolBox 工具宿主先调用 ToolDataStore.Initialize。 "); public static string DataDirectory => ToolDataManager.GetToolDataDirectory(CurrentToolId); public static void Initialize(string toolId) { var validatedId = ToolDataManager.ValidateToolId(toolId); lock (InitializationLock) { if (_currentToolId is null) { _currentToolId = validatedId; return; } if (!string.Equals(_currentToolId, validatedId, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException($"当前进程已绑定工具“{_currentToolId}”,不能切换到“{validatedId}”。"); } } public static T Read(string key, T fallback = default!) => TryRead(key, out var value) ? value! : fallback; public static bool TryRead(string key, out T? value) { try { value = ToolDataManager.ReadJson(CurrentToolId, key); return value is not null; } catch (IOException) { value = default; return false; } catch (UnauthorizedAccessException) { value = default; return false; } catch (JsonException) { value = default; return false; } } public static Task ReadAsync(string key, CancellationToken cancellationToken = default) => ToolDataManager.ReadJsonAsync(CurrentToolId, key, cancellationToken); public static void Write(string key, T value) => ToolDataManager.WriteJson(CurrentToolId, key, value); public static Task WriteAsync(string key, T value, CancellationToken cancellationToken = default) => ToolDataManager.WriteJsonAsync(CurrentToolId, key, value, cancellationToken); public static bool Delete(string key) => ToolDataManager.DeleteEntry(CurrentToolId, key); /// /// 获取当前工具隔离目录下的文件路径。传入值必须是相对路径且不能越过工具目录。 /// 适用于不便以 JSON 表示的缓存或二进制数据。 /// public static string GetFilePath(string relativePath, bool createParentDirectory = false) => ToolDataManager.GetToolFilePath(CurrentToolId, relativePath, createParentDirectory); public static ToolWindowPlacement? ReadWindowPlacement() => Read(WindowPlacementKey); public static void WriteWindowPlacement(ToolWindowPlacement placement) => Write(WindowPlacementKey, placement); } /// /// XFEToolBox 主程序使用的数据管理入口。它按工具 ID 查询、统计或清空数据, /// 不会改变工具进程的当前数据上下文。 /// public static class ToolDataManager { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true }; private static readonly ConcurrentDictionary FileLocks = new(StringComparer.OrdinalIgnoreCase); public static string RootDirectory => Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "XFEToolBox", "CrossVersion", "ToolData"); public static string ValidateToolId(string toolId) { ArgumentException.ThrowIfNullOrWhiteSpace(toolId); var value = toolId.Trim(); if (value.Length > 160 || value is "." or ".." || value.Any(character => !(char.IsLetterOrDigit(character) || character is '.' or '-' or '_'))) throw new ArgumentException("工具 ID 只能包含字母、数字、点、横线和下划线,且长度不能超过 160。", nameof(toolId)); return value; } public static string GetToolDataDirectory(string toolId) { var validatedId = ValidateToolId(toolId); var root = Path.GetFullPath(RootDirectory); var target = Path.GetFullPath(Path.Combine(root, validatedId)); EnsureContained(root, target); return target; } public static string GetToolFilePath(string toolId, string relativePath, bool createParentDirectory = false) { ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); if (Path.IsPathRooted(relativePath)) throw new ArgumentException("工具数据文件必须使用相对路径。", nameof(relativePath)); var toolDirectory = GetToolDataDirectory(toolId); var target = Path.GetFullPath(Path.Combine(toolDirectory, relativePath)); EnsureContained(toolDirectory, target); if (string.Equals(target, toolDirectory, StringComparison.OrdinalIgnoreCase)) throw new ArgumentException("工具数据文件路径不能指向数据目录本身。", nameof(relativePath)); if (createParentDirectory) Directory.CreateDirectory(Path.GetDirectoryName(target)!); return target; } public static bool HasToolData(string toolId) { var directory = GetToolDataDirectory(toolId); return Directory.Exists(directory) && Directory.EnumerateFileSystemEntries(directory).Any(); } public static long GetToolDataSize(string toolId) { var directory = GetToolDataDirectory(toolId); if (!Directory.Exists(directory)) return 0; try { return Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories) .Sum(path => new FileInfo(path).Length); } catch (IOException) { return 0; } catch (UnauthorizedAccessException) { return 0; } } public static bool ClearToolData(string toolId) { var directory = GetToolDataDirectory(toolId); if (!Directory.Exists(directory)) return false; Directory.Delete(directory, recursive: true); return true; } public static T? ReadJson(string toolId, string key) => ReadJsonAsync(toolId, key).GetAwaiter().GetResult(); public static async Task ReadJsonAsync( string toolId, string key, CancellationToken cancellationToken = default) { var path = GetJsonPath(toolId, key); if (!File.Exists(path)) return default; var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { await using var stream = new FileStream( path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken).ConfigureAwait(false); } finally { gate.Release(); } } public static void WriteJson(string toolId, string key, T value) => WriteJsonAsync(toolId, key, value).GetAwaiter().GetResult(); public static async Task WriteJsonAsync( string toolId, string key, T value, CancellationToken cancellationToken = default) { var path = GetJsonPath(toolId, key); var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); string? temporaryPath = null; try { Directory.CreateDirectory(Path.GetDirectoryName(path)!); temporaryPath = $"{path}.{Guid.NewGuid():N}.tmp"; await using (var stream = new FileStream( temporaryPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan)) { await JsonSerializer.SerializeAsync(stream, value, JsonOptions, cancellationToken).ConfigureAwait(false); await stream.FlushAsync(cancellationToken).ConfigureAwait(false); } File.Move(temporaryPath, path, overwrite: true); temporaryPath = null; } finally { if (temporaryPath is not null && File.Exists(temporaryPath)) File.Delete(temporaryPath); gate.Release(); } } public static bool DeleteEntry(string toolId, string key) { var path = GetJsonPath(toolId, key); var gate = FileLocks.GetOrAdd(path, static _ => new SemaphoreSlim(1, 1)); gate.Wait(); try { if (!File.Exists(path)) return false; File.Delete(path); RemoveEmptyParents(Path.GetDirectoryName(path)!, GetToolDataDirectory(toolId)); return true; } finally { gate.Release(); } } private static string GetJsonPath(string toolId, string key) { ArgumentException.ThrowIfNullOrWhiteSpace(key); var relativePath = key.EndsWith(".json", StringComparison.OrdinalIgnoreCase) ? key : key + ".json"; return GetToolFilePath(toolId, relativePath); } private static void EnsureContained(string rootPath, string targetPath) { var root = Path.GetFullPath(rootPath) .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var target = Path.GetFullPath(targetPath); if (!target.StartsWith(root + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("工具数据路径越过了 XFEToolBox 管理的数据目录。 "); } private static void RemoveEmptyParents(string directory, string stopDirectory) { var stop = Path.GetFullPath(stopDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var current = Path.GetFullPath(directory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); while (!string.Equals(current, stop, StringComparison.OrdinalIgnoreCase) && current.StartsWith(stop + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) && Directory.Exists(current) && !Directory.EnumerateFileSystemEntries(current).Any()) { Directory.Delete(current); current = Path.GetDirectoryName(current) ?? stop; } } }