ScriptControlを64bit環境で使ったり、COMオブジェクトをCLSIDで初期化したりする

## はじめに

以下はJScriptで記述しますが、VBScriptやVBAでも使用可能な内容です。

32bit版しか用意されていないScriptControlを64bit環境で使うことを考えます。こちらで紹介されている、32bit版のPowerShellでインスタンス化してVBAに持ってくる例を見て閃きました。

https://qiita.com/nukie_53/items/8401232a2d626c7f8078

[たび](https://qiita.com/U-1F992/items/85a6de7abef384357a6f)[たび](https://qiita.com/U-1F992/items/12b7297e9661b29d5ab9)紹介しているように、`Shell32.Shell`（`"Shell.Application"`）クラスのインスタンスからたどって`Windows().Item().Put/SetProperty`メソッドを使用すると、プロセス間で値やオブジェクトを共有できます。これを利用して32bit版のPowerShellから`MSScriptControl.ScriptControl`クラスのインスタンスを持ち込むのはかんたんなのですが、かねてからオブジェクトを保持するPowerShellの終了処理に頭を悩ませていました。デストラクタのないJScript（ECMAScript 3）で終了フラグを設けて`finally`内でセットするなど考えてきましたが、あまりよい方法ではありませんでした。

そこで先の例を見て、PowerShellなら.NETのパワーで参照カウントを覗いて、減ったら終了すればよいということに気づいたというわけです。

## COMオブジェクトの参照カウント

とはいえ、さすがにCOMオブジェクトの参照カウントを直接確認する方法はありません。`System.Runtime.InteropServices.Marshal`クラスの`AddRef`メソッドを使用し参照カウントを1追加し（戻り値で追加後のカウントがわかるが捨てて）、`Release`メソッドでカウントを1減じて戻り値を得ることで、追加前の参照カウント値を得ます。Microsoft Learn公認の方法です……"Do not rely on the return value of AddRef"とありますが見なかったことにします。

https://learn.microsoft.com/en-us/dotnet/api/system.runtime.interopservices.marshal.addref?view=net-7.0

それぞれのメソッドは`IUnknown`インターフェースへのポインタを引数に取ります。`GetObjectForIUnknown`メソッドを使用して、COMオブジェクトから`IUnknown`のポインタを取得します。

```powershell
function Get-RefCount([System.IntPtr]$ptr){
  $_=[System.Runtime.InteropServices.Marshal]::AddRef($ptr);
  return [System.Runtime.InteropServices.Marshal]::Release($ptr);
}

$fso=New-Object -ComObject 'Scripting.FileSystemObject';
$ptr=[System.Runtime.InteropServices.Marshal]::GetIUnknownForObject($obj);

Get-RefCount $ptr
# おそらく多くの環境で2と表示される
# $fso自体と$ptrで2つ？

$fso
#
# Drives
# ------
# System.__ComObject
#
$fso
Get-RefCount $ptr
# 4？
# 使用するたびに増える
```

ここでCOMオブジェクト（例では`$fso`）を、同様に`"Shell.Application"`のインスタンスを生成して適当なキーで公開しながら、`while`ループで`Get-RefCount`を監視します。

```powershell
function Get-RefCount([System.IntPtr]$ptr){
  $_=[System.Runtime.InteropServices.Marshal]::AddRef($ptr);
  return [System.Runtime.InteropServices.Marshal]::Release($ptr);
}
function Expose-Object([object]$obj,[string]$key){
  $ptr=[System.Runtime.InteropServices.Marshal]::GetIUnknownForObject($obj);
  $sh=New-Object -ComObject 'Shell.Application';
  $sh.Windows().Item().PutProperty($key,$obj);
  $cnt=Get-RefCount $ptr;
  while((Get-RefCount $ptr) -ge $cnt){
    Start-Sleep -Milliseconds 1;
  }
}

$fso=New-Object -ComObject 'Scripting.FileSystemObject';
Expose-Object $fso 'key';
```

JScript側では、あらかじめキーに`null`をセットしておき、`null`でなくなったタイミングでオブジェクトを取得します。詳細な仕様は不明ですが、JScript側で参照をコピーしたあと、当該キーに`null`をセットしておかないと、JScriptの終了後も参照カウントが正しく減らないようです。

```javascript
var item = new ActiveXObject("Shell.Application").Windows().Item();
item.PutProperty("key", null);

// PowerShellワンライナーをRunで実行。省略

while (!item.GetProperty(key)) {
    WScript.Sleep(1);
}
var fso = item.GetProperty(key);
item.PutProperty(key, null);
```

## CLSIDへの対応

ここまでの調べものをしていくなかで、以下のIssueを見つけました。

https://github.com/PowerShell/PowerShell/issues/17626

PowerShellではひとつ関数を作るだけで、（ProgIDではなく）CLSIDからもCOMオブジェクトを初期化できるようです。

```powershell
function New-ComObject([string]$id){
  if($id -as [guid]){
    return [System.Activator]::CreateInstance([type]::GetTypeFromCLSID($id));
  }else{
    return New-Object -ComObject $id;
  }
}
```

用途は思い浮かびませんが、せっかくなので組み込みます。

## ワンライナーを関数にまとめる

PowerShellを選べるようにして、失敗した時のためのタイムアウトも設けます。`%TEMP%`以下にキーと同名のファイルを作成し標準エラー出力をリダイレクトしておき、タイムアウト発生時はここからエラーメッセージを取得します。

`new ActiveXObject("WScript.Shell").Run`の引数を`1`（ウィンドウを表示）とすれば、`createComObject`を実行するたびにPowerShellが起動され、JScriptの終了とともにきれいに閉じることが見て取れます。

```javascript
function _exposePowerShellObject(powershellPath, script, timeoutMilliseconds) {
    timeoutMilliseconds = timeoutMilliseconds || 10000;

    var key = (function getRandomString(length) {
        var ret = "";
        var strChars =
            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        for (var i = 1; i <= length; i++) {
            ret += strChars.charAt(Math.floor(Math.random() * strChars.length));
        }
        return ret;
    })(16);
    var wshShell = new ActiveXObject("WScript.Shell");
    var errorOutput = wshShell.ExpandEnvironmentStrings("%TEMP%") + "\\" + key;

    var item = new ActiveXObject("Shell.Application").Windows().Item();
    item.PutProperty(key, null);

    // Not sure but maybe something will be needed.
    // script = script.replace('"', '`"');
    var fullScript =
        "$ErrorActionPreference='Stop';" +
        script +
        ";" +
        "function Get-RefCount([System.IntPtr]$ptr){" +
        "  $_=[System.Runtime.InteropServices.Marshal]::AddRef($ptr);" +
        "  return [System.Runtime.InteropServices.Marshal]::Release($ptr);" +
        "}" +
        "function Expose-Object([object]$obj,[string]$key){" +
        "  $ptr=[System.Runtime.InteropServices.Marshal]::GetIUnknownForObject($obj);" +
        "  (New-Object -ComObject 'Shell.Application').Windows().Item().PutProperty($key,$obj);" +
        "  $cnt=Get-RefCount $ptr;" +
        "  while((Get-RefCount $ptr) -ge $cnt){" +
        "    Start-Sleep -Milliseconds 1;" +
        "  }" +
        "}" +
        "Expose-Object $obj '" +
        key +
        "';";
    wshShell.Run(
        "cmd /c" +
            powershellPath +
            " -Command " +
            fullScript +
            "2>" +
            errorOutput,
        0
    );

    var startTime = new Date().getTime();
    while (!item.GetProperty(key)) {
        WScript.Sleep(1);
        if (new Date().getTime() - startTime > timeoutMilliseconds) {
            break;
        }
    }

    var fso = new ActiveXObject("Scripting.FileSystemObject");
    var ret = item.GetProperty(key);
    if (!ret) {
        var message = "Timeout while waiting for COM object retrieval.\n";
        try {
            var file = fso.OpenTextFile(errorOutput);
            message += file.ReadAll();
            file.Close();
        } catch (e) {}
        try {
            fso.DeleteFile(errorOutput, true);
        } catch (e) {}

        throw new Error(message);
    }
    try {
        fso.DeleteFile(errorOutput, true);
    } catch (e) {}
    // This will cause Get-RefCount to decrease at the end of JScript
    // and PowerShell will exit successfully.
    item.PutProperty(key, null);
    return ret;
}

