diff --git a/Launcher.Next.App/App.xaml.cs b/Launcher.Next.App/App.xaml.cs index 20cf786..dc346ea 100644 --- a/Launcher.Next.App/App.xaml.cs +++ b/Launcher.Next.App/App.xaml.cs @@ -14,6 +14,17 @@ public partial class App : System.Windows.Application Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray())); 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; } new MainWindow().Show(); } diff --git a/Launcher.Next.App/LocalParityLaunchCli.cs b/Launcher.Next.App/LocalParityLaunchCli.cs new file mode 100644 index 0000000..c9a85e9 --- /dev/null +++ b/Launcher.Next.App/LocalParityLaunchCli.cs @@ -0,0 +1,29 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Launcher.Next.App; + +/// Explicit automation of the same LocalParityLaunchService used by the main-window actions. +internal static class LocalParityLaunchCli +{ + public static async Task 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; + } + } +} diff --git a/Launcher.Next.App/LocalParityLaunchService.cs b/Launcher.Next.App/LocalParityLaunchService.cs new file mode 100644 index 0000000..f3c68b5 --- /dev/null +++ b/Launcher.Next.App/LocalParityLaunchService.cs @@ -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; + +/// +/// 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. +/// +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(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 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 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 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 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"; } } +} diff --git a/Launcher.Next.App/MainWindow.xaml b/Launcher.Next.App/MainWindow.xaml index c3df942..31d0dcc 100644 --- a/Launcher.Next.App/MainWindow.xaml +++ b/Launcher.Next.App/MainWindow.xaml @@ -1 +1 @@ -