.NET Core で Marshal.GetActiveObject を再現してみたときのメモ

.NET Core には Marshal.GetActiveObject(String) メソッド (System.Runtime.InteropServices) | Microsoft Docs が存在しないため、試しに自分で実装してみたときのメモ。

なお、後から「そういえばリファレンスソースがあったよな…?」とおもって確認してみたら、Marshal.GetActiveObjectの部分もあったので、 下手に手実装せず、リファレンスソースをベースにした方がいいとは思います。

referencesource.microsoft.com


自分が作成したコード

Windows PowerShell 5.1、PowerShell Core 7.0 のどちらのAdd-Typeでも問題無く使用できることは確認。

using System;
using System.ComponentModel;
using System.Runtime.InteropServices;

namespace Example.Example /* いい感じに変えること */
{
    public static class COMSupport
    {
        public static object GetActiveObject(string progID)
        {
            const int S_OK = 0x0000;
            Guid clsId = Guid.Empty;
            if (S_OK != NativeMethods.CLSIDFromString(progID, out clsId))
            {
                throw new Win32Exception();
            }
            object com;
            if (S_OK != NativeMethods.GetActiveObject(clsId, IntPtr.Zero, out com))
            {
                throw new Win32Exception();
            }
            return com;
        }
        private static class NativeMethods
        {
            /// <summary>
            /// Retrieves a pointer to a running object that has been registered with OLE.
            /// </summary>
            /// <param name="rclsid">The class identifier (CLSID) of the active object from the OLE registration database.</param>
            /// <param name="pvReserved">Reserved for future use. Must be null.</param>
            /// <param name="ppunk">The requested active object.</param>
            /// <returns>If this function succeeds, it returns S_OK. Otherwise, it returns an HRESULT error code.</returns>
            /// <see cref="https://docs.microsoft.com/ja-jp/windows/win32/api/oleauto/nf-oleauto-getactiveobject"/>
            [DllImport(
                "oleaut32.dll",
                EntryPoint = "GetActiveObject", 
                CallingConvention = CallingConvention.Winapi,
                ExactSpelling = true,
                PreserveSig = true,
                SetLastError = true
            )]
            public static extern int GetActiveObject(
                [MarshalAs(UnmanagedType.LPStruct), In] Guid rclsid,
                [In] IntPtr pvReserved /* = System.IntPtr.Zero */,
                [MarshalAs(UnmanagedType.Interface), Out] out object ppunk
            );

            /// <summary>
            /// Converts a string generated by the StringFromCLSID function back into the original CLSID.
            /// </summary>
            /// <param name="lpsz">The string representation of the CLSID.</param>
            /// <param name="pclsid">A pointer to the CLSID.</param>
            /// <returns>This function can return the standard return value E_INVALIDARG, as well as the following values.</returns>
            [DllImport(
                "ole32.dll",
                EntryPoint = "CLSIDFromString",
                CallingConvention = CallingConvention.Winapi,
                CharSet = CharSet.Unicode,
                ExactSpelling = true,
                PreserveSig = true, 
                SetLastError = true
            )]
            public static extern int CLSIDFromString(
                [MarshalAs(UnmanagedType.LPWStr), In] string lpsz,
                [MarshalAs(UnmanagedType.Struct), Out] out Guid pclsid
            );
        }
    }
}

リファレンスソースとの違い

ProgID→CLSID変換方法

DLL関数の方の GetActiveObjectではオブジェクトの取得に、慣れ親しんだProgID(Excel.Applicationとか。人がオブジェクトを指定するためのもの)ではなく、CLSID(Windowsが対象を認識するためのID。GUID形式)を指定する。

そのProgIDからCLSIDの変換に使用しているDLL関数が異なっていた。

リファレンスソースは、CLSIDFromProgIDExを使用し、自分はCLSIDFromStringを使用している。

名前からするに、本来はCLSIDFromProgIDExを使うべきだが「レジストリを変更します」的な雰囲気の文言があったため、 VBAでExcelを使う - QiitaでProgIDからCLSIDへの変換に使われていたCLSIDFromStringを使用した。

.NET Framework内部でCLSIDFromProgIDExを使っている以上、それでいい気がするけれど、なんとなく気分の問題。

DLL関数のPreserveSigの設定

今回使用しているDLL関数はHRESULT(成功や失敗の理由を示す整数値)の返り値を返す。

このような場合に、DllImportPreserveSig フィールドfalseにし、返り値をvoidに変更するとDLL関数エラー時に自動でC#の例外に変換してくれる。

要するにPreserveSig = falseとしてDLL関数の返り値をvoidにすると、以下のように書いているところがただの呼び出しでOKになる。

if (S_OK != NativeMethods.CLSIDFromString(progID, out clsId))
{
    throw new Win32Exception();
}

ちゃんと書こうと思って、PreserveSig = trueとしたけれど、結局エラーにする以上trueにしても良かった気がする(usingで指定するものも減る)。

はまったこと

Guidを返り値で受け取る時のMarshalAsの定義

Guidを入力で使うときは[MarshalAs(UnmanagedType.LPStruct)]を付けるとよい、と聞いていたので 返り値の方にも間違えて付けてしまったところ、メモリアクセス違反のエラーでプロセスが落ちてしまった。

入力として渡す分には、ポインタを渡して参照してもらえればいいけれど、出力として貰う場合は[MarshalAs(UnmanagedType.Struct)]とする必要があった。

参照への参照の表現方法

DLL関数のGetActiveObjectの定義は以下のようになっており、ppunkIUnknown(COMオブジェクト)へのポインタのポインタ(参照への参照)となっている。

HRESULT GetActiveObject(
  REFCLSID rclsid,
  void     *pvReserved,
  IUnknown **ppunk
);

VBAであれば、ByRefでObject型を渡すように定義すればOKなので、C#でもそのままref object ppunkのように定義したら、以下のようなエラーが発生してしまった。

Specified OLE variant is invalid.
指定された OLE 変数が無効です。

適当に試したところ、以下のどちらかの方法であればCOMオブジェクトへの参照への参照を表現出来るようだった。

ref IntPtr ppunkとして、Marshal.GetObjectForIUnknownで変換する

まずは、COMオブジェクトへの参照として、ポインタIntPtrでやりとりし、取得したポインタをMarshal.GetObjectForIUnknownでCOMオブジェクトにする、という方法。

[MarshalAs(UnmanagedType.Interface)]を指定する

UnmanagedType.Interfaceを指定することで、その引数がCOMの型と認識され自動でCOMオブジェクトとしてくれる。 今回は引数をobjectで定義しているためUnmanagedType.IUnknownでも問題はない。

参考

GetActiveObject function (oleauto.h) - Win32 apps | Microsoft Docs
Marshal.GetActiveObject のリファレンスソース
GetActiveObject function (oleauto.h) - Win32 apps | Microsoft Docs
CLSIDFromProgIDEx function (combaseapi.h) - Win32 apps | Microsoft Docs
DllImportAttribute クラス (System.Runtime.InteropServices) | Microsoft Docs
MarshalAsAttribute クラス (System.Runtime.InteropServices) | Microsoft Docs
VBAでExcelを使う - Qiita
【Windows/C#】なるべく丁寧にDllImportを使う - Qiita