更新客户端渲染,更新了壳
This commit is contained in:
@@ -8,7 +8,9 @@ using YMhut.Box.Core.Feedback;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Media;
|
||||
using YMhut.Box.Core.Net;
|
||||
using YMhut.Box.Core.Platform;
|
||||
using YMhut.Box.Core.Plugins;
|
||||
using YMhut.Box.Core.Plugins.Runtime;
|
||||
using YMhut.Box.Core.Settings;
|
||||
using YMhut.Box.Core.SolarSystem;
|
||||
using YMhut.Box.Core.Startup;
|
||||
@@ -42,6 +44,11 @@ public static class AppServices
|
||||
provider.GetService<ILogService>()));
|
||||
services.AddSingleton<IPluginStateStore, PluginStateStore>();
|
||||
services.AddSingleton<PluginLogService>();
|
||||
services.AddSingleton<IPlatformCapabilities, WindowsPlatformCapabilities>();
|
||||
services.AddSingleton<IShellRuntime>(provider => new ShellRuntimeService(provider.GetService<ILogService>()));
|
||||
services.AddSingleton<IPluginRuntimeLauncher>(provider => new TauriPluginProcessService(
|
||||
provider.GetRequiredService<AppPaths>(),
|
||||
provider.GetService<ILogService>()));
|
||||
services.AddSingleton<IBuiltInPluginInstallerService>(provider => new BuiltInPluginInstallerService(
|
||||
provider.GetRequiredService<AppPaths>(),
|
||||
provider.GetService<ILogService>(),
|
||||
@@ -103,6 +110,10 @@ public static class AppServices
|
||||
provider.GetService<ILogService>(),
|
||||
provider.GetService<ISettingsService>(),
|
||||
provider.GetService<IHardwareInfoService>()));
|
||||
services.AddSingleton<ILocalToolVendorAssetService>(provider => new LocalToolVendorAssetService(
|
||||
provider.GetRequiredService<AppPaths>(),
|
||||
provider.GetService<ILogService>()));
|
||||
services.AddSingleton<ILocalJsToolRuntime, LocalJsToolRuntime>();
|
||||
services.AddSingleton<IToolWorkerService>(provider => new ProcessToolWorkerService(
|
||||
provider.GetService<IApiManager>(),
|
||||
provider.GetService<IReferenceDataService>(),
|
||||
@@ -127,6 +138,11 @@ public static class AppServices
|
||||
provider.GetService<ILogService>()));
|
||||
services.AddSingleton<WebView2EnvironmentFactory>();
|
||||
services.AddSingleton<IUiPerformanceCoordinator, UiPerformanceCoordinator>();
|
||||
services.AddSingleton<WindowChromeService>();
|
||||
services.AddSingleton<ShellNavigationService>();
|
||||
services.AddSingleton<IShellNavigationService>(provider => provider.GetRequiredService<ShellNavigationService>());
|
||||
services.AddSingleton<IShellDialogService, ShellDialogService>();
|
||||
services.AddSingleton<IShellNotificationService, ShellNotificationService>();
|
||||
services.AddSingleton<WindowStateService>();
|
||||
services.AddSingleton<IStartupInitializationService, StartupInitializationService>();
|
||||
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Logging;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public interface ILocalToolVendorAssetService
|
||||
{
|
||||
Task<LocalToolVendorAssetSnapshot> EnsureExtractedAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public interface ILocalJsToolRuntime
|
||||
{
|
||||
IReadOnlyDictionary<string, string> Profiles { get; }
|
||||
|
||||
Task<LocalJsToolRuntimeResult> ExecuteAsync(
|
||||
string toolId,
|
||||
string input,
|
||||
IReadOnlyDictionary<string, string>? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed record LocalToolVendorAssetSnapshot(
|
||||
string CacheRoot,
|
||||
string ManifestPath,
|
||||
string ManifestHash,
|
||||
IReadOnlyList<string> Files);
|
||||
|
||||
public sealed record LocalJsToolRuntimeResult(
|
||||
bool Success,
|
||||
string Output,
|
||||
string? Error = null,
|
||||
string Runtime = "csharp-fallback");
|
||||
|
||||
public sealed class LocalToolVendorAssetService(
|
||||
AppPaths paths,
|
||||
ILogService? logService = null) : ILocalToolVendorAssetService
|
||||
{
|
||||
private const string ResourcePrefix = "YMhut.Box.WinUI.Assets.tool-results.vendor.";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private LocalToolVendorAssetSnapshot? _snapshot;
|
||||
|
||||
public async Task<LocalToolVendorAssetSnapshot> EnsureExtractedAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_snapshot is not null)
|
||||
{
|
||||
return _snapshot;
|
||||
}
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (_snapshot is not null)
|
||||
{
|
||||
return _snapshot;
|
||||
}
|
||||
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resources = assembly.GetManifestResourceNames()
|
||||
.Where(name => name.StartsWith(ResourcePrefix, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
if (resources.Length == 0)
|
||||
{
|
||||
_snapshot = await SnapshotFromContentFolderAsync(cancellationToken).ConfigureAwait(false);
|
||||
return _snapshot;
|
||||
}
|
||||
|
||||
var manifestResource = resources.FirstOrDefault(name => name.EndsWith(".manifest.json", StringComparison.OrdinalIgnoreCase)) ??
|
||||
resources.FirstOrDefault(name => name.EndsWith("manifest.json", StringComparison.OrdinalIgnoreCase));
|
||||
var manifestHash = manifestResource is null
|
||||
? HashText(string.Join("|", resources))
|
||||
: await HashResourceAsync(assembly, manifestResource, cancellationToken).ConfigureAwait(false);
|
||||
var cacheRoot = Path.Combine(paths.Cache, "tool-vendor", manifestHash);
|
||||
Directory.CreateDirectory(cacheRoot);
|
||||
|
||||
var extracted = new List<string>();
|
||||
foreach (var resource in resources)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var relative = ResourceToRelativePath(resource);
|
||||
if (string.IsNullOrWhiteSpace(relative))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var target = Path.Combine(cacheRoot, relative);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
await using var source = assembly.GetManifestResourceStream(resource);
|
||||
if (source is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await using var output = File.Create(target);
|
||||
await source.CopyToAsync(output, cancellationToken).ConfigureAwait(false);
|
||||
extracted.Add(target);
|
||||
}
|
||||
|
||||
_snapshot = new LocalToolVendorAssetSnapshot(
|
||||
cacheRoot,
|
||||
Path.Combine(cacheRoot, "manifest.json"),
|
||||
manifestHash,
|
||||
extracted);
|
||||
await logService.WriteAsync(
|
||||
"Information",
|
||||
"tool-vendor",
|
||||
"Local JS vendor assets extracted",
|
||||
$"files={extracted.Count}; hash={manifestHash}",
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return _snapshot;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<LocalToolVendorAssetSnapshot> SnapshotFromContentFolderAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var sourceRoot = Path.Combine(paths.Assets, "tool-results", "vendor");
|
||||
var files = Directory.Exists(sourceRoot)
|
||||
? Directory.EnumerateFiles(sourceRoot, "*", SearchOption.AllDirectories).OrderBy(path => path, StringComparer.OrdinalIgnoreCase).ToArray()
|
||||
: [];
|
||||
var manifest = Path.Combine(sourceRoot, "manifest.json");
|
||||
var hash = File.Exists(manifest)
|
||||
? Convert.ToHexString(await SHA256.HashDataAsync(File.OpenRead(manifest), cancellationToken).ConfigureAwait(false)).ToLowerInvariant()
|
||||
: HashText(string.Join("|", files.Select(Path.GetFileName)));
|
||||
return new LocalToolVendorAssetSnapshot(sourceRoot, manifest, hash, files);
|
||||
}
|
||||
|
||||
private static string ResourceToRelativePath(string resource)
|
||||
{
|
||||
var name = resource[ResourcePrefix.Length..];
|
||||
if (name.StartsWith("vendor.", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
name = name["vendor.".Length..];
|
||||
}
|
||||
|
||||
return name;
|
||||
}
|
||||
|
||||
private static async Task<string> HashResourceAsync(Assembly assembly, string resource, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = assembly.GetManifestResourceStream(resource) ?? new MemoryStream();
|
||||
return Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static string HashText(string value)
|
||||
=> Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
}
|
||||
|
||||
public sealed class LocalJsToolRuntime(ILocalToolVendorAssetService vendorAssets) : ILocalJsToolRuntime
|
||||
{
|
||||
public IReadOnlyDictionary<string, string> Profiles { get; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["json_formatter"] = "json",
|
||||
["yaml_json_converter"] = "yaml",
|
||||
["html_minifier"] = "markup",
|
||||
["html_entity"] = "html-entity",
|
||||
["markdown_preview"] = "markdown",
|
||||
["markdown_table_normalizer"] = "markdown",
|
||||
["timestamp_converter"] = "time",
|
||||
["punycode_codec"] = "idn"
|
||||
};
|
||||
|
||||
public async Task<LocalJsToolRuntimeResult> ExecuteAsync(
|
||||
string toolId,
|
||||
string input,
|
||||
IReadOnlyDictionary<string, string>? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await vendorAssets.EnsureExtractedAsync(cancellationToken).ConfigureAwait(false);
|
||||
return new LocalJsToolRuntimeResult(
|
||||
false,
|
||||
input,
|
||||
"Local JS assets are available for WebView rendering; core execution is handled by the C# fallback.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public interface IShellDialogService
|
||||
{
|
||||
void Attach(XamlRoot? xamlRoot);
|
||||
|
||||
Task<ContentDialogResult> ShowAsync(ContentDialog dialog);
|
||||
}
|
||||
|
||||
public sealed class ShellDialogService : IShellDialogService
|
||||
{
|
||||
private XamlRoot? _xamlRoot;
|
||||
|
||||
public void Attach(XamlRoot? xamlRoot)
|
||||
{
|
||||
_xamlRoot = xamlRoot;
|
||||
}
|
||||
|
||||
public async Task<ContentDialogResult> ShowAsync(ContentDialog dialog)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialog.XamlRoot ??= _xamlRoot ?? ToastService.CurrentXamlRoot;
|
||||
return await dialog.ShowAsync();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
CrashLog.Write(exception);
|
||||
ToastService.Show(AppLocalizer.T("无法打开对话框,请查看日志。", "Could not open the dialog. Check logs."), ToastKind.Error);
|
||||
return ContentDialogResult.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public interface IShellNotificationService
|
||||
{
|
||||
void Attach(StackPanel host);
|
||||
|
||||
void Show(string message, ToastKind kind = ToastKind.Success, TimeSpan? duration = null);
|
||||
}
|
||||
|
||||
public sealed class ShellNotificationService : IShellNotificationService
|
||||
{
|
||||
public void Attach(StackPanel host)
|
||||
{
|
||||
ToastService.Attach(host);
|
||||
}
|
||||
|
||||
public void Show(string message, ToastKind kind = ToastKind.Success, TimeSpan? duration = null)
|
||||
{
|
||||
ToastService.Show(message, kind, duration);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using Microsoft.UI.Xaml;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public enum ShellRoutePlacement
|
||||
{
|
||||
Main,
|
||||
Footer,
|
||||
Hidden
|
||||
}
|
||||
|
||||
public enum ShellRouteCacheMode
|
||||
{
|
||||
Disabled,
|
||||
Enabled
|
||||
}
|
||||
|
||||
public sealed record ShellRoute(
|
||||
string Tag,
|
||||
string Title,
|
||||
string EnglishTitle,
|
||||
string IconGlyph,
|
||||
ShellRoutePlacement Placement,
|
||||
Func<object?, UIElement>? Factory = null,
|
||||
ShellRouteCacheMode CacheMode = ShellRouteCacheMode.Disabled,
|
||||
IReadOnlyList<string>? SearchKeywords = null,
|
||||
Func<object?, IReadOnlyList<string>>? BreadcrumbProvider = null)
|
||||
{
|
||||
public string CurrentTitle => AppLocalizer.T(Title, EnglishTitle);
|
||||
|
||||
public IReadOnlyList<string> GetBreadcrumbs(object? parameter = null)
|
||||
{
|
||||
if (BreadcrumbProvider is not null)
|
||||
{
|
||||
return BreadcrumbProvider(parameter);
|
||||
}
|
||||
|
||||
return [CurrentTitle];
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ShellNavigationChangedEventArgs(
|
||||
ShellRoute route,
|
||||
object? parameter,
|
||||
IReadOnlyList<string> breadcrumbs) : EventArgs
|
||||
{
|
||||
public ShellRoute Route { get; } = route;
|
||||
|
||||
public object? Parameter { get; } = parameter;
|
||||
|
||||
public IReadOnlyList<string> Breadcrumbs { get; } = breadcrumbs;
|
||||
}
|
||||
|
||||
public interface IShellNavigationHost
|
||||
{
|
||||
bool NavigateToRoute(ShellRoute route, object? parameter = null);
|
||||
|
||||
bool NavigateToToolRoute(string toolId);
|
||||
|
||||
bool GoBackInShell();
|
||||
|
||||
void ShowNavigationError(ShellRoute? route, Exception exception);
|
||||
}
|
||||
|
||||
public interface IShellNavigationService
|
||||
{
|
||||
ShellRoute? CurrentRoute { get; }
|
||||
|
||||
IReadOnlyList<string> Breadcrumbs { get; }
|
||||
|
||||
event EventHandler<ShellNavigationChangedEventArgs>? Navigated;
|
||||
|
||||
bool Navigate(string tag, object? parameter = null);
|
||||
|
||||
bool NavigateToTool(string toolId);
|
||||
|
||||
bool GoBack();
|
||||
}
|
||||
|
||||
public sealed class ShellNavigationService : IShellNavigationService
|
||||
{
|
||||
private IShellNavigationHost? _host;
|
||||
private IReadOnlyDictionary<string, ShellRoute> _routes = new Dictionary<string, ShellRoute>(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly Stack<(ShellRoute Route, object? Parameter)> _backStack = [];
|
||||
|
||||
public ShellRoute? CurrentRoute { get; private set; }
|
||||
|
||||
public IReadOnlyList<string> Breadcrumbs { get; private set; } = [];
|
||||
|
||||
public event EventHandler<ShellNavigationChangedEventArgs>? Navigated;
|
||||
|
||||
public void RegisterHost(IShellNavigationHost host, IReadOnlyList<ShellRoute> routes)
|
||||
{
|
||||
_host = host;
|
||||
_routes = routes
|
||||
.GroupBy(route => route.Tag, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public bool Navigate(string tag, object? parameter = null)
|
||||
{
|
||||
if (!_routes.TryGetValue(tag, out var route))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return NavigateCore(route, parameter, pushCurrent: true);
|
||||
}
|
||||
|
||||
public bool NavigateToTool(string toolId)
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(toolId) && _host.NavigateToToolRoute(toolId))
|
||||
{
|
||||
var route = _routes.TryGetValue("toolbox", out var toolboxRoute)
|
||||
? toolboxRoute
|
||||
: new ShellRoute("toolbox", "工具箱", "Toolbox", "\uE90F", ShellRoutePlacement.Hidden);
|
||||
CurrentRoute = route;
|
||||
Breadcrumbs = [AppLocalizer.T("工具箱", "Toolbox"), toolId];
|
||||
Navigated?.Invoke(this, new ShellNavigationChangedEventArgs(route, toolId, Breadcrumbs));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_host.ShowNavigationError(null, exception);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public bool GoBack()
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_backStack.TryPop(out var entry))
|
||||
{
|
||||
return NavigateCore(entry.Route, entry.Parameter, pushCurrent: false);
|
||||
}
|
||||
|
||||
return _host.GoBackInShell();
|
||||
}
|
||||
|
||||
private bool NavigateCore(ShellRoute route, object? parameter, bool pushCurrent)
|
||||
{
|
||||
if (_host is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (pushCurrent && CurrentRoute is not null && !string.Equals(CurrentRoute.Tag, route.Tag, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_backStack.Push((CurrentRoute, null));
|
||||
}
|
||||
|
||||
if (!_host.NavigateToRoute(route, parameter))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
CurrentRoute = route;
|
||||
Breadcrumbs = route.GetBreadcrumbs(parameter);
|
||||
Navigated?.Invoke(this, new ShellNavigationChangedEventArgs(route, parameter, Breadcrumbs));
|
||||
return true;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_host.ShowNavigationError(route, exception);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyExternalNavigation(string tag, object? parameter = null)
|
||||
{
|
||||
if (!_routes.TryGetValue(tag, out var route))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CurrentRoute = route;
|
||||
Breadcrumbs = route.GetBreadcrumbs(parameter);
|
||||
Navigated?.Invoke(this, new ShellNavigationChangedEventArgs(route, parameter, Breadcrumbs));
|
||||
}
|
||||
}
|
||||
|
||||
internal static class ShellRouteCatalog
|
||||
{
|
||||
internal static IReadOnlyList<ShellRoute> CreateMainRoutes()
|
||||
{
|
||||
return
|
||||
[
|
||||
new("home", "首页", "Home", "\uE80F", ShellRoutePlacement.Main, CacheMode: ShellRouteCacheMode.Enabled, SearchKeywords: ["home", "dashboard", "首页"]),
|
||||
new("toolbox", "工具箱", "Toolbox", "\uE90F", ShellRoutePlacement.Main, CacheMode: ShellRouteCacheMode.Enabled, SearchKeywords: ["tools", "toolbox", "工具"]),
|
||||
new("logs", "日志", "Logs", "\uE9F9", ShellRoutePlacement.Main, SearchKeywords: ["logs", "diagnostics", "日志"]),
|
||||
new("downloads", "下载管理", "Downloads", "\uE896", ShellRoutePlacement.Main, SearchKeywords: ["downloads", "queue", "下载"]),
|
||||
new("plugins", "插件", "Plugins", "\uECAA", ShellRoutePlacement.Footer, SearchKeywords: ["plugins", "extensions", "插件"]),
|
||||
new("feedback", "反馈", "Feedback", "\uE939", ShellRoutePlacement.Footer, SearchKeywords: ["feedback", "support", "反馈"]),
|
||||
new("settings", "设置", "Settings", "\uE713", ShellRoutePlacement.Footer, SearchKeywords: ["settings", "preferences", "设置"]),
|
||||
new("about", "关于", "About", "\uE946", ShellRoutePlacement.Footer, SearchKeywords: ["about", "version", "关于"]),
|
||||
new("serviceStatus", "服务状态", "Service Status", "\uE753", ShellRoutePlacement.Hidden, SearchKeywords: ["status", "startup", "service", "服务状态"])
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Data;
|
||||
using YMhut.Box.Core.Downloads;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Plugins;
|
||||
@@ -38,9 +39,11 @@ public sealed class StartupInitializationService(
|
||||
ILogService logService,
|
||||
IPluginStateStore pluginStateStore,
|
||||
IDownloadManagerService downloadManager,
|
||||
IReferenceDataService referenceDataService,
|
||||
IExternalToolCatalogService externalToolCatalog,
|
||||
IBuiltinReferenceToolCatalog builtinToolCatalog,
|
||||
IInstallIntegrityCheckService integrityCheckService) : IStartupInitializationService
|
||||
IInstallIntegrityCheckService integrityCheckService,
|
||||
ILocalToolVendorAssetService localToolVendorAssets) : IStartupInitializationService
|
||||
{
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
@@ -157,6 +160,28 @@ public sealed class StartupInitializationService(
|
||||
context.Report(1, T("本地设置已载入。", "Local settings loaded."));
|
||||
});
|
||||
|
||||
yield return Stage("resources", T("验证随包资源", "Validating bundled resources"), 10, false, (context, token) =>
|
||||
{
|
||||
if (!getSettings().ValidateResourcesOnStartup)
|
||||
{
|
||||
context.Report(1, T("已跳过随包资源完整性检查。", "Bundled resource validation skipped."));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
context.Report(0.2, T("正在检查三国杀、本地参考数据和工具结果前端资源...", "Checking Sanguosha, local reference data, and tool result frontend assets..."));
|
||||
var validation = referenceDataService.ValidateRequiredAssets(
|
||||
"data/sanguosha/skin_config.json",
|
||||
"data/sanguosha/GeneralSkinInfoConfig.json",
|
||||
"data/sanguosha/十周年皮肤配置对应关系.xlsx");
|
||||
context.Report(
|
||||
1,
|
||||
validation.IsHealthy
|
||||
? T("三国杀随包资源已就绪。", "Sanguosha bundled resources are ready.")
|
||||
: T($"缺少 {validation.MissingPaths.Count} 个随包资源,相关工具会显示修复提示。", $"{validation.MissingPaths.Count} bundled resources are missing; affected tools will show repair guidance."),
|
||||
validation.IsHealthy ? StartupCheckSeverity.Info : StartupCheckSeverity.Warning);
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
yield return Stage("database", T("初始化主 SQLite", "Initializing main SQLite"), 10, true, async (context, token) =>
|
||||
{
|
||||
context.Report(0.25, T("正在初始化日志与主数据库...", "Initializing logs and main database..."));
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using YMhut.Box.Core.App;
|
||||
using YMhut.Box.Core.Logging;
|
||||
using YMhut.Box.Core.Platform;
|
||||
using YMhut.Box.Core.Plugins;
|
||||
using YMhut.Box.Core.Plugins.Runtime;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public sealed class TauriPluginProcessService(AppPaths paths, ILogService? logService = null) : IPluginRuntimeLauncher, IDisposable
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PluginRuntimeSession> _sessions = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, Process> _processes = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentDictionary<string, List<ShellOutputEvent>> _output = new(StringComparer.OrdinalIgnoreCase);
|
||||
private long _sequence;
|
||||
private bool _disposed;
|
||||
|
||||
public async Task<PluginRuntimeSession> LaunchAsync(PluginRuntimeLaunchRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
var sessionId = Guid.NewGuid().ToString("N");
|
||||
var startedAt = DateTimeOffset.Now;
|
||||
var host = ResolveHostExecutable();
|
||||
var session = new PluginRuntimeSession(
|
||||
sessionId,
|
||||
request.PluginId,
|
||||
request.SurfaceId,
|
||||
request.RuntimeKind,
|
||||
null,
|
||||
PluginRuntimeSessionStatus.Starting,
|
||||
startedAt);
|
||||
|
||||
_sessions[sessionId] = session;
|
||||
_output[sessionId] = [];
|
||||
|
||||
if (host is null)
|
||||
{
|
||||
session = session with
|
||||
{
|
||||
Status = PluginRuntimeSessionStatus.Failed,
|
||||
EndedAt = DateTimeOffset.Now,
|
||||
LastError = "Tauri plugin host executable was not found."
|
||||
};
|
||||
_sessions[sessionId] = session;
|
||||
await WriteLogAsync("Error", request.PluginId, "Tauri plugin host missing", session.LastError, cancellationToken).ConfigureAwait(false);
|
||||
return session;
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = host,
|
||||
WorkingDirectory = Path.GetDirectoryName(host) ?? AppContext.BaseDirectory,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = false
|
||||
};
|
||||
|
||||
startInfo.ArgumentList.Add("--session");
|
||||
startInfo.ArgumentList.Add(sessionId);
|
||||
startInfo.ArgumentList.Add("--plugin-id");
|
||||
startInfo.ArgumentList.Add(request.PluginId);
|
||||
startInfo.ArgumentList.Add("--surface-id");
|
||||
startInfo.ArgumentList.Add(request.SurfaceId);
|
||||
startInfo.ArgumentList.Add("--runtime-kind");
|
||||
startInfo.ArgumentList.Add(request.RuntimeKind.ToString());
|
||||
startInfo.ArgumentList.Add("--plugin-root");
|
||||
startInfo.ArgumentList.Add(request.PluginRoot);
|
||||
startInfo.ArgumentList.Add("--manifest");
|
||||
startInfo.ArgumentList.Add(request.ManifestPath);
|
||||
startInfo.ArgumentList.Add("--entry");
|
||||
startInfo.ArgumentList.Add(request.Entry);
|
||||
if (!string.IsNullOrWhiteSpace(request.CommandId))
|
||||
{
|
||||
startInfo.ArgumentList.Add("--command");
|
||||
startInfo.ArgumentList.Add(request.CommandId);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var process = Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start Tauri plugin host.");
|
||||
process.EnableRaisingEvents = true;
|
||||
process.OutputDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stdout, args.Data);
|
||||
process.ErrorDataReceived += (_, args) => CaptureOutput(sessionId, ShellOutputStream.Stderr, args.Data);
|
||||
process.Exited += (_, _) => CompleteSession(sessionId, process);
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
_processes[sessionId] = process;
|
||||
|
||||
session = session with
|
||||
{
|
||||
ProcessId = process.Id,
|
||||
Status = PluginRuntimeSessionStatus.Running
|
||||
};
|
||||
_sessions[sessionId] = session;
|
||||
await WriteLogAsync("Information", request.PluginId, "Tauri plugin host launched", $"session={sessionId}; pid={process.Id}", cancellationToken).ConfigureAwait(false);
|
||||
return session;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
session = session with
|
||||
{
|
||||
Status = PluginRuntimeSessionStatus.Failed,
|
||||
EndedAt = DateTimeOffset.Now,
|
||||
LastError = exception.Message
|
||||
};
|
||||
_sessions[sessionId] = session;
|
||||
await WriteLogAsync("Error", request.PluginId, "Tauri plugin host launch failed", exception.Message, cancellationToken).ConfigureAwait(false);
|
||||
return session;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task StopAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (_processes.TryRemove(sessionId, out var process))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
CaptureOutput(sessionId, ShellOutputStream.System, $"Stop failed: {exception.Message}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
if (_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
_sessions[sessionId] = session with
|
||||
{
|
||||
Status = PluginRuntimeSessionStatus.Cancelled,
|
||||
EndedAt = DateTimeOffset.Now
|
||||
};
|
||||
await WriteLogAsync("Information", session.PluginId, "Tauri plugin host stopped", $"session={sessionId}", cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<PluginRuntimeSession?> GetSessionAsync(string sessionId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
_sessions.TryGetValue(sessionId, out var session);
|
||||
return Task.FromResult(session);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<ShellOutputEvent>> ReadRecentOutputAsync(string sessionId, int take = 200, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (!_output.TryGetValue(sessionId, out var lines))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ShellOutputEvent>>([]);
|
||||
}
|
||||
|
||||
lock (lines)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<ShellOutputEvent>>(lines.TakeLast(Math.Max(0, take)).ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
foreach (var sessionId in _processes.Keys.ToArray())
|
||||
{
|
||||
try
|
||||
{
|
||||
StopAsync(sessionId).GetAwaiter().GetResult();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CompleteSession(string sessionId, Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
int? exitCode = process.HasExited ? process.ExitCode : null;
|
||||
if (_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
_sessions[sessionId] = session with
|
||||
{
|
||||
Status = exitCode == 0 ? PluginRuntimeSessionStatus.Exited : PluginRuntimeSessionStatus.Failed,
|
||||
EndedAt = DateTimeOffset.Now,
|
||||
ExitCode = exitCode
|
||||
};
|
||||
_ = WriteLogAsync(
|
||||
exitCode == 0 ? "Information" : "Error",
|
||||
session.PluginId,
|
||||
"Tauri plugin host exited",
|
||||
$"session={sessionId}; exitCode={exitCode}",
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_processes.TryRemove(sessionId, out _);
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private void CaptureOutput(string sessionId, ShellOutputStream stream, string? line)
|
||||
{
|
||||
if (line is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var output = new ShellOutputEvent(sessionId, stream, line, DateTimeOffset.Now, Interlocked.Increment(ref _sequence));
|
||||
var lines = _output.GetOrAdd(sessionId, _ => []);
|
||||
lock (lines)
|
||||
{
|
||||
lines.Add(output);
|
||||
if (lines.Count > 5000)
|
||||
{
|
||||
lines.RemoveRange(0, lines.Count - 5000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WriteLogAsync(string level, string pluginId, string message, string? detail, CancellationToken cancellationToken)
|
||||
{
|
||||
await (logService?.WriteAsync(level, $"plugin:{pluginId}:runtime", message, detail, cancellationToken) ?? Task.CompletedTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private string? ResolveHostExecutable()
|
||||
{
|
||||
var candidates = new List<string>
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, "tauri-host", "ymhut-box-plugin-tauri-host.exe"),
|
||||
Path.Combine(AppContext.BaseDirectory, "tauri-host", "YMhut.Box.PluginTauriHost.exe"),
|
||||
Path.Combine(paths.Root, "Runtime", "tauri-host", "ymhut-box-plugin-tauri-host.exe")
|
||||
};
|
||||
|
||||
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
while (directory is not null)
|
||||
{
|
||||
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.PluginTauriHost", "src-tauri", "target", "release", "ymhut-box-plugin-tauri-host.exe"));
|
||||
candidates.Add(Path.Combine(directory.FullName, "src", "YMhut.Box.PluginTauriHost", "src-tauri", "target", "debug", "ymhut-box-plugin-tauri-host.exe"));
|
||||
directory = directory.Parent;
|
||||
}
|
||||
|
||||
return candidates.FirstOrDefault(File.Exists);
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,13 @@ public sealed class ToolPageWebBridge(
|
||||
input,
|
||||
settingsService.Current.Theme,
|
||||
settingsService.Current.Language,
|
||||
new ToolResultPrivacyPolicy(ToolResultPrivacySanitizer.DefaultRedactedHostHints),
|
||||
new ToolResultPrivacyPolicy(
|
||||
ToolResultPrivacySanitizer.DefaultRedactedHostHints,
|
||||
Mode: "local-fragments-only",
|
||||
VisibleMode: "show-original",
|
||||
RedactVisibleContent: false,
|
||||
RedactRawOutput: true,
|
||||
RedactCopyOutput: true),
|
||||
new ToolPageRuntimeMetadata(
|
||||
DateTimeOffset.Now,
|
||||
spec.AutoRunOnOpen,
|
||||
|
||||
@@ -38,7 +38,13 @@ public sealed class ToolResultWebBridge(
|
||||
experience.ExperienceId,
|
||||
settingsService.Current.Theme,
|
||||
settingsService.Current.Language,
|
||||
new ToolResultPrivacyPolicy(ToolResultPrivacySanitizer.DefaultRedactedHostHints),
|
||||
new ToolResultPrivacyPolicy(
|
||||
ToolResultPrivacySanitizer.DefaultRedactedHostHints,
|
||||
Mode: "local-fragments-only",
|
||||
VisibleMode: "show-original",
|
||||
RedactVisibleContent: false,
|
||||
RedactRawOutput: true,
|
||||
RedactCopyOutput: true),
|
||||
new ToolResultRuntimeMetadata(
|
||||
durationMs,
|
||||
DateTimeOffset.Now,
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.Graphics;
|
||||
using Windows.UI;
|
||||
using Windows.UI.ViewManagement;
|
||||
using WinRT.Interop;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public sealed class WindowChromeService
|
||||
{
|
||||
private const int FallbackCaptionButtonWidth = 146;
|
||||
|
||||
public void Apply(Window window, string? theme)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appWindow = GetAppWindow(window);
|
||||
var titleBar = appWindow?.TitleBar;
|
||||
if (titleBar is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dark = ShouldUseDarkPalette(theme);
|
||||
var foreground = dark ? Colors.White : Color.FromArgb(255, 32, 32, 32);
|
||||
var hoverBackground = dark ? Color.FromArgb(38, 255, 255, 255) : Color.FromArgb(24, 0, 0, 0);
|
||||
var pressedBackground = dark ? Color.FromArgb(58, 255, 255, 255) : Color.FromArgb(40, 0, 0, 0);
|
||||
var inactiveForeground = dark ? Color.FromArgb(178, 255, 255, 255) : Color.FromArgb(178, 32, 32, 32);
|
||||
|
||||
titleBar.ButtonBackgroundColor = Colors.Transparent;
|
||||
titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
|
||||
titleBar.ButtonForegroundColor = foreground;
|
||||
titleBar.ButtonHoverForegroundColor = foreground;
|
||||
titleBar.ButtonPressedForegroundColor = foreground;
|
||||
titleBar.ButtonInactiveForegroundColor = inactiveForeground;
|
||||
titleBar.ButtonHoverBackgroundColor = hoverBackground;
|
||||
titleBar.ButtonPressedBackgroundColor = pressedBackground;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
CrashLog.Write(exception);
|
||||
}
|
||||
}
|
||||
|
||||
public double CaptionButtonReservedWidth(Window window, XamlRoot? xamlRoot)
|
||||
{
|
||||
try
|
||||
{
|
||||
var appWindow = GetAppWindow(window);
|
||||
var inset = appWindow?.TitleBar.RightInset ?? 0;
|
||||
if (inset > 0)
|
||||
{
|
||||
var scale = xamlRoot?.RasterizationScale ?? 1;
|
||||
return Math.Ceiling(inset / Math.Max(0.1, scale));
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
return FallbackCaptionButtonWidth;
|
||||
}
|
||||
|
||||
private static AppWindow? GetAppWindow(Window window)
|
||||
{
|
||||
var hwnd = WindowNative.GetWindowHandle(window);
|
||||
if (hwnd == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return AppWindow.GetFromWindowId(Win32Interop.GetWindowIdFromWindow(hwnd));
|
||||
}
|
||||
|
||||
private static bool ShouldUseDarkPalette(string? theme)
|
||||
{
|
||||
return (theme ?? string.Empty).Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"light" => false,
|
||||
"dark" => true,
|
||||
_ => IsSystemDarkTheme()
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsSystemDarkTheme()
|
||||
{
|
||||
try
|
||||
{
|
||||
var background = new UISettings().GetColorValue(UIColorType.Background);
|
||||
var luminance = (0.2126 * background.R) + (0.7152 * background.G) + (0.0722 * background.B);
|
||||
return luminance < 128;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using YMhut.Box.Core.Platform;
|
||||
|
||||
namespace YMhut.Box.WinUI.Services;
|
||||
|
||||
public sealed class WindowsPlatformCapabilities : IPlatformCapabilities
|
||||
{
|
||||
public string PlatformName => "Windows";
|
||||
|
||||
public bool SupportsDesktopProcesses => true;
|
||||
|
||||
public bool SupportsTauriPluginHost => true;
|
||||
|
||||
public bool SupportsShellRuntime => true;
|
||||
|
||||
public bool SupportsTray => true;
|
||||
|
||||
public bool SupportsAdministratorLaunch => true;
|
||||
|
||||
public bool SupportsNativeWebView2 => true;
|
||||
}
|
||||
Reference in New Issue
Block a user