DenoでWindowsのAPIを利用する
前回は自作DLLをDenoで呼び出したり値のやり取りをちょっとだけやってみました。
それの応用として最も難しいと思われるWindowsのAPIを使ってウィンドウの生成をやってみたいと思います。
ウィンドウを生成する流れ
ではWindowsでウィンドウをどうやって生成していくか流れを見ていきます。
- ウィンドウクラスを作成する
- ウィンドウクラスを登録する
- ウィンドウを作成する
- メッセージループを回す
実際のCのコードを見てみます。今回はWinMainを使わない方法となります。
#include <Windows.h>
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
if (msg == WM_CREATE) {
CREATESTRUCT* tpCreateSt = (CREATESTRUCT*)lp;
ShowWindow(hwnd, SW_SHOW);//SW_SHOWDEFAULT
UpdateWindow(hwnd);
return 0;
}
if (msg == WM_DESTROY) {
PostQuitMessage(0);
}
return DefWindowProc(hwnd, msg, wp, lp);
}
static TCHAR WINDOW_CLASS_NAME[] = L"AppWindow";
static TCHAR WINDOW_TITLE[] = L"test";
int main() {
HINSTANCE hInstance = GetModuleHandle(0);
WNDCLASSEX wcex;
memset(&wcex, 0, sizeof(WNDCLASSEX));
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpszClassName = WINDOW_CLASS_NAME;
wcex.lpfnWndProc = WndProc;
ATOM result = RegisterClassEx(&wcex);
HWND hwnd = CreateWindowEx(
0,
wcex.lpszClassName,
WINDOW_TITLE,
WS_VISIBLE | WS_OVERLAPPEDWINDOW,
CW_USEDEFAULT,
CW_USEDEFAULT,
400,
300,
nullptr,
nullptr,
nullptr,
nullptr
);
MSG msg;
while (0 < GetMessageW(&msg, hwnd, 0, 0)) {
DispatchMessageW(&msg);
}
return 0;
}
色々雑ですが最低限ウィンドウの表示が可能です。これを目指します。
注意点として、今回は64bitかつUTF-16を使用することにします。
ウィンドウクラスの作成
Cではどんな感じで作るのか見てみましょう。
WNDCLASSEX wcex;
memset(&wcex, 0, sizeof(WNDCLASSEX));
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.style = CS_HREDRAW | CS_VREDRAW;
wcex.lpfnWndProc = DefWindowProc;
wcex.lpszClassName = WINDOW_CLASS_NAME;
やっていることは以下のページにある WNDCLASSEX
に値を詰めていくだけです。
ですが色々面倒なので注意点を見ていきます。
構造体のサイズ
まず特筆すべきは cbSize
でしょう。これは何かというと WNDCLASSEX
の構造体のバイト数です。
これが正しくないとちゃんと読み取ってくれないので正確なサイズを入れる必要があるとともに、Deno側では生成するバッファのサイズを決める重要な要素です。
早速ドキュメントに書かれた構造体の中身を見ていきます。
開始位置 | Cの型 | 名前 | バイト数 | FFIの型 | Denoの型 |
---|---|---|---|---|---|
0 | UINT | cbSize | 4 | u32 | number |
4 | UINT | style | 4 | u32 | number |
8 | WNDPROC | lpfnWndProc | 8 | pointer | PointerObject |
16 | int | cbClsExtra | 4 | i32 | number |
20 | int | cbWndExtra | 4 | i32 | number |
24 | HINSTANCE | hInstance | 8 | pointer | PointerObject |
32 | HICON | hIcon | 8 | pointer | PointerObject |
40 | HCURSOR | hCursor | 8 | pointer | PointerObject |
48 | HBRUSH | hbrBackground | 8 | pointer | PointerObject |
56 | LPCWSTR | lpszMenuName | 8 | pointer | PointerObject |
64 | LPCWSTR | lpszClassName | 8 | pointer | PointerObject |
72 | HICON | hIconSm | 8 | pointer | PointerObject |
合計80バイトとなります。
注意事項にあった通り今回は64bit想定です。FFIの型で pointer
表記があるものは Deno.PointerValue
として使うことができ、この型の実態は number|bigint
です。ですが先程書いた通り64bit環境での話なので今回は固定で bigint
扱いにします。
ここまでわかったので、まずは80バイトの領域を確保しましょう。
const data = new Uint8Array(80);
次に値を入れていきますがどうすればよいでしょうか?ここで非常に良いものがあります。DataViewです。
これは生のメモリ領域をいい感じに読み書きするものです。今回80バイトの生のメモリ領域を確保したので、 DataView
を使っていじっていきたいと思います。具体的には以下のように使います。
const dataView = new DataView(data.buffer);
// cbSizeの位置(0)に構造体のバイト数=80をu32(unsigned int)のフォーマットに従い書き込む
dataView.setUint32(0, 80);
実際に見てみます。
Uint8Array(80) [
0, 0, 0, 80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
書き込めていますね。ではCの方はどうやって入っているでしょうか?ちょっと無理やり確認してみます。
// 80バイト確保
unsigned char buf[80];
// 構造体のポインタを取得
WNDCLASSEX* _src = &wcex;
// 構造体のポインタを無理やりunsigned charのポインタにする
unsigned char* src = (unsigned char*)_src;
// ループで一つ一つbufの中にデータを入れる
for (int i = 0;i<80;++i) {
buf[i] = ((unsigned char *)src)[i];
}
こんな感じでデータを無理やりつっこみ中身を見てみました。最初の4バイトを見てみましょう。
80 0 0 0
早速先程作ったデータと中身が異なりますね?
エンディアン
Cではネイティブのデータを扱いますのでCPUのメモリの使い方の影響を受けます。1バイト毎に使うなら使い方もなにもないのですが、例えば2バイトで1セットのデータなどの複数バイトで1つのデータになると格納方法が異なります。
16バイトの領域に 1 というデータを書き込む場合、ビット列で埋めると以下のようになります。
00000000 00000001
これをメモリにどう入れるかで2つのやり方があります。
エンディアン | 上位(0番目) | 下位(1番目) |
---|---|---|
ビッグエンディアン | 00000000 | 00000001 |
リトルエンディアン | 00000001 | 00000000 |
よく使われるCPUではリトルエンディアンを採用していて、次のような流れで処理が行われます。
// 2バイトのメモリ領域を使って1という値を入れる。
// リトルエンディアンだと中身は以下になる。
// data[0] = 00000001, data[1] = 00000000
const data = new Uint16Array([1]);
// 確認作業。結果は1
console.log(new DataView(data.buffer).getUint8(0));
// 確認作業。結果は0
console.log(new DataView(data.buffer).getUint8(1));
// DataViewで2バイトのメモリ領域であるとしてデータを読むと1が返ってきてほしいが256が返ってくる
console.log(new DataView(data.buffer).getUint16(0));
これは非常に困りますね?JSでも生の値を扱うと色々差異が発生します。
ここで次のように処理を変えます。
- 今動かしているCPUのエンディアンを判定する
-
DataView
の読み書きではエンディアンを指定して操作を行う
ではコードを修正します。
// リトルエンディアンならtrue
const endian = new Uint8Array(Uint16Array.of(1).buffer)[0] === 1;
const data = new Uint8Array(80);
const dataView = new DataView(data.buffer);
// 第三引数が存在しないかfalseの場合はビッグエンディアン、trueの場合はリトルエンディアンで書き込む
// cbSize(0)に書き込む
dataView.setUint32(0, 80, endian);
結果を見てみます。
Uint8Array(80) [
80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0
]
データの並びがCの実装と一致しましたね。この方向でデータを埋めていきましょう。
構造体にウィンドウクラス名をつける
ここまでで WNDCLASSEX
の領域確保と構造体のサイズを格納しました。次に重要なのはこいつの名前です。名前を使って登録したりウィンドウに利用するのでちゃんと名前をつけて上げる必要があります。
名前に関してはCでは文字の配列のポインタを渡します。つまりCでもDenoでもやることは次のようになります。
- クラス名の入った文字列を作成する
- その文字列を指し示すポインタを取得する
- 構造体に入れる
ここで問題となるのは1つ目の文字列です。文字列にはいろいろな格納方法がありますが、今回はUTF-16にします。理由は以下です。
- Windowsでは文字列でUTF-16を使う場合に末尾に
W
がつくAPIが用意されている- 例:
CreateWindowEX
というAPIは状況に応じて使い分けが発生し、UTF-18を使う場合にはCreateWindowEXW
が使われる
- 例:
- 何も考えず全角文字が使える
- JSも内部はUTF-16なので中のデータをそのままバッファに詰めることができれば無変換で扱える
そんなわけでまずは文字列のポインタを取得してみましょう。
// 文字列を渡すとその文字列のポインタを返す
function CreateStringPointer(value: string) {
// JSはUTF-16で文字列を扱っているので1文字ずつコードを取得していけばよい
const buffer = new Uint16Array(
<number[]> [].map.call(value + '\0', (c: string) => {
return c.charCodeAt(0);
}),
);
// バッファへのポインタを取得
return Deno.UnsafePointer.of(buffer);
}
これで文字列の作成とそのポインタを得ることができました。
しかし、Denoの v1.31.0
からポインタを生の値ではなく隠蔽したオブジェクト(Deno.PointerObject
)として扱うようになりました。 基本的にはその PointerObject
を渡せばよいのですが、今回のようにポインタの値そのものが必要な場合があります。
そこで PointerObject
から生のポインタを取得する関数も用意しておきます。今回は64bit想定なので必ず bigint
にします。
// PointerObjectを渡すと生のポインタを返す(BigIntに変換する)
function GetRawPointerValue(pointer: Deno.PointerValue) {
return BigInt(Deno.UnsafePointer.value(pointer));
}
後は以下のようにデータを詰め込みます。
// lpszClassName(64)に書き込む
dataView.setBigUint64(
64,
GetRawPointerValue(CreateStringPointer('AppWindow')),
endian,
);
これで文字列の問題は解決です。
ウィンドウプロシージャの設定
ウィンドウは作成や閉じるなどのイベントが多発します。
そのイベントを受け取って処理を行うコールバック関数をウィンドウプロシージャと呼び、その関数のポインタを構造体に入れる必要があります。
このウィンドウプロシージャにはデフォルトの関数が提供されてます。
特殊なことをしないならばこれでサクッと動くので、確認がてらこれをそのまま渡し……たいところですが、現状DenoではDLLから読み込んだ関数の生のポインタを受け取る術がなさそうです。あったら教えて!!
そんなわけで以下のようにデフォルトのウィンドウプロシージャを呼び出す関数を作り、それを登録していきます。
const libs = Deno.dlopen(
'user32.dll',
{
DefWindowProcW: {
parameters: [
'pointer', // HWND,
'u32', // UINT,
'pointer', // WPARAM,
'pointer', // LPARAM,
],
result: 'pointer', // LRESULT
},
},
);
// デフォルトのウィンドウプロシージャを呼び出すだけの関数
const windowProcedure = new Deno.UnsafeCallback(
{
parameters: [
'pointer', // HWND,
'u32', // UINT,
'pointer', // WPARAM,
'pointer', // LPARAM,
],
result: 'pointer', // LRESULT
},
(
hWnd: Deno.PointerValue,
Msg: number,
wParam: Deno.PointerValue,
lParam: Deno.PointerValue,
) => {
return libs.symbols.DefWindowProcW(hWnd, Msg, wParam, lParam);
},
);
// lpfnWndProc(8)に書き込む
dataView.setBigUint64(8, GetRawPointerValue(windowProcedure.pointer), endian);
これでウィンドウプロシージャを登録できました。
その他の値を入れていく
さて、難しい値はここまでです。その他必要な値を入れていきましょう。
基本的な値は0で良いですが、ウィンドウクラスのスタイルだけは設定しておきます。Cでは以下のように書いていました。
wcex.style = CS_VREDRAW | CS_HREDRAW;
これの意味と値を調べます。
CS_VREDRAW
は 0x0001
の値を持ち意味は移動や高さ変更があるとウィンドウを再描画します。
CS_HREDRAW
は 0x0002
の値を持ち意味は移動や横幅変更があるとウィンドウを再描画します。
この値を書き込んでおきます。
// style(4)に書き込む
dataView.setUint32(4, 0x0001 | 0x0002, endian);
これでウィンドウクラスの構造体を作り終えました。
ウィンドウクラスの登録
先程作ったウィンドウクラスを登録します。 RegisterClassExW
を使います。
const libs = Deno.dlopen(
'user32.dll',
{
// 省略
RegisterClassExW: {
parameters: [
'pointer', // WNDCLASSEXW
],
result: 'u16', // ATOM
},
},
);
// ウィンドウクラスへのポインタ
const wcex = Deno.UnsafePointer.of(data);
// ウィンドウクラスを登録(0で失敗)
if (libs.symbols.RegisterClassExW(wcex) === 0) {
throw new Error(`Failure RegisterClassExW.`);
}
これで0にならなければ登録完了です。
ウィンドウの作成
さていよいよウィンドウの作成です。詳細を見てみます。
わぁ、引数多いですね。一つ一つ潰して行きましょう。
Cの型 | 名前 | バイト数 | FFIの型 | Denoの型 | 意味 |
---|---|---|---|---|---|
DWORD | dwExStyle | 4 | u32 | number | 拡張ウィンドウスタイル |
LPCWSTR | lpClassName | 8 | pointer | PointerObject | 登録したウィンドウクラス名 |
LPCWSTR | lpWindowName | 8 | pointer | PointerObject | ウィンドウのタイトル |
DWORD | dwStyle | 4 | u32 | number | ウィンドウスタイル |
int | X | 4 | i32 | number | 表示X座標 |
int | Y | 4 | i32 | number | 表示Y座標 |
int | nWidth | 4 | i32 | number | 横幅 |
int | nHeight | 4 | i32 | number | 高さ |
HWND | hWndParent | 8 | pointer | PointerObject | 親のウィンドウハンドル(nullでいい) |
HMENU | hMenu | 8 | pointer | PointerObject | メニューのデータ(nullでいい) |
HINSTANCE | hInstance | 8 | pointer | PointerObject | 参照するインスタンスのポインタ(nullで今のexeファイル) |
LPVOID | lpParam | 8 | pointer | PointerObject | ウィンドウ作成時にウィンドウプロシージャに渡す値(nullでいい) |
今までの実装で基本なんとかなりそうですね。入れていきます。
const defaultPosition = -2147483648; // CW_USEDEFAULT = 0x80000000
const windowHandle = libs.symbols.CreateWindowExW(
0, // 拡張ウィンドウスタイルは特に指定なし
windowClassName, // ウィンドウクラス名
CreateStringPointer('テスト'), // ウィンドウのタイトル
0x10000000 | 0x00cf0000, // ウィンドウのスタイル
defaultPosition, // Windowsが勝手に場所を決めてくれる
defaultPosition, // Windowsが勝手に場所を決めてくれる
400, // ウィンドウの横幅
300, // ウィンドウの高さ
null, // ここから先は使わないので全部null
null,
null,
null,
);
よくわからない値を見ていきます。まずはウィンドウのスタイルです。
WS_VISIBLE
は 0x10000000
の値を持ち意味はウィンドウを表示するかどうかです。表示されないと困るので使いましょう。
WS_OVERLAPPEDWINDOW
は いくつかの値を組み合わせて 0x00cf0000
の値を持ち、意味はまぁいい感じのウィンドウを作ってくれます。せっかく用意してくれたものなのでありがたく使わせてもらいます。
後注意すべきは defaultPosition
でしょう。
Cでは数値の型を厳密に持っているので、32bitのint型で 0x80000000
を指定すれば負の値である -2147483648
になります。
しかしJSでは number
が倍精度浮動小数点数のため64bitの領域があります。そのまま指定すれば正の整数であり32bitのint型に収まらない範囲の値となってしまいます。
これによりDenoの外部関数の呼び出しが失敗してしまいます。ここは一旦面倒なので直に値を入れます。
このようにドキュメント通りの値を入れるとエラーになる場合があるので注意しましょう。もし自分で定数周りを扱いたいなら何かをラップするなり定数を再定義するなりした方が安全だと思います。
メッセージループ
さて、ここまでの実装を動かした人はわかると思いますが、一瞬だけウィンドウが出てプログラムが終了します。
Windowsでは以下のようにウィンドウからのメッセージを待つ処理が入ります。
MSG msg;
while (0 < GetMessageW(&msg, hwnd, 0, 0)) {
DispatchMessageW(&msg);
}
これを再現していきましょう。
メッセージ構造体
メッセージは GetMessageW()
で受け取りその値を構造体に書き込みます。終了メッセージを受信した場合は返り値が0になり、エラーが発生した場合は負の値、その他以外は0より大きい値が返ってきます。
ではこの構造体を見てみましょう。
今回は中身を使わないのでバイト数だけほしいです。
Cの型 | 名前 | バイト数 | 備考 |
---|---|---|---|
HWND | hwnd | 8 | |
UINT | message | 4 | |
WPARAM | wParam | 8 | |
LPARAM | lParam | 8 | |
DWORD | time | 4 | |
POINT | pt | 16 | 構造体 |
DWORD | lPrivate | 4 |
なんと POINT
が構造体です。構造体の中に構造体がある場合、ポインタではないのでそのまま埋め込まれます。
こちら中身を調べると LONG
の値が2つあったので 8*2
の 16
バイトあります。
これでメッセージ構造体用のサイズがわかりました。後は Uint8Array
でメモリ領域を確保してポインタを渡せば良いでしょう。
各種APIのつなぎこみ
では必要な2つのAPIをつなぎ込んでいきます。
まずは GetMessageW
です。
メッセージのポインタ、ウィンドウハンドル、後は0を与えておけば良いので難しくはないです。
次に DispatchMessageW
です。
こちらはメッセージのポインタを受け取るだけですね。
const libs = Deno.dlopen(
'user32.dll',
{
// 省略
DispatchMessageW: {
parameters: [
'pointer', // LPMSG,
],
result: 'pointer', // LRESULT
},
GetMessageW: {
parameters: [
'pointer', // LPMSG,
'pointer', // HWND
'u32', // UINT
'u32', // UINT
],
result: 'i32',
},
},
);
メッセージループの作成
APIが揃ったのでメッセージループを実装していきます。
const message = Deno.UnsafePointer.of(new Uint8Array(52));
while (0 < libs.symbols.GetMessageW(message, windowHandle, 0, 0)) {
libs.symbols.DispatchMessageW(message);
}
完成形
以下が完成コードです。
// 文字列を渡すとその文字列のポインタを返す
function CreateStringPointer(value: string) {
// JSはUTF-16で文字列を扱っているので1文字ずつコードを取得していけばよい
const buffer = new Uint16Array(
<number[]> [].map.call(value + '\0', (c: string) => {
return c.charCodeAt(0);
}),
);
// バッファへのポインタを取得
return Deno.UnsafePointer.of(buffer);
}
// PointerObjectを渡すと生のポインタを返す(BigIntに変換する)
function GetRawPointerValue(pointer: Deno.PointerValue) {
return BigInt(Deno.UnsafePointer.value(pointer));
}
// WinAPIの入ったDLLの読み込み
const libs = Deno.dlopen(
'user32.dll',
{
CreateWindowExW: {
parameters: [
'i32', // DWORD
'pointer', // LPCWSTR
'pointer', // LPCWSTR
'i32', // DWORD,
'i32', //int
'i32', //int
'i32', //int
'i32', //int
'pointer', // HWND
'pointer', // HMENU
'pointer', // HINSTANCE
'pointer', // LPVOID
],
result: 'pointer', // HWND,
},
DefWindowProcW: {
parameters: [
'pointer', // HWND,
'u32', // UINT,
'pointer', // WPARAM,
'pointer', // LPARAM,
],
result: 'pointer', // LRESULT
},
DispatchMessageW: {
parameters: [
'pointer', // LPMSG,
],
result: 'pointer', // LRESULT
},
GetMessageW: {
parameters: [
'pointer', // LPMSG,
'pointer', // HWND
'u32', // UINT
'u32', // UINT
],
result: 'i32',
},
RegisterClassExW: {
parameters: [
'pointer', // WNDCLASSEXW
],
result: 'u16', // ATOM
},
},
);
// デフォルトのウィンドウプロシージャを呼び出すだけの関数
const windowProcedure = new Deno.UnsafeCallback(
{
parameters: [
'pointer', // HWND,
'u32', // UINT,
'pointer', // WPARAM,
'pointer', // LPARAM,
],
result: 'pointer', // LRESULT
},
(
hWnd: Deno.PointerValue,
Msg: number,
wParam: Deno.PointerValue,
lParam: Deno.PointerValue,
) => {
return libs.symbols.DefWindowProcW(hWnd, Msg, wParam, lParam);
},
);
// リトルエンディアンならtrue
const endian = new Uint8Array(Uint16Array.of(1).buffer)[0] === 1;
// ウィンドウクラス名
const windowClassName = CreateStringPointer('AppWindow');
// ウィンドウクラスの作成
const data = new Uint8Array(80);
const dataView = new DataView(data.buffer);
// cbSize(0)に書き込む
dataView.setUint32(0, 80, endian);
// style(4)に書き込む
dataView.setUint32(4, 0x0001 | 0x0002, endian);
// lpfnWndProc(8)に書き込む
dataView.setBigUint64(8, GetRawPointerValue(windowProcedure.pointer), endian);
// lpszClassName(64)に書き込む
dataView.setBigUint64(64, GetRawPointerValue(windowClassName), endian);
console.log(data);
// ウィンドウクラスへのポインタ
const wcex = Deno.UnsafePointer.of(data);
// ウィンドウクラスを登録(0で失敗)
if (libs.symbols.RegisterClassExW(wcex) === 0) {
throw new Error(`Failure RegisterClassExW.`);
}
// Windowsにウィンドウの位置を任せる
const defaultPosition = -2147483648; // CW_USEDEFAULT = 0x80000000
// ウィンドウの作成
const windowHandle = libs.symbols.CreateWindowExW(
0, // 拡張ウィンドウスタイルは特に指定なし
windowClassName, // ウィンドウクラス名
CreateStringPointer('テスト'), // ウィンドウのタイトル
0x10000000 | 0x00cf0000, // ウィンドウのスタイル
defaultPosition, // Windowsが勝手に場所を決めてくれる
defaultPosition, // Windowsが勝手に場所を決めてくれる
400, // ウィンドウの横幅
300, // ウィンドウの高さ
null, // ここから先は使わないので全部null
null,
null,
null,
);
console.log(windowHandle);
// メッセージループ
const message = Deno.UnsafePointer.of(new Uint8Array(52));
while (0 < libs.symbols.GetMessageW(message, windowHandle, 0, 0)) {
libs.symbols.DispatchMessageW(message);
}
実行コマンドは以下です。
deno run --allow-ffi ./sample.ts
これでDenoからWindowsのAPIを叩いてウィンドウ生成までできました。
色々やらないといけないことが不足しているので直さないといけない箇所は色々あるのですが、とりあえずウィンドウが出たので良しとしましょう。
いくつかつらいポイントはありましたが大体ウィンドウクラス構造体のデータ作成部分だった気がしますね。
生のデータ作成とAPIとのつなぎ込み、コールバック関数の作成等ができれば大体の事はできるのである意味これがDLL利用の最難関ではないかとおもいます。これからは必要なAPIをつなぎ込んだりしてDenoでGUIアプリの作成とかやってみたいですね。
Discussion