Skip to content

Commit bb4976c

Browse files
committed
update
1 parent d66cb22 commit bb4976c

File tree

4 files changed

+34
-61
lines changed

4 files changed

+34
-61
lines changed

src/content/blogs/2024-12-24-highdpi.md

Lines changed: 34 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,12 @@ High DPI とは Windows 独自の概念で「**画面の細かさを表す仮想
2525

2626
```xml
2727
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
28-
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" xmlns:asmv3="urn:schemas-microsoft-com:asm.v3" manifestVersion="1.0">
29-
<asmv3:application>
30-
<asmv3:windowsSettings xmlns="http://schemas.microsoft.com/SMI/2017/WindowsSettings">
31-
<gdiScaling>true</gdiScaling>
32-
</asmv3:windowsSettings>
33-
</asmv3:application>
28+
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
29+
<application xmlns="urn:schemas-microsoft-com:asm.v3">
30+
<windowsSettings>
31+
<gdiScaling xmlns="http://schemas.microsoft.com/SMI/2017/WindowsSettings">true</gdiScaling>
32+
</windowsSettings>
33+
</application>
3434
<dependency>
3535
<dependentAssembly>
3636
<assemblyIdentity
@@ -125,7 +125,7 @@ Windows 2000 のような古い OS では単にこの window message が発出
125125

126126
## 起動時の DPI 取得
127127

128-
起動直後、すなわち一度も `WM_DPICHANGED` が発行されていない状況では、自分で API を呼び出して DPI 値を取得するしかありません。そして、ここで OS バージョンによる細かい違いが発生します(クソポイント 1)
128+
起動直後、すなわち一度も `WM_DPICHANGED` が発行されていない状況では、自分で API を呼び出して DPI 値を取得するしかありません。そして、ここで OS バージョンによる細かい違いが発生します。
129129

130130
<dl>
131131
<dt><a href="https://learn.microsoft.com/en-us/windows/win32/api/wingdi/nf-wingdi-getdevicecaps"><code>GetDeviceCaps()</code></a></dt>
@@ -152,10 +152,6 @@ HDC hDC = GetDC(hWnd);
152152
sysDPI = GetDeviceCaps(hDC, LOGPIXELSX);
153153
ReleaseDC(hWnd, hDC);
154154

155-
// DLL hijacking 対策としてカレントディレクトリからの DLL 読み込みを無効化
156-
// 別で既に WinMain() などから呼び出してあるなら不要
157-
SetDllDirectoryW(L"");
158-
159155
HMODULE user32 = NULL, shcore = NULL;
160156

161157
// Windows 10 Anniversary Update (1607) 以降で使える GetDpiForWindow() の読み込みを試行する
@@ -203,19 +199,20 @@ typedef enum MONITOR_DPI_TYPE {
203199
MDT_DEFAULT = MDT_EFFECTIVE_DPI
204200
} MONITOR_DPI_TYPE;
205201
206-
typedef UINT (CALLBACK *GetDpiForWindow_t)(HWND hwnd);
207-
typedef HRESULT (CALLBACK *GetDpiForMonitor_t)(HMONITOR hmonitor, MONITOR_DPI_TYPE dpiType, UINT *dpiX, UINT *dpiY);
208-
typedef HMONITOR (CALLBACK *MonitorFromWindow_t)(HWND hwnd, DWORD dwFlags);
202+
typedef UINT (WINAPI *GetDpiForWindow_t)(HWND hwnd);
203+
typedef HRESULT (WINAPI *GetDpiForMonitor_t)(HMONITOR hmonitor, MONITOR_DPI_TYPE dpiType, UINT *dpiX, UINT *dpiY);
204+
typedef HMONITOR (WINAPI *MonitorFromWindow_t)(HWND hwnd, DWORD dwFlags);
209205
210206
static inline int adjust(int coord) { return coord * DPI / 96; }
211207
```
212208

213209
やっていることは以下の通りです。
214210

215-
1. `User32.dll` から `GetDpiForWindow()`(最新 API)を読み込もうとし、成功すればそれを使う
211+
1. フォールバック値かつ System DPI 値として `GetDeviceCaps()`(大昔からある API)の値を取得する
212+
1. `User32.dll` から `GetDpiForWindow()`(最新 API)を読み込もうとし、成功すればこれを使う
216213
- 関数ポインタとして動的に読み込んでいるので、この API 関数がない OS でも起動自体は弾かれない
217-
1. 失敗すれば `Shcore.dll` から `GetDpiForMonitor()`(ちょい古 API)を読み込もうとし、成功すればそれを使う
218-
1. 失敗すれば `GetDeviceCaps()`(大昔からある API)の値を使う
214+
1. 失敗すれば `Shcore.dll` から `GetDpiForMonitor()``User32.dll` から `MonitorFromWindow()` を読み込もうとし、成功すればこれを使う
215+
1. 失敗すれば最初に取得した System DPI 値を使う
219216
1. 得た値を元に [`SetWindowPos()`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-setwindowpos) でウィンドウサイズを変更する
220217
- `WM_SIZE` で DPI 値を元に UI 要素を再配置する実装になっている前提
221218
- 与えている引数の内容はあくまでも参考例
@@ -227,7 +224,6 @@ HDC hDC = GetDC(hWnd);
227224
sysDPI = GetDeviceCaps(hDC, LOGPIXELSX);
228225
ReleaseDC(hWnd, hDC);
229226

230-
SetDllDirectoryW(L"");
231227
HMODULE shcore = LoadLibraryW(L"Shcore.dll"), user32 = LoadLibraryW(L"User32.dll");
232228
GetDpiForMonitor_t getDpiForMonitor = NULL;
233229
if (shcore) getDpiForMonitor = (GetDpiForMonitor_t)(void *)GetProcAddress(shcore, "GetDpiForMonitor");
@@ -248,62 +244,35 @@ if (user32) FreeLibrary(user32);
248244
SetWindowPos(hWnd, NULL, 0, 0, adjust(480), adjust(320), SWP_NOZORDER | SWP_NOACTIVATE | SWP_NOMOVE);
249245
```
250246
251-
なお、これらのサンプルコードでは `LoadLibraryW()` に単に DLL ファイル名を渡して無条件で読み込みを試行しています。[`SetDllDirectoryW(L"")`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw) によりカレントディレクトリに不正な DLL が置かれるケースは対策していますが、システムフォルダに不正な DLL を置かれるケース[^6]などを懸念する場合は、バージョンによって存在しない可能性のある `Shcore.dll` について `versionhelpers.h` にある組み込み関数 [`IsWindows8Point1OrGreater()`](https://learn.microsoft.com/en-us/windows/win32/api/versionhelpers/nf-versionhelpers-iswindows8point1orgreater) でガードした上で読み込みます。これは Windows 2000 でも使える API を呼び出す `inline` 関数として SDK 内に定義されているので、Windows 2000, XP のような古い OS でも動作します。SDK になければ [`VerifyVersionInfoW()`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-verifyversioninfow) を使うと良いでしょう。ただし、以下のように manifest に互換性記述を追加しないと正しく動作しないようなので注意が必要です(クソポイント 2)
247+
なお、これらのサンプルコードでは `Shcore.dll` を `LoadLibraryW()` で読み込んでいますが、この DLL は古い OS には存在しません(`User32.dll` は必ず存在します)。これでは、該当の OS でカレントディレクトリ[^6]に `Shcore.dll` という名前の偽の DLL が置かれた場合、その不正な DLL が実行されてしまいます(DLL hijacking の脆弱性)。これを防ぐため、以下のコードを `WinMain()` 関数などでアプリの起動直後に実行しておき、カレントディレクトリを DLL の検索パスから外してください([詳細](https://learn.microsoft.com/en-us/windows/win32/dlls/dynamic-link-library-security))。ここで読み込んでいる `Kernel32.dll` については必ず OS に存在するので考慮不要です
252248
253-
```xml
254-
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
255-
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
256-
<application xmlns="urn:schemas-microsoft-com:asm.v3">
257-
<windowsSettings>
258-
<dpiAware xmlns="http://schemas.microsoft.com/SMI/2005/WindowsSettings">True/PM</dpiAware>
259-
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2, PerMonitor</dpiAwareness>
260-
</windowsSettings>
261-
</application>
262-
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
263-
<application>
264-
<!-- Windows 10, Windows Server 2016 and Windows Server 2019 -->
265-
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>
266-
<!-- Windows 8.1 and Windows Server 2012 R2 -->
267-
<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>
268-
<!-- Windows 8 and Windows Server 2012 -->
269-
<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>
270-
<!-- Windows 7 and Windows Server 2008 R2 -->
271-
<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>
272-
<!-- Windows Vista and Windows Server 2008 -->
273-
<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}"/>
274-
</application>
275-
</compatibility>
276-
<dependency>
277-
<dependentAssembly>
278-
<assemblyIdentity
279-
type="win32"
280-
name="Microsoft.Windows.Common-Controls"
281-
version="6.0.0.0"
282-
processorArchitecture="*"
283-
publicKeyToken="6595b64144ccf1df"
284-
language="*"
285-
/>
286-
</dependentAssembly>
287-
</dependency>
288-
</assembly>
249+
```c
250+
HMODULE kernel32 = LoadLibraryW(L"Kernel32.dll");
251+
if (kernel32) {
252+
SetDllDirectoryW_t setDllDirectoryW = (SetDllDirectoryW_t)(void *)GetProcAddress(kernel32, "SetDllDirectoryW");
253+
if (setDllDirectoryW) setDllDirectoryW(L""); // DLL hijacking prevention.
254+
FreeLibrary(kernel32);
255+
}
289256
```
290257

258+
ちなみに、[`SetDllDirectoryW()`](https://learn.microsoft.com/en-us/windows/win32/api/winbase/nf-winbase-setdlldirectoryw) を直接呼び出さないのは Windows 2000 にこの API が実装されていないためなので、Windows XP 以降が対象なら単に `SetDllDirectoryW(L"");` だけで十分です。この機能を持たない Windows 2000 上では DLL hijacking に対して脆弱となりますが、そもそも今では脆弱性の塊みたいな OS なのでそこまで考えなくても良いでしょう。
259+
291260
## 番外編:`MessageBoxW()`
292261

293262
High DPI 対応にあたり、考慮が必要な API があります。ここではほとんどのプログラムで使用するであろう超主要なものを説明します。まずはちょっとしたダイアログの表示に使う [`MessageBoxW()`](https://learn.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-messageboxw) についてです。
294263

295264
![](2024-12-24-highdpi/taskdialog.png)
296265

297-
`MessageBoxW()` は System DPI レベルなので、異なる DPI 設定のモニタ間を移動させたり DPI 設定を変更したりするとピンぼけします(クソポイント 3)。その対策として代替 API である [`TaskDialog()`](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-taskdialog) が使えますが、引数などの仕様が大きく異なります。また、これは Windows Vista で導入されたため Windows XP 以前では存在しません。なので、以下の仕様の wrapper 関数を作成する必要があります(まあ、Windows Vista 以降しかサポートしないなら `TaskDialog()` をそのまま使えば良いわけですが)。
266+
`MessageBoxW()` は System DPI レベルなので、異なる DPI 設定のモニタ間を移動させたり DPI 設定を変更したりするとピンぼけします。その対策として代替 API である [`TaskDialog()`](https://learn.microsoft.com/en-us/windows/win32/api/commctrl/nf-commctrl-taskdialog) が使えますが、引数などの仕様が大きく異なります。また、これは Windows Vista で導入されたため Windows XP 以前では存在しません。なので、以下の仕様の wrapper 関数を作成する必要があります(まあ、Windows Vista 以降しかサポートしないなら `TaskDialog()` をそのまま使えば良いわけですが)。
298267

299268
- `TaskDialog()` が使える場合はこれを使う
300269
- 使えない場合は `MessageBoxW()` を使う
301270

302-
以下の `messageBox()` が最低限の実装例です(全機能をサポートしているわけではない)。関数仕様は `MessageBoxW()` 側に合わせていますが、`TaskDialog()` 側の都合で `hInst` が必須になっていること、`TaskDialog()` が対応しないメッセージ種は `MessageBoxW()` に fallback させていること、対応しない `MB_ICONQUESTION``TaskDialog()` 使用時には `MB_ICONINFORMATION` に置き換えるなどの違いがあります。実装としては、`MessageBoxW()` スタイルの引数を解析し、それに相当するダイアログが得られるように `TaskDialog()` の引数に変換していることと、非対応機能が使われた場合や `TaskDialog()` が存在しない場合は fallback として `MessageBoxW()` を使うようにしているのがポイントです。なお、ここでも `LoadLibraryW()` を使っているため、先述の DLL hijacking に関する留意点が存在します
271+
以下の `messageBox()` が最低限の実装例です(全機能をサポートしているわけではない)。関数仕様は `MessageBoxW()` 側に合わせていますが、`TaskDialog()` 側の都合で `hInst` が必須になっていること、`TaskDialog()` が対応しないメッセージ種は `MessageBoxW()` に fallback させていること、対応しない `MB_ICONQUESTION``TaskDialog()` 使用時には情報アイコンに置き換えるなどの違いがあります。実装としては、`MessageBoxW()` スタイルの引数を解析し、それに相当するダイアログが得られるように `TaskDialog()` の引数に変換していることと、非対応機能が使われた場合や `TaskDialog()` が存在しない場合は fallback として `MessageBoxW()` を使うようにしているのがポイントです。
303272

304273
```c
305274
// TaskDialog() の関数ポインタ型定義
306-
typedef HRESULT (__stdcall *TaskDialog_t)(HWND hwndOwner, HINSTANCE hInstance, const wchar_t *pszWindowTitle,
275+
typedef HRESULT (WINAPI *TaskDialog_t)(HWND hwndOwner, HINSTANCE hInstance, const wchar_t *pszWindowTitle,
307276
const wchar_t *pszMainInstruction, const wchar_t *pszContent,
308277
int dwCommonButtons, const wchar_t *pszIcon, int *pnButton);
309278

@@ -374,7 +343,7 @@ mbfallback: // 諸理由で TaskDialog() を使えなかった場合は Message
374343
375344
## 番外編:`ChooseFontW()`
376345
377-
次はフォント選択画面を表示する [`ChooseFontW()`](https://learn.microsoft.com/en-us/windows/win32/api/commdlg/nc-commdlg-choosefontw) に関してで、これも System DPI レベルの対応です。しかし、こちらはピンぼけではなく**挙動がおかしくなります**(クソポイント 4)
346+
次はフォント選択画面を表示する [`ChooseFontW()`](https://learn.microsoft.com/en-us/windows/win32/api/commdlg/nc-commdlg-choosefontw) に関してで、これも System DPI レベルの対応です。しかし、こちらはピンぼけではなく**挙動がおかしくなります**。
378347
379348
![](2024-12-24-highdpi/choosefontw.png)
380349
@@ -412,7 +381,7 @@ if (ret) {
412381

413382
## おわりに
414383

415-
本日は、Windows の High DPI 対応用 API の現状を簡単にまとめた上で、Win32 API 実装で古い OS との互換性を切らずに High DPI 対応する方法を説明しました。複雑な実装とはなりますが、古い環境でも最新の環境でも完璧に振る舞えるアプリを開発できるのが C/C++ による Win32 API 直呼び出し実装の醍醐味だと思います。まあ、実用的なアプリを開発するには原始的すぎて今となっては非現実的ですが、古くから開発を続けているアプリの High DPI 対応化などの役に立てば幸いです。
384+
本日は、Windows の High DPI 対応用 API の現状を簡単にまとめた上で、Win32 API 実装で古い OS との互換性を切らずに High DPI 対応する方法を説明しました。歴史的経緯で塗り固められた Windows の汚い部分に真正面から向き合うことになりますが、古い環境でも最新の環境でも完璧に振る舞えるアプリを開発できるのが C/C++ による Win32 API 直呼び出し実装の醍醐味だと思います。まあ、実用的なアプリを開発するには原始的すぎて今となっては非現実的ですが、古くから開発を続けているアプリの High DPI 対応化などの役に立てば幸いです。
416385

417386
実際に Windows 2000 以降の互換性を維持しながら、Windows 11 でも完璧に Per-Monitor V2 High DPI 対応するように設計したソフトウェアとして、拙作の [Brainfuck インタプリタ](https://github.com/watamario15/brainfuck)があります(これはさらに preprocessor switch で [SHARP Brain 電子辞書](https://jp.sharp/edictionary/)を含む [Windows CE](https://ja.wikipedia.org/wiki/Microsoft_Windows_Embedded_CE) にも対応します)。まあ、2025 年を目前に控えて Windows 10 より古い OS をサポートする理由はもはや皆無なわけですが、ロマンがありますよね。
418387

@@ -424,6 +393,10 @@ Windows XP 100% (96 DPI):
424393

425394
![](2024-12-24-highdpi/winxp.png)
426395

396+
Windows 2000 100% (96 DPI):
397+
398+
![](2024-12-24-highdpi/win2000.png)
399+
427400
そんなわけで、私の今年の Advent Calendar 2024 記事はこれで最後です。良いお年を〜
428401

429402
## 参考サイト
@@ -435,4 +408,4 @@ Windows XP 100% (96 DPI):
435408
[^3]: この手のものを手動指定すると将来値が変わるかもと不安になるかもしれませんが、これはコンパイル時展開のマクロ定数なので、この値が変化すると `WM_DPICHANGED` マクロを使っていても結局再コンパイルが必要になります。つまり、単にコードの読みやすさだけの問題で、それも自分で定義するなりコメントを書くなりでカバーできる範囲です。
436409
[^4]: Device context まで指定して呼び出す画面の pixels per inch を返す関数が、High DPI awareness のセーフガードまでついているにも関わらず、System DPI しか返さないのが謎でしかないのです。セーフガードがある時点で壊れる互換性なんてないだろうし、むしろ画面の pixels per inch を取れると期待するプログラムの互換性を壊しているはずです。
437410
[^5]: 2025 年を目前に控えた今では普通それで良いですが、「古い OS との互換性を切らずに High DPI 対応する」が本記事の趣旨なので。
438-
[^6]: もっとも、システムフォルダに不正な DLL が置かれているような状況では、システムはほぼ確実に攻撃者の手中にあるので、私見としてはどう足掻いても無駄だ(そんなことを考慮する意味はない)と思っています。この私見について責任は負いませんが...
411+
[^6]: 何と Windows ではアプリに関連付けられた拡張子のファイルをダブルクリックすると、そのファイルの場所をカレントディレクトリとして起動します。つまり、攻撃者がアプリで開ける普通のファイルと共に偽の DLL を含む zip ファイルをユーザに渡し、ユーザがそれを丸ごと展開して普通のファイルを開いた場合、その横にある偽の DLL も `LoadLibraryW()` で読み込まれて実行されます。こんなとんでもないクソ仕様があるため脆弱性として対策が必要になります。
-561 KB
Loading
313 KB
Loading
-510 KB
Loading

0 commit comments

Comments
 (0)