LoginSignup
20
18

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-01-25

はじめに

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

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

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

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

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

やったこと

以下を丸パク、いや、参考にさせていただき、やったことを挙げていきます。
- Native Message1(外部ソフト登録)
- Native Message2(拡張機能)
- Native Message3(通信設定 拡張側)
- Native Message5(2byte文字等の対応)

(1) 拡張機能のマニフェストファイル作成

namedescriptionversionはお好きな値で。
あと、以下の例なら、48x48の好きなアイコン画像も必要です。

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.が発生しないようにするためです。

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に修正します。

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」とともに、以下のように実装します。

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();
        }
    }
}
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がやってくれるのか、バッチか何かを用意しないといけないか、それとも手動しかないか
20
18
3

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
20
18