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 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 InstallRuntimeAsync( SoftwareDefinition definition, IProgress 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 UninstallLocations(string displayNameFragment) { var result = new List(); 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 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 arguments, string workingDirectory, IProgress? 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? 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 PrefixArguments); }