[Chrome拡張機能]ローカルのフォルダ／ファイルを開く機能を作ってみた

## はじめに
自分が勤めている会社に、社内Wikiみたいなものがあるのですが、各ページに書かれている参考資料へのパスが、社内のファイルサーバやローカルのパスになっていることが、多々あります。
パスを毎回コピーしてエクスプローラに貼り付けるのが、だんだん面倒になってきました。
そこで、Chrome拡張機能で自動化することにしました。

- 文字列を選択し、右クリックした時のコンテキストメニューから実行する
- 選択文字列がフォルダ／ファイルのパスなら開く
- 選択文字列の中に`file:`とか`<`とか`>`とかあったら、事前に取り除く

先に言っておきますが、苦労の割にあまり自動化されません。。。

しかも、Chromeウェブストアにないため、Chromeを起動する度に毎回「無効化する」かどうか聞かれます。
作った機能からして、Chromeウェブストアに置かせてもらえる気がしません。

そのため、毎回聞かれても無効化せずに利用いただくか、投稿が部分的にでも何かの参考になれば幸いです。

## やったこと

以下を~~丸パク~~、いや、参考にさせていただき、やったことを挙げていきます。
- [Native Message1(外部ソフト登録）](http://hdszk2015.hatenablog.com/entry/2015/11/29/110426)
- [Native Message2(拡張機能）](http://hdszk2015.hatenablog.com/entry/2015/11/29/112518)
- [Native Message3(通信設定　拡張側)](http://hdszk2015.hatenablog.com/entry/2015/11/30/193710)
- [Native Message5(2byte文字等の対応)](http://hdszk2015.hatenablog.com/entry/2015/11/30/193852)

### (1) 拡張機能のマニフェストファイル作成
`name`、`description`、`version`はお好きな値で。
あと、以下の例なら、48x48の好きなアイコン画像も必要です。

```json:manifest.json
{
	"manifest_version": 2,
	"name": "OpenSelectedText",
	"description": "Open Selected Text",
	"version": "1.0",
	"background": {
		"scripts": ["background.js"],
		"persistent": false
	},
	"permissions": [
		"contextMenus",
		"nativeMessaging"
	],
	"icons": {
		"48": "icon48.png"
	}
}
```

### (2) 拡張機能の本体となるスクリプト作成
今回は、パスとなる文字列を選択後の、コンテキストメニューから呼ぶことにしました。
manifest.jsonで`"persistent": false`にしているため、`chrome.contextMenus.create()`の中で`onclick`は指定しないで、メニューのIDから判断することにします。

スクリプトの終わり際にある、以下2点が重要です。
- `chrome.runtime.sendNativeMessage()`を呼んでいること
- `chrome.runtime.sendNativeMessage()`の第1引数を、後述するレジストリキーと合わせること
特に1点目は、参考サイトの方法
（`chrome.runtime.connectNative()`で取得した`port`に対して`port.postMessage()`を呼ぶ）
と異なります。
その理由は、ホスト側のプロセスが勝手に終わっても、Chrome側にエラー`Native host has exited.`が発生しないようにするためです。

```js:background.js
//コンテキストメニューのクリック時イベントハンドラ
function onClickHandler(info, tab)
{
	if (info.menuItemId == "OpenSelectedText")
	{
		sendText(info, tab);
	}
};
chrome.contextMenus.onClicked.addListener(onClickHandler);

//拡張機能インストール時のみ、自メニュー追加
chrome.runtime.onInstalled.addListener(function()
{
	chrome.contextMenus.create(
	{
		id			: "OpenSelectedText",
		title		: "選択文字列をパスとして開く",
		type		: "normal",
		contexts	: ["selection"]
	});
});

//選択文字列を送信
function sendText(info, tab)
{
	var SelectedText = encodeURIComponent(info.selectionText.replace(/\\/g, '/'));
	
	chrome.runtime.sendNativeMessage(
		"host1",
		{ SelectedText },
		function(response)
		{
			var message = decodeURIComponent(response);
			console.log(message);
			
			if (message != "OK")
			{
				alert(message);
			}
		}
	);
}
```

### (3) 拡張機能の読み込み
作ったマニフェストファイルとスクリプト（とアイコン画像）を、ローカルの任意フォルダに集めます。
（以降、フォルダを`C:\Work\OpenSelectedText`と仮定しますが、各自読み替えてください）
そして、Chromeのメニュー「その他のツール」－「拡張機能」から、
「パッケージ化されていない拡張機能を読み込む」ボタンを押し、上記フォルダを指定します。
読み込んだ拡張機能に表示されたIDの値が次に必要なので、控えておいてください。

### (4) 拡張機能と通信するホストのマニフェストファイル作成
拡張機能と通信するホスト用に、任意名称のマニフェストファイルを作ります。
（以降、ファイル名を`OST_Host.json`と仮定しますが、各自読み替えてください）

`name`は後述のレジストリキーと合わせます。
`description`はお好きな値で。
`path`には、この後作るホスト（*.exe）へのパスを書きます。
`allowed_origins`には、下記の例からID部分を、読み込んだ拡張機能のIDに修正します。

```json:OST_Host.json
{
	"name": "host1",
	"description": "Open Selected Text Host",
	"path": "OST_Host.exe",
	"type": "stdio",
	"allowed_origins": ["chrome-extension://bgkppcgfghmbmlfljpldaaddklfeaafg/"]
}
```

で、作ったマニフェストファイルも`C:\Work\OpenSelectedText`に置いちゃいます。（本当はどこでもいいと思いますが）

### (5) ホストを登録するためのレジストリ編集
レジストリ上、`HKEY_CURRENT_USER\SOFTWARE\Google\Chrome`にキー`NativeMessagingHosts`がなければ、作成しておきます。
さらに、`NativeMessagingHosts`直下に、スクリプトで呼ぶ`chrome.runtime.sendNativeMessage()`の第1引数と同じ名称のキー（今回なら`host1`）を作成します。
作成したキー（今回なら`host1`）の値に、ホストのマニフェストファイルへの絶対パスを設定します。

レジストリ登録用のファイル（*.reg）風に書くと、こんな感じです。

```
Windows Registry Editor Version 5.00

[HKEY_CURRENT_USER\SOFTWARE\Google\Chrome\NativeMessagingHosts\host1]
@="C:\\Work\\OpenSelectedText\\OST_Host.json"
```

### (6) ホスト作成
C#で作ります。参考サイトには「コンソールアプリケーション」とあったのですが、DOS窓をチラ見せしたくないので、筆者は以下の手順で作り始めました。
（この手順が正しいかは不明ですが）

1. Visual Studio起動（筆者は、PCにまだ入っていたVisual C# 2008 Express使用）
2. 新規プロジェクトの作成で、「Windowsフォームアプリケーション」を選択
3. 作成したプロジェクトから、「Form1.cs」を削除
4. プロジェクトのプロパティにて、スタートアップを「Program」に変更

Chromeから来るデータはJSONなので、（ただ使ってみたかっただけですが）DataContractJsonSerializerを使ってみます。

作成したプロジェクトには以下3点、参照を追加します。
- Microsoft.JScript
- System.Runtime.Serialization
- System.ServiceModel.Web（これだけは.NET Framework 4以降なら不要）

新規クラス「NativeMessage.cs」を追加して、「Program.cs」とともに、以下のように実装します。

```cs:NativeMessage.cs
using Microsoft.JScript;
using System;
using System.IO;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;

namespace OST_Host
{
    [DataContract]
    public class Message
    {
        [DataMember]
        public string SelectedText { get; set; }
    }

    class NativeMessage
    {
        public static string StringRead()
        {
            // JSONデータの受信
            string inStr = OpenStandardStreamIn();
            inStr = GlobalObject.decodeURIComponent(inStr);

            // JSONデータのデシリアライズ
            var serializer = new DataContractJsonSerializer(typeof(Message));
            using (var ms = new MemoryStream(Encoding.UTF8.GetBytes(inStr)))
            {
                var data = (Message)serializer.ReadObject(ms);
                return data.SelectedText;
            }
        }

        public static void StringWrite(string stringData)
        {
            int limit = 1024 * 1024 - 2;
            string stringText = GlobalObject.encodeURIComponent(stringData);
            while (stringText.Length >= limit)
            {
                OpenStandardStreamOut("\"" + stringText.Substring(0, limit) + "\"");
                stringText = stringText.Substring(limit);
            }
            OpenStandardStreamOut("\"" + stringText + "\"");
        }

        private static string OpenStandardStreamIn()
        {
            Stream stdin = Console.OpenStandardInput();
            byte[] bytes = new byte[4];
            stdin.Read(bytes, 0, 4);
            int length = BitConverter.ToInt32(bytes, 0);
            string input = "";
            for (int i = 0; i < length; i++) input += (char)stdin.ReadByte();
            stdin.Close();
            return input;
        }

        private static void OpenStandardStreamOut(string stringData)
        {
            byte[] bytes = BitConverter.GetBytes(stringData.Length);
            Stream stdout = Console.OpenStandardOutput();
            for (int i = 0; i < 4; i++) stdout.WriteByte(bytes[i]);
            Console.Write(stringData);
            stdout.Close();
        }
    }
}
```

```cs:Program.cs
using System;
using System.Diagnostics;
using System.IO;

namespace OST_Host
{
    static class Program
    {
        [STAThread]
        static void Main(string[] args)
        {
            // 受信文字列（￥が全て／になっている）
            string inStr = NativeMessage.StringRead();
            int index;
            string[] prefixes = new string[2] {"file://", "file:"};

            if (inStr == string.Empty)
            {
                NativeMessage.StringWrite("選択文字列が空です。");
            }
            else
            {
                // 不要な文字の削除
                index = inStr.LastIndexOf("<");
                if (index >= 0)
                {
                    inStr = inStr.Substring(index + 1);
                }
                index = inStr.IndexOf(">");
                if (index >= 0)
                {
                    inStr = inStr.Substring(0, index);
                }

                // "file:"の削除（"FILE:"と書く方はまれだと思うが、一応は考慮）
                for (index = 0; index < prefixes.Length; index++)
                {
                    if (inStr.StartsWith(prefixes[index], StringComparison.OrdinalIgnoreCase))
                    {
                        inStr = inStr.Substring(prefixes[index].Length);
                    }
                }

                // UNCパス先頭の"//"と、"file://"の"//"が合体していたケースの対処
                if (!inStr.StartsWith("//") && !inStr.Contains(":"))
                {
                    inStr = "//" + inStr;
                }

                // 通信～デシリアライズ前とは逆の変換
                inStr = inStr.Replace("/", "\\");

                if (Directory.Exists(inStr))
                {
                    try
                    {
                        Process.Start("explorer.exe", "/e, \"" + inStr + "\"");
                        NativeMessage.StringWrite("OK");
                    }
                    catch (Exception)
                    {
                        NativeMessage.StringWrite("フォルダを開けません。");
                    }
                }
                else if (File.Exists(inStr))
                {
                    try
                    {
                        ProcessStartInfo psi = new ProcessStartInfo(inStr);
                        psi.WorkingDirectory = Directory.GetParent(inStr).FullName;
                        Process.Start(psi);
                        NativeMessage.StringWrite("OK");
                    }
                    catch (Exception)
                    {
                        NativeMessage.StringWrite("ファイルを開けません。");
                    }
                }
                else
                {
                    NativeMessage.StringWrite("不正なパスです。");
                }
            }
        }
    }
}
```

ビルドして生成したファイルも、`C:\Work\OpenSelectedText`に置いちゃいます。
（ホストのマニフェストファイルに書いた`path`と合わせます）

## 終わりに
選択範囲を狭くすれば、ファイルパスに対しても親フォルダを表示できるので、我ながら便利だと思います。
あとは、以下2点だけが気になります。

- Chromeウェブストアに置かせてもらえるか
- 置かせてもらえたとしても、他PCにインストールするとき、レジストリ編集はChromeがやってくれるのか、バッチか何かを用意しないといけないか、それとも手動しかないか