Java でフォルダを開いたら start の謎仕様に行き当たった話

技術研究所の(あ)です。
画像を加工したりアノテーションを付けたりするツールを Java でいろいろ作ってます。Windows 用に exe 化してパッケージ化する技も覚えたのでいろいろ便利です。

画像の加工を行ったあと、結果の画像を出力用のフォルダに書き込んだら、そのフォルダを (エクスプローラで) 開くようにするのが親切というものです。もちろん、すでに開いているときに二重に開いてしまうのはイケてないです。

Java からそういうことをやるにはいくつか方法があるのですが、テストしてみたらそれぞれ (ちょっとずつ) いまいちな挙動を示しました。最終的には「こうすればok」な解にたどりつきはしましたが、なかなかハマりどころだと思われるので以下にまとめておきます。

Java の実装は Windows10, 64bit 環境の OpenJDK-11.0.2 と Oracle JDK-14.0.1 で試しています。

フォルダを開く・基礎編

Java からエクスプローラのフォルダを開こうと思った場合、ぐぐると大別して二つの方法が出てきます。一つが Desktop を使う方法、もう一つが Runtime を使う方法です。

Desktop を使う

開きたいフォルダのパスが String path に入っているとすると、Desktop を使うと

File file = new File(path);
Desktop.getDesktop().open(file);

でフォルダが開きます。既にフォルダが開いている状態で実行すると、新たなウィンドウは開かずに既にあるウィンドウが手前に来ます。一見、これでまっっっっったく問題なさそうなのですが、意外な落とし穴 (バグ?) があります (詳しくはのちほど…)。

ちなみに

Desktop.getDesktop().browse(new URI(path));

という変化球な方法もありますが、これはパスの表記がURI的表記に限定的されてしまいます。

Runtime を使う(1)

Runtime を使うと、Windows のコマンドを実行できます。たとえばエクスプローラを直接起動してやることができます。

Runtime.getRuntime().exec("explorer.exe " + path);

これで、目的のフォルダを表示するエクスプローラが起動します。path の表記のファイル区切り文字に ‘/’ も使いたい場合などには、

File file = new File(path);
Runtime.getRuntime().exec("explorer.exe " + file.getPath());

とするとよいです。

String[] command = new String[2];
command[0] = "explorer.exe";
command[1] = file.getPath();
Runtime.getRuntime().exec(command);

とも書けます。

エクスプローラを直接起動する方法の大きな弱点は、既に開いているフォルダに対しては二重に開いてしまう、というところです。

Runtime を使う(2)

ご存知の方も多いと思いますが、Windows のコマンドプロンプト、cmd.exe には “start” という便利な内蔵コマンドがあります。引数にファイルなどを指定すると、拡張子で関連付けられたアプリケーションでそのファイルを開いてくれます。フォルダを指定すると、そのフォルダを (二重にならずに) 開いてくれます。

cmd.exe の内蔵コマンドなので、Runtime から start を呼び出すときは “cmd.exe /C start path_name”  のような感じで cmd.exe への引数として渡してやります。

String[] command = new String[4];
command[0] = "cmd.exe";
command[1] = "/C"; // 以下のコマンドを実行するオプション
command[2] = "start";
command[3] = file.getPath();
Runtime.getRuntime().exec(command);

二重に開くこともないので、これでばっちりです(……と、思うじゃん?)

謎の挙動編

Desktop の謎

さて、ついに意外な落とし穴の実態が明らかになります。まずは Desktop。

フォルダを開くときは、実行したところからの相対パスで指定したくなります。Desktop.getDesktop().open(new File(“.”)); とすると、カレントディレクトリが開きます。では、ひとつ上のところを開こうと Desktop.getDesktop().open(new File(“..”)); とすると…?

…なぜかこれもカレントディレクトリが開いてしまいます。そして ../.. を開くと、ひとつ上のフォルダが開きます。じゃあ、.. が一回分ずれるのかな、と思いきや、../other_dir を開くとちゃんと普通にひとつ上のフォルダの下の other_dir が開きます。(なん…だと?)
「特定のパターンだけずれる」なので、パス表記を予め調べて補正することも不可能ではないですが、それはあまりやりたくないですよね。

これはバグなのか何かの謎仕様なのか…。OpenJDK でも Oracle の JDK でも同じ挙動をします。

Runtime (というか start) の謎

start を使った場合は、相対パスに関して Desktop のような謎挙動はしません。
が、しかし、別のところに落とし穴があります。それは「パス名にスペースを含む場合」です。

たとえば、”C:/Program Files” を開こうとすると…

何故かコマンドプロンプトが起動します。な、何を言ってるかわからねーと思(ry

この挙動、実はコマンドプロンプトから start コマンドを使っても同じです。start . などではちゃんとフォルダが開きますが、start “C:/Program Files” (空白が入るときはダブルクォートで囲む必要がありますね) とすると、フォルダではなく、新しいコマンドプロンプトが開きます。

解決編 (Runtime)

全ては start の謎仕様のせいだった!

あれこれ悩んだのですが、結論は start の引数に関する謎仕様にありました。コマンドラインで start /? として調べてみると、

はい、あやしいオプションがありますね。

「”タイトル”」!!!

た、たしかにさっきのコマンドプロンプトも、よく見るとウィンドウのタイトルが C:\Program Files になっている!?

このコマンドラインヘルプだけでは判りませんが、start コマンドは、最初の引数がダブルクォートで囲われているとき、それをタイトルとみなす、という仕様らしいです。なんなんだその仕様……。

そしてここで判るもう一つの微妙に嫌な仕様は、Runtime.getRuntime().exec(String[] command); で引数を渡すとき、

command[3] = f.getPath();

のようになっていても、この文字列にスペースが含まれると、文字列全体がダブルクォートで囲われて start に渡される、ということです。(その変換しているのは cmd.exe かな…(未確認))

となると対策は

そういう原因であるならば、”タイトル” 引数を常につけてやれば解決するはずです。

試しにまずはコマンドラインから start “dummy” “C:\Program Files” とダミーのタイトルをつけて実行してみると、今度はちゃんと所望のフォルダが開きます。エクスプローラのウィンドウのタイトルは表示するフォルダによって自動的に変わるので、ダミーのタイトルはどこにも出てきません。

Java からも、

String[] command = new String[5];
command[0] = "cmd.exe";
command[1] = "/C";
command[2] = "start";
command[3] = "\"dummy\"";
command[4] = file.getPath();
Runtime.getRuntime().exec(command);

のようにすれば ok です。これでようやく、パス名表記を気にせず、二重にウィンドウが開くこともなく、目的のフォルダを開くことができます。(いやまだ気づいていない落とし穴があるかもしれませんが…)

さいごに

今回紹介した落とし穴は二つとも、比較的気が付きづらいところにあって、ふつうに使っている分には出くわさないことも多いでしょう (自分でもよく気づいた (テストした) な、と思うレベル ) (Desktop のほうは開発者にレポートしたほうがよいのかなー (要調査))。その分、不意に出くわしたときの混乱度合いは大きそうです。

この記事がそんな混乱を避けるお役に立てば幸いです。

 

追記: 解決編 (Desktop)

その後、Desktop のほうも解決策を見つけました。

File file = new File(path);
Desktop.getDesktop().open(file.getCanonicalFile());

です。getCanonicalFile() は new File(file.getCanonicalPath()) とするのと同等で「正規化されたパス名文字列」に基づいた File オブジェクトを返します。似たような File クラスのメソッドとして絶対パス名を返す getAbsolutePath()、そしてそれに基づくFile オブジェクトを返す getAbsoluteFile() もあるのですが、こちらでは前述の症状は変わりません。

getAbsolutePath() と getCanonicalPath()、もともとどういう使い分けの意図があるのかまでは調べていませんが、結果としては getAbsolutePath() の処理のどこかに不具合がある可能性が濃厚かと思われます。うーむ。

  • このエントリーをはてなブックマークに追加