6 Commits
10 changed files with 447 additions and 72 deletions
+31 -5
View File
@@ -21,7 +21,8 @@ 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 LocalParityPrepared(LocalParityLaunchProfile Profile, PackageRequirement Requirement,
PackageManifest Manifest, ImmutableLocalPackage Package, PackageActivationResult Activation, PackageManifest Manifest, ImmutableLocalPackage Package, PackageActivationResult Activation,
@@ -41,7 +42,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)
@@ -99,7 +106,8 @@ internal sealed class LocalParityLaunchService
} }
var loaded = runtimeConfirmed; var loaded = runtimeConfirmed;
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 = 3, launcherCommit = ThisCommit(), autodeskCommit = "68f5006", operationId, configuration = prepared.Profile.Name, selectedPattern = prepared.Profile.PatternName, settingsSnapshot, 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 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);
} }
@@ -129,6 +137,24 @@ internal sealed class LocalParityLaunchService
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)); }
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; } 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() != 3) throw new InvalidOperationException("LOCAL_PARITY_EVIDENCE_INVALID"); return final; }
private static string ThisCommit() { try { var start = new ProcessStartInfo("git", "rev-parse HEAD") { WorkingDirectory = AppContext.BaseDirectory, UseShellExecute = false, RedirectStandardOutput = true }; using var p = Process.Start(start); return p is null ? "unknown" : p.StandardOutput.ReadToEnd().Trim(); } catch { return "unknown"; } } 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"; }
}
} }
+140 -58
View File
@@ -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,194 @@ 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 stage = "ProductSelection";
internal MainWindow(LocalParityLaunchProfile profile) { localProfile=profile; InitializeComponent(); WireSourceVerifiedSelection(); Initialize(); } private string code = "PRODUCT_NOT_SELECTED";
private void WireSourceVerifiedSelection() private string detail = "";
{ private string pattern = "";
SelectProjectButton.Click -= SelectProject_Click; private string expectedRoster = "";
SelectProjectButton.Click += SelectProjectFile_Click; private string orderedIds = "";
AutomaticMode.Checked -= LoadMode_Changed; private string mappingState = "NotEvaluated";
ListMode.Checked -= LoadMode_Changed; private string preparationState = "NotStarted";
AutomaticMode.Checked += LoadModeChanged; private string activationState = "NotStarted";
ListMode.Checked += LoadModeChanged; 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="Выберите программное обеспечение.";
if(localProfile is not null) { Products.SelectedIndex=2; Status.Text="Выберите controlled non-production папку проекта."; }
}
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 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; new LauncherProduct("navisworks", "Navisworks Manage 2026", File.Exists(navisworks) ? navisworks : null, File.Exists(navisworks) ? "Найден" : "Navisworks Manage 2026 не найден"),
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;} new LauncherProduct("pnid", "Plant P&ID 2026", plant?.ExecutablePath, plant is null ? "Plant P&ID 2026 не найден" : "Используется установленный Plant runtime"),
projectPath=path; ProjectPath.Text=path; ResolvePlugins(); 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;
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);
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); 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");
orderedIds = string.Join(", ", plan.OrderedIds);
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 ? "Автоматически" : "Выбран вручную");
Transition("OrderedPluginIdsResolved", "PLUGIN_PLAN_RESOLVED", "Плагины определены");
mappingState = plan.UnmappedIds.Count == 0 ? "Mapped" : "Unmapped: " + string.Join(", ", plan.UnmappedIds);
if (plan.UnmappedIds.Count > 0)
{
StartButton.IsEnabled = false; StartButton.IsEnabled = false;
Status.Text = plan.UnmappedIds.Count == 0 Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", $"Для {product.DisplayName} отсутствуют пакеты 2026: {string.Join(", ", plan.UnmappedIds)}");
? "Packages for the selected plugins are not mapped to 2026 packages." return;
: $"Packages for {product.DisplayName} are not yet available for 2026: {string.Join(", ", plan.UnmappedIds)}";
} }
catch (Exception ex) if (localProfile is null || selected.Id != "plant3d")
{
StartButton.IsEnabled = false;
Transition("PackageMappingFailed", "PACKAGE_MAPPING_NOT_FOUND", "Для выбранных плагинов отсутствуют пакеты 2026");
return;
}
ValidateLocalProfileDefinitions(plan);
mappingState = "Mapped";
StartButton.IsEnabled = true;
Transition("Ready", "READY", "Готово к запуску");
}
catch (Exception ex) { ReportFailure(ex, "RosterResolutionFailed"); }
}
private void ValidateLocalProfileDefinitions(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);
}
private async void Start_Click(object sender, System.Windows.RoutedEventArgs e)
{
if (localProfile is null || selected?.Id != "plant3d" || projectPath is null || !StartButton.IsEnabled) return;
StartButton.IsEnabled = false;
try
{
preparationState = "InProgress";
Transition("PackagePreparation", "PACKAGE_PREPARING", "Подготовка плагинов");
var profile = manualRosterPath is null ? localProfile : localProfile with { DownloadListPath = manualRosterPath };
var prepared = await localService.PrepareAsync(profile, CancellationToken.None);
preparationState = "Succeeded";
activationState = prepared.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.ItemsSource = null;
Plugins.IsEnabled = false; Plugins.IsEnabled = false;
StartButton.IsEnabled = false; StartButton.IsEnabled = false;
Status.Text = ex.Message == "LEGACY_DOWNLOAD_LIST_NOT_FOUND" pattern = expectedRoster = orderedIds = detail = "";
? "Для выбранного проекта не найден список плагинов" mappingState = preparationState = activationState = bootstrapState = processState = "NotStarted";
: UserMessage(ex.Message); processId = null;
packageLoadMarker = "NotStarted";
} }
}
private void ResolvePlugins() private void Transition(string nextStage, string nextCode, string message, string? technicalDetail = null)
{ {
if(selected is null||projectPath is null)return; stage = nextStage;
if(localProfile is not null && selected.Id=="plant3d") code = nextCode;
detail = technicalDetail ?? "";
Status.Text = message;
}
private void ReportFailure(Exception exception, string fallbackStage)
{ {
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 failure = LauncherOperationFailures.Classify(exception.Message, fallbackStage, fallbackStage == "ProcessStartFailed");
Transition(failure.Stage, failure.Code, failure.UserMessage, exception.ToString());
} }
PluginStatus.Text="Для выбранного продукта нет approved local mapping пакетов."; Plugins.ItemsSource=null; Plugins.IsEnabled=false; StartButton.IsEnabled=false; Status.Text="Источник пакета недоступен для выбранного продукта.";
} private void Help_Click(object sender, System.Windows.RoutedEventArgs e) => System.Windows.MessageBox.Show("Выберите ПО и project file. В автоматическом режиме Launcher использует список плагинов, определённый проектом. Режим \"По списку\" позволяет явно выбрать download_list.xml.", "Справка");
private async void Start_Click(object sender,System.Windows.RoutedEventArgs e)
private void Diagnostics_Click(object sender, System.Windows.RoutedEventArgs e)
{ {
if(localProfile is null||selected?.Id!="plant3d"||projectPath is null){Status.Text="Не удалось определить список плагинов.";return;} 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}";
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; } System.Windows.MessageBox.Show(report, "Диагностика Launcher Next");
catch(Exception ex){Status.Text="Ошибка: "+UserMessage(ex.Message);StartButton.IsEnabled=true;}
} }
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");
} }
@@ -0,0 +1,21 @@
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),
"LEGACY_DOWNLOAD_SELECTION_INVALID" => new("PluginPlanFailed", "DOWNLOAD_LIST_INVALID", "Список плагинов проекта содержит недопустимые или повторяющиеся записи", 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),
_ 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,8 +4,11 @@ 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 LegacyDownloadSelection(IReadOnlyList<string> PluginIds);
public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel); public sealed record LegacyCompatibilityMapping(string LegacyId, string PackageId, string Version, string Channel);
@@ -27,8 +30,12 @@ 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);
} }
@@ -0,0 +1,61 @@
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)));
}
+9
View File
@@ -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.
+54
View 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
@@ -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,29 @@
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));
}
}
@@ -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("LEGACY_DOWNLOAD_SELECTION_INVALID", "PluginPlanFailed", "DOWNLOAD_LIST_INVALID")]
[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);
}
}