WScript.ShellのExecメソッドでCLIアプリケーションを非表示で実行する

:::note warn
以下は訳あって削除済みの過去記事の転載です（published at 2023-08-25 23:33）。

現在は[より汎用的な方法](https://qiita.com/U-1F992/items/2d808037a3c8354aa17a#%E3%81%8A%E3%81%BE%E3%81%91wscriptexe%E3%81%A7%E5%AE%9F%E8%A1%8C%E3%81%97%E3%81%A6%E3%82%82%E3%82%B3%E3%83%B3%E3%82%BD%E3%83%BC%E3%83%AB%E3%82%A6%E3%82%A3%E3%83%B3%E3%83%89%E3%82%A6%E3%81%8C%E8%A1%A8%E7%A4%BA%E3%81%95%E3%82%8C%E3%81%AA%E3%81%84exec%E4%BB%A3%E6%9B%BF)を思いつき、こちらを使用しています。
:::

---

## はじめに

`WScript.Shell`オブジェクトの`Exec`メソッドは、類似する`Run`メソッドと異なり、戻り値の`WshShellExec`オブジェクトを通じて立ち上げた子プロセスの標準入力／出力／エラー出力にアクセスできます。その代わりに`Run`メソッドに備わっているウィンドウ非表示のオプションがなく、wscript.exeやVBAでCLIアプリケーションを実行するとコンソールウィンドウが出てきてしまうのが悩ましいところです。無駄なコンソールウィンドウが出なければ、見た目がよくなるだけでなく、現在のウィンドウからフォーカスが奪われないなどそこそこ実用性があります。

こちらの記事では、cscript.exeで`Exec`メソッドを実行した際にはコンソールウィンドウが出現しないことを利用して、`Run`メソッドでウィンドウを非表示にして「cscript.exeで`Exec`メソッドを実行するスクリプト」を実行するアイディアが紹介されていました。

http://scripting.cocolog-nifty.com/blog/2007/05/wscriptshellexe_6e22.html

今回はこれをさらに押し進めた手法を紹介します。`Exec`相当の関数実行時にcscript.exeで実行させるスクリプトを動的に生成し、さらに`Shell.Application`オブジェクトの`Windows().Item().PutProperty`／`GetProperty`メソッドを介して、cscript.exe側から呼び出し元に`WshScriptExec`オブジェクトを公開させるというのが基本的な発想です。

<details><summary>比較用（VBScript）</summary>

```vb
Dim pwsh
Set pwsh = CreateObject("WScript.Shell").Exec("powershell.exe")
WScript.Echo(pwsh.StdOut.ReadLine())
```

</details>

<details><summary>VBScript</summary>

```vb
Class WshScriptExecProvider

    Private objWshShell 'As Object
    Private objShell 'As Object
    Private arrKeys() 'As String

    Private Sub Class_Initialize()
        Set objWshShell = CreateObject("WScript.Shell")
        Set objShell = CreateObject("Shell.Application")
        ReDim arrKeys(-1)
    End Sub

    Private Sub Class_Terminate()
        Dim i 'As Long
        For i = LBound(arrKeys) To UBound(arrKeys)
            objShell.Windows().Item().PutProperty arrKeys(i) & "_Quit", True
        Next 'i

        Set objWshShell = Nothing
        Set objShell = Nothing
    End Sub

    Private Function GenerateRandomString(lLength) 'As String
        Dim i 'As Long
        Dim ret 'As String
        ret = ""
        Dim strChars 'As String
        strChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

        Randomize
        For i = 1 To lLength
            ret = ret & Mid(strChars, Int(Len(strChars) * Rnd() + 1), 1)
        Next 'i

        GenerateRandomString = ret
    End Function

    Private Function GenerateVBScriptContent(strCommand, strKey) 'As String
        GenerateVBScriptContent = "" & _
        "Set sh = CreateObject(""Shell.Application""): " & _
        "Set item = sh.Windows().Item(): " & _
        _
        "item.PutProperty """ & strKey & """, CreateObject(""WScript.Shell"").Exec(""" & strCommand & """): " & _
        "item.PutProperty """ & strKey & "_Quit"", False: " & _
        _
        "Do While Not item.GetProperty(""" & strKey & "_Quit""): " & _
        "    WScript.Sleep 100: " & _
        "Loop: " & _
        _
        "Set sh = Nothing"
    End Function

    Private Function GenerateRunCommand(strScript, strKey) 'As String
        GenerateRunCommand = "" & _
        "cmd.exe /c echo " & strScript & " > %TEMP%\" & strKey & ".vbs &&" & _
        "cscript %TEMP%\" & strKey & ".vbs &&" & _
        "del %TEMP%\" & strKey & ".vbs"
    End Function

    Private Function GetObjectFromShell(strKey) 'As Object
        Dim ret 'As Object
        Dim startTime 'As Double
        startTime = Timer

        On Error Resume Next
        Do While True
            Set ret = objShell.Windows().Item().GetProperty(strKey)
            If Err.Number = 0 Then
                Exit Do
            End If
            Err.Clear

            WScript.Sleep(1)

            If 1 < Timer - startTime Then
                On Error GoTo 0
            End If
        Loop
        On Error GoTo 0

        Set GetObjectFromShell = ret
    End Function

    Public Function Exec(strCommand) 'As Object
        Dim strKey 'As String
        strKey = GenerateRandomString(16)
        Dim strScript 'As String
        strScript = GenerateVBScriptContent(strCommand, strKey)
        Dim strRunCommand 'As String
        strRunCommand = GenerateRunCommand(strScript, strKey)

        objWshShell.Run strRunCommand, 0, False
        Dim ret 'As Object
        Set ret = GetObjectFromShell(strKey)

        ReDim Preserve arrKeys(UBound(arrKeys) + 1)
        arrKeys(UBound(arrKeys)) = strKey
        Set Exec = ret
    End Function

End Class

Dim provider
Set provider = New WshScriptExecProvider
Dim pwsh
Set pwsh = provider.Exec("powershell.exe")
WScript.Echo(pwsh.StdOut.ReadLine())
```

</details>

<details><summary>JScript</summary>

```js
function WshScriptExecProvider() {

    var objWshShell = new ActiveXObject("WScript.Shell");
    var objShell = new ActiveXObject("Shell.Application");
    var keys = [];

    this.Dispose = function () {
        for (var i = 0; i < keys.length; i++) {
            objShell.Windows().Item().PutProperty(keys[i] + "_Quit", true);
        }
    };

    function _generateRandomString(length) {
        var ret = "";
        var strChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
        for (var i = 1; i <= length; i++) {
            ret += strChars.charAt(Math.floor(Math.random() * strChars.length));
        }
        return ret;
    }

    function _generateVBScriptContent(command, key) {
        return '' +
            'Set sh = CreateObject("Shell.Application"): ' +
            'Set item = sh.Windows().Item(): ' +

            'item.PutProperty "' + key + '", CreateObject("WScript.Shell").Exec("' + command + '"): ' +
            'item.PutProperty "' + key + '_Quit", False: ' +

            'Do While Not item.GetProperty("' + key + '_Quit"): ' +
            '    WScript.Sleep 100: ' +
            'Loop: ' +

            'Set sh = Nothing';
    }

    function _generateRunCommand(script, key) {
        return '' +
            'cmd.exe /c echo ' + script + ' > %TEMP%\\' + key + '.vbs && ' +
            'cscript %TEMP%\\' + key + '.vbs && ' +
            'del %TEMP%\\' + key + '.vbs';
    }

    function _getObjectFromShell(key) {
        var ret;
        var startTime = new Date().getTime();
        while (true) {
            ret = objShell.Windows().Item().GetProperty(key);
            if (ret != null) {
                break;
            }
            WScript.Sleep(1);
            if (new Date().getTime() - startTime > 1000) {
                throw new Error();
            }
        }
        return ret;
    }

    this.Exec = function (command) {
        var key = _generateRandomString(16);
        var script = _generateVBScriptContent(command, key);
        var runCommand = _generateRunCommand(script, key);

        objWshShell.Run(runCommand, 0, false);
        var ret = _getObjectFromShell(key);

        keys.push(key);
        return ret;
    };
}

var provider = new WshScriptExecProvider();
try {
    var pwsh = provider.Exec("powershell.exe");
    WScript.Echo(pwsh.StdOut.ReadLine());
} finally {
    provider.Dispose();
}
```

</details>

## スクリプトの動的生成とオブジェクトの共有

まずcscript.exeで実行するスクリプトの動的生成部に注目します。以下のスクリプトを1行につなげたものをシェルワンライナーで保存しています。埋め込まれる文字列を便宜上`{{（変数名）}}`と表現します。

```vb
Set sh = CreateObject("Shell.Application")
Set item = sh.Windows().Item()

item.PutProperty "{{strKey}}", CreateObject("WScript.Shell").Exec("{{strCommand}}")
item.PutProperty "{{strKey}}_Quit", False

Do While Not item.GetProperty("{{strKey}}_Quit")
    WScript.Sleep 100
Loop

Set sh = Nothing
```

`Shell.Application`オブジェクトの`Windows().Item().PutProperty`／`GetProperty`メソッドでは、文字列をキーに値やオブジェクトを登録できます。登録された内容は別プロセスからもアクセスできるため、事実上のプロセス間通信が可能になります。

キーとなる文字列は16文字のランダムな英数字を埋め込んでいます。このスクリプトが終了すると登録した参照も切れてしまうため、`_Quit`をサフィックスとして停止用のフラグも併せて登録します。

内部で作成したファイルを実行するようなスクリプトは往々にしてウイルス判定されやすいですが、シェルワンライナーにしてしまえばお目こぼしをもらえることが多い気がします。キーをファイル名にも使用し、環境変数`TEMP`が示すディレクトリに保存します。スクリプトの終了後には自動的に削除されるようにします。

```bat
cmd.exe /c echo （中略） > %TEMP%\{{strKey}}.vbs
cscript %TEMP%\{{strKey}}.vbs
del %TEMP%\{{strKey}}.vbs
```

衝突判定はしていないのでお祈りをお願いします。

## 非同期で共有されるオブジェクトの取得

続いて共有されたオブジェクトの取得部分です。上記の通り`GetProperty`メソッドで取得しますが、`Run`メソッドによるcscript.exeの実行を非同期にしているため、共有される前に取得を試みてしまう可能性があります。

JScriptでは`null`が返るので比較で容易に対処できますが、VBScriptではこの箇所でエラーが発生します。`On Error Resume Next`でエラーを処理して、間隔をおいてリトライします。1秒間オブジェクトを取得できなかった場合エラー処理をデフォルトの即時送出に戻す（`On Error GoTo 0`）ことで、次回試行時の`GetProperty`のエラーはそのまま送出されます。次の部分を比較してみてください。

<details><summary>VBScript</summary>

```vb
Private Function GetObjectFromShell(strKey) 'As Object
    Dim ret 'As Object
    Dim startTime 'As Double
    startTime = Timer

    On Error Resume Next
    Do While True
        Set ret = objShell.Windows().Item().GetProperty(strKey)
        If Err.Number = 0 Then
            Exit Do
        End If
        Err.Clear

        WScript.Sleep(1)

        If 1 < Timer - startTime Then
            On Error GoTo 0
        End If
    Loop
    On Error GoTo 0

    Set GetObjectFromShell = ret
End Function
```

</details>

<details><summary>JScript</summary>

```js
function _getObjectFromShell(key) {
    var ret;
    var startTime = new Date().getTime();
    while (true) {
        ret = objShell.Windows().Item().GetProperty(key);
        if (ret != null) {
            break;
        }
        WScript.Sleep(1);
        if (new Date().getTime() - startTime > 1000) {
            throw new Error();
        }
    }
    return ret;
}
```

</details>

## cscript.exe用プロセスの終了

キーは配列に貯めておきます。Execを実行してオブジェクトを公開している子プロセスは明示的に終了する必要があることに注意してください。VBScriptではデストラクタ内で、貯めたキーをもとに先ほど設定した終了用のフラグをセットします。

```vb
Dim i 'As Long
For i = LBound(arrKeys) To UBound(arrKeys)
    objShell.Windows().Item().PutProperty arrKeys(i) & "_Quit", True
Next 'i
```

JScriptではデストラクタを利用できないため、try/finallyで処理してください。

```js
var provider = new WshScriptExecProvider();
try {
    var pwsh = provider.Exec("powershell.exe");
    WScript.Echo(pwsh.StdOut.ReadLine());
} finally {
    provider.Dispose();
}
```

## まとめ

標準出力を一斉に取得するだけなら一時ファイルへのリダイレクトなど他の方法も考えられますが、標準入力も扱えるのが`Exec`メソッドの特徴です。いちいちコンソールウィンドウが出ないことで、少し便利に使えるようになります。いろいろ試してみてください。

Execで対話型CLIアプリケーションを利用したこちらの記事もぜひどうぞ。

https://qiita.com/U-1F992/items/c5c3693b278962c38024