function createComObject(id, force32bit) {
    // https://github.com/PowerShell/PowerShell/issues/17626
    var script =
        "function New-ComObject([string]$id){" +
        "  if($id -as [guid]){" +
        "    return [System.Activator]::CreateInstance([type]::GetTypeFromCLSID($id));" +
        "  }else{" +
        "    return New-Object -ComObject $id;" +
        "  }" +
        "}" +
        "$obj=New-ComObject '" +
        id +
        "'";
    var powershell = "powershell";
    if (force32bit) {
        powershell =
            "C:\\Windows\\SysWOW64\\WindowsPowerShell\\v1.0\\powershell.exe";
    }
    return _exposePowerShellObject(powershell, script);
}

// WScript.CreateObjectやActiveXObjectと同等のProgIDを使用する初期化
var ie = createComObject("InternetExplorer.Application");
ie.Visible = true;
WScript.Sleep(2000);
ie.Quit();

// CLSIDを使用する初期化
var CLSID_WScript_Shell = "72C24DD5-D70A-438B-8A42-98424B88AFB8";
var wshShell = createComObject(CLSID_WScript_Shell);
wshShell.Popup("Hello CLSID");

// 32bit版しか用意されていないActiveXコンポーネントを64bit環境で利用する
// new ActiveXObject("ScriptControl");
// => 「Microsoft JScript 実行時エラー: オートメーション サーバーはオブジェクトを作成できません。」
var sc = createComObject("ScriptControl", true);
sc.Language = "VBScript";
sc.Eval('MsgBox("Hello ScriptControl")');
```

## おまけ：`wscript.exe`で実行してもコンソールウィンドウが表示されないExec代替

スクリプトを読めばもう察したかもしれませんが、`_exposePowerShellObject`関数は引数に取るワンライナーで`$obj`に入るオブジェクトをなんでも公開します。非表示の`Run`メソッドで実行しているため、これで`WshScriptExec`オブジェクトを公開すれば、`Exec`メソッドを直接実行した場合と異なり`wscript.exe`で実行してもコンソールウィンドウが表示されなくなります。冒頭で紹介した過去の試行よりもこちらのほうが便利です。

```javascript
function createWshScriptExec(command) {
    return _exposePowerShellObject(
        "powershell",
        "$obj=(New-Object -ComObject 'WScript.Shell').Exec('" + command + "')"
    );
}

var exec = createWshScriptExec('powershell -Command "Get-Location"');
while (exec.Status != 1) {
    WScript.Sleep(1);
}
WScript.Echo(exec.StdOut.ReadAll());
```