Add WinUI and core source
build-winui / winui (push) Has been cancelled
build-winui / winui (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<bool> 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<string> 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<EmbeddedBuiltInPlugin> DiscoverEmbeddedPlugins()
|
||||
{
|
||||
var assembly = typeof(BuiltInPluginInstallerService).Assembly;
|
||||
var resources = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.Ordinal))
|
||||
.Select(ParseResource)
|
||||
.OfType<EmbeddedBuiltInResource>()
|
||||
.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<EmbeddedBuiltInResource> Resources);
|
||||
|
||||
private sealed record EmbeddedBuiltInResource(
|
||||
string PluginToken,
|
||||
string RelativePath,
|
||||
string ResourceName);
|
||||
}
|
||||
Reference in New Issue
Block a user