Windows Script Host で情報が取得できないショートカットファイル
このエントリはファイル名に一部の記号や特殊文字を含んだショートカットファイルが Windows Script Host から認識されない例の紹介と、IShellLink を利用してショートカットファイルから情報を取得する方法を紹介します。
このエントリは Visual Studio 2022 Community Edition で .NET 6 以降 と C# を使用するので、C# での基本的なコーディング知識を持っている人が対象です。
目次
はじめに
このエントリは【.NET6 で Generic Host を使った常駐アプリ(以下、元記事)】で書ききれなかった事を補足するためのエントリです。
このエントリで紹介するサンプルは元記事と同じなので、できれば先に元記事を読んでいただく方が分かりやすいと思います。
元記事と同じく、このエントリのサンプルは .NET6 で書いているので、C# 10 以降の記法にはなっていますが、.NET6 固有の新機能等は使用していないため、.NET5 でも .NET Core でも .NET Framework でもほぼそのまま使えます。
Windows のショートカットファイルから情報を取得する
元記事に書いた通り、ここで紹介するサンプルアプリは『最近使ったファイル フォルダ』を監視して、追加されたショートカットファイルからリンク先ファイルのパスを取得して保存するアプリです。
fig. 1 はショートカットファイルのプロパティです。
fig. 1 の吹き出しで指した箇所は Windows Script Host から取得できるショートカットファイルのプロパティですが、このエントリではリンク先ファイルのパス(TargetPath プロパティ)を取得する方法のみ紹介します。取得する方法は大きく分けて以下の 2 つがあるようです。
- Windows Script Host を利用する
- IShellLink を COM 経由で利用する
元記事でサンプルアプリを作成していた時は『コーディング量が少なそう…』と言う程度の理由で 1 番目の【Windows Script Host を使用する】を選択しました。
Windows Script Host を使用してショートカットファイルの情報を取得する
src. 1 は Windows Script Host を利用した場合のコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | using System.Text; namespace elf.Windows.Libraries; /// <summary>WindowsScriptingHostライブラリを表します。</summary> public static class WindowsScriptingHosts { /// <summary>ショートカットファイルから元ファイルのフルパスを取得します。</summary> /// <param name="shortcutFilePath"> /// <para>ショートカットファイル(*.lnk)のフルパスを表す文字列。</para> /// <para>実際のショートカットファイルが存在しなくても構いません。</para> /// </param> /// <returns> /// <para>ショートカットファイルから取得した元ファイルのパスを表す文字列。</para> /// <para>ショートカットファイルが存在しない場合は空文字が返ります。</para> /// <para>取得した元ファイルが存在するかはチェックしません。</para> /// </returns> public static string GetSourcePathFromShortcutFile(string shortcutFilePath) { #pragma warning disable CA1416 // プラットフォームの互換性を検証 var wshType = Type.GetTypeFromProgID("WScript.Shell"); #pragma warning restore CA1416 // プラットフォームの互換性を検証 if (wshType == null) return string.Empty; dynamic shell = Activator.CreateInstance(wshType)!; return shell.CreateShortcut(shortcutFilePath).TargetPath; } } |
src. 1 は【[C#] ショートカットファイル(.lnk)の内容を取得する – ざこノート】を参考にして書きましたが、.NET6 になっても .NET Framework とほとんど変わらないコードで呼び出せます。
Windows Script Host を使用する場合、プロジェクトに参照設定を追加するパターンと追加しないパターンがありますが、src. 1 では【プロジェクトに参照設定を追加しないパターン】で書いています。参照設定を追加しない場合、26 行目のように生成した WScript.Shell を dynamic な変数で受け取る必要があるため、Intellisence の恩恵を受けられないのはデメリットですが、2 箇所程度であれば大して気にならないと思います。
Intellisence の恩恵以外にも COM 参照を追加する・しないで、メリット・デメリットはあると思いますが、今回は src. 1 の部分でしか使用しない事と、Excel の VBA 等では CreateObject する事が多い…的なふんわりした理由で全く調べず『参照設定無し』で書きました。又、機会があれば調べるかもしれません。
又、元記事で必要なのは TargetPath だけだったので、shell.CreateShortcut の返り値を変数に代入せず TargetPath を取得していますが、他の情報が必要な場合は shell.CreateShortcut の返り値を dynamic な変数に代入すれば取得できます。
28 行目の CreateShortcut は読んで字のごとく新規のショートカットを作成するメソッドに思えるかもしれませんが、引数に既存のショートカットファイルのパスを渡せば、そのショートカットファイルを返すと言う動作になっているので、既存のショートカットファイルを扱う場合でも使用できます。
そして、20、22 行目の『#pragma warning』はビルド警告を抑止するために入れています。src. 1 は別プロジェクト(elf.Windows.Libraries)に分けているので、プロジェクト自体にビルド警告抑止を設定する事も出来ますが、警告は src. 1 の 21 行目だけなので、対象行のみ抑止しています。
GetSourcePathFromShortcutFile メソッドをテストする
src. 2 は src. 1 をユニットテストするコードです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | using Xunit; using System.Reflection; using System.IO; namespace elf.Windows.Libraries.Tests { public class WindowsScriptingHostsTests { [Theory(DisplayName ="ショートカットファイルあり")] [InlineData(@"D:\MyVideo\現実主義勇者の王国再建記 11話 「李代桃僵(りだいとうきょう)」.mp4", @"現実主義勇者の王国再建記 11話 「李代桃僵(りだいとうきょう)」.mp4.lnk")] [InlineData(@"D:\MyVideo\平穏世代の韋駄天達 #03 「飄」.mp4", @"平穏世代の韋駄天達 #03 「飄」.mp4.lnk")] public void GetSourcePathFromShortcutFileTest(string srcPath, string linkFileName) { var linkFilePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); linkFilePath = Path.Combine(linkFilePath!, linkFileName); var gettedSrcPath = WindowsScriptingHosts.GetSourcePathFromShortcutFile(linkFilePath); Assert.True(WindowsScriptingHosts.GetSourcePathFromShortcutFile(linkFilePath) == srcPath); } } } |
src. 2 は xUnit テストフレームワークを使用しています。
xUnit については別途、新規エントリを書こうと思っているので、ここでは詳しく紹介しませんが、src. 2 はプロジェクトに含めたショートカットファイルのリンク先ファイルパスをテストするコードです。
リポジトリ にコミットしているショートカットファイルは管理人の『最近使ったファイル フォルダ』から取り出したファイルなので、ダブルクリックしても『リンク先が見つからない』的なエラーにはなりますが、テストで読み取るだけなら他の環境でも問題なく使用できると思います。
テスト用のファイルは ASCII ファイル名だと問題なくパスしそうなので、その時見ていたアニメのタイトルとサブタイトルを Wikipedia から適当にコピーして変更していて、実際のテスト結果が fig. 2 です。
fig. 2 の通り問題なく取得できています。fig. 2 のテスト結果だけで済めばこのエントリを書く事にはならなかったと思います。
ファイル名に記号を含んだショートカットファイル
このエントリを書くことになった原因は、その時たまたま見ていた fig. 3 の『ヴァニタスの手記』と言うアニメです。
このアニメのサブタイトルは『Bal masqué―仮面が嗤う夜―』(Wikipedia より)のようなフランス語(?)等で使用されるアキュート・アクセント付き『e』が使われているようなので試しに追加してみました。
fig. 4 はテストケースに『[InlineData(@”D:\MyVideo\ヴァニタスの手記 Mémoire 04 「Bal masqué―仮面が嗤う夜―」.mp4″, @”ヴァニタスの手記 Mémoire 04 「Bal masqué―仮面が嗤う夜―」.mp4.lnk”)]』を追加した結果です。
エラーになりました… 実際試した時はエラーになるとは予想していなくて焦りました…
はっきりとした原因は調べても分かりませんでしたが、テスト用に pixabay からダウンロードしていた動画のファイル名を『Cat – 79034é.mp4』のように【é】を追加しただけでもエラーになるので【é】が原因なのは間違いなさそうです。
検索すると teratail で『一部の記号や特殊文字を含んだファイルが認識できない』と言う VBScript の質問も見つかったので、【é】以外の記号でも再現するようですし、Excel の VBA 等で Windows Script Host を利用する場合でも同じ結果になりそう(未確認)です。
src. 3 は上の teratail の質問を参考に、ファイル名を Shift JIS に変換して Windows Script Host に渡してみました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | using System.Diagnostics; using System.Text; namespace elf.Windows.Libraries; /// <summary>WindowsScriptingHostライブラリを表します。</summary> public static class WindowsScriptingHosts { /// <summary>ショートカットファイルから元ファイルのフルパスを取得します。</summary> /// <param name="shortcutFilePath"> /// <para>ショートカットファイル(*.lnk)のフルパスを表す文字列。</para> /// <para>実際のショートカットファイルが存在しなくても構いません。</para> /// </param> /// <returns> /// <para>ショートカットファイルから取得した元ファイルのパスを表す文字列。</para> /// <para>ショートカットファイルが存在しない場合は空文字が返ります。</para> /// <para>取得した元ファイルが存在するかはチェックしません。</para> /// </returns> public static string GetSourcePathFromShortcutFile(string shortcutFilePath) { #pragma warning disable CA1416 // プラットフォームの互換性を検証 var wshType = Type.GetTypeFromProgID("WScript.Shell"); #pragma warning restore CA1416 // プラットフォームの互換性を検証 if (wshType == null) return string.Empty; dynamic shell = Activator.CreateInstance(wshType)!; Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); var s_jisBytes = Encoding.GetEncoding("shift_jis").GetBytes(shortcutFilePath); var s_jisString = Encoding.GetEncoding("shift_jis").GetString(s_jisBytes); Debug.WriteLine(s_jisString); return shell.CreateShortcut(s_jisString).TargetPath; } } |
29行目の【Encoding.RegisterProvider】はプロジェクトのエンコードリストに Shift JIS を追加するためのメソッドです。.NET Core 以降のエンコードリストには Shift JIS が含まれないため、30 行目ように Encoding.GetEncoding に【shift_jis】を指定すると System.ArgumentException が以下のようなエラーメッセージと共に Throw されます。
『’shift_jis’ is not a supported encoding name. For information on defining a custom encoding, see the documentation for the Encoding.RegisterProvider method. Arg_ParamName_Name』
上の実行時エラーを回避するにはプロジェクトに Shift JIS エンコードを追加する必要があり、そのためにはソリューションの NuGet パッケージの管理ページで fig. 5 のように【codepage】を検索します。
fig. 5 の検索結果一覧に出て来る【System.Text.Encoding.CodePages】を対象プロジェクトにインストールして、src. 3 の 29 行目のように Encoding.GetEncoding の呼び出し前に【Encoding.RegisterProvider】を呼び出しておくとエンコードリストに Shift JIS が登録され、実行時エラーも Throw されなくなります。
(本来ならメソッド内でなく static コンストラクタで呼び出す方が良いと思いますが、ここでは試しにやってみる程度なのでメソッド内に記述しています)
System.Text.Encoding.CodePages を追加すると使用可能になるエンコードのリストは『.NET 5 でSJISを使う方法 | C#プログラミング再入門』等で紹介されていました。
ここまでやってみても結果は NG でした。32 行目に指定したデバッグ出力にはファイル名が『ヴァニタスの手記 Memoire 04 「Bal masque―仮面が嗤う夜―」.mp4.lnk』と出力されていて、【é】は【e】に変換されてしまうようでした。ファイル名が違う文字に置き換えられる事で、存在しないショートカットファイルのパスが渡されたと解釈していると予想しています。
Windows Script Host の内部処理は分かりませんが、CreateShortcut メソッドは存在しないファイルパスを渡した場合、新規のショートカットファイルを作成するように動作します。その場合、TargetPath は空文字を返すため、動作自体は正常に行われていると予想しています。
とりあえず Windows Script Host にこだわりがある訳でもなく、src. 3 以外の対策も思い付かないので、最初に紹介したもう 1 つの【IShellLink を利用する】方法に切り替えることにしました。
IShellLink を利用してショートカットファイルの情報を取得する
IShellLink を利用するために src. 4 のような ShellLink クラスを新規に追加しました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 | using elf.Windows.Libraries.ShellLinks; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text; namespace elf.Windows.Libraries; /// <summary>WindowsのShellLinkを表します。</summary> public class ShellLink : IDisposable { private readonly IShellLinkW? shell; private readonly IPersistFile? persist; /// <summary>ショートカットファイルからリンク先ファイルのパスを取得します。</summary> /// <param name="linkFilePath">ショートカットファイルのパスを表す文字列。</param> /// <returns>ショートカットファイルから取得したリンク先ファイルのパスを表す文字列。</returns> public string GetLinkSourceFilePath(string linkFilePath) { if (!File.Exists(linkFilePath)) return string.Empty; this.persist?.Load(linkFilePath, 0x00000000); var srcPath = new StringBuilder(IShellLinkW.MAX_PATH, IShellLinkW.MAX_PATH); var data = new WIN32_FIND_DATAW(); this.shell?.GetPath(srcPath, srcPath.Capacity, ref data, SLGP_FLAGS.UNCPRIORITY); return srcPath.ToString(); } /// <summary>デフォルトコンストラクタ。</summary> public ShellLink() { this.shell = (IShellLinkW)new ShellLinkObject(); this.persist = this.shell as IPersistFile; } private bool disposedValue; /// <summary>このクラスのインスタンスを破棄します。</summary> /// <param name="disposing">Disposeが実行されたかを表すbool。</param> protected virtual void Dispose(bool disposing) { if (!disposedValue) { if (disposing) { if (this.persist != null) Marshal.ReleaseComObject(this.persist); if (this.shell != null) Marshal.ReleaseComObject(this.shell); } // TODO: アンマネージド リソース (アンマネージド オブジェクト) を解放し、ファイナライザーをオーバーライドします // TODO: 大きなフィールドを null に設定します disposedValue = true; } } ~ 略 ~ } |
基本的には【vbAccelerator – Creating and Modifying Shortcuts】から丸パクリしただけなので詳細は元のサイト を見ていただく方が良いと思います。
実際は src. 4 で紹介している ShellLink クラス以外にもインタフェースや列挙型等が必要ですが、ここでは全ソースを紹介しないので必要であれば GitHub リポジトリ を見てください。
サンプルは C# 10 で書いているので .NET6 以外では【namespace】の部分等を修正する必要はありますが、.NET Core や .NET Framework でもほとんどそのまま使用できます。
src. 4 の ShellLink クラスは IShellLinkW と IPersistFile を private な変数に保持して Dispose 時に【Marshal.ReleaseComObject】しています。IShellLinkW と IPersistFile は COM オブジェクトなので不要になった場合に Marshal.ReleaseComObject するのは必須ですが、インスタンス生成だけでも結構重い処理だと思う(未計測)ので複数ファイルを処理する場合も考慮して src. 4 のような設計にしています。
そして、Windows Script Host の場合と同様に src. 5 の ShellLink クラス用のユニットテストコードを追加します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | using Xunit; using System.IO; using System.Reflection; namespace elf.Windows.Libraries.Tests { public class ShellLinkTests { [Theory(DisplayName = "ショートカットファイルあり")] [InlineData(@"D:\MyVideo\現実主義勇者の王国再建記 11話 「李代桃僵(りだいとうきょう)」.mp4", @"現実主義勇者の王国再建記 11話 「李代桃僵(りだいとうきょう)」.mp4.lnk")] [InlineData(@"D:\MyVideo\平穏世代の韋駄天達 #03 「飄」.mp4", @"平穏世代の韋駄天達 #03 「飄」.mp4.lnk")] [InlineData(@"D:\MyVideo\ヴァニタスの手記 Mémoire 04 「Bal masqué―仮面が嗤う夜―」.mp4", @"ヴァニタスの手記 Mémoire 04 「Bal masqué―仮面が嗤う夜―」.mp4.lnk")] public void GetLinkSourceFilePathTest(string srcPath, string linkFileName) { var linkFilePath = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); linkFilePath = Path.Combine(linkFilePath!, linkFileName); using (var shellLink = new ShellLink()) { var sourceFilePath = shellLink.GetLinkSourceFilePath(linkFilePath); Assert.True(sourceFilePath == srcPath); } } } } |
テスト用のファイルは Windows Script Host の場合と同じで、12 行目に Windows Script Host でエラーになったファイルも含めていて、fig. 6 がユニットテストの実行結果です。
ShellLink クラスは Windows Script Host と同じテストプロジェクト内に作成しているので、Windows Script Host クラスにはまだエラーが残っていますが、ShellLink クラスはすべて正常終了しています。IShellLink を利用すると、記号を含んだファイル名でも問題無く取得できるようです。
読み取り専用のショートカットファイル
上で紹介した【[C#] ショートカットファイル(.lnk)の内容を取得する – ざこノート】には『Shell32.Shell を使用しない理由』として Msdn forums の【Cannot Resolve Shortcut when RdOnly Attribute Set】が挙げられていたので、テスト用のファイルに【読み取り専用属性】を設定して確認しました。結論としては特に問題なく取得できました。
Msdn forums の質問は .NET Framework 4.6.2 で発生したようですが、おそらく .NET のバージョンではなく Shell DLL のバージョンが関係していると予想(未調査)しています。管理人の自宅環境は Windows 10 Pro Ver. 21H1 なので、少なくともそれ以降であればショートカットファイルの読み取り専用属性については考慮しなくても良さそうです。
加えて、【[C#] ショートカットファイル(.lnk)の内容を取得する – ざこノート】では『管理者権限などに影響して例外になる場合がある』と書かれていて、Stack Overflow のリンクが紹介されていますが、該当のリンク先では管理者権限についての質問が見当たらなかったため確認できませんでした。
※ このエントリはざこノートさんの批判が目的ではありません。情報を検証した結果を書いているに過ぎないことはご理解ください。
おわりに
元々書く予定がなかったエントリですが、バグ…と言うより問題がある動作が見つかったので別エントリに分けました。管理人の本業である業務系システム開発ではショートカットファイルを扱う機会は少ないでしょうし、ユーザが自由に名前を付けたファイルを扱うことも多くないので、あまり問題にはならないとは思います。
ですが、UTF-8 で書かれた Web ページが大半を占めている現状では、Wikipedia 等の Web ページから気軽にコピーした文字をファイル名に設定することもあると思うので、一般に配布するようなアプリを作成するような場合は一考が必要だと思います。
尚、このエントリで紹介しているサンプルコードは元記事と同じリポジトリにアップ済みです。