From 5bd72ec9b1a56bb56f00135b98d67101978c62c2 Mon Sep 17 00:00:00 2001 From: JusterZhu Date: Wed, 20 May 2026 23:03:52 +0800 Subject: [PATCH] feat: add SimulationService and wire up SimulateViewModel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SimulationService orchestrates full simulation flow: validate → copy patch → start server → generate scripts → run client → verify - Checks dotnet SDK version before starting - Runs client.cs via dotnet run, captures stdout/stderr with timeout - Verifies update result (file count, delete_files.json consumption) - SimulateViewModel now calls SimulationService on Start --- src/Services/SimulationService.cs | 214 ++++++++++++++++++++++++++++ src/ViewModels/SimulateViewModel.cs | 45 +++++- 2 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 src/Services/SimulationService.cs diff --git a/src/Services/SimulationService.cs b/src/Services/SimulationService.cs new file mode 100644 index 0000000..0e828d9 --- /dev/null +++ b/src/Services/SimulationService.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GeneralUpdate.Tools.Models; + +namespace GeneralUpdate.Tools.Services; + +/// +/// Orchestrates the full update simulation: server, client, upgrade, log collection. +/// +public class SimulationService +{ + private readonly ClientGeneratorService _generator = new(); + private readonly LocalUpdateServer _server = new(); + private readonly StringBuilder _fullLog = new(); + private int _timeoutSeconds = 60; + + public IReadOnlyList LogLines => _fullLog.ToString().Split('\n').ToList(); + + public async Task RunAsync( + SimulateConfigModel config, + IProgress? progress = null, + CancellationToken ct = default) + { + var result = new SimulationResult(); + var sw = Stopwatch.StartNew(); + + try + { + // 1. Validate + Log("STEP 1: Validating inputs", progress); + Validate(config); + + // 2. Prepare output directory + Log($"STEP 2: Preparing {config.OutputDirectory}", progress); + Directory.CreateDirectory(config.OutputDirectory); + + // 3. Copy patch to server working dir + Log("STEP 3: Setting up local server", progress); + var serverPatchDir = Path.Combine(config.OutputDirectory, ".server"); + Directory.CreateDirectory(serverPatchDir); + var patchName = Path.GetFileName(config.PatchFilePath); + var patchDest = Path.Combine(serverPatchDir, patchName); + File.Copy(config.PatchFilePath, patchDest, true); + + var hash = ComputeQuickHash(patchDest); + LocalUpdateServerFiles.Register(patchName, patchDest); + _server.Updates.Add((config.CurrentVersion, config.TargetVersion, hash, patchDest, config.AppType)); + + await _server.StartAsync(config.ServerPort); + Log($" Server running on {_server.BaseUrl}", progress); + config.ServerPort = _server.Port; + + // 4. Generate client/upgrade scripts + Log("STEP 4: Generating client.cs and upgrade.cs", progress); + await _generator.GenerateAsync(config, config.OutputDirectory); + Log($" client.cs → {config.OutputDirectory}", progress); + Log($" upgrade.cs → {config.OutputDirectory}", progress); + + // 5. Run client + Log("STEP 5: Running client (dotnet run client.cs)", progress); + var clientResult = await RunDotNetScript(config.OutputDirectory, "client.cs", ct); + Log(clientResult.Output, progress); + + if (!clientResult.Success) + { + Log(" Client failed - see output above", progress); + result.Success = false; + result.ErrorMessage = "Client exited with error"; + return result; + } + + Log(" Client completed successfully", progress); + + // 6. Verify the patch was applied + Log("STEP 6: Verifying update result", progress); + await Task.Delay(2000, ct); // Give upgrade process time to complete + VerifyUpdateResult(config, result); + + result.Success = true; + result.Elapsed = sw.Elapsed; + Log($"✅ Simulation complete ({sw.Elapsed.TotalSeconds:F1}s)", progress); + } + catch (Exception ex) + { + result.Success = false; + result.ErrorMessage = ex.Message; + Log($"❌ Simulation failed: {ex.Message}", progress); + } + finally + { + try + { + await _server.DisposeAsync(); + LocalUpdateServerFiles.Clear(); + } + catch { } + + result.FullLog = _fullLog.ToString(); + } + + return result; + } + + private void Validate(SimulateConfigModel config) + { + if (!Directory.Exists(config.AppDirectory)) + throw new DirectoryNotFoundException($"App directory not found: {config.AppDirectory}"); + + if (!File.Exists(config.PatchFilePath)) + throw new FileNotFoundException($"Patch file not found: {config.PatchFilePath}"); + + if (string.IsNullOrWhiteSpace(config.OutputDirectory)) + throw new ArgumentException("Output directory is required"); + + // Check dotnet + try + { + var psi = new ProcessStartInfo("dotnet", "--version") + { + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + using var p = Process.Start(psi); + p?.WaitForExit(5000); + var ver = p?.StandardOutput.ReadToEnd().Trim(); + if (string.IsNullOrEmpty(ver) || !ver.StartsWith("10.") && !ver.StartsWith("11.")) + throw new InvalidOperationException(".NET 10.0 SDK is required. Install from https://dotnet.microsoft.com/"); + } + catch (InvalidOperationException) { throw; } + catch { throw new InvalidOperationException("dotnet CLI not found. Install .NET 10.0 SDK."); } + } + + private async Task<(bool Success, string Output)> RunDotNetScript(string workDir, string script, CancellationToken ct) + { + var psi = new ProcessStartInfo("dotnet", $"run {script}") + { + WorkingDirectory = workDir, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + using var p = Process.Start(psi)!; + var output = new StringBuilder(); + + var readTask = Task.Run(async () => + { + while (!p.StandardOutput.EndOfStream) + output.AppendLine(await p.StandardOutput.ReadLineAsync(ct)); + }, ct); + + var errorTask = Task.Run(async () => + { + while (!p.StandardError.EndOfStream) + output.AppendLine(await p.StandardError.ReadLineAsync(ct)); + }, ct); + + // Wait with timeout + var completed = p.WaitForExit(_timeoutSeconds * 1000); + if (!completed) + { + p.Kill(true); + return (false, output + "\n[TIMEOUT] Simulation exceeded time limit"); + } + + await Task.WhenAll(readTask, errorTask); + return (p.ExitCode == 0, output.ToString()); + } + + private void VerifyUpdateResult(SimulateConfigModel config, SimulationResult result) + { + // Check if delete_files.json was consumed (it should be gone or applied) + var deleteFile = Path.Combine(config.AppDirectory, "delete_files.json"); + if (File.Exists(deleteFile)) + { + result.Notes.Add("delete_files.json still present - HandleDeleteList may not have run"); + } + + // Count files changed in app directory + var fileCount = Directory.GetFiles(config.AppDirectory, "*", SearchOption.AllDirectories).Length; + result.Notes.Add($"Files in app directory after update: {fileCount}"); + } + + private static string ComputeQuickHash(string filePath) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + using var fs = File.OpenRead(filePath); + return BitConverter.ToString(sha.ComputeHash(fs)).Replace("-", "").ToLowerInvariant(); + } + + private void Log(string msg, IProgress? progress) + { + var line = $"[{DateTime.Now:HH:mm:ss}] {msg}"; + _fullLog.AppendLine(line); + progress?.Report(line); + } +} + +public class SimulationResult +{ + public bool Success { get; set; } + public string? ErrorMessage { get; set; } + public TimeSpan Elapsed { get; set; } + public string FullLog { get; set; } = ""; + public List Notes { get; } = new(); +} diff --git a/src/ViewModels/SimulateViewModel.cs b/src/ViewModels/SimulateViewModel.cs index 211be8d..600be07 100644 --- a/src/ViewModels/SimulateViewModel.cs +++ b/src/ViewModels/SimulateViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.IO; using System.Threading.Tasks; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; @@ -11,6 +12,7 @@ namespace GeneralUpdate.Tools.ViewModels; public partial class SimulateViewModel : ViewModelBase { private readonly LocalizationService _loc = LocalizationService.Instance; + private readonly SimulationService _sim = new(); public SimulateConfigModel Config { get; } = new(); @@ -62,11 +64,44 @@ public SimulateViewModel() [RelayCommand] async Task StartSimulation() { - // Validation will be implemented in issue #4 - if (string.IsNullOrWhiteSpace(Config.AppDirectory)) { Status = "请选择旧版本应用目录"; return; } - if (string.IsNullOrWhiteSpace(Config.PatchFilePath)) { Status = "请选择补丁包文件"; return; } - if (string.IsNullOrWhiteSpace(Config.OutputDirectory)) { Status = "请选择模拟输出目录"; return; } - Status = "模拟功能将在后续 issue 中实现"; + if (string.IsNullOrWhiteSpace(Config.AppDirectory)) { Status = _loc["Sim.ValidateDirs"]; return; } + if (string.IsNullOrWhiteSpace(Config.PatchFilePath)) { Status = _loc["Sim.ValidateDirs"]; return; } + if (string.IsNullOrWhiteSpace(Config.OutputDirectory)) { Status = _loc["Sim.ValidateDirs"]; return; } + + IsRunning = true; + Log.Clear(); + Status = "Starting simulation..."; + + try + { + var progress = new Progress(L); + var result = await _sim.RunAsync(Config, progress); + + if (result.Success) + { + Status = $"Simulation completed ({result.Elapsed.TotalSeconds:F1}s)"; + L($"Result: {(result.Success ? "PASS" : "FAIL")}"); + foreach (var note in result.Notes) + L($" Note: {note}"); + + // Generate report + var reportPath = Path.Combine(Config.OutputDirectory, "simulation_report.md"); + // report generation will be in next PR + } + else + { + Status = $"Simulation failed: {result.ErrorMessage}"; + } + } + catch (Exception ex) + { + Status = $"Error: {ex.Message}"; + L($"FATAL: {ex}"); + } + finally + { + IsRunning = false; + } } void L(string msg) => Log.Add($"[{DateTime.Now:HH:mm:ss}] {msg}");