はじめに
以下は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());