Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 40 additions & 23 deletions src/EtabSharp/Core/ETABSApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;


namespace EtabSharp.Core;
Expand Down Expand Up @@ -176,9 +177,23 @@ public void Close(bool savePrompt = false)
}

/// <summary>
/// Disposes this wrapper. Calls ApplicationExit(false) — does NOT save.
/// For Mode A (attach) flows, do NOT dispose — release COM manually via ComCleanup.
/// For Mode B (hidden) flows, disposing is correct and will exit the hidden instance.
/// Disposes this wrapper by releasing the COM references it holds. It does <b>not</b>
/// call <c>ApplicationExit</c> and never shuts ETABS down.
///
/// <para><b>Attached / external session</b> (<see cref="ETABSWrapper.Connect"/>,
/// <see cref="ETABSWrapper.ConnectToProcess"/>): disposing on its own is the correct
/// and complete cleanup. The user's ETABS stays running, which is the point — the
/// session was never ours to end.</para>
///
/// <para><b>Caller-owned session being shut down</b> (a hidden instance from
/// <see cref="ETABSWrapper.CreateNew"/>, or a handle wrapped with
/// <see cref="ETABSWrapper.WrapExisting"/>): request the authoritative
/// <c>app.Application.ApplicationExit(false)</c> and resolve the process exit first,
/// then dispose. Dispose is never a substitute for that exit — it releases references,
/// not the application — and CSI documents that the <c>cSapModel</c> reference should
/// be dropped after <c>ApplicationExit</c>.</para>
///
/// <para>Safe to call more than once.</para>
/// </summary>
public void Dispose()
{
Expand All @@ -187,27 +202,15 @@ public void Dispose()

// Only attempt COM cleanup on Windows platforms where Marshal.ReleaseComObject is supported.
// This prevents CA1416 diagnostics and avoids calling Windows-only runtime APIs on other platforms.
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
if (OperatingSystem.IsWindows())
{
try
{
// Release child managers first (reverse init order)
if (_model.IsValueCreated)
Marshal.ReleaseComObject(_model.Value);

if (_application.IsValueCreated)
Marshal.ReleaseComObject(_application.Value);

// Release raw COM refs last (parents after children)
Marshal.ReleaseComObject(_sapModel);
Marshal.ReleaseComObject(_api);

_logger.LogInformation("COM references released");
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error releasing COM objects: {Message}", ex.Message);
}
// Only _sapModel and _api are real COM references. _model and _application are
// ordinary managed wrappers around them, and Marshal.ReleaseComObject rejects a
// non-COM object — releasing them here used to throw and, because every release
// shared one try block, could skip the two releases that actually matter.
// Each real reference is now released independently.
ReleaseComReference(_sapModel, nameof(SapModel));
ReleaseComReference(_api, "cOAPI");
}
else
{
Expand All @@ -217,6 +220,20 @@ public void Dispose()
GC.SuppressFinalize(this);
}

[SupportedOSPlatform("windows")]
private void ReleaseComReference(object comReference, string name)
{
try
{
Marshal.ReleaseComObject(comReference);
_logger.LogInformation("COM reference released: {Reference}", name);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error releasing COM reference {Reference}: {Message}", name, ex.Message);
}
}

#region Advanced / Raw Access

/// <summary>
Expand Down
86 changes: 83 additions & 3 deletions src/EtabSharp/Core/ETABSWrapper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ namespace EtabSharp.Core;
/// Factory for creating and connecting to ETABS v22+ instances.
/// Returns ETABSApplication — the single entry point for all ETABS interaction.
///
/// Two usage patterns:
/// ETABSWrapper.Connect() — Mode A: attach to user's running ETABS
/// ETABSWrapper.CreateNew() — Mode B: start a new hidden instance
/// Three usage patterns:
/// ETABSWrapper.Connect() — Mode A: attach to the user's running ETABS
/// ETABSWrapper.CreateNew() — Mode B: start a new hidden instance
/// ETABSWrapper.WrapExisting() — Mode C: wrap a cOAPI the caller created and started itself
/// </summary>
public static class ETABSWrapper
{
Expand All @@ -21,6 +22,85 @@ public static class ETABSWrapper

#region Public Factory Methods

/// <summary>
/// Mode C: wraps a <see cref="ETABSv1.cOAPI"/> the caller has <b>already created and
/// already started</b>, without performing any lifecycle action of its own.
///
/// <para>This is deliberately low level. It exists for callers that must own the raw
/// CSI lifecycle and the exact OS process identity themselves — creating the object
/// with <c>cHelper.CreateObject(path)</c>, checking <c>cOAPI.ApplicationStart()</c>,
/// proving which process they own, and calling <c>cSapModel.InitializeNewModel()</c> —
/// and only then want EtabSharp's model and domain abstractions over that exact
/// instance. Callers that do not need that control should use
/// <see cref="Connect"/> or <see cref="CreateNew"/> instead.</para>
///
/// <para>This method performs <b>no</b> lifecycle or discovery work: it does not create
/// an object, does not call <c>ApplicationStart</c>, does not attach through
/// <c>GetObject</c>/<c>GetObjectProcess</c> or the ROT, does not enumerate or select
/// ETABS processes, and never calls <c>Hide</c>, <c>Unhide</c> or <c>ApplicationExit</c>.
/// The only member it touches on <paramref name="api"/> is <c>SapModel</c>.</para>
///
/// <para><b>Ownership:</b> the caller owns the application lifecycle. Wrapping does not
/// transfer it, and disposing the returned wrapper releases COM references only — it
/// never exits ETABS. When the caller is shutting that session down, it must request
/// the authoritative <c>ApplicationExit(false)</c> and resolve the process exit itself,
/// and dispose afterwards.</para>
/// </summary>
/// <param name="api">
/// An existing, already-started API object. The exact instance is preserved; nothing
/// is created or re-attached on the caller's behalf.
/// </param>
/// <param name="majorVersion">ETABS major version of the running instance (22 or newer).</param>
/// <param name="apiVersion">OAPI version reported by that instance; 0 if the caller could not read it.</param>
/// <param name="fullVersion">Full ETABS version string of the running instance, e.g. "23.3.0".</param>
/// <param name="logger">Optional logger for diagnostics.</param>
/// <exception cref="ArgumentNullException"><paramref name="api"/> is null.</exception>
/// <exception cref="ArgumentException"><paramref name="fullVersion"/> is null or blank.</exception>
/// <exception cref="ArgumentOutOfRangeException">
/// <paramref name="majorVersion"/> is below the minimum supported version, or
/// <paramref name="apiVersion"/> is negative.
/// </exception>
/// <exception cref="InvalidOperationException">
/// <paramref name="api"/> exposes no <c>SapModel</c>. An object that was never started
/// is not usable, and this method will not start it.
/// </exception>
public static ETABSApplication WrapExisting(
ETABSv1.cOAPI api,
int majorVersion,
double apiVersion,
string fullVersion,
ILogger<ETABSApplication>? logger = null)
{
ArgumentNullException.ThrowIfNull(api);

if (string.IsNullOrWhiteSpace(fullVersion))
{
throw new ArgumentException(
"Full version must be supplied by the caller; wrapping does not discover it.",
nameof(fullVersion));
}

if (majorVersion < MINIMUM_SUPPORTED_VERSION)
{
throw new ArgumentOutOfRangeException(
nameof(majorVersion),
majorVersion,
$"ETABS v{MINIMUM_SUPPORTED_VERSION} is the minimum supported version.");
}

if (apiVersion < 0)
{
throw new ArgumentOutOfRangeException(
nameof(apiVersion),
apiVersion,
"OAPI version cannot be negative.");
}

// Unlike Connect/CreateNew this returns non-null or throws: the caller already holds
// a handle it proved, so a silent null would discard the reason wrapping failed.
return new ETABSApplication(api, majorVersion, apiVersion, fullVersion, logger);
}

/// <summary>
/// Mode A: Connects to the currently running ETABS instance (v22+).
/// Does NOT call ApplicationStart or Hide — attaches to whatever ETABS the user has open.
Expand Down
15 changes: 13 additions & 2 deletions test/EtabSharp.Test/EtabSharp.Test.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<RootNamespace>EtabSharp.Test</RootNamespace>
<TargetFrameworks>net10.0</TargetFrameworks>
<TargetFrameworks>net8.0;net10.0</TargetFrameworks>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>

Expand Down Expand Up @@ -40,8 +40,19 @@
<ProjectReference Include="..\..\src\EtabSharp\EtabSharp.csproj" />
</ItemGroup>

<!--
Mirrors the detection order in src\EtabSharp\EtabSharp.csproj: local lib\ override
first, then newest installed ETABS. Without this the tests resolve ETABSv1 only from
an ETABS 22 install, so a machine with only ETABS 23/24 cannot compile a test that
names an ETABSv1 type.
-->
<PropertyGroup>
<EtabsPath Condition="Exists('C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll')">C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="Exists('$(MSBuildThisFileDirectory)..\..\lib\ETABSv1.dll')">$(MSBuildThisFileDirectory)..\..\lib\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files\Computers and Structures\ETABS 24\ETABSv1.dll')">C:\Program Files\Computers and Structures\ETABS 24\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files (x86)\Computers and Structures\ETABS 24\ETABSv1.dll')">C:\Program Files (x86)\Computers and Structures\ETABS 24\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files\Computers and Structures\ETABS 23\ETABSv1.dll')">C:\Program Files\Computers and Structures\ETABS 23\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files (x86)\Computers and Structures\ETABS 23\ETABSv1.dll')">C:\Program Files (x86)\Computers and Structures\ETABS 23\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll')">C:\Program Files\Computers and Structures\ETABS 22\ETABSv1.dll</EtabsPath>
<EtabsPath Condition="'$(EtabsPath)' == '' AND Exists('C:\Program Files (x86)\Computers and Structures\ETABS 22\ETABSv1.dll')">C:\Program Files (x86)\Computers and Structures\ETABS 22\ETABSv1.dll</EtabsPath>
</PropertyGroup>

Expand Down
Loading