zcbot/windows-node/Zcbot.WindowsNode/SoftwareRuntimeManager.cs

531 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using Microsoft.Win32;
using System.ComponentModel;
using System.Diagnostics;
using System.Security;
using System.Text;
namespace Zcbot.WindowsNode;
internal enum SoftwarePathKind
{
None,
Executable,
Directory,
}
internal sealed record SoftwareDefinition(
string Id,
string DisplayName,
string RuntimeId,
SoftwarePathKind PathKind,
string? EnvironmentVariable,
string? RequiredRelativePath,
string RequirementsRelativePath);
internal sealed record SoftwareLocation(
bool Available,
string? Path,
string Source,
string Detail);
internal sealed record RuntimeInstallResult(string RuntimePath, string Detail);
internal static class SoftwareRuntimeManager
{
private const string SettingsKey = @"Software\Zcbot\WindowsNode\Software";
private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/";
internal static IReadOnlyList<SoftwareDefinition> Definitions { get; } =
[
new(
"origin",
"Origin",
"origin",
SoftwarePathKind.None,
null,
null,
@"adapters\origin.plot@v2\requirements.txt"),
new(
"ansys",
"ANSYS Mechanical 2024 R2",
"ansys",
SoftwarePathKind.Directory,
"AWP_ROOT242",
@"aisol\bin\winx64\AnsysWBU.exe",
@"adapters\ansys.mechanical.static_structural@v2\requirements.txt"),
new(
"blender",
"Blender",
"blender",
SoftwarePathKind.Executable,
"ZCBOT_BLENDER_EXE",
"blender.exe",
@"adapters\blender.scene.author@v1\requirements.txt"),
];
internal static SoftwareDefinition ByRuntimeId(string runtimeId) =>
Definitions.Single(item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
internal static SoftwareLocation ResolveLocation(SoftwareDefinition definition)
{
var configured = ReadConfiguredPath(definition.Id);
if (!string.IsNullOrWhiteSpace(configured))
{
return Location(definition, configured, "界面配置", strict: true);
}
if (definition.EnvironmentVariable is not null)
{
var environment = Environment.GetEnvironmentVariable(definition.EnvironmentVariable);
if (!string.IsNullOrWhiteSpace(environment))
{
return Location(
definition,
environment,
$"环境变量 {definition.EnvironmentVariable}",
strict: true);
}
}
return definition.Id switch
{
"origin" => DetectOrigin(),
"ansys" => DetectAnsys(definition),
"blender" => DetectBlender(definition),
_ => new SoftwareLocation(false, null, "自动检测", "没有可用的自动检测规则。"),
};
}
internal static void SaveConfiguredPath(SoftwareDefinition definition, string path)
{
var location = Location(definition, path, "界面配置", strict: true);
if (!location.Available || location.Path is null)
{
throw new InvalidDataException(location.Detail);
}
using var key = Registry.CurrentUser.CreateSubKey(SettingsKey, writable: true)
?? throw new InvalidOperationException("无法保存专业软件设置。");
key.SetValue(definition.Id, location.Path, RegistryValueKind.String);
}
internal static void ClearConfiguredPath(SoftwareDefinition definition)
{
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey, writable: true);
key?.DeleteValue(definition.Id, throwOnMissingValue: false);
}
internal static void ApplyProcessEnvironment(ProcessStartInfo startInfo, string? runtimeId)
{
if (runtimeId is null)
{
return;
}
var definition = Definitions.SingleOrDefault(
item => item.RuntimeId.Equals(runtimeId, StringComparison.Ordinal));
if (definition?.EnvironmentVariable is null)
{
return;
}
var location = ResolveLocation(definition);
if (location.Available && location.Path is not null)
{
startInfo.Environment[definition.EnvironmentVariable] = location.Path;
}
}
internal static async Task<RuntimeInstallResult> InstallRuntimeAsync(
SoftwareDefinition definition,
IProgress<string> progress,
CancellationToken cancellationToken)
{
var location = ResolveLocation(definition);
if (!location.Available)
{
throw new InvalidOperationException(
$"尚未检测到 {definition.DisplayName}{location.Detail}");
}
var requirements = ResolveBundledFile(definition.RequirementsRelativePath);
var root = NodePaths.ForCurrentMachine().RootDirectory;
var runtimes = Path.Combine(root, "runtimes");
Directory.CreateDirectory(runtimes);
NodeConfigStore.RestrictDirectory(root);
var runtime = Path.Combine(runtimes, definition.RuntimeId);
var staging = Path.Combine(
runtimes,
$".{definition.RuntimeId}.installing-{Guid.NewGuid():N}");
var backup = Path.Combine(
runtimes,
$".{definition.RuntimeId}.rollback-{Guid.NewGuid():N}");
var stagingPython = Path.Combine(staging, "Scripts", "python.exe");
var movedExisting = false;
try
{
progress.Report("正在查找 Python 3.12…");
var bootstrap = await FindPython312Async(cancellationToken);
progress.Report("正在创建隔离运行环境…");
await RunAsync(
bootstrap.FileName,
bootstrap.PrefixArguments.Concat(["-m", "venv", staging]),
AppContext.BaseDirectory,
progress,
cancellationToken);
if (!File.Exists(stagingPython))
{
throw new InvalidDataException("Python venv 未生成 Scripts\\python.exe。");
}
await VerifyPython312Async(stagingPython, cancellationToken);
progress.Report("正在安装 adapter 依赖…");
var indexUrl = Environment.GetEnvironmentVariable("ZCBOT_PIP_INDEX_URL");
if (string.IsNullOrWhiteSpace(indexUrl))
{
indexUrl = DefaultIndexUrl;
}
await RunAsync(
stagingPython,
["-m", "pip", "install", "--index-url", indexUrl, "--requirement", requirements],
AppContext.BaseDirectory,
progress,
cancellationToken);
if (Directory.Exists(runtime))
{
Directory.Move(runtime, backup);
movedExisting = true;
}
Directory.Move(staging, runtime);
if (movedExisting)
{
Directory.Delete(backup, recursive: true);
}
NodeConfigStore.RestrictDirectory(runtime);
return new RuntimeInstallResult(
Path.Combine(runtime, "Scripts", "python.exe"),
$"{definition.DisplayName} 运行环境安装完成。");
}
catch
{
if (movedExisting && !Directory.Exists(runtime) && Directory.Exists(backup))
{
Directory.Move(backup, runtime);
}
throw;
}
finally
{
DeleteDirectoryIfPresent(staging);
DeleteDirectoryIfPresent(backup);
}
}
private static SoftwareLocation DetectOrigin()
{
try
{
using var clsid = Registry.ClassesRoot.OpenSubKey(@"Origin.ApplicationSI\CLSID");
var clsidValue = clsid?.GetValue(null) as string;
if (string.IsNullOrWhiteSpace(clsidValue))
{
return new SoftwareLocation(false, null, "COM 注册", "未找到 Origin COM 注册信息。");
}
using var server = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidValue}\LocalServer32");
var command = server?.GetValue(null) as string;
var executable = ExecutableFromCommand(command);
return executable is null
? new SoftwareLocation(false, null, "COM 注册", "Origin COM 服务路径无效。")
: new SoftwareLocation(true, executable, "COM 注册", $"已检测到 {executable}");
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
return new SoftwareLocation(false, null, "COM 注册", exception.Message);
}
}
private static SoftwareLocation DetectAnsys(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
foreach (var location in UninstallLocations("ANSYS"))
{
candidates.Add((location, "注册表"));
candidates.Add((Path.Combine(location, "v242"), "注册表"));
}
candidates.Add((@"C:\Program Files\ANSYS Inc\v242", "标准目录"));
return FirstLocation(definition, candidates);
}
private static SoftwareLocation DetectBlender(SoftwareDefinition definition)
{
var candidates = new List<(string Path, string Source)>();
var path = FindOnPath("blender.exe");
if (path is not null)
{
candidates.Add((path, "PATH"));
}
candidates.AddRange(UninstallLocations("Blender")
.Select(location => (Path.Combine(location, "blender.exe"), "注册表")));
foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" })
{
var root = Environment.GetEnvironmentVariable(variable);
if (string.IsNullOrWhiteSpace(root)) continue;
var blenderRoot = Path.Combine(root, "Blender Foundation");
if (!Directory.Exists(blenderRoot)) continue;
candidates.AddRange(Directory.EnumerateDirectories(blenderRoot, "Blender *")
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
.Select(item => (Path.Combine(item, "blender.exe"), "标准目录")));
}
return FirstLocation(definition, candidates);
}
private static SoftwareLocation FirstLocation(
SoftwareDefinition definition,
IEnumerable<(string Path, string Source)> candidates)
{
foreach (var candidate in candidates)
{
var location = Location(definition, candidate.Path, candidate.Source, strict: false);
if (location.Available)
{
return location;
}
}
return new SoftwareLocation(false, null, "自动检测", "没有找到有效的安装位置。");
}
private static SoftwareLocation Location(
SoftwareDefinition definition,
string value,
string source,
bool strict)
{
try
{
var path = Path.GetFullPath(Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')));
var required = definition.PathKind switch
{
SoftwarePathKind.Executable => path,
SoftwarePathKind.Directory => Path.Combine(path, definition.RequiredRelativePath!),
_ => path,
};
var valid = File.Exists(required)
&& (definition.PathKind != SoftwarePathKind.Executable
|| Path.GetFileName(required).Equals(
definition.RequiredRelativePath,
StringComparison.OrdinalIgnoreCase));
return valid
? new SoftwareLocation(true, path, source, $"已检测到 {required}")
: new SoftwareLocation(
false,
strict ? path : null,
source,
$"指定位置缺少 {definition.RequiredRelativePath}。");
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException or SecurityException)
{
return new SoftwareLocation(false, null, source, exception.Message);
}
}
private static string? ReadConfiguredPath(string id)
{
using var key = Registry.CurrentUser.OpenSubKey(SettingsKey);
return key?.GetValue(id) as string;
}
private static IReadOnlyList<string> UninstallLocations(string displayNameFragment)
{
var result = new List<string>();
foreach (var hive in new[] { RegistryHive.LocalMachine, RegistryHive.CurrentUser })
{
foreach (var view in new[] { RegistryView.Registry64, RegistryView.Registry32 })
{
RegistryKey? baseKey = null;
RegistryKey? uninstall = null;
try
{
baseKey = RegistryKey.OpenBaseKey(hive, view);
uninstall = baseKey.OpenSubKey(
@"SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall");
if (uninstall is null) continue;
foreach (var name in uninstall.GetSubKeyNames())
{
using var entry = uninstall.OpenSubKey(name);
var displayName = entry?.GetValue("DisplayName") as string;
var location = entry?.GetValue("InstallLocation") as string;
if (!string.IsNullOrWhiteSpace(displayName)
&& displayName.Contains(displayNameFragment, StringComparison.OrdinalIgnoreCase)
&& !string.IsNullOrWhiteSpace(location))
{
result.Add(location);
}
}
}
catch (Exception exception) when (
exception is IOException or SecurityException or UnauthorizedAccessException)
{
// A registry view can be unavailable to the current node account.
}
finally
{
uninstall?.Dispose();
baseKey?.Dispose();
}
}
}
return result.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private static string? FindOnPath(string filename)
{
var path = Environment.GetEnvironmentVariable("PATH");
if (string.IsNullOrWhiteSpace(path)) return null;
foreach (var directory in path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
try
{
var candidate = Path.Combine(directory.Trim().Trim('"'), filename);
if (File.Exists(candidate)) return Path.GetFullPath(candidate);
}
catch (Exception exception) when (
exception is ArgumentException or IOException or NotSupportedException)
{
// Ignore malformed PATH entries and continue with the next one.
}
}
return null;
}
private static string? ExecutableFromCommand(string? command)
{
if (string.IsNullOrWhiteSpace(command)) return null;
var value = Environment.ExpandEnvironmentVariables(command.Trim());
var candidate = value.StartsWith('"')
? value[1..].Split('"', 2)[0]
: value.Split(".exe", 2, StringSplitOptions.None)[0] + ".exe";
return File.Exists(candidate) ? Path.GetFullPath(candidate) : null;
}
private static string ResolveBundledFile(string relativePath)
{
var root = Path.GetFullPath(AppContext.BaseDirectory);
var path = Path.GetFullPath(Path.Combine(root, relativePath));
if (!path.StartsWith(root, StringComparison.OrdinalIgnoreCase) || !File.Exists(path))
{
throw new InvalidDataException("Adapter requirements 文件缺失或路径非法。");
}
return path;
}
private static async Task<BootstrapPython> FindPython312Async(
CancellationToken cancellationToken)
{
var candidates = new[]
{
new BootstrapPython("py.exe", ["-3.12"]),
new BootstrapPython("python.exe", []),
};
foreach (var candidate in candidates)
{
try
{
await RunAsync(
candidate.FileName,
candidate.PrefixArguments.Concat(
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"]),
AppContext.BaseDirectory,
progress: null,
cancellationToken);
return candidate;
}
catch (Exception exception) when (
exception is InvalidOperationException or Win32Exception)
{
// Try the next fixed Python 3.12 discovery method.
}
}
throw new InvalidOperationException(
"未找到 Python 3.12。请先安装 Python 3.12 并启用 py launcher 或加入 PATH。");
}
private static Task VerifyPython312Async(string python, CancellationToken cancellationToken) =>
RunAsync(
python,
["-c", "import sys; raise SystemExit(0 if sys.version_info[:2] == (3, 12) else 1)"],
AppContext.BaseDirectory,
progress: null,
cancellationToken);
private static async Task RunAsync(
string filename,
IEnumerable<string> arguments,
string workingDirectory,
IProgress<string>? progress,
CancellationToken cancellationToken)
{
var startInfo = new ProcessStartInfo
{
FileName = filename,
WorkingDirectory = workingDirectory,
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8,
};
foreach (var argument in arguments)
{
startInfo.ArgumentList.Add(argument);
}
startInfo.Environment["PYTHONUTF8"] = "1";
startInfo.Environment["PYTHONIOENCODING"] = "utf-8";
using var process = Process.Start(startInfo)
?? throw new InvalidOperationException("运行环境安装进程未启动。");
var stdout = RelayAsync(process.StandardOutput, progress);
var stderr = RelayAsync(process.StandardError, progress);
try
{
await process.WaitForExitAsync(cancellationToken);
}
catch (OperationCanceledException)
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
throw;
}
await Task.WhenAll(stdout, stderr);
if (process.ExitCode != 0)
{
throw new InvalidOperationException(
$"运行环境安装进程失败,退出码 {process.ExitCode}。");
}
}
private static async Task RelayAsync(StreamReader reader, IProgress<string>? progress)
{
while (await reader.ReadLineAsync() is { } line)
{
if (!string.IsNullOrWhiteSpace(line)) progress?.Report(line);
}
}
private static void DeleteDirectoryIfPresent(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch (Exception exception) when (exception is IOException or UnauthorizedAccessException)
{
// A successful runtime swap must not be reported as failed only because
// antivirus software still holds a handle to the disposable directory.
}
}
private sealed record BootstrapPython(
string FileName,
IReadOnlyList<string> PrefixArguments);
}