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

これは[ただの集団 Advent Calendar 2020](https://adventar.org/calendars/5581)の13日目の記事です。


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


## ◇ 参考

+ [vue-web-extension を使って Chrome 拡張機能を開発する方法](https://r17n.page/2019/09/03/chrome-extension-with-vue-web-extension/)
+ [Vue.jsを使ったブラウザ拡張機能の作り方](https://mnao305.hatenablog.com/entry/2019/02/05/191044)
+ [Vue.js を使ってChrome 拡張機能を作った話(はてなブックマークのコメントを Qiita 記事内に表示)](https://qiita.com/17number/items/e78af9bff52b19d5cb42)
+ [Chrome Developers API Reference](https://developer.chrome.com/docs/extensions/)
+ [whois API](https://chanshige.hatenablog.com/entry/2019/02/16/184907)

# ■ 開発環境

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

# ■ 導入手順

[Kocal/vue-web-extension](https://github.com/Kocal/vue-web-extension)を使って作って行きます。

```bash:とりあえず作業用ディレクトリを作る
$ mkdir myDir
$ cd myDir
```

```bash:プロジェクト作成
$ 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.
```

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

```bash:調査中・・・
# 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` を使う方法に変わっていた。

```bash:
$ vue create --preset kocal/vue-web-extension sample-extension
```
終わるまで結構時間がかかります。

## ◇ オプション

```bash
@vue/cli-plugin-eslint
# lintは好みで選択しました
? Pick an ESLint config: Standard
? Pick additional lint features: Lint on save
```
```bash
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
```

```bash
Preset options:
? Install axios? Yes
```

# ■ 構成

## ◇ package.json

```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

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

```js: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 serve` で `watch` を開始する
+ あとはコードを書いていく (HMRが効いているので再読み込みとかはいらないはず・・・)
+ `yarn build` で本番ビルド
 
# ■ サンプルコード

## ◇ Axiosを使えるようにする

```js: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

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

```vue: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. レスポンスの情報を表示する

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

```vue: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でもできそう） 
ただし外部リソースの利用については注意が必要である。