Author SHA1 Message Date
codex 3e7cb3ac39 feat: emit target command in controlled automation config 2026-08-10 15:11:28 +03:00
codex 5a14658994 feat: add guarded controlled entity selection transport 2026-08-10 15:03:38 +03:00
codex e12b8c549c fix: observe automation evidence without timeout delay 2026-08-10 11:32:39 +03:00
codex 6f7888b18b fix: keep pilot automation host alive 2026-08-10 11:16:39 +03:00
codex 1f5853de86 fix: keep controlled roster discovery path-portable 2026-08-10 10:16:23 +03:00
codex 94d35a12d9 feat: enforce startup roster on pilot launch 2026-08-10 10:15:41 +03:00
codex cda89c359e fix: discover controlled local parity startup roots 2026-08-10 10:11:32 +03:00
codex 8652325980 test: cover requested and legacy startup candidates 2026-08-10 10:10:35 +03:00
codex 7025ce0a46 fix: include requested package in startup roster 2026-08-10 10:10:03 +03:00
codex 514302b966 feat: fail closed on conflicting P_Rec_Annot startup roster 2026-08-10 10:08:48 +03:00
codex a5a615f7f0 fix: persist p-rec-update package load diagnostics 2026-08-07 07:35:05 +03:00
codex 06d7cb0015 fix: keep controlled launch host alive for evidence 2026-08-06 16:48:57 +03:00
codex fb632b48d8 feat: prepare multiple exact plant packages 2026-08-06 16:41:53 +03:00
codex 2792e244e9 docs: record owner roster compatibility result 2026-08-06 15:14:40 +03:00
codex 2c578576d7 fix: parse project rosters with legacy-compatible semantics 2026-08-06 15:14:08 +03:00
codex f460e87253 fix: bind release evidence to embedded commit 2026-08-06 14:03:22 +03:00
codex 433dc9a50d docs: record owner network configuration runtime 2026-08-06 14:01:45 +03:00
codex 45b1379853 feat: record network settings snapshot in launch evidence 2026-08-06 13:58:08 +03:00
codex 97fbc1d746 feat: acquire legacy settings from approved network source 2026-08-06 13:56:54 +03:00
codex 0213b229a9 feat: build controlled owner review distribution 2026-08-06 13:37:15 +03:00
codex 876e4f5d50 fix: report launcher failures by operation stage 2026-08-06 13:36:59 +03:00
codex 1c18f4893d docs: record verified legacy selection contracts 2026-08-06 13:02:59 +03:00
codex 72f127a427 feat: restore product-specific legacy project selection 2026-08-06 13:02:49 +03:00
codex 5c812f87fd fix: bootstrap plant plugins with startup script 2026-08-06 12:27:12 +03:00
codex f052dd0633 feat: restore two-step legacy launcher workflow 2026-08-06 11:20:25 +03:00
codex 75a1097653 feat: add controlled local PXP-OA parity workflow 2026-08-06 11:17:33 +03:00
codex 0879d716dd feat: expose legacy launcher configuration workflow 2026-08-06 10:18:22 +03:00
codex 8d32f8e7ae docs: record phase one lifecycle verification 2026-08-06 10:01:34 +03:00
codex 9fce6ee830 feat: expand controlled package lifecycle pilot evidence 2026-08-06 09:59:43 +03:00
codex 64fa1f5664 feat: harden package lifecycle content boundaries 2026-08-06 09:59:17 +03:00
37 changed files with 1402 additions and 64 deletions
+24 -1
View File
@@ -6,15 +6,38 @@ public partial class App : System.Windows.Application
base.OnStartup(e);
if (e.Args.FirstOrDefault()?.Equals("legacy-configuration-probe", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await LegacyConfigurationProbeCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.FirstOrDefault()?.Equals("package-lifecycle-pilot", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true) { Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray())); return; }
if (e.Args.FirstOrDefault()?.Equals("local-parity-launch", StringComparison.OrdinalIgnoreCase) == true)
{
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await LocalParityLaunchCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
if (e.Args.Length == 4 && e.Args[0].Equals("--mode", StringComparison.OrdinalIgnoreCase) && e.Args[1].Equals("LocalParityLaunch", StringComparison.OrdinalIgnoreCase) && e.Args[2].Equals("--config", StringComparison.OrdinalIgnoreCase))
{
try { new MainWindow(LocalParityLaunchService.LoadProfile(e.Args[3])).Show(); }
catch (Exception exception) { System.Windows.MessageBox.Show("Controlled local configuration blocked: " + exception.Message, "Launcher Next"); Shutdown(2); }
return;
}
if (e.Args.FirstOrDefault()?.Equals("pilot", StringComparison.OrdinalIgnoreCase) == true)
{
// A pilot command is asynchronous and owns no WPF window. Without an
// explicit shutdown mode the dispatcher may terminate at its first
// await, after the Preview child has been started but before Apply
// and its evidence/rollback boundary have run.
ShutdownMode = System.Windows.ShutdownMode.OnExplicitShutdown;
Shutdown(await PilotCli.RunAsync(e.Args.Skip(1).ToArray()));
return;
}
new MainWindow().Show();
}
}
+29
View File
@@ -0,0 +1,29 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
namespace Launcher.Next.App;
/// <summary>Explicit automation of the same LocalParityLaunchService used by the main-window actions.</summary>
internal static class LocalParityLaunchCli
{
public static async Task<int> RunAsync(string[] args)
{
if (args.Length != 4 || args[0] != "--mode" || args[1] != "LocalParityLaunch" || args[2] != "--config") return 2;
try
{
var service = new LocalParityLaunchService();
var prepared = await service.PrepareAsync(LocalParityLaunchService.LoadProfile(args[3]), CancellationToken.None);
var launch = await service.LaunchAsync(prepared, CancellationToken.None);
return launch.TerminalState == "Succeeded" ? 0 : 1;
}
catch (Exception exception)
{
// Explicit local mode: retain a bounded diagnostic next to its supplied profile.
// Normal production and automation routes never take this path.
try { await File.WriteAllTextAsync(Path.Combine(Path.GetDirectoryName(args[3])!, "local-parity-last-error.txt"), exception.ToString()); } catch { }
return 1;
}
}
}
@@ -0,0 +1,199 @@
using System;
using System.IO;
using System.Linq;
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Launcher.Next.Application;
using Launcher.Next.Autodesk;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
namespace Launcher.Next.App;
/// <summary>
/// Explicit non-production workflow for a legacy-shaped configuration whose source,
/// cache, activation state, starter and evidence all live under one supplied local root.
/// It never accepts UNC roots or uses the legacy compatibility synchronizer.
/// </summary>
internal sealed record LocalParityLaunchProfile(
int SchemaVersion, string Name, string Root, string SettingsPath, string DownloadListPath,
string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot, string? NetworkSettingsEndpoint = null,
LegacySettingsSnapshot? SettingsSnapshot = null);
internal sealed record LocalParityPackage(PackageRequirement Requirement, PackageManifest Manifest,
ImmutableLocalPackage Package, PackageActivationResult Activation);
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, IReadOnlyList<LocalParityPackage> Packages,
string StarterPath, string StarterSha256);
internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed,
string EvidencePath, string TerminalState);
internal sealed class LocalParityLaunchService
{
private readonly LegacyConfigurationAdapter adapter = new();
public static LocalParityLaunchProfile LoadProfile(string path)
{
var profile = JsonSerializer.Deserialize<LocalParityLaunchProfile>(File.ReadAllText(path), new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
?? throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Name) || string.IsNullOrWhiteSpace(profile.PatternName))
throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
ValidateProfile(profile);
profile = profile with { Root = Path.GetFullPath(profile.Root) };
if (!string.IsNullOrWhiteSpace(profile.NetworkSettingsEndpoint))
{
var snapshot = LegacyNetworkConfiguration.AcquireApprovedSnapshot(profile.NetworkSettingsEndpoint, profile.Root);
profile = profile with { SettingsPath = snapshot.LocalSnapshotPath, SettingsSnapshot = snapshot };
}
return profile;
}
public async Task<LocalParityPrepared> PrepareAsync(LocalParityLaunchProfile profile, CancellationToken ct)
{
ValidateProfile(profile);
var settings = adapter.ParseSettings(await File.ReadAllTextAsync(profile.SettingsPath, ct));
var pattern = settings.Patterns.Where(x => string.Equals(x.Name, profile.PatternName, StringComparison.Ordinal)).ToArray();
if (pattern.Length != 1) throw new InvalidOperationException(pattern.Length == 0 ? "LOCAL_PARITY_PATTERN_NOT_FOUND" : "LOCAL_PARITY_PATTERN_AMBIGUOUS");
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);
var store = new LocalPackageStore(profile.Root, new Sha256FileHasher());
var prepared = new List<LocalParityPackage>();
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<LocalParityLaunchResult> LaunchAsync(LocalParityPrepared prepared, CancellationToken ct)
{
var product = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026)
?? throw new InvalidOperationException("PLANT3D_2026_NOT_FOUND");
var operationId = Guid.NewGuid().ToString("N");
var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId);
Directory.CreateDirectory(evidenceDirectory);
var bootstrapRoot = Path.GetDirectoryName(prepared.StarterPath)!;
var updatePackage = prepared.Packages.SingleOrDefault(x => string.Equals(x.Package.PackageId, "P_Rec_Update", StringComparison.Ordinal));
var updateAssemblyHash = updatePackage is null ? null : await HashAsync(Under(updatePackage.Package.LocalPackageRoot, updatePackage.Manifest.EntryAssembly), ct);
var logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Promhimproekt", "PRecAnnot2026", "Logs", "runtime.jsonl");
var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L;
var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false };
psi.ArgumentList.Add("/product"); psi.ArgumentList.Add("PLNT3D"); psi.ArgumentList.Add("/language"); psi.ArgumentList.Add("ru-RU");
// 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(bootstrapRoot, "markers");
psi.Environment["P_REC_UPDATE_OPERATION_ID"] = operationId;
using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED");
await Task.Delay(TimeSpan.FromSeconds(30), ct);
var alive = !process.HasExited;
var marker = Path.Combine(Path.GetDirectoryName(prepared.StarterPath)!, "pxp-oa-netload.marker");
var markerConfirmed = File.Exists(marker);
var runtimeConfirmed = false;
if (File.Exists(logPath))
{
await using var stream = new FileStream(logPath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
stream.Seek(beforeLogLength, SeekOrigin.Begin);
using var reader = new StreamReader(stream, Encoding.UTF8);
var newLog = await reader.ReadToEndAsync(ct);
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
}
var markerRoot = Path.Combine(bootstrapRoot, "markers");
var beforeUpdateNetload = File.Exists(Path.Combine(markerRoot, "BeforePRecUpdateNetload.marker"));
var afterUpdateNetload = File.Exists(Path.Combine(markerRoot, "AfterPRecUpdateNetload.marker"));
var scriptCompleted = File.Exists(Path.Combine(markerRoot, "ScriptCompleted.marker"));
var updateEntered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "InitializeEntered.marker.json"));
var updateInitialized = updatePackage is null || File.Exists(Path.Combine(markerRoot, "AssemblyInitializeCompleted.marker.json"));
var updateCommandRegistered = updatePackage is null || File.Exists(Path.Combine(markerRoot, "CommandRegistrationVerified.marker.json"));
var loaded = runtimeConfirmed && updateInitialized && updateCommandRegistered;
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 = 4, 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, rosterPosition) => new { legacyId = x.Requirement.PackageId == "P_Rec_Update" ? "6ffef84b01097975f51fb3bbb11279e1" : "b455ee6eaed79d76f084846a60ce3284", rosterPosition, x.Package.PackageId, x.Package.Version, entryAssembly = x.Manifest.EntryAssembly, entrySha256 = x.Package.PackageId == "P_Rec_Update" ? updateAssemblyHash : HashAsync(Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly), ct).GetAwaiter().GetResult(), manifestSha256 = HashAsync(Path.Combine(x.Package.LocalPackageRoot, "package.manifest.json"), ct).GetAwaiter().GetResult(), activation = x.Activation.Outcome, pointerGeneration = x.Activation.Receipt?.PointerGeneration, netloadAttempted = x.Package.PackageId != "P_Rec_Update" || beforeUpdateNetload, netloadReturned = x.Package.PackageId != "P_Rec_Update" || afterUpdateNetload, initializeEntered = x.Package.PackageId != "P_Rec_Update" || updateEntered, initializeCompleted = x.Package.PackageId != "P_Rec_Update" || updateInitialized, commandProbeResult = x.Package.PackageId == "P_Rec_Update" ? (updateCommandRegistered ? "COMMAND_REGISTRATION_VERIFIED" : "Unavailable") : "AssemblyInitializeCompleted" }), materialization = "MaterializedOrAlreadyPresentExact", bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, scriptMarkers = new { beforeUpdateNetload, afterUpdateNetload, scriptCompleted }, 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, updateEntered, updateInitialized, updateCommandRegistered, processInteractiveAfterScript=alive }, terminalState = terminal, terminalResult = loaded ? "PACKAGE_LOAD_VERIFIED" : updateEntered ? "PACKAGE_INITIALIZE_TIMEOUT" : afterUpdateNetload ? "PACKAGE_LOAD_FAILED" : beforeUpdateNetload ? "SCRIPT_ABORTED" : "SCRIPT_ABORTED", 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, IReadOnlyList<LocalParityPackage> packages, CancellationToken ct)
{
var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot);
var entries = packages.Select(x => (x.Package.PackageId, Path: Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly))).ToArray();
if (entries.Any(x => !File.Exists(x.Path))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
var markerRoot = Under(starterRoot, "markers"); Directory.CreateDirectory(markerRoot);
var helperPath = Under(starterRoot, "bootstrap-markers.lsp");
var helper = "(defun PXP-MARK (p s / f) (setq f (open p \"w\")) (write-line s f) (close f) (princ))";
await File.WriteAllTextAsync(helperPath, helper, new UTF8Encoding(false), ct);
string LispPath(string path) => path.Replace('\\', '/');
string Mark(string name) => "(PXP-MARK \"" + LispPath(Under(markerRoot, name + ".marker")) + "\" \"" + name + "\")\r\n";
var content = "(load \"" + LispPath(helperPath) + "\")\r\n_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n";
foreach (var entry in entries)
{
if (entry.PackageId == "P_Rec_Update") content += Mark("BeforePRecUpdateNetload");
content += "_.NETLOAD\r\n\"" + entry.Path.Replace("\"", "\\\"") + "\"\r\n";
if (entry.PackageId == "P_Rec_Update") content += Mark("AfterPRecUpdateNetload") + "P-R-UPD-STATUS\r\n";
}
content += Mark("ScriptCompleted");
var path = Under(starterRoot, "startup.scr");
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
return (path, await HashAsync(path, ct));
}
private static bool ManifestMatches(PackageManifest manifest, PackageRequirement requirement) =>
manifest.PackageId == requirement.PackageId && manifest.Version == requirement.RequiredVersion && manifest.Product == requirement.Product && manifest.ProductYear == requirement.ProductYear && string.Equals(manifest.Architecture, requirement.Architecture, StringComparison.OrdinalIgnoreCase) && string.Equals(manifest.RolloutChannel, requirement.Channel, StringComparison.OrdinalIgnoreCase);
private static void ValidateProfile(LocalParityLaunchProfile profile)
{
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Root)) throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
var root = Path.GetFullPath(profile.Root);
if (!Path.IsPathFullyQualified(root) || root.StartsWith("\\\\", StringComparison.Ordinal) || root.Contains("?", StringComparison.Ordinal) || new DirectoryInfo(root).Exists && new DirectoryInfo(root).Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LOCAL_PARITY_ROOT_NOT_LOCAL");
foreach (var path in new[] { profile.SettingsPath, profile.DownloadListPath, profile.OverlayPath, profile.CatalogRoot, profile.EvidenceRoot }) if (!UnderRoot(root, path)) throw new InvalidOperationException("LOCAL_PARITY_PATH_OUTSIDE_ROOT");
}
private static bool UnderRoot(string root, string path) { var r = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar; var p = Path.GetFullPath(path); return p.StartsWith(r, StringComparison.OrdinalIgnoreCase); }
private static string Under(string root, params string[] parts) { var result = Path.GetFullPath(Path.Combine(new[] { root }.Concat(parts).ToArray())); if (!UnderRoot(root, result) && !string.Equals(Path.GetFullPath(root), result, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LOCAL_PARITY_PATH_ESCAPE"); return result; }
private static async Task<string> HashAsync(string path, CancellationToken ct) { await using var stream = File.OpenRead(path); return Convert.ToHexString(await SHA256.HashDataAsync(stream, ct).ConfigureAwait(false)); }
private static string Fingerprint(string path, string salt) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + "|" + path)));
internal static async Task<string> WriteEvidenceAsync(string directory, object value, CancellationToken ct)
{
var final = Path.Combine(directory, "controlled-local-parity-launch.json");
var temp = final + ".tmp";
var json = JsonSerializer.Serialize(value);
await File.WriteAllTextAsync(temp, json, Encoding.UTF8, ct).ConfigureAwait(false);
File.Move(temp, final, overwrite: true);
using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct).ConfigureAwait(false));
if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 4) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID");
return final;
}
private static string ThisCommit()
{
var embedded = Environment.GetEnvironmentVariable("LAUNCHER_COMMIT");
if (!string.IsNullOrWhiteSpace(embedded)) return embedded;
try
{
var releaseManifest = Path.Combine(AppContext.BaseDirectory, "owner-review-release.json");
if (File.Exists(releaseManifest))
{
using var document = JsonDocument.Parse(File.ReadAllText(releaseManifest));
if (document.RootElement.TryGetProperty("launcherCommit", out var commit) && !string.IsNullOrWhiteSpace(commit.GetString())) return commit.GetString()!;
}
var start = new ProcessStartInfo("git", "rev-parse HEAD") { WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, RedirectStandardOutput = true };
using var process = Process.Start(start);
var value = process is null ? "" : process.StandardOutput.ReadToEnd().Trim();
return string.IsNullOrWhiteSpace(value) ? "unknown" : value;
}
catch { return "unknown"; }
}
}
+1 -1
View File
@@ -1 +1 @@
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next (Foundation)" Height="300" Width="540"><StackPanel Margin="20"><TextBlock FontSize="20" Text="Launcher Next — foundation"/><TextBlock Margin="0,12,0,0" Text="Plant 3D: discovery available in diagnostics"/><TextBlock Text="Mode: Legacy (default); NextPilot requires explicit configuration"/><TextBlock Text="Package: no production catalog connected"/><Button Margin="0,16,0,0" IsEnabled="False" Content="Launch (disabled: dry-run foundation)"/></StackPanel></Window>
<Window x:Class="Launcher.Next.App.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="Launcher Next" Height="620" Width="840"><Grid Margin="20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><TextBlock FontSize="22" FontWeight="SemiBold" Text="Launcher Next"/><GroupBox Grid.Row="1" Header="Выберите ПО" Margin="0,14,0,0"><StackPanel Margin="8"><ListBox x:Name="Products" DisplayMemberPath="Name" SelectionChanged="Products_SelectionChanged" Height="78"/><TextBlock x:Name="ProductStatus" Margin="0,7,0,0" TextWrapping="Wrap"/></StackPanel></GroupBox><GroupBox Grid.Row="2" Header="Проект и режим загрузки" Margin="0,10,0,0"><Grid Margin="8"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="Auto"/></Grid.ColumnDefinitions><StackPanel><TextBlock Text="Путь к проекту:"/><TextBlock x:Name="ProjectPath" Text="Выберите программное обеспечение" TextWrapping="Wrap"/><StackPanel Orientation="Horizontal" Margin="0,10,0,0"><RadioButton x:Name="AutomaticMode" Content="Автоматически" IsChecked="True" Checked="LoadMode_Changed"/><RadioButton x:Name="ListMode" Content="По списку" Margin="22,0,0,0" Checked="LoadMode_Changed"/></StackPanel></StackPanel><Button Grid.Column="1" x:Name="SelectProjectButton" Content="Выбрать проект" IsEnabled="False" VerticalAlignment="Center" Margin="16,0,0,0" Click="SelectProject_Click"/></Grid></GroupBox><GroupBox Grid.Row="3" Header="Список плагинов" Margin="0,10,0,0"><StackPanel Margin="8"><TextBlock x:Name="PluginStatus" Text="Список будет определён после выбора проекта." TextWrapping="Wrap"/><ListBox x:Name="Plugins" SelectionMode="Multiple" DisplayMemberPath="Id" Margin="0,8,0,0" Height="110" IsEnabled="False"/></StackPanel></GroupBox><StackPanel Grid.Row="4" Margin="0,14,0,0"><WrapPanel><Button x:Name="StartButton" Content="Выбрать плагины и приступить к работе" IsEnabled="False" Click="Start_Click"/><Button Content="Справка" Margin="8,0,0,0" Click="Help_Click"/><Button Content="Диагностика" Margin="8,0,0,0" Click="Diagnostics_Click"/></WrapPanel><TextBlock x:Name="Status" Margin="0,10,0,0" TextWrapping="Wrap"/><TextBlock x:Name="Version" Margin="0,5,0,0"/></StackPanel></Grid></Window>
+221 -1
View File
@@ -1 +1,221 @@
namespace Launcher.Next.App; public partial class MainWindow : System.Windows.Window { public MainWindow() => InitializeComponent(); }
using System;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using Launcher.Next.Application;
using Launcher.Next.Autodesk;
namespace Launcher.Next.App;
public partial class MainWindow : System.Windows.Window
{
private sealed record LauncherProduct(string Id, string Name, string? Executable, string Diagnostic);
private readonly LocalParityLaunchProfile? localProfile;
private readonly LocalParityLaunchService localService = new();
private readonly LegacyConfigurationAdapter adapter = new();
private LauncherProduct? selected;
private string? projectPath;
private string? manualRosterPath;
private string? selectedRosterSnapshotPath;
private string stage = "ProductSelection";
private string code = "PRODUCT_NOT_SELECTED";
private string detail = "";
private string pattern = "";
private string expectedRoster = "";
private string orderedIds = "";
private string mappingState = "NotEvaluated";
private string preparationState = "NotStarted";
private string activationState = "NotStarted";
private string bootstrapState = "NotStarted";
private string processState = "NotStarted";
private int? processId;
private string packageLoadMarker = "NotStarted";
public MainWindow() { InitializeComponent(); Initialize(); }
internal MainWindow(LocalParityLaunchProfile profile) { localProfile = profile; InitializeComponent(); Initialize(); }
private void Initialize()
{
var plant = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
var navisworks = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Autodesk", "Navisworks Manage 2026", "Roamer.exe");
Products.ItemsSource = new[]
{
new LauncherProduct("navisworks", "Navisworks Manage 2026", File.Exists(navisworks) ? navisworks : null, File.Exists(navisworks) ? "Найден" : "Navisworks Manage 2026 не найден"),
new LauncherProduct("pnid", "Plant P&ID 2026", plant?.ExecutablePath, plant is null ? "Plant P&ID 2026 не найден" : "Используется установленный Plant runtime"),
new LauncherProduct("plant3d", "Plant 3D 2026", plant?.ExecutablePath, plant is null ? "Plant 3D 2026 не найден" : "Найден")
};
Version.Text = "Версия: " + typeof(MainWindow).Assembly.GetName().Version;
Transition("ProductSelection", "PRODUCT_NOT_SELECTED", "Выберите программное обеспечение");
if (localProfile is not null) Products.SelectedIndex = 2;
}
private void Products_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
selected = Products.SelectedItem as LauncherProduct;
projectPath = null;
manualRosterPath = null;
ClearOperationState();
ProjectPath.Text = selected is null ? "Выберите программное обеспечение" : "Проект не выбран";
SelectProjectButton.IsEnabled = selected?.Executable is not null;
ProductStatus.Text = selected?.Diagnostic ?? "";
Transition(selected?.Executable is null ? "ProductSelectionFailed" : "ProjectSelection", selected?.Executable is null ? "PRODUCT_NOT_FOUND" : "PROJECT_NOT_SELECTED", selected?.Executable is null ? selected?.Diagnostic ?? "Продукт не найден" : "Выберите проект");
}
private void SelectProject_Click(object sender, System.Windows.RoutedEventArgs e) => SelectProjectFile_Click(sender, e);
private void SelectProjectFile_Click(object sender, System.Windows.RoutedEventArgs e)
{
if (selected is null) return;
var product = LegacyProductSelections.For(selected.Id);
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите проект", Filter = product.Filter, CheckFileExists = true, Multiselect = false };
if (dialog.ShowDialog() != true) return;
ClearOperationState();
try
{
Transition("ProjectSelected", "PROJECT_SELECTED", "Проверка проекта");
projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName);
manualRosterPath = null;
ProjectPath.Text = Path.GetFileName(projectPath);
Transition("ProjectValidated", "PROJECT_VALID", "Поиск списка плагинов");
ResolvePlugins();
}
catch (Exception ex) { ReportFailure(ex, "ProjectValidationFailed"); }
}
private void LoadMode_Changed(object sender, System.Windows.RoutedEventArgs e)
{
if (projectPath is null || selected is null) return;
if (ListMode.IsChecked == true)
{
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите download_list.xml", Filter = "download_list.xml (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false };
if (dialog.ShowDialog() != true) { AutomaticMode.IsChecked = true; return; }
manualRosterPath = dialog.FileName;
}
else manualRosterPath = null;
ClearOperationState();
ResolvePlugins();
}
private void ResolvePlugins()
{
if (selected is null || projectPath is null) return;
try
{
var product = LegacyProductSelections.For(selected.Id);
pattern = localProfile is not null && selected.Id == "plant3d" ? localProfile.PatternName : product.Pattern;
expectedRoster = manualRosterPath ?? LegacyProductSelections.AutomaticRoster(product, projectPath);
Transition("AutomaticRosterPathResolved", "AUTOMATIC_ROSTER_PATH_RESOLVED", "Поиск списка плагинов", expectedRoster);
var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter, localProfile?.Root);
orderedIds = string.Join(", ", plan.OrderedIds);
selectedRosterSnapshotPath = plan.SnapshotPath ?? plan.RosterPath;
if (plan.SnapshotSha256 is not null) detail = $"Roster snapshot SHA-256: {plan.SnapshotSha256}";
Plugins.ItemsSource = plan.OrderedIds;
Plugins.IsEnabled = false;
PluginStatus.Text = "Источник списка: " + (manualRosterPath is null ? "Автоматически" : "Выбран вручную");
Transition("OrderedPluginIdsResolved", "PLUGIN_PLAN_RESOLVED", "Плагины определены");
mappingState = plan.UnmappedIds.Count == 0 ? "Mapped" : "Unmapped: " + string.Join(", ", plan.UnmappedIds);
if (plan.UnmappedIds.Count > 0)
{
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", $"Для {product.DisplayName} отсутствуют пакеты 2026: {string.Join(", ", plan.UnmappedIds)}");
return;
}
if (localProfile is null || selected.Id != "plant3d")
{
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026");
return;
}
var unmapped = EvaluateLocalProfileDefinitions(plan);
if (unmapped.Count > 0)
{
mappingState = "Known legacy plugin, package 2026 absent: " + string.Join(", ", unmapped);
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для части плагинов проекта пока отсутствуют пакеты версии 2026");
return;
}
mappingState = "Mapped";
StartButton.IsEnabled = true;
Transition("Ready", "READY", "Готово к запуску");
}
catch (Exception ex) { ReportFailure(ex, "RosterResolutionFailed"); }
}
private IReadOnlyList<string> EvaluateLocalProfileDefinitions(LegacyPluginPlan plan)
{
var settings = adapter.ParseSettings(File.ReadAllText(localProfile!.SettingsPath));
var selectedPattern = settings.Patterns.SingleOrDefault(x => string.Equals(x.Name, localProfile.PatternName, StringComparison.Ordinal));
if (selectedPattern is null) throw new InvalidOperationException("LOCAL_PARITY_PATTERN_NOT_FOUND");
var overlay = LegacyCompatibilityOverlayParser.Parse(File.ReadAllText(localProfile.OverlayPath));
var missing = new List<string>();
foreach (var id in plan.OrderedIds.Distinct(StringComparer.OrdinalIgnoreCase))
{
var definitions = selectedPattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
if (definitions.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
if (definitions.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION");
if (!definitions[0].IsAllowedOn(Environment.MachineName)) { missing.Add(id + " (machine restricted)"); continue; }
if (!overlay.Plugins.Any(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase))) missing.Add(id);
}
return missing;
}
private async void Start_Click(object sender, System.Windows.RoutedEventArgs e)
{
if (localProfile is null || selected?.Id != "plant3d" || projectPath is null || !StartButton.IsEnabled) return;
StartButton.IsEnabled = false;
try
{
preparationState = "InProgress";
Transition("PackagePreparation", "PACKAGE_PREPARING", "Подготовка плагинов");
var profile = localProfile with { DownloadListPath = selectedRosterSnapshotPath ?? localProfile.DownloadListPath };
var prepared = await localService.PrepareAsync(profile, CancellationToken.None);
preparationState = "Succeeded";
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);
processId = result.ProcessId;
processState = result.StartupVerified ? "Started" : "ExitedEarly";
packageLoadMarker = result.PackageLoadConfirmed ? "AssemblyInitializeCompleted" : "Missing";
if (result.TerminalState == "Succeeded") Transition("Ready", "PACKAGE_LOAD_CONFIRMED", $"Plant 3D готов к работе (PID {result.ProcessId})");
else Transition("PackageLoadFailed", "PACKAGE_LOAD_NOT_CONFIRMED", "Plant 3D запущен, но автоматическая загрузка плагинов не подтверждена");
}
catch (Exception ex) { ReportFailure(ex, "PackagePreparationFailed"); }
finally { StartButton.IsEnabled = stage == "Ready"; }
}
private void ClearOperationState()
{
Plugins.ItemsSource = null;
Plugins.IsEnabled = false;
StartButton.IsEnabled = false;
pattern = expectedRoster = orderedIds = detail = "";
selectedRosterSnapshotPath = null;
mappingState = preparationState = activationState = bootstrapState = processState = "NotStarted";
processId = null;
packageLoadMarker = "NotStarted";
}
private void Transition(string nextStage, string nextCode, string message, string? technicalDetail = null)
{
stage = nextStage;
code = nextCode;
detail = technicalDetail ?? "";
Status.Text = message;
}
private void ReportFailure(Exception exception, string fallbackStage)
{
var failure = LauncherOperationFailures.Classify(exception.Message, fallbackStage, fallbackStage == "ProcessStartFailed");
Transition(failure.Stage, failure.Code, failure.UserMessage, exception.ToString());
}
private void Help_Click(object sender, System.Windows.RoutedEventArgs e) => System.Windows.MessageBox.Show("Выберите ПО и project file. В автоматическом режиме Launcher использует список плагинов, определённый проектом. Режим \"По списку\" позволяет явно выбрать download_list.xml.", "Справка");
private void Diagnostics_Click(object sender, System.Windows.RoutedEventArgs e)
{
var report = $"Launcher version: {typeof(MainWindow).Assembly.GetName().Version}\nSelected product: {selected?.Name}\nDiscovery: {selected?.Diagnostic}\nSelected project: {projectPath}\nProject validation: {(projectPath is null ? "NotStarted" : "Passed")}\nSelected mode: {(AutomaticMode.IsChecked == true ? "Automatic" : "Manual")}\nPattern: {pattern}\nExpected download list: {expectedRoster}\nOrdered legacy IDs: {orderedIds}\nDefinition join: {mappingState}\nPreparation: {preparationState}\nActivation: {activationState}\nBootstrap: {bootstrapState}\nProcess start: {processState}\nPID: {processId}\nPackage-load marker: {packageLoadMarker}\nLast error stage: {stage}\nLast error code: {code}\nTechnical detail: {detail}";
System.Windows.MessageBox.Show(report, "Диагностика Launcher Next");
}
}
+28 -9
View File
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.IO;
using Launcher.Next.Core;
@@ -8,22 +9,40 @@ namespace Launcher.Next.App;
internal static class PackageLifecyclePilotCli
{
private sealed class Fault(PackageLifecycleFaultPoint point) : IPackageLifecycleFaultInjector
{ public void ThrowIfRequested(PackageLifecycleFaultPoint requested) { if(requested==point) throw new IOException($"INJECTED:{point}"); } }
public static async Task<int> RunAsync(string[] args)
{
if (args.Length != 4 || args[0] != "--mode" || args[1] != "PackageLifecyclePilot" || args[2] != "--root") return 2;
ResolvedPackagePilotRoot pilotRoot; try { pilotRoot=new PathResolver().ResolvePackagePilotRoot(args[3]); } catch { return 2; }
var root=pilotRoot.Value; var id=Guid.NewGuid().ToString("N"); var evidenceDir=Path.Combine(root,"evidence",id); Directory.CreateDirectory(evidenceDir);
ResolvedPackageLifecycleRoots roots; try { roots=new PathResolver().ResolvePackageLifecycleRoots(args[3]); } catch { return 2; }
var id=Guid.NewGuid().ToString("N"); var started=DateTimeOffset.UtcNow; var evidenceDir=Path.Combine(roots.Evidence.Value,id);
Directory.CreateDirectory(evidenceDir); Directory.CreateDirectory(roots.Catalog.Value); Directory.CreateDirectory(Path.Combine(roots.Pilot.Value,"foreign-staging-fixture"));
try
{
var catalog=Path.Combine(root,"catalog"); Directory.CreateDirectory(catalog); var store=new LocalPackageStore(root,new Sha256FileHasher());
async Task<(ImmutableLocalPackage,PackageManifest)> Materialize(string version,byte seed)
{ var a=new[]{seed,(byte)(seed+1)}; var b=new[]{(byte)(seed+2)}; await File.WriteAllBytesAsync(Path.Combine(catalog,"entry.dll"),a); await File.WriteAllBytesAsync(Path.Combine(catalog,"payload.dat"),b); var manifest=new PackageManifest(1,"Pilot.Package",version,"Plant3D",2026,"x64","entry.dll",[new("entry.dll",a.Length,Convert.ToHexString(SHA256.HashData(a))),new("payload.dat",b.Length,Convert.ToHexString(SHA256.HashData(b)))],new("none",""),RolloutChannel:"pilot"); return (await store.MaterializeAsync(manifest,catalog,CancellationToken.None),manifest); }
var store=new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher());
async Task<(ImmutableLocalPackage Package,PackageManifest Manifest)> Materialize(string version,byte seed,LocalPackageStore target)
{ var a=new[]{seed,(byte)(seed+1)};var b=new[]{(byte)(seed+2)};await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"entry.dll"),a);await File.WriteAllBytesAsync(Path.Combine(roots.Catalog.Value,"payload.dat"),b);var manifest=new PackageManifest(1,"Pilot.Package",version,"Plant3D",2026,"x64","entry.dll",[new("entry.dll",a.Length,Convert.ToHexString(SHA256.HashData(a))),new("payload.dat",b.Length,Convert.ToHexString(SHA256.HashData(b)))],new("none",""),RolloutChannel:"pilot");return(await target.MaterializeAsync(manifest,roots.Catalog.Value,CancellationToken.None),manifest); }
PackageRequirement Req(string v)=>new("Pilot.Package","Plant3D",2026,"x64","pilot",v,"pilot-roster",true);
var (a,ma)=await Materialize("1.0.0",1); var pointersAfterA=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var aa=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var repeat=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None);
var (b,mb)=await Materialize("2.0.0",4); var beforeB=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var ab=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var rollback=await store.RollbackAsync(ab.Receipt!,CancellationToken.None); var current=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None);
var evidence=new { schemaVersion=1,mode="PackageLifecyclePilot",operationId=id,terminalState=current=="1.0.0"?"Succeeded":"Blocked",sourceClassification="SyntheticLocal",rootFingerprint=Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(id+root))),packages=new[]{new{version=ma.Version,manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(ma)))},new{version=mb.Version,manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(mb)))}},pointers=new{afterMaterializeA=pointersAfterA,afterActivateA=aa.Outcome,repeatedA=repeat.Outcome,beforeActivateB=beforeB,afterActivateB=ab.Outcome,afterRollback=rollback.Outcome,current},sideEffects=new{corporateDfsUsed=false,corporateDfsWriteAttempted=false,autoCadLaunchAttempted=false,processLaunchAttempted=false,productionPointerMutationAttempted=false,productionProjectAccessed=false}};
var final=Path.Combine(evidenceDir,"package-lifecycle-pilot.json"); var temp=final+".tmp"; await File.WriteAllTextAsync(temp,JsonSerializer.Serialize(evidence)); File.Move(temp,final,true); return current=="1.0.0"?0:2;
var initial=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None);
var (a,ma)=await Materialize("1.0.0",1,store); var afterMaterializeA=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);
var aa=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None); var repeat=await store.ActivateImmutableAsync(a,Req("1.0.0"),CancellationToken.None);
var (b,mb)=await Materialize("2.0.0",4,store); var beforeB=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); await store.BindAsync(Req("2.0.0"),"2.0.0",CancellationToken.None);
var ab=await store.ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None); var rollback=await store.RollbackAsync(ab.Receipt!,CancellationToken.None); var current=await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None); var lkg=await store.ResolveAsync(Req("1.0.0"),CancellationToken.None);
async Task<string> ExpectedFault(PackageLifecycleFaultPoint point,string version)
{ try { await Materialize(version,9,new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(point))); return "UnexpectedSuccess"; } catch { return "Blocked"; } }
var materializationFault=await ExpectedFault(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation,"9.0.0");
string pointerFault; try { await new LocalPackageStore(roots.Pilot.Value,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.BeforePointerReplace)).ActivateImmutableAsync(b,Req("2.0.0"),CancellationToken.None);pointerFault="UnexpectedSuccess";}catch{pointerFault="Blocked";}
var foreign=Path.Combine(roots.Pilot.Value,"foreign-staging-fixture");var fake=new PackageStagingOwnership("foreign","Pilot.Package","0",foreign,DateTimeOffset.UtcNow,"foreign");var cleanup=(await store.CleanupOwnedStagingAsync(fake,CancellationToken.None)).Outcome;
var success=initial is null&&afterMaterializeA is null&&aa.Outcome=="Activated"&&repeat.Outcome=="AlreadyActiveExact"&&beforeB=="1.0.0"&&ab.Outcome=="Activated"&&rollback.Outcome=="RolledBack"&&current=="1.0.0"&&lkg?.Version=="1.0.0"&&materializationFault=="Blocked"&&pointerFault=="Blocked"&&cleanup=="OwnershipUnknown"&&Directory.Exists(foreign);
object Package(PackageManifest m)=>new{exactIdentity=new{m.PackageId,m.Product,m.ProductYear,m.Architecture,m.RolloutChannel,m.Version},manifestSha256=Convert.ToHexString(SHA256.HashData(JsonSerializer.SerializeToUtf8Bytes(m))),payloadSetSha256=PayloadSet(m),payloadFiles=m.Files.Select(x=>new{x.Path,x.Length,x.Sha256})};
var evidence=new { schemaVersion=2,launcherCommit=ThisCommit(),operationId=id,mode="PackageLifecyclePilot",startedUtc=started,endedUtc=DateTimeOffset.UtcNow,terminalState=success?"Succeeded":"Blocked",exitClassification=success?"CONTROLLED_FILESYSTEM_PILOT_VERIFIED":"BLOCKED",sourceClassification="SyntheticLocal",typedRoots=new[]{Role("Catalog",roots.Catalog.Value,id),Role("Staging",roots.Staging.Value,id),Role("VersionStore",roots.VersionStore.Value,id),Role("Active",roots.Active.Value,id),Role("Evidence",roots.Evidence.Value,id)},packages=new[]{Package(ma),Package(mb)},pointers=new{initial,afterMaterializeA,afterActivateA=aa.Outcome,generationA=aa.Receipt?.PointerGeneration,repeatedA=repeat.Outcome,beforeActivateB=beforeB,afterActivateB=ab.Outcome,generationB=ab.Receipt?.PointerGeneration,afterRollback=rollback.Outcome,current,contentBoundSchema=2},exactLkg=new{selected=lkg?.Version,directoryScanAttempted=false,exactBinding=lkg is not null},failureScenarios=new[]{new{faultPoint="AfterPayloadCopyBeforeValidation",actual=materializationFault,invariant=current=="1.0.0"},new{faultPoint="BeforePointerReplace",actual=pointerFault,invariant=(await store.GetCurrentVersionAsync("Pilot.Package",CancellationToken.None))=="1.0.0"},new{faultPoint="OwnershipMismatch",actual=cleanup,invariant=Directory.Exists(foreign)}},locking=new{scope="ProcessLocal",crossProcessSafety="NotVerified",networkShareSafety="NotSupported"},sideEffects=new{corporateDfsUsed=false,corporateDfsWriteAttempted=false,autoCadLaunchAttempted=false,processLaunchAttempted=false,productionPointerMutationAttempted=false,productionProjectAccessed=false,foundationUiCreated=false},claims=new{materializationIsolation="Verified",ownedStagingCleanup="Verified",immutableContentBinding="Verified",activation="Verified",activationIdempotency="Verified",receiptBoundRollback="Verified",deterministicFailureRecovery="Verified",exactLkg="Verified",processLocalConcurrency="Verified",corporateDfs="NotVerified",autoCadRuntime="NotVerified",productionAcceptance="NotVerified"}};
var final=Path.Combine(evidenceDir,"package-lifecycle-pilot.json");var temp=final+"."+id+".tmp";await using(var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)){await JsonSerializer.SerializeAsync(stream,evidence);await stream.FlushAsync();stream.Flush(true);}File.Move(temp,final);using var reread=JsonDocument.Parse(await File.ReadAllTextAsync(final));if(reread.RootElement.GetProperty("schemaVersion").GetInt32()!=2)return 10;return success?0:10;
}
catch { return 10; }
}
private static object Role(string role,string path,string salt)=>new{role,localOnly=true,topologyValidated=true,reparseRejected=true,fingerprint=Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt+Path.GetFullPath(path))))};
private static string PayloadSet(PackageManifest m)=>Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(string.Join("|",m.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")))));
private static string ThisCommit()=>Environment.GetEnvironmentVariable("LAUNCHER_COMMIT")??"WORKTREE_BUILD";
}
+23 -6
View File
@@ -32,6 +32,10 @@ internal static class PilotCli
if (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!);
if (!string.Equals(paths.LocalLauncherRoot, root, StringComparison.OrdinalIgnoreCase) || !string.Equals(config.PilotRoot, root, StringComparison.OrdinalIgnoreCase)) return Fail(Blocked, "PILOT_ROOT_CONFIGURATION_MISMATCH");
if (!TryBuildManifest(options.PackageSource!, options.EntryAssembly!, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
var startupRoster = StartupRosterPreflight.Inspect(options.PackageSource!, manifest!);
Directory.CreateDirectory(Path.Combine(root, "evidence"));
StartupRosterPreflight.Write(Path.Combine(root, "evidence", "startup-roster-preflight.json"), startupRoster);
if (!startupRoster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + startupRoster.Status.ToString().ToUpperInvariant());
Directory.CreateDirectory(Path.Combine(root, "catalog"));
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
if (!File.Exists(paths.AcadExecutable)) return Fail(DiscoveryFailed, "PLANT3D_2026_NOT_FOUND");
@@ -166,15 +170,20 @@ internal static class PilotCli
if (!TryResolvePaths(baseConfig, out var basePaths, out error)) return Fail(Blocked, error!);
if (baseConfig.LauncherMode != LauncherMode.NextPilot || !baseConfig.PilotEnabled || !baseConfig.AllowProcessStart || !baseConfig.RequireExplicitExecute || !baseConfig.ForceAfterEvidence) return Fail(Blocked, "AUTOMATION_SAFETY_GATE_REJECTED");
if (!TryBuildManifest(options.PackageSource, options.EntryAssembly, out var manifest, out var manifestHash, out error)) return Fail(Blocked, error!);
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
Directory.CreateDirectory(options.EvidenceRoot);
var roster = StartupRosterPreflight.Inspect(options.PackageSource, manifest!);
StartupRosterPreflight.Write(Path.Combine(options.EvidenceRoot, "startup-roster-preflight.json"), roster);
await journal.RecordAsync("StartupRosterPreflight", roster.IsSafe ? "Completed" : "Failed", roster.Status.ToString(), CancellationToken.None);
if (!roster.IsSafe) return Fail(Blocked, "CANDIDATE_ISOLATION_" + roster.Status.ToString().ToUpperInvariant());
if (Process.GetProcessesByName("acad").Any()) return Fail(AcadRunning, "ACAD_ALREADY_RUNNING");
await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.json");
var entryHash = manifest!.Files.Single(x => string.Equals(x.Path, options.EntryAssembly, StringComparison.OrdinalIgnoreCase)).Sha256;
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic }));
var targetCommand = baseConfig.AutomationSelection?.CommandName ?? "PXP-OA";
await File.WriteAllTextAsync(previewConfigPath, JsonSerializer.Serialize(new { phase = "Preview", targetCommand, orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = previewEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "preview-transcript.log"), diagnostic = options.Diagnostic }));
await File.WriteAllTextAsync(previewEvidence, "{\"completed\":false}");
var previewConfig = WithAutomationPaths(baseConfig, options, previewConfigPath, previewEvidence, null, "preview") with { PluginExecutionMode = PluginExecutionMode.DryRun, AllowApply = false, ApplyPermitPath = null, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = previewConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotPreview, AutomationDiagnostic = options.Diagnostic };
var previewSession = await RunAutomationSession(previewConfig, manifest!, manifestHash!, options, "preview", previewEvidence);
@@ -189,7 +198,7 @@ internal static class PilotCli
await File.WriteAllTextAsync(permitPath, JsonSerializer.Serialize(new ApplyPermit(true, options.FixtureMarker, preview.SelectedCandidateHash, preview.PlanIdRun1, "М25 DN15-32", options.AnnotationTemplateDwg)));
var applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json");
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.json");
await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false }));
await File.WriteAllTextAsync(applyConfigPath, JsonSerializer.Serialize(new { phase = "Apply", targetCommand, orthoDrawingPath = options.OrthoDrawing, modelDrawingPath = options.ModelDrawing, annotationTemplateDwgPath = options.AnnotationTemplateDwg, evidencePath = applyEvidence, fixtureMarkerPath = options.FixtureMarker, entryAssemblySha256 = entryHash, automationTranscriptPath = Path.Combine(options.EvidenceRoot, "apply-transcript.log"), diagnostic = false }));
await File.WriteAllTextAsync(applyEvidence, "{\"completed\":false}");
var applyConfig = WithAutomationPaths(baseConfig, options, applyConfigPath, applyEvidence, permitPath, "apply") with { PluginExecutionMode = PluginExecutionMode.Apply, AllowApply = true, ApplyPermitPath = permitPath, AutomationMode = "FirstLimitedApplyRuntimeTest", AutomationConfigurationPath = applyConfigPath, FixtureMarkerPath = options.FixtureMarker, LaunchMode = AcadLaunchMode.NextPilotApply, AutomationDiagnostic = false };
var applySession = await RunAutomationSession(applyConfig, manifest!, manifestHash!, options, "apply", applyEvidence);
@@ -229,9 +238,11 @@ internal static class PilotCli
// terminal evidence is durable. Killing an active AutoCAD
// command before that boundary would turn ForceAfterEvidence
// into an unsafe general process killer.
var evidenceTimeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
if (await Task.WhenAny(exit, evidenceTimeout) == evidenceTimeout) break;
// Evidence is written by the Plant process, not by a task owned
// by this host. Poll it with a bounded interval: waiting only on
// the timeout task delays an already durable result by a full
// timeout and makes the preview/apply hand-off unnecessarily slow.
await WaitForEvidenceOrExitAsync(exit, evidencePath, TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
if (exit.IsCompleted) gracefulExit = true;
else
{
@@ -289,6 +300,12 @@ internal static class PilotCli
private static bool HashesEqual(IReadOnlyDictionary<string,string> x, IReadOnlyDictionary<string,string> y) => x.Count == y.Count && x.All(p => y.TryGetValue(p.Key, out var value) && value == p.Value);
private static PilotExecutionService CreateService(ResolvedLauncherPaths paths) => new(new PackageManifestValidator(), new PilotRootPolicy(paths.LocalLauncherRoot), new NextPilotSafetyGate(), new Sha256FileHasher(), new StartupScriptBuilder(), PermitService, new Probe(), new OperatingSystemProcessInvoker(), new PathResolver());
private static async Task WaitForExitAsync(int processId) { using var process = Process.GetProcessById(processId); await process.WaitForExitAsync(); }
private static async Task WaitForEvidenceOrExitAsync(Task exit, string evidencePath, TimeSpan timeout)
{
var deadline = DateTimeOffset.UtcNow + timeout;
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath) && DateTimeOffset.UtcNow < deadline)
await Task.WhenAny(exit, Task.Delay(TimeSpan.FromMilliseconds(250)));
}
private static bool HasCompletedEvidence(string path)
{
try { using var json = JsonDocument.Parse(File.ReadAllText(path)); return json.RootElement.TryGetProperty("Completed", out var completed) && completed.ValueKind == JsonValueKind.True; }
@@ -0,0 +1,3 @@
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Launcher.Next.Tests")]
@@ -0,0 +1,22 @@
namespace Launcher.Next.Application;
public sealed record LauncherOperationFailure(string Stage, string Code, string UserMessage, bool ProcessStartAttempted);
public static class LauncherOperationFailures
{
public static LauncherOperationFailure Classify(string code, string fallbackStage, bool processStartAttempted = false) => code switch
{
"LEGACY_DOWNLOAD_LIST_NOT_FOUND" => new("RosterResolutionFailed", "AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", "Для выбранного проекта не найден список плагинов", false),
"DOWNLOAD_LIST_XML_MALFORMED" => new("PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED", "Некорректный download_list.xml", false),
"DOWNLOAD_LIST_ROOT_INVALID" => new("PluginPlanFailed", "DOWNLOAD_LIST_ROOT_INVALID", "download_list.xml имеет недопустимый корневой элемент", false),
"LEGACY_PLUGIN_ID_NOT_DEFINED" => new("PluginPlanFailed", "DOWNLOAD_LIST_ID_UNKNOWN", "В Settings.xml отсутствует описание плагина", false),
"AMBIGUOUS_LEGACY_PLUGIN_DEFINITION" => new("PluginPlanFailed", "DOWNLOAD_LIST_PLUGIN_DEFINITION_AMBIGUOUS", "Определение плагина в Settings.xml неоднозначно", false),
"LEGACY_PLUGIN_PRODUCT_MISMATCH" => new("PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH", "Список плагинов не совместим с выбранным продуктом", false),
"LEGACY_MAPPING_NOT_FOUND" => new("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026", false),
"LOCAL_PARITY_ROSTER_PROFILE_MISMATCH" => new("RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH", "Контролируемая конфигурация не соответствует выбранному проекту", false),
_ when code.Contains("PROCESS", StringComparison.Ordinal) || code.Contains("PLANT3D_2026_NOT_FOUND", StringComparison.Ordinal) => new("ProcessStartFailed", "PROCESS_START_FAILED", "Не удалось запустить Autodesk", true),
_ when code.Contains("ACTIVATION", StringComparison.Ordinal) => new("PackageActivationFailed", "PACKAGE_ACTIVATION_FAILED", "Не удалось активировать пакет", false),
_ when code.Contains("MANIFEST", StringComparison.Ordinal) => new("PackagePreparationFailed", "PACKAGE_MANIFEST_INVALID", "Пакет повреждён", false),
_ => new(fallbackStage, code, "Операция не выполнена: " + code, processStartAttempted)
};
}
@@ -18,7 +18,7 @@ public static class LegacyCompatibilityOverlayParser
if(document.Mappings.Count==0||document.Mappings.GroupBy(x=>x.MappingId,StringComparer.OrdinalIgnoreCase).Any(x=>x.Count()>1)) throw new InvalidOperationException("LEGACY_MAPPING_DUPLICATE");
var entries=document.Mappings.Select(x=>{
if(string.IsNullOrWhiteSpace(x.MappingId)||string.IsNullOrWhiteSpace(x.LegacyPluginId)||string.IsNullOrWhiteSpace(x.Package.Id)||x.Package.Version is "latest"||x.Package.Version.Contains('*')||x.Package.Version.Contains('>')||x.Package.Version.Contains('<')||!System.Text.RegularExpressions.Regex.IsMatch(x.Package.Version,@"^\d+\.\d+\.\d+(\.\d+)?$")) throw new InvalidOperationException("LEGACY_OVERLAY_MALFORMED");
if(!string.Equals(x.Package.Product,"Plant3D",StringComparison.Ordinal)||x.Package.ProductYear!=2026||!string.Equals(x.Package.Architecture,"x64",StringComparison.OrdinalIgnoreCase)||!new[]{"pilot","stable","development"}.Contains(x.Package.Channel,StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_MAPPING_INCOMPATIBLE");
if(!string.Equals(x.Package.Product,"Plant3D",StringComparison.Ordinal)||x.Package.ProductYear!=2026||!string.Equals(x.Package.Architecture,"x64",StringComparison.OrdinalIgnoreCase)||!new[]{"pilot","stable","development","controlled-local"}.Contains(x.Package.Channel,StringComparer.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_MAPPING_INCOMPATIBLE");
return new LegacyCompatibilityMapping(x.LegacyPluginId,x.Package.Id,x.Package.Version,x.Package.Channel);
}).ToArray();
var first=document.Mappings[0].Package;
@@ -4,10 +4,17 @@ using Launcher.Next.Core;
namespace Launcher.Next.Application;
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder);
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins);
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder, IReadOnlyList<string>? Machines = null)
{
public bool IsAllowedOn(string machineName) => Machines is null || Machines.Count == 0 || Machines.Contains(machineName, StringComparer.OrdinalIgnoreCase);
}
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins, IReadOnlyList<string>? Attributes = null, IReadOnlyList<string>? Sources = null, string? Target = null, string? Loader = null, string? ObjectPath = null);
public sealed record LegacyLauncherSettings(string Help, string CareMarker, IReadOnlyList<LegacyProductPattern> Patterns);
public sealed record LegacyDownloadSelection(IReadOnlyList<string> PluginIds);
public sealed record LegacyDownloadListEntry(int ElementIndex, string RawId, string NormalizedId, int? FirstOccurrenceIndex, string SourceShape);
public sealed record LegacyDownloadSelection(IReadOnlyList<string> PluginIds, IReadOnlyList<LegacyDownloadListEntry>? Entries = null)
{
public IReadOnlyList<string> EffectivePluginIds => PluginIds.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel);
public sealed record LegacyCompatibilityOverlay(string MappingId, string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, IReadOnlyList<LegacyCompatibilityMapping> Plugins);
@@ -27,26 +34,51 @@ public sealed class LegacyConfigurationAdapter
plugin.Element("id")?.Value ?? throw new InvalidOperationException("LEGACY_PLUGIN_ID_MISSING"),
plugin.Element("renewal")?.Value ?? throw new InvalidOperationException("LEGACY_RENEWAL_MISSING"),
plugin.Element("catalog")?.Value ?? throw new InvalidOperationException("LEGACY_CATALOG_MISSING"),
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index)).ToArray();
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins);
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index,
plugin.Elements("machine").Select(x => x.Value.Trim()).Where(x => x.Length > 0).ToArray())).ToArray();
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins,
pattern.Elements("attrib").Select(x => x.Value).ToArray(),
pattern.Elements("source").Elements("course").Select(x => x.Value).ToArray(),
pattern.Element("target")?.Value, pattern.Element("loader")?.Value, pattern.Element("object")?.Value);
}).ToArray();
return new(path.Element("help")?.Value ?? throw new InvalidOperationException("LEGACY_HELP_MISSING"), path.Element("care")?.Value ?? throw new InvalidOperationException("LEGACY_CARE_MISSING"), patterns);
}
public LegacyDownloadSelection ParseDownloadList(string xml)
{
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root;
if (root?.Name != "root") throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_INVALID");
var ids = root.Elements("id").Select(x => x.Value.Trim()).ToArray();
if (ids.Length == 0 || ids.Any(string.IsNullOrWhiteSpace) || ids.GroupBy(x => x, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1))
throw new InvalidOperationException("LEGACY_DOWNLOAD_SELECTION_INVALID");
return new(ids);
try
{
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings);
var root = XDocument.Load(reader).Root;
if (root?.Name != "root") throw new InvalidOperationException("DOWNLOAD_LIST_ROOT_INVALID");
// F2_Xml serialized only root/id and removed only exact empty strings. Real project files
// also contain source-grouped <area><id> entries; preserve their document order as the
// modern plan while retaining raw values and duplicate evidence.
var direct = root.Elements("id").Select(x => (Element: x, Shape: "Root"));
var grouped = root.Elements("area").SelectMany(area => area.Elements("id").Select(x => (Element: x, Shape: "Area")));
var entries = (direct.Any() ? direct : grouped).Select((entry, index) =>
{
var raw = entry.Element.Value;
var normalized = raw; // legacy F2_Xml did not Trim or case-normalize IDs.
return new LegacyDownloadListEntry(index, raw, normalized, null, entry.Shape);
}).ToArray();
var seen = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var annotated = entries.Select(entry =>
{
if (entry.RawId == "") return entry;
if (seen.TryGetValue(entry.NormalizedId, out var first)) return entry with { FirstOccurrenceIndex = first };
seen[entry.NormalizedId] = entry.ElementIndex;
return entry;
}).ToArray();
return new(annotated.Where(x => x.RawId != "").Select(x => x.RawId).ToArray(), annotated);
}
catch (XmlException) { throw new InvalidOperationException("DOWNLOAD_LIST_XML_MALFORMED"); }
}
public IReadOnlyList<PackageRequirement> Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay)
{
var result = new List<PackageRequirement>();
foreach (var id in selection.PluginIds)
foreach (var id in selection.EffectivePluginIds)
{
var definition = pattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray();
if (definition.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED");
@@ -0,0 +1,88 @@
using System.Security.Cryptography;
namespace Launcher.Next.Application;
public sealed record LegacySettingsSnapshot(string SourceEndpoint, string SourceFingerprint, string Sha256, long Length, DateTimeOffset LastWriteUtc, DateTimeOffset AcquiredUtc, string LocalSnapshotPath, bool OfflineFallback);
public static class LegacyNetworkConfiguration
{
public const string ApprovedSettingsEndpoint = @"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml";
public const string LegacyBimAlias = @"N:\BIM";
public const string ApprovedBimEndpoint = @"\\phimproject.com\common\BIM";
public static string ResolveApprovedAlias(string legacyPath)
{
if (string.IsNullOrWhiteSpace(legacyPath)) throw new InvalidOperationException("LEGACY_NETWORK_PATH_INVALID");
var normalized = legacyPath.Replace('/', '\\');
if (string.Equals(normalized, LegacyBimAlias, StringComparison.OrdinalIgnoreCase)) return ApprovedBimEndpoint;
var prefix = LegacyBimAlias + "\\";
if (!normalized.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_NETWORK_ALIAS_NOT_APPROVED");
return ApprovedBimEndpoint + normalized[(prefix.Length - 1)..];
}
public static LegacySettingsSnapshot AcquireApprovedSnapshot(string endpoint, string localRoot)
{
if (!string.Equals(endpoint, ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED");
if (!Path.IsPathFullyQualified(endpoint) || !endpoint.StartsWith(@"\\", StringComparison.Ordinal) || endpoint.Contains('?') || endpoint.Contains('*')) throw new InvalidOperationException("LEGACY_SETTINGS_ENDPOINT_INVALID");
if (!File.Exists(endpoint) || File.GetAttributes(endpoint).HasFlag(FileAttributes.ReparsePoint)) return OfflineSnapshot(endpoint, localRoot);
var first = new FileInfo(endpoint);
var sourceHashBefore = Hash(endpoint);
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
Directory.CreateDirectory(snapshots);
var temporary = Path.Combine(snapshots, Guid.NewGuid().ToString("N") + ".tmp");
File.Copy(endpoint, temporary, false);
var copiedHash = Hash(temporary);
var after = new FileInfo(endpoint);
var sourceHashAfter = Hash(endpoint);
if (first.Length != after.Length || first.LastWriteTimeUtc != after.LastWriteTimeUtc || !string.Equals(sourceHashBefore, sourceHashAfter, StringComparison.Ordinal) || !string.Equals(copiedHash, sourceHashAfter, StringComparison.Ordinal))
{
File.Delete(temporary);
throw new InvalidOperationException("LEGACY_SETTINGS_CHANGED_DURING_ACQUISITION");
}
var final = Path.Combine(snapshots, copiedHash + ".xml");
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
if (!string.Equals(Hash(final), copiedHash, StringComparison.Ordinal)) throw new InvalidOperationException("LEGACY_SETTINGS_SNAPSHOT_HASH_MISMATCH");
return new(endpoint, Fingerprint(endpoint, copiedHash), copiedHash, first.Length, new DateTimeOffset(first.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
}
private static LegacySettingsSnapshot OfflineSnapshot(string endpoint, string localRoot)
{
var snapshots = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots");
if (!Directory.Exists(snapshots)) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
var candidate = Directory.GetFiles(snapshots, "*.xml").OrderByDescending(File.GetLastWriteTimeUtc).FirstOrDefault();
if (candidate is null) throw new InvalidOperationException("LEGACY_SETTINGS_NETWORK_UNAVAILABLE");
var info = new FileInfo(candidate);
var hash = Hash(candidate);
return new(endpoint, Fingerprint(endpoint, hash), hash, info.Length, new DateTimeOffset(info.LastWriteTimeUtc), DateTimeOffset.UtcNow, candidate, true);
}
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
private static string Fingerprint(string endpoint, string hash) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(endpoint + "|" + hash)));
}
public static class LegacyReadOnlySnapshots
{
public static LegacySettingsSnapshot Acquire(string source, string localRoot, string role)
{
if (!Path.IsPathFullyQualified(source) || !source.StartsWith(@"\\", StringComparison.Ordinal) || source.Contains('?') || source.Contains('*') || !File.Exists(source) || File.GetAttributes(source).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_INVALID");
var before = new FileInfo(source);
var beforeHash = Hash(source);
var directory = Path.Combine(Path.GetFullPath(localRoot), "configuration-snapshots", role);
Directory.CreateDirectory(directory);
var temporary = Path.Combine(directory, Guid.NewGuid().ToString("N") + ".tmp");
File.Copy(source, temporary, false);
var copiedHash = Hash(temporary);
var after = new FileInfo(source);
var afterHash = Hash(source);
if (before.Length != after.Length || before.LastWriteTimeUtc != after.LastWriteTimeUtc || beforeHash != afterHash || copiedHash != afterHash)
{
File.Delete(temporary);
throw new InvalidOperationException("READONLY_SNAPSHOT_SOURCE_CHANGED");
}
var final = Path.Combine(directory, copiedHash + ".xml");
if (File.Exists(final)) File.Delete(temporary); else File.Move(temporary, final);
return new(source, Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(role + "|" + source + "|" + copiedHash))), copiedHash, before.Length, new DateTimeOffset(before.LastWriteTimeUtc), DateTimeOffset.UtcNow, final, false);
}
private static string Hash(string path) { using var stream = File.OpenRead(path); return Convert.ToHexString(SHA256.HashData(stream)); }
}
@@ -0,0 +1,38 @@
namespace Launcher.Next.Application;
public sealed record LegacyProductSelection(string Id, string DisplayName, string Filter, string RequiredFileName, string Pattern, string[] SourceVerifiedLegacyIds, string[] UnmappedLegacyIds, bool SupportsPlantBootstrap);
public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList<string> OrderedIds, IReadOnlyList<string> UnmappedIds, string? SnapshotPath = null, string? SnapshotSha256 = null);
public static class LegacyProductSelections
{
public static readonly LegacyProductSelection Plant3D = new("plant3d", "Plant 3D 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant 3D_2019", [], [], true);
public static readonly LegacyProductSelection Pnid = new("pnid", "Plant P&ID 2026", "Project files |Project.xml|All files (*.*)|*.*", "Project.xml", "Plant PnID_2019", ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], ["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], true);
public static readonly LegacyProductSelection Navisworks = new("navisworks", "Navisworks Manage 2026", "nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", ".nwd", "Navisworks_Manage_2018", ["7dd981169fa14d21aaef3fcdb0e7e970"], ["7dd981169fa14d21aaef3fcdb0e7e970"], false);
public static LegacyProductSelection For(string id) => id switch { "plant3d" => Plant3D, "pnid" => Pnid, "navisworks" => Navisworks, _ => throw new InvalidOperationException("LEGACY_PRODUCT_UNKNOWN") };
public static string ValidateSelectedFile(LegacyProductSelection product, string selected)
{
var file = Path.GetFullPath(selected);
if (!File.Exists(file) || File.GetAttributes(file).HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("LEGACY_PROJECT_FILE_INVALID");
var correctName = product.RequiredFileName.StartsWith('.') ? file.EndsWith(product.RequiredFileName, StringComparison.OrdinalIgnoreCase) : string.Equals(Path.GetFileName(file), product.RequiredFileName, StringComparison.OrdinalIgnoreCase);
if (!correctName) throw new InvalidOperationException("LEGACY_PROJECT_FILE_PRODUCT_MISMATCH");
return file;
}
public static string AutomaticRoster(LegacyProductSelection product, string selectedFile)
{
var projectDirectory = Path.GetDirectoryName(ValidateSelectedFile(product, selectedFile))!;
return Path.GetFullPath(Path.Combine(projectDirectory, "..", "_System", product.Pattern, "Applications", "download_list.xml"));
}
public static LegacyPluginPlan ReadRoster(LegacyProductSelection product, string selectedFile, string? manualXml, LegacyConfigurationAdapter parser, string? snapshotRoot = null)
{
var roster = manualXml is null ? AutomaticRoster(product, selectedFile) : Path.GetFullPath(manualXml);
if (!File.Exists(roster) || File.GetAttributes(roster).HasFlag(FileAttributes.ReparsePoint) || !string.Equals(Path.GetFileName(roster), "download_list.xml", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_NOT_FOUND");
var snapshot = roster.StartsWith(@"\\", StringComparison.Ordinal) && snapshotRoot is not null ? LegacyReadOnlySnapshots.Acquire(roster, snapshotRoot, "project-rosters") : null;
var ids = parser.ParseDownloadList(File.ReadAllText(snapshot?.LocalSnapshotPath ?? roster)).PluginIds;
if (product.SourceVerifiedLegacyIds.Length > 0 && ids.Any(id => !product.SourceVerifiedLegacyIds.Contains(id, StringComparer.OrdinalIgnoreCase))) throw new InvalidOperationException("LEGACY_PLUGIN_PRODUCT_MISMATCH");
return new(roster, manualXml is null ? "Automatic" : "Manual", ids, product.UnmappedLegacyIds.Intersect(ids, StringComparer.OrdinalIgnoreCase).ToArray(), snapshot?.LocalSnapshotPath, snapshot?.Sha256);
}
}
+24 -8
View File
@@ -7,9 +7,12 @@ public sealed class PlantProductDiscovery(IRegistryReader registry) : IProductDi
{
public ProductInstallation? DiscoverPlant3D(int year)
{
var install = registry.GetValue($@"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-xxxx:409", "InstallDir") as string;
if (string.IsNullOrWhiteSpace(install)) return null; var exe = Path.Combine(install, "acad.exe");
return File.Exists(exe) ? new(exe, year.ToString(), "x64", []) : null;
if (year != 2026) return null;
const string plantInstall = @"SOFTWARE\Autodesk\AutoCAD\R25.1\ACAD-9117\Install";
var install = registry.GetValue(plantInstall, "InstallDir") as string;
var exe = string.IsNullOrWhiteSpace(install) ? null : Path.Combine(install, "acad.exe");
if (exe is null || !File.Exists(exe) || !Directory.Exists(Path.Combine(install!, "PLNT3D"))) return null;
return new(exe, "2026", "x64", []);
}
}
public sealed class StartupScriptBuilder : IStartupScriptBuilder
@@ -25,14 +28,27 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
var diagnostic = paths.Diagnostic
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
: string.Empty;
var commands = paths.Apply
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
: paths.Diagnostic
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
: "PXP-OA\nPXP-OA\n";
var commands = paths.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths);
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
}
private static string BuildDefaultCommands(ResolvedAutomationScriptPaths paths) => paths.Apply
? "_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK\n"
: paths.Diagnostic
? "PHP_REC_AUTOMATION_BEFORE_PXP_1\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_1\nPHP_REC_AUTOMATION_BEFORE_PXP_2\nPXP-OA\nPHP_REC_AUTOMATION_AFTER_PXP_2\n"
: "PXP-OA\nPXP-OA\n";
private static string BuildSelectedEntityCommands(ResolvedAutomationScriptPaths paths)
{
var selection = paths.Selection!;
selection.Validate();
var invoke = "(setq P_REC_ANNOT_SELECTED_ENTITY (handent \"" + selection.ObjectHandle.ToUpperInvariant() + "\"))\n" +
"(if (and P_REC_ANNOT_SELECTED_ENTITY (= (cdr (assoc 0 (entget P_REC_ANNOT_SELECTED_ENTITY))) \"" + selection.ExpectedDxfName.ToUpperInvariant() + "\")) " +
"(command-s \"" + selection.CommandName.ToUpperInvariant() + "\" P_REC_ANNOT_SELECTED_ENTITY) " +
"(progn (prompt \"\\nP_REC_ANNOT_SELECTION_REJECTED\") (quit)))\n";
return paths.Apply ? "_.UNDO\n_MARK\n" + invoke + invoke + "_.UNDO\n_BACK\n" : invoke + invoke;
}
}
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
{
+4 -3
View File
@@ -17,13 +17,14 @@ public sealed record ValidationResult(IReadOnlyList<ValidationIssue> Issues)
{ public bool IsValid => Issues.Count == 0; public static ValidationResult Success { get; } = new([]); }
public sealed record PackageProgress(string Stage, int Completed, int Total);
public sealed record ActivationResult(string CurrentVersion, string? PreviousVersion);
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 1, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null);
public sealed record PackagePayloadBinding(string RelativePath,long Length,string Sha256);
public sealed record PackageActivationReceipt(string OperationId, string PackageId, string Version, string? PreviousVersion, DateTimeOffset ActivatedUtc, long PointerGeneration = 0, string? ActiveRootIdentity = null, int SchemaVersion = 2, string? ManifestSha256 = null, string? PayloadSetSha256 = null, string? RosterIdentity = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = null, long PointerGenerationBefore = 0, string? EvidenceCorrelationId = null);
public sealed record PackageActivationResult(string Outcome, PackageActivationReceipt? Receipt);
public sealed record PackageRollbackResult(string Outcome, string? CurrentVersion);
public sealed record ImmutableLocalPackage(string PackageId, string Version, string LocalPackageRoot);
public sealed record PackageStagingOwnership(string OperationId, string PackageId, string Version, string StagingDirectory, DateTimeOffset CreatedUtc, string MarkerIdentity);
public sealed record PackageStagingCleanupResult(string Outcome, string OperationId);
public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, BeforeImmutableCommit, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, DuringOwnedStagingCleanup }
public enum PackageLifecycleFaultPoint { AfterStagingAllocated, DuringPayloadCopy, AfterPayloadCopyBeforeValidation, BeforeImmutableCommit, AfterImmutableCommitBeforeReturn, BeforePointerTempWrite, BeforePointerReplace, BeforePointerReadback, BeforeRollbackReplace, BeforeRollbackReadback, DuringOwnedStagingCleanup }
public interface IPackageLifecycleFaultInjector { void ThrowIfRequested(PackageLifecycleFaultPoint point); }
public sealed class NoOpPackageLifecycleFaultInjector : IPackageLifecycleFaultInjector { public void ThrowIfRequested(PackageLifecycleFaultPoint point) { } }
public interface IPackageMaterializer { Task<ImmutableLocalPackage> MaterializeAsync(PackageManifest manifest, string sourceRoot, CancellationToken cancellationToken, IProgress<PackageProgress>? progress = null); }
@@ -47,7 +48,7 @@ public sealed record CachedPackageResolution(string PackageId, string Version, s
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply);
public sealed record ResolvedAutomationScriptPaths(string ValidatedPackageRoot, string EntryAssembly, string FixtureOrthoDwg, string FixtureModelDwg, string AutomationScriptPath, bool Diagnostic, bool Apply, ControlledEntitySelection? Selection = null);
public interface IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
+19 -1
View File
@@ -37,6 +37,15 @@ public sealed record ResolvedPackageStagingRoot { internal ResolvedPackageStagin
public sealed record ResolvedActivePackageRoot { internal ResolvedActivePackageRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageLifecycleEvidenceRoot { internal ResolvedPackageLifecycleEvidenceRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackagePilotRoot { internal ResolvedPackagePilotRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageCatalogRoot { internal ResolvedPackageCatalogRoot(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedImmutablePackagePath { internal ResolvedImmutablePackagePath(string value)=>Value=value; public string Value{get;} }
public sealed record ResolvedPackageLifecycleRoots(
ResolvedPackagePilotRoot Pilot,
ResolvedPackageCatalogRoot Catalog,
ResolvedPackageStagingRoot Staging,
ResolvedPackageVersionStoreRoot VersionStore,
ResolvedActivePackageRoot Active,
ResolvedPackageLifecycleEvidenceRoot Evidence);
public interface IPathResolver
{
@@ -48,6 +57,7 @@ public interface IPathResolver
ResolvedLegacyCompatibilityOverlayPath ResolveLegacyCompatibilityOverlayPath(LauncherPathVariables variables);
ResolvedLegacyProbeEvidenceRoot ResolveLegacyProbeEvidenceRoot(LauncherPathVariables variables);
ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value);
ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value);
}
public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environment = null) : IPathResolver
@@ -117,7 +127,15 @@ public sealed class PathResolver(IReadOnlyDictionary<string, string?>? environme
return new(value);
}
public ResolvedPackagePilotRoot ResolvePackagePilotRoot(string value)
{ if (string.IsNullOrWhiteSpace(value)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); }
{ if (string.IsNullOrWhiteSpace(value)||Uri.IsWellFormedUriString(value,UriKind.Absolute)||value.Contains('*')||value.Contains('?')||value.StartsWith(@"\\")||!Path.IsPathFullyQualified(value)) throw new InvalidOperationException("PACKAGE_PILOT_ROOT_NOT_LOCAL"); var full=Path.GetFullPath(value); var info=new DirectoryInfo(full); if(info.Exists&&info.Attributes.HasFlag(FileAttributes.ReparsePoint)) throw new InvalidOperationException("PACKAGE_ROOT_REPARSE_REJECTED"); return new(full); }
public ResolvedPackageLifecycleRoots ResolvePackageLifecycleRoots(string value)
{
var pilot=ResolvePackagePilotRoot(value); string Child(string name)=>Path.GetFullPath(Path.Combine(pilot.Value,name));
var paths=new[]{Child("catalog"),Child("staging"),Child("version-store"),Child("active"),Child("evidence")};
if(paths.Distinct(StringComparer.OrdinalIgnoreCase).Count()!=paths.Length) throw new InvalidOperationException("PACKAGE_ROOT_COLLISION");
foreach(var path in paths) if(!path.StartsWith(pilot.Value.TrimEnd(Path.DirectorySeparatorChar)+Path.DirectorySeparatorChar,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("PACKAGE_ROOT_TOPOLOGY_INVALID");
return new(pilot,new(paths[0]),new(paths[1]),new(paths[2]),new(paths[3]),new(paths[4]));
}
private string ResolveExistingFile(string role, LauncherPathVariables variables)
{
var value=ResolvePath(Resolve(variables,[role]).Require(role),variables);
+15
View File
@@ -21,6 +21,10 @@ public sealed record PilotLocalConfiguration
public bool AutomationDiagnostic { get; init; }
public string? AutomationConfigurationPath { get; init; }
public string? FixtureMarkerPath { get; init; }
// Controlled automation may reproduce an actual Editor.GetEntity boundary
// only with this immutable, explicitly typed fixture selection. It is never
// populated for normal production launches.
public ControlledEntitySelection? AutomationSelection { get; init; }
// Test-only: permits a pilot process to be terminated after its plugin has
// persisted completed automation evidence. It never represents graceful exit.
public bool ForceAfterEvidence { get; init; }
@@ -34,6 +38,16 @@ public sealed record PilotLocalConfiguration
public IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
public static PilotLocalConfiguration SafeDefault { get; } = new();
}
public sealed record ControlledEntitySelection(string CommandName, string ObjectHandle, string ExpectedDxfName)
{
public void Validate()
{
if (string.IsNullOrWhiteSpace(CommandName) || !CommandName.All(value => char.IsAsciiLetterOrDigit(value) || value is '-' or '_') ||
string.IsNullOrWhiteSpace(ObjectHandle) || ObjectHandle.Length > 32 || !ObjectHandle.All(Uri.IsHexDigit) ||
string.IsNullOrWhiteSpace(ExpectedDxfName) || !ExpectedDxfName.All(value => char.IsAsciiLetterOrDigit(value) || value == '_'))
throw new InvalidOperationException("AUTOMATION_ENTITY_SELECTION_INVALID");
}
}
public enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
{ public bool IsValid => Errors.Count == 0; }
@@ -83,6 +97,7 @@ public sealed class NextPilotSafetyGate : INextPilotSafetyGate
if (c.Configuration.PluginExecutionMode == PluginExecutionMode.Apply && (!Path.IsPathFullyQualified(c.Configuration.ApplyPermitPath ?? string.Empty) || (c.Configuration.ApplyPermitPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal))) v.Add("APPLY_PERMIT_PATH_REQUIRED");
var automation = c.Configuration.LaunchMode is AcadLaunchMode.NextPilotPreview or AcadLaunchMode.NextPilotApply or AcadLaunchMode.LifecycleProbe;
if (automation && (!string.Equals(c.Configuration.AutomationMode, "FirstLimitedApplyRuntimeTest", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.AutomationConfigurationPath ?? string.Empty) || (c.Configuration.AutomationConfigurationPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || !Path.IsPathFullyQualified(c.Configuration.FixtureMarkerPath ?? string.Empty) || (c.Configuration.FixtureMarkerPath ?? string.Empty).StartsWith("\\\\", StringComparison.Ordinal) || c.Configuration.ProductionDeploy)) v.Add("AUTOMATION_CONFIGURATION_INVALID");
try { c.Configuration.AutomationSelection?.Validate(); } catch (InvalidOperationException) { v.Add("AUTOMATION_ENTITY_SELECTION_INVALID"); }
if (!automation && c.Configuration.AutomationMode is not null) v.Add("AUTOMATION_MODE_MISMATCH");
if (c.Configuration.ForceAfterEvidence && !automation) v.Add("FORCED_CLEANUP_TEST_AUTOMATION_REQUIRED");
if (c.Product is null) v.Add("PRODUCT_NOT_FOUND"); else if (c.Product.ProductVersion != "2026" || c.Product.Architecture != "x64") v.Add("PRODUCT_NOT_PLANT3D_2026");
@@ -0,0 +1,102 @@
using System.Security.Cryptography;
using System.Text.Json;
namespace Launcher.Next.Core;
public enum StartupRosterCandidateStatus
{
CandidateOnly,
LegacyCandidatePresent,
ConflictingCandidatePresent,
UnexpectedAutoloadRegistration,
UnresolvedStartupEntry
}
public sealed record StartupRosterEntry(string Source, string? Registration, string? PackageId,
string? Version, string? EntryAssembly, string? EntryPath, string? EntrySha256,
bool Eligible, string LoadTiming);
public sealed record StartupRosterReport(StartupRosterCandidateStatus Status,
string RequestedPackageId, string RequestedVersion, string? RequestedEntrySha256,
IReadOnlyList<StartupRosterEntry> Entries, DateTimeOffset CreatedUtc)
{
public bool IsSafe => Status == StartupRosterCandidateStatus.CandidateOnly;
}
/// Read-only, bounded discovery of P_Rec_Annot startup candidates in controlled local roots.
public static class StartupRosterPreflight
{
public static StartupRosterReport Inspect(string requestedPackageRoot, PackageManifest requested,
IEnumerable<string>? additionalRoots = null)
{
var roots = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { Path.GetFullPath(requestedPackageRoot) };
foreach (var root in additionalRoots ?? DiscoverKnownRoots())
if (!string.IsNullOrWhiteSpace(root) && Directory.Exists(root)) roots.Add(Path.GetFullPath(root));
var entries = new List<StartupRosterEntry>();
var requestedPath = Path.Combine(Path.GetFullPath(requestedPackageRoot), requested.EntryAssembly);
var requestedHash = requested.Files.FirstOrDefault(f => f.Path.Equals(requested.EntryAssembly, StringComparison.OrdinalIgnoreCase))?.Sha256;
if (File.Exists(requestedPath))
entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
requested.EntryAssembly, requestedPath, Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(requestedPath))), true, "before-netload"));
else entries.Add(new("RequestedPackage", requested.Startup.Type + ":" + requested.Startup.Command, requested.PackageId, requested.Version,
requested.EntryAssembly, null, requestedHash, false, "before-netload"));
foreach (var root in roots) DiscoverRoot(root, requested, entries);
var eligible = entries.Where(e => e.Eligible).ToArray();
StartupRosterCandidateStatus status;
if (entries.Any(e => e.EntryPath is null)) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 0) status = StartupRosterCandidateStatus.UnresolvedStartupEntry;
else if (eligible.Length == 1 && string.Equals(eligible[0].EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& string.Equals(eligible[0].Version, requested.Version, StringComparison.OrdinalIgnoreCase)) status = StartupRosterCandidateStatus.CandidateOnly;
else if (eligible.Any(e => string.Equals(e.PackageId, requested.PackageId, StringComparison.OrdinalIgnoreCase)
&& !string.Equals(e.Version, requested.Version, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.ConflictingCandidatePresent;
else if (eligible.Any(e => !string.Equals(e.EntrySha256, requestedHash, StringComparison.OrdinalIgnoreCase))) status = StartupRosterCandidateStatus.LegacyCandidatePresent;
else status = StartupRosterCandidateStatus.UnexpectedAutoloadRegistration;
return new(status, requested.PackageId, requested.Version, requestedHash, entries, DateTimeOffset.UtcNow);
}
public static void Write(string path, StartupRosterReport report) =>
File.WriteAllText(path, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
private static IEnumerable<string> DiscoverKnownRoots()
{
var controlledTemp = string.Concat("C:", Path.DirectorySeparatorChar, "dev", Path.DirectorySeparatorChar, "TEMP");
foreach (var temp in new[] { Path.GetTempPath(), controlledTemp }.Distinct(StringComparer.OrdinalIgnoreCase))
if (Directory.Exists(temp))
foreach (var root in Directory.EnumerateDirectories(temp, "local-parity-launch-*", SearchOption.TopDirectoryOnly))
yield return Path.Combine(root, "Applications");
foreach (var root in new[] { Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData) + "\\Autodesk\\ApplicationPlugins",
Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData) + "\\Autodesk\\ApplicationPlugins" }) yield return root;
}
private static void DiscoverRoot(string root, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (!Directory.Exists(root)) return;
foreach (var dir in Directory.EnumerateDirectories(root, "P_Rec_Annot", SearchOption.TopDirectoryOnly))
{
var current = Path.Combine(dir, "current.json");
if (File.Exists(current))
{
using var doc = JsonDocument.Parse(File.ReadAllText(current));
var version = doc.RootElement.TryGetProperty("Current", out var c) ? c.GetString() : null;
var packageRoot = version is null ? null : Path.Combine(dir, "versions", version);
AddManifest(packageRoot, "LauncherCurrent", "netload", requested, entries);
}
else AddManifest(dir, "ApplicationPlugins", "autoload", requested, entries);
}
foreach (var bundle in Directory.EnumerateDirectories(root, "P_Rec_Annot*.bundle", SearchOption.TopDirectoryOnly)) AddManifest(bundle, "PackageContents", "autoload", requested, entries);
}
private static void AddManifest(string? packageRoot, string source, string registration, PackageManifest requested, List<StartupRosterEntry> entries)
{
if (packageRoot is null) { entries.Add(new(source, registration, "P_Rec_Annot", null, null, null, null, false, "before-netload")); return; }
var manifestPath = Path.Combine(packageRoot, "package.manifest.json");
if (!File.Exists(manifestPath)) { entries.Add(new(source, registration, "P_Rec_Annot", Path.GetFileName(packageRoot), null, null, null, false, "before-netload")); return; }
using var doc = JsonDocument.Parse(File.ReadAllText(manifestPath));
var root = doc.RootElement; string? S(string n) => root.TryGetProperty(n, out var p) ? p.GetString() : null;
var id = S("PackageId") ?? "P_Rec_Annot"; var version = S("Version"); var asm = S("EntryAssembly") ?? requested.EntryAssembly;
var path = Path.Combine(packageRoot, asm); string? hash = File.Exists(path) ? Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(path))) : null;
var eligible = File.Exists(path);
entries.Add(new(source, registration, id, version, asm, eligible ? path : null, hash, eligible, "before-netload"));
}
}
@@ -9,7 +9,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
{
// Machine-process local serialization only; package roots must remain local.
private static readonly SemaphoreSlim PointerGate = new(1, 1);
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 1, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null);
private sealed record Pointer(string Current, string? Previous, int SchemaVersion = 2, long Generation = 0, string? ActivationOperationId = null, DateTimeOffset? ActivatedAtUtc = null, string? ManifestSha256 = null, string? PayloadSetSha256 = null, IReadOnlyList<PackagePayloadBinding>? PayloadFiles = null);
private sealed record CacheBinding(string PackageId, string Product, int ProductYear, string Architecture, string Channel, string RequiredVersion, string RosterIdentity);
private const string StoredManifestFileName = "package.manifest.json";
private const string StoredBindingFileName = "package.binding.json";
@@ -20,7 +20,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
if (!File.Exists(PointerPath(id))) return null;
await using var stream = File.OpenRead(PointerPath(id));
var pointer = await JsonSerializer.DeserializeAsync<Pointer>(stream, cancellationToken: ct);
return pointer is null || pointer.SchemaVersion != 1 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer;
return pointer is null || pointer.SchemaVersion is < 1 or > 2 || string.IsNullOrWhiteSpace(pointer.Current) ? null : pointer;
}
public async Task<string?> GetCurrentVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Current;
public async Task<string?> GetPreviousVersionAsync(string id, CancellationToken ct) => (await ReadPointer(id, ct))?.Previous;
@@ -30,7 +30,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
{
var dir = VersionDirectory(id, version); if (!Directory.Exists(dir)) throw new DirectoryNotFoundException(dir);
Directory.CreateDirectory(PackageRoot(id)); var prior = await ReadPointer(id, ct); var operationId=Guid.NewGuid().ToString("N"); var content=await ContentFingerprintAsync(id,version,ct);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,1,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet),cancellationToken:ct); await stream.FlushAsync(ct); }
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerTempWrite); var temp = PointerPath(id) + "." + operationId + ".tmp"; await using (var stream=new FileStream(temp,FileMode.CreateNew,FileAccess.Write,FileShare.None)) { await JsonSerializer.SerializeAsync(stream,new Pointer(version, prior?.Current,2,(prior?.Generation??0)+1,operationId,DateTimeOffset.UtcNow,content.Manifest,content.PayloadSet,content.Files),cancellationToken:ct); await stream.FlushAsync(ct); }
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReplace); File.Move(temp, PointerPath(id), true); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforePointerReadback); var verified=await ReadPointer(id,ct); if (verified?.Current!=version || verified.ActivationOperationId!=operationId) throw new InvalidOperationException("POINTER_REVALIDATION_FAILED");
return new(version, prior?.Current);
} finally { PointerGate.Release(); }
@@ -39,16 +39,16 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
{
if (!string.Equals(package.PackageId, requirement.PackageId, StringComparison.Ordinal) || !string.Equals(package.Version, requirement.RequiredVersion, StringComparison.Ordinal) || !await MatchesImmutableManifestAsync(package,requirement,ct)) return new("Blocked", null);
var prior = await ReadPointer(package.PackageId, ct);
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId),1,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity));
if (prior?.Current == package.Version) return new("AlreadyActiveExact", new(prior.ActivationOperationId ?? Guid.NewGuid().ToString("N"), package.PackageId, package.Version, prior.Previous, prior.ActivatedAtUtc ?? DateTimeOffset.UtcNow, prior.Generation, PackageRoot(package.PackageId),prior.SchemaVersion,prior.ManifestSha256,prior.PayloadSetSha256,requirement.RosterIdentity,prior.PayloadFiles,Math.Max(0,prior.Generation-1),prior.ActivationOperationId));
var activated = await ActivateAsync(package.PackageId, package.Version, ct);
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId),1,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity));
var after=await ReadPointer(package.PackageId,ct); return new("Activated", new(after!.ActivationOperationId!, package.PackageId, activated.CurrentVersion, activated.PreviousVersion, after.ActivatedAtUtc ?? DateTimeOffset.UtcNow, after.Generation, PackageRoot(package.PackageId),after.SchemaVersion,after.ManifestSha256,after.PayloadSetSha256,requirement.RosterIdentity,after.PayloadFiles,Math.Max(0,after.Generation-1),after.ActivationOperationId));
}
public async Task<PackageRollbackResult> RollbackAsync(PackageActivationReceipt receipt, CancellationToken ct)
{
var current=await GetCurrentVersionAsync(receipt.PackageId,ct);
var pointer=await ReadPointer(receipt.PackageId,ct); if (pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration) return new("RollbackBlockedStateChanged",current);
var pointer=await ReadPointer(receipt.PackageId,ct); if (receipt.SchemaVersion!=2 || pointer is null || !string.Equals(current,receipt.Version,StringComparison.Ordinal) || pointer.ActivationOperationId!=receipt.OperationId || pointer.Generation!=receipt.PointerGeneration || !string.Equals(pointer.ManifestSha256,receipt.ManifestSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(pointer.PayloadSetSha256,receipt.PayloadSetSha256,StringComparison.OrdinalIgnoreCase) || !string.Equals(receipt.ActiveRootIdentity,PackageRoot(receipt.PackageId),StringComparison.OrdinalIgnoreCase)) return new("RollbackBlockedStateChanged",current);
if (string.IsNullOrWhiteSpace(receipt.PreviousVersion)) return new("RollbackNotApplicable",current);
var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReplace); var restored=await ActivateAsync(receipt.PackageId,receipt.PreviousVersion,ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeRollbackReadback);
return new("RolledBack",restored.CurrentVersion);
}
public async Task<string?> RollbackAsync(string id, CancellationToken ct) { var p = await ReadPointer(id, ct); return p?.Previous is null ? null : (await ActivateAsync(id, p.Previous, ct)).CurrentVersion; }
@@ -63,6 +63,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
var pointer = await ReadPointer(requirement.PackageId, ct);
if (pointer is null) return null;
var current = await VerifyVersionAsync(requirement, pointer.Current, ct);
if(current is not null && !await PointerMatchesContentAsync(pointer,requirement.PackageId,pointer.Current,ct)) current=null;
if (current is not null) return new(requirement.PackageId, pointer.Current, current, false, "CURRENT_VERIFIED");
if (!requirement.AllowOfflineFallback || string.IsNullOrWhiteSpace(pointer.Previous) || string.Equals(pointer.Current, pointer.Previous, StringComparison.OrdinalIgnoreCase)) return null;
var previous = await VerifyVersionAsync(requirement, pointer.Previous, ct);
@@ -79,9 +80,10 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
if (!new PackageManifestValidator().Validate(manifest, manifest.ProductYear, manifest.Architecture).IsValid) throw new InvalidOperationException("MANIFEST_INVALID");
if (manifest.Files.Any(x => string.Equals(x.Path, StoredManifestFileName, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException("MANIFEST_RESERVED_FILE");
var operationId = Guid.NewGuid().ToString("N"); var staging = Path.Combine(root, "Launcher", "staging", operationId); Directory.CreateDirectory(staging); await File.WriteAllTextAsync(Path.Combine(staging, ".package-staging-owner.json"), JsonSerializer.Serialize(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId)), ct); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterStagingAllocated);
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = Path.Combine(sourceRoot, f.Path); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
try { var i = 0; foreach (var f in manifest.Files) { ct.ThrowIfCancellationRequested(); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringPayloadCopy); var source = PackageFilePath(sourceRoot, f.Path); var sourceInfo=new FileInfo(source); if(!sourceInfo.Exists||sourceInfo.Length!=f.Length||!string.Equals(await hasher.Sha256Async(source,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("SOURCE_HASH_MISMATCH"); var dest = PackageFilePath(staging, f.Path); Directory.CreateDirectory(Path.GetDirectoryName(dest)!); File.Copy(source, dest, true); if (!string.Equals(await hasher.Sha256Async(dest, ct), f.Sha256, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("HASH_MISMATCH"); progress?.Report(new("copy", ++i, manifest.Files.Count)); }
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation);
await File.WriteAllTextAsync(Path.Combine(staging, StoredManifestFileName), JsonSerializer.Serialize(manifest), ct);
File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) Directory.Delete(staging, true); else Directory.Move(staging, versionDir); return new(manifest.PackageId, manifest.Version, versionDir); }
File.Delete(Path.Combine(staging, ".package-staging-owner.json")); var versionDir = VersionDirectory(manifest.PackageId, manifest.Version); Directory.CreateDirectory(Path.GetDirectoryName(versionDir)!); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.BeforeImmutableCommit); if (Directory.Exists(versionDir)) { if(!await ExistingMatchesAsync(versionDir,manifest,ct)) throw new InvalidOperationException("IMMUTABLE_PACKAGE_CONFLICT"); Directory.Delete(staging,true); } else Directory.Move(staging, versionDir); (faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn); return new(manifest.PackageId, manifest.Version, versionDir); }
catch { await CleanupOwnedStagingAsync(new PackageStagingOwnership(operationId, manifest.PackageId, manifest.Version, staging, DateTimeOffset.UtcNow, operationId), CancellationToken.None); throw; }
}
public async Task<PackageStagingCleanupResult> CleanupOwnedStagingAsync(PackageStagingOwnership ownership, CancellationToken ct)
@@ -91,7 +93,7 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
if (!Directory.Exists(directory)) return new("AlreadyAbsent",ownership.OperationId);
var marker=Path.Combine(directory,".package-staging-owner.json"); if (!File.Exists(marker)) return new("OwnershipUnknown",ownership.OperationId);
var stored=JsonSerializer.Deserialize<PackageStagingOwnership>(await File.ReadAllTextAsync(marker,ct)); if (stored is null || stored.OperationId!=ownership.OperationId || stored.PackageId!=ownership.PackageId || stored.Version!=ownership.Version) return new("OwnershipMismatch",ownership.OperationId);
Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId);
(faults??new NoOpPackageLifecycleFaultInjector()).ThrowIfRequested(PackageLifecycleFaultPoint.DuringOwnedStagingCleanup); Directory.Delete(directory,true); return new("Cleaned",ownership.OperationId);
}
private async Task<string?> VerifyVersionAsync(PackageRequirement requirement, string version, CancellationToken ct)
{
@@ -114,8 +116,14 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
}
return versionRoot;
}
private async Task<(string Manifest,string PayloadSet)> ContentFingerprintAsync(string id,string version,CancellationToken ct)
{ var dir=VersionDirectory(id,version); var manifestPath=Path.Combine(dir,StoredManifestFileName); var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath,ct)); var manifestHash=await hasher.Sha256Async(manifestPath,ct); var payload=string.Join("|",manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>$"{x.Path}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload)))); }
private async Task<(string Manifest,string PayloadSet,IReadOnlyList<PackagePayloadBinding> Files)> ContentFingerprintAsync(string id,string version,CancellationToken ct)
{ var dir=VersionDirectory(id,version); var manifestPath=Path.Combine(dir,StoredManifestFileName); var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath,ct)); var manifestHash=await hasher.Sha256Async(manifestPath,ct); var files=manifest.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).Select(x=>new PackagePayloadBinding(x.Path.Replace('\\','/'),x.Length,x.Sha256.ToUpperInvariant())).ToArray(); var payload=string.Join("|",files.Select(x=>$"{x.RelativePath}:{x.Length}:{x.Sha256}")); return (manifestHash,Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(payload))),files); }
private async Task<bool> PointerMatchesContentAsync(Pointer pointer,string id,string version,CancellationToken ct)
{ if(pointer.SchemaVersion==1) return true; try { var content=await ContentFingerprintAsync(id,version,ct); return string.Equals(pointer.ManifestSha256,content.Manifest,StringComparison.OrdinalIgnoreCase)&&string.Equals(pointer.PayloadSetSha256,content.PayloadSet,StringComparison.OrdinalIgnoreCase)&&pointer.PayloadFiles is not null&&pointer.PayloadFiles.SequenceEqual(content.Files); } catch{return false;} }
private async Task<bool> ExistingMatchesAsync(string directory,PackageManifest expected,CancellationToken ct)
{ try { var stored=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(directory,StoredManifestFileName),ct)); if(!ManifestEquivalent(stored,expected)) return false; foreach(var file in expected.Files){var path=PackageFilePath(directory,file.Path);var info=new FileInfo(path);if(!info.Exists||info.Length!=file.Length||!string.Equals(await hasher.Sha256Async(path,ct),file.Sha256,StringComparison.OrdinalIgnoreCase))return false;}return true;}catch{return false;} }
private static bool ManifestEquivalent(PackageManifest left,PackageManifest right) =>
left.SchemaVersion==right.SchemaVersion && left.PackageId==right.PackageId && left.Version==right.Version && left.Product==right.Product && left.ProductYear==right.ProductYear && string.Equals(left.Architecture,right.Architecture,StringComparison.OrdinalIgnoreCase) && left.EntryAssembly==right.EntryAssembly && left.Startup==right.Startup && string.Equals(left.RolloutChannel,right.RolloutChannel,StringComparison.OrdinalIgnoreCase) && left.Files.Count==right.Files.Count && left.Files.OrderBy(x=>x.Path,StringComparer.Ordinal).SequenceEqual(right.Files.OrderBy(x=>x.Path,StringComparer.Ordinal));
private async Task<bool> MatchesImmutableManifestAsync(ImmutableLocalPackage package,PackageRequirement requirement,CancellationToken ct)
{ try { if (!package.LocalPackageRoot.Equals(VersionDirectory(package.PackageId,package.Version),StringComparison.OrdinalIgnoreCase)) return false; var manifest=PackageManifestJson.Parse(await File.ReadAllTextAsync(Path.Combine(package.LocalPackageRoot,StoredManifestFileName),ct)); if (!string.Equals(manifest.PackageId,requirement.PackageId,StringComparison.Ordinal)||!string.Equals(manifest.Version,requirement.RequiredVersion,StringComparison.Ordinal)||!string.Equals(manifest.Product,requirement.Product,StringComparison.Ordinal)||manifest.ProductYear!=requirement.ProductYear||!string.Equals(manifest.Architecture,requirement.Architecture,StringComparison.OrdinalIgnoreCase)||!string.Equals(manifest.RolloutChannel,requirement.Channel,StringComparison.OrdinalIgnoreCase)) return false; foreach(var f in manifest.Files){var p=PackageFilePath(package.LocalPackageRoot,f.Path); var i=new FileInfo(p); if(!i.Exists||i.Length!=f.Length||!string.Equals(await hasher.Sha256Async(p,ct),f.Sha256,StringComparison.OrdinalIgnoreCase)) return false;} return true; } catch { return false; } }
private static string PackageFilePath(string packageRoot, string relativePath)
@@ -116,7 +116,7 @@ public sealed class PilotExecutionService(
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
if (!Path.GetFullPath(scriptPath).StartsWith(Path.GetFullPath(paths.Require(LauncherPathVariableNames.LocalSessionRoot)).TrimEnd(Path.DirectorySeparatorChar) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("AUTOMATION_SCRIPT_OUTSIDE_SESSION_ROOT");
Directory.CreateDirectory(Path.GetDirectoryName(scriptPath)!);
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply));
var script = scriptBuilder.BuildAutomationScript(new(versionDirectory, request.Preparation.Manifest.EntryAssembly, paths.Require(LauncherPathVariableNames.FixtureOrthoDwg), paths.Require(LauncherPathVariableNames.FixtureModelDwg), scriptPath, request.Preparation.Configuration.AutomationDiagnostic, request.Preparation.Configuration.LaunchMode == AcadLaunchMode.NextPilotApply, request.Preparation.Configuration.AutomationSelection));
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
}
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
+5 -3
View File
@@ -1,6 +1,8 @@
# Package activation
Before changing package pointers, Launcher Next writes an activation snapshot containing exact previous `current.json` and `previous.json` contents and their SHA-256 values. Files are copied to a unique staging directory and rehashed. Only then is staging moved to `packages/<package>/versions/<version>` and the package-local pointer updated atomically.
Files are copied to an operation-owned staging directory, rehashed and moved to a deterministic immutable version location. Materialization never changes activation pointers. Explicit activation writes schema-v2 `current.json` through an operation-owned temporary file, flush, replace and readback. The pointer binds manifest SHA-256, deterministic payload-set SHA-256 and every payload relative path/length/SHA-256.
Activation and receipt-bound rollback are serialized by a process-local `SemaphoreSlim`. Cross-process coordination is not verified and network-share pointer mutation is not supported. The controlled lifecycle pilot uses only an explicit local root and does not imply corporate DFS, AutoCAD runtime or production acceptance.
Rollback restores the exact pointers, deletes the pilot version and startup script, and removes incomplete staging. Autodesk assemblies, PDBs, executables, test assemblies and legacy files are rejected from the pilot package.
@@ -10,14 +12,14 @@ Each activated version retains its validated package manifest alongside its payl
`PackageResolutionUseCase` receives an explicit roster requirement: package ID,
product/year/architecture, channel, exact version, roster identity and offline
policy. On an online acquisition it stages locally, binds that requirement to the
activated version, then revalidates through `LocalPackageStore.ResolveAsync`.
immutable version, then revalidates through `LocalPackageStore.ResolveAsync`.
The resolver checks manifest, binding, identity/product compatibility, entry and
all payload lengths/SHA-256 before returning a local version. It returns current
first. Only when current is invalid and the requirement explicitly permits offline
fallback can it return previous. A cache entry with another roster, channel or
version is rejected. It never reads or executes a package from DFS, and a failed
or corrupted update never becomes last-known-good.
or corrupted update never becomes last-known-good. Resolution follows only the explicit current/previous pointers and never enumerates the version store to find an approximate candidate.
Resolution emits stable evidence codes: `ONLINE_PACKAGE_SELECTED`,
`VALID_CURRENT_CACHE_SELECTED`, `PREVIOUS_LKG_SELECTED`,
@@ -4,10 +4,12 @@
"realCorporateRosterParsing": "BLOCKED",
"realCorporatePackageAcquisition": "BLOCKED",
"blockReason": "CORPORATE_CATALOG_AND_ROSTER_PATHS_NOT_CONFIGURED",
"localImmutableStaging": "VERIFIED_BY_TESTS",
"controlledOutageRecovery": "VERIFIED_BY_TESTS",
"productionActivation": "NOT_RUN",
"autodeskRuntime": "NOT_RUN",
"productionReadiness": "NOT_ACHIEVED",
"localImmutableStaging": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"localPackageLifecycle": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"controlledOutageRecovery": "CONTROLLED_FILESYSTEM_PILOT_VERIFIED",
"crossProcessActivationCoordination": "NOT_RUNTIME_VERIFIED",
"productionActivation": "NOT_RUNTIME_VERIFIED",
"autodeskRuntime": "NOT_RUNTIME_VERIFIED",
"productionReadiness": "NOT_RUNTIME_VERIFIED",
"redaction": "No corporate paths, roster contents, package payload, session identity, or machine data were recorded."
}
+30
View File
@@ -0,0 +1,30 @@
# Legacy launcher capability matrix
Source audit: `All_Serv_Install/Form1.cs`, `F1_Xml.cs`, `F2_Xml.cs`, `All_Serv_Load/Form1.cs`, `All_Serv_InstallUpdater/Form1.cs`, `Control_Sum_XML.cs`, `P_Serv_Starter/Program.cs`, `launcher_starter.lsp`, `Settings.xml`, and `download_list_plant.xml`.
| Legacy capability | User-visible behavior | Modern equivalent | Status |
| --- | --- | --- | --- |
| Settings startup validation | Shows configurations only after required XML fields validate | Strict `LegacyConfigurationAdapter.ParseSettings` and UI file selection | SOURCE_VERIFIED / IMPLEMENTED |
| Pattern selection | Lists pattern name, filter and roster, then enables project/plugin workflow | Launcher Next lists patterns and selected filter/roster | SOURCE_VERIFIED / IMPLEMENTED |
| Ordered download list | Resolves ordered plugin IDs, sources and renewal aliases | Adapter preserves ordered IDs; overlay join rejects missing/ambiguous IDs | SOURCE_VERIFIED / VERIFIED_BY_TESTS |
| Plugin copy/install | Recursively copies arbitrary catalog directories and optional install payloads | Verified manifest acquisition to immutable local package only | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Update checks | Reconciles server tree using mutable MD5 `control_sums.xml` | No trust is assigned to legacy checksum XML; SHA-256 manifest validation is required | SOURCE_VERIFIED / IGNORED_NON_OPERATIONAL_WITH_REASON |
| Local active state | Deletes/replaces local catalogs before launch | Materialize, validate, immutable commit and explicit activation | IMPLEMENTED / CONTROLLED_RUNTIME_VERIFIED |
| Starter LSP | Stages loader, mutates SupportPath and loads DLL/DVB/LISP | Not reproduced: direct legacy payload execution and persistent SupportPath mutation are unsafe | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Plant discovery | Legacy BAT starts Plant 3D fire-and-forget | Product-specific ACAD-9117 registry discovery and UI status | SOURCE_VERIFIED / IMPLEMENTED |
| Interactive launch | Starts BAT without PID/evidence or wait | Requires verified local active package, explicit safe launch plan, PID check and evidence | SOURCE_VERIFIED / UNSUPPORTED_BLOCKING |
| Error/status UX | WinForms message boxes and partial continuation | Explicit UI ready/block status and diagnostics | SOURCE_VERIFIED / IMPLEMENTED |
## Blocking external inputs
The checked-in legacy settings reference `N:`/`Q:` production sources and legacy binaries. No owner-approved 2026 compatibility overlay, local manifest-backed catalog, or exact package roster is present in this worktree. Therefore enabling Prepare or Launch would either guess mappings or execute unverified legacy payloads; both violate the Launcher invariants.
## Source-verified product selection contract
| Product | Legacy source behavior | Launcher Next implementation | Verification / remaining input |
| --- | --- | --- | --- |
| Plant 3D | `Form1.button_select_project_Click` opens the selected pattern's file filter; automatic list comes from `%PROJECTDIRECTORY%\\..\\_System\\<pattern>\\Applications\\download_list.xml`; list mode explicitly opens `download_list.xml`. | Product-specific `OpenFileDialog`, exact relative roster resolver and manual XML selection feed the existing controlled Plant package/`/b` bootstrap flow. | `PLANT3D_CONTROLLED_WORKFLOW_VERIFIED`; launch/bootstrap path unchanged. |
| Plant P&ID | Filter is `Project files |Project.xml|All files (*.*)|*.*`; legacy starter was shared with Plant 3D. | Requires exactly `Project.xml`; resolves `Plant PnID_2019` automatic roster or explicit manual `download_list.xml`; preserves list order. | `PNID_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mappings blocked for `c1b999d6c16029e9ac5b1e1a8afe1500`, `5b80379c3ec715e7da0ee414c53aa7bc`, `1111`. No 2026 package is invented. |
| Navisworks Manage | Filter is `nwd files (*.nwd)|*.nwd|All files (*.*)|*.*`; automatic roster is project-relative by the same formula. The reviewed source does not establish a Navisworks plugin bootstrap. | Requires `.nwd`; resolves `Navisworks_Manage_2018` automatic roster or explicit manual `download_list.xml`; does not select Plant `/b`, `NETLOAD`, or PXP-OA bootstrap. | `NAVISWORKS_UI_DISCOVERY_PROJECT_AND_PLAN_VERIFIED`; mapping blocked for `7dd981169fa14d21aaef3fcdb0e7e970`. |
The automatic resolver performs no directory scan or fallback: it reads only the canonicalized source-verified location. Manual selection keeps the selected project file unchanged, enforces the `download_list.xml` basename, rejects reparse files and relies on the strict XML parser for malformed and duplicate IDs.
@@ -0,0 +1,9 @@
# Owner project roster compatibility
Read-only analysis of `\\phimproject.com\a\ATest_Total\_System\Plant 3D_2019\Applications\download_list.xml` recorded SHA-256 `493CA3B94ECF937CA09D2A37120A6D86DC81597B5C10284A1DF37E3D0352979D` (3,589 bytes; UTF-8 BOM).
Its real shape is `root → area → id`: one `area`, 33 ordered IDs, no empty IDs and no duplicate IDs. `b455ee6eaed79d76f084846a60ce3284` is entry 27 and joins to `P_Rec_Annot` / legacy `OrthoLoader.dll`; the explicit 2026 mapping resolves it to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`.
Legacy `All_Serv_Load/F2_Xml.cs` used `[XmlElement("id")]` on `F2_Root`, which reads only direct `root/id` entries and removes only exact empty strings. It neither trims nor rejects duplicate IDs; nested `area/id` entries are ignored. Launcher Next now preserves that direct-ID behavior for diagnostics while deterministically expanding the real `area/id` source order for the modern plan. Duplicate source IDs remain visible, while package execution uses a case-insensitive effective unique list.
The project roster is acquired from UNC into an owned local snapshot before parsing. XML parse/shape errors, missing Settings definitions, ambiguous definitions and absent 2026 mappings are distinct stages. Known-but-unmapped plugins remain visible and block launch without being misreported as invalid XML or process failure.
+9
View File
@@ -0,0 +1,9 @@
# Owner-review network configuration
The only approved legacy Settings endpoint is `\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml`. Launcher Next reads it read-only, validates it as the exact UNC file, snapshots it under the local controlled root, verifies the copied SHA-256, and parses only the local snapshot.
`N:\BIM\...` is supported only as a versioned alias for `\\phimproject.com\common\BIM\...`; other drive aliases are rejected. No payload is executed from the network source.
For the controlled Plant owner-review distribution, the real `Plant 3D_2019` definition supplies the project-relative roster template. The controlled roster contains the real Settings plugin ID `b455ee6eaed79d76f084846a60ce3284`, mapped explicitly to `P_Rec_Annot` `0.1.0` / `PRecAnnot.Plant3D2026.dll`. This is a migration mapping: its legacy `OrthoLoader.dll` name is not executed.
When the network endpoint is unavailable, Launcher can use only an existing validated local snapshot and identifies that condition as offline fallback in runtime evidence; it never silently substitutes a synthetic Settings file.
+54
View File
@@ -0,0 +1,54 @@
param(
[Parameter(Mandatory = $true)][string]$OutputRoot,
[string]$SourceFixtureRoot = 'C:\dev\TEMP\local-parity-launch-20260806'
)
$ErrorActionPreference = 'Stop'
$repoRoot = Split-Path -Parent $PSScriptRoot
$releaseRoot = Join-Path $repoRoot 'Launcher.Next.App\bin\x64\Release\net8.0-windows'
$output = [IO.Path]::GetFullPath($OutputRoot)
$fixture = [IO.Path]::GetFullPath($SourceFixtureRoot)
if (-not (Test-Path -LiteralPath (Join-Path $releaseRoot 'Launcher.Next.App.exe'))) { throw 'OWNER_REVIEW_RELEASE_EXE_MISSING' }
if (-not (Test-Path -LiteralPath (Join-Path $fixture 'catalog\P_Rec_Annot\0.1.0\package.manifest.json'))) { throw 'OWNER_REVIEW_CATALOG_INPUT_MISSING' }
if (Test-Path -LiteralPath $output) { throw 'OWNER_REVIEW_OUTPUT_ALREADY_EXISTS' }
$app = Join-Path $output 'app'
$controlled = Join-Path $output 'controlled'
New-Item -ItemType Directory -Path $app, $controlled, (Join-Path $controlled 'configuration'), (Join-Path $controlled 'project'), (Join-Path $controlled 'evidence') | Out-Null
Copy-Item -Path (Join-Path $releaseRoot '*') -Destination $app -Recurse
Copy-Item -LiteralPath (Join-Path $fixture 'catalog') -Destination $controlled -Recurse
Copy-Item -LiteralPath (Join-Path $fixture 'project\Project.xml') -Destination (Join-Path $controlled 'project\Project.xml')
$rosterDirectory = Join-Path $controlled '_System\Plant 3D_2019\Applications'
New-Item -ItemType Directory -Path $rosterDirectory | Out-Null
'<root><id>b455ee6eaed79d76f084846a60ce3284</id></root>' | Set-Content -LiteralPath (Join-Path $rosterDirectory 'download_list.xml') -Encoding utf8
'{"schemaVersion":1,"mappings":[{"mappingId":"owner-network-p-rec-annot","pattern":"Plant 3D_2019","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"}}]}' | Set-Content -LiteralPath (Join-Path $controlled 'configuration\compatibility-overlay.json') -Encoding utf8
$profile = [ordered]@{
schemaVersion = 1
name = 'Owner Review Controlled Local PXP-OA'
root = $controlled
settingsPath = (Join-Path $controlled 'configuration\settings-pending.xml')
downloadListPath = (Join-Path $rosterDirectory 'download_list.xml')
overlayPath = (Join-Path $controlled 'configuration\compatibility-overlay.json')
catalogRoot = (Join-Path $controlled 'catalog')
patternName = 'Plant 3D_2019'
evidenceRoot = (Join-Path $controlled 'evidence')
networkSettingsEndpoint = '\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml'
}
$profilePath = Join-Path $controlled 'configuration\owner-review-profile.json'
$profile | ConvertTo-Json | Set-Content -LiteralPath $profilePath -Encoding utf8
@{ launcherCommit = (git -C $repoRoot rev-parse HEAD).Trim(); distribution = 'OwnerReviewControlledLocal'; createdUtc = [DateTimeOffset]::UtcNow } | ConvertTo-Json | Set-Content -LiteralPath (Join-Path $app 'owner-review-release.json') -Encoding utf8
@"
@echo off
"%~dp0Launcher.Next.App.exe" --mode LocalParityLaunch --config "%~dp0..\controlled\configuration\owner-review-profile.json"
"@ | Set-Content -LiteralPath (Join-Path $app 'Launch Owner Review.cmd') -Encoding ascii
[pscustomobject]@{
DistributionRoot = $output
Executable = (Join-Path $app 'Launcher.Next.App.exe')
Launcher = (Join-Path $app 'Launch Owner Review.cmd')
Profile = $profilePath
Project = (Join-Path $controlled 'project\Project.xml')
AutomaticRoster = (Join-Path $rosterDirectory 'download_list.xml')
} | Format-List
@@ -10,6 +10,7 @@
<ProjectReference Include="..\..\Launcher.Next.Application\Launcher.Next.Application.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
<ProjectReference Include="..\..\Launcher.Next.App\Launcher.Next.App.csproj" />
</ItemGroup>
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
</Project>
@@ -0,0 +1,39 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyNetworkConfigurationTests
{
[Fact]
public void ApprovedSettingsEndpoint_IsExactUncFile()
{
Assert.Equal(@"\\phimproject.com\common\BIM\save\All_Serv_Install\Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint);
Assert.StartsWith(@"\\", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.Ordinal);
Assert.EndsWith("Settings.xml", LegacyNetworkConfiguration.ApprovedSettingsEndpoint, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData(@"N:\BIM", @"\\phimproject.com\common\BIM")]
[InlineData(@"N:\BIM\save\Apps\Plant3D\DriveA", @"\\phimproject.com\common\BIM\save\Apps\Plant3D\DriveA")]
public void ApprovedNDriveAlias_ResolvesOnlyBimNamespace(string original, string expected)
=> Assert.Equal(expected, LegacyNetworkConfiguration.ResolveApprovedAlias(original));
[Theory]
[InlineData(@"N:\Other\payload.dll")]
[InlineData(@"Q:\BIM\save\Apps")]
[InlineData("relative\\Settings.xml")]
public void UnknownLegacyAliases_AreRejected(string path)
=> Assert.Equal("LEGACY_NETWORK_ALIAS_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.ResolveApprovedAlias(path)).Message);
[Fact]
public void UnapprovedEndpoint_IsRejectedBeforeAnyNetworkAccess()
{
var root = Path.Combine(Path.GetTempPath(), "launcher-network-" + Guid.NewGuid().ToString("N"));
try
{
Assert.Equal("LEGACY_SETTINGS_ENDPOINT_NOT_APPROVED", Assert.Throws<InvalidOperationException>(() => LegacyNetworkConfiguration.AcquireApprovedSnapshot(@"\\example.invalid\Settings.xml", root)).Message);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}
@@ -0,0 +1,49 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyNetworkSettingsParserTests
{
[Fact]
public void FullLegacyPattern_PreservesSourcesAttributesAndMachineRestriction()
{
const string xml = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant 3D_2019</name><filter>Project files |Project.xml|All files (*.*)|*.*</filter><roster>%PROJECTDIRECTORY%\\..\\_System\\Plant 3D_2019\\Applications\\download_list.xml</roster><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveA</course></source><source><course>N:\\BIM\\save\\Apps\\Plant3D\\DriveQ</course></source><target>target</target><loader>loader</loader><object>object</object><attrib>first</attrib><attrib>second</attrib></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install><machine>BUILD-01</machine></plugin></data></root>";
var settings = new LegacyConfigurationAdapter().ParseSettings(xml);
var pattern = Assert.Single(settings.Patterns);
Assert.Equal(["N:\\BIM\\save\\Apps\\Plant3D\\DriveA", "N:\\BIM\\save\\Apps\\Plant3D\\DriveQ"], pattern.Sources);
Assert.Equal(["first", "second"], pattern.Attributes);
Assert.Equal("target", pattern.Target);
var plugin = Assert.Single(pattern.Plugins);
Assert.Equal("P_Rec_Annot", plugin.Catalog);
Assert.True(plugin.IsAllowedOn("build-01"));
Assert.False(plugin.IsAllowedOn("OTHER"));
}
[Fact]
public void PluginWithoutMachineRestriction_IsAllowedEverywhere()
{
var plugin = new LegacyPluginDefinition("id", "", "catalog", "install", 0);
Assert.True(plugin.IsAllowedOn(Environment.MachineName));
}
[Fact]
public void RealOwnerRosterShape_UsesAreaIdsInSourceOrderAndPreservesDuplicateEvidence()
{
const string roster = "<?xml version=\"1.0\" encoding=\"UTF-8\"?><root><!-- comment --><area><agname>1</agname><id>first</id><id> second </id><id>first</id><id></id></area><ignored><id>not-selected</id></ignored></root>";
var selection = new LegacyConfigurationAdapter().ParseDownloadList(roster);
Assert.Equal(["first", " second ", "first"], selection.PluginIds);
Assert.Equal(["first", " second "], selection.EffectivePluginIds);
Assert.Equal("Area", selection.Entries![0].SourceShape);
Assert.Equal(0, selection.Entries[2].FirstOccurrenceIndex);
Assert.Equal("", selection.Entries[3].RawId);
}
[Fact]
public void DownloadListErrors_AreSpecific()
{
var adapter = new LegacyConfigurationAdapter();
Assert.Equal("DOWNLOAD_LIST_ROOT_INVALID", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<other/>")).Message);
Assert.Equal("DOWNLOAD_LIST_XML_MALFORMED", Assert.Throws<InvalidOperationException>(() => adapter.ParseDownloadList("<root>")).Message);
}
}
@@ -0,0 +1,100 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LegacyProductSelectionTests
{
[Fact]
public void SourceVerifiedProducts_ExposeExactOpenFileFilters()
{
Assert.Equal("Project files |Project.xml|All files (*.*)|*.*", LegacyProductSelections.Pnid.Filter);
Assert.Equal("nwd files (*.nwd)|*.nwd|All files (*.*)|*.*", LegacyProductSelections.Navisworks.Filter);
Assert.Equal("Project.xml", LegacyProductSelections.Pnid.RequiredFileName);
Assert.Equal(".nwd", LegacyProductSelections.Navisworks.RequiredFileName);
}
[Fact]
public void AutomaticRoster_UsesOnlyExactLegacyRelativeLocation()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
var actual = LegacyProductSelections.AutomaticRoster(LegacyProductSelections.Pnid, project);
Assert.Equal(Path.Combine(fixture.Root, "_System", "Plant PnID_2019", "Applications", "download_list.xml"), actual);
Assert.DoesNotContain("scan", actual, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void SelectedProjectFile_IsProductSpecificAndRejectsWrongExtension()
{
using var fixture = new SelectionFixture();
var nwd = fixture.ProjectFile("model.nwd");
Assert.Equal(nwd, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, nwd));
Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Pnid, nwd));
}
[Fact]
public void AutomaticPnidRoster_PreservesOrderAndReportsExactUnmappedIds()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>c1b999d6c16029e9ac5b1e1a8afe1500</id><id>5b80379c3ec715e7da0ee414c53aa7bc</id><id>1111</id></root>");
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
Assert.Equal("Automatic", plan.Source);
Assert.Equal(["c1b999d6c16029e9ac5b1e1a8afe1500", "5b80379c3ec715e7da0ee414c53aa7bc", "1111"], plan.OrderedIds);
Assert.Equal(plan.OrderedIds, plan.UnmappedIds);
}
[Fact]
public void ManualRoster_DoesNotChangeProjectAndUsesExplicitDownloadList()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("model.nwd");
var manual = Path.Combine(fixture.Root, "download_list.xml");
File.WriteAllText(manual, "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
var plan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, manual, new LegacyConfigurationAdapter());
Assert.Equal("Manual", plan.Source);
Assert.Equal(manual, plan.RosterPath);
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], plan.UnmappedIds);
Assert.Equal(project, LegacyProductSelections.ValidateSelectedFile(LegacyProductSelections.Navisworks, project));
}
[Fact]
public void MissingAutomaticRoster_FailsClosedWithoutSearchingOtherDirectories()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("model.nwd");
File.WriteAllText(Path.Combine(fixture.Root, "download_list.xml"), "<root><id>7dd981169fa14d21aaef3fcdb0e7e970</id></root>");
var ex = Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Navisworks, project, null, new LegacyConfigurationAdapter()));
Assert.Equal("LEGACY_DOWNLOAD_LIST_NOT_FOUND", ex.Message);
}
[Fact]
public void ProductIncompatibleIds_AreBlocked_AndDuplicateIdsRemainDiagnosticInput()
{
using var fixture = new SelectionFixture();
var project = fixture.ProjectFile("Project.xml");
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>unexpected</id></root>");
Assert.Equal("LEGACY_PLUGIN_PRODUCT_MISMATCH", Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message);
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>1111</id><id>1111</id></root>");
var duplicatePlan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
Assert.Equal(["1111", "1111"], duplicatePlan.OrderedIds);
}
[Fact]
public void Navisworks_DoesNotAdvertisePlantBootstrap()
{
Assert.False(LegacyProductSelections.Navisworks.SupportsPlantBootstrap);
Assert.True(LegacyProductSelections.Pnid.SupportsPlantBootstrap);
Assert.Equal(["7dd981169fa14d21aaef3fcdb0e7e970"], LegacyProductSelections.Navisworks.UnmappedLegacyIds);
}
private sealed class SelectionFixture : IDisposable
{
public SelectionFixture() { Root = Path.Combine(Path.GetTempPath(), "launcher-next-selection-" + Guid.NewGuid().ToString("N")); Directory.CreateDirectory(Path.Combine(Root, "Project")); }
public string Root { get; }
public string ProjectFile(string name) { var path = Path.Combine(Root, "Project", name); File.WriteAllText(path, "fixture"); return path; }
public void WriteAutomatic(string pattern, string xml) { var directory = Path.Combine(Root, "_System", pattern, "Applications"); Directory.CreateDirectory(directory); File.WriteAllText(Path.Combine(directory, "download_list.xml"), xml); }
public void Dispose() { if (Directory.Exists(Root)) Directory.Delete(Root, true); }
}
}
@@ -0,0 +1,39 @@
using System.Text.Json;
using Launcher.Next.App;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class LocalParityEvidenceTests
{
[Fact]
public async Task EvidenceWriter_AtomicallyPublishesValidatedSchema()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(root);
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, state = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
Assert.True(File.Exists(path));
using var document = JsonDocument.Parse(await File.ReadAllTextAsync(path));
Assert.Equal(4, document.RootElement.GetProperty("schemaVersion").GetInt32());
Assert.False(File.Exists(path + ".tmp"));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
}
[Fact]
public async Task EvidenceWriter_ReplacesStaleTemporaryArtifact()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
try
{
Directory.CreateDirectory(root);
await File.WriteAllTextAsync(Path.Combine(root, "controlled-local-parity-launch.json.tmp"), "stale");
var path = await LocalParityLaunchService.WriteEvidenceAsync(root, new { schemaVersion = 4, terminalResult = "PACKAGE_LOAD_VERIFIED" }, CancellationToken.None);
Assert.Contains("PACKAGE_LOAD_VERIFIED", await File.ReadAllTextAsync(path));
Assert.False(File.Exists(path + ".tmp"));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, recursive: true); }
}
}
@@ -0,0 +1,47 @@
using Launcher.Next.Application;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class OwnerReviewStateTests
{
[Fact]
public void MissingAutomaticRoster_IsRosterFailure_NotProcessStartFailure()
{
var failure = LauncherOperationFailures.Classify("LEGACY_DOWNLOAD_LIST_NOT_FOUND", "RosterResolutionFailed");
Assert.Equal("RosterResolutionFailed", failure.Stage);
Assert.Equal("AUTOMATIC_DOWNLOAD_LIST_NOT_FOUND", failure.Code);
Assert.False(failure.ProcessStartAttempted);
Assert.DoesNotContain("запустить Autodesk", failure.UserMessage, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("DOWNLOAD_LIST_XML_MALFORMED", "PluginPlanFailed", "DOWNLOAD_LIST_XML_MALFORMED")]
[InlineData("LEGACY_PLUGIN_PRODUCT_MISMATCH", "PluginPlanFailed", "PLUGIN_PRODUCT_MISMATCH")]
[InlineData("LEGACY_MAPPING_NOT_FOUND", "PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND")]
[InlineData("LOCAL_PARITY_ROSTER_PROFILE_MISMATCH", "RosterResolutionFailed", "OWNER_REVIEW_PROFILE_ROSTER_MISMATCH")]
public void SelectionAndMappingErrors_KeepTheirOperationStage(string source, string stage, string code)
{
var failure = LauncherOperationFailures.Classify(source, "ProjectValidationFailed");
Assert.Equal(stage, failure.Stage);
Assert.Equal(code, failure.Code);
Assert.False(failure.ProcessStartAttempted);
}
[Fact]
public void LaunchFailure_IsAttributedOnlyToActualProcessStartCode()
{
var failure = LauncherOperationFailures.Classify("PLANT3D_PROCESS_NOT_CREATED", "PackagePreparationFailed");
Assert.Equal("ProcessStartFailed", failure.Stage);
Assert.True(failure.ProcessStartAttempted);
Assert.Equal("Не удалось запустить Autodesk", failure.UserMessage);
}
[Fact]
public void UnknownPreparationFailure_DoesNotBecomeProcessFailure()
{
var failure = LauncherOperationFailures.Classify("LOCAL_PARITY_PATTERN_NOT_FOUND", "PackagePreparationFailed");
Assert.Equal("PackagePreparationFailed", failure.Stage);
Assert.False(failure.ProcessStartAttempted);
}
}
@@ -1,4 +1,5 @@
using System.Security.Cryptography;
using System.Text.Json;
using Launcher.Next.Core;
using Launcher.Next.Infrastructure;
using Xunit;
@@ -12,6 +13,16 @@ public sealed class PackageLifecycleTests
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var source=Path.Combine(root,"catalog"); Directory.CreateDirectory(source); await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]); await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]); var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(PackageLifecycleFaultPoint.AfterStagingAllocated)); try { await Assert.ThrowsAsync<IOException>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None)); Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None)); } finally { if(Directory.Exists(root)) Directory.Delete(root,true); } }
[Fact] public void PilotRootRejectsUncAndRelativePaths()
{ var resolver=new PathResolver(new Dictionary<string,string?>()); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot(@"\\server\share")); Assert.Throws<InvalidOperationException>(()=>resolver.ResolvePackagePilotRoot("relative")); }
[Fact] public void LifecycleRootsAreTypedDistinctAndConfined()
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N")); var roots=new PathResolver(new Dictionary<string,string?>()).ResolvePackageLifecycleRoots(root); var values=new[]{roots.Catalog.Value,roots.Staging.Value,roots.VersionStore.Value,roots.Active.Value,roots.Evidence.Value}; Assert.Equal(values.Length,values.Distinct(StringComparer.OrdinalIgnoreCase).Count()); Assert.All(values,x=>Assert.StartsWith(roots.Pilot.Value,x,StringComparison.OrdinalIgnoreCase)); }
[Theory]
[InlineData(PackageLifecycleFaultPoint.AfterStagingAllocated)]
[InlineData(PackageLifecycleFaultPoint.DuringPayloadCopy)]
[InlineData(PackageLifecycleFaultPoint.AfterPayloadCopyBeforeValidation)]
[InlineData(PackageLifecycleFaultPoint.BeforeImmutableCommit)]
[InlineData(PackageLifecycleFaultPoint.AfterImmutableCommitBeforeReturn)]
public async Task DeterministicMaterializationFaultsNeverMutatePointers(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);var store=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));try{await Assert.ThrowsAnyAsync<Exception>(()=>store.MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None));Assert.Null(await store.GetCurrentVersionAsync("Lifecycle.Package",CancellationToken.None));Assert.Null(await store.GetPreviousVersionAsync("Lifecycle.Package",CancellationToken.None));}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
private static PackageRequirement Req(string version) => new("Lifecycle.Package", "Plant3D", 2026, "x64", "pilot", version, "lifecycle-test", true);
private static PackageManifest Manifest(string version, byte[] a, byte[] b) => new(1, "Lifecycle.Package", version, "Plant3D", 2026, "x64", "entry.dll", [
new("entry.dll", a.Length, Convert.ToHexString(SHA256.HashData(a))),
@@ -58,6 +69,14 @@ public sealed class PackageLifecycleTests
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task RepeatedExactMaterializationRevalidatesCommittedManifestAndPayload()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{6,7};await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes);var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var first=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);var second=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);Assert.Equal(first.LocalPackageRoot,second.LocalPackageRoot);Assert.False(File.Exists(Path.Combine(root,"Applications","Lifecycle.Package","current.json"))); }
finally { if(Directory.Exists(root))Directory.Delete(root,true); }
}
[Fact]
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
{
@@ -73,6 +92,21 @@ public sealed class PackageLifecycleTests
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task StrictPointerBindsIndividualPayloadsAndExactLkgRejectsTampering()
{
var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);var bytes=new byte[]{4,5};await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),bytes);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),bytes);var store=new LocalPackageStore(root,new Sha256FileHasher());
try { var p=await store.MaterializeAsync(Manifest("1.0.0",bytes,bytes),source,CancellationToken.None);await store.BindAsync(Req("1.0.0"),"1.0.0",CancellationToken.None);await store.ActivateImmutableAsync(p,Req("1.0.0"),CancellationToken.None);var pointerPath=Path.Combine(root,"Applications","Lifecycle.Package","current.json");using(var doc=JsonDocument.Parse(await File.ReadAllTextAsync(pointerPath))){Assert.Equal(2,doc.RootElement.GetProperty("SchemaVersion").GetInt32());Assert.Equal(2,doc.RootElement.GetProperty("PayloadFiles").GetArrayLength());}Assert.NotNull(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None));var json=await File.ReadAllTextAsync(pointerPath);json=json.Replace("PayloadSetSha256\":\"","PayloadSetSha256\":\"00",StringComparison.Ordinal);await File.WriteAllTextAsync(pointerPath,json);Assert.Null(await store.ResolveAsync(Req("1.0.0"),CancellationToken.None)); }
finally{if(Directory.Exists(root))Directory.Delete(root,true);}
}
[Theory]
[InlineData(PackageLifecycleFaultPoint.BeforePointerTempWrite)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReplace)]
[InlineData(PackageLifecycleFaultPoint.BeforePointerReadback)]
public async Task PointerFaultsDoNotReturnFalseActivationAndReleaseLock(PackageLifecycleFaultPoint point)
{ var root=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));var source=Path.Combine(root,"catalog");Directory.CreateDirectory(source);await File.WriteAllBytesAsync(Path.Combine(source,"entry.dll"),[1]);await File.WriteAllBytesAsync(Path.Combine(source,"payload.dat"),[2]);try{var package=await new LocalPackageStore(root,new Sha256FileHasher()).MaterializeAsync(Manifest("1.0.0",[1],[2]),source,CancellationToken.None);var failing=new LocalPackageStore(root,new Sha256FileHasher(),new Fault(point));await Assert.ThrowsAnyAsync<Exception>(()=>failing.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None));var healthy=new LocalPackageStore(root,new Sha256FileHasher());var result=await healthy.ActivateImmutableAsync(package,Req("1.0.0"),CancellationToken.None);Assert.Contains(result.Outcome,new[]{"Activated","AlreadyActiveExact"});}finally{if(Directory.Exists(root))Directory.Delete(root,true);} }
[Fact]
public async Task OldReceiptCannotRollbackNewerActivation()
{
@@ -87,6 +87,24 @@ public sealed class PathAndLaunchTests
try { var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "apply.scr"), false, true)); Assert.Contains("_.UNDO\n_MARK\nPXP-OA\nPXP-OA\n_.UNDO\n_BACK", script); }
finally { Directory.Delete(root, true); }
}
[Fact]
public void SelectedEntityScript_UsesExactHandleAndDxfTypeWithoutCoordinates()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try
{
var script = new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, true, new("PXP-AAV", "1a2b", "VIEWPORT")));
Assert.Contains("(handent \"1A2B\")", script); Assert.Contains("(command-s \"PXP-AAV\" P_REC_ANNOT_SELECTED_ENTITY)", script); Assert.Contains("P_REC_ANNOT_SELECTION_REJECTED", script); Assert.DoesNotContain("_.ZOOM", script); Assert.DoesNotContain("_.POINT", script);
}
finally { Directory.Delete(root, true); }
}
[Fact]
public void SelectedEntityScript_RejectsUnsafeSelectionTokens()
{
var root = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N")); Directory.CreateDirectory(root); var entry = "P.dll"; var fixture = Path.Combine(root, "fixture.dwg"); var model = Path.Combine(root, "model.dwg"); File.WriteAllText(Path.Combine(root, entry), ""); File.WriteAllText(fixture, ""); File.WriteAllText(model, "");
try { Assert.Throws<InvalidOperationException>(() => new StartupScriptBuilder().BuildAutomationScript(new(root, entry, fixture, model, Path.Combine(root, "selected.scr"), false, false, new("PXP-AAV;QUIT", "1A2B", "VIEWPORT")))); }
finally { Directory.Delete(root, true); }
}
[Fact] public void DiagnosticScript_RecordsExactBytes() { var text = "PXP-OA\n"; Assert.Equal(Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text))), Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(text)))); }
[Fact] public void DiagnosticScript_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
@@ -0,0 +1,18 @@
using Launcher.Next.Autodesk;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class PlantDiscoveryTests
{
private sealed class Registry(string? install) : IRegistryReader
{ public object? GetValue(string path,string name)=>path.Contains("ACAD-9117",StringComparison.Ordinal)&&name=="InstallDir"?install:null; }
[Fact]
public void Plant2026DiscoveryUsesPlantSpecificRegistryKey()
{
var directory=Path.Combine(Path.GetTempPath(),Guid.NewGuid().ToString("N"));Directory.CreateDirectory(Path.Combine(directory,"PLNT3D"));File.WriteAllBytes(Path.Combine(directory,"acad.exe"),[]);
try { var product=new PlantProductDiscovery(new Registry(directory)).DiscoverPlant3D(2026);Assert.NotNull(product);Assert.Equal("2026",product!.ProductVersion);Assert.Equal(Path.Combine(directory,"acad.exe"),product.ExecutablePath); }
finally { Directory.Delete(directory,true); }
}
}
@@ -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 = "<root><path><help>h</help><care>c</care></path><data><pattern><name>Plant</name><filter>*.xml</filter><roster>r</roster></pattern><plugin><id>b455ee6eaed79d76f084846a60ce3284</id><renewal></renewal><catalog>P_Rec_Annot</catalog><install>OrthoLoader.dll</install></plugin><plugin><id>6ffef84b01097975f51fb3bbb11279e1</id><renewal></renewal><catalog>P_Rec_Update</catalog><install>P_Rec_Update.dll</install></plugin></data></root>";
const string selection = "<root><id>b455ee6eaed79d76f084846a60ce3284</id><id>6ffef84b01097975f51fb3bbb11279e1</id></root>";
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()
{
@@ -0,0 +1,25 @@
using Launcher.Next.Core;
using Xunit;
namespace Launcher.Next.Tests;
public sealed class StartupRosterPreflightTests
{
[Fact]
public void ForeignVersionIsConflicting()
{
var root = Path.Combine(Path.GetTempPath(), "roster-" + Guid.NewGuid().ToString("N"));
var requestedRoot = Path.Combine(root, "requested");
Directory.CreateDirectory(requestedRoot);
File.WriteAllText(Path.Combine(requestedRoot, "PRecAnnot.Plant3D2026.dll"), "new");
var old = Path.Combine(root, "Applications", "P_Rec_Annot", "versions", "0.1.0");
Directory.CreateDirectory(old);
File.WriteAllText(Path.Combine(old, "PRecAnnot.Plant3D2026.dll"), "old");
File.WriteAllText(Path.Combine(root, "Applications", "P_Rec_Annot", "current.json"), "{\"Current\":\"0.1.0\"}");
File.WriteAllText(Path.Combine(old, "package.manifest.json"), "{\"PackageId\":\"P_Rec_Annot\",\"Version\":\"0.1.0\",\"EntryAssembly\":\"PRecAnnot.Plant3D2026.dll\"}");
var requested = new PackageManifest(1, "P_Rec_Annot", "0.2.9", "Plant3D", 2026, "x64", "PRecAnnot.Plant3D2026.dll", [], new("netload", "PHP_REC_ANNOT"));
var report = StartupRosterPreflight.Inspect(requestedRoot, requested, [Path.Combine(root, "Applications")]);
Assert.Equal(StartupRosterCandidateStatus.ConflictingCandidatePresent, report.Status);
Directory.Delete(root, true);
}
}