背景
PowerShellでツールを作っていると、「ユーザーにフォルダを選んでもらいたい」という場面がけっこう出てきます。ログの出力先、バックアップ先、処理対象のフォルダ……等ですね。
ところが、一般的なFolderBrowserDialogでフォルダ選択ダイアログを出そうとすると以下のようなフォルダツリー形式のダイアログが出てきます。
……これが、どうにも使いにくい。
- フォルダをツリーで一段ずつ開く必要がある
- 参照先によってはいちいち画面を下までスライドさせないといけない
- パスを直接入力したりコピペしたりできない
- クイックアクセス等が使えない
一方、ファイル選択ダイアログOpenFileDialogではモダンなエクスプローラー形式の画面が普通に出てきます。
そこで、「同じモダンな見た目でフォルダを選ばせる方法はないものか」と調べてみました。
結果、複数の方法で実現できることが分かりました。
この記事では、それらの方法を解説した上でおすすめ方法を紹介します。
先に結論
フォルダツリー形式を回避する方法は以下の選択肢があります。
- ファイル選択をフォルダ選択に偽装
- PowerShell 7 を使う
- 外部DLL(Windows API Code Pack)
- C#インライン定義でネイティブAPI直叩き
この中で自分がおすすめするのは、4つ目のC#インライン定義で、Windowsの IFileOpenDialog というモダンなAPIを直接呼び出す方法です。古いツリー形式を避けつつ、使い勝手・配布のしやすさ・セキュリティのバランスが一番取れていると感じたためです。
デメリットはコードの長さ。ただ、一度「定型文」として用意してしまえば、あとはコピペで使い回せます。かつ、.ps1 ファイル1本で完結するので、「配布先のPCで動かない」「セキュリティに引っかかる」といったトラブルを回避しやすいのもポイントです。
なぜ標準のフォルダ選択は「古い」のか
Windowsにおける 「フォルダ選択ダイアログ」には世代が2つあります。
| 呼び方 | 実体 | 見た目 | 立ち位置 |
|---|---|---|---|
| 旧世代(ツリー形式) |
System.Windows.Forms.FolderBrowserDialog(.NET Framework版) |
ツリーをポチポチ開く。パス入力もコピペも不可 | 古い |
| モダン形式(エクスプローラー形式) |
IFileOpenDialog(Windows Vista以降のOS標準) |
アドレスバー・検索・クイックアクセスが使える | 現在のWindows標準 |
では、なぜPowerShellではツリー形式が出てしまうのか。理由はエンジン(.NET)の世代にあります。
- Windowsに最初から入っている PowerShell 5.1 は、土台が古い .NET Framework。このため
FolderBrowserDialogを呼ぶと、内部的に古いツリーUIが出てきます。 - 一方、PowerShell 7(土台が新しい .NET Core 3.0以降)では、
FolderBrowserDialogの中身がモダンなIFileOpenDialogを呼ぶように改修されています。
つまり、Windows標準のフォルダ選択が古いわけではなく、PowerShellのエンジンである.NETが古いだけ。これは、呼び出し方を工夫すればモダンUIのフォルダ選択をPowerShellで使用可能であることを示唆しています。
フォルダ選択を実装する4つの方法
モダンなフォルダ選択を実現する方法は、大まかに4つに分けられます。以下はその比較です。
| 手法 | UIの綺麗さ | 外部依存 | 動作環境 | コード量 | 向いている場面 |
|---|---|---|---|---|---|
| ① ファイル選択のフリ | △(偽装) | なし | PS5.1でOK | 最小 | とりあえず動けばいい個人用 |
| ② PowerShell 7 | ◎ | なし | PS7が必須 | 最小 | 実行環境を強制できる時 |
| ③ 外部DLL | ◎ | DLLが必須 | PS5.1でOK | 少 | OSS持ち込みOKな環境 |
| ④ C#インライン定義 | ◎ | なし | PS5.1でOK | 多 | 業務ツール・配布用 |
各手法に向き不向きはあるものの、すべて動作上問題はありません。なので自分の用途(個人用か配布用か、環境を縛れるか)に照らして選ぶのがよいと思います。
以下より、それぞれの手法について解説していきます。
① ファイル選択ダイアログを「フォルダ選択のフリ」で使う
ツリー形式の回避方法として最も有名なのがこちらの手法です。
「モダンなファイル選択ダイアログを、フォルダ選択っぽく見せかける」という発想ですね。
コード
Add-Type -AssemblyName System.Windows.Forms
$dialog = [System.Windows.Forms.OpenFileDialog]::new()
$dialog.Title = "フォルダを選択してください(※何かファイルを選択するか、そのまま開くを押してください)"
$dialog.FileName = "フォルダを選択" # ダミーのファイル名
$dialog.Filter = "フォルダ|*." # 拡張子なしをフィルタ
$dialog.ValidateNames = $false # ファイル名の検証を無効化
$dialog.CheckFileExists = $false # ファイルの存在チェックを無効化
$dialog.CheckPathExists = $true # パスの存在チェックは有効化
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
# 選択されたダミーファイルパスから、親フォルダのパスを抽出する
$folderPath = [System.IO.Path]::GetDirectoryName($dialog.FileName)
Write-Host "選択されたフォルダ: $folderPath"
} else {
Write-Host "キャンセルされました。"
}
ポイント
流れは ファイル選択ダイアログOpenFileDialogにて、①ダミーのファイル名を指定 ②検証系プロパティを片っ端からゆるめて、「ファイルじゃないものでも確定できる」状態に ③指定されたダミーファイルの親フォルダを取得 です。
-
ValidateNames = $false:ファイル名として正しいかのチェックを切る -
CheckFileExists = $false:そのファイルが実在するかのチェックを切る -
CheckPathExists = $true:ただしフォルダ(パス)が実在するかは確認する
メリット・デメリット
- メリット:追加ライブラリ不要。数行で書けて、PS5.1でもそのまま動く。
- デメリット:画面の本質は「ファイル選択」のまま。目的のフォルダを「開く」だけでは選択がされず、「開く」を2回クリックしてもらってダミーのファイル名を選択してもらう、といった少し不自然な操作をユーザーに求めることに。配布する場合はその説明も必要なのが面倒。
要するに「動くけれど、操作にクセが残る」手法ですね。とりあえず自分用に手早く済ませたいとき向きです。
② PowerShell 7 を使う
次は、そもそもの土台を新しくしてしまうアプローチです。前提の章で触れたとおり、PowerShell 7なら FolderBrowserDialog がそのままモダンUIになります。
コード
# ※ PowerShell 5.1 で実行すると、古いツリー形式が表示されます。
Add-Type -AssemblyName System.Windows.Forms
$dialog = [System.Windows.Forms.FolderBrowserDialog]::new()
# 以下はPowerShell 7でのみ指定可能
# $dialog.Description = "保存先のフォルダを選択してください"
# $dialog.UseDescriptionForTitle = $true #タイトルバーにDescriptionを表示
if ($dialog.ShowDialog() -eq [System.Windows.Forms.DialogResult]::OK) {
Write-Host "選択されたフォルダ: $($dialog.SelectedPath)"
} else {
Write-Host "キャンセルされました。"
}
ポイント
コードはむしろ①よりシンプルで、ごく普通に FolderBrowserDialog を呼んでいるだけです。違いは実行する環境のみ。同じこのコードを、
- PowerShell 5.1 で実行 → 古いツリー形式
- PowerShell 7 で実行 → モダンなエクスプローラー形式
というふうに、環境によって結果が変わります。
Description および UseDescriptionForTitle = $true は .NET Core 特有のプロパティで、タイトルバーに表示させる文字列を指定する際に使用します。
メリット・デメリット
-
メリット:コードが最小。既存の
FolderBrowserDialogのコードを書き換えずにモダンUIの恩恵を受けられる。 - デメリット:PowerShell 7のインストールが必須。Windowsに標準で入っているのは今も5.1なので、誰かにツールを渡すたびに「PowerShell 7を入れてください」とお願いすることになる。
自分の環境だけで完結する作業なら問題ないですが、配布前提のツールだと少し使いにくいです。
③ 外部DLL(Windows API Code Pack)を使う
3つ目は、有志が作成したラッパーライブラリ(DLL)を利用する方法です。
コード
# ※ 事前に Microsoft.WindowsAPICodePack.dll と .Shell.dll を同一フォルダに配置しておく必要があります。
# スクリプトと同じフォルダにあるDLLのパスを取得
$dllPathCore = Join-Path $PSScriptRoot "Microsoft.WindowsAPICodePack.dll"
$dllPathShell = Join-Path $PSScriptRoot "Microsoft.WindowsAPICodePack.Shell.dll"
# DLLの読み込み
Add-Type -Path $dllPathCore
Add-Type -Path $dllPathShell
$dialog = [Microsoft.WindowsAPICodePack.Dialogs.CommonOpenFileDialog]::new()
$dialog.IsFolderPicker = $true # ここをTrueにするだけでフォルダ選択モードになる
$dialog.Title = "フォルダを選択してください"
$dialog.InitialDirectory = "C:\"
if ($dialog.ShowDialog() -eq [Microsoft.WindowsAPICodePack.Dialogs.CommonFileDialogResult]::Ok) {
Write-Host "選択されたフォルダ: $($dialog.FileName)"
} else {
Write-Host "キャンセルされました。"
}
ポイント
まず事前にDLLを入手し、PowerShellからアクセスできる場所に格納しておく必要があります。本コードの場合、スクリプト(.ps1)と同じフォルダを参照させています。
(入手方法は後述)
その上で Add-Type でパスを指定してDLLを読み込ませています。
あとは
$dialog.IsFolderPicker = $true
の一行だけでファイル選択がフォルダ選択モードに切り替わります。DLLが裏側の複雑な処理を全部隠してくれているので、PowerShell側のコードは数行で済みます。
メリット・デメリット
-
メリット:PowerShell側のコードがとにかくクリーン。
IsFolderPicker = $true一発でフォルダ選択になる手軽さです。 -
デメリット:外部DLLが必須。
.ps1単体では動かなくなり、必ずDLLとセットで配布・管理する必要が出ます。多くの職場では出所が不明なOSSやDLLの使用が禁止されていることも多く、その場合はそもそも使えません。
また、後述するMOTWによりDLLのロック解除が必要となります。スクリプト配布時にこの解除方法も案内する必要があり、一段階面倒くささが増します。
DLLのブロック解除が必要
外部DLLを使用し上記コードを動かす際、以下のようなエラーが発生しました。
Add-Type : ファイルまたはアセンブリ '...Microsoft.WindowsAPICodePack.dll'、
またはその依存関係の 1 つが読み込めませんでした。操作はサポートされません。
(HRESULT からの例外:0x80131515)
これ以下にもエラーが続きますが、根本原因はDLLの読込に失敗しているだけ(0x80131515)。その後の「型が見つからない」「プロパティがない」というエラーは、最初のエラーに起因して連鎖的に発生しているだけでした。
DLL読込失敗の原因は Mark of the Web(MOTW) というWindowsのセキュリティ機能でした。インターネット等からダウンロードしたファイルには「外部から来た危ないファイルかも」という目印が付けられます。PowerShellはその目印がついたDLLの読み込みをブロックしてしまうため、結果として読込失敗となっていました。
対処方法はシンプルにブロックを解除すればOK。
対象DLLそれぞれを右クリック → 「プロパティ」 → 下の方にあるセキュリティの項目の 「許可する」にチェックを入れOKを押す。
また、PowerShellのUnblock-Fileコマンドでブロック解除を自動化することも可能です。(詳細は割愛)
④ C#コードをインライン定義して、ネイティブAPIを直接呼ぶ(今回採用)
最後が、今回おすすめする方法です。①〜③のデメリットを、まとめて解消できます。
やることをひとことで言うと、「C#のコードをスクリプトの中に直接書き込み、その場でコンパイルして、Windows標準のモダンダイアログ IFileOpenDialog を直接呼び出す」。外部DLLは一切不要で、.ps1 1本で完結します。
イメージ:「通訳を連れて、役所の奥の窓口へ」
以下、たとえ話。
PowerShellは「日本語しか話せない自分」です。やりたい手続き(モダンなフォルダ選択)は、海外の役所の奥にある窓口にあります。ところがこの窓口、現地の言葉(=Windowsの低レベルな言語)しか通じません。普通にPowerShellがお願いしても、言葉が通じなくて門前払いされてしまいます。
そこで登場するのが C#という「通訳」。C#は日本語(PowerShellの世界)も現地語(Windowsの奥の世界)も両方しゃべれます。この通訳を連れて行けば、奥の窓口とちゃんと話ができる、という構図ですね。
| イメージ | 技術用語 | 役割 |
|---|---|---|
| 日本語しか話せない自分 | PowerShell | こちら側(マネージド)の住人 |
| 現地語しか通じない、役所の奥の窓口 | ネイティブAPI / COM | 向こう側(アンマネージド)の世界 |
| 連れて行く通訳 | C# | 両方の言葉が分かる仲介役 |
| その場で通訳を雇う | Add-Type |
C#をその場でコンパイルして呼び出す |
| 申請したい部署の正式コード | GUID / CLSID | どの窓口かを一意に指定する |
| 記入順を遵守しないと受理されない申請書 | vtable(メソッドの順番) | 順番を守らないと別の手続きが暴発する |
| 受付で借りた入館証(キーカード)を退館時にすべて返す |
Marshal.Release* 系 |
返し忘れたカードが溜まる=メモリリーク |
特に最後の2つが、注意する点です。
- 申請書の記入順(vtable):奥の窓口は、申請書を「項目名」ではなく「上から何番目の欄か」で読み取ります。だから、たとえ使わない欄でも、決められた順番どおりにすべて書いておかないと、別の手続きと取り違えられて事故(フリーズ)に繋がります。
- 入館証の返却(メモリ解放):奥の窓口とやり取りする間、いくつもの入館証(キーカード)を借ります。普段のPowerShellやC#には「自動でカードを返してくれる係(ガベージコレクター)」がいるのですが、この奥の窓口にはその係が入れません。だから、用が済んだら自分で全部のカードを受付に返す必要があります。返し忘れると、回収されないカードがどんどん溜まる=メモリリークが起きて、何度も実行するうちにPCが重くなったり、最悪クラッシュしたりします。
この「通訳を雇って、正しい順番で申請書を書き、最後にちゃんとカードを返す」という流れが、一番抑えておきたいポイントです。
全体フローのイメージ
クリックしてマーメイド図を表示
コード
少し長いですが、コピペで使い回せます。難解に見える前半(インターフェース定義)は、要するに「申請書をフォーマットに従い正しい順番で書いている」だけなので、理解できなくても問題ないです。
# C#コードを文字列として用意し、Add-Typeでコンパイルして使う。
# PowerShell単体ではCOMインターフェースを定義できないため、C#の力を借りる。
$source = @"
using System;
using System.Runtime.InteropServices; // DllImportやMarshalなど、ネイティブ連携用の機能をまとめた名前空間
public class NativeFolderBrowser
{
// COMオブジェクトを生成するWindows API(ole32.dll内の関数)を呼び出せるようにする。
// 補足: [DllImport] = DLL内のネイティブ関数をC#から呼べるようにする宣言。extern = 実体が外部(DLL)にある関数。
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(
[MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, // 生成したいCOMクラスのID(CLSID)
IntPtr pUnkOuter, // 集約用。今回は未使用(Zero)
uint dwClsContext, // 実行コンテキスト(今回はプロセス内=1)
[MarshalAs(UnmanagedType.LPStruct)] Guid riid, // 欲しいインターフェースのID(IID)
out IntPtr ppv); // 生成されたオブジェクトのポインタを受け取る
// 補足: IntPtr = ネイティブ側のアドレス(ポインタ)を表す型。out = 呼び出し元へ値を返すための引数。
// 補足: [MarshalAs(...)] = C#の型をネイティブ側の型へどう変換するかの指定。
// パス文字列からIShellItem(項目オブジェクト)を作るWindows API(shell32.dll内の関数)。初期フォルダ指定に使用。
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] // CharSet.Unicode = 文字列をUTF-16で受け渡す
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath, // 変換元のフォルダパス
IntPtr pbc, // バインドコンテキスト。未使用(Zero)
[MarshalAs(UnmanagedType.LPStruct)] Guid riid, // 欲しいインターフェースのID(IShellItem)
out IntPtr ppv); // 生成されたIShellItemのポインタ
// Windows Vista以降の「モダンなファイル/フォルダ選択ダイアログ」のCOMインターフェース。
// 補足: interface = メソッドの一覧(約束事)だけの定義。実体はWindows側が持つ。[ComImport]=COM部品の目印/[Guid]=一意なID。
// 重要: COMはメソッドを「名前」ではなく「並び順(vtable)」で呼ぶ。使わないメソッドも含め正しい順番で全部並べる必要がある。
// 補足: [PreserveSig] = 戻り値(HRESULT)を例外化せず、intのまま受け取る指定。実際に使うものに「← 使用」を付す。
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog
{
// --- IModalWindow から ---
[PreserveSig] int Show([In] IntPtr parent); // ← 使用: ダイアログを表示する
// --- IFileDialog から ---
[PreserveSig] int SetFileTypes([In] uint cFileTypes, [In] IntPtr rgFilterSpec);
[PreserveSig] int SetFileTypeIndex([In] uint iFileType);
[PreserveSig] int GetFileTypeIndex(out uint piFileType);
[PreserveSig] int Advise([In] IntPtr pfde, out uint pdwCookie);
[PreserveSig] int Unadvise([In] uint dwCookie);
[PreserveSig] int SetOptions([In] uint fos); // ← 使用: 動作オプションを設定
[PreserveSig] int GetOptions(out uint fos); // ← 使用: 現在のオプションを取得
[PreserveSig] int SetDefaultFolder([In] IShellItem psi);
[PreserveSig] int SetFolder([In] IShellItem psi); // ← 使用: 初期表示フォルダを設定
[PreserveSig] int GetFolder(out IShellItem ppsi);
[PreserveSig] int GetCurrentSelection(out IShellItem ppsi);
[PreserveSig] int SetFileName([In, MarshalAs(UnmanagedType.LPWStr)] string pszName);
[PreserveSig] int GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
[PreserveSig] int SetTitle([In, MarshalAs(UnmanagedType.LPWStr)] string pszTitle); // ← 使用: タイトルを設定
[PreserveSig] int SetOkButtonLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszText);
[PreserveSig] int SetFileNameLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
[PreserveSig] int GetResult(out IShellItem ppsi); // ← 使用: 単一選択の結果を取得
[PreserveSig] int AddPlace([In] IShellItem psi, uint fdap);
[PreserveSig] int SetDefaultExtension([In, MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
[PreserveSig] int Close([MarshalAs(UnmanagedType.Error)] int hr);
[PreserveSig] int SetClientGuid([In] ref Guid guid);
[PreserveSig] int ClearClientData();
[PreserveSig] int SetFilter([MarshalAs(UnmanagedType.Interface)] IntPtr pFilter);
// --- IFileOpenDialog 固有 ---
[PreserveSig] int GetResults(out IntPtr ppenum);
[PreserveSig] int GetSelectedItems(out IntPtr ppsai); // ← 使用: 複数選択の結果を取得
}
// 選択された項目(フォルダ)1件を表すインターフェース。ここからパス文字列を取り出す。ここもvtable順で全定義する。
[ComImport]
[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
[PreserveSig] int BindToHandler([In] IntPtr pbc, [In] ref Guid rbhid, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetParent(out IShellItem ppsi);
[PreserveSig] int GetDisplayName([In] uint sigdnName, out IntPtr ppszName); // ← 使用: 項目のパス文字列を取得
[PreserveSig] int GetAttributes([In] uint sfgaoMask, out uint psfgaoAttribs);
[PreserveSig] int Compare([In] IShellItem psi, [In] uint hint, out int piOrder);
}
// 複数選択したときの「項目の集まり」を表すインターフェース。
[ComImport]
[Guid("b63ea76d-1f85-456f-a19c-48159efa858b")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItemArray
{
[PreserveSig] int BindToHandler([In] IntPtr pbc, [In] ref Guid rbhid, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetPropertyStore([In] int flags, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetPropertyDescriptionList([In] ref Guid keyType, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetAttributes([In] int AttribFlags, [In] uint sfgaoMask, out uint psfgaoAttribs);
[PreserveSig] int GetCount(out uint pdwNumItems); // ← 使用: 選択件数を取得
[PreserveSig] int GetItemAt([In] uint dwIndex, out IShellItem ppsi); // ← 使用: i番目の項目を取得
[PreserveSig] int EnumItems(out IntPtr ppenumShellItems);
}
// --- 呼び出し口(オーバーロード)。使う側が引数を少なく書けるよう段階的に用意 ---
// 補足: static = クラスをnewせず [NativeFolderBrowser]::ShowDialog(...) で直接呼べる指定。
// 補足: オーバーロード = 同じ名前で引数違いのメソッドを複数用意する仕組み。戻り値は常にstring[](配列)。
public static string[] ShowDialog(string title)
{
return ShowDialog(title, null, false); // 足りない引数を既定値で補って本体へ渡す
}
public static string[] ShowDialog(string title, string initialFolder)
{
return ShowDialog(title, initialFolder, false); // 複数選択=falseで本体へ
}
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect)
{
return ShowDialog(title, initialFolder, allowMultiSelect, 0); // 追加オプション=0(なし)で本体へ
}
// --- 本体。実際の処理はすべてここで行う ---
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect, uint additionalOptions)
{
// 入れ物を先に宣言。finallyで確実に解放するため、ここでまとめて用意する。
IFileOpenDialog dialog = null;
IShellItem item = null;
IShellItemArray itemsArray = null;
IntPtr ppszName = IntPtr.Zero; // 取得したパス文字列のポインタ
IntPtr ppv = IntPtr.Zero; // ダイアログ本体のポインタ
IntPtr ppvArray = IntPtr.Zero; // 複数選択結果のポインタ
try
{
// ▼ 正式ルート(CoCreateInstance)でダイアログのCOMオブジェクトを生成する。
Guid clsid = new Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7"); // CLSID: 作るのは「FileOpenDialog」
Guid iid = new Guid("d57c7288-d4ad-4768-be02-9d969532d960"); // IID: 欲しいのは「IFileOpenDialog」
int hr = CoCreateInstance(clsid, IntPtr.Zero, 1, iid, out ppv); // 第3引数1 = プロセス内で実行(CLSCTX_INPROC_SERVER)
// 補足: hr(HRESULT) = Windows APIの結果コード。0(S_OK)が成功、それ以外は失敗。
if (hr != 0 || ppv == IntPtr.Zero) { return null; } // 生成失敗
// ネイティブのポインタを、C#で扱えるインターフェース型に変換する。
dialog = (IFileOpenDialog)Marshal.GetObjectForIUnknown(ppv);
// ▼ ダイアログの動作オプションを設定する。オプションはビットフラグ(1bitごとに意味を持つ数値)。
uint options;
dialog.GetOptions(out options); // 現在のオプションを取得(既存設定を消さないため)
uint FOS_PICKFOLDERS = 0x00000020; // ファイルではなくフォルダを選ばせる ← このメソッドの肝
uint FOS_FORCEFILESYSTEM = 0x00000040; // 実在するファイルシステム上の場所だけ選ばせる
uint FOS_ALLOWMULTISELECT= 0x00000200; // 複数選択を許可
uint FOS_PATHMUSTEXIST = 0x00000800; // 存在するパスのみ許可
options = options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST; // | (ビットOR)でフラグをまとめる
if (allowMultiSelect) { options = options | FOS_ALLOWMULTISELECT; } // 複数選択指定時のみ追加
options = options | additionalOptions; // 呼び出し側からの追加指定を反映
dialog.SetOptions(options);
// ▼ 初期表示フォルダの指定があれば設定する(文字列パスをIShellItemに変換してセット)。
if (!string.IsNullOrEmpty(initialFolder))
{
Guid iidShellItem = new Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"); // IShellItemのID
IntPtr ppvShellItem = IntPtr.Zero;
hr = SHCreateItemFromParsingName(initialFolder, IntPtr.Zero, iidShellItem, out ppvShellItem); // パス→項目オブジェクト
if (hr == 0 && ppvShellItem != IntPtr.Zero)
{
IShellItem initialFolderItem = (IShellItem)Marshal.GetObjectForIUnknown(ppvShellItem);
dialog.SetFolder(initialFolderItem); // 初期フォルダとして設定
Marshal.Release(ppvShellItem); // 借りたポインタを解放(下記メモリ管理を参照)
}
}
if (!string.IsNullOrEmpty(title)) { dialog.SetTitle(title); } // タイトルバーの文言
// ▼ ダイアログを表示する。ユーザーが操作を終えるまでここで待つ。OK=0(S_OK)、キャンセル=0以外。
hr = dialog.Show(IntPtr.Zero); // 引数は親ウィンドウ。なければZero
if (hr == 0)
{
uint SIGDN_FILESYSPATH = 0x80058000; // 「フルパス形式で名前が欲しい」の指定
if (allowMultiSelect)
{
// ▼ 複数選択:リストを取得してループで全件取り出す。
hr = dialog.GetSelectedItems(out ppvArray);
if (hr == 0 && ppvArray != IntPtr.Zero)
{
itemsArray = (IShellItemArray)Marshal.GetObjectForIUnknown(ppvArray);
uint count = 0;
hr = itemsArray.GetCount(out count); // 選択件数
if (hr == 0 && count > 0)
{
string[] results = new string[count];
for (uint i = 0; i < count; i++)
{
IShellItem selectedItem = null;
hr = itemsArray.GetItemAt(i, out selectedItem); // i番目の項目
if (hr == 0 && selectedItem != null)
{
IntPtr ppszItemName = IntPtr.Zero;
hr = selectedItem.GetDisplayName(SIGDN_FILESYSPATH, out ppszItemName); // パス文字列(ポインタ)を取得
if (hr == 0 && ppszItemName != IntPtr.Zero)
{
results[i] = Marshal.PtrToStringAuto(ppszItemName); // ポインタ→C#文字列
Marshal.FreeCoTaskMem(ppszItemName); // 文字列用に確保されたメモリを解放
}
Marshal.ReleaseComObject(selectedItem); // 項目オブジェクトを解放
}
}
return results;
}
}
}
else
{
// ▼ 単一選択:結果を1件取り出す。
hr = dialog.GetResult(out item);
if (hr == 0 && item != null)
{
hr = item.GetDisplayName(SIGDN_FILESYSPATH, out ppszName); // パス文字列(ポインタ)を取得
if (hr == 0 && ppszName != IntPtr.Zero)
{
string result = Marshal.PtrToStringAuto(ppszName); // ポインタ→C#文字列
Marshal.FreeCoTaskMem(ppszName); // 文字列用メモリを解放
ppszName = IntPtr.Zero; // 二重解放を防ぐためクリア
return new string[] { result }; // 1件でも配列で返す
}
}
}
}
return null; // キャンセル or 取得失敗
}
finally
{
// ▼ 後片付け: 借りたカードを全部返す(アンマネージドリソースの解放)。
// 補足: ネイティブ側にはGC(自動掃除)が効かない。解放し忘れるとメモリリークになる。
// 補足: ReleaseComObject=インターフェース越しの参照 / Release=生ポインタ / FreeCoTaskMem=文字列メモリ を解放。
if (ppszName != IntPtr.Zero) Marshal.FreeCoTaskMem(ppszName);
if (itemsArray != null) Marshal.ReleaseComObject(itemsArray);
if (item != null) Marshal.ReleaseComObject(item);
if (dialog != null) Marshal.ReleaseComObject(dialog);
if (ppvArray != IntPtr.Zero) Marshal.Release(ppvArray);
if (ppv != IntPtr.Zero) Marshal.Release(ppv);
}
}
}
"@
# 型がまだ無いときだけコンパイルする(再実行時のエラー回避)
if (-not ([System.Management.Automation.PSTypeName]'NativeFolderBrowser').Type) {
Add-Type -TypeDefinition $source
}
# ============== 使用例 ==============
# 単一フォルダ選択
$selectedFolders = [NativeFolderBrowser]::ShowDialog("保存先フォルダを選択してください")
# 初期フォルダを指定したい場合
# $selectedFolders = [NativeFolderBrowser]::ShowDialog("フォルダを選択してください", "C:\Users")
# 複数フォルダ選択を許可したい場合
# $selectedFolders = [NativeFolderBrowser]::ShowDialog("フォルダを選択(複数可)", $null, $true)
# 戻り値は常に配列。キャンセル時はnull。件数1でも配列として扱う。
$foldersArray = @($selectedFolders)
if ($foldersArray -and $foldersArray.Length -gt 0) {
if ($foldersArray.Length -eq 1) {
Write-Host "選択されたフォルダ: $($foldersArray[0])"
} else {
Write-Host "選択されたフォルダ ($($foldersArray.Length) 件):"
foreach ($folder in $foldersArray) {
Write-Host " - $folder"
}
}
} else {
Write-Host "キャンセルされました。"
}
ポイント
後ろのほうに詳細解説を折りたたみでいれますが、ここでは主要なポイントを解説します。
-
C#をその場で書いて使用する
PowerShellではC#のコードを文字列として用意し、Add-Typeでその場でコンパイルして使用することができます。今回はNativeFolderBrowserという型をPowerShellに生やしていて、これが「通訳をその場で雇う」の正体になります。 -
前半(インターフェース定義)=「通訳」の準備
モダンなフォルダ選択の正体はIFileOpenDialogという ファイル選択と共通のCOMオブジェクトです。これをFOS_PICKFOLDERSオプション指定でフォルダ選択モードに切り替えます。ただし PowerShell 5.1単体ではCOMインターフェースを定義できない(=オプション指定できない)ので、C#でこれを実現しています。 -
メソッドを全部・順番どおりに書く(vtable)
前述の通り、COMはメソッドを 並び順(vtable) で管理します。そのため、自分が使わないメソッドも含めて 仕様どおりの順番で全部並べる必要があります。前半の長い羅列はこのための写経のようなものです。 -
オーバーロードでオプション指定の省略に対応
ShowDialogは引数違いで入口を4つ用意(オーバーロード)しています。そのため、初期フォルダや複数選択の可否といったオプション指定について、呼ぶ側は最小限の記述で指定できるようにしています。
オプションはダイアログタイトル、初期フォルダ、複数フォルダ指定の可否、その他オプションといった順番で、後ろの方から省略可能にしてあります。 -
finallyでリソースを必ず解放
前述の通り、COM/ネイティブ側のメモリはガベージコレクト(自動掃除)の対象外となるため、自分でリソースを解放する必要があります。途中エラーが出ても確実に開放されるよう、finallyで処理を行なうようにしています。
C#部分 詳細解説(ほぼ自分用)
クリックして展開
0. この C# コードの全体像
NativeFolderBrowser という1つのクラスの中に、次の4ブロックが入っている。
NativeFolderBrowser クラス
├─ ブロック① Windows API の宣言(DllImport) … ネイティブ関数を呼ぶための宣言
├─ ブロック② COMインターフェースの定義 … 長いが「型の設計図」(vtable順の写経)
├─ ブロック③ ShowDialog オーバーロード … 引数の少ない入口(3つ)
└─ ブロック④ ShowDialog 本体 … 実際の処理はここだけ
重要な見方:
- ブロック①②は「Windowsが要求する型の設計図を書いている」だけ。
- ブロック④が「設計図を使って実際にダイアログを動かす」部分。
- ①②は理解できなくてもコピペで動かすことは可能。ただし「なぜこう書くのか」を知ると、エラー時の対処や改造がしやすくなる。
1. C#の基本記法(このコードで出てくるもの)
このコードを読むために必要な最小限の文法。
1-1. コメント
// 一行コメント。// 以降は無視される。
C#のコメントは // から行末まで。
1-2. セミコロン ;
C#では文(命令)の末尾に ; を付けるのが基本。
int hr = CoCreateInstance(...);
return null;
1-3. 波括弧 { } とブロック
public class NativeFolderBrowser
{
// 中身
}
{ } で「この中身はひとまとまり」と囲む。クラス・メソッド・if文など、あらゆるブロックに使う。
1-4. using(名前空間の import)
using System;
using System.Runtime.InteropServices;
| 記法 | 意味 |
|---|---|
using 名前空間; |
他のライブラリの機能を使えるようにする。Pythonの import に近い。 |
System |
.NETの基本機能(文字列、配列など)。 |
System.Runtime.InteropServices |
ネイティブ(Windows API)との連携に必要(DllImport、Marshal 等)。 |
1-5. クラス class
public class NativeFolderBrowser
{
// メンバー(フィールド、メソッド、インターフェース定義など)
}
| 記法 | 意味 |
|---|---|
class |
データと処理をひとまとめにした「型」の定義。設計図。 |
public |
外から見える・使える(アクセス修飾子)。 |
NativeFolderBrowser |
クラス名。 |
1-6. アクセス修飾子
| 修飾子 | 意味 |
|---|---|
public |
どこからでもアクセス可能。 |
private |
このクラスの内部からだけ使える。 |
このコードでは、Windows API宣言やCOMインターフェースは private(内部用)、ShowDialog は public(外部から呼ぶ入口)。
1-7. static
public static string[] ShowDialog(string title)
| 記法 | 意味 |
|---|---|
static |
インスタンスを作らず、クラス名から直接呼べるメソッド。 |
通常は new クラス名() でインスタンスを作ってから呼ぶが、static メソッドは NativeFolderBrowser.ShowDialog(...) のようにクラス名から直接呼べる。
1-8. 型(データの種類)
| 型 | 意味 | 例 |
|---|---|---|
int |
整数 |
0, -1, 42
|
uint |
符号なし整数(0以上) | ビットフラグなど |
bool |
真偽値 |
true, false
|
string |
文字列 | "C:\\Users" |
string[] |
文字列の配列 | new string[] { "C:\\A" } |
Guid |
128bitの一意ID(COMで部品を識別) | new Guid("...") |
IntPtr |
メモリ上のアドレス(ポインタ) |
IntPtr.Zero = 空 |
1-9. 変数宣言
IFileOpenDialog dialog = null;
uint options;
IntPtr ppv = IntPtr.Zero;
| 記法 | 意味 |
|---|---|
型 変数名 = 初期値; |
変数を宣言し、初期値を入れる。 |
型 変数名; |
宣言のみ(後で値を入れる)。 |
null |
「何も指していない」状態。 |
IntPtr.Zero |
ポインタ型の「空」(アドレス0)。 |
1-10. メソッド(関数)
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect, uint additionalOptions)
{
return null;
}
| 部分 | 意味 |
|---|---|
public static |
修飾子 |
string[] |
戻り値の型(文字列配列) |
ShowDialog |
メソッド名 |
(string title, ...) |
引数 |
return null; |
呼び出し元に値を返して終了 |
1-11. 引数の修飾子 [In] [Out] out ref
COMインターフェース定義でよく出る。
| 修飾子 | 意味 |
|---|---|
[In] |
呼び出し側→Windows側へ値を渡す(入力) |
[Out] / out
|
Windows側→呼び出し側へ値を返す(出力) |
ref |
入出力両方(参照渡し) |
int GetOptions(out uint fos);
dialog.GetOptions(out options);
1-12. 属性(角括弧 [...])
[DllImport("ole32.dll")]
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[PreserveSig]
| 記法 | 意味 |
|---|---|
[属性名(...)] |
直後の宣言に追加情報を付ける。C#のメタデータ。 |
「この関数は ole32.dll にある」「この interface は COM 部品だ」等をコンパイラやランタイムに伝える。
1-13. interface(インターフェース)
private interface IFileOpenDialog
{
[PreserveSig] int Show([In] IntPtr parent);
}
| 記法 | 意味 |
|---|---|
interface |
メソッドの一覧(約束事)だけを定義。中身(実装)は書かない。 |
| 実体 | Windows側(COM)が持っている。C#側は「こういうメソッドがあるはず」と宣言するだけ。 |
1-14. 型キャスト (型名)
dialog = (IFileOpenDialog)Marshal.GetObjectForIUnknown(ppv);
「このオブジェクトを IFileOpenDialog 型として扱う」と明示する。
1-15. if / for / try / finally
if (hr == 0) { ... }
for (uint i = 0; i < count; i++) { ... }
try { ... } finally { ... }
| 記法 | 意味 |
|---|---|
== / !=
|
等しい / 等しくない |
|| / &&
|
論理OR / 論理AND |
| |
ビットOR(フラグを合成。論理ORとは別物) |
1-16. new
Guid clsid = new Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7");
string[] results = new string[count];
新しいオブジェクトや配列を生成する。
1-17. オーバーロード
public static string[] ShowDialog(string title) { ... }
public static string[] ShowDialog(string title, string initialFolder) { ... }
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect) { ... }
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect, uint additionalOptions) { ... }
同じメソッド名で引数の数・型が違うものを複数定義できる。引数が少ない3つは、足りない引数を補って引数4つの本体に委譲している。
2. 本コード解説
ShowDialog 本体(ブロック④)を中心に、関連する宣言・定義を順に解説する。
先に全体の処理の流れを示してから、各ブロックに入る。
2-1. 処理の流れ(C#本体のまとめ)
1. CoCreateInstance でダイアログ(COM)を生成
↓
2. GetOptions → SetOptions でフォルダ選択モードに設定
↓
3. (あれば)SHCreateItemFromParsingName → SetFolder で初期フォルダ設定
↓
4. (あれば)SetTitle でタイトル設定
↓
5. Show でダイアログ表示 → ユーザー操作を待つ
↓
6-A. OK + 単一選択 → GetResult → GetDisplayName → パス文字列を配列で返す
6-B. OK + 複数選択 → GetSelectedItems → ループで GetDisplayName → 配列で返す
6-C. キャンセル/失敗 → null を返す
↓
7. finally で借りたリソースをすべて解放
2-2. ブロック①:Windows API の宣言
Windows付属DLL内のネイティブ関数を、C#から呼べるように宣言だけ書いている。中身(実装)はWindows側にある。
📎 ブロック① 対象コード全文(クリックで展開)
// COMオブジェクトを生成するWindows API(ole32.dll内の関数)を呼び出せるようにする。
// 補足: [DllImport] = DLL内のネイティブ関数をC#から呼べるようにする宣言。extern = 実体が外部(DLL)にある関数。
[DllImport("ole32.dll")]
private static extern int CoCreateInstance(
[MarshalAs(UnmanagedType.LPStruct)] Guid rclsid, // 生成したいCOMクラスのID(CLSID)
IntPtr pUnkOuter, // 集約用。今回は未使用(Zero)
uint dwClsContext, // 実行コンテキスト(今回はプロセス内=1)
[MarshalAs(UnmanagedType.LPStruct)] Guid riid, // 欲しいインターフェースのID(IID)
out IntPtr ppv); // 生成されたオブジェクトのポインタを受け取る
// 補足: IntPtr = ネイティブ側のアドレス(ポインタ)を表す型。out = 呼び出し元へ値を返すための引数。
// 補足: [MarshalAs(...)] = C#の型をネイティブ側の型へどう変換するかの指定。
// パス文字列からIShellItem(項目オブジェクト)を作るWindows API(shell32.dll内の関数)。初期フォルダ指定に使用。
[DllImport("shell32.dll", CharSet = CharSet.Unicode)] // CharSet.Unicode = 文字列をUTF-16で受け渡す
private static extern int SHCreateItemFromParsingName(
[MarshalAs(UnmanagedType.LPWStr)] string pszPath, // 変換元のフォルダパス
IntPtr pbc, // バインドコンテキスト。未使用(Zero)
[MarshalAs(UnmanagedType.LPStruct)] Guid riid, // 欲しいインターフェースのID(IShellItem)
out IntPtr ppv); // 生成されたIShellItemのポインタ
2-2-1. CoCreateInstance(ole32.dll)
| 要素 | 説明 |
|---|---|
[DllImport("ole32.dll")] |
Windows付属の ole32.dll 内の関数を呼ぶ宣言 |
extern |
関数の実体は外部(DLL)にある。C#側には中身を書かない |
戻り値 int
|
Windows APIの結果コード(HRESULT)。0 = 成功 |
rclsid |
作りたいCOMオブジェクトの種類ID(CLSID) |
pUnkOuter |
集約用。使わないので IntPtr.Zero を渡す |
dwClsContext |
実行コンテキスト。1 = 同一プロセス内 |
riid |
欲しいインターフェースのID(IID) |
out ppv |
生成されたオブジェクトへのポインタが返る |
何のため?
Windows上のCOMオブジェクト(フォルダ選択ダイアログ)を正式な方法で生成するため。
2-2-2. SHCreateItemFromParsingName(shell32.dll)
| 要素 | 説明 |
|---|---|
CharSet = CharSet.Unicode |
文字列をUTF-16で受け渡す(Windows標準) |
pszPath |
フォルダのパス文字列(例:"C:\\Users") |
pbc |
使わないので IntPtr.Zero
|
riid |
欲しい型 = IShellItem のID |
out ppv |
生成された IShellItem へのポインタ |
何のため?
「C:\Users という文字列」を、Windowsが理解する項目オブジェクト(IShellItem) に変換するため。初期フォルダ指定で使う。
2-2-3. [MarshalAs(...)] とは
C#の型とWindows(ネイティブ)の型はそのままでは一致しない。
[MarshalAs(UnmanagedType.LPWStr)] 等は「この引数をネイティブ側ではこういう形式で渡す」と指定する橋渡しの設定。
2-3. ブロック②:COMインターフェース定義
コードの大部分を占める。実装は書かず、Windows側のCOM部品が持つメソッド一覧を、正しい順番で宣言している。
📎 ブロック② 対象コード全文(クリックで展開)
// Windows Vista以降の「モダンなファイル/フォルダ選択ダイアログ」のCOMインターフェース。
// 補足: interface = メソッドの一覧(約束事)だけの定義。実体はWindows側が持つ。[ComImport]=COM部品の目印/[Guid]=一意なID。
// 重要: COMはメソッドを「名前」ではなく「並び順(vtable)」で呼ぶ。使わないメソッドも含め正しい順番で全部並べる必要がある。
// 補足: [PreserveSig] = 戻り値(HRESULT)を例外化せず、intのまま受け取る指定。実際に使うものに「← 使用」を付す。
[ComImport]
[Guid("d57c7288-d4ad-4768-be02-9d969532d960")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IFileOpenDialog
{
// --- IModalWindow から ---
[PreserveSig] int Show([In] IntPtr parent); // ← 使用: ダイアログを表示する
// --- IFileDialog から ---
[PreserveSig] int SetFileTypes([In] uint cFileTypes, [In] IntPtr rgFilterSpec);
[PreserveSig] int SetFileTypeIndex([In] uint iFileType);
[PreserveSig] int GetFileTypeIndex(out uint piFileType);
[PreserveSig] int Advise([In] IntPtr pfde, out uint pdwCookie);
[PreserveSig] int Unadvise([In] uint dwCookie);
[PreserveSig] int SetOptions([In] uint fos); // ← 使用: 動作オプションを設定
[PreserveSig] int GetOptions(out uint fos); // ← 使用: 現在のオプションを取得
[PreserveSig] int SetDefaultFolder([In] IShellItem psi);
[PreserveSig] int SetFolder([In] IShellItem psi); // ← 使用: 初期表示フォルダを設定
[PreserveSig] int GetFolder(out IShellItem ppsi);
[PreserveSig] int GetCurrentSelection(out IShellItem ppsi);
[PreserveSig] int SetFileName([In, MarshalAs(UnmanagedType.LPWStr)] string pszName);
[PreserveSig] int GetFileName([MarshalAs(UnmanagedType.LPWStr)] out string pszName);
[PreserveSig] int SetTitle([In, MarshalAs(UnmanagedType.LPWStr)] string pszTitle); // ← 使用: タイトルを設定
[PreserveSig] int SetOkButtonLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszText);
[PreserveSig] int SetFileNameLabel([In, MarshalAs(UnmanagedType.LPWStr)] string pszLabel);
[PreserveSig] int GetResult(out IShellItem ppsi); // ← 使用: 単一選択の結果を取得
[PreserveSig] int AddPlace([In] IShellItem psi, uint fdap);
[PreserveSig] int SetDefaultExtension([In, MarshalAs(UnmanagedType.LPWStr)] string pszDefaultExtension);
[PreserveSig] int Close([MarshalAs(UnmanagedType.Error)] int hr);
[PreserveSig] int SetClientGuid([In] ref Guid guid);
[PreserveSig] int ClearClientData();
[PreserveSig] int SetFilter([MarshalAs(UnmanagedType.Interface)] IntPtr pFilter);
// --- IFileOpenDialog 固有 ---
[PreserveSig] int GetResults(out IntPtr ppenum);
[PreserveSig] int GetSelectedItems(out IntPtr ppsai); // ← 使用: 複数選択の結果を取得
}
// 選択された項目(フォルダ)1件を表すインターフェース。ここからパス文字列を取り出す。ここもvtable順で全定義する。
[ComImport]
[Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItem
{
[PreserveSig] int BindToHandler([In] IntPtr pbc, [In] ref Guid rbhid, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetParent(out IShellItem ppsi);
[PreserveSig] int GetDisplayName([In] uint sigdnName, out IntPtr ppszName); // ← 使用: 項目のパス文字列を取得
[PreserveSig] int GetAttributes([In] uint sfgaoMask, out uint psfgaoAttribs);
[PreserveSig] int Compare([In] IShellItem psi, [In] uint hint, out int piOrder);
}
// 複数選択したときの「項目の集まり」を表すインターフェース。
[ComImport]
[Guid("b63ea76d-1f85-456f-a19c-48159efa858b")]
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
private interface IShellItemArray
{
[PreserveSig] int BindToHandler([In] IntPtr pbc, [In] ref Guid rbhid, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetPropertyStore([In] int flags, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetPropertyDescriptionList([In] ref Guid keyType, [In] ref Guid riid, out IntPtr ppv);
[PreserveSig] int GetAttributes([In] int AttribFlags, [In] uint sfgaoMask, out uint psfgaoAttribs);
[PreserveSig] int GetCount(out uint pdwNumItems); // ← 使用: 選択件数を取得
[PreserveSig] int GetItemAt([In] uint dwIndex, out IShellItem ppsi); // ← 使用: i番目の項目を取得
[PreserveSig] int EnumItems(out IntPtr ppenumShellItems);
}
2-3-1. なぜこんなに長いのか — vtable(仮想関数テーブル)
COMは、メソッドを名前ではなく並び順(何番目か) で呼び出す。
C#側の定義順:
1番目 → Show
2番目 → SetFileTypes
3番目 → SetFileTypeIndex
...
「使わないから」3番目を飛ばして4番目を書くと、Windows側は「3番目を呼んだ」と解釈し、別のメソッドが実行されてフリーズする。
そのため、Microsoftの公式仕様書に載っている全メソッドを、正しい順番で、1行も飛ばさず書く必要がある。
実際に使うのは一部だけ(コメントの ← 使用 が付いたもの)。残りは「順番合わせのための埋め草」。
2-3-2. 共通の属性
| 属性 | 説明 |
|---|---|
[ComImport] |
「実装はWindows側にある外部COM部品」という印 |
[Guid("...")] |
128bitの一意識別子。COM部品・インターフェースを特定する |
[InterfaceType(...)] |
COMインターフェースの種類 |
2-3-3. [PreserveSig]
通常、COMのエラー(HRESULT)が返るとC#は例外を投げるが、[PreserveSig] があると int の戻り値としてそのまま受け取る。
hr = dialog.Show(IntPtr.Zero);
if (hr == 0) { ... } // 0 = 成功(S_OK)、0以外 = キャンセルやエラー
2-3-4. IFileOpenDialog — 使うメソッド一覧
| メソッド | 役割 |
|---|---|
Show |
ダイアログを画面に表示。ユーザーが操作するまで待つ |
GetOptions |
現在の動作オプション(ビットフラグ)を取得 |
SetOptions |
動作オプションを設定(フォルダ選択モードにするのがここ) |
SetFolder |
初期表示フォルダを設定 |
SetTitle |
タイトルバーの文字列を設定 |
GetResult |
単一選択の結果(1件)を取得 |
GetSelectedItems |
複数選択の結果(リスト)を取得 |
2-3-5. IShellItem — 1件の項目(フォルダ)
| メソッド | 役割 |
|---|---|
GetDisplayName |
項目の名前(パス文字列)を取得 |
2-3-6. IShellItemArray — 複数選択の結果リスト
| メソッド | 役割 |
|---|---|
GetCount |
選択件数を取得 |
GetItemAt |
i番目の項目(IShellItem)を取得 |
2-4. ブロック③:ShowDialog オーバーロード
引数が省略されたケースの入口を3パターン用意し、すべて引数4つの本体にバケツリレーのように処理を委譲している。
📎 ブロック③ 対象コード全文(クリックで展開)
// --- 呼び出し口(オーバーロード)。使う側が引数を少なく書けるよう段階的に用意 ---
// 補足: static = クラスをnewせず [NativeFolderBrowser]::ShowDialog(...) で直接呼べる指定。
// 補足: オーバーロード = 同じ名前で引数違いのメソッドを複数用意する仕組み。戻り値は常にstring[](配列)。
public static string[] ShowDialog(string title)
{
return ShowDialog(title, null, false); // 足りない引数を既定値で補って本体へ渡す
}
public static string[] ShowDialog(string title, string initialFolder)
{
return ShowDialog(title, initialFolder, false); // 複数選択=falseで本体へ
}
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect)
{
return ShowDialog(title, initialFolder, allowMultiSelect, 0); // 追加オプション=0(なし)で本体へ
}
入口1: ShowDialog("保存先を選んで")
→ ShowDialog("保存先を選んで", null, false)
→ ShowDialog("保存先を選んで", null, false, 0) // 本体
入口2: ShowDialog("保存先を選んで", "C:\\Users")
→ ShowDialog("保存先を選んで", "C:\\Users", false)
→ 本体
入口3: ShowDialog("保存先を選んで", "C:\\Users", true)
→ ShowDialog("保存先を選んで", "C:\\Users", true, 0)
→ 本体
省略された場合、null = 値なし、false = 複数選択しない、0 = 追加オプションなし、という既定値をセットするようにしている。
2-5. ブロック④:ShowDialog 本体
実際の処理はすべてここ。 上から順に追う。
📎 ブロック④ 対象コード全文(クリックで展開)
// --- 本体。実際の処理はすべてここで行う ---
public static string[] ShowDialog(string title, string initialFolder, bool allowMultiSelect, uint additionalOptions)
{
// 入れ物を先に宣言。finallyで確実に解放するため、ここでまとめて用意する。
IFileOpenDialog dialog = null;
IShellItem item = null;
IShellItemArray itemsArray = null;
IntPtr ppszName = IntPtr.Zero; // 取得したパス文字列のポインタ
IntPtr ppv = IntPtr.Zero; // ダイアログ本体のポインタ
IntPtr ppvArray = IntPtr.Zero; // 複数選択結果のポインタ
try
{
// ▼ 正式ルート(CoCreateInstance)でダイアログのCOMオブジェクトを生成する。
Guid clsid = new Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7"); // CLSID: 作るのは「FileOpenDialog」
Guid iid = new Guid("d57c7288-d4ad-4768-be02-9d969532d960"); // IID: 欲しいのは「IFileOpenDialog」
int hr = CoCreateInstance(clsid, IntPtr.Zero, 1, iid, out ppv); // 第3引数1 = プロセス内で実行(CLSCTX_INPROC_SERVER)
// 補足: hr(HRESULT) = Windows APIの結果コード。0(S_OK)が成功、それ以外は失敗。
if (hr != 0 || ppv == IntPtr.Zero) { return null; } // 生成失敗
// ネイティブのポインタを、C#で扱えるインターフェース型に変換する。
dialog = (IFileOpenDialog)Marshal.GetObjectForIUnknown(ppv);
// ▼ ダイアログの動作オプションを設定する。オプションはビットフラグ(1bitごとに意味を持つ数値)。
uint options;
dialog.GetOptions(out options); // 現在のオプションを取得(既存設定を消さないため)
uint FOS_PICKFOLDERS = 0x00000020; // ファイルではなくフォルダを選ばせる ← このメソッドの肝
uint FOS_FORCEFILESYSTEM = 0x00000040; // 実在するファイルシステム上の場所だけ選ばせる
uint FOS_ALLOWMULTISELECT= 0x00000200; // 複数選択を許可
uint FOS_PATHMUSTEXIST = 0x00000800; // 存在するパスのみ許可
options = options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST; // | (ビットOR)でフラグをまとめる
if (allowMultiSelect) { options = options | FOS_ALLOWMULTISELECT; } // 複数選択指定時のみ追加
options = options | additionalOptions; // 呼び出し側からの追加指定を反映
dialog.SetOptions(options);
// ▼ 初期表示フォルダの指定があれば設定する(文字列パスをIShellItemに変換してセット)。
if (!string.IsNullOrEmpty(initialFolder))
{
Guid iidShellItem = new Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe"); // IShellItemのID
IntPtr ppvShellItem = IntPtr.Zero;
hr = SHCreateItemFromParsingName(initialFolder, IntPtr.Zero, iidShellItem, out ppvShellItem); // パス→項目オブジェクト
if (hr == 0 && ppvShellItem != IntPtr.Zero)
{
IShellItem initialFolderItem = (IShellItem)Marshal.GetObjectForIUnknown(ppvShellItem);
dialog.SetFolder(initialFolderItem); // 初期フォルダとして設定
Marshal.Release(ppvShellItem); // 借りたポインタを解放(下記メモリ管理を参照)
}
}
if (!string.IsNullOrEmpty(title)) { dialog.SetTitle(title); } // タイトルバーの文言
// ▼ ダイアログを表示する。ユーザーが操作を終えるまでここで待つ。OK=0(S_OK)、キャンセル=0以外。
hr = dialog.Show(IntPtr.Zero); // 引数は親ウィンドウ。なければZero
if (hr == 0)
{
uint SIGDN_FILESYSPATH = 0x80058000; // 「フルパス形式で名前が欲しい」の指定
if (allowMultiSelect)
{
// ▼ 複数選択:リストを取得してループで全件取り出す。
hr = dialog.GetSelectedItems(out ppvArray);
if (hr == 0 && ppvArray != IntPtr.Zero)
{
itemsArray = (IShellItemArray)Marshal.GetObjectForIUnknown(ppvArray);
uint count = 0;
hr = itemsArray.GetCount(out count); // 選択件数
if (hr == 0 && count > 0)
{
string[] results = new string[count];
for (uint i = 0; i < count; i++)
{
IShellItem selectedItem = null;
hr = itemsArray.GetItemAt(i, out selectedItem); // i番目の項目
if (hr == 0 && selectedItem != null)
{
IntPtr ppszItemName = IntPtr.Zero;
hr = selectedItem.GetDisplayName(SIGDN_FILESYSPATH, out ppszItemName); // パス文字列(ポインタ)を取得
if (hr == 0 && ppszItemName != IntPtr.Zero)
{
results[i] = Marshal.PtrToStringAuto(ppszItemName); // ポインタ→C#文字列
Marshal.FreeCoTaskMem(ppszItemName); // 文字列用に確保されたメモリを解放
}
Marshal.ReleaseComObject(selectedItem); // 項目オブジェクトを解放
}
}
return results;
}
}
}
else
{
// ▼ 単一選択:結果を1件取り出す。
hr = dialog.GetResult(out item);
if (hr == 0 && item != null)
{
hr = item.GetDisplayName(SIGDN_FILESYSPATH, out ppszName); // パス文字列(ポインタ)を取得
if (hr == 0 && ppszName != IntPtr.Zero)
{
string result = Marshal.PtrToStringAuto(ppszName); // ポインタ→C#文字列
Marshal.FreeCoTaskMem(ppszName); // 文字列用メモリを解放
ppszName = IntPtr.Zero; // 二重解放を防ぐためクリア
return new string[] { result }; // 1件でも配列で返す
}
}
}
}
return null; // キャンセル or 取得失敗
}
finally
{
// ▼ 後片付け: 借りたカードを全部返す(アンマネージドリソースの解放)。
// 補足: ネイティブ側にはGC(自動掃除)が効かない。解放し忘れるとメモリリークになる。
// 補足: ReleaseComObject=インターフェース越しの参照 / Release=生ポインタ / FreeCoTaskMem=文字列メモリ を解放。
if (ppszName != IntPtr.Zero) Marshal.FreeCoTaskMem(ppszName);
if (itemsArray != null) Marshal.ReleaseComObject(itemsArray);
if (item != null) Marshal.ReleaseComObject(item);
if (dialog != null) Marshal.ReleaseComObject(dialog);
if (ppvArray != IntPtr.Zero) Marshal.Release(ppvArray);
if (ppv != IntPtr.Zero) Marshal.Release(ppv);
}
}
2-5-1. 変数の事前宣言
IFileOpenDialog dialog = null;
IShellItem item = null;
IShellItemArray itemsArray = null;
IntPtr ppszName = IntPtr.Zero;
IntPtr ppv = IntPtr.Zero;
IntPtr ppvArray = IntPtr.Zero;
| 変数 | 用途 |
|---|---|
dialog |
ダイアログ本体 |
item |
単一選択の結果 |
itemsArray |
複数選択の結果リスト |
ppszName |
取得したパス文字列のメモリアドレス |
ppv |
ダイアログ生成時の生ポインタ |
ppvArray |
複数選択結果の生ポインタ |
なぜ先に宣言?
finally ブロック(後片付け)からこれらを参照するため。try の中だけで宣言すると finally から見えない。
2-5-2. ダイアログの生成
Guid clsid = new Guid("DC1C5A9C-E88A-4dde-A5A1-60F82A20AEF7");
Guid iid = new Guid("d57c7288-d4ad-4768-be02-9d969532d960");
int hr = CoCreateInstance(clsid, IntPtr.Zero, 1, iid, out ppv);
if (hr != 0 || ppv == IntPtr.Zero) { return null; }
dialog = (IFileOpenDialog)Marshal.GetObjectForIUnknown(ppv);
| ステップ | 説明 |
|---|---|
clsid |
「FileOpenDialog という種類のCOMオブジェクトを作れ」 |
iid |
「IFileOpenDialog というインターフェースで使いたい」 |
CoCreateInstance(...) |
Windows APIでCOMオブジェクトを生成 |
hr != 0 |
失敗したら null を返して終了 |
Marshal.GetObjectForIUnknown(ppv) |
生ポインタをC#の IFileOpenDialog 型に変換 |
Marshal クラス = C#(マネージド)と Windows(アンマネージド)の間の通訳係。
2-5-3. オプション設定
uint options;
dialog.GetOptions(out options);
uint FOS_PICKFOLDERS = 0x00000020;
uint FOS_FORCEFILESYSTEM = 0x00000040;
uint FOS_ALLOWMULTISELECT= 0x00000200;
uint FOS_PATHMUSTEXIST = 0x00000800;
options = options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM | FOS_PATHMUSTEXIST;
if (allowMultiSelect) { options = options | FOS_ALLOWMULTISELECT; }
options = options | additionalOptions;
dialog.SetOptions(options);
2-5-3-1. ビットフラグとは
0x00000020 のような16進数は、2進数では特定のビットが1になっている。
FOS_PICKFOLDERS = 0x00000020 = ...0010 0000 (6bit目が1)
FOS_FORCEFILESYSTEM = 0x00000040 = ...0100 0000 (7bit目が1)
|(ビットOR)で複数フラグを1つの数値に合成する。
| フラグ | 効果 |
|---|---|
FOS_PICKFOLDERS |
ファイルではなくフォルダを選ばせる(このコードの核心) |
FOS_FORCEFILESYSTEM |
実在するファイルシステム上の場所だけ |
FOS_ALLOWMULTISELECT |
複数フォルダ選択を許可 |
FOS_PATHMUSTEXIST |
存在するパスのみ許可 |
2-5-4. 初期フォルダの設定
if (!string.IsNullOrEmpty(initialFolder))
{
Guid iidShellItem = new Guid("43826d1e-e718-42ee-bc55-a1e261c37bfe");
IntPtr ppvShellItem = IntPtr.Zero;
hr = SHCreateItemFromParsingName(initialFolder, IntPtr.Zero, iidShellItem, out ppvShellItem);
if (hr == 0 && ppvShellItem != IntPtr.Zero)
{
IShellItem initialFolderItem = (IShellItem)Marshal.GetObjectForIUnknown(ppvShellItem);
dialog.SetFolder(initialFolderItem);
Marshal.Release(ppvShellItem);
}
}
| ステップ | 説明 |
|---|---|
string.IsNullOrEmpty(...) |
文字列が null または空でないかチェック |
SHCreateItemFromParsingName |
パス文字列 → IShellItem に変換 |
dialog.SetFolder(...) |
ダイアログの初期表示位置にセット |
Marshal.Release(ppvShellItem) |
使い終わったポインタを解放 |
2-5-5. タイトル設定
if (!string.IsNullOrEmpty(title)) { dialog.SetTitle(title); }
2-5-6. ダイアログ表示
hr = dialog.Show(IntPtr.Zero);
| 要素 | 説明 |
|---|---|
Show |
ダイアログを画面に出す。ユーザーの操作が終わるまでここで待つ |
IntPtr.Zero |
親ウィンドウなし |
戻り値 hr
|
0 = OK、0x800704C7 等 = キャンセル |
2-5-7. 結果の取得 — 単一選択
hr = dialog.GetResult(out item);
if (hr == 0 && item != null)
{
hr = item.GetDisplayName(SIGDN_FILESYSPATH, out ppszName);
if (hr == 0 && ppszName != IntPtr.Zero)
{
string result = Marshal.PtrToStringAuto(ppszName);
Marshal.FreeCoTaskMem(ppszName);
ppszName = IntPtr.Zero;
return new string[] { result };
}
}
| ステップ | 説明 |
|---|---|
GetResult(out item) |
ユーザーが選んだ1件を取得 |
GetDisplayName(SIGDN_FILESYSPATH, ...) |
フルパス形式で名前を取得 |
SIGDN_FILESYSPATH = 0x80058000 |
「フルパスが欲しい」という指定定数 |
Marshal.PtrToStringAuto(ppszName) |
メモリ上の文字列 → C#の string
|
Marshal.FreeCoTaskMem(ppszName) |
Windows側が確保した文字列メモリを解放 |
return new string[] { result } |
1件でも配列で返す |
2-5-8. 結果の取得 — 複数選択
hr = dialog.GetSelectedItems(out ppvArray);
itemsArray = (IShellItemArray)Marshal.GetObjectForIUnknown(ppvArray);
hr = itemsArray.GetCount(out count);
string[] results = new string[count];
for (uint i = 0; i < count; i++)
{
hr = itemsArray.GetItemAt(i, out selectedItem);
hr = selectedItem.GetDisplayName(SIGDN_FILESYSPATH, out ppszItemName);
results[i] = Marshal.PtrToStringAuto(ppszItemName);
Marshal.FreeCoTaskMem(ppszItemName);
Marshal.ReleaseComObject(selectedItem);
}
return results;
単一選択と同じ流れを、件数分ループしている。
2-5-9. キャンセル・失敗時
return null;
OK以外、または取得失敗時は null を返す。
2-5-10. finally — リソース解放
finally
{
if (ppszName != IntPtr.Zero) Marshal.FreeCoTaskMem(ppszName);
if (itemsArray != null) Marshal.ReleaseComObject(itemsArray);
if (item != null) Marshal.ReleaseComObject(item);
if (dialog != null) Marshal.ReleaseComObject(dialog);
if (ppvArray != IntPtr.Zero) Marshal.Release(ppvArray);
if (ppv != IntPtr.Zero) Marshal.Release(ppv);
}
2-5-10-1. なぜ finally が必要か
通常のC#オブジェクトはガベージコレクター(GC) が自動でメモリを回収する。
しかしCOMオブジェクトやWindows API経由で借りたメモリはGCの管轄外(アンマネージド)。
解放し忘れるとメモリリークになる。
finally は 成功しても失敗しても必ず実行されるので、ここで確実に返却する。
2-5-10-2. 3種類の解放メソッド
| メソッド | 対象 |
|---|---|
Marshal.ReleaseComObject(obj) |
COMインターフェース越しに借りたオブジェクト |
Marshal.Release(ptr) |
生のポインタ(IUnknown) |
Marshal.FreeCoTaskMem(ptr) |
Windows側が確保した文字列メモリ |
3. 用語集
| 用語 | 説明 |
|---|---|
| マネージド | .NET(C#)が管理する世界。GCがメモリを自動回収 |
| アンマネージド | Windows API / COM の世界。自分で解放が必要 |
| COM | Component Object Model。Windows部品を共通規格で使う仕組み |
| P/Invoke | Platform Invoke。C#からネイティブDLLの関数を呼ぶ仕組み |
| HRESULT | Windows APIの結果コード。0 = 成功 |
| CLSID | COMクラスの一意ID。「何を作るか」 |
| IID | COMインターフェースの一意ID。「どう使うか」 |
| GUID | 128bitの一意識別子。CLSID/IIDの実体 |
| vtable | COMがメソッドを順番で呼ぶ仕組み |
| Marshal | マネージド↔アンマネージド間のデータ変換・メモリ管理クラス |
| IntPtr | メモリアドレス(ポインタ)を表す型 |
| ビットフラグ | 1つの数値の各ビットがON/OFFで機能を表す設計 |
4. 改造するときの入口
| やりたいこと | 触る場所 |
|---|---|
| タイトル・初期フォルダ・複数選択 |
ShowDialog(...) の引数(本体の改造不要) |
| 追加の動作オプション |
FOS_* 定数を増やす / additionalOptions 引数 |
| エラー時の挙動変更 |
if (hr != 0) ブロック |
| 別のダイアログ(保存ダイアログ等) | CLSID/IID と interface 定義の差し替え |
5. PowerShell側のラッパー(呼び出し方)
C#コードはPowerShellスクリプト内に文字列として埋め込み、Add-Type でコンパイルしてから呼び出す。
📎 PowerShell側 対象コード全文(クリックで展開)
# C#コードを文字列として用意し、Add-Typeでコンパイルして使う。
# PowerShell単体ではCOMインターフェースを定義できないため、C#の力を借りる。
$source = @"
(ここに上記 C# コード全文が入る)
"@
# 型がまだ無いときだけコンパイルする(再実行時のエラー回避)
if (-not ([System.Management.Automation.PSTypeName]'NativeFolderBrowser').Type) {
Add-Type -TypeDefinition $source
}
# ============== 使用例 ==============
# 単一フォルダ選択
$selectedFolders = [NativeFolderBrowser]::ShowDialog("保存先フォルダを選択してください")
# 初期フォルダを指定したい場合
# $selectedFolders = [NativeFolderBrowser]::ShowDialog("フォルダを選択してください", "C:\Users")
# 複数フォルダ選択を許可したい場合
# $selectedFolders = [NativeFolderBrowser]::ShowDialog("フォルダを選択(複数可)", $null, $true)
# 戻り値は常に配列。キャンセル時はnull。件数1でも配列として扱う。
$foldersArray = @($selectedFolders)
if ($foldersArray -and $foldersArray.Length -gt 0) {
if ($foldersArray.Length -eq 1) {
Write-Host "選択されたフォルダ: $($foldersArray[0])"
} else {
Write-Host "選択されたフォルダ ($($foldersArray.Length) 件):"
foreach ($folder in $foldersArray) {
Write-Host " - $folder"
}
}
} else {
Write-Host "キャンセルされました。"
}
5-1. ヒアストリング $source = @"..."@"
| 記法 | 意味 |
|---|---|
$source |
PowerShellの変数。文字列を入れる箱。 |
@" … "@
|
ヒアストリング。改行を含む複数行の文字列をそのまま書ける。 |
終端の "@
|
行頭から始まる必要がある(前に空白を入れない)。 |
C#コードを .cs ファイルにせず、PowerShellスクリプト内に直接書くための仕組み。
5-2. Add-Type -TypeDefinition $source
| 記法 | 意味 |
|---|---|
Add-Type |
文字列として渡したC#コードをその場でコンパイルし、PowerShellから使える型にする。 |
'NativeFolderBrowser' |
コンパイル後に生えるC#クラス名。 |
if (-not (...).Type) |
同じスクリプトを2回実行したとき、型が既に存在すれば再コンパイルしない。 |
コンパイルが成功すると、[NativeFolderBrowser]::ShowDialog(...) のように呼べる。
5-3. 戻り値の扱い
| C#側 | PowerShell側 |
|---|---|
return null; |
$null になる → 「キャンセル」と判断 |
return new string[] { result }; |
文字列配列として受け取る |
| — |
@($selectedFolders) で常に配列化(件数1でも配列として扱う) |
6. 気になったこと
6-1. インターフェース定義を理解しなくても使える?
使える。 Windows公式仕様に沿った「型の設計図の写経」であり、一度正しく書いてしまえば改造不要。
実際に触るのは ShowDialog の呼び方と、必要なら FOS_* フラグの追加程度。
6-2. なぜ C# が必要なのか?
COMインターフェース(メソッドの順番付き設計図)を定義するには、C#の [ComImport] + interface + [DllImport] が必要。
PowerShell単体ではこれができない。
6-3. 1件でも string[] 配列で返すのはなぜ?
呼び出し側で「単一も複数も同じ配列として扱える」ようにするため。
6-4. エラー時に例外は投げないのか?
[PreserveSig] + if (hr == 0) パターンで、例外ではなく戻り値で成否を判断している。
6-5. return null と PowerShell の $null の関係
C#の null は PowerShell に返ると $null になる。
@($selectedFolders) と配列化し、空/null チェックで「キャンセル」を判定している。
メリット・デメリット
-
メリット
- ネイティブと同じ操作感:OS標準のエクスプローラーと同じ感覚で操作できます。パス入力もクイックアクセスも使えます。
-
依存ゼロ・単一ファイル:外部DLL不要で
.ps11本に収まります。PowerShell 5.1でそのまま動くので配布がラクです。 - 取り回しやすい:DLLを持ち込まないので、③で出たような「ブロック解除」も不要です。
- 自由度が高い:タイトル変更・初期フォルダ・複数選択まで対応できます。
-
デメリット
- コードが長く、一見難解:Win32 API/COMの知識がある程度要ります。ただ、一度用意すればコピペで使い回せるので、実運用での負担はそれほどでもないと思います。
まとめ
- ① ファイル選択のフリ:手軽だが偽装感が残り、ユーザーが確定操作で迷いやすい。配布の場合は1つ余計な説明が増える。自分用に手早く済ませたいとき向き。
- ② PowerShell 7:コードは綺麗だが、相手にPS7を要求するので実行環境を縛れるとき向き。
- ③ 外部DLL:コードは綺麗だが、DLLの持ち回りと「ブロック解除」がやや面倒。OSS持ち込みOKな環境向き。
- ④ C#インライン定義:コードは長いが、ネイティブと同じUI・依存ゼロ・単一ファイルで、配布ツールに向いている。
というわけで、今回のように「配布前提のツールで、使い勝手を妥協したくない」というケースでは、④がおすすめです。コピペで使い回せばコードの長さも気にならなくなります。
所感
フォルダ選択はユーザーにとってツールの入口となる部分。ここが快適だとツール全体の使用感が変わってきます。たかがフォルダ選択、されどフォルダ選択。用途と状況に合わせた最適な手段を選べればと思います。
それにしても、ちょっとした変更なのにここまで深堀りすることになるとは思わなかった。。。
早く PowerShell 7 が標準になってくれないかなぁ。



