feat: add controlled local PXP-OA parity workflow
This commit is contained in:
@@ -14,6 +14,17 @@ public partial class App : System.Windows.Application
|
|||||||
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
|
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (e.Args.FirstOrDefault()?.Equals("local-parity-launch", StringComparison.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
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) { Shutdown(await PilotCli.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; }
|
||||||
new MainWindow().Show();
|
new MainWindow().Show();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,138 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, PackageRequirement Requirement,
|
||||||
|
PackageManifest Manifest, ImmutableLocalPackage Package, PackageActivationResult Activation,
|
||||||
|
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);
|
||||||
|
return profile with { Root = Path.GetFullPath(profile.Root) };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
if (requirements.Count != 1) throw new InvalidOperationException("LOCAL_PARITY_EXACTLY_ONE_PACKAGE_REQUIRED");
|
||||||
|
var requirement = requirements[0];
|
||||||
|
var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion);
|
||||||
|
var manifestPath = Under(source, "package.manifest.json");
|
||||||
|
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct));
|
||||||
|
if (!ManifestMatches(manifest, requirement)) throw new InvalidOperationException("LOCAL_PARITY_MANIFEST_REQUIREMENT_MISMATCH");
|
||||||
|
var store = new LocalPackageStore(profile.Root, new Sha256FileHasher());
|
||||||
|
var package = await store.MaterializeAsync(manifest, source, ct);
|
||||||
|
await store.BindAsync(requirement, package.Version, ct);
|
||||||
|
var activation = await store.ActivateImmutableAsync(package, requirement, ct);
|
||||||
|
if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome);
|
||||||
|
if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED");
|
||||||
|
var starter = await GenerateStarterAsync(profile, package, manifest, ct);
|
||||||
|
return new(profile, requirement, manifest, package, activation, starter.Path, starter.Sha256);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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"); psi.ArgumentList.Add("/s"); psi.ArgumentList.Add(Path.GetDirectoryName(prepared.StarterPath)!);
|
||||||
|
psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke";
|
||||||
|
using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED");
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(8), 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);
|
||||||
|
}
|
||||||
|
var loaded = markerConfirmed || runtimeConfirmed;
|
||||||
|
var terminal = alive && loaded ? "Succeeded" : "Blocked";
|
||||||
|
var evidence = new { schemaVersion = 1, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, orderedPluginIds = new[] { prepared.Requirement.PackageId }, exactPackage = new { prepared.Package.PackageId, prepared.Package.Version, prepared.Manifest.Product, prepared.Manifest.ProductYear, prepared.Manifest.Architecture, prepared.Manifest.RolloutChannel }, manifestSha256 = await HashAsync(Path.Combine(prepared.Package.LocalPackageRoot, "package.manifest.json"), ct), payloads = prepared.Manifest.Files.Select(x => new { path = x.Path, x.Length, x.Sha256 }), materialization = "MaterializedOrAlreadyPresentExact", activation = prepared.Activation.Outcome, pointerGeneration = prepared.Activation.Receipt?.PointerGeneration, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = false }, starter = new { sha256 = prepared.StarterSha256, localOnly = true }, process = new { pid = process.Id, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, productionDeploymentAttempted = false } };
|
||||||
|
var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct);
|
||||||
|
return new(process.Id, alive, loaded, evidencePath, terminal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, ImmutableLocalPackage package, PackageManifest manifest, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var starterRoot = Under(profile.Root, "starter"); Directory.CreateDirectory(starterRoot);
|
||||||
|
var entry = Under(package.LocalPackageRoot, manifest.EntryAssembly);
|
||||||
|
if (!File.Exists(entry)) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
|
||||||
|
var marker = Under(starterRoot, "pxp-oa-netload.marker");
|
||||||
|
var escapedEntry = entry.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
var escapedMarker = marker.Replace("\\", "\\\\").Replace("\"", "\\\"");
|
||||||
|
var content = $"(vl-load-com)\n(command \"_.NETLOAD\" \"{escapedEntry}\")\n(setq parity-marker (open \"{escapedMarker}\" \"w\"))\n(write-line \"NETLOAD_RETURNED\" parity-marker)\n(close parity-marker)\n(princ)\n";
|
||||||
|
// AutoCAD loads acad.lsp at startup and acaddoc.lsp for each document.
|
||||||
|
// Both are identical and live solely in the local generated support directory.
|
||||||
|
var bootstrap = Under(starterRoot, "acad.lsp");
|
||||||
|
var path = Under(starterRoot, "acaddoc.lsp");
|
||||||
|
await File.WriteAllTextAsync(bootstrap, content, new UTF8Encoding(false), ct);
|
||||||
|
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)); }
|
||||||
|
private static string Fingerprint(string path, string salt) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(salt + "|" + path)));
|
||||||
|
private 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"; await using (var stream = new FileStream(temp, FileMode.CreateNew, FileAccess.Write, FileShare.None)) { await JsonSerializer.SerializeAsync(stream, value, cancellationToken: ct); await stream.FlushAsync(ct); } File.Move(temp, final); using var doc = JsonDocument.Parse(await File.ReadAllTextAsync(final, ct)); if (doc.RootElement.GetProperty("schemaVersion").GetInt32() != 1) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); return final; }
|
||||||
|
private static string ThisCommit() { try { var start = new ProcessStartInfo("git", "rev-parse HEAD") { WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, RedirectStandardOutput = true }; using var p = Process.Start(start); return p is null ? "unknown" : p.StandardOutput.ReadToEnd().Trim(); } catch { return "unknown"; } }
|
||||||
|
}
|
||||||
@@ -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" Height="560" Width="780"><Grid Margin="20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><TextBlock FontSize="22" FontWeight="SemiBold" Text="Launcher Next — Plant 3D 2026"/><Grid Grid.Row="1" Margin="0,18,0,10"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions><GroupBox Header="Autodesk"><StackPanel Margin="8"><TextBlock x:Name="AutodeskStatus"/><TextBlock x:Name="ExecutableStatus" TextWrapping="Wrap"/><Button Margin="0,8,0,0" Content="Refresh" Click="Refresh_Click"/></StackPanel></GroupBox><GroupBox Grid.Column="1" Header="Configuration" Margin="12,0,0,0"><StackPanel Margin="8"><TextBlock x:Name="ConfigurationStatus" Text="Configuration not selected"/><Button Margin="0,8,0,0" Content="Open legacy Settings.xml" Click="OpenSettings_Click"/></StackPanel></GroupBox></Grid><Grid Grid.Row="2"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions><GroupBox Header="Available configurations"><ListBox x:Name="Patterns" Margin="8" DisplayMemberPath="Name" SelectionChanged="Patterns_SelectionChanged"/></GroupBox><GroupBox Grid.Column="1" Header="Selected configuration" Margin="12,0,0,0"><StackPanel Margin="8"><TextBlock x:Name="PatternDetails" TextWrapping="Wrap"/><TextBlock Margin="0,12,0,0" Text="Packages" FontWeight="SemiBold"/><TextBlock x:Name="PackageStatus" Text="Package preparation required" TextWrapping="Wrap"/></StackPanel></GroupBox></Grid><StackPanel Grid.Row="3" Margin="0,14,0,0"><WrapPanel><Button x:Name="PrepareButton" Content="Prepare / Install / Update" IsEnabled="False" Margin="0,0,8,0"/><Button x:Name="LaunchButton" Content="Launch Plant 3D" IsEnabled="False"/><Button Content="Diagnostics" Margin="8,0,0,0" Click="Diagnostics_Click"/></WrapPanel><TextBlock x:Name="Status" Margin="0,10,0,0" TextWrapping="Wrap"/></StackPanel></Grid></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="560" Width="780"><Grid Margin="20"><Grid.RowDefinitions><RowDefinition Height="Auto"/><RowDefinition Height="Auto"/><RowDefinition Height="*"/><RowDefinition Height="Auto"/></Grid.RowDefinitions><TextBlock FontSize="22" FontWeight="SemiBold" Text="Launcher Next — Plant 3D 2026"/><Grid Grid.Row="1" Margin="0,18,0,10"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions><GroupBox Header="Autodesk"><StackPanel Margin="8"><TextBlock x:Name="AutodeskStatus"/><TextBlock x:Name="ExecutableStatus" TextWrapping="Wrap"/><Button Margin="0,8,0,0" Content="Refresh" Click="Refresh_Click"/></StackPanel></GroupBox><GroupBox Grid.Column="1" Header="Configuration" Margin="12,0,0,0"><StackPanel Margin="8"><TextBlock x:Name="ConfigurationStatus" Text="Configuration not selected"/><Button Margin="0,8,0,0" Content="Open legacy Settings.xml" Click="OpenSettings_Click"/></StackPanel></GroupBox></Grid><Grid Grid.Row="2"><Grid.ColumnDefinitions><ColumnDefinition Width="*"/><ColumnDefinition Width="*"/></Grid.ColumnDefinitions><GroupBox Header="Available configurations"><ListBox x:Name="Patterns" Margin="8" DisplayMemberPath="Name" SelectionChanged="Patterns_SelectionChanged"/></GroupBox><GroupBox Grid.Column="1" Header="Selected configuration" Margin="12,0,0,0"><StackPanel Margin="8"><TextBlock x:Name="PatternDetails" TextWrapping="Wrap"/><TextBlock Margin="0,12,0,0" Text="Packages" FontWeight="SemiBold"/><TextBlock x:Name="PackageStatus" Text="Package preparation required" TextWrapping="Wrap"/></StackPanel></GroupBox></Grid><StackPanel Grid.Row="3" Margin="0,14,0,0"><WrapPanel><Button x:Name="PrepareButton" Content="Prepare / Install / Update" IsEnabled="False" Margin="0,0,8,0" Click="Prepare_Click"/><Button x:Name="LaunchButton" Content="Launch Plant 3D" IsEnabled="False" Click="Launch_Click"/><Button Content="Diagnostics" Margin="8,0,0,0" Click="Diagnostics_Click"/></WrapPanel><TextBlock x:Name="Status" Margin="0,10,0,0" TextWrapping="Wrap"/></StackPanel></Grid></Window>
|
||||||
|
|||||||
@@ -5,7 +5,11 @@ namespace Launcher.Next.App;
|
|||||||
public partial class MainWindow : System.Windows.Window
|
public partial class MainWindow : System.Windows.Window
|
||||||
{
|
{
|
||||||
private readonly LegacyConfigurationAdapter adapter=new();
|
private readonly LegacyConfigurationAdapter adapter=new();
|
||||||
|
private readonly LocalParityLaunchProfile? localProfile;
|
||||||
|
private readonly LocalParityLaunchService localService=new();
|
||||||
|
private LocalParityPrepared? prepared;
|
||||||
public MainWindow() { InitializeComponent(); RefreshDiscovery(); Status.Text="Ready: choose a legacy Settings.xml to inspect approved configurations."; }
|
public MainWindow() { InitializeComponent(); RefreshDiscovery(); Status.Text="Ready: choose a legacy Settings.xml to inspect approved configurations."; }
|
||||||
|
internal MainWindow(LocalParityLaunchProfile profile) { localProfile=profile; InitializeComponent(); RefreshDiscovery(); LoadLocalProfile(profile); }
|
||||||
private void RefreshDiscovery()
|
private void RefreshDiscovery()
|
||||||
{
|
{
|
||||||
var product=new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
|
var product=new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
|
||||||
@@ -23,4 +27,21 @@ public partial class MainWindow : System.Windows.Window
|
|||||||
private void Patterns_SelectionChanged(object sender,System.Windows.Controls.SelectionChangedEventArgs e)
|
private void Patterns_SelectionChanged(object sender,System.Windows.Controls.SelectionChangedEventArgs e)
|
||||||
{ if(Patterns.SelectedItem is not LegacyProductPattern pattern)return;PatternDetails.Text=$"Filter: {pattern.Filter}\nRoster: {pattern.Roster}\nLegacy plugins: {pattern.Plugins.Count}";PackageStatus.Text="Exact package requirements are derived only after approved overlay + ordered download_list.xml join.";Status.Text="Configuration selected; provide approved local compatibility inputs to prepare packages."; }
|
{ if(Patterns.SelectedItem is not LegacyProductPattern pattern)return;PatternDetails.Text=$"Filter: {pattern.Filter}\nRoster: {pattern.Roster}\nLegacy plugins: {pattern.Plugins.Count}";PackageStatus.Text="Exact package requirements are derived only after approved overlay + ordered download_list.xml join.";Status.Text="Configuration selected; provide approved local compatibility inputs to prepare packages."; }
|
||||||
private void Diagnostics_Click(object sender,System.Windows.RoutedEventArgs e)=>System.Windows.MessageBox.Show($"{AutodeskStatus.Text}\n{ConfigurationStatus.Text}\n{Status.Text}","Launcher Next diagnostics");
|
private void Diagnostics_Click(object sender,System.Windows.RoutedEventArgs e)=>System.Windows.MessageBox.Show($"{AutodeskStatus.Text}\n{ConfigurationStatus.Text}\n{Status.Text}","Launcher Next diagnostics");
|
||||||
|
private void LoadLocalProfile(LocalParityLaunchProfile profile)
|
||||||
|
{
|
||||||
|
try { var settings=adapter.ParseSettings(System.IO.File.ReadAllText(profile.SettingsPath)); Patterns.ItemsSource=settings.Patterns; Patterns.SelectedItem=settings.Patterns.Single(x=>x.Name==profile.PatternName); ConfigurationStatus.Text="Controlled Local PXP-OA"; PrepareButton.IsEnabled=true; LaunchButton.IsEnabled=false; Status.Text="Ready: local verified package preparation is available; Launch is disabled until activation succeeds."; }
|
||||||
|
catch(Exception ex) { Status.Text="Blocked: "+ex.Message; }
|
||||||
|
}
|
||||||
|
private async void Prepare_Click(object sender,System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if(localProfile is null) { Status.Text="Blocked: choose an explicit controlled local configuration."; return; }
|
||||||
|
try { PrepareButton.IsEnabled=false; Status.Text="Preparing verified local package…"; prepared=await localService.PrepareAsync(localProfile,CancellationToken.None); PackageStatus.Text=$"Package: {prepared.Package.PackageId} {prepared.Package.Version}\nState: Active\nValidation: Passed"; LaunchButton.IsEnabled=true; Status.Text="Package active and verified. Launch Plant 3D is enabled."; }
|
||||||
|
catch(Exception ex) { Status.Text="Prepare blocked: "+ex.Message; PrepareButton.IsEnabled=true; }
|
||||||
|
}
|
||||||
|
private async void Launch_Click(object sender,System.Windows.RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if(prepared is null) { Status.Text="Launch blocked: package is not active."; return; }
|
||||||
|
try { LaunchButton.IsEnabled=false; Status.Text="Launching interactive Plant 3D 2026…"; var result=await localService.LaunchAsync(prepared,CancellationToken.None); Status.Text=result.TerminalState=="Succeeded"?$"Plant 3D started (PID {result.ProcessId}); PXP-OA load confirmed.":$"Launch blocked; evidence written. PID {result.ProcessId}."; LaunchButton.IsEnabled=true; }
|
||||||
|
catch(Exception ex) { Status.Text="Launch failed: "+ex.Message; LaunchButton.IsEnabled=true; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
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=>{
|
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.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);
|
return new LegacyCompatibilityMapping(x.LegacyPluginId,x.Package.Id,x.Package.Version,x.Package.Channel);
|
||||||
}).ToArray();
|
}).ToArray();
|
||||||
var first=document.Mappings[0].Package;
|
var first=document.Mappings[0].Package;
|
||||||
|
|||||||
@@ -121,7 +121,9 @@ public sealed class LocalPackageStore(string root, IFileHasher hasher, IPackageL
|
|||||||
private async Task<bool> PointerMatchesContentAsync(Pointer pointer,string id,string version,CancellationToken ct)
|
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;} }
|
{ 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)
|
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(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;} }
|
{ 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)
|
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; } }
|
{ 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)
|
private static string PackageFilePath(string packageRoot, string relativePath)
|
||||||
|
|||||||
@@ -69,6 +69,14 @@ public sealed class PackageLifecycleTests
|
|||||||
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
|
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]
|
[Fact]
|
||||||
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
|
public async Task ActivationIsIdempotentAndRollbackRestoresPrevious()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user