fix: report launcher failures by operation stage

This commit is contained in:
codex
2026-08-06 13:36:59 +03:00
parent 1c18f4893d
commit 876e4f5d50
3 changed files with 213 additions and 63 deletions
+135 -53
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;
@@ -16,106 +17,187 @@ public partial class MainWindow : System.Windows.Window
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)
};
}
@@ -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);
}
}