using System.ComponentModel; using System.Diagnostics; using System.IO; using System.IO.Compression; using System.Security; using System.Security.Cryptography; using System.Text; using System.Text.Json; using XFEToolBox.Core.Model; using XFEToolBox.Core.Tools; using XFEToolBox.WpfCore.Controls; namespace XFEToolBox.Client.Utilities; internal static class ToolProjectRunService { private const int MaximumPackageEntryCount = 512; private const long MaximumExtractedPackageBytes = 128L * 1024 * 1024; private static readonly TimeSpan RuntimeStartupObservationWindow = TimeSpan.FromSeconds(2); private static readonly TimeSpan RuntimeRelaunchDetectionWindow = TimeSpan.FromSeconds(1.5); private static readonly JsonSerializerOptions ManifestJsonOptions = new(JsonSerializerDefaults.Web); public static async Task BuildAsync( string workspaceRoot, ToolPackageManifest manifest, CancellationToken cancellationToken = default) { return await BuildAndRunCoreAsync( workspaceRoot, manifest, $"{manifest.Name} · 生成验证", temporaryWorkspaceRoot: null, launchAfterBuild: false, runAsAdministrator: false, cancellationToken); } public static async Task BuildAndRunAsync( string workspaceRoot, ToolPackageManifest manifest, CancellationToken cancellationToken = default) { return await BuildAndRunCoreAsync( workspaceRoot, manifest, $"{manifest.Name} · 运行预览", temporaryWorkspaceRoot: null, launchAfterBuild: true, runAsAdministrator: false, cancellationToken); } public static async Task BuildPackageAndRunAsync( string packagePath, string expectedToolId, string expectedVersion, string expectedSha256, bool runAsAdministrator = false, CancellationToken cancellationToken = default) { var packageWorkspaceRoot = Path.Combine( Path.GetTempPath(), "XFEToolBox", "PackageRuns", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(packageWorkspaceRoot); try { await VerifyPackageHashAsync(packagePath, expectedSha256, cancellationToken); var manifest = await ExtractAndValidatePackageAsync( packagePath, packageWorkspaceRoot, expectedToolId, expectedVersion, cancellationToken); return await BuildAndRunCoreAsync( packageWorkspaceRoot, manifest, manifest.Name, packageWorkspaceRoot, launchAfterBuild: true, runAsAdministrator, cancellationToken); } catch (OperationCanceledException) { TryDeleteDirectory(packageWorkspaceRoot); throw; } catch (Exception exception) { TryDeleteDirectory(packageWorkspaceRoot); return new ToolRunResult(false, exception.Message, null); } } private static async Task BuildAndRunCoreAsync( string workspaceRoot, ToolPackageManifest manifest, string windowTitle, string? temporaryWorkspaceRoot, bool launchAfterBuild, bool runAsAdministrator, CancellationToken cancellationToken) { var runtimeRoot = Path.Combine(Path.GetTempPath(), "XFEToolBox", "CodeStudioRuns", Guid.NewGuid().ToString("N")); var outputRoot = Path.Combine(runtimeRoot, "output"); Directory.CreateDirectory(runtimeRoot); try { var assemblyName = $"XFEToolRuntime_{Guid.NewGuid():N}"; var hostAssemblyName = typeof(ToolProjectRunService).Assembly.GetName().Name ?? throw new InvalidOperationException("无法确定宿主程序集名称。"); var requiresElevatedProcess = launchAfterBuild && ToolRuntimeProcessStartInfoFactory.ResolveRunAsAdministrator( runAsAdministrator, manifest.RequiresAdministrator); var preparedWorkspaceRoot = Path.Combine(runtimeRoot, "source"); await PrepareWorkspaceAsync( workspaceRoot, preparedWorkspaceRoot, hostAssemblyName, cancellationToken); var projectPath = Path.Combine(runtimeRoot, "ToolRuntime.csproj"); var entryPath = Path.Combine(runtimeRoot, "RuntimeEntry.g.cs"); var toolIconPath = ResolveToolIconPath(preparedWorkspaceRoot, manifest.Icon); await File.WriteAllTextAsync( projectPath, CreateProjectFile(preparedWorkspaceRoot, assemblyName, hostAssemblyName, manifest.NuGetPackages), new UTF8Encoding(false), cancellationToken); await File.WriteAllTextAsync( entryPath, CreateRuntimeEntry(manifest, hostAssemblyName, windowTitle, toolIconPath, requiresElevatedProcess), new UTF8Encoding(false), cancellationToken); var buildInfo = new ProcessStartInfo("dotnet") { WorkingDirectory = runtimeRoot, UseShellExecute = false, CreateNoWindow = true, RedirectStandardOutput = true, RedirectStandardError = true }; buildInfo.ArgumentList.Add("build"); buildInfo.ArgumentList.Add(projectPath); buildInfo.ArgumentList.Add("--nologo"); buildInfo.ArgumentList.Add("--output"); buildInfo.ArgumentList.Add(outputRoot); buildInfo.ArgumentList.Add("--property:UseSharedCompilation=false"); buildInfo.ArgumentList.Add("--property:RestoreIgnoreFailedSources=true"); using var buildProcess = Process.Start(buildInfo) ?? throw new InvalidOperationException("无法启动 .NET SDK。请确认已安装 .NET 10 SDK。"); var standardOutputTask = buildProcess.StandardOutput.ReadToEndAsync(cancellationToken); var standardErrorTask = buildProcess.StandardError.ReadToEndAsync(cancellationToken); await buildProcess.WaitForExitAsync(cancellationToken); var buildOutput = (await standardOutputTask) + Environment.NewLine + (await standardErrorTask); if (buildProcess.ExitCode != 0) { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); return new ToolRunResult(false, FormatBuildFailure(buildOutput), null); } var runtimeAssembly = Path.Combine(outputRoot, assemblyName + ".dll"); if (!File.Exists(runtimeAssembly)) throw new FileNotFoundException("编译成功,但没有找到工具运行程序集。", runtimeAssembly); if (!launchAfterBuild) { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); return new ToolRunResult(true, "工具工程已成功生成。", null); } var runtimeExecutable = Path.Combine(outputRoot, assemblyName + ".exe"); if (!File.Exists(runtimeExecutable)) throw new FileNotFoundException("编译成功,但没有找到工具运行程序。", runtimeExecutable); var runInfo = ToolRuntimeProcessStartInfoFactory.Create( runtimeExecutable, workspaceRoot, requiresElevatedProcess); var runtimeProcess = Process.Start(runInfo) ?? throw new InvalidOperationException("工具运行进程启动失败。"); var runtimeStandardOutputTask = runInfo.RedirectStandardOutput ? runtimeProcess.StandardOutput.ReadToEndAsync() : Task.FromResult(string.Empty); var runtimeStandardErrorTask = runInfo.RedirectStandardError ? runtimeProcess.StandardError.ReadToEndAsync() : Task.FromResult(string.Empty); var runtimeProcessName = Path.GetFileNameWithoutExtension(runtimeExecutable); var exitTask = runtimeProcess.WaitForExitAsync(cancellationToken); var startupObservationTask = Task.Delay(RuntimeStartupObservationWindow, cancellationToken); if (await Task.WhenAny(exitTask, startupObservationTask) == exitTask) { await exitTask; var runtimeOutput = (await runtimeStandardOutputTask) + Environment.NewLine + (await runtimeStandardErrorTask); var exitCode = runtimeProcess.ExitCode; var replacementProcess = await FindReplacementProcessAsync(runtimeProcessName, runtimeProcess.Id); runtimeProcess.Dispose(); if (replacementProcess is not null) { _ = CleanupAfterExitAsync( replacementProcess, runtimeRoot, temporaryWorkspaceRoot, Task.FromResult(string.Empty), Task.FromResult(string.Empty), runtimeProcessName); return new ToolRunResult(true, "工具已切换到新的权限进程。", replacementProcess.Id); } TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); return new ToolRunResult(false, FormatRuntimeFailure(runtimeOutput, exitCode), null); } cancellationToken.ThrowIfCancellationRequested(); _ = CleanupAfterExitAsync( runtimeProcess, runtimeRoot, temporaryWorkspaceRoot, runtimeStandardOutputTask, runtimeStandardErrorTask, runtimeProcessName); return new ToolRunResult(true, "工具已完成编译并在独立窗口中运行。", runtimeProcess.Id); } catch (Win32Exception exception) when (exception.NativeErrorCode == 1223) { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); return new ToolRunResult(false, "已取消管理员权限请求,工具没有启动。", null); } catch (OperationCanceledException) { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); throw; } catch (Exception exception) { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); return new ToolRunResult(false, exception.Message, null); } } internal static string CreateProjectFile( string workspaceRoot, string assemblyName, string hostAssemblyName, IReadOnlyCollection? nugetPackages) { var root = EscapeXml(Path.GetFullPath(workspaceRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); var coreAssembly = EscapeXml(typeof(ToolPackageManifest).Assembly.Location); var clientCoreAssembly = EscapeXml(typeof(AppPath).Assembly.Location); var wpfCoreAssembly = EscapeXml(typeof(TabView).Assembly.Location); var clientAssemblyPath = typeof(ToolProjectRunService).Assembly.Location; var clientAssembly = EscapeXml(clientAssemblyPath); var xfeExtensionAssembly = EscapeXml(Path.Combine( Path.GetDirectoryName(clientAssemblyPath)!, "XFEExtension.NetCore.dll")); var packageReferences = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["CommunityToolkit.Mvvm"] = "8.4.2" }; if (nugetPackages?.Count > ToolNuGetPackageRules.MaximumPackageCount) throw new InvalidDataException($"NuGet 包最多允许 {ToolNuGetPackageRules.MaximumPackageCount} 个。"); var customPackageIds = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var package in nugetPackages ?? []) { if (!ToolNuGetPackageRules.IsValidPackageId(package.Id) || !ToolNuGetPackageRules.IsValidExactVersion(package.Version)) throw new InvalidDataException($"NuGet 包引用不合法:{package.Id} {package.Version}。"); if (!customPackageIds.Add(package.Id)) throw new InvalidDataException($"NuGet 包不能重复:{package.Id}。"); packageReferences[package.Id.Trim()] = package.Version.Trim(); } var packageReferenceXml = string.Join( Environment.NewLine, packageReferences .OrderBy(package => package.Key, StringComparer.OrdinalIgnoreCase) .Select(package => $" ")); return $$""" WinExe net10.0-windows true true false enable enable {{assemblyName}} XFEToolBox.RuntimeTool XFEToolBox.RuntimeHost.RuntimeEntry false false false {{packageReferenceXml}} {{coreAssembly}}true {{clientCoreAssembly}}true {{wpfCoreAssembly}}true {{xfeExtensionAssembly}}true {{clientAssembly}}true """; } private static string CreateRuntimeEntry( ToolPackageManifest manifest, string hostAssemblyName, string windowTitle, string? toolIconPath, bool requiresElevatedProcess) { var window = NormalizeWindowSettings(manifest.Window); var toolId = JsonSerializer.Serialize(manifest.Id.Trim()); var viewClass = JsonSerializer.Serialize(manifest.Entry.ViewClass); var title = JsonSerializer.Serialize(windowTitle); var displayTitle = JsonSerializer.Serialize(manifest.Name.Trim()); var subtitle = JsonSerializer.Serialize( string.IsNullOrWhiteSpace(manifest.Subtitle) ? manifest.Description.Trim() : manifest.Subtitle.Trim()); var iconPath = JsonSerializer.Serialize(toolIconPath); var themeResourceUri = JsonSerializer.Serialize( "pack://application:,,,/XFEToolBox.WpfCore;component/Resources/Style/ToolThemeResources.xaml"); var mainStyleResourceUri = JsonSerializer.Serialize( $"pack://application:,,,/{hostAssemblyName};component/Resources/Style/MainStyle.xaml"); var defaultIconResourceUri = JsonSerializer.Serialize( $"pack://application:,,,/{hostAssemblyName};component/Resources/Image/default_tool_icon.png"); var width = JsonSerializer.Serialize(window.Width); var height = JsonSerializer.Serialize(window.Height); var minWidth = JsonSerializer.Serialize(window.MinWidth); var minHeight = JsonSerializer.Serialize(window.MinHeight); var allowResize = JsonSerializer.Serialize(window.AllowResize); var allowMaximize = JsonSerializer.Serialize(window.AllowMaximize); var showMinimizeButton = JsonSerializer.Serialize(window.ShowMinimizeButton); var showCloseButton = JsonSerializer.Serialize(window.ShowCloseButton); var requiresAdministrator = JsonSerializer.Serialize(requiresElevatedProcess); return $$""" using System.IO; using System.Reflection; using System.Security.Principal; using System.Windows; using System.Windows.Controls; using System.Windows.Media; using System.Windows.Media.Imaging; using System.Windows.Threading; using XFEToolBox.Client.Utilities; using XFEToolBox.WpfCore.Controls; using XFEToolBox.WpfCore.Windowing; using XFEToolBox.Core.Tools; namespace XFEToolBox.RuntimeHost; public static class RuntimeEntry { [STAThread] public static void Main() { var application = new Application { ShutdownMode = ShutdownMode.OnMainWindowClose }; application.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri({{themeResourceUri}}, UriKind.Absolute) }); application.Resources.MergedDictionaries.Add(new ResourceDictionary { Source = new Uri({{mainStyleResourceUri}}, UriKind.Absolute) }); try { if ({{requiresAdministrator}} && !IsCurrentProcessAdministrator()) throw new UnauthorizedAccessException("工具要求管理员权限,但运行进程没有获得管理员令牌。"); ToolDataStore.Initialize({{toolId}}); var viewType = Assembly.GetExecutingAssembly().GetType({{viewClass}}, throwOnError: true)!; var instance = Activator.CreateInstance(viewType) ?? throw new InvalidOperationException("无法创建入口视图实例。"); Window window; object content; if (instance is Window toolWindow) { window = toolWindow; content = toolWindow.Content ?? new Grid(); toolWindow.Content = null; } else { if (instance is not UIElement toolContent) throw new InvalidOperationException("入口类型必须继承 UIElement 或 Window。"); window = new Window(); content = toolContent; } ConfigureWindow(application, window, content); application.Run(window); } catch (Exception exception) { MessageBox.Show(exception.ToString(), "工具运行失败", MessageBoxButton.OK, MessageBoxImage.Error); } } private static bool IsCurrentProcessAdministrator() { using var identity = WindowsIdentity.GetCurrent(); return new WindowsPrincipal(identity).IsInRole(WindowsBuiltInRole.Administrator); } private static void ConfigureWindow(Application application, Window window, object content) { var savedPlacement = ToolDataStore.ReadWindowPlacement(); window.Title = {{title}}; window.MinWidth = {{minWidth}}; window.MinHeight = {{minHeight}}; window.Width = NormalizePlacementDimension(savedPlacement?.Width, {{width}}, window.MinWidth); window.Height = NormalizePlacementDimension(savedPlacement?.Height, {{height}}, window.MinHeight); window.SizeToContent = SizeToContent.Manual; window.WindowState = WindowState.Normal; if (savedPlacement is not null && IsPlacementVisible(savedPlacement)) { window.WindowStartupLocation = WindowStartupLocation.Manual; window.Left = savedPlacement.Left; window.Top = savedPlacement.Top; } else { window.WindowStartupLocation = WindowStartupLocation.CenterScreen; } window.WindowStyle = WindowStyle.None; window.AllowsTransparency = true; window.ResizeMode = {{allowResize}} ? ResizeMode.CanResize : ResizeMode.NoResize; WindowWorkAreaHelper.Attach(window); window.Background = Brushes.Transparent; window.Foreground = (Brush)application.FindResource("ToolTextPrimaryBrush"); var windowIcon = LoadWindowIcon(); window.Icon = windowIcon; var captionBar = new WindowCaptionBar { Height = 25, VerticalAlignment = VerticalAlignment.Top, AllowMaximize = {{allowResize}} && {{allowMaximize}}, DragHandleVisibility = Visibility.Visible, MinimizeButtonVisibility = {{showMinimizeButton}} ? Visibility.Visible : Visibility.Collapsed, CloseButtonVisibility = {{showCloseButton}} ? Visibility.Visible : Visibility.Collapsed }; Grid.SetRow(captionBar, 0); var titleIcon = new Image { Source = windowIcon, Width = 28, Height = 28, Stretch = Stretch.Uniform, HorizontalAlignment = HorizontalAlignment.Center, VerticalAlignment = VerticalAlignment.Center }; var titleIconSurface = new Border { Width = 42, Height = 42, CornerRadius = new CornerRadius(12), Background = Brushes.White, Child = titleIcon }; var titleText = new TextBlock { Text = {{displayTitle}}, Foreground = Brushes.White, FontSize = 16, FontWeight = FontWeights.SemiBold, TextTrimming = TextTrimming.CharacterEllipsis }; var subtitleText = new TextBlock { Text = {{subtitle}}, Foreground = Brushes.White, FontSize = 10.5, Opacity = 0.82, Margin = new Thickness(0, 2, 0, 0), TextTrimming = TextTrimming.CharacterEllipsis }; var titleTextPanel = new StackPanel { Margin = new Thickness(11, 0, 0, 0), VerticalAlignment = VerticalAlignment.Center }; titleTextPanel.Children.Add(titleText); titleTextPanel.Children.Add(subtitleText); var titleIdentity = new Grid { Margin = new Thickness(18, 9, 100, 9), HorizontalAlignment = HorizontalAlignment.Stretch, VerticalAlignment = VerticalAlignment.Center, IsHitTestVisible = false }; titleIdentity.ColumnDefinitions.Add(new ColumnDefinition { Width = GridLength.Auto }); titleIdentity.ColumnDefinitions.Add(new ColumnDefinition { Width = new GridLength(1, GridUnitType.Star) }); Grid.SetColumn(titleIconSurface, 0); Grid.SetColumn(titleTextPanel, 1); titleIdentity.Children.Add(titleIconSurface); titleIdentity.Children.Add(titleTextPanel); Grid.SetRow(titleIdentity, 0); var contentPresenter = new ContentControl { Content = content, HorizontalContentAlignment = HorizontalAlignment.Stretch, VerticalContentAlignment = VerticalAlignment.Stretch }; var contentSurface = new Border { Background = (Brush)application.FindResource("ToolSurfaceBrush"), CornerRadius = new CornerRadius(16, 16, 17, 17), Child = contentPresenter }; Grid.SetRow(contentSurface, 1); var layout = new Grid(); layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(64) }); layout.RowDefinitions.Add(new RowDefinition { Height = new GridLength(1, GridUnitType.Star) }); layout.Children.Add(captionBar); layout.Children.Add(titleIdentity); layout.Children.Add(contentSurface); var windowSurface = new RoundedClipBorder { Margin = new Thickness(5), CornerRadius = new CornerRadius(19), Background = (Brush)application.FindResource("MainColor"), Child = layout }; var windowRoot = new Grid(); windowRoot.Children.Add(windowSurface); if ({{allowResize}}) { windowRoot.Children.Add(new WindowResizeGrip { Margin = new Thickness(0, 0, 5, 5) }); } window.Content = windowRoot; AttachWindowPlacementPersistence(window, savedPlacement); } private static double NormalizePlacementDimension(double? value, double fallback, double minimum) { if (value is not { } candidate || !double.IsFinite(candidate) || candidate <= 0) return fallback; return Math.Max(minimum, candidate); } private static bool IsPlacementVisible(ToolWindowPlacement placement) { if (!double.IsFinite(placement.Left) || !double.IsFinite(placement.Top) || !double.IsFinite(placement.Width) || !double.IsFinite(placement.Height) || placement.Width <= 0 || placement.Height <= 0) return false; const double visibleEdge = 72; var virtualLeft = SystemParameters.VirtualScreenLeft; var virtualTop = SystemParameters.VirtualScreenTop; var virtualRight = virtualLeft + SystemParameters.VirtualScreenWidth; var virtualBottom = virtualTop + SystemParameters.VirtualScreenHeight; return placement.Left + placement.Width >= virtualLeft + visibleEdge && placement.Top + visibleEdge <= virtualBottom && placement.Left + visibleEdge <= virtualRight && placement.Top + placement.Height >= virtualTop + visibleEdge; } private static void AttachWindowPlacementPersistence(Window window, ToolWindowPlacement? savedPlacement) { var lastVisibleState = string.Equals( savedPlacement?.LastVisibleState, nameof(WindowState.Maximized), StringComparison.Ordinal) ? WindowState.Maximized : WindowState.Normal; var saveTimer = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(350) }; void SavePlacement() { saveTimer.Stop(); var state = window.WindowState; if (state != WindowState.Minimized) lastVisibleState = state == WindowState.Maximized ? WindowState.Maximized : WindowState.Normal; var bounds = state == WindowState.Normal ? new Rect(window.Left, window.Top, window.ActualWidth, window.ActualHeight) : window.RestoreBounds; if (!double.IsFinite(bounds.Left) || !double.IsFinite(bounds.Top) || !double.IsFinite(bounds.Width) || !double.IsFinite(bounds.Height) || bounds.Width <= 0 || bounds.Height <= 0) return; ToolDataStore.WriteWindowPlacement(new ToolWindowPlacement( bounds.Left, bounds.Top, bounds.Width, bounds.Height, state.ToString(), lastVisibleState.ToString(), state == WindowState.Minimized, DateTimeOffset.UtcNow)); } void QueueSave() { saveTimer.Stop(); saveTimer.Start(); } saveTimer.Tick += (_, _) => SavePlacement(); window.SizeChanged += (_, _) => QueueSave(); window.LocationChanged += (_, _) => QueueSave(); window.StateChanged += (_, _) => QueueSave(); window.Closing += (_, _) => SavePlacement(); window.Loaded += (_, _) => { // 记录最小化状态,但启动时恢复到最后一个可见状态,避免用户误以为工具没有打开。 var restoreMaximized = {{allowResize}} && {{allowMaximize}} && (string.Equals(savedPlacement?.State, nameof(WindowState.Maximized), StringComparison.Ordinal) || string.Equals(savedPlacement?.State, nameof(WindowState.Minimized), StringComparison.Ordinal) && string.Equals(savedPlacement?.LastVisibleState, nameof(WindowState.Maximized), StringComparison.Ordinal)); if (restoreMaximized) window.WindowState = WindowState.Maximized; }; } private static ImageSource LoadWindowIcon() { try { string? path = {{iconPath}}; if (!string.IsNullOrWhiteSpace(path) && File.Exists(path)) { var image = new BitmapImage(); image.BeginInit(); image.CacheOption = BitmapCacheOption.OnLoad; image.UriSource = new Uri(path, UriKind.Absolute); image.EndInit(); image.Freeze(); return image; } } catch { // 包内图标不可用时回退到宿主的默认工具图标。 } return new BitmapImage(new Uri({{defaultIconResourceUri}}, UriKind.Absolute)); } } """; } private static NormalizedWindowSettings NormalizeWindowSettings(ToolWindowManifest? settings) { settings ??= new ToolWindowManifest(); var minWidth = NormalizeDimension(settings.MinWidth, ToolWindowManifest.DefaultMinWidth, 320, 3840); var minHeight = NormalizeDimension(settings.MinHeight, ToolWindowManifest.DefaultMinHeight, 220, 2160); var width = Math.Max(minWidth, NormalizeDimension(settings.Width, ToolWindowManifest.DefaultWidth, 320, 3840)); var height = Math.Max(minHeight, NormalizeDimension(settings.Height, ToolWindowManifest.DefaultHeight, 220, 2160)); return new NormalizedWindowSettings( width, height, minWidth, minHeight, settings.AllowResize, settings.AllowResize && settings.AllowMaximize, settings.ShowMinimizeButton, settings.ShowCloseButton); } private static double NormalizeDimension(double value, double fallback, double minimum, double maximum) => Math.Clamp(double.IsFinite(value) && value > 0 ? value : fallback, minimum, maximum); private static string? ResolveToolIconPath(string workspaceRoot, string? manifestIcon) { if (string.IsNullOrWhiteSpace(manifestIcon) || Path.IsPathRooted(manifestIcon)) return null; var root = Path.GetFullPath(workspaceRoot).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + Path.DirectorySeparatorChar; var path = Path.GetFullPath(Path.Combine(root, manifestIcon.Replace('/', Path.DirectorySeparatorChar))); if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path)) return null; return path; } private sealed record NormalizedWindowSettings( double Width, double Height, double MinWidth, double MinHeight, bool AllowResize, bool AllowMaximize, bool ShowMinimizeButton, bool ShowCloseButton); private static async Task CleanupAfterExitAsync( Process process, string runtimeRoot, string? temporaryWorkspaceRoot, Task standardOutputTask, Task standardErrorTask, string runtimeProcessName) { var originalProcessId = process.Id; try { await process.WaitForExitAsync(); await Task.WhenAll(standardOutputTask, standardErrorTask); process.Dispose(); var replacementProcess = await FindReplacementProcessAsync(runtimeProcessName, originalProcessId); if (replacementProcess is not null) { using (replacementProcess) await replacementProcess.WaitForExitAsync(); } } catch { // 运行进程已由系统结束时,无需继续等待。 } finally { TryDeleteDirectory(runtimeRoot); if (temporaryWorkspaceRoot is not null) TryDeleteDirectory(temporaryWorkspaceRoot); } } private static async Task FindReplacementProcessAsync(string processName, int excludedProcessId) { var deadline = DateTime.UtcNow + RuntimeRelaunchDetectionWindow; do { foreach (var candidate in Process.GetProcessesByName(processName)) { if (candidate.Id == excludedProcessId) { candidate.Dispose(); continue; } try { if (!candidate.HasExited) return candidate; } catch { // 进程可能在枚举后立即退出,继续等待真正的替代进程。 } candidate.Dispose(); } await Task.Delay(100); } while (DateTime.UtcNow < deadline); return null; } private static async Task VerifyPackageHashAsync( string packagePath, string expectedSha256, CancellationToken cancellationToken) { if (!File.Exists(packagePath)) throw new FileNotFoundException("没有找到已缓存的工具包。", packagePath); if (string.IsNullOrWhiteSpace(expectedSha256)) throw new InvalidDataException("服务器没有提供工具包 SHA-256。 "); await using var stream = new FileStream( packagePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); var actualSha256 = Convert.ToHexString(await SHA256.HashDataAsync(stream, cancellationToken)).ToLowerInvariant(); if (!actualSha256.Equals(expectedSha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("工具包校验失败,缓存内容可能已损坏。 "); } private static async Task PrepareWorkspaceAsync( string sourceRoot, string destinationRoot, string hostAssemblyName, CancellationToken cancellationToken) { var normalizedSourceRoot = Path.GetFullPath(sourceRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; foreach (var sourcePath in Directory.EnumerateFiles( normalizedSourceRoot, "*", SearchOption.AllDirectories)) { cancellationToken.ThrowIfCancellationRequested(); var relativePath = Path.GetRelativePath(normalizedSourceRoot, sourcePath); var segments = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); if (segments.Any(segment => segment.Equals("bin", StringComparison.OrdinalIgnoreCase) || segment.Equals("obj", StringComparison.OrdinalIgnoreCase) || segment.Equals(".git", StringComparison.OrdinalIgnoreCase))) continue; var extension = Path.GetExtension(sourcePath); if (!extension.Equals(".cs", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".xaml", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".png", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".jpg", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".jpeg", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".gif", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".bmp", StringComparison.OrdinalIgnoreCase) && !extension.Equals(".ico", StringComparison.OrdinalIgnoreCase)) continue; var destinationPath = Path.Combine(destinationRoot, relativePath); Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); if (extension.Equals(".xaml", StringComparison.OrdinalIgnoreCase)) { var xaml = await File.ReadAllTextAsync(sourcePath, cancellationToken); xaml = NormalizeLegacyHostAssemblyReferences(xaml, hostAssemblyName); await File.WriteAllTextAsync( destinationPath, xaml, new UTF8Encoding(false), cancellationToken); continue; } await using var source = new FileStream( sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); await using var destination = new FileStream( destinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); await source.CopyToAsync(destination, cancellationToken); } } private static string NormalizeLegacyHostAssemblyReferences(string xaml, string hostAssemblyName) { const string wpfCoreAssemblyName = "XFEToolBox.WpfCore"; static string ReplaceNamespaceAssembly( string source, string oldNamespace, string oldAssembly, string newNamespace, string newAssembly) { foreach (var quote in new[] { '\"', '\'' }) { source = source.Replace( $"clr-namespace:{oldNamespace};assembly={oldAssembly}{quote}", $"clr-namespace:{newNamespace};assembly={newAssembly}{quote}", StringComparison.Ordinal); } return source; } var normalized = ReplaceNamespaceAssembly( xaml, "XFEToolBox.Client.Views.Controls", "XFEToolBox.Client", "XFEToolBox.WpfCore.Controls", wpfCoreAssemblyName); normalized = ReplaceNamespaceAssembly( normalized, "XFEToolBox.Client.Views.Controls", hostAssemblyName, "XFEToolBox.WpfCore.Controls", wpfCoreAssemblyName); normalized = ReplaceNamespaceAssembly( normalized, "XFEToolBox.WpfCore.Controls", hostAssemblyName, "XFEToolBox.WpfCore.Controls", wpfCoreAssemblyName); normalized = ReplaceNamespaceAssembly( normalized, "XFEToolBox.Client.Views.Behavior", "XFEToolBox.Client", "XFEToolBox.WpfCore.Behaviors", wpfCoreAssemblyName); normalized = ReplaceNamespaceAssembly( normalized, "XFEToolBox.Client.Views.Behavior", hostAssemblyName, "XFEToolBox.WpfCore.Behaviors", wpfCoreAssemblyName); normalized = ReplaceNamespaceAssembly( normalized, "XFEToolBox.WpfCore.Behaviors", hostAssemblyName, "XFEToolBox.WpfCore.Behaviors", wpfCoreAssemblyName); normalized = normalized .Replace( "/XFEToolBox.Client;component/Resources/Style/ToolThemeResources.xaml", $"/{wpfCoreAssemblyName};component/Resources/Style/ToolThemeResources.xaml", StringComparison.Ordinal) .Replace( $"/{hostAssemblyName};component/Resources/Style/ToolThemeResources.xaml", $"/{wpfCoreAssemblyName};component/Resources/Style/ToolThemeResources.xaml", StringComparison.Ordinal); if (hostAssemblyName.Equals("XFEToolBox.Client", StringComparison.Ordinal)) return normalized; return normalized .Replace( ";assembly=XFEToolBox.Client\"", $";assembly={hostAssemblyName}\"", StringComparison.Ordinal) .Replace( ";assembly=XFEToolBox.Client'", $";assembly={hostAssemblyName}'", StringComparison.Ordinal) .Replace( "/XFEToolBox.Client;component/", $"/{hostAssemblyName};component/", StringComparison.Ordinal); } private static async Task ExtractAndValidatePackageAsync( string packagePath, string destinationRoot, string expectedToolId, string expectedVersion, CancellationToken cancellationToken) { var normalizedRoot = Path.GetFullPath(destinationRoot).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var extractedPaths = new HashSet(StringComparer.OrdinalIgnoreCase); long extractedBytes = 0; using var archive = ZipFile.OpenRead(packagePath); if (archive.Entries.Count > MaximumPackageEntryCount) throw new InvalidDataException($"工具包文件数量超过限制({MaximumPackageEntryCount})。"); foreach (var entry in archive.Entries) { cancellationToken.ThrowIfCancellationRequested(); var relativePath = entry.FullName.Replace('\\', '/').TrimStart('/'); var segments = relativePath.Split('/', StringSplitOptions.RemoveEmptyEntries); if (segments.Length == 0) continue; if (Path.IsPathRooted(entry.FullName) || segments.Any(segment => segment is "." or ".." || segment.Contains(':'))) throw new InvalidDataException($"工具包包含不安全路径:{entry.FullName}"); var destinationPath = Path.GetFullPath(Path.Combine( normalizedRoot, Path.Combine(segments))); if (!destinationPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException($"工具包文件超出解包目录:{entry.FullName}"); if (!extractedPaths.Add(destinationPath)) throw new InvalidDataException($"工具包包含重复文件:{entry.FullName}"); if (string.IsNullOrEmpty(entry.Name)) { Directory.CreateDirectory(destinationPath); continue; } if (entry.Length < 0 || entry.Length > MaximumExtractedPackageBytes - extractedBytes) throw new InvalidDataException("工具包解压后的总大小超过 128 MB 限制。 "); extractedBytes += entry.Length; Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); await using var source = entry.Open(); await using var output = new FileStream( destinationPath, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.Asynchronous | FileOptions.SequentialScan); await source.CopyToAsync(output, cancellationToken); } var manifestPath = Path.Combine(destinationRoot, "manifest.json"); if (!File.Exists(manifestPath)) throw new InvalidDataException("工具包缺少 manifest.json。 "); var manifest = JsonSerializer.Deserialize( await File.ReadAllTextAsync(manifestPath, cancellationToken), ManifestJsonOptions) ?? throw new InvalidDataException("manifest.json 内容为空。 "); if (manifest.PackageFormatVersion != ToolPackageManifest.CurrentPackageFormatVersion) throw new InvalidDataException($"不支持工具包格式版本 {manifest.PackageFormatVersion}。 "); if (!manifest.Id.Equals(expectedToolId, StringComparison.OrdinalIgnoreCase) || !manifest.Version.Equals(expectedVersion, StringComparison.OrdinalIgnoreCase)) throw new InvalidDataException("工具包清单与服务器目录信息不一致。 "); if (manifest.Entry is null) throw new InvalidDataException("工具包清单缺少入口配置。 "); EnsurePackageFileExists(normalizedRoot, manifest.Entry.ViewXaml, "入口 XAML"); EnsurePackageFileExists(normalizedRoot, manifest.Entry.ViewCodeBehind, "入口代码后置"); if (!string.IsNullOrWhiteSpace(manifest.Entry.ViewModel)) EnsurePackageFileExists(normalizedRoot, manifest.Entry.ViewModel, "ViewModel"); if (string.IsNullOrWhiteSpace(manifest.Entry.ViewClass)) throw new InvalidDataException("工具包清单缺少入口视图类。 "); return manifest; } private static void EnsurePackageFileExists(string normalizedRoot, string relativePath, string description) { if (string.IsNullOrWhiteSpace(relativePath) || Path.IsPathRooted(relativePath)) throw new InvalidDataException($"工具包清单中的{description}路径无效。 "); var fullPath = Path.GetFullPath(Path.Combine( normalizedRoot, relativePath.Replace('/', Path.DirectorySeparatorChar))); if (!fullPath.StartsWith(normalizedRoot, StringComparison.OrdinalIgnoreCase) || !File.Exists(fullPath)) throw new InvalidDataException($"工具包缺少{description}文件:{relativePath}"); } private static string FormatBuildFailure(string output) { var importantLines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .Where(line => line.Contains("error", StringComparison.OrdinalIgnoreCase) || line.Contains("错误", StringComparison.OrdinalIgnoreCase)) .Take(6) .ToArray(); if (importantLines.Length == 0) importantLines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).TakeLast(6).ToArray(); return "工具编译未通过:\n" + string.Join(Environment.NewLine, importantLines); } private static string FormatRuntimeFailure(string output, int exitCode) { var importantLines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries) .Where(line => line.Contains("Exception", StringComparison.OrdinalIgnoreCase) || line.Contains("error", StringComparison.OrdinalIgnoreCase) || line.Contains("错误", StringComparison.OrdinalIgnoreCase) || line.Contains("找不到", StringComparison.OrdinalIgnoreCase)) .Take(8) .ToArray(); if (importantLines.Length == 0) importantLines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries).TakeLast(8).ToArray(); var details = importantLines.Length == 0 ? "工具进程在窗口显示前意外退出。" : string.Join(Environment.NewLine, importantLines); return $"工具启动失败(退出代码 {exitCode}):\n{details}"; } private static string EscapeXml(string value) => SecurityElement.Escape(value) ?? value; private static void TryDeleteDirectory(string path) { try { if (Directory.Exists(path)) Directory.Delete(path, recursive: true); } catch { // 运行进程退出瞬间仍可能占用文件,系统临时目录会在后续清理。 } } } internal sealed record ToolRunResult(bool Success, string Message, int? ProcessId);