VB.NETアプリで、Windows Explorerと同じ右クリックメニュー(標準項目+圧縮ソフトなどのシェル拡張)をそのまま表示して実行したい——。その場合、.NETのContextMenuではなく、Windowsシェルが提供するCOMインターフェイス(IContextMenu/IContextMenu2)を呼び出すのが最短ルートです。この記事では、ファイルパスと表示座標からネイティブのコンテキストメニューを出す手順と、拡張メニューで必須になりやすいWndProc転送までを、VB.NETの実装パターンとして具体的にまとめます。
VB.NETで「Explorerと同じ右クリックメニュー」を出すのが難しく見える理由
Windows Explorerの右クリックメニューが厄介なのは、見た目のメニュー表示よりも「中身」が多層になっている点です。Explorerのメニューは、OS標準項目(開く、送る、プロパティなど)に加えて、インストールされているアプリが登録するシェル拡張(Shell Extension)の項目も合成して作られています。
つまり、単に自分でMenuStrip/ContextMenuStripを組み立てても、以下の要件は満たせません。
- 圧縮ソフト、Gitクライアント、クラウド同期、セキュリティ製品などが追加するサードパーティ項目まで含めたい
- 「開く」だけでなく、各項目の実行までExplorerと同じ動作にしたい
- オーナードロー(独自描画)やサブメニューなど、特殊なメニュー表示にも対応したい
この「Explorerがやっていること」をアプリ側で再現する正攻法が、WindowsシェルのCOMであるIContextMenu / IContextMenu2(必要ならIContextMenu3)を直接使う方法です。
結論:IContextMenu/IContextMenu2をP/Invoke+COMで直接呼び出すのが最適解
「ファイルパスと表示位置を渡したら、Explorerと同じメニューが出る」タイプの既製ライブラリは確かに存在しますが、内部でやっているのは多くの場合、まさにIContextMenu系のラップです。仕組みを理解しておくと、以下のメリットがあります。
- メニューが出ない/一部の拡張が表示されない原因(ビット数、メッセージ転送、COM初期化など)を切り分けできる
- 必要な機能だけを最小実装でき、依存が増えない
- エラー時の挙動(落ちる、固まる、例外)を自分で制御しやすい
実装の全体像(使うAPIと役割)
| 工程 | 目的 | 主に使うもの |
|---|---|---|
| PIDL取得 | ファイルパスをシェルが扱うID(PIDL)へ変換 | ILCreateFromPath(またはSHParseDisplayName) |
| 親フォルダへバインド | 親IShellFolderと子PIDL(相対)を得る | SHBindToParent |
| IContextMenu取得 | 対象ファイルのコンテキストメニュー生成元を取得 | IShellFolder.GetUIObjectOf |
| メニュー構築 | 空のHMENUに項目を埋めさせる | CreatePopupMenu + IContextMenu.QueryContextMenu |
| メニュー表示 | 指定座標にポップアップ表示し、選択コマンドを得る | TrackPopupMenu(またはTrackPopupMenuEx) |
| 選択項目の実行 | Explorerと同じ動作を実行 | IContextMenu.InvokeCommand(CMINVOKECOMMANDINFO/EX) |
| 描画メッセージ転送 | 拡張メニューのオーナードロー等を崩さない | IContextMenu2.HandleMenuMsg(必要ならIContextMenu3) |
| 後始末 | メモリ/ハンドル/COM参照を確実に解放 | DestroyMenu / ILFree / Marshal.FinalReleaseComObject |
実装手順をもう少し噛み砕く
PIDLとは何か(なぜ必要か)
PIDL(Pointer to an Item ID List)は、Windowsシェルがファイルやフォルダなどの「シェルアイテム」を識別するための内部表現です。Explorerのメニュー生成は、単なるファイルパス文字列ではなく、PIDLやIShellFolderを起点に行われます。そこで、まずファイルパスからPIDLを作ります。
SHBindToParentを挟む理由
コンテキストメニュー取得で重要なのは「対象アイテム(子)」だけでなく、「どのフォルダ(親IShellFolder)の中にあるアイテムか」というコンテキストです。SHBindToParentを使うと、
- 親フォルダのIShellFolder
- 親から見た相対PIDL(子PIDL)
が同時に取れるため、GetUIObjectOfでIContextMenuへ到達しやすくなります。
QueryContextMenuのフラグで「拡張メニュー」を含める
Explorerの「拡張メニュー」(Shiftを押しながら右クリックしたときに増える項目)相当まで含めたい場合は、QueryContextMenuにCMF_EXTENDEDVERBSを渡します。さらにExplorerに近い挙動を狙うなら、CMF_EXPLOREも合わせるのが定番です。
TrackPopupMenuの戻り値をInvokeCommandへつなぐ
TrackPopupMenu(TPM_RETURNCMD)で得られるのは、ユーザーが選択したメニュー項目のコマンドIDです。QueryContextMenuで指定したidCmdFirstを基準に、(選択ID – idCmdFirst)をlpVerbへ渡すことで、IContextMenu.InvokeCommandが正しく動きます。
拡張メニュー表示で重要:WndProcでIContextMenu2へメッセージ転送する
シェル拡張の中には、アイコン付き、特殊な余白、独自描画(オーナードロー)などを行うものがあり、これらはWin32メッセージ(WM_DRAWITEM/WM_MEASUREITEM等)を受け取って描画を成立させます。Explorerはそれを適切に拡張へ転送していますが、あなたのアプリでも同様に転送しないと、
- メニューが真っ白になる
- サブメニューが崩れる
- 特定の拡張だけ表示が欠ける/落ちる
といった現象が出ます。そこで、フォームのWndProcをオーバーライドし、対象メニューを表示している間だけIContextMenu2(可能ならIContextMenu3)に転送します。
| メッセージ | 意味 | 転送先 |
|---|---|---|
| WM_INITMENUPOPUP | サブメニューの初期化 | IContextMenu2.HandleMenuMsg |
| WM_MEASUREITEM | オーナードロー項目のサイズ計測 | IContextMenu2.HandleMenuMsg |
| WM_DRAWITEM | オーナードロー項目の描画 | IContextMenu2.HandleMenuMsg |
| WM_MENUCHAR / WM_MENUGETOBJECT | キーボード操作や高度なメニュー処理 | 可能ならIContextMenu3.HandleMenuMsg2 |
VB.NET実装例:Explorerと同じコンテキストメニューを表示・実行する
ここでは、WinFormsを想定した実装パターンを示します(WPFでも、HwndSourceのメッセージフックで同じ考え方が使えます)。ポイントは、
- メニュー表示中だけIContextMenu2/3参照を保持する
- WndProcで必要なメッセージを転送する
- PIDL/メニュー/COM参照を確実に解放する
の3つです。
フォーム側(WndProc転送+右クリックで表示)
' ※HTML貼り付け用に < > をエスケープしています
Option Strict On
Option Explicit On
Imports System.Windows.Forms
Public Class MainForm
Private ReadOnly _shellMenu As New ShellContextMenu()
Protected Overrides Sub WndProc(ByRef m As Message)
' 拡張メニューの描画/初期化メッセージをシェルへ転送
If _shellMenu.HandleMenuMessage(m) Then
Return
End If
MyBase.WndProc(m)
End Sub
Private Sub MainForm_MouseUp(sender As Object, e As MouseEventArgs) Handles Me.MouseUp
If e.Button <> MouseButtons.Right Then Return
' 例:任意のファイル(実際は選択中のファイルパスを渡す)
Dim target As String = "C:\Windows\System32\notepad.exe"
' 画面座標で表示(Cursor.PositionでもOK)
Dim p = Cursor.Position
_shellMenu.ShowForFile(target, Me.Handle, p.X, p.Y)
End Sub
End Class
ShellContextMenuクラス(IContextMenu/IContextMenu2を使う本体)
' ※HTML貼り付け用に < > をエスケープしています
Option Strict On
Option Explicit On
Imports System
Imports System.Runtime.InteropServices
Imports System.Windows.Forms
Public NotInheritable Class ShellContextMenu
' メニュー表示中だけ保持(WndProcで転送するため)
Private _cm2 As IContextMenu2 = Nothing
Private _cm3 As IContextMenu3 = Nothing
' --- Win32 messages ---
Private Const WM_NULL As UInteger = 0UI
Private Const WM_DRAWITEM As UInteger = &H2BUI
Private Const WM_MEASUREITEM As UInteger = &H2CUI
Private Const WM_INITMENUPOPUP As UInteger = &H117UI
Private Const WM_MENUCHAR As UInteger = &H120UI
Private Const WM_MENUGETOBJECT As UInteger = &H124UI
Private Const SW_SHOWNORMAL As Integer = 1
Public Function HandleMenuMessage(ByRef m As Message) As Boolean
' IContextMenu3が取れる場合は、より広いメッセージを処理可能
If _cm3 IsNot Nothing Then
Dim result As IntPtr = IntPtr.Zero
Dim hr As Integer = _cm3.HandleMenuMsg2(CUInt(m.Msg), m.WParam, m.LParam, result)
If hr = 0 Then
m.Result = result
Return True
End If
End If
If _cm2 Is Nothing Then Return False
Select Case CUInt(m.Msg)
Case WM_INITMENUPOPUP, WM_DRAWITEM, WM_MEASUREITEM
Dim hr As Integer = _cm2.HandleMenuMsg(CUInt(m.Msg), m.WParam, m.LParam)
Return (hr = 0)
Case WM_MENUCHAR, WM_MENUGETOBJECT
' IContextMenu2ではカバーしきれないことがある(拡張次第)
Return False
End Select
Return False
End Function
Public Sub ShowForFile(filePath As String, ownerHwnd As IntPtr, x As Integer, y As Integer)
If String.IsNullOrWhiteSpace(filePath) Then Return
Dim pidlAbs As IntPtr = ILCreateFromPathW(filePath)
If pidlAbs = IntPtr.Zero Then Return
Dim parent As IShellFolder = Nothing
Dim pidlChild As IntPtr = IntPtr.Zero
Dim pUnkContextMenu As IntPtr = IntPtr.Zero
Dim cm As IContextMenu = Nothing
Dim hMenu As IntPtr = IntPtr.Zero
Try
' 親IShellFolder + 子PIDL(相対)を取得
Dim iidIShellFolder As Guid = GetType(IShellFolder).GUID
Dim hr As Integer = SHBindToParent(pidlAbs, iidIShellFolder, parent, pidlChild)
If hr <> 0 Then Marshal.ThrowExceptionForHR(hr)
' IContextMenuを取得
Dim apidl() As IntPtr = {pidlChild}
Dim iidIContextMenu As Guid = GetType(IContextMenu).GUID
hr = parent.GetUIObjectOf(ownerHwnd, 1UI, apidl, iidIContextMenu, IntPtr.Zero, pUnkContextMenu)
If hr <> 0 OrElse pUnkContextMenu = IntPtr.Zero Then Marshal.ThrowExceptionForHR(hr)
cm = CType(Marshal.GetObjectForIUnknown(pUnkContextMenu), IContextMenu)
' RCWが参照を持つので、元ポインタはRelease
Marshal.Release(pUnkContextMenu)
pUnkContextMenu = IntPtr.Zero
' 描画対応(可能ならIContextMenu3)
_cm2 = TryCast(cm, IContextMenu2)
_cm3 = TryCast(cm, IContextMenu3)
' メニュー生成
hMenu = CreatePopupMenu()
If hMenu = IntPtr.Zero Then Throw New System.ComponentModel.Win32Exception()
Const idCmdFirst As UInteger = 1UI
Const idCmdLast As UInteger = &H7FFFUI
Dim flags As CMF = CMF.EXPLORE Or CMF.EXTENDEDVERBS
Dim qcm As Integer = cm.QueryContextMenu(hMenu, 0UI, idCmdFirst, idCmdLast, flags)
If qcm < 0 Then Marshal.ThrowExceptionForHR(qcm)
' メニューが即閉じないように前面化(定番の小技)
SetForegroundWindow(ownerHwnd)
' 表示して選択されたコマンドIDを取得
Dim cmd As UInteger = TrackPopupMenuEx(
hMenu,
TPM.RETURNCMD Or TPM.RIGHTBUTTON,
x,
y,
ownerHwnd,
IntPtr.Zero
)
If cmd <> 0UI Then
Dim invoke As New CMINVOKECOMMANDINFOEX()
invoke.cbSize = Marshal.SizeOf(GetType(CMINVOKECOMMANDINFOEX))
invoke.fMask = CMIC.ASYNCOK Or CMIC.UNICODE
invoke.hwnd = ownerHwnd
invoke.lpVerb = New IntPtr(CInt(cmd - idCmdFirst))
invoke.nShow = SW_SHOWNORMAL
Dim ic As Integer = cm.InvokeCommand(invoke)
If ic < 0 Then Marshal.ThrowExceptionForHR(ic)
End If
' TrackPopupMenu系の挙動安定化(よくある対策)
PostMessage(ownerHwnd, WM_NULL, IntPtr.Zero, IntPtr.Zero)
Finally
' 表示が終わったら参照を外す(WndProc転送も停止)
_cm3 = Nothing
_cm2 = Nothing
If hMenu <> IntPtr.Zero Then DestroyMenu(hMenu)
If cm IsNot Nothing Then Marshal.FinalReleaseComObject(cm)
If parent IsNot Nothing Then Marshal.FinalReleaseComObject(parent)
If pidlAbs <> IntPtr.Zero Then ILFree(pidlAbs)
If pUnkContextMenu <> IntPtr.Zero Then Marshal.Release(pUnkContextMenu)
End Try
End Sub
' --- COM interfaces ---
<ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("000214e4-0000-0000-C000-000000000046")>
Private Interface IContextMenu
<PreserveSig>
Function QueryContextMenu(hMenu As IntPtr,
indexMenu As UInteger,
idCmdFirst As UInteger,
idCmdLast As UInteger,
uFlags As CMF) As Integer
<PreserveSig>
Function InvokeCommand(ByRef pici As CMINVOKECOMMANDINFOEX) As Integer
<PreserveSig>
Function GetCommandString(idCmd As UInteger,
uType As UInteger,
pReserved As IntPtr,
pszName As IntPtr,
cchMax As UInteger) As Integer
End Interface
<ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("000214f4-0000-0000-C000-000000000046")>
Private Interface IContextMenu2
Inherits IContextMenu
<PreserveSig>
Function HandleMenuMsg(uMsg As UInteger, wParam As IntPtr, lParam As IntPtr) As Integer
End Interface
<ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("BCFCE0A0-EC17-11d0-8D10-00A0C90F2719")>
Private Interface IContextMenu3
Inherits IContextMenu2
<PreserveSig>
Function HandleMenuMsg2(uMsg As UInteger, wParam As IntPtr, lParam As IntPtr, ByRef plResult As IntPtr) As Integer
End Interface
<ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown),
Guid("000214E6-0000-0000-C000-000000000046")>
Private Interface IShellFolder
<PreserveSig>
Function ParseDisplayName(hwnd As IntPtr, pbc As IntPtr, pszDisplayName As String,
ByRef pchEaten As UInteger, ByRef ppidl As IntPtr,
ByRef pdwAttributes As UInteger) As Integer
<PreserveSig>
Function EnumObjects(hwnd As IntPtr, grfFlags As UInteger, ByRef ppenumIDList As IntPtr) As Integer
<PreserveSig>
Function BindToObject(pidl As IntPtr, pbc As IntPtr, ByRef riid As Guid, ByRef ppv As IntPtr) As Integer
<PreserveSig>
Function BindToStorage(pidl As IntPtr, pbc As IntPtr, ByRef riid As Guid, ByRef ppv As IntPtr) As Integer
<PreserveSig>
Function CompareIDs(lParam As IntPtr, pidl1 As IntPtr, pidl2 As IntPtr) As Integer
<PreserveSig>
Function CreateViewObject(hwndOwner As IntPtr, ByRef riid As Guid, ByRef ppv As IntPtr) As Integer
<PreserveSig>
Function GetAttributesOf(cidl As UInteger, apidl() As IntPtr, ByRef rgfInOut As UInteger) As Integer
<PreserveSig>
Function GetUIObjectOf(hwndOwner As IntPtr, cidl As UInteger, apidl() As IntPtr,
ByRef riid As Guid, rgfReserved As IntPtr, ByRef ppv As IntPtr) As Integer
<PreserveSig>
Function GetDisplayNameOf(pidl As IntPtr, uFlags As UInteger, ByRef pName As IntPtr) As Integer
<PreserveSig>
Function SetNameOf(hwnd As IntPtr, pidl As IntPtr, pszName As String, uFlags As UInteger, ByRef ppidlOut As IntPtr) As Integer
End Interface
' --- Structures / Enums ---
<StructLayout(LayoutKind.Sequential)>
Private Structure CMINVOKECOMMANDINFOEX
Public cbSize As Integer
Public fMask As CMIC
Public hwnd As IntPtr
Public lpVerb As IntPtr
Public lpParameters As IntPtr
Public lpDirectory As IntPtr
Public nShow As Integer
Public dwHotKey As Integer
Public hIcon As IntPtr
Public lpTitle As IntPtr
Public lpVerbW As IntPtr
Public lpParametersW As IntPtr
Public lpDirectoryW As IntPtr
Public lpTitleW As IntPtr
Public ptInvoke As POINT
End Structure
<StructLayout(LayoutKind.Sequential)>
Private Structure POINT
Public x As Integer
Public y As Integer
End Structure
<Flags>
Private Enum CMF As UInteger
NORMAL = 0UI
EXPLORE = &H4UI
EXTENDEDVERBS = &H100UI
End Enum
<Flags>
Private Enum TPM As UInteger
RIGHTBUTTON = &H2UI
RETURNCMD = &H100UI
End Enum
<Flags>
Private Enum CMIC As UInteger
UNICODE = &H4000UI
ASYNCOK = &H100000UI
End Enum
' --- P/Invoke ---
<DllImport("shell32.dll", EntryPoint:="ILCreateFromPathW", CharSet:=CharSet.Unicode, ExactSpelling:=True)>
Private Shared Function ILCreateFromPathW(pszPath As String) As IntPtr
End Function
<DllImport("shell32.dll", ExactSpelling:=True)>
Private Shared Sub ILFree(pidl As IntPtr)
End Sub
<DllImport("shell32.dll", ExactSpelling:=True)>
Private Shared Function SHBindToParent(pidl As IntPtr,
ByRef riid As Guid,
<Out, MarshalAs(UnmanagedType.Interface)> ByRef ppv As IShellFolder,
ByRef ppidlLast As IntPtr) As Integer
End Function
<DllImport("user32.dll", ExactSpelling:=True)>
Private Shared Function CreatePopupMenu() As IntPtr
End Function
<DllImport("user32.dll", ExactSpelling:=True)>
Private Shared Function DestroyMenu(hMenu As IntPtr) As Boolean
End Function
<DllImport("user32.dll", ExactSpelling:=True)>
Private Shared Function TrackPopupMenuEx(hMenu As IntPtr,
uFlags As TPM,
x As Integer,
y As Integer,
hwnd As IntPtr,
lptpm As IntPtr) As UInteger
End Function
<DllImport("user32.dll", ExactSpelling:=True)>
Private Shared Function SetForegroundWindow(hwnd As IntPtr) As Boolean
End Function
<DllImport("user32.dll", ExactSpelling:=True)>
Private Shared Function PostMessage(hWnd As IntPtr, Msg As UInteger, wParam As IntPtr, lParam As IntPtr) As Boolean
End Function
End Class
つまずきポイントと対策(実運用で差が出るところ)
「出たけど一部が出ない」「拡張だけ欠ける」「環境によって動かない」といった相談が多いのは、ほとんどが次の論点に集約されます。
| 症状 | よくある原因 | 対策 |
|---|---|---|
| 特定のシェル拡張だけ出ない | x86/x64のビット数不一致(拡張が読み込めない) | OSに合わせてx64ビルド(またはAny CPU+Prefer 32-bitを見直す) |
| メニューが崩れる/真っ白/アイコンが欠ける | WndProcでWM_DRAWITEM/WM_MEASUREITEM等を転送していない | IContextMenu2.HandleMenuMsg(可能ならIContextMenu3)へ転送 |
| メニューが出てもクリック後に何も起きない | InvokeCommandのlpVerbがずれている/idCmdFirstとの対応が取れていない | TrackPopupMenuの戻り値から必ず(cmd – idCmdFirst)を渡す |
| メニューが一瞬で閉じる/外側クリックで変な挙動 | SetForegroundWindow/WM_NULLがない場合に起きやすい | 表示直前にSetForegroundWindow、表示後にPostMessage(WM_NULL) |
| UIスレッド以外から呼ぶと不安定 | COM/メッセージループ前提(STA)を満たしていない | WinForms UIスレッドで呼ぶ(別スレッドならSTA+MessageLoopを用意) |
| アプリが落ちる/固まる | サードパーティ拡張が自プロセスに読み込まれ、例外/デッドロックを起こす | 必要最小限に使う。可能なら別プロセス分離(後述)も検討 |
Windows 11の右クリック(新デザイン)との関係
Windows 11ではExplorerの右クリックメニューが刷新されましたが、アプリがIContextMenuで表示するメニューは、環境によっては従来型(クラシック)に見えることがあります。重要なのは見た目ではなく、
- Explorerが提供する標準の動作
- シェル拡張が提供する動作
を「同じように呼び出せる」点です。見た目まで完全一致させたい要求は難易度が上がるため、業務アプリではまず機能一致(同じ項目と同じ実行結果)をゴールに据えるのが現実的です。
複数ファイル選択に拡張したい場合の考え方
Explorerのように複数選択時のメニューを出したい場合、IShellFolder.GetUIObjectOfに渡すPIDL配列を複数にします。ただしコツがあります。
- 基本は「同一フォルダ内の複数ファイル」なら相対PIDL配列で対応しやすい
- 別フォルダを跨ぐ選択を完全再現するには、IDataObject経由などさらに踏み込んだ実装が必要になることがある
まずは、アプリ内の一覧が「同一ディレクトリを表示する設計」なら、複数PIDLを渡す拡張でExplorerに近い体験を作りやすいです。
既製ライブラリを使う場合のチェックリスト
自前実装が重い場合は、シェルコンテキストメニューをラップしたライブラリを使うのも現実的です。ただし「出るけど崩れる」「拡張が欠ける」問題はラッパーでも起こるため、選定では次を確認すると失敗しにくいです。
| 確認ポイント | 理由 |
|---|---|
| x64対応が明確か | 拡張のビット数と合わないと項目が欠けることがある |
| IContextMenu2/3のメッセージ転送を実装しているか | オーナードロー拡張で表示崩れを防ぐため |
| COM/ハンドル解放が確実か | リークや不安定化(メニューが出なくなる等)を防ぐため |
| 例外時の挙動(落ちないか) | 拡張は自プロセスに読み込まれるため、保護が重要 |
実運用でおすすめの設計(安定性を上げるコツ)
シェル拡張は便利ですが、あなたのアプリのプロセス内で実行されます。安定性と保守性を考えるなら、次の設計が効きます。
- 表示の入口を1箇所に集約:メニュー表示処理をクラス化し、フォームごとに散らさない
- メニュー表示中だけ参照を保持:IContextMenu2/3の参照は使い終わったら必ずNothingへ
- x64を基本にする:Windowsがx64なら、まずx64で揃える
- 落ちやすい環境では分離も検討:どうしても拡張が原因で落ちるなら、メニュー表示を別プロセスに逃がす(IPCで結果だけ返す)設計も有効
「右クリックメニューは便利だけど、業務アプリが落ちたら本末転倒」というケースは多いので、使いどころ(ファイル操作が主機能か、補助機能か)も合わせて判断するのがコツです。
まとめ
- VB.NETでも、Explorerと同等のネイティブ右クリックメニューはIContextMenu/IContextMenu2で実現できる
- 基本の流れは「PIDL取得 → 親IShellFolder → GetUIObjectOf → QueryContextMenu → TrackPopupMenu → InvokeCommand」
- 拡張メニューの表示崩れ対策として、WndProcでIContextMenu2(可能なら3)へメッセージ転送が重要
- x86/x64、STA、リソース解放、拡張の安定性など、実運用の落とし穴を押さえると完成度が上がる

コメント