200 lines
17 KiB
C#
200 lines
17 KiB
C#
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"; }
|
|
}
|
|
}
|