From fb632b48d8481fc886536586d1a05b01bd3fb570 Mon Sep 17 00:00:00 2001 From: codex Date: Thu, 6 Aug 2026 16:41:53 +0300 Subject: [PATCH] feat: prepare multiple exact plant packages --- Launcher.Next.App/LocalParityLaunchService.cs | 52 +++++++++++-------- Launcher.Next.App/MainWindow.xaml.cs | 2 +- .../RosterAdaptersTests.cs | 12 +++++ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/Launcher.Next.App/LocalParityLaunchService.cs b/Launcher.Next.App/LocalParityLaunchService.cs index c115210..ab3cff7 100644 --- a/Launcher.Next.App/LocalParityLaunchService.cs +++ b/Launcher.Next.App/LocalParityLaunchService.cs @@ -24,8 +24,10 @@ internal sealed record LocalParityLaunchProfile( string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot, string? NetworkSettingsEndpoint = null, LegacySettingsSnapshot? SettingsSnapshot = null); -internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, PackageRequirement Requirement, - PackageManifest Manifest, ImmutableLocalPackage Package, PackageActivationResult Activation, +internal sealed record LocalParityPackage(PackageRequirement Requirement, PackageManifest Manifest, + ImmutableLocalPackage Package, PackageActivationResult Activation); + +internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, IReadOnlyList Packages, string StarterPath, string StarterSha256); internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed, @@ -60,20 +62,23 @@ internal sealed class LocalParityLaunchService var selection = adapter.ParseDownloadList(await File.ReadAllTextAsync(profile.DownloadListPath, ct)); var overlay = LegacyCompatibilityOverlayParser.Parse(await File.ReadAllTextAsync(profile.OverlayPath, ct)); var requirements = adapter.Join(pattern[0], selection, overlay); - if (requirements.Count != 1) throw new InvalidOperationException("LOCAL_PARITY_EXACTLY_ONE_PACKAGE_REQUIRED"); - var requirement = requirements[0]; - var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion); - var manifestPath = Under(source, "package.manifest.json"); - var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); - if (!ManifestMatches(manifest, requirement)) throw new InvalidOperationException("LOCAL_PARITY_MANIFEST_REQUIREMENT_MISMATCH"); var store = new LocalPackageStore(profile.Root, new Sha256FileHasher()); - var package = await store.MaterializeAsync(manifest, source, ct); - await store.BindAsync(requirement, package.Version, ct); - var activation = await store.ActivateImmutableAsync(package, requirement, ct); - if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome); - if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED"); - var starter = await GenerateStarterAsync(profile, package, manifest, ct); - return new(profile, requirement, manifest, package, activation, starter.Path, starter.Sha256); + var prepared = new List(); + foreach (var requirement in requirements) + { + var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion); + var manifestPath = Under(source, "package.manifest.json"); + var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct)); + if (!ManifestMatches(manifest, requirement)) throw new InvalidOperationException("LOCAL_PARITY_MANIFEST_REQUIREMENT_MISMATCH"); + var package = await store.MaterializeAsync(manifest, source, ct); + await store.BindAsync(requirement, package.Version, ct); + var activation = await store.ActivateImmutableAsync(package, requirement, ct); + if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome); + if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED"); + prepared.Add(new(requirement, manifest, package, activation)); + } + var starter = await GenerateStarterAsync(profile, prepared, ct); + return new(profile, prepared, starter.Path, starter.Sha256); } public async Task LaunchAsync(LocalParityPrepared prepared, CancellationToken ct) @@ -90,6 +95,7 @@ internal sealed class LocalParityLaunchService // Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last. psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath); psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke"; + psi.Environment["P_REC_UPDATE_MARKER_ROOT"] = Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "markers"); using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED"); await Task.Delay(TimeSpan.FromSeconds(30), ct); var alive = !process.HasExited; @@ -104,21 +110,23 @@ internal sealed class LocalParityLaunchService var newLog = await reader.ReadToEndAsync(ct); runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal); } - var loaded = runtimeConfirmed; + var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal)); + var updateInitialized = updatePackage is null || File.Exists(Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "markers", "AssemblyInitializeCompleted.marker")); + var loaded = runtimeConfirmed && updateInitialized; var terminal = alive && loaded ? "Succeeded" : "Blocked"; var settingsSnapshot = prepared.Profile.SettingsSnapshot is null ? null : new { sourceEndpointFingerprint = prepared.Profile.SettingsSnapshot.SourceFingerprint, prepared.Profile.SettingsSnapshot.Sha256, prepared.Profile.SettingsSnapshot.Length, prepared.Profile.SettingsSnapshot.LastWriteUtc, prepared.Profile.SettingsSnapshot.AcquiredUtc, prepared.Profile.SettingsSnapshot.OfflineFallback }; - var evidence = new { schemaVersion = 3, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = new[] { prepared.Requirement.PackageId }, exactPackage = new { prepared.Package.PackageId, prepared.Package.Version, prepared.Manifest.Product, prepared.Manifest.ProductYear, prepared.Manifest.Architecture, prepared.Manifest.RolloutChannel }, manifestSha256 = await HashAsync(Path.Combine(prepared.Package.LocalPackageRoot, "package.manifest.json"), ct), payloads = prepared.Manifest.Files.Select(x => new { path = x.Path, x.Length, x.Sha256 }), materialization = "MaterializedOrAlreadyPresentExact", activation = prepared.Activation.Outcome, pointerGeneration = prepared.Activation.Receipt?.PointerGeneration, bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, processInteractiveAfterScript=alive }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } }; + var evidence = new { schemaVersion = 3, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, orderedPluginIds = prepared.Packages.Select(x => x.Requirement.PackageId), exactPackages = prepared.Packages.Select(x => new { x.Package.PackageId, x.Package.Version, x.Manifest.Product, x.Manifest.ProductYear, x.Manifest.Architecture, x.Manifest.RolloutChannel, manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), payloads = x.Manifest.Files.Select(f => new { path = f.Path, f.Length, f.Sha256 }), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, updateInitialized, processInteractiveAfterScript=alive }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } }; var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct); return new(process.Id, alive, loaded, evidencePath, terminal); } - private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, ImmutableLocalPackage package, PackageManifest manifest, CancellationToken ct) + private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, IReadOnlyList packages, CancellationToken ct) { var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot); - var entry = Under(package.LocalPackageRoot, manifest.EntryAssembly); - if (!File.Exists(entry)) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING"); - var marker = Under(starterRoot, "pxp-oa-netload.marker"); - var content = "_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n_.NETLOAD\r\n\"" + entry.Replace("\"", "\\\"") + "\"\r\n"; + var entries = packages.Select(x => Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly)).ToArray(); + if (entries.Any(x => !File.Exists(x))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING"); + Directory.CreateDirectory(Under(starterRoot, "markers")); + var content = "_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n" + string.Concat(entries.Select(entry => "_.NETLOAD\r\n\"" + entry.Replace("\"", "\\\"") + "\"\r\n")); var path = Under(starterRoot, "startup.scr"); await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct); return (path, await HashAsync(path, ct)); diff --git a/Launcher.Next.App/MainWindow.xaml.cs b/Launcher.Next.App/MainWindow.xaml.cs index 632fa11..60b3311 100644 --- a/Launcher.Next.App/MainWindow.xaml.cs +++ b/Launcher.Next.App/MainWindow.xaml.cs @@ -171,7 +171,7 @@ public partial class MainWindow : System.Windows.Window var profile = localProfile with { DownloadListPath = selectedRosterSnapshotPath ?? localProfile.DownloadListPath }; var prepared = await localService.PrepareAsync(profile, CancellationToken.None); preparationState = "Succeeded"; - activationState = prepared.Activation.Outcome; + activationState = string.Join(", ", prepared.Packages.Select(x => x.Activation.Outcome)); bootstrapState = "Created"; Transition("ProcessStart", "PROCESS_STARTING", "Запуск Plant 3D"); var result = await localService.LaunchAsync(prepared, CancellationToken.None); diff --git a/tests/Launcher.Next.Tests/RosterAdaptersTests.cs b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs index 587308c..83267e1 100644 --- a/tests/Launcher.Next.Tests/RosterAdaptersTests.cs +++ b/tests/Launcher.Next.Tests/RosterAdaptersTests.cs @@ -42,6 +42,18 @@ public sealed class RosterAdaptersTests Assert.Single(requirements); Assert.Equal("PHIM.A", requirements[0].PackageId); } + [Fact] + public void ExplicitPRecUpdateMapping_PreservesRosterOrderAlongsidePRecAnnot() + { + const string settings = "hcPlant*.xmlrb455ee6eaed79d76f084846a60ce3284P_Rec_AnnotOrthoLoader.dll6ffef84b01097975f51fb3bbb11279e1P_Rec_UpdateP_Rec_Update.dll"; + const string selection = "b455ee6eaed79d76f084846a60ce32846ffef84b01097975f51fb3bbb11279e1"; + const string overlay = """{"schemaVersion":1,"mappings":[{"mappingId":"p-rec-annot","pattern":"Plant","legacyPluginId":"b455ee6eaed79d76f084846a60ce3284","legacyCatalog":"P_Rec_Annot","legacyInstall":"OrthoLoader.dll","package":{"id":"P_Rec_Annot","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}},{"mappingId":"p-rec-update","pattern":"Plant","legacyPluginId":"6ffef84b01097975f51fb3bbb11279e1","legacyCatalog":"P_Rec_Update","legacyInstall":"P_Rec_Update.dll","package":{"id":"P_Rec_Update","product":"Plant3D","productYear":2026,"architecture":"x64","channel":"controlled-local","version":"0.1.0"}}]}"""; + var adapter = new LegacyConfigurationAdapter(); + var requirement = adapter.Join(adapter.ParseSettings(settings).Patterns[0], adapter.ParseDownloadList(selection), LegacyCompatibilityOverlayParser.Parse(overlay)); + Assert.Equal(["P_Rec_Annot", "P_Rec_Update"], requirement.Select(x => x.PackageId)); + Assert.All(requirement, x => Assert.Equal("0.1.0", x.RequiredVersion)); + } + [Fact] public void LegacyDownloadList_DoesNotInventPluginMetadata() {