using System.ComponentModel; using System.Diagnostics; using System.Text; namespace Zcbot.WindowsNode; internal static class ManagedRuntimeInstaller { private const string DefaultIndexUrl = "https://pypi.tuna.tsinghua.edu.cn/simple/"; private static readonly ProcessSupervisor ProcessSupervisor = new(); internal static async Task InstallAsync( SoftwareDefinition definition, IProgress progress, CancellationToken cancellationToken) { if (AdapterCatalog.HasActiveExecution) { throw new InvalidOperationException( $"{definition.DisplayName} 仍有任务在执行,暂不能更换运行环境。"); } var location = SoftwareLocationService.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 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 = ProcessSupervisor.Start( startInfo, "运行环境安装进程未启动。"); var stdout = ProcessSupervisor.CaptureOutputAsync(process.StandardOutput, progress); var stderr = ProcessSupervisor.CaptureOutputAsync(process.StandardError, progress); try { await process.WaitForExitAsync(cancellationToken); } catch (OperationCanceledException) { await ProcessSupervisor.TerminateTreeAsync(process); throw; } await Task.WhenAll(stdout, stderr); if (process.ExitCode != 0) { throw new InvalidOperationException( $"运行环境安装进程失败,退出码 {process.ExitCode}。"); } } 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); }