diff --git a/Launcher.Next.App/MainWindow.xaml.cs b/Launcher.Next.App/MainWindow.xaml.cs index b2ad8f1..632fa11 100644 --- a/Launcher.Next.App/MainWindow.xaml.cs +++ b/Launcher.Next.App/MainWindow.xaml.cs @@ -17,6 +17,7 @@ public partial class MainWindow : System.Windows.Window private LauncherProduct? selected; private string? projectPath; private string? manualRosterPath; + private string? selectedRosterSnapshotPath; private string stage = "ProductSelection"; private string code = "PRODUCT_NOT_SELECTED"; private string detail = ""; @@ -105,10 +106,10 @@ public partial class MainWindow : System.Windows.Window 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); - if (localProfile is not null && selected.Id == "plant3d" && !string.Equals(Path.GetFullPath(plan.RosterPath), Path.GetFullPath(localProfile.DownloadListPath), StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException("LOCAL_PARITY_ROSTER_PROFILE_MISMATCH"); + var plan = LegacyProductSelections.ReadRoster(product, projectPath, manualRosterPath, adapter, localProfile?.Root); orderedIds = string.Join(", ", plan.OrderedIds); + selectedRosterSnapshotPath = plan.SnapshotPath ?? plan.RosterPath; + if (plan.SnapshotSha256 is not null) detail = $"Roster snapshot SHA-256: {plan.SnapshotSha256}"; Plugins.ItemsSource = plan.OrderedIds; Plugins.IsEnabled = false; PluginStatus.Text = "Источник списка: " + (manualRosterPath is null ? "Автоматически" : "Выбран вручную"); @@ -126,7 +127,14 @@ public partial class MainWindow : System.Windows.Window Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026"); return; } - ValidateLocalProfileDefinitions(plan); + 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", "Готово к запуску"); @@ -134,13 +142,22 @@ public partial class MainWindow : System.Windows.Window catch (Exception ex) { ReportFailure(ex, "RosterResolutionFailed"); } } - private void ValidateLocalProfileDefinitions(LegacyPluginPlan plan) + private IReadOnlyList EvaluateLocalProfileDefinitions(LegacyPluginPlan plan) { var settings = adapter.ParseSettings(File.ReadAllText(localProfile!.SettingsPath)); var selectedPattern = settings.Patterns.SingleOrDefault(x => string.Equals(x.Name, localProfile.PatternName, StringComparison.Ordinal)); if (selectedPattern is null) throw new InvalidOperationException("LOCAL_PARITY_PATTERN_NOT_FOUND"); var overlay = LegacyCompatibilityOverlayParser.Parse(File.ReadAllText(localProfile.OverlayPath)); - adapter.Join(selectedPattern, new LegacyDownloadSelection(plan.OrderedIds), overlay); + var missing = new List(); + foreach (var id in plan.OrderedIds.Distinct(StringComparer.OrdinalIgnoreCase)) + { + var definitions = selectedPattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray(); + if (definitions.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED"); + if (definitions.Length > 1) throw new InvalidOperationException("AMBIGUOUS_LEGACY_PLUGIN_DEFINITION"); + if (!definitions[0].IsAllowedOn(Environment.MachineName)) { missing.Add(id + " (machine restricted)"); continue; } + if (!overlay.Plugins.Any(x => string.Equals(x.LegacyId, id, StringComparison.OrdinalIgnoreCase))) missing.Add(id); + } + return missing; } private async void Start_Click(object sender, System.Windows.RoutedEventArgs e) @@ -151,7 +168,7 @@ public partial class MainWindow : System.Windows.Window { preparationState = "InProgress"; Transition("PackagePreparation", "PACKAGE_PREPARING", "Подготовка плагинов"); - var profile = manualRosterPath is null ? localProfile : localProfile with { DownloadListPath = manualRosterPath }; + var profile = localProfile with { DownloadListPath = selectedRosterSnapshotPath ?? localProfile.DownloadListPath }; var prepared = await localService.PrepareAsync(profile, CancellationToken.None); preparationState = "Succeeded"; activationState = prepared.Activation.Outcome; @@ -174,6 +191,7 @@ public partial class MainWindow : System.Windows.Window Plugins.IsEnabled = false; StartButton.IsEnabled = false; pattern = expectedRoster = orderedIds = detail = ""; + selectedRosterSnapshotPath = null; mappingState = preparationState = activationState = bootstrapState = processState = "NotStarted"; processId = null; packageLoadMarker = "NotStarted"; diff --git a/Launcher.Next.Application/LauncherOperationFailure.cs b/Launcher.Next.Application/LauncherOperationFailure.cs index 75edf8e..209234d 100644 --- a/Launcher.Next.Application/LauncherOperationFailure.cs +++ b/Launcher.Next.Application/LauncherOperationFailure.cs @@ -7,10 +7,11 @@ 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), - "LEGACY_DOWNLOAD_SELECTION_INVALID" => new("PluginPlanFailed", "DOWNLOAD_LIST_INVALID", "Список плагинов проекта содержит недопустимые или повторяющиеся записи", 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_PLUGIN_ID_NOT_DEFINED" => new("PluginPlanFailed", "PLUGIN_DEFINITION_NOT_FOUND", "Для одного из плагинов отсутствует определение", false), - "AMBIGUOUS_LEGACY_PLUGIN_DEFINITION" => new("PluginPlanFailed", "PLUGIN_DEFINITION_AMBIGUOUS", "Определение плагина неоднозначно", 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), diff --git a/Launcher.Next.Application/LegacyConfigurationAdapters.cs b/Launcher.Next.Application/LegacyConfigurationAdapters.cs index 3625870..121008e 100644 --- a/Launcher.Next.Application/LegacyConfigurationAdapters.cs +++ b/Launcher.Next.Application/LegacyConfigurationAdapters.cs @@ -10,7 +10,11 @@ public sealed record LegacyPluginDefinition(string Id, string Renewal, string Ca } public sealed record LegacyProductPattern(string Name, string Filter, string Roster, IReadOnlyList Plugins, IReadOnlyList? Attributes = null, IReadOnlyList? Sources = null, string? Target = null, string? Loader = null, string? ObjectPath = null); public sealed record LegacyLauncherSettings(string Help, string CareMarker, IReadOnlyList Patterns); -public sealed record LegacyDownloadSelection(IReadOnlyList PluginIds); +public sealed record LegacyDownloadListEntry(int ElementIndex, string RawId, string NormalizedId, int? FirstOccurrenceIndex, string SourceShape); +public sealed record LegacyDownloadSelection(IReadOnlyList PluginIds, IReadOnlyList? Entries = null) +{ + public IReadOnlyList EffectivePluginIds => PluginIds.Distinct(StringComparer.OrdinalIgnoreCase).ToArray(); +} public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel); public sealed record LegacyCompatibilityOverlay(string MappingId, string Product, int ProductYear, string Architecture, bool AllowOfflineFallback, IReadOnlyList Plugins); @@ -42,18 +46,39 @@ public sealed class LegacyConfigurationAdapter public LegacyDownloadSelection ParseDownloadList(string xml) { - using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); var root = XDocument.Load(reader).Root; - if (root?.Name != "root") throw new InvalidOperationException("LEGACY_DOWNLOAD_LIST_INVALID"); - var ids = root.Elements("id").Select(x => x.Value.Trim()).ToArray(); - if (ids.Length == 0 || ids.Any(string.IsNullOrWhiteSpace) || ids.GroupBy(x => x, StringComparer.OrdinalIgnoreCase).Any(x => x.Count() > 1)) - throw new InvalidOperationException("LEGACY_DOWNLOAD_SELECTION_INVALID"); - return new(ids); + try + { + using var reader = XmlReader.Create(new StringReader(xml), XmlSettings); + var root = XDocument.Load(reader).Root; + if (root?.Name != "root") throw new InvalidOperationException("DOWNLOAD_LIST_ROOT_INVALID"); + // F2_Xml serialized only root/id and removed only exact empty strings. Real project files + // also contain source-grouped 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(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 Join(LegacyProductPattern pattern, LegacyDownloadSelection selection, LegacyCompatibilityOverlay overlay) { var result = new List(); - foreach (var id in selection.PluginIds) + foreach (var id in selection.EffectivePluginIds) { var definition = pattern.Plugins.Where(x => string.Equals(x.Id, id, StringComparison.OrdinalIgnoreCase)).ToArray(); if (definition.Length == 0) throw new InvalidOperationException("LEGACY_PLUGIN_ID_NOT_DEFINED"); diff --git a/Launcher.Next.Application/LegacyNetworkConfiguration.cs b/Launcher.Next.Application/LegacyNetworkConfiguration.cs index 4f6a523..4a8f84c 100644 --- a/Launcher.Next.Application/LegacyNetworkConfiguration.cs +++ b/Launcher.Next.Application/LegacyNetworkConfiguration.cs @@ -59,3 +59,30 @@ public static class LegacyNetworkConfiguration 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)); } +} diff --git a/Launcher.Next.Application/LegacyProductSelection.cs b/Launcher.Next.Application/LegacyProductSelection.cs index cd988bc..9890026 100644 --- a/Launcher.Next.Application/LegacyProductSelection.cs +++ b/Launcher.Next.Application/LegacyProductSelection.cs @@ -1,7 +1,7 @@ namespace Launcher.Next.Application; public sealed record LegacyProductSelection(string Id, string DisplayName, string Filter, string RequiredFileName, string Pattern, string[] SourceVerifiedLegacyIds, string[] UnmappedLegacyIds, bool SupportsPlantBootstrap); -public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList OrderedIds, IReadOnlyList UnmappedIds); +public sealed record LegacyPluginPlan(string RosterPath, string Source, IReadOnlyList OrderedIds, IReadOnlyList UnmappedIds, string? SnapshotPath = null, string? SnapshotSha256 = null); 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")); } - 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); 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"); - 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); } } diff --git a/tests/Launcher.Next.Tests/LegacyNetworkSettingsParserTests.cs b/tests/Launcher.Next.Tests/LegacyNetworkSettingsParserTests.cs index 6986276..543028d 100644 --- a/tests/Launcher.Next.Tests/LegacyNetworkSettingsParserTests.cs +++ b/tests/Launcher.Next.Tests/LegacyNetworkSettingsParserTests.cs @@ -26,4 +26,24 @@ public sealed class LegacyNetworkSettingsParserTests var plugin = new LegacyPluginDefinition("id", "", "catalog", "install", 0); Assert.True(plugin.IsAllowedOn(Environment.MachineName)); } + + [Fact] + public void RealOwnerRosterShape_UsesAreaIdsInSourceOrderAndPreservesDuplicateEvidence() + { + const string roster = "1first second firstnot-selected"; + 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(() => adapter.ParseDownloadList("")).Message); + Assert.Equal("DOWNLOAD_LIST_XML_MALFORMED", Assert.Throws(() => adapter.ParseDownloadList("")).Message); + } } diff --git a/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs b/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs index 20a68d9..08a298b 100644 --- a/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs +++ b/tests/Launcher.Next.Tests/LegacyProductSelectionTests.cs @@ -70,14 +70,15 @@ public sealed class LegacyProductSelectionTests } [Fact] - public void ProductIncompatibleAndDuplicateIds_AreBlocked() + public void ProductIncompatibleIds_AreBlocked_AndDuplicateIdsRemainDiagnosticInput() { using var fixture = new SelectionFixture(); var project = fixture.ProjectFile("Project.xml"); fixture.WriteAutomatic("Plant PnID_2019", "unexpected"); Assert.Equal("LEGACY_PLUGIN_PRODUCT_MISMATCH", Assert.Throws(() => LegacyProductSelections.ReadRoster(LegacyProductSelections.Pnid, project, null, new LegacyConfigurationAdapter())).Message); fixture.WriteAutomatic("Plant PnID_2019", "11111111"); - Assert.Equal("LEGACY_DOWNLOAD_SELECTION_INVALID", Assert.Throws(() => 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] diff --git a/tests/Launcher.Next.Tests/OwnerReviewStateTests.cs b/tests/Launcher.Next.Tests/OwnerReviewStateTests.cs index 6415224..8f5d1e3 100644 --- a/tests/Launcher.Next.Tests/OwnerReviewStateTests.cs +++ b/tests/Launcher.Next.Tests/OwnerReviewStateTests.cs @@ -16,7 +16,7 @@ public sealed class OwnerReviewStateTests } [Theory] - [InlineData("LEGACY_DOWNLOAD_SELECTION_INVALID", "PluginPlanFailed", "DOWNLOAD_LIST_INVALID")] + [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")]