595 lines
22 KiB
C#
595 lines
22 KiB
C#
using Microsoft.Win32;
|
||
using System.ComponentModel;
|
||
using System.Diagnostics;
|
||
using System.Security;
|
||
using System.Text;
|
||
using System.Text.RegularExpressions;
|
||
|
||
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.Executable,
|
||
"ZCBOT_ORIGIN_EXE",
|
||
"Origin*.exe",
|
||
@"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(definition),
|
||
"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(SoftwareDefinition definition)
|
||
{
|
||
var candidates = new List<(string Path, string Source)>();
|
||
try
|
||
{
|
||
using var clsid = Registry.ClassesRoot.OpenSubKey(@"Origin.ApplicationSI\CLSID");
|
||
var clsidValue = clsid?.GetValue(null) as string;
|
||
if (!string.IsNullOrWhiteSpace(clsidValue))
|
||
{
|
||
using var server = Registry.ClassesRoot.OpenSubKey($@"CLSID\{clsidValue}\LocalServer32");
|
||
var executable = ExecutableFromCommand(server?.GetValue(null) as string);
|
||
if (executable is not null)
|
||
{
|
||
candidates.Add((executable, "COM 注册"));
|
||
}
|
||
}
|
||
}
|
||
catch (Exception exception) when (
|
||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||
{
|
||
// Continue with uninstall metadata and standard install directories.
|
||
}
|
||
candidates.AddRange(UninstallLocations("Origin")
|
||
.SelectMany(OriginExecutables)
|
||
.Select(path => (path, "注册表")));
|
||
foreach (var variable in new[] { "ProgramFiles", "ProgramW6432" })
|
||
{
|
||
var root = Environment.GetEnvironmentVariable(variable);
|
||
if (string.IsNullOrWhiteSpace(root)) continue;
|
||
var originRoot = Path.Combine(root, "OriginLab");
|
||
if (!Directory.Exists(originRoot)) continue;
|
||
try
|
||
{
|
||
candidates.AddRange(Directory.EnumerateDirectories(originRoot)
|
||
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
|
||
.SelectMany(OriginExecutables)
|
||
.Select(path => (path, "标准目录")));
|
||
}
|
||
catch (Exception exception) when (
|
||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||
{
|
||
// Continue with candidates already collected from other sources.
|
||
}
|
||
}
|
||
return FirstLocation(definition, candidates);
|
||
}
|
||
|
||
private static IEnumerable<string> OriginExecutables(string location)
|
||
{
|
||
if (File.Exists(location))
|
||
{
|
||
yield return location;
|
||
yield break;
|
||
}
|
||
if (!Directory.Exists(location)) yield break;
|
||
IEnumerable<string> files;
|
||
try
|
||
{
|
||
files = Directory.EnumerateFiles(location, "Origin*.exe", SearchOption.TopDirectoryOnly)
|
||
.OrderByDescending(item => item, StringComparer.OrdinalIgnoreCase)
|
||
.ToArray();
|
||
}
|
||
catch (Exception exception) when (
|
||
exception is IOException or SecurityException or UnauthorizedAccessException)
|
||
{
|
||
yield break;
|
||
}
|
||
foreach (var file in files) yield return file;
|
||
}
|
||
|
||
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
|
||
|| MatchesExecutable(
|
||
Path.GetFileName(required),
|
||
definition.RequiredRelativePath!));
|
||
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 bool MatchesExecutable(string filename, string pattern)
|
||
{
|
||
if (!pattern.Contains('*'))
|
||
{
|
||
return filename.Equals(pattern, StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
var parts = pattern.Split('*', 2);
|
||
return filename.StartsWith(parts[0], StringComparison.OrdinalIgnoreCase)
|
||
&& filename.EndsWith(parts[1], StringComparison.OrdinalIgnoreCase)
|
||
&& filename.Length >= parts[0].Length + parts[1].Length;
|
||
}
|
||
|
||
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().TrimEnd('\0'));
|
||
var match = Regex.Match(
|
||
value,
|
||
"^\\s*(?:\"(?<quoted>[^\"]+\\.exe)\"|(?<plain>.+?\\.exe))(?:\\s|$)",
|
||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
|
||
if (!match.Success) return null;
|
||
var candidate = match.Groups["quoted"].Success
|
||
? match.Groups["quoted"].Value
|
||
: match.Groups["plain"].Value;
|
||
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);
|
||
}
|