Chromeの拡張機能を作ってみる Vol.3 実装

RmTabs

前回の続きです。

以前までの記事は以下になります。

クリックした回数を検知して指定回数だった場合にタブを削除して通知、というだけの機能ですが一旦クリック回数は3回(トリプルクリック)固定でまずは実装してみます。
(後々設定画面で変更出来るようにすることを想定してファイルや依存ライブラリなどは前回までに準備してありますが)

拡張機能の動作構成

拡張機能では

  • バックグラウンドプロセス
  • 各ページで動作するコンテントスクリプト

を持つことが出来ます。

バックグラウンドプロセスをブラウザで1つ、コンテントスクリプトは開かれたタブでページが読み込まれた分だけ動かします。
なので、動作としてはコンテントスクリプトがバックグラウンドプロセスと通信をしながら目的の機能を動作させていく形になります。

作る拡張機能の性質によってはコンテントスクリプトが要らないケースもありますが、今回は各ページ上でのトリプルクリックを検知する必要があるので、この形になります。

必要なオブジェクトの作成

動作を実現させるために必要なオブジェクトを作ります。

バックグラウンドプロセスとなるクラスをManager、コンテントスクリプトとなるクラスをAgentとしてsrc/libs/以下に作成します。

Managerクラスは以下の通り。

/*
 * libs/Manager.js
 */

import _ from 'lodash';

/**
 * Manager
 *
 * @class
 * @classdesc 管理クラス
 */
export default class Manager {

  /**
   * コンストラクタ
   *
   * @constructor
   * @public
   * @param {Chrome} chrome Chromeオブジェクト
   */
  constructor(chrome) {
    this.chrome = chrome;
    this.manifest = chrome.runtime.getManifest();
    this.histories = [];
    this.init();
  }

  /**
   * 初期化処理
   */
  init() {
    // 接続された際の処理
    this.chrome.extension.onConnect.addListener((port) => {
      if ('name' in port && port.name in this) {
        port.onMessage.addListener(this[port.name].bind(this));
      } else {
        console.warn('Handler was not found');
      }
    });

    // 通知をクリックされた際の処理
    this.chrome.notifications.onClicked.addListener((notificationId) => {
      switch (notificationId) {
        case 'REMOVE_TABS':
          // 通知をクリックした際の振る舞い
          // this.chrome.notifications.clear(notificationId);
          break;
        default:
          break;
      }
    });

    // 通知のボタンをクリックされた際の処理
    this.chrome.notifications.onButtonClicked.addListener(
      (notificationId, buttonIndex) => {
        switch (notificationId) {
          case 'REMOVE_TABS':
            if (buttonIndex === 0) {
              // 削除通知かつ1番目は「元に戻す」処理
              this.undoTabs();
            }

            // 通知を削除
            this.chrome.notifications.clear(notificationId);
            break;
          default:
            break;
        }
      });
  }

  /**
   * アクティブなウィンドウを取得
   *
   * @return {Promise} アクティブなウィンドウ
   */
  getCurrentWindow() {
    return new Promise((resolve, reject) => {
      this.chrome.windows.getCurrent({
        populate: true,
        windowTypes: ['normal'],
      }, (win) => {
        if (this.chrome.runtime.lastError) {
          reject(this.chrome.runtime.lastError);
        } else {
          resolve(win);
        }
      });
    });
  }

  /**
   * 現在のタブを取得
   *
   * @return {Promise} アクティブなタブ
   */
  getCurrentTab() {
    return new Promise((resolve, reject) => {
      this.getCurrentWindow()
        .then((win) => {
          this.chrome.tabs.query({
            windowId: win.id,
            active: true,
          }, (tabs) => {
            if (this.chrome.runtime.lastError) {
              reject(this.chrome.runtime.lastError);
            } else if (!tabs || tabs.length <= 0) {
              reject(new Error('Current tab is none.'));
            } else {
              resolve(_.head(tabs));
            }
          });
        });
    });
  }

  /**
   * タブを削除
   *
   * @private
   * @param {Object} message 受信メッセージ
   */
  removeTabs(message) {
    this.getCurrentWindow()
      .then((win) => {
        const tabs = _.cloneDeep(win.tabs);

        // ソート
        // 右側 = インデックスの昇順
        // 左側 = インデックスの降順
        tabs.sort((a, b) => {
          if (a.index < b.index) {
            return message.align === 'right' ? -1 : 1;
          } else if (a.index > b.index) {
            return message.align === 'right' ? 1 : -1;
          }

          return 0;
        });

        // 現在のタブより後ろにあるものを削除対象とする
        const activeTabIndex = _.findIndex(tabs, {active: true});
        const activeTab = tabs[activeTabIndex];
        const removeTabs = _.slice(tabs, activeTabIndex + 1);

        // タブを削除
        this.chrome.tabs.remove(removeTabs.map((item) => item.id), () => {
          const history = {
            align: message.align,
            removeTabs,
            removedAt: new Date().toLocaleString(),
          };

          // 履歴を追加
          this.histories.push(history);

          // 通知
          chrome.notifications.create('REMOVE_TABS', {
            type: 'basic',
            title: 'RmTabs',
            // メッセージをテンプレートから生成
            message: _.template(chrome.i18n.getMessage('notification_remove_tabs'))({
              length: removeTabs.length,
              align: chrome.i18n.getMessage(message.align),
            }),
            isClickable: true,
            iconUrl: 'img/icon-512.png',
            buttons: [
              {
                title: chrome.i18n.getMessage('undo'),
              },
            ],
          });

          // 送信元のタブへイベント名・送信メッセージ・履歴を梱包して送信
          this.chrome.tabs.sendMessage(activeTab.id, {
            name: 'onRemovedTabs',
            message,
            history,
          });
        });
      })
      .catch(console.error);
  }

  /**
   * タブを元に戻す
   */
  undoTabs() {
    const latest = _.last(this.histories);

    this.getCurrentTab()
      .then((tab) => {
        _.each(latest.removeTabs, (item, i) => {
          this.chrome.tabs.create({
            url: item.url,
            selected: false,
            index: (latest.align === 'right'
              ? tab.index + 1 + i
              : tab.index
            ),
          });
        });
      })
      .catch(console.error);
  }

  /**
   * 削除履歴を追加
   *
   * @param {Object} history 削除履歴
   */
  addHistory(history) {
    this.histories.push(history);

    // 30件に保つ
    this.histories = _.slice(this.histories, 0, 30);

    // TODO: historiesについて
    // * localStorageに保存する
    // * 件数でなく日付の範囲にするかもしれない(1ヶ月など)
  }
}

続いてAgentクラスは以下の通り。

/*
 * libs/Agent.js
 */

/**
 * Agent
 *
 * @class
 * @classdesc エージェントクラス
 */
export default class Agent {

  /**
   * コンストラクタ
   *
   * @constructor
   * @public
   * @param {Window} window ウィンドウオブジェクト
   * @param {Chrome} chrome Chromeオブジェクト
   */
  constructor(window, chrome) {
    this.window = window;
    this.chrome = chrome;
    this.ports = {};
    this.init();
  }

  /**
   * 初期化処理
   *
   * @private
   */
  init() {
    this.chrome.extension.onMessage.addListener((response, sender) => {
      if ('name' in response && response.name in this) {
        // Call handler
        this[response.name](response);
      } else {
        console.warn('Handler was not found');
      }
    });

    this.portInit('removeTabs');
    this.bindEvents();
  }

  /**
   * ポートオブジェクトの初期化
   *
   * @private
   * @param {string[]} methods メソッド
   */
  portInit(...methods) {
    methods.forEach((method) => {
      const port = this.chrome.extension.connect({name: method});

      port.onDisconnect.addListener(() => {
        delete this.ports[method];
      });

      this.ports[method] = port;
    });
  }

  /**
   * イベントハンドラのセット
   */
  bindEvents() {
    this.window.addEventListener('click', (e) => {
      if (this.ports.removeTabs && e.detail === 3) {
        const document = this.window.document;
        const align = (e.clientX > document.documentElement.clientWidth / 2)
          ? 'right'
          : 'left';

        this.ports.removeTabs.postMessage({
          align,
          clientX: e.clientX,
          clientY: e.clientY,
          pageX: e.pageX,
          pageY: e.pageY,
        });

        this.window.getSelection().collapse(document.body, 0);
      }
    });
  }

  /**
   * 削除後のイベントハンドラ
   *
   * @param {Object} response レスポンス
   */
  onRemovedTabs(response) {
    // TODO: 処理が必要であればここに追記
    // console.log('onRemovedTabs', response);
  }
}

処理内容

ほとんどコメントに何をしてるか書いてはありますが、基本的にChromeのAPIを使用して全ての動作を実現させます。
エージェントはマネージャーと直接関わるわけでなく、接続を保ってメッセージを送受信することでそれぞれの役割を果たします。

投げたメッセージが何の意味を持つのかをnameというプロパティで表すようにして、受けた方はnameプロパティの内容で処理を分岐する形です。
(今はタブを削除する、という1つだけではありますが)

あとは現在開かれている全てのタブをChromeのAPIに問い合わせて帰ってきた中からアクティブなタブを省いた右側、あるいは左側の全てのタブオブジェクトに対して削除の命令を出して、その履歴を持っておきます。
その後通知を出していますが、誤って閉じてしまったときのために「元に戻す」ボタンを付けています。
このボタンが押されたら、先程の履歴の最新のものを参照して復元している、という内容です。

動作確認

処理が実装できたら、実際にバックグラウンドプロセス、コンテントスクリプトとして動作させるために作成したクラスをラップしたファイルを作り、manifest.jsonに記述します。
それぞれbackground.jscontentscript.jsとします。

/*
 * background.js
 */

import Manager from './libs/Manager';

window.Manager = new Manager(chrome);
/*
 * contentscript.js
 */

import Agent from './libs/Agent';

/**
 * Agent.
 */
window.Agent = new Agent(window, chrome);

manifest.jsonは以下の通り

{
  "manifest_version": 2,
  "version": "0.0.1",
  "name": "RmTabs",
  "description": "Quickly remove a lot of tabs.",
  "homepage_url": "https://github.com/ym-aozora/rmtabs.git",
  "icons": {
    "16": "img/icon-16.png",
    "19": "img/icon-19.png",
    "32": "img/icon-32.png",
    "48": "img/icon-48.png",
    "64": "img/icon-64.png",
    "128": "img/icon-128.png"
  },
  "permissions": [
    "background",
    "tabs",
    "notifications"
  ],
  "web_accessible_resources": [],
  "background": {
    "scripts": [
      "js/background.js"
    ]
  },
  "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'",
  "content_scripts": [
    {
      "matches": ["http://*/*", "https://*/*"],
      "exclude_globs": [
        "*.3gp",
        "*.3gpp",
        "*.7z",
        "*.ai",
        "*.asf",
        "*.asx",
        "*.atom",
        "*.atom",
        "*.avi",
        "*.bin",
        "*.bmp",
        "*.cco",
        "*.crt",
        "*.css",
        "*.css",
        "*.deb",
        "*.der",
        "*.dll",
        "*.dmg",
        "*.doc",
        "*.ear",
        "*.eot",
        "*.eps",
        "*.exe",
        "*.flv",
        "*.gif",
        "*.gif",
        "*.hqx",
        "*.htc",
        "*.ico",
        "*.img",
        "*.iso",
        "*.jad",
        "*.jar",
        "*.jardiff",
        "*.jng",
        "*.jnlp",
        "*.jpeg",
        "*.jpg",
        "*.js",
        "*.js",
        "*.json",
        "*.kar",
        "*.kml",
        "*.kmz",
        "*.m4a",
        "*.m4v",
        "*.mid",
        "*.midi",
        "*.mml",
        "*.mng",
        "*.mov",
        "*.mp3",
        "*.mp4",
        "*.mpeg",
        "*.mpg",
        "*.msi",
        "*.msm",
        "*.msp",
        "*.ogg",
        "*.otf",
        "*.pdb",
        "*.pdf",
        "*.pem",
        "*.pl",
        "*.pm",
        "*.png",
        "*.ppt",
        "*.prc",
        "*.ps",
        "*.ra",
        "*.rar",
        "*.rpm",
        "*.rss",
        "*.rss",
        "*.rtf",
        "*.run",
        "*.sea",
        "*.shtml",
        "*.sit",
        "*.svg",
        "*.svgz",
        "*.swf",
        "*.tcl",
        "*.tif",
        "*.tiff",
        "*.tk",
        "*.ttf",
        "*.txt",
        "*.war",
        "*.wbmp",
        "*.webm",
        "*.webp",
        "*.wml",
        "*.wmlc",
        "*.wmv",
        "*.woff",
        "*.xls",
        "*.xml",
        "*.xml",
        "*.xpi",
        "*.zip"
      ],
      "css": ["css/contentscript.css"],
      "js": [
        "js/contentscript.js"
      ],
      "run_at": "document_start"
    }
  ],
  "browser_action": {
    "default_icon": {
      "19": "img/icon-19.png",
      "32": "img/icon-32.png"
    },
    "default_title": "RmTabs"
  },
  "default_locale":"en"
}

backgroundの項と、content_scriptsの項が動作させるJavaScriptに関する内容です。

backgroundに関しては動作させるスクリプトのファイル名を記述しているだけです。

content_scriptsに関しては以下の内容を記述しています。

  • run_at: どのタイミングでスクリプトを走らせるのか
  • js: 読み込むJavaScriptファイル
  • css: 読み込むCSSファイル
  • matches: どのURLパターンのページで動作させるのか
  • exclude_globs: そのURLパターンのページでは動作させないのか

あとはpermissionsで以下の許可を頂く旨を記述しています。

  • バックグラウンドプロセスを動作させること
  • タブにアクセスすること
  • 通知を出すこと

詳しくは以下に載っています。

https://developer.chrome.com/extensions/manifest

ここまで出来たら前回までに準備したgulpタスクを使って動作を確認してみます。

プロジェクトルートにて

$ gulp

と打ち込みエンターを押すとdist/ディレクトリ以下に成果物がビルドされます。

その後Chromeを開いて、設定 > 拡張機能と進み、デベロッパーモードをONにした状態でパッケージ化されていない拡張機能を読み込むボタンを押します。
すると、ディレクトリの指定を促されるので、ビルドされたmanifest.jsonが置いてあるディレクトリ、すなわちdist/ディレクトリを指定します。
これで拡張機能が読み込まれ、動作確認が出来ます。

ソースコードを修正した場合はgulpがファイルの変更を自動検知して再ビルドが行われますが、Chromeの方の拡張機能も再読込しないと内容が反映されません。
先程の設定画面のおそらく一番上に追加した拡張機能が表示されていますので、そこのリロードをクリックすると再読込されます。
コンテントスクリプトの修正の場合は、開いているページ自体も再読込する必要があります。

ここまででほぼ動作は満たせたので、次回は公開に関する部分を書いていきます。

関連リンク

Recent Posts

Archive