LoginSignup
4
3

Windowsエクスプローラーに存在しないファイルをドロップする(Part1)

Last updated at Posted at 2024-01-20

はじめに

表題を詳しく説明すると、「ドライブ上にまだ存在しないデータ(メモリ上だったり、ネットワーク上だったり)について、デスクトップアプリケーションから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. ファイルは1つだけで、ファイルのプロパティ情報(ファイル名・サイズ等)はすでに分かっており、ファイルのデータもすでに手元にある→本記事
  2. ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。未定義動作が起きる可能性を許容する。→Part2
  3. ファイルは複数あり、ファイルのプロパティ情報はすでに分かっており、ファイルのデータもすでに手元にある。定義済み動作のみ許容する。→Part3
  4. ファイルは複数あり、ファイルのプロパティ情報は取得に時間がかかり、ファイルのデータも取得に時間がかかる。デスクトップアプリを応答不能にしたくない。→Part4
  5. エクスプローラーにドロップするなら、せっかくだしドラッグ中のイメージとかテキストとかも表示したい。↓こういうの→ドラッグドロップ時にイメージを表示する
    image.png
  6. いっそのことドロップ先のフォルダーがわかれば一番手っ取り早い。→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;
}

StructLayoutAttributeMarshalAsAttributeがバイナリシリアル化のときの動作に影響します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の内部処理までまとめられたのはよかったと思います。

  1. シェル クリップボードの形式に記載があります。なお、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不要です。

  2. 構造体のマーシャリングをカスタマイズする

  3. Win32 と C++ の概要 / 文字列の操作

  4. WPF DataFormats.cs, WinForms DataFormats.cs

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3