Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7cb3ac39 | ||
|
|
5a14658994 | ||
|
|
e12b8c549c | ||
|
|
6f7888b18b | ||
|
|
1f5853de86 | ||
|
|
94d35a12d9 | ||
|
|
cda89c359e | ||
|
|
8652325980 | ||
|
|
7025ce0a46 | ||
|
|
514302b966 | ||
|
|
a5a615f7f0 | ||
|
|
06d7cb0015 | ||
|
|
fb632b48d8 | ||
|
|
2792e244e9 | ||
|
|
2c578576d7 | ||
|
|
f460e87253 | ||
|
|
433dc9a50d | ||
|
|
45b1379853 | ||
|
|
97fbc1d746 | ||
|
|
0213b229a9 | ||
|
|
876e4f5d50 |
@@ -6,16 +6,19 @@ public partial class App : System.Windows.Application
|
|||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
if (e.Args.FirstOrDefault()?.Equals("legacy-configuration-probe", StringComparison.OrdinalIgnoreCase) == true)
|
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()));
|
Shutdown(await LegacyConfigurationProbeCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.Args.FirstOrDefault()?.Equals("package-lifecycle-pilot", StringComparison.OrdinalIgnoreCase) == true)
|
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()));
|
Shutdown(await PackageLifecyclePilotCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.Args.FirstOrDefault()?.Equals("local-parity-launch", StringComparison.OrdinalIgnoreCase) == true)
|
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()));
|
Shutdown(await LocalParityLaunchCli.RunAsync(e.Args.Skip(1).ToArray()));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -25,7 +28,16 @@ public partial class App : System.Windows.Application
|
|||||||
catch (Exception exception) { System.Windows.MessageBox.Show("Controlled local configuration blocked: " + exception.Message, "Launcher Next"); Shutdown(2); }
|
catch (Exception exception) { System.Windows.MessageBox.Show("Controlled local configuration blocked: " + exception.Message, "Launcher Next"); Shutdown(2); }
|
||||||
return;
|
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)
|
||||||
|
{
|
||||||
|
// 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();
|
new MainWindow().Show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,10 +21,13 @@ namespace Launcher.Next.App;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed record LocalParityLaunchProfile(
|
internal sealed record LocalParityLaunchProfile(
|
||||||
int SchemaVersion, string Name, string Root, string SettingsPath, string DownloadListPath,
|
int SchemaVersion, string Name, string Root, string SettingsPath, string DownloadListPath,
|
||||||
string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot);
|
string OverlayPath, string CatalogRoot, string PatternName, string EvidenceRoot, string? NetworkSettingsEndpoint = null,
|
||||||
|
LegacySettingsSnapshot? SettingsSnapshot = null);
|
||||||
|
|
||||||
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, PackageRequirement Requirement,
|
internal sealed record LocalParityPackage(PackageRequirement Requirement, PackageManifest Manifest,
|
||||||
PackageManifest Manifest, ImmutableLocalPackage Package, PackageActivationResult Activation,
|
ImmutableLocalPackage Package, PackageActivationResult Activation);
|
||||||
|
|
||||||
|
internal sealed record LocalParityPrepared(LocalParityLaunchProfile Profile, IReadOnlyList<LocalParityPackage> Packages,
|
||||||
string StarterPath, string StarterSha256);
|
string StarterPath, string StarterSha256);
|
||||||
|
|
||||||
internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed,
|
internal sealed record LocalParityLaunchResult(int ProcessId, bool StartupVerified, bool PackageLoadConfirmed,
|
||||||
@@ -41,7 +44,13 @@ internal sealed class LocalParityLaunchService
|
|||||||
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Name) || string.IsNullOrWhiteSpace(profile.PatternName))
|
if (profile.SchemaVersion != 1 || string.IsNullOrWhiteSpace(profile.Name) || string.IsNullOrWhiteSpace(profile.PatternName))
|
||||||
throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
|
throw new InvalidOperationException("LOCAL_PARITY_PROFILE_INVALID");
|
||||||
ValidateProfile(profile);
|
ValidateProfile(profile);
|
||||||
return profile with { Root = Path.GetFullPath(profile.Root) };
|
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)
|
public async Task<LocalParityPrepared> PrepareAsync(LocalParityLaunchProfile profile, CancellationToken ct)
|
||||||
@@ -53,20 +62,23 @@ internal sealed class LocalParityLaunchService
|
|||||||
var selection = adapter.ParseDownloadList(await File.ReadAllTextAsync(profile.DownloadListPath, ct));
|
var selection = adapter.ParseDownloadList(await File.ReadAllTextAsync(profile.DownloadListPath, ct));
|
||||||
var overlay = LegacyCompatibilityOverlayParser.Parse(await File.ReadAllTextAsync(profile.OverlayPath, ct));
|
var overlay = LegacyCompatibilityOverlayParser.Parse(await File.ReadAllTextAsync(profile.OverlayPath, ct));
|
||||||
var requirements = adapter.Join(pattern[0], selection, overlay);
|
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 store = new LocalPackageStore(profile.Root, new Sha256FileHasher());
|
||||||
var package = await store.MaterializeAsync(manifest, source, ct);
|
var prepared = new List<LocalParityPackage>();
|
||||||
await store.BindAsync(requirement, package.Version, ct);
|
foreach (var requirement in requirements)
|
||||||
var activation = await store.ActivateImmutableAsync(package, requirement, ct);
|
{
|
||||||
if (activation.Outcome is not ("Activated" or "AlreadyActiveExact")) throw new InvalidOperationException("LOCAL_PARITY_ACTIVATION_" + activation.Outcome);
|
var source = Under(profile.CatalogRoot, requirement.PackageId, requirement.RequiredVersion);
|
||||||
if (await store.ResolveAsync(requirement, ct) is null) throw new InvalidOperationException("LOCAL_PARITY_ACTIVE_REVALIDATION_FAILED");
|
var manifestPath = Under(source, "package.manifest.json");
|
||||||
var starter = await GenerateStarterAsync(profile, package, manifest, ct);
|
var manifest = PackageManifestJson.Parse(await File.ReadAllTextAsync(manifestPath, ct));
|
||||||
return new(profile, requirement, manifest, package, activation, starter.Path, starter.Sha256);
|
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)
|
public async Task<LocalParityLaunchResult> LaunchAsync(LocalParityPrepared prepared, CancellationToken ct)
|
||||||
@@ -76,6 +88,9 @@ internal sealed class LocalParityLaunchService
|
|||||||
var operationId = Guid.NewGuid().ToString("N");
|
var operationId = Guid.NewGuid().ToString("N");
|
||||||
var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId);
|
var evidenceDirectory = Under(prepared.Profile.EvidenceRoot, operationId);
|
||||||
Directory.CreateDirectory(evidenceDirectory);
|
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 logPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Promhimproekt", "PRecAnnot2026", "Logs", "runtime.jsonl");
|
||||||
var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L;
|
var beforeLogLength = File.Exists(logPath) ? new FileInfo(logPath).Length : 0L;
|
||||||
var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false };
|
var psi = new ProcessStartInfo { FileName = product.ExecutablePath, WorkingDirectory = Path.GetDirectoryName(product.ExecutablePath)!, UseShellExecute = false };
|
||||||
@@ -83,6 +98,8 @@ internal sealed class LocalParityLaunchService
|
|||||||
// Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last.
|
// Controlled-local only: accepted Plant runtime bootstrap. Keep /b and the owned SCR last.
|
||||||
psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath);
|
psi.ArgumentList.Add("/b"); psi.ArgumentList.Add(prepared.StarterPath);
|
||||||
psi.Environment["P_REC_ANNOT_RUNTIME_MODE"] = "Smoke";
|
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");
|
using var process = Process.Start(psi) ?? throw new InvalidOperationException("PLANT3D_PROCESS_NOT_CREATED");
|
||||||
await Task.Delay(TimeSpan.FromSeconds(30), ct);
|
await Task.Delay(TimeSpan.FromSeconds(30), ct);
|
||||||
var alive = !process.HasExited;
|
var alive = !process.HasExited;
|
||||||
@@ -97,20 +114,40 @@ internal sealed class LocalParityLaunchService
|
|||||||
var newLog = await reader.ReadToEndAsync(ct);
|
var newLog = await reader.ReadToEndAsync(ct);
|
||||||
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
|
runtimeConfirmed = newLog.Contains("AssemblyInitializeCompleted", StringComparison.Ordinal) && newLog.Contains($"\"ProcessId\":{process.Id}", StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
var loaded = runtimeConfirmed;
|
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 terminal = alive && loaded ? "Succeeded" : "Blocked";
|
||||||
var evidence = new { schemaVersion = 2, 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, bootstrapMechanism="StartupScript", commandLineSwitch="/b", scriptWasLastArgument=true, scriptSha256=prepared.StarterSha256, plant = new { product = "Plant3D", year = 2026, executableFingerprint = Fingerprint(product.ExecutablePath, operationId), interactive = true, batchArgumentUsed = true }, process = new { pid = process.Id, newProcessCreated=true, startupVerified = alive, packageLoadConfirmed = loaded, markerConfirmed, runtimeJournalConfirmed = runtimeConfirmed, processInteractiveAfterScript=alive }, terminalState = terminal, sideEffects = new { corporateDfsUsed = false, corporateDfsWriteAttempted = false, directDfsExecutionAttempted = false, productionProjectAccessed = false, productionPointerMutationAttempted = false, machineWideSecurityChanged=false, productionDeploymentAttempted = false } };
|
var 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);
|
var evidencePath = await WriteEvidenceAsync(evidenceDirectory, evidence, ct);
|
||||||
return new(process.Id, alive, loaded, evidencePath, terminal);
|
return new(process.Id, alive, loaded, evidencePath, terminal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, ImmutableLocalPackage package, PackageManifest manifest, CancellationToken ct)
|
private static async Task<(string Path, string Sha256)> GenerateStarterAsync(LocalParityLaunchProfile profile, IReadOnlyList<LocalParityPackage> packages, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot);
|
var starterRoot = Under(profile.Root, "bootstrap", Guid.NewGuid().ToString("N")); Directory.CreateDirectory(starterRoot);
|
||||||
var entry = Under(package.LocalPackageRoot, manifest.EntryAssembly);
|
var entries = packages.Select(x => (x.Package.PackageId, Path: Under(x.Package.LocalPackageRoot, x.Manifest.EntryAssembly))).ToArray();
|
||||||
if (!File.Exists(entry)) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
|
if (entries.Any(x => !File.Exists(x.Path))) throw new InvalidOperationException("LOCAL_PARITY_ENTRY_MISSING");
|
||||||
var marker = Under(starterRoot, "pxp-oa-netload.marker");
|
var markerRoot = Under(starterRoot, "markers"); Directory.CreateDirectory(markerRoot);
|
||||||
var content = "_.FILEDIA\r\n0\r\n_.CMDDIA\r\n0\r\n_.NETLOAD\r\n\"" + entry.Replace("\"", "\\\"") + "\"\r\n";
|
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");
|
var path = Under(starterRoot, "startup.scr");
|
||||||
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
await File.WriteAllTextAsync(path, content, new UTF8Encoding(false), ct);
|
||||||
return (path, await HashAsync(path, ct));
|
return (path, await HashAsync(path, ct));
|
||||||
@@ -127,8 +164,36 @@ internal sealed class LocalParityLaunchService
|
|||||||
}
|
}
|
||||||
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 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 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 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)));
|
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() != 2) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); return final; }
|
internal static async Task<string> WriteEvidenceAsync(string directory, object value, CancellationToken ct)
|
||||||
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"; } }
|
{
|
||||||
|
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,6 +1,7 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using Launcher.Next.Application;
|
using Launcher.Next.Application;
|
||||||
using Launcher.Next.Autodesk;
|
using Launcher.Next.Autodesk;
|
||||||
@@ -9,113 +10,212 @@ namespace Launcher.Next.App;
|
|||||||
|
|
||||||
public partial class MainWindow : System.Windows.Window
|
public partial class MainWindow : System.Windows.Window
|
||||||
{
|
{
|
||||||
private sealed record LauncherProduct(string Id,string Name,string? Executable,string Diagnostic);
|
private sealed record LauncherProduct(string Id, string Name, string? Executable, string Diagnostic);
|
||||||
private readonly LocalParityLaunchProfile? localProfile;
|
private readonly LocalParityLaunchProfile? localProfile;
|
||||||
private readonly LocalParityLaunchService localService=new();
|
private readonly LocalParityLaunchService localService = new();
|
||||||
private readonly LegacyConfigurationAdapter adapter=new();
|
private readonly LegacyConfigurationAdapter adapter = new();
|
||||||
private LauncherProduct? selected;
|
private LauncherProduct? selected;
|
||||||
private string? projectPath;
|
private string? projectPath;
|
||||||
private string? manualRosterPath;
|
private string? manualRosterPath;
|
||||||
public MainWindow() { InitializeComponent(); WireSourceVerifiedSelection(); Initialize(); }
|
private string? selectedRosterSnapshotPath;
|
||||||
internal MainWindow(LocalParityLaunchProfile profile) { localProfile=profile; InitializeComponent(); WireSourceVerifiedSelection(); Initialize(); }
|
private string stage = "ProductSelection";
|
||||||
private void WireSourceVerifiedSelection()
|
private string code = "PRODUCT_NOT_SELECTED";
|
||||||
{
|
private string detail = "";
|
||||||
SelectProjectButton.Click -= SelectProject_Click;
|
private string pattern = "";
|
||||||
SelectProjectButton.Click += SelectProjectFile_Click;
|
private string expectedRoster = "";
|
||||||
AutomaticMode.Checked -= LoadMode_Changed;
|
private string orderedIds = "";
|
||||||
ListMode.Checked -= LoadMode_Changed;
|
private string mappingState = "NotEvaluated";
|
||||||
AutomaticMode.Checked += LoadModeChanged;
|
private string preparationState = "NotStarted";
|
||||||
ListMode.Checked += LoadModeChanged;
|
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()
|
private void Initialize()
|
||||||
{
|
{
|
||||||
var plant=new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
|
var plant = new PlantProductDiscovery(new WindowsRegistryReader()).DiscoverPlant3D(2026);
|
||||||
var navisworks=Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),"Autodesk","Navisworks Manage 2026","Roamer.exe");
|
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 3D 2026 runtime"),new LauncherProduct("plant3d","Plant 3D 2026",plant?.ExecutablePath,plant is null?"Plant 3D 2026 не найден":"Найден")};
|
Products.ItemsSource = new[]
|
||||||
Version.Text="Версия: "+typeof(MainWindow).Assembly.GetName().Version;
|
{
|
||||||
Status.Text="Выберите программное обеспечение.";
|
new LauncherProduct("navisworks", "Navisworks Manage 2026", File.Exists(navisworks) ? navisworks : null, File.Exists(navisworks) ? "Найден" : "Navisworks Manage 2026 не найден"),
|
||||||
if(localProfile is not null) { Products.SelectedIndex=2; Status.Text="Выберите controlled non-production папку проекта."; }
|
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; ProjectPath.Text=selected is null?"Выберите программное обеспечение":"Папка проекта не выбрана"; SelectProjectButton.IsEnabled=selected?.Executable is not null; StartButton.IsEnabled=false; Plugins.ItemsSource=null; Plugins.IsEnabled=false; ProductStatus.Text=selected?.Diagnostic??""; Status.Text=selected?.Executable is null?selected?.Diagnostic??"Выберите программное обеспечение":"Выберите папку проекта."; }
|
private void Products_SelectionChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||||
private void SelectProject_Click(object sender,System.Windows.RoutedEventArgs e)
|
|
||||||
{
|
{
|
||||||
if(selected is null)return; var dialog=new Microsoft.Win32.OpenFolderDialog { Title="Выберите папку проекта" }; if(dialog.ShowDialog()!=true)return;
|
selected = Products.SelectedItem as LauncherProduct;
|
||||||
var path=Path.GetFullPath(dialog.FolderName); if(!Directory.Exists(path)){Status.Text="Выбранная папка недоступна.";return;} if(selected.Id is "plant3d" or "pnid" && !File.Exists(Path.Combine(path,"Project.xml"))){Status.Text="Выбранная папка не является проектом Plant 3D.";return;}
|
projectPath = null;
|
||||||
projectPath=path; ProjectPath.Text=path; ResolvePlugins();
|
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)
|
private void SelectProjectFile_Click(object sender, System.Windows.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (selected is null) return;
|
if (selected is null) return;
|
||||||
var product = LegacyProductSelections.For(selected.Id);
|
var product = LegacyProductSelections.For(selected.Id);
|
||||||
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Select project", Filter = product.Filter, CheckFileExists = true, Multiselect = false };
|
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Выберите проект", Filter = product.Filter, CheckFileExists = true, Multiselect = false };
|
||||||
if (dialog.ShowDialog() != true) return;
|
if (dialog.ShowDialog() != true) return;
|
||||||
|
ClearOperationState();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
Transition("ProjectSelected", "PROJECT_SELECTED", "Проверка проекта");
|
||||||
projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName);
|
projectPath = LegacyProductSelections.ValidateSelectedFile(product, dialog.FileName);
|
||||||
manualRosterPath = null;
|
manualRosterPath = null;
|
||||||
ProjectPath.Text = Path.GetFileName(projectPath);
|
ProjectPath.Text = Path.GetFileName(projectPath);
|
||||||
ResolvePluginsSourceVerified();
|
Transition("ProjectValidated", "PROJECT_VALID", "Поиск списка плагинов");
|
||||||
|
ResolvePlugins();
|
||||||
}
|
}
|
||||||
catch (Exception ex) { Status.Text = UserMessage(ex.Message); }
|
catch (Exception ex) { ReportFailure(ex, "ProjectValidationFailed"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadModeChanged(object sender, System.Windows.RoutedEventArgs e)
|
private void LoadMode_Changed(object sender, System.Windows.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (projectPath is null || selected is null) return;
|
if (projectPath is null || selected is null) return;
|
||||||
if (ListMode.IsChecked == true)
|
if (ListMode.IsChecked == true)
|
||||||
{
|
{
|
||||||
var dialog = new Microsoft.Win32.OpenFileDialog { Title = "Select download_list.xml", Filter = "download_list.xml (*.xml)|*.xml|All files (*.*)|*.*", CheckFileExists = true, Multiselect = false };
|
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; }
|
if (dialog.ShowDialog() != true) { AutomaticMode.IsChecked = true; return; }
|
||||||
manualRosterPath = dialog.FileName;
|
manualRosterPath = dialog.FileName;
|
||||||
}
|
}
|
||||||
else manualRosterPath = null;
|
else manualRosterPath = null;
|
||||||
ResolvePluginsSourceVerified();
|
ClearOperationState();
|
||||||
|
ResolvePlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void LoadMode_Changed(object sender,System.Windows.RoutedEventArgs e) { if(projectPath is not null)ResolvePlugins(); }
|
private void ResolvePlugins()
|
||||||
private void ResolvePluginsSourceVerified()
|
|
||||||
{
|
{
|
||||||
if (selected is null || projectPath is null) return;
|
if (selected is null || projectPath is null) return;
|
||||||
if (localProfile is not null && selected.Id == "plant3d") { ResolvePlugins(); return; }
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var product = LegacyProductSelections.For(selected.Id);
|
var product = LegacyProductSelections.For(selected.Id);
|
||||||
var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter);
|
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.ItemsSource = plan.OrderedIds;
|
||||||
Plugins.IsEnabled = false;
|
Plugins.IsEnabled = false;
|
||||||
PluginStatus.Text = $"List source: {plan.Source}.";
|
PluginStatus.Text = "Источник списка: " + (manualRosterPath is null ? "Автоматически" : "Выбран вручную");
|
||||||
StartButton.IsEnabled = false;
|
Transition("OrderedPluginIdsResolved", "PLUGIN_PLAN_RESOLVED", "Плагины определены");
|
||||||
Status.Text = plan.UnmappedIds.Count == 0
|
mappingState = plan.UnmappedIds.Count == 0 ? "Mapped" : "Unmapped: " + string.Join(", ", plan.UnmappedIds);
|
||||||
? "Packages for the selected plugins are not mapped to 2026 packages."
|
if (plan.UnmappedIds.Count > 0)
|
||||||
: $"Packages for {product.DisplayName} are not yet available for 2026: {string.Join(", ", plan.UnmappedIds)}";
|
{
|
||||||
}
|
StartButton.IsEnabled = false;
|
||||||
catch (Exception ex)
|
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", $"Для {product.DisplayName} отсутствуют пакеты 2026: {string.Join(", ", plan.UnmappedIds)}");
|
||||||
{
|
return;
|
||||||
Plugins.ItemsSource = null;
|
}
|
||||||
Plugins.IsEnabled = false;
|
if (localProfile is null || selected.Id != "plant3d")
|
||||||
StartButton.IsEnabled = false;
|
{
|
||||||
Status.Text = ex.Message == "LEGACY_DOWNLOAD_LIST_NOT_FOUND"
|
StartButton.IsEnabled = false;
|
||||||
? "Для выбранного проекта не найден список плагинов"
|
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026");
|
||||||
: UserMessage(ex.Message);
|
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 void ResolvePlugins()
|
|
||||||
|
private IReadOnlyList<string> EvaluateLocalProfileDefinitions(LegacyPluginPlan plan)
|
||||||
{
|
{
|
||||||
if(selected is null||projectPath is null)return;
|
var settings = adapter.ParseSettings(File.ReadAllText(localProfile!.SettingsPath));
|
||||||
if(localProfile is not null && selected.Id=="plant3d")
|
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 settings=adapter.ParseSettings(File.ReadAllText(localProfile.SettingsPath)); var pattern=settings.Patterns.Single(x=>x.Name==localProfile.PatternName); Plugins.ItemsSource=pattern.Plugins; Plugins.IsEnabled=ListMode.IsChecked==true; foreach(var item in Plugins.Items)Plugins.SelectedItems.Add(item); PluginStatus.Text=AutomaticMode.IsChecked==true?"Плагины определены автоматически для выбранного проекта.":"Выберите допустимый список плагинов."; StartButton.IsEnabled=true; Status.Text="Готово к запуску."; return;
|
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);
|
||||||
}
|
}
|
||||||
PluginStatus.Text="Для выбранного продукта нет approved local mapping пакетов."; Plugins.ItemsSource=null; Plugins.IsEnabled=false; StartButton.IsEnabled=false; Status.Text="Источник пакета недоступен для выбранного продукта.";
|
return missing;
|
||||||
}
|
}
|
||||||
private async void Start_Click(object sender,System.Windows.RoutedEventArgs e)
|
|
||||||
|
private async void Start_Click(object sender, System.Windows.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if(localProfile is null||selected?.Id!="plant3d"||projectPath is null){Status.Text="Не удалось определить список плагинов.";return;}
|
if (localProfile is null || selected?.Id != "plant3d" || projectPath is null || !StartButton.IsEnabled) return;
|
||||||
try { StartButton.IsEnabled=false; Status.Text="Подготовка плагинов…"; var prepared=await localService.PrepareAsync(localProfile,CancellationToken.None); Status.Text="Запуск Autodesk…"; var result=await localService.LaunchAsync(prepared,CancellationToken.None); Status.Text=result.TerminalState=="Succeeded"?$"Autodesk запущен (PID {result.ProcessId}).":"Не удалось подтвердить загрузку пакета; диагностика сохранена."; StartButton.IsEnabled=true; }
|
StartButton.IsEnabled = false;
|
||||||
catch(Exception ex){Status.Text="Ошибка: "+UserMessage(ex.Message);StartButton.IsEnabled=true;}
|
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");
|
||||||
}
|
}
|
||||||
private static string UserMessage(string code)=>code.Contains("NOT_FOUND",StringComparison.Ordinal)?"Не найден Plant 3D 2026":code.Contains("MANIFEST",StringComparison.Ordinal)?"Пакет повреждён":code.Contains("ACTIVATION",StringComparison.Ordinal)?"Не удалось активировать пакет":"Не удалось запустить Autodesk";
|
|
||||||
private void Help_Click(object sender,System.Windows.RoutedEventArgs e)=>System.Windows.MessageBox.Show("Выберите ПО, затем папку проекта. В автоматическом режиме Launcher сам подготовит допустимые пакеты и запустит Autodesk.","Справка");
|
|
||||||
private void Diagnostics_Click(object sender,System.Windows.RoutedEventArgs e)=>System.Windows.MessageBox.Show($"{selected?.Diagnostic}\n{ProjectPath.Text}\n{Status.Text}","Диагностика Launcher Next");
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ internal static class PilotCli
|
|||||||
if (!TryResolvePaths(config, out var paths, out error)) return Fail(Blocked, error!);
|
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 (!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!);
|
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"));
|
Directory.CreateDirectory(Path.Combine(root, "catalog"));
|
||||||
await File.WriteAllTextAsync(Path.Combine(root, "catalog", "P_Rec_Annot.manifest.json"), JsonSerializer.Serialize(manifest));
|
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");
|
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 (!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 (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 (!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);
|
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);
|
await journal.RecordAsync("HashPreparationStarted", "Started", null, CancellationToken.None);
|
||||||
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
|
var before = await ContentHashesAsync(options.FixtureMarker, journal, CancellationToken.None);
|
||||||
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
|
await journal.RecordAsync("HashPreparationCompleted", "Completed", null, CancellationToken.None);
|
||||||
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
|
var previewConfigPath = Path.Combine(options.EvidenceRoot, "automation-preview.config.json");
|
||||||
var previewEvidence = Path.Combine(options.EvidenceRoot, "preview-result.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;
|
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}");
|
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 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);
|
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)));
|
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 applyConfigPath = Path.Combine(options.EvidenceRoot, "automation-apply.config.json");
|
||||||
var applyEvidence = Path.Combine(options.EvidenceRoot, "apply-result.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}");
|
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 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);
|
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
|
// terminal evidence is durable. Killing an active AutoCAD
|
||||||
// command before that boundary would turn ForceAfterEvidence
|
// command before that boundary would turn ForceAfterEvidence
|
||||||
// into an unsafe general process killer.
|
// into an unsafe general process killer.
|
||||||
var evidenceTimeout = Task.Delay(TimeSpan.FromSeconds(Math.Clamp(config.GracefulExitTimeoutSeconds, 1, 120)));
|
// Evidence is written by the Plant process, not by a task owned
|
||||||
while (!exit.IsCompleted && !HasCompletedEvidence(evidencePath))
|
// by this host. Poll it with a bounded interval: waiting only on
|
||||||
if (await Task.WhenAny(exit, evidenceTimeout) == evidenceTimeout) break;
|
// 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;
|
if (exit.IsCompleted) gracefulExit = true;
|
||||||
else
|
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 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 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 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)
|
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; }
|
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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,10 +4,17 @@ using Launcher.Next.Core;
|
|||||||
|
|
||||||
namespace Launcher.Next.Application;
|
namespace Launcher.Next.Application;
|
||||||
|
|
||||||
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder);
|
public sealed record LegacyPluginDefinition(string Id, string Renewal, string Catalog, string Install, int DefinitionOrder, IReadOnlyList<string>? Machines = null)
|
||||||
public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList<LegacyPluginDefinition> Plugins);
|
{
|
||||||
|
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 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 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);
|
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("id")?.Value ?? throw new InvalidOperationException("LEGACY_PLUGIN_ID_MISSING"),
|
||||||
plugin.Element("renewal")?.Value ?? throw new InvalidOperationException("LEGACY_RENEWAL_MISSING"),
|
plugin.Element("renewal")?.Value ?? throw new InvalidOperationException("LEGACY_RENEWAL_MISSING"),
|
||||||
plugin.Element("catalog")?.Value ?? throw new InvalidOperationException("LEGACY_CATALOG_MISSING"),
|
plugin.Element("catalog")?.Value ?? throw new InvalidOperationException("LEGACY_CATALOG_MISSING"),
|
||||||
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index)).ToArray();
|
plugin.Element("install")?.Value ?? throw new InvalidOperationException("LEGACY_INSTALL_MISSING"), index,
|
||||||
return new LegacyProductPattern(Value("name"), Value("filter"), Value("roster"), plugins);
|
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();
|
}).ToArray();
|
||||||
return new(path.Element("help")?.Value ?? throw new InvalidOperationException("LEGACY_HELP_MISSING"), path.Element("care")?.Value ?? throw new InvalidOperationException("LEGACY_CARE_MISSING"), patterns);
|
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)
|
public LegacyDownloadSelection ParseDownloadList(string xml)
|
||||||
{
|
{
|
||||||
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root;
|
try
|
||||||
if (root?.Name != "root") throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_INVALID");
|
{
|
||||||
var ids = root.Elements("id").Select(x => x.Value.Trim()).ToArray();
|
using var reader = XmlReader.Create(new StringReader(xml), XmlSettings);
|
||||||
if (ids.Length == 0 || ids.Any(string.IsNullOrWhiteSpace) || ids.GroupBy(x => x, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1))
|
var root = XDocument.Load(reader).Root;
|
||||||
throw new InvalidOperationException("LEGACY_DOWNLOAD_SELECTION_INVALID");
|
if (root?.Name != "root") throw new InvalidOperationException("DOWNLOAD_LIST_ROOT_INVALID");
|
||||||
return new(ids);
|
// 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)
|
public IReadOnlyList<PackageRequirement> Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay)
|
||||||
{
|
{
|
||||||
var result = new List<PackageRequirement>();
|
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();
|
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");
|
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)); }
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace Launcher.Next.Application;
|
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 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);
|
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 class LegacyProductSelections
|
||||||
{
|
{
|
||||||
@@ -26,12 +26,13 @@ public static class LegacyProductSelections
|
|||||||
return Path.GetFullPath(Path.Combine(projectDirectory, "..", "_System", product.Pattern, "Applications", "download_list.xml"));
|
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)
|
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);
|
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");
|
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 ids = parser.ParseDownloadList(File.ReadAllText(roster)).PluginIds;
|
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");
|
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());
|
return new(roster, manualXml is null ? "Automatic" : "Manual", ids, product.UnmappedLegacyIds.Intersect(ids, StringComparer.OrdinalIgnoreCase).ToArray(), snapshot?.LocalSnapshotPath, snapshot?.Sha256);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,14 +28,27 @@ public sealed class StartupScriptBuilder : IStartupScriptBuilder
|
|||||||
var diagnostic = paths.Diagnostic
|
var diagnostic = paths.Diagnostic
|
||||||
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
? "_.CMDECHO\n1\n_.NOMUTT\n0\n_.LOGFILEMODE\n1\n"
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
var commands = paths.Apply
|
var commands = paths.Selection is null ? BuildDefaultCommands(paths) : BuildSelectedEntityCommands(paths);
|
||||||
? "_.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";
|
|
||||||
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
return dialogFreeOpen + "_.OPEN\n" + quote(fixture) + "\n" + diagnostic + BuildNetloadScript(paths.ValidatedPackageRoot, paths.EntryAssembly) +
|
||||||
"PHP_REC_AUTOMATION_PREPARE\n" + commands + "PHP_REC_AUTOMATION_FINALIZE\n";
|
"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
|
public sealed class DryRunAutodeskProcessLauncher : IAutodeskProcessLauncher
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ public sealed record CachedPackageResolution(string PackageId, string Version, s
|
|||||||
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
public interface ILastKnownGoodPackageResolver { Task<CachedPackageResolution?> ResolveAsync(PackageRequirement requirement, CancellationToken cancellationToken); }
|
||||||
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
|
public interface IPackageRequirementCacheBinder { Task BindAsync(PackageRequirement requirement, string version, CancellationToken cancellationToken); }
|
||||||
public interface IAutodeskProcessLauncher { PreparedLaunch Prepare(LaunchRequest request); }
|
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 IStartupScriptBuilder { string BuildNetloadScript(string validatedPackageRoot, string entryAssembly); string BuildAutomationScript(ResolvedAutomationScriptPaths paths); }
|
||||||
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
public interface ILauncherConfigurationProvider { LauncherMode GetMode(); }
|
||||||
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
public interface ILauncherTelemetry { void Write(string eventType, IReadOnlyDictionary<string, object?>? fields = null); }
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ public sealed record PilotLocalConfiguration
|
|||||||
public bool AutomationDiagnostic { get; init; }
|
public bool AutomationDiagnostic { get; init; }
|
||||||
public string? AutomationConfigurationPath { get; init; }
|
public string? AutomationConfigurationPath { get; init; }
|
||||||
public string? FixtureMarkerPath { 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
|
// Test-only: permits a pilot process to be terminated after its plugin has
|
||||||
// persisted completed automation evidence. It never represents graceful exit.
|
// persisted completed automation evidence. It never represents graceful exit.
|
||||||
public bool ForceAfterEvidence { get; init; }
|
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 IReadOnlyDictionary<string, string?> PathVariables { get; init; } = new Dictionary<string, string?>();
|
||||||
public static PilotLocalConfiguration SafeDefault { get; } = new();
|
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 enum AcadLaunchMode { Production, NextPilotPreview, NextPilotApply, LifecycleProbe }
|
||||||
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
public sealed record PilotConfigurationLoadResult(PilotLocalConfiguration Configuration, IReadOnlyList<string> Errors)
|
||||||
{ public bool IsValid => Errors.Count == 0; }
|
{ 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");
|
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;
|
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");
|
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 (!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.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");
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -116,7 +116,7 @@ public sealed class PilotExecutionService(
|
|||||||
scriptPath = paths.Require(LauncherPathVariableNames.AutomationScriptPath);
|
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");
|
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)!);
|
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);
|
await File.WriteAllTextAsync(scriptPath, script, cancellationToken);
|
||||||
}
|
}
|
||||||
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
|
var arguments = new AcadArgumentBuilder().Build(request.Preparation.Configuration.LaunchMode, automation ? scriptPath : null);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.Application\Launcher.Next.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Launcher.Next.Infrastructure\Launcher.Next.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
<ProjectReference Include="..\..\Launcher.Next.Autodesk\Launcher.Next.Autodesk.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Launcher.Next.App\Launcher.Next.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
|
<ItemGroup><None Update="fixtures\p-rec-annot-pilot.manifest.json" CopyToOutputDirectory="PreserveNewest" /></ItemGroup>
|
||||||
</Project>
|
</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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,14 +70,15 @@ public sealed class LegacyProductSelectionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ProductIncompatibleAndDuplicateIds_AreBlocked()
|
public void ProductIncompatibleIds_AreBlocked_AndDuplicateIdsRemainDiagnosticInput()
|
||||||
{
|
{
|
||||||
using var fixture = new SelectionFixture();
|
using var fixture = new SelectionFixture();
|
||||||
var project = fixture.ProjectFile("Project.xml");
|
var project = fixture.ProjectFile("Project.xml");
|
||||||
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>unexpected</id></root>");
|
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);
|
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>");
|
fixture.WriteAutomatic("Plant PnID_2019", "<root><id>1111</id><id>1111</id></root>");
|
||||||
Assert.Equal("LEGACY_DOWNLOAD_SELECTION_INVALID", Assert.Throws<InvalidOperationException>(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message);
|
var duplicatePlan = LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter());
|
||||||
|
Assert.Equal(["1111", "1111"], duplicatePlan.OrderedIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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); }
|
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); }
|
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_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_HasTrailingNewline() => Assert.EndsWith("\n", "PHP_REC_AUTOMATION_FINALIZE\n");
|
||||||
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
|
[Fact] public void DiagnosticScript_RecordsEncoding() => Assert.Equal("utf-8", System.Text.Encoding.UTF8.WebName);
|
||||||
|
|||||||
@@ -42,6 +42,18 @@ public sealed class RosterAdaptersTests
|
|||||||
Assert.Single(requirements); Assert.Equal("PHIM.A", requirements[0].PackageId);
|
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]
|
[Fact]
|
||||||
public void LegacyDownloadList_DoesNotInventPluginMetadata()
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user