using YMhut.Box.Core.App; using YMhut.Box.Core.Logging; using YMhut.Box.Core.Settings; using System.Security.Cryptography; using System.Text; namespace YMhut.Box.Core.Plugins; public interface IBuiltInPluginInstallerService { Task EnsureInstalledAsync(CancellationToken cancellationToken = default); Task ResetAsync(string pluginId, CancellationToken cancellationToken = default); } public sealed class BuiltInPluginInstallerService( AppPaths paths, ILogService? logService = null, ISettingsService? settingsService = null) : IBuiltInPluginInstallerService { private const string ResourcePrefix = "YMhut.Box.Core.Plugins.BuiltIn."; private const string FingerprintFileName = ".ymhut-built-in.sha256"; private static readonly string[] KnownRootFiles = [ "README.md", "ymhut.plugin.json", "index.html", "style.css", "main.js" ]; private readonly SemaphoreSlim _gate = new(1, 1); public string PluginsRoot => PluginRegistryService.ResolvePluginsRoot(paths, settingsService?.Current.PluginRootPath); public async Task EnsureInstalledAsync(CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { Directory.CreateDirectory(PluginsRoot); var plugins = DiscoverEmbeddedPlugins(); if (plugins.Count == 0) { await WriteLogAsync("Warning", "No built-in plugin resources found", PluginsRoot, cancellationToken).ConfigureAwait(false); return; } foreach (var plugin in plugins) { var targetRoot = Path.Combine(PluginsRoot, plugin.FolderName); if (Directory.Exists(targetRoot)) { if (await IsUnmodifiedBuiltInPluginAsync(targetRoot, cancellationToken).ConfigureAwait(false)) { Directory.Delete(targetRoot, recursive: true); await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false); await WriteLogAsync("Information", "Built-in plugin refreshed", targetRoot, cancellationToken).ConfigureAwait(false); } continue; } await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false); await WriteLogAsync("Information", "Built-in plugin installed", targetRoot, cancellationToken).ConfigureAwait(false); } } finally { _gate.Release(); } } public async Task ResetAsync(string pluginId, CancellationToken cancellationToken = default) { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var plugin = DiscoverEmbeddedPlugins().FirstOrDefault(item => string.Equals(item.FolderName, pluginId, StringComparison.OrdinalIgnoreCase)); if (plugin is null) { return false; } var targetRoot = Path.Combine(PluginsRoot, plugin.FolderName); if (Directory.Exists(targetRoot)) { Directory.Delete(targetRoot, recursive: true); } await ExtractEmbeddedPluginAsync(plugin, targetRoot, cancellationToken).ConfigureAwait(false); await WriteLogAsync("Information", "Built-in plugin reset", targetRoot, cancellationToken).ConfigureAwait(false); return true; } finally { _gate.Release(); } } private static async Task IsUnmodifiedBuiltInPluginAsync(string targetRoot, CancellationToken cancellationToken) { var fingerprintPath = Path.Combine(targetRoot, FingerprintFileName); if (!File.Exists(fingerprintPath)) { return false; } try { var saved = (await File.ReadAllTextAsync(fingerprintPath, cancellationToken).ConfigureAwait(false)).Trim(); if (string.IsNullOrWhiteSpace(saved)) { return false; } var current = await ComputeDirectoryFingerprintAsync(targetRoot, cancellationToken).ConfigureAwait(false); return string.Equals(saved, current, StringComparison.OrdinalIgnoreCase); } catch (IOException) { return false; } catch (UnauthorizedAccessException) { return false; } } private static async Task ExtractEmbeddedPluginAsync(EmbeddedBuiltInPlugin plugin, string targetRoot, CancellationToken cancellationToken) { var assembly = typeof(BuiltInPluginInstallerService).Assembly; Directory.CreateDirectory(targetRoot); foreach (var resource in plugin.Resources) { cancellationToken.ThrowIfCancellationRequested(); var target = Path.GetFullPath(Path.Combine(targetRoot, resource.RelativePath)); if (!PluginRegistryService.IsInside(targetRoot, target)) { throw new InvalidDataException($"Built-in plugin resource escapes target directory: {resource.ResourceName}"); } Directory.CreateDirectory(Path.GetDirectoryName(target)!); await using var input = assembly.GetManifestResourceStream(resource.ResourceName) ?? throw new InvalidOperationException($"Built-in plugin resource cannot be opened: {resource.ResourceName}"); await using var output = File.Create(target); await input.CopyToAsync(output, cancellationToken).ConfigureAwait(false); } var fingerprint = await ComputeDirectoryFingerprintAsync(targetRoot, cancellationToken).ConfigureAwait(false); await File.WriteAllTextAsync(Path.Combine(targetRoot, FingerprintFileName), fingerprint, Encoding.UTF8, cancellationToken).ConfigureAwait(false); } private static async Task ComputeDirectoryFingerprintAsync(string targetRoot, CancellationToken cancellationToken) { using var sha = SHA256.Create(); var files = Directory.EnumerateFiles(targetRoot, "*", SearchOption.AllDirectories) .Where(path => !string.Equals(Path.GetFileName(path), FingerprintFileName, StringComparison.OrdinalIgnoreCase)) .Select(path => Path.GetFullPath(path)) .Order(StringComparer.OrdinalIgnoreCase); foreach (var file in files) { cancellationToken.ThrowIfCancellationRequested(); var relative = file.Substring(Path.GetFullPath(targetRoot).Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Replace('\\', '/'); var nameBytes = Encoding.UTF8.GetBytes(relative); sha.TransformBlock(nameBytes, 0, nameBytes.Length, null, 0); sha.TransformBlock([0], 0, 1, null, 0); var content = await File.ReadAllBytesAsync(file, cancellationToken).ConfigureAwait(false); sha.TransformBlock(content, 0, content.Length, null, 0); sha.TransformBlock([0], 0, 1, null, 0); } sha.TransformFinalBlock([], 0, 0); return Convert.ToHexString(sha.Hash ?? []); } private Task WriteLogAsync(string level, string message, string detail, CancellationToken cancellationToken) { return logService?.WriteAsync(level, "plugin", message, detail, cancellationToken) ?? Task.CompletedTask; } private static IReadOnlyList DiscoverEmbeddedPlugins() { var assembly = typeof(BuiltInPluginInstallerService).Assembly; var resources = assembly.GetManifestResourceNames() .Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal)) .Select(ParseResource) .OfType() .GroupBy(resource => resource.PluginToken, StringComparer.OrdinalIgnoreCase) .Select(group => new EmbeddedBuiltInPlugin( DecodePluginFolder(group.Key), group.OrderBy(resource => resource.RelativePath, StringComparer.OrdinalIgnoreCase).ToArray())) .Where(plugin => plugin.Resources.Any(resource => string.Equals(resource.RelativePath, PluginManifest.FileName, StringComparison.OrdinalIgnoreCase))) .OrderBy(plugin => plugin.FolderName, StringComparer.OrdinalIgnoreCase) .ToArray(); return resources; } private static EmbeddedBuiltInResource? ParseResource(string resourceName) { var relativeName = resourceName[ResourcePrefix.Length..]; foreach (var fileName in KnownRootFiles) { var suffix = "." + fileName; if (!relativeName.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) { continue; } var token = relativeName[..^suffix.Length]; if (string.IsNullOrWhiteSpace(token)) { return null; } return new EmbeddedBuiltInResource(token, fileName, resourceName); } return null; } private static string DecodePluginFolder(string token) { return token.Replace('_', '-'); } private sealed record EmbeddedBuiltInPlugin( string FolderName, IReadOnlyList Resources); private sealed record EmbeddedBuiltInResource( string PluginToken, string RelativePath, string ResourceName); }