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<ToolRunResult> 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<ToolRunResult> 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<ToolRunResult> 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<ToolRunResult> 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<ToolNuGetPackageReference>? 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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["CommunityToolkit.Mvvm"] = "8.4.2"
};
if (nugetPackages?.Count > ToolNuGetPackageRules.MaximumPackageCount)
throw new InvalidDataException($"NuGet 包最多允许 {ToolNuGetPackageRules.MaximumPackageCount} 个。");
var customPackageIds = new HashSet<string>(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 => $" <PackageReference Include=\"{EscapeXml(package.Key)}\" Version=\"{EscapeXml(package.Value)}\" />"));
return $$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseAppHost>true</UseAppHost>
<SelfContained>false</SelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>{{assemblyName}}</AssemblyName>
<RootNamespace>XFEToolBox.RuntimeTool</RootNamespace>
<StartupObject>XFEToolBox.RuntimeHost.RuntimeEntry</StartupObject>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<EnableDefaultPageItems>false</EnableDefaultPageItems>
<EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition>
</PropertyGroup>
<ItemGroup>
<Compile Include="RuntimeEntry.g.cs" />
<Compile Include="{{root}}\**\*.cs" Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
<Page Include="{{root}}\**\*.xaml" Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
<Resource Include="{{root}}\**\*.png;{{root}}\**\*.jpg;{{root}}\**\*.jpeg;{{root}}\**\*.gif;{{root}}\**\*.bmp;{{root}}\**\*.ico"
Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
{{packageReferenceXml}}
<Reference Include="XFEToolBox.Core"><HintPath>{{coreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEToolBox.Client.Core"><HintPath>{{clientCoreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEToolBox.WpfCore"><HintPath>{{wpfCoreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEExtension.NetCore"><HintPath>{{xfeExtensionAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="{{EscapeXml(hostAssemblyName)}}"><HintPath>{{clientAssembly}}</HintPath><Private>true</Private></Reference>
</ItemGroup>
</Project>
""";
}
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<string> standardOutputTask,
Task<string> 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<Process?> 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<ToolPackageManifest> 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<string>(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<ToolPackageManifest>(
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);
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<ToolRunResult> 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<ToolRunResult> 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<ToolRunResult> 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<ToolRunResult> 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<ToolNuGetPackageReference>? 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<string, string>(StringComparer.OrdinalIgnoreCase)
{
["CommunityToolkit.Mvvm"] = "8.4.2"
};
if (nugetPackages?.Count > ToolNuGetPackageRules.MaximumPackageCount)
throw new InvalidDataException($"NuGet 包最多允许 {ToolNuGetPackageRules.MaximumPackageCount} 个。");
var customPackageIds = new HashSet<string>(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 => $" <PackageReference Include=\"{EscapeXml(package.Key)}\" Version=\"{EscapeXml(package.Value)}\" />"));
return $$"""
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<UseWPF>true</UseWPF>
<UseAppHost>true</UseAppHost>
<SelfContained>false</SelfContained>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<AssemblyName>{{assemblyName}}</AssemblyName>
<RootNamespace>XFEToolBox.RuntimeTool</RootNamespace>
<StartupObject>XFEToolBox.RuntimeHost.RuntimeEntry</StartupObject>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
<EnableDefaultPageItems>false</EnableDefaultPageItems>
<EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition>
</PropertyGroup>
<ItemGroup>
<Compile Include="RuntimeEntry.g.cs" />
<Compile Include="{{root}}\**\*.cs" Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
<Page Include="{{root}}\**\*.xaml" Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
<Resource Include="{{root}}\**\*.png;{{root}}\**\*.jpg;{{root}}\**\*.jpeg;{{root}}\**\*.gif;{{root}}\**\*.bmp;{{root}}\**\*.ico"
Exclude="{{root}}\bin\**;{{root}}\obj\**" Link="Source\%(RecursiveDir)%(Filename)%(Extension)" />
</ItemGroup>
<ItemGroup>
{{packageReferenceXml}}
<Reference Include="XFEToolBox.Core"><HintPath>{{coreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEToolBox.Client.Core"><HintPath>{{clientCoreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEToolBox.WpfCore"><HintPath>{{wpfCoreAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="XFEExtension.NetCore"><HintPath>{{xfeExtensionAssembly}}</HintPath><Private>true</Private></Reference>
<Reference Include="{{EscapeXml(hostAssemblyName)}}"><HintPath>{{clientAssembly}}</HintPath><Private>true</Private></Reference>
</ItemGroup>
</Project>
""";
}
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<string> standardOutputTask,
Task<string> 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<Process?> 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<ToolPackageManifest> 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<string>(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<ToolPackageManifest>(
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);