LoginSignup
3
2

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

Last updated at Posted at 2023-10-31

はじめに

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

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

たびたび紹介しているように、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"とありますが見なかったことにします。

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

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を監視します。

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の終了後も参照カウントが正しく減らないようです。

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を見つけました。

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

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の終了とともにきれいに閉じることが見て取れます。

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で実行してもコンソールウィンドウが表示されなくなります。冒頭で紹介した過去の試行よりもこちらのほうが便利です。

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());
3
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
2