Windowsエクスプローラーへファイルドロップした際、ドロップ先のフォルダーを取得する

# はじめに
[Windowsエクスプローラーに存在しないファイルをドロップする（Part4）](https://qiita.com/miswil/items/39dfdffde6df0d2a384c)の続きです。ドロップ先のフォルダーさえわかれば面倒くさいこと不要で直接ファイル保存できるだろ、って話です。この記事単体でも読めます。
# 設計
どうやって実現するかをはじめに解説します。シリーズの過去記事では`IDataObject`の工夫で乗り切ってきましたが、それでは実現することができません。Windowsエクスプローラーが`IDataObject`オブジェクトのデータの一つとしてドロップ先フォルダーとかを格納してくれればいいんですけど、残念ながらそういう動きはないようです。Windowsエクスプローラーは`IDataObject`から自分が欲しいデータ（`FileDrop`とか`FILEGROUPEDESCRIPTOR`とか`FILECONTENTS`とか）だけ取り出して自己完結して終わります。
そこで全く違う仕組みが必要になるわけですが、それが[`ICopyHook`COMインタフェース](https://learn.microsoft.com/ja-jp/previous-versions/windows/desktop/legacy/bb776049(v=vs.85))です。このインタフェースはフォルダーをコピー・移動・リネーム・削除したときにHookすることができるシェル拡張機能のインタフェースです。
[シェル拡張](https://learn.microsoft.com/ja-jp/windows/win32/shell/shell-entry)とはOSが様々なオブジェクト（ファイル・フォルダー・プリンター・ドライブ・プリンターetc...）を管理する方法に独自の機能を追加する方法です。身近な例では拡張子の関連付けもその1つです。特定の拡張子を持つファイル選択したときに特定のプログラムを起動するように機能を拡張しているわけですね。簡単なシェル拡張だとレジストリに特定のキーや値を設定するだけで拡張することができます。複雑な機能の場合プログラムを書いてdllやexeの形式にしてやることでプラグインのように挿入することもできます。Windowsエクスプローラーはシェルの機能の一部であり、オブジェクトにアクセスするためのUIなので、シェル拡張機能でその動作を拡張することができます。
`ICopyHook`は**すべてのフォルダー**のコピー・移動・リネーム・削除に対して`CopyCopyCallback`メソッドが呼び出されます。そのためシステム的な影響度は大きく、実装を誤るとフォルダーのコピー・移動・リネーム・削除が行えなくなります。そのためよく注意して実装してください。
ドラッグ元となるアプリケーション含めて、全体として次のような動きになるように実装します。
1. （ドラッグ元アプリケーション）ドラッグ開始時(あるいはドロップ時)に特定のパスにフォルダーを作成し、そのパスを`DataFormats.FileDrop`フォーマット（`CF_HDROP`フォーマット）で`IDataObject`にセットする
1. (ドラッグ元アプリケーション）作った`IDataObject`を使ってドラッグを開始する
1. （Windowsエクスプローラー）`IDataObject`に`DataFormats.FileDrop`フォーマット（`CF_HDROP`フォーマット）が含まれているためドロップを受け入れる。フォルダーのパスを指定されているのでフォルダーを移動またはコピーしようとする
1. （Windowsエクスプローラー）`ICopyHook`オブジェクトの`CopyCopyCallback`メソッドを呼び出す
1. （`ICopyHook`実装）フォルダーの移動元（コピー元）が特定のパスの場合、ドラッグ元プロセスにコピー先フォルダーパスを通知。フォルダーの移動（コピー）動作をキャンセル
1. （ドラッグ元アプリケーション）通知された内容をもとに任意の動作を開始

# 実装
## 言語
実装言語はVisual C++を使います。シェル拡張の実装にはC#は使いません。Microsoftはマネージドコードをシェル拡張に使うことを推奨していないためです。
>Microsoft recommends against writing managed in-process extensions to Windows Explorer or Windows Internet Explorer and does not consider them a supported scenario.
(https://learn.microsoft.com/en-us/previous-versions/windows/desktop/legacy/dd758089(v=vs.85) )
## プロジェクト作成
Visual Studioで作る場合、ATLプロジェクトを使うと簡単です。ボイラープレートコードをテンプレートから作ってくれます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247638/badcabe7-3992-bdeb-ef50-84d0cb895d2b.png)
このとき一緒にできる「（プロジェクト名）PS」というプロジェクトは今回使わないので消してかまいません。
プロジェクトができたら、プロジェクトを右クリック→「プロパティ」→「リンカー」→「全般」から「ユーザーごとのリダイレクト」を「はい」に変更します。ビルド構成ごとに設定値があるため、通常ならDebugとReleaseそれぞれに設定します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247638/2248bb71-19c1-57a7-1581-602d7747d980.png)
その後、プロジェクトを右クリック→「追加」→「新しい項目」から「ATL」カテゴリの「ATLシンプルオブジェクト」を選択
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247638/3101fe47-acd8-ae96-6c0c-d4c68add7d30.png)
「その他」オプションでスレッドモデルは"Apartment"（これが既定値のはず）、集約は「いいえ」、インタフェースは「カスタム」として作成します。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1247638/3c32f6a7-2877-6d5d-454d-c23aadc8e002.png)
この時点で一度ビルドして、テンプレートのコードで環境設定に異常がないか確認しておきましょう。
## インタフェース実装
`ICopyHook`インタフェースを実装します。各ファイルの次の記載を追記・変更していきます。
pch.h：ICopyHookインタフェースを含むヘッダをinclude
```diff_cpp:pch.h
#include "framework.h"
+#include <ShlObj.h>
```
DroppedFolderDetectHookShellExtension.idl：不要なインタフェース定義を削除
```diff_cpp:DroppedFolderDetectHookShellExtension.idl
-[
-	object,
-	uuid(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx),
-	pointer_default(unique)
-]
-interface IDroppedFolderDetectHook : IUnknown
-{
-};
// 中略
	coclass DroppedFolderDetectHook
	{
-		[default] interface IDroppedFolderDetectHook;
+		[default] interface IUnknown;
	};
```
DroppedFolderDetectHook.h：ICopyHookクラスからの継承、メソッドの宣言
```diff_cpp:DroppedFolderDetectHook.h
class ATL_NO_VTABLE CDroppedFolderDetectHook :
	public CComObjectRootEx<CComSingleThreadModel>,
	public CComCoClass<CDroppedFolderDetectHook, &CLSID_DroppedFolderDetectHook>,
	public IDroppedFolderDetectHook
-	public IDroppedFolderDetectHook
+	public ICopyHook
 // 中略
 BEGIN_COM_MAP(CDroppedFolderDetectHook)
-	COM_INTERFACE_ENTRY(IDroppedFolderDetectHook)
+	COM_INTERFACE_ENTRY_IID(IID_IShellCopyHook, ICopyHook)
END_COM_MAP()
 // 中略
 public:
+	UINT CopyCallback(
+		HWND hwnd, UINT wFunc, UINT wFlags,
+		PCWSTR pszSrcFile, DWORD dwSrcAttribs,
+		PCWSTR pszDestFile, DWORD dwDestAttribs) override;
```
DroppedFolderDetectHook.cpp：メソッドの実装
```diff_cpp:DroppedFolderDetectHook.cpp
+UINT CDroppedFolderDetectHook::CopyCallback(
+	HWND hwnd, UINT wFunc, UINT wFlags,
+	PCWSTR pszSrcFile, DWORD dwSrcAttribs,
+	PCWSTR pszDestFile, DWORD dwDestAttribs)
+{
+	return IDYES;
+}
```
DroppedFolderDetectHook.rgs：レジストリ登録の追加
```diff_cpp:DroppedFolderDetectHook.rgs
HKCR
{
	NoRemove CLSID
	{
		ForceRemove {748c00ab-b809-4dd0-a2c4-9b954e9ee6d2} = s 'DroppedFolderDetectHook class'
		{
			InprocServer32 = s '%MODULE%'
			{
				val ThreadingModel = s 'Apartment'
			}
			TypeLib = s '{300d1fde-9a07-468c-bbc5-37cd2466adc0}'
			Version = s '1.0'
		}
	}
+	NoRemove Directory
+	{
+		NoRemove shellex
+		{
+			NoRemove CopyHookHandlers
+			{
+				ForceRemove DroppedFolderDetectHook = s '{748c00ab-b809-4dd0-a2c4-9b954e9ee6d2}'
+			}
+		}
+	}
}
```
実装はひとまずすべての操作を許容するように`IDYES`を返します。ここまででビルドしてレジストリを確認すると、"HKEY_CLASSES_ROOT\Directory\shellex\CopyHookHandlers\DroppedFolderDetectHook"の既定値にInterfaceIDが、"HKEY_CLASSES_ROOT\CLSID\{InterfaceID}"にDLLへのパスが登録されているはずです。
このDLLをWindowsエクスプローラーに読み込ませる必要があります。OSを再起動させる以外に、エクスプローラーの再起動でも再読み込みさせることができます。
```bat
taskkill /f /im explorer.exe; start explorer.exe
```
## 動作を確かめる
試しにすべての操作を拒否してみます。
```diff_cpp:DroppedFolderDetectHook.cpp
UINT CDroppedFolderDetectHook::CopyCallback(
	HWND hwnd, UINT wFunc, UINT wFlags,
	PCWSTR pszSrcFile, DWORD dwSrcAttribs,
	PCWSTR pszDestFile, DWORD dwDestAttribs)
{
-	return IDYES;
+	return IDNO;
}
```
既存のdllはWindowsエクスプローラーによって開かれているため上書きできず、ビルドが成功しません。一度Windowsエクスプローラーを再起動すれば上書きすることができ再読み込みされます。
Windowsエクスプローラー上で新しいフォルダーを作ってリネームや削除をしようとするとことごとく無視されることがわかると思います。これはあくまでシェル上の動作を拡張しているだけなので、コマンドプロンプトやPowerShellからリネームや削除をすることはできます。フォルダーが完全に操作できなくなると、OS含むいろんなプログラムで甚大な不具合が発生しそうですしね。あくまでWindowsエクスプローラー上での操作だけです。
## 実装を発展させる
設計の方針で書いたような動作を実装します。
```cpp:DroppedFolderDetectHook.cpp
UINT CDroppedFolderDetectHook::CopyCallback(
	HWND hwnd, UINT wFunc, UINT wFlags,
	PCWSTR pszSrcFile, DWORD dwSrcAttribs,
	PCWSTR pszDestFile, DWORD dwDestAttribs)
{
	TCHAR targetPath[MAX_PATH + 1];
	TCHAR longTargetPath[MAX_PATH + 1];
	TCHAR longSrcPath[MAX_PATH + 1];
	GetTempPath2(MAX_PATH + 1, targetPath);
	GetLongPathName(targetPath, longTargetPath, MAX_PATH + 1);
	GetLongPathName(pszSrcFile, longSrcPath, MAX_PATH + 1);
	StrNCat(longTargetPath, TEXT("DropSource"), MAX_PATH + 1);
	if (StrNCmp(longSrcPath, longTargetPath, _tcslen(longTargetPath)) != 0) {
		return IDYES;
	}

	LPTSTR lpszPipename = TEXT("\\\\.\\pipe\\com.example.dropdirectly");
	DWORD nwrite;
	HANDLE hPipe = CreateFile(
		lpszPipename,
		GENERIC_WRITE,
		0,
		NULL,
		OPEN_EXISTING,
		0,
		NULL);
	WriteFile(hPipe,
		pszDestFile,
		(_tcslen(pszDestFile) + 1) * sizeof(TCHAR),
		&nwrite, NULL);
	FlushFileBuffers(hPipe);
	CloseHandle(hPipe);
	return IDNO;
}
```
%TEMP%\DropSourceフォルダーを移動させようとしたときのみHookが動作するようにします。このコードはWindowsエクスプローラーのプロセス内で実行されるので、ドラッグ元アプリケーションと通信するために何らかの仕組みが必要です。今回は名前付きパイプを使いました。
ドラッグ元アプリケーションでもパイプを開いて待ち構えます。
```csharp:MainWindow.xaml.cs
private async Task WaitForDrop()
{
    while (true)
    {
        using var pipe = new NamedPipeServerStream(
            "com.example.dropdirectly",
            PipeDirection.In,
            1,
            PipeTransmissionMode.Byte);
        await pipe.WaitForConnectionAsync();
        var reader = new StreamReader(pipe, Encoding.Unicode);
        var dest = Path.GetDirectoryName(await reader.ReadToEndAsync());
        await this.Dispatcher.BeginInvoke(() => MessageBox.Show(this, $"Dropped to {dest}"));
    }
}
```
これでドラッグ先のフォルダーを取得することができました。
# ソースコード全体
https://github.com/miswil/DropFiles/tree/master/DropDirectly
# 所感
割とポピュラーなやり方で、WinSCPやTortoiseSVNなんかでも使われているみたいです。