Vue.jsでChrome拡張機能を作って失敗した話


これはただの集団 Advent Calendar 2020の13日目の記事です。

Chromeの拡張機能が htmljs で作れると知りまして、
自分がよく使っている Vue.js でやってみようと思いまして試しに作ってみた。
・・・ところ見事に失敗した話。

◇ 参考

■ 開発環境

  • macOS:Catalina
  • Node.js:v14.7.0
  • nodebrew:v8.9.4
  • yarn:v1.22.10
  • Chrome:v87.0 (x86)

■ 導入手順

Kocal/vue-web-extensionを使って作って行きます。

とりあえず作業用ディレクトリを作る
$ mkdir myDir
$ cd myDir
プロジェクト作成
$ vue init kocal/vue-web-extension sample-extension

  Command vue init requires a global addon to be installed.
  Please run yarn global add @vue/cli-init and try again.

◇ 何やら失敗したので色々確認・・・

調査中・・・
# vue-cliは入ってる。
$ vue --version
@vue/cli 4.5.6

# しばらくやってなかったのでupgradeする。
$ yarn global upgrade

# 指示通りinitをいれる。
$ yarn global add @vue/cli-init

# 再挑戦してみたが・・・
$ vue init kocal/vue-web-extension sample-extension
vue-cli · ENOENT: no such file or directory,'/Users/hogehoge/.vue-templates/kocal-vue-web-extension/template'

◇ Readmeを読んだら・・・・

init ではなく create を使う方法に変わっていた。

$ vue create --preset kocal/vue-web-extension sample-extension

終わるまで結構時間がかかります。

◇ オプション

@vue/cli-plugin-eslint
# lintは好みで選択しました
? Pick an ESLint config: Standard
? Pick additional lint features: Lint on save
vue-cli-plugin-browser-extension
# 簡単にpopupを作る予定なので2つだけ
? Which browser extension components do you wish to generate? background, popup
# ストアに公開せず個人の範疇で利用するので、今回は無し
? Generate a new signing key (danger)? No
Preset options:
? Install axios? Yes

■ 構成

◇ package.json

package.json
{
  "name": "sample-extension",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "serve": "vue-cli-service build --mode development --watch",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint"
  },
  "dependencies": {
    "axios": "^0.20.0",
    "core-js": "^3.6.5",
    "vue": "^2.6.11",
    "vue-router": "^3.2.0",
    "vuex": "^3.4.0"
  },
  "devDependencies": {
    "@vue/cli-plugin-babel": "~4.5.0",
    "@vue/cli-plugin-eslint": "~4.5.0",
    "@vue/cli-plugin-router": "~4.5.0",
    "@vue/cli-plugin-vuex": "~4.5.0",
    "@vue/cli-service": "~4.5.0",
    "@vue/eslint-config-standard": "^5.1.2",
    "babel-eslint": "^10.1.0",
    "eslint": "^6.7.2",
    "eslint-plugin-import": "^2.20.2",
    "eslint-plugin-node": "^11.1.0",
    "eslint-plugin-promise": "^4.2.1",
    "eslint-plugin-standard": "^4.0.0",
    "eslint-plugin-vue": "^6.2.2",
    "sass": "^1.26.5",
    "sass-loader": "^8.0.2",
    "vue-cli-plugin-browser-extension": "latest",
    "vue-template-compiler": "^2.6.11"
  }
}

◇ manifest.json

とりあえず初期設定のままで。

src/manifest.json
{
  "manifest_version": 2,
  "name": "__MSG_extName__",
  "homepage_url": "http://localhost:8080/",
  "description": "A Vue Browser Extension",
  "default_locale": "en",
  "permissions": [
    "<all_urls>",
    "*://*/*"
  ],
  "icons": {
    "16": "icons/16.png",
    "48": "icons/48.png",
    "128": "icons/128.png"
  },
  "background": {
    "scripts": [
      "js/background.js"
    ],
    "persistent": false
  },
  "browser_action": {
    "default_popup": "popup.html",
    "default_title": "__MSG_extName__",
    "default_icon": {
      "19": "icons/19.png",
      "38": "icons/38.png"
    }
  }
}

◇ ディレクトリ

sample-extension/
 ├── node_modules/
 ├── public/
 │   ├── _locales/
 │   │   └── en
 │   ├── icons/
 │   │   ├── 128.png
 │   │   ├── 16.png
 │   │   ├── 19.png
 │   │   ├── 38.png
 │   │   └── 48.png
 │   ├── browser-extension.html
 │   ├── favicon.ico
 │   └── index.html
 ├── src/
 │   ├── assets/
 │   │   └── logo.png
 │   ├── components/
 │   │   └── HelloWorld.vue
 │   ├── popup/ (今回はこれを使う)
 │   │   ├── App.vue
 │   │   └── main.js
 │   ├── router/
 │   │   └── index.js
 │   ├── store/
 │   │   └── index.js
 │   ├── views/
 │   │   ├── About.vue
 │   │   └── Home.vue
 │   ├── App.vue
 │   ├── background.js
 │   ├── main.js
 │   └── manifest.json
 ├── README.md
 ├── babel.config.js
 ├── package-lock.json
 ├── package.json
 └── vue.config.js

■ 開発手順

  • Chromeの拡張機能画面から デベロッパーモード をONにする
  • distディレクトリをパッケージとしてブラウザに登録する
  • yarn servewatch を開始する
  • あとはコードを書いていく (HMRが効いているので再読み込みとかはいらないはず・・・)
  • yarn build で本番ビルド

■ サンプルコード

◇ Axiosを使えるようにする

src/popup/main.js
import Vue from 'vue'
import axios from 'axios'
import App from './App.vue' //←これを追加

Vue.prototype.$axios = axios //←これを追加

/* eslint-disable no-new */
new Vue({
  el: '#app',
  render: h => h(App)
})

◇ Before

アイコンをクリックして出てくるポップアップの内容が変更されたのを確認。
変更するファイルはこれで良さそう。

src/popup/app.vue
<template>
  <hello-world />
</template>

<script>
import HelloWorld from '@/components/HelloWorld.vue'

export default {
  name: 'App',
  components: { HelloWorld }
}
</script>

<style>
html {
  width: 400px;
  height: 400px;
}
</style>

◇ After

  1. アイコンをクリック
  2. アクティブになっているタブのURLを取得
  3. 取得したURLを元にリクエストを投げる
  4. レスポンスの情報を表示する

・・・みたいな処理になるはず。

src/popup/app.vue
<template>
  <div>
    <dl>
      <dt>currentUrl</dt>
      <dd>{{ currentUrl }}</dd>
    </dl>
    <dl>
      <dt>originDomain</dt>
      <dd>{{ originDomain }}</dd>
    </dl>
    <dl>
      <dt>responseData</dt>
      <dd>request:{{ targetUrl }}</dd>
      <dd>{{ responseData }}</dd>
    </dl>
  </div>
</template>

<script>

export default {
  name: 'App',
  data () {
    return {
      rawUrl: '',
      originDomain: '',
      targetUrl: '',
      baseUrl: 'https://api.whoisproxy.info/whois/',
      responseData: {}
    }
  },
  created () {
    this.initialize()
    this.urlRequest(this.targetUrl)
  },

  methods: {
    initialize () {
      chrome.tabs.query({ active: true }, (tabs) => {
        this.setRawUrl(tabs)
        this.setOriginDomain(this.rawUrl)
        this.setTargetUrl(this.originDomain)
      })
    },
    setCurrentUrl (tabs) {
      this.rawUrl = tabs[0].url
    },
    setOriginDomain (url) {
      let candidate = []
      candidate = url.split('/')
      this.originDomain = `${candidate[2]}`
    },
    setTargetUrl (domainData) {
      this.targetUrl = `${this.baseUrl}${domainData}`
    },
    urlRequest (url) {
      this.$axios
        .get(url)
        .then(response => (this.responseData = response))
        .catch(error => console.log(error))
    },
  }
}
</script>

<style>
html {
  min-width: 400px;
  min-height: 400px;
}
</style>

ところがレスポンスの内容が見たことのある HTML のみ・・・
想定通りであれば JSON が返ってくるはず。

■ 結論

おそらくですが、 Content Security Policy に引っかかったようです。
manifest.json のContent-Security-Policyあたりが怪しいですね。
background.js を利用すればできるのかもしれませんが力尽きました・・・)
良い方法があればご教示いただけると大変ありがたいです。

参考:
https://developer.chrome.com/docs/apps/contentSecurityPolicy/
https://developers.google.com/web/fundamentals/security/csp
https://oxynotes.com/?p=8895
https://xxxx7.com/2014/04/07/152039

◇ まとめ

Chrome extensionは javascript で簡単に作れる。(jQueryやモダンなAngular・Reactでもできそう)
ただし外部リソースの利用については注意が必要である。