はじめに
表題を詳しく説明すると、「ドライブ上にまだ存在しないデータ(メモリ上だったり、ネットワーク上だったり)について、デスクトップアプリケーションからWindowsエクスプローラーにドラッグ&ドロップすることで、ドロップ先にデータを書き込んだファイルを作成する」という意味です。
この動作を確認できる身近な例は、Webブラウザーの画像データです。画像データはWebブラウザーにレンダリングされているメモリ上のデータですが、画像をドラッグしてWindowsエクスプローラーにドロップすると画像ファイルが作成されます。その中身のデータは当然元の画像データです。
ドラッグ・ドロップを用いてファイルを操作する場合、たとえば.NETではファイルのパスを指定します。つまり、すでにドライブ上にファイルが存在している必要があります。
var data = new DataObject();
data.SetData(DataFormats.FileDrop, "C:\example.txt");
しかし、今回のテーマである「存在しないファイル」はそもそもパスがありません。何を指定してやればよいのでしょう。
対象プラットフォーム
フレームワークは.NET、そしてデスクトップアプリケーションということでWinForms、WPFを基本にして、プログラミング言語はC#で記述します。しかし最終的には言語やフレームワークに依存しなくなります。
環境情報
Windowsエクスプローラーという外部アプリが関係しますが、動作確認は以下バージョンで行っています。
- .NET 8.0.101
- OS
- エディション Windows 11 Pro
- バージョン 22H2
- OS ビルド 22621.3007
- エクスペリエンス Windows Feature Experience Pack 1000.22681.1000.0
What/How
この課題に対するソリューションは複数あり、やりたいことが複雑になるほど実装も複雑になります。複雑になるほど汎用ソリューションとして使えますが、保守性を考えると適切なレベルで妥協するほうがよいでしょう。
それぞれ内容が全く異なるため記事を分けます。簡単な順に
- ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→本記事
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→Part2
- ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→Part3
- ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→Part4
- エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
- いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→Windowsエクスプローラーへファイルドロップした際、ドロップ先のフォルダーを取得する
実装
まずはコードです。WinFormsでもWPFでも同じです。実装だけでいい人はこれでOK。
var fileSize = 10L;
var data = new DataObject();
var fileInfo = new MemoryStream();
var fileInfoWriter = new BinaryWriter(fileInfo);
// (1)
fileInfoWriter.Write(1);
// (2)
fileInfoWriter.Write(0x40);
fileInfoWriter.Write(new byte[16 + 4 * 2 + 4 * 2 + 4 + 4 * 2 * 3]);
fileInfoWriter.Write((uint)(fileSize >> 32));
fileInfoWriter.Write((uint)(fileSize & 0xFFFFFFFF));
fileInfoWriter.Write(Encoding.Unicode.GetBytes("example.txt".PadRight(260, '\0')));
fileInfoWriter.Flush();
fileInfo.Position = 0;
data.SetData("FileGroupDescriptorW", fileInfo);
var fileContents = new MemoryStream();
var fileContentsWriter = new StreamWriter(fileContents);
fileContentsWriter.Write("1234567890");
fileContentsWriter.Flush();
fileContents.Position = 0;
data.SetData("FileContents", fileContents);
とりあえずこうやって作ったDataObject
を使ってドラッグを開始すればWindowsエクスプローラーにドロップできてファイルが作成されます。
解説
オブジェクトのバイナリ表現とか、配列のメモリ配置とか、ポインターとか、C/C++な内容があるためそこは堪えてください。
データ形式
SetDataの引数として、見慣れないデータ形式を使っています。
データ形式 | 解説 |
---|---|
FileGroupDescriptorW |
ファイルのプロパティ情報を格納する |
FileContents |
ファイルの内容を格納する |
これらはWindowsエクスプローラーがDataObject
に要求するデータ形式1の一部です。この形式に対して適切なデータを用意してやることで、Windowsエクスプローラー側が勝手に読み込んでいいように処理してくれます。
FileGroupDescriptorW
シェル クリップボードの形式/CFSTR_FILEDESCRIPTORの説明をだいぶ端折ると、グローバルメモリオブジェクト上のFILEGROUPDESCRIPTOR
構造体を表すデータ形式です。グローバルメモリ上では、最初の32ビットで配列数を表し、それ以降でFILEDESCRIPTOR
構造体の配列を表します。
コードを見返すと、(1)でファイル数(今回は1)のバイナリ表現を書き込んで、(2)以降にFILEDESCRIPTOR
構造体のバイナリ表現を1つ書き込んでいます。FILEDESCRIPTOR
構造体のバイナリ表現は、メンバーが宣言された順番で書き込みます。あとはコードとFILEDESCRIPTOR構造体を見比べてみればなにを書き込んでいるかわかると思います。注意が必要な点としては、FILETIME
構造体とファイルサイズを表す部分です。32ビット変数2つを組み合わせて使っており、若干の変換が必要となります。
ここでクイズです。MemoryStream
はグローバルメモリオブジェクトでしょうか。正解はNoです。マネージドオブジェクトですもんね。でも心配は不要。DataObject
で変換してくれます。
FileContents
ファイルのデータを表すデータ形式です。シェル クリップボードの形式/CFSTR_FILECONTENTSではいろいろ難しいことを書いていますが、要はファイルのデータを直接MemoryStream
に書き込んでください。例によって、グローバルメモリへの変換はDataObject
内で行ってくれます。
応用編
ドラッグ&ドロップ以外のデータ交換
クリップボードでもIDataObject
を使ってデータを指定できます。こうすれば「貼り付け」操作で新しいファイルを作成できます。
Clipboard.SetDataObject(data);
FILEDESCRIPTOR
構造体のバイナリ変換
一番面倒なのがFILEDESCRIPTOR
構造体をバイナリ表現に変換する部分です。上記のようにバイナリを直接扱ってもいいんですが、オブジェクト指向じゃない。
そこで登場するのがMarshal
クラスです。この先何度もお世話になるので今のうちに紹介しておきます。Marshal
クラスのStructureToPtr
メソッドを使うことで構造体をまるっとバイナリに変換することができます。詳しくは型のマーシャリングを参照してください。
下準備
FILEDESCRIPTOR
構造体は.NETには存在しないので、構造体を定義します。
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct FILEDESCRIPTOR
{
public uint dwFlags;
public Guid clsid;
public Size sizel;
public Point pointl;
public uint dwFileAttributes;
public FILETIME ftCreationTime;
public FILETIME ftLastAccessTime;
public FILETIME ftLastWriteTime;
public uint nFileSizeHigh;
public uint nFileSizeLow;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
public string cFileName;
}
StructLayoutAttribute
やMarshalAsAttribute
がバイナリシリアル化のときの動作に影響します2。メンバー変数の名前は重要ではないですが、宣言順は絶対に変えないようにしましょう。
FILEDESCRIPTOR
構造体の構築
FILEDESCRIPTOR
構造体はメンバー設定に細かいルールがあります。詳細はFILEDESCRIPTOR構造体に詳しく記述があります。なんらかのファクトリを作成するのが便利です。
バイナリ化(マーシャリング)
Marshal
クラスを使いFILEDESCRIPTOR
構造体をバイナリ表現に変換します。今回の文脈では特に「マーシャリング」と呼ばれますが、これはマネージドコード(.NETアプリ)とネイティブコード(Windowsエクスプローラー)の間でデータやり取りがある場合のデータ変換を表す用語です。
var fileGroupDescriptorBinary = new MemoryStream();
fileGroupDescriptorBinary.Write(BitConverter.GetBytes(1));
var fileDescriptor = // FILEDESCRIPTORの生成
var fdSize = Marshal.SizeOf<FILEDESCRIPTOR>();
var fdPtr = Marshal.AllocHGlobal(fdSize);
try
{
Marshal.StructureToPtr(fileDescriptor, fdPtr, false);
var fdArray = new byte[fdSize];
Marshal.Copy(fdPtr, fdArray, 0, fdSize);
fileGroupDescriptorBinary.Write(fdArray);
}
finally
{
Marshal.FreeHGlobal(fdPtr);
}
Marshal.AllocHGlobal
メソッドはグローバルメモリ空間にメモリブロックを確保し、Marshal.FreeHGlobal
はそれを解放します。グローバルメモリ空間上にいちど変換してからそれをbyte配列にコピーしさらにMemoryStream
に書き戻すのは非常に面倒ですが、これ以外にいい方法を知っている方いたら教えてください。
ソースコード全体
上記のコードそのままではなくさらにブラッシュアップしていますが、https://github.com/miswil/DropFiles/tree/master/DropSingleFile にあります。
所感
今回の内容はお決まりの解決法で目新しいものはありませんが、やはりここから始める必要があるでしょう。情報源やDataObject
の内部処理までまとめられたのはよかったと思います。
-
シェル クリップボードの形式に記載があります。なお、CFSTR_FILEDESCRIPTOR, CFSTR_FILECONTENTSといった定数からどうやって対応する文字列を手に入れるのかというと、先述のリンク先でこれらの定数はShlobj.hで定義されているとあるので、WindowsSDKのインストール先からShlobj.hを探してきてその中から定数定義を見つけます。CFSTR_FILEDESCRIPTORのほうは、CFSTR_FILEDESCRIPTOR"A", CFSTR_FILEDESCRIPTOR"W"という定数のどちらかになっていますが、これらは単に文字コードがANSIがUNICODEかの違いです。ANSIを選ぶ理由はないので3かならずCFSTR_FILEDESCRIPTORWのほうを使います。
なお、リンク先で「CFSTR_XXX 形式識別子を RegisterClipboardFormat に渡します。」といった記述がありますが、これは.NETが内部的に行ってくれるため4不要です。 ↩