diff --git a/src/Kompas.Mcp.Core/Interop/NativeMethods.cs b/src/Kompas.Mcp.Core/Interop/NativeMethods.cs new file mode 100644 index 0000000..0087575 --- /dev/null +++ b/src/Kompas.Mcp.Core/Interop/NativeMethods.cs @@ -0,0 +1,40 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; + +namespace Kompas.Mcp.Core.Interop; + +/// +/// Нативные функции для attach к уже запущенному COM-серверу. +/// В .NET 5+ Marshal.GetActiveObject отсутствует, поэтому используем +/// CLSIDFromProgID (ole32) + GetActiveObject (oleaut32) напрямую. +/// +[SupportedOSPlatform("windows")] +internal static class NativeMethods +{ + [DllImport("ole32.dll", PreserveSig = false)] + private static extern void CLSIDFromProgID( + [MarshalAs(UnmanagedType.LPWStr)] string progId, out Guid clsid); + + [DllImport("oleaut32.dll", PreserveSig = false)] + private static extern void GetActiveObject( + ref Guid rclsid, IntPtr pvReserved, + [MarshalAs(UnmanagedType.IUnknown)] out object ppunk); + + /// + /// Подключиться к запущенному экземпляру COM-сервера по ProgID. + /// Возвращает null, если ProgID не зарегистрирован или экземпляр не запущен. + /// + public static object? GetActiveObject(string progId) + { + try + { + CLSIDFromProgID(progId, out Guid clsid); + GetActiveObject(ref clsid, IntPtr.Zero, out object obj); + return obj; + } + catch (COMException) + { + return null; // MK_E_UNAVAILABLE / CO_E_CLASSSTRING и т.п. + } + } +} diff --git a/src/Kompas.Mcp.Core/KompasConnectionInfo.cs b/src/Kompas.Mcp.Core/KompasConnectionInfo.cs new file mode 100644 index 0000000..28997a2 --- /dev/null +++ b/src/Kompas.Mcp.Core/KompasConnectionInfo.cs @@ -0,0 +1,20 @@ +namespace Kompas.Mcp.Core; + +/// Сведения об установленном подключении к КОМПАС. +public sealed record KompasConnectionInfo +{ + /// ProgID, по которому удалось подключиться (например, KOMPAS.Application.5). + public required string ProgId { get; init; } + + /// true — экземпляр КОМПАС запущен этим процессом; false — присоединились к работающему. + public required bool WasLaunched { get; init; } + + /// Версия системы в формате Major.Minor.Release. + public required string Version { get; init; } + + /// Номер сборки внутри версии. + public required int BuildNumber { get; init; } + + /// Редакция (по ProgID): "Home/LT" для KOMPASLT.*, иначе "Full". + public required string Edition { get; init; } +} diff --git a/src/Kompas.Mcp.Core/KompasSession.cs b/src/Kompas.Mcp.Core/KompasSession.cs new file mode 100644 index 0000000..4988da9 --- /dev/null +++ b/src/Kompas.Mcp.Core/KompasSession.cs @@ -0,0 +1,154 @@ +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Kompas.Mcp.Core.Interop; +using Kompas.Mcp.Core.Threading; +using Kompas6API5; +using KompasAPI7; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Kompas.Mcp.Core; + +/// +/// Подключение к КОМПАС и владение корневыми COM-объектами на время жизни сервера. +/// +/// Подключается через API5 (), так как он нужен для построения 3D +/// (ksPart) и снимков (ksDocument3D), а из него получает современный API7 (). +/// Все обращения к COM выполняются на STA-потоке . +/// +/// +[SupportedOSPlatform("windows")] +public sealed class KompasSession : IDisposable +{ + // Порядок попыток. Нужен KompasObject (API5), поэтому .7 не используем как точку входа. + // Проверено на v24 Home: зарегистрирован KOMPAS.Application.5 (не LT). + private static readonly string[] ProgIds = + { + "KOMPAS.Application.5", + "KOMPASLT.Application.5", + }; + + private readonly KompasDispatcher _dispatcher; + private readonly ILogger _log; + + private KompasObject? _kompas; // API5 root + private IApplication? _app; // API7 application + private bool _launchedByUs; + + public KompasSession(KompasDispatcher dispatcher, ILogger? log = null) + { + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + _log = log ?? NullLogger.Instance; + } + + public bool IsConnected => _app is not null; + + /// Корневой объект API5. Доступен только на STA-потоке после подключения. + public KompasObject Kompas => + _kompas ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync."); + + /// Приложение API7. Доступно только на STA-потоке после подключения. + public IApplication Application => + _app ?? throw new InvalidOperationException("Нет подключения к КОМПАС. Сначала вызовите ConnectAsync."); + + /// Подключиться к КОМПАС: attach к запущенному или launch нового экземпляра. + public Task ConnectAsync(bool visible = true, CancellationToken ct = default) + => _dispatcher.InvokeAsync(() => ConnectCore(visible), ct); + + private KompasConnectionInfo ConnectCore(bool visible) + { + if (_app is not null) + return BuildInfo(LastProgId); + + // 1) attach к уже запущенному экземпляру + foreach (var id in ProgIds) + { + if (NativeMethods.GetActiveObject(id) is KompasObject ko) + { + _kompas = ko; + LastProgId = id; + _launchedByUs = false; + _log.LogInformation("Присоединились к запущенному КОМПАС ({ProgId})", id); + break; + } + } + + // 2) запустить новый экземпляр + if (_kompas is null) + { + foreach (var id in ProgIds) + { + var type = Type.GetTypeFromProgID(id, throwOnError: false); + if (type is null) continue; + try + { + if (Activator.CreateInstance(type) is KompasObject ko) + { + _kompas = ko; + LastProgId = id; + _launchedByUs = true; + _log.LogInformation("Запустили новый КОМПАС ({ProgId})", id); + break; + } + } + catch (Exception ex) + { + _log.LogWarning(ex, "Не удалось создать экземпляр {ProgId}", id); + } + } + } + + if (_kompas is null) + throw new InvalidOperationException( + "Не удалось подключиться к КОМПАС. Убедитесь, что КОМПАС-3D установлен и запускается."); + + _app = (IApplication?)_kompas.ksGetApplication7() + ?? throw new InvalidOperationException("ksGetApplication7() вернул null — API7 недоступен."); + + _app.Visible = visible; + + return BuildInfo(LastProgId); + } + + private string LastProgId { get; set; } = string.Empty; + + private KompasConnectionInfo BuildInfo(string progId) + { + int major = 0, minor = 0, release = 0, build = 0; + try { _app!.GetSystemVersion(out major, out minor, out release, out build); } + catch (Exception ex) { _log.LogWarning(ex, "GetSystemVersion недоступен"); } + + int buildNumber = 0; + try { buildNumber = _app!.BuildNumber; } + catch (Exception ex) { _log.LogWarning(ex, "BuildNumber недоступен"); } + + return new KompasConnectionInfo + { + ProgId = progId, + WasLaunched = _launchedByUs, + Version = $"{major}.{minor}.{release}", + BuildNumber = buildNumber, + Edition = progId.StartsWith("KOMPASLT", StringComparison.OrdinalIgnoreCase) ? "Home/LT" : "Full", + }; + } + + public void Dispose() + { + // Освобождаем COM-объекты на том же STA-потоке, на котором они созданы. + try + { + _dispatcher.InvokeAsync(() => + { + // Quit не вызываем: не «убиваем» КОМПАС пользователя (см. docs/OPEN_QUESTIONS.md). + if (_app is not null && Marshal.IsComObject(_app)) Marshal.ReleaseComObject(_app); + if (_kompas is not null && Marshal.IsComObject(_kompas)) Marshal.ReleaseComObject(_kompas); + _app = null; + _kompas = null; + }).GetAwaiter().GetResult(); + } + catch (ObjectDisposedException) + { + // Диспетчер уже остановлен — COM освободит GC-финализатор. + } + } +} diff --git a/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs b/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs new file mode 100644 index 0000000..22e5d62 --- /dev/null +++ b/src/Kompas.Mcp.Core/Threading/KompasDispatcher.cs @@ -0,0 +1,111 @@ +using System.Collections.Concurrent; +using System.Runtime.Versioning; + +namespace Kompas.Mcp.Core.Threading; + +/// +/// Выделенный STA-поток, который владеет всеми COM-вызовами к КОМПАС. +/// +/// КОМПАС — однопоточный STA COM-сервер в единственном экземпляре, поэтому весь доступ +/// к нему сериализуется через этот поток. Асинхронные обработчики MCP-инструментов +/// маршалят работу сюда через . +/// +/// +/// v1: простая очередь задач без явного насоса сообщений Windows. Для исходящих +/// синхронных вызовов из out-of-process клиента этого достаточно (COM сам обслуживает +/// STA на время вызова). Если при интеграции появятся зависания на колбэках/диалогах — +/// заменить цикл на насос сообщений (WPF Dispatcher / нативный GetMessage). См. docs/OPEN_QUESTIONS.md. +/// +/// +[SupportedOSPlatform("windows")] +public sealed class KompasDispatcher : IDisposable +{ + private readonly Thread _thread; + private readonly BlockingCollection _queue = new(); + private readonly ManualResetEventSlim _ready = new(false); + private volatile bool _disposed; + + public KompasDispatcher() + { + _thread = new Thread(ThreadProc) + { + IsBackground = true, + Name = "KompasSTA", + }; + _thread.SetApartmentState(ApartmentState.STA); + _thread.Start(); + _ready.Wait(); + } + + /// Выполняется ли вызывающий код уже на STA-потоке диспетчера. + public bool IsOnDispatcherThread => Thread.CurrentThread == _thread; + + /// Поставить функцию в очередь STA-потока и дождаться результата. + public Task InvokeAsync(Func func, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(func); + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + if (_disposed) + { + tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher))); + return tcs.Task; + } + + // Уже на STA-потоке — выполняем синхронно, иначе будет дедлок (ждать себя же). + if (IsOnDispatcherThread) + { + try { tcs.SetResult(func()); } + catch (Exception ex) { tcs.SetException(ex); } + return tcs.Task; + } + + try + { + _queue.Add(() => + { + if (ct.IsCancellationRequested) { tcs.TrySetCanceled(ct); return; } + try { tcs.TrySetResult(func()); } + catch (Exception ex) { tcs.TrySetException(ex); } + }); + } + catch (InvalidOperationException) + { + tcs.SetException(new ObjectDisposedException(nameof(KompasDispatcher))); + } + + return tcs.Task; + } + + /// Поставить действие в очередь STA-потока и дождаться завершения. + public Task InvokeAsync(Action action, CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(action); + return InvokeAsync(() => { action(); return true; }, ct); + } + + private void ThreadProc() + { + _ready.Set(); + foreach (var work in _queue.GetConsumingEnumerable()) + { + // Исключения каждой задачи уже перехвачены и переданы в её TaskCompletionSource. + work(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + + _queue.CompleteAdding(); + if (!_thread.Join(TimeSpan.FromSeconds(10))) + { + // STA-поток не завершился (возможно, висит COM-вызов) — оставляем как фон. + } + + _queue.Dispose(); + _ready.Dispose(); + } +} diff --git a/tests/Kompas.Mcp.Tests/DispatcherTests.cs b/tests/Kompas.Mcp.Tests/DispatcherTests.cs new file mode 100644 index 0000000..5d6af39 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/DispatcherTests.cs @@ -0,0 +1,54 @@ +using Kompas.Mcp.Core.Threading; + +namespace Kompas.Mcp.Tests; + +/// Юнит-тесты STA-диспетчера. COM не требуется. +[Trait("Category", "Unit")] +public sealed class DispatcherTests +{ + [Fact] + public async Task Runs_work_on_STA_thread() + { + using var d = new KompasDispatcher(); + var state = await d.InvokeAsync(() => Thread.CurrentThread.GetApartmentState()); + Assert.Equal(ApartmentState.STA, state); + } + + [Fact] + public async Task Serializes_all_work_on_one_thread() + { + using var d = new KompasDispatcher(); + var id1 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId); + var id2 = await d.InvokeAsync(() => Environment.CurrentManagedThreadId); + Assert.Equal(id1, id2); + Assert.NotEqual(Environment.CurrentManagedThreadId, id1); + } + + [Fact] + public async Task Propagates_exceptions_to_caller() + { + using var d = new KompasDispatcher(); + await Assert.ThrowsAsync( + () => d.InvokeAsync(() => throw new InvalidOperationException("boom"))); + } + + [Fact] + public async Task Throws_ObjectDisposed_after_dispose() + { + var d = new KompasDispatcher(); + d.Dispose(); + await Assert.ThrowsAsync(() => d.InvokeAsync(() => { })); + } + + [Fact] + public async Task Nested_invoke_runs_inline_without_deadlock() + { + using var d = new KompasDispatcher(); + var result = await d.InvokeAsync(() => + { + // Вложенный вызов на том же потоке должен выполниться синхронно, без дедлока. + return d.InvokeAsync(() => 42).GetAwaiter().GetResult(); + }); + Assert.Equal(42, result); + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs b/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs new file mode 100644 index 0000000..2d0da18 --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/ConnectionTests.cs @@ -0,0 +1,31 @@ +namespace Kompas.Mcp.Tests.Integration; + +/// Интеграция: подключение к КОМПАС. Требует установленный КОМПАС-3D. +[Trait("Category", "Integration")] +[Collection(KompasCollection.Name)] +public sealed class ConnectionTests +{ + private readonly KompasFixture _fx; + + public ConnectionTests(KompasFixture fx) => _fx = fx; + + [Fact] + public void Connects_to_kompas() + { + Assert.True(_fx.Session.IsConnected); + } + + [Fact] + public void Reports_version_24() + { + Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Version)); + Assert.StartsWith("24", _fx.Info.Version); + } + + [Fact] + public void Reports_progid_and_edition() + { + Assert.Contains("Application.5", _fx.Info.ProgId); + Assert.False(string.IsNullOrWhiteSpace(_fx.Info.Edition)); + } +} diff --git a/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs b/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs new file mode 100644 index 0000000..de18c9b --- /dev/null +++ b/tests/Kompas.Mcp.Tests/Integration/KompasFixture.cs @@ -0,0 +1,35 @@ +using Kompas.Mcp.Core; +using Kompas.Mcp.Core.Threading; + +namespace Kompas.Mcp.Tests.Integration; + +/// +/// Общая фикстура интеграционных тестов: один STA-диспетчер и одно подключение к КОМПАС +/// на весь набор тестов коллекции (КОМПАС присоединяется/запускается единожды). +/// +public sealed class KompasFixture : IDisposable +{ + public KompasDispatcher Dispatcher { get; } + public KompasSession Session { get; } + public KompasConnectionInfo Info { get; } + + public KompasFixture() + { + Dispatcher = new KompasDispatcher(); + Session = new KompasSession(Dispatcher); + Info = Session.ConnectAsync(visible: true).GetAwaiter().GetResult(); + } + + public void Dispose() + { + Session.Dispose(); + Dispatcher.Dispose(); + } +} + +/// Коллекция, позволяющая делить одно подключение между классами интеграционных тестов. +[CollectionDefinition(Name)] +public sealed class KompasCollection : ICollectionFixture +{ + public const string Name = "kompas"; +} diff --git a/tests/Kompas.Mcp.Tests/UnitTest1.cs b/tests/Kompas.Mcp.Tests/UnitTest1.cs deleted file mode 100644 index c583d30..0000000 --- a/tests/Kompas.Mcp.Tests/UnitTest1.cs +++ /dev/null @@ -1,10 +0,0 @@ -namespace Kompas.Mcp.Tests; - -public class UnitTest1 -{ - [Fact] - public void Test1() - { - - } -